diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 957294e..f7867bf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,3 +12,16 @@ Stack owns command mappings and package metadata, not the upstream skill impleme | Stewdio blank-page skill | StewdioAI/blank-page | `cd42b37fa7cd0a5550e51f45cc8f82f63377b98c` | MIT | Stack-Codex is Stack-owned repository content, bundled under `packages/stack-codex/content` so a GitHub clone contains the complete eight-skill package. Its package pin, `483015638591618e884d4c21f1fa037b645bb872e33acf063fa7a5f802c80df3`, is a deterministic SHA-256 digest over sorted relative paths and exact file bytes. It is not an external upstream and does not appear in the third-party table above. + +## Stageshow + +The pinned integration in `skills/imported/benjitaylor/stageshow` imports +Benji Taylor's Stageshow 0.1.0 from `benjitaylor/skills` at +`802057409787133703c56001080169e347305572`. Its MIT license, upstream notices, +retained artwork licenses/source records and CC0 photograph attribution remain +with the import. The MIT license does not cover app-bundle artwork or grant +trademark rights. App-bundle icons and five official-only brand SVGs are omitted; +copied phone glyph paths are replaced with independently authored generic +geometry. `source.json` and `STACK-REVIEW.md` record the exact subset, modifications +and representative isolated runtime validation. Local compiler/installer receipts +record managed-runtime availability; dependencies are not bundled into exports. diff --git a/artifacts/audits/phase1-architecture/capability-reconciliation.json b/artifacts/audits/phase1-architecture/capability-reconciliation.json index a8b2a70..7951563 100644 --- a/artifacts/audits/phase1-architecture/capability-reconciliation.json +++ b/artifacts/audits/phase1-architecture/capability-reconciliation.json @@ -39,6 +39,7 @@ "skills/engineering/react-doctor/SKILL.md": "skills/engineering/react-doctor/capability.json", "skills/engineering/simplify/SKILL.md": "skills/engineering/simplify/capability.json", "skills/engineering/tdd/SKILL.md": "skills/engineering/tdd/capability.json", + "skills/imported/benjitaylor/stageshow/SKILL.md": "skills/imported/benjitaylor/stageshow/capability.json", "skills/imported/david/david-agent-self-scheduling/SKILL.md": "skills/imported/david/david-agent-self-scheduling/capability.json", "skills/imported/david/david-anti-sleep/SKILL.md": "skills/imported/david/david-anti-sleep/capability.json", "skills/imported/david/david-brain-to-docs/SKILL.md": "skills/imported/david/david-brain-to-docs/capability.json", @@ -199,6 +200,7 @@ "skills/engineering/react-doctor/SKILL.md", "skills/engineering/simplify/SKILL.md", "skills/engineering/tdd/SKILL.md", + "skills/imported/benjitaylor/stageshow/SKILL.md", "skills/imported/david/david-agent-self-scheduling/SKILL.md", "skills/imported/david/david-anti-sleep/SKILL.md", "skills/imported/david/david-brain-to-docs/SKILL.md", @@ -330,8 +332,8 @@ "missing": [] }, "counts": { - "callable_skills": 158, - "manifests": 158, + "callable_skills": 159, + "manifests": 159, "package_exports": 20, "private_declarations": 1, "unclassified": 0 diff --git a/artifacts/validation/stageshow/2026-11-01.json b/artifacts/validation/stageshow/2026-11-01.json new file mode 100644 index 0000000..67a5236 --- /dev/null +++ b/artifacts/validation/stageshow/2026-11-01.json @@ -0,0 +1,134 @@ +{ + "activation": { + "lifecycle": "candidate", + "performed": false, + "publish_targets": [], + "validation_status": "blocked" + }, + "browser_launcher_sha256": "48e9ee06dc76b378bf6f3b701f72a844a2db33f5056086cd5ab2642fa394d271", + "browser_video_acceptance": { + "limitation": "film.mjs exports PNG stills/contact sheets; an actual video-recording and decode/visual inspection step is still required", + "required_checks": [ + "matching official browser for locked playwright-core 1.48.2", + "browser sandbox active", + "no host mounts or private content", + "test network denial proven", + "representative still and loop", + "compact layout", + "reduced motion", + "truthful failed rehearsal status", + "caption escaping at runtime", + "video decodes with expected duration, dimensions, changing frames and final state", + "output containment", + "browser/server shutdown", + "independent visual review" + ], + "status": "not run", + "video_artifact": null, + "visual_review": "not performed" + }, + "capability": "stageshow", + "changes": [ + "Preserved existing candidate, notices, omitted-asset policy, lockfile and two film.mjs patches.", + "Enabled chromiumSandbox explicitly for both browser-selection paths; no weaker retry.", + "Updated source hashes and validation-status prose without changing candidate lifecycle or publication targets." + ], + "checks": [ + { + "browser_launched": false, + "failures": 3, + "name": "launcher regression before fix", + "status": "expected failure", + "tests": 4 + }, + { + "browser_launched": false, + "command": "PATH=/opt/homebrew/opt/python@3.11/libexec/bin:$PATH /opt/homebrew/bin/python3.11 -m unittest tests.test_stageshow_intake tests.test_stageshow_browser_launcher tests.test_capability_registry tests.test_audit_capabilities tests.test_compile_runtime tests.test_install_runtime tests.test_documented_commands", + "failures": 0, + "name": "focused Stack tests", + "python": "3.11.15", + "status": "passed", + "tests": 59 + }, + { + "command": "node --check skills/imported/benjitaylor/stageshow/scripts/browser.mjs", + "name": "JavaScript syntax", + "status": "passed" + }, + { + "command": "python3 scripts/build-capability-registry.py --check", + "name": "capability registry", + "status": "passed" + }, + { + "command": "git diff --check -- skills/imported/benjitaylor/stageshow tests/test_stageshow_browser_launcher.py", + "name": "patch whitespace", + "status": "passed" + }, + { + "name": "fresh-reader check", + "scope": "changed integration-contract prose and validation follow-up; one ambiguous phrase clarified", + "status": "passed" + }, + { + "failures": 1, + "name": "initial default-Python test run", + "reason": "Python 3.9 lacked tomllib for an existing documented command; Python 3.11 rerun passed", + "status": "failed", + "tests": 59 + }, + { + "name": "bun skill guards", + "reason": "This Stack repository uses Python unittest and has no root Bun package or test:skill-guards script.", + "status": "not applicable" + }, + { + "name": "Claude/Codex behavioral skill evaluation", + "reason": "Candidate remains inactive; changed prose records validation status and the sandbox patch. Cross-runtime video acceptance requires the blocked isolated setup.", + "status": "not run" + } + ], + "imported_file_hashes_digest": "a9c16b987382624e4d7e80eec0990376488f73da1b3808e0000d725b6f1916ef", + "isolation_attempt": { + "booted": false, + "browser_launched": false, + "cleanup": "Unbooted VM definition deleted; Lima reports no instance", + "container_runtime": false, + "download_probes": [ + { + "result": "curl exit 6: could not resolve host", + "url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2" + }, + { + "result": "curl exit 6: could not resolve host", + "url": "https://mcr.microsoft.com/v2/playwright/manifests/v1.48.2-jammy" + } + ], + "host_mounts": [], + "host_public_key_loading": false, + "proxy_environment_propagation": false, + "result": "VM creation stopped on a denied default image-cache write", + "ssh_agent_forwarding": false, + "test_listener_started": false, + "vm": "Lima plain-mode Debian 12 arm64" + }, + "prior_validation": { + "browser_video": "user-reported incomplete after guest DNS failure; prior VM removed", + "checked_in_review": "skills/imported/benjitaylor/stageshow/STACK-REVIEW.md", + "install_typecheck_build": "user-reported pass; newer receipts not located; not independently rerun", + "prohibited_diagnostic": "not retried; no privilege elevation or bypass attempted" + }, + "recorded_at": "2026-11-01T22:24:22.668125+00:00", + "resume_requirements": [ + "Use a supported runner that permits setup-only official VM/browser/dependency downloads and VM image-cache writes, or supply a verified offline image/bundle.", + "Bind browser and dependency assets to the existing lockfile and verify their identity before executing them.", + "Copy only the pinned candidate and synthetic fixture into a fresh isolated environment; enforce and prove network denial before any test execution.", + "Keep the browser sandbox enabled and stop on isolation failure; do not reuse signed-in browsers, alter host security controls or retry the denied diagnostic.", + "Record and inspect a real video before changing the hold. Review catalog activation separately from runtime publication; publication is prohibited in this task." + ], + "schema_version": 1, + "source_state": "existing staged candidate plus uncommitted follow-up; no commit or publication performed", + "stack_base_commit": "0da4f2e3c8598bbb807db2a90e014366094124eb", + "status": "blocked-before-browser-video-validation", + "upstream_revision": "802057409787133703c56001080169e347305572" +} diff --git a/artifacts/validation/stageshow/2026-11-09-accepted.json b/artifacts/validation/stageshow/2026-11-09-accepted.json new file mode 100644 index 0000000..4aa7cd6 --- /dev/null +++ b/artifacts/validation/stageshow/2026-11-09-accepted.json @@ -0,0 +1,98 @@ +{ + "artifact_root": "artifacts/validation/stageshow/local-2026-11-09", + "artifacts_sha256": { + "outputs/browser-acceptance.json": "83abf1e2f03d92f7af74fb45c010440c320787ea28f7afd91ddddb64d3326ae9", + "outputs/caption-audit.json": "7d97a352845fc22bcf3c53b00108e159d965beacd2e1ba222a7d70ce078e040d", + "outputs/failure.json": "4d472b836a19c52239e078dbf26ef565705b0b697c07326dbe65babf8b7adedd", + "outputs/loop-probe-final.json": "3e03a910ad32941a329985ee5e77b558bfbdfc2c61076f7d69024341c69c37fb", + "outputs/loop-ui.mp4": "d06a9415c941242b355bcfe0cb253582cd54671153e6a96cd4a18444981cbd82", + "outputs/loop-video.json": "69ee8648dc6cadf67de36cff4966eb0d4935872bbdfb7874cd0dddaf4355e09f", + "outputs/representative-compact.json": "7230a75fd54713b135cb75ddee4c803643ea983971038b1abee92807131c7f1d", + "outputs/representative-loop-final.json": "4182b772d8fe7d1e627a496359d636cbf6287b52957eb912b67f2a985a3f6246", + "outputs/representative-still.json": "7230a75fd54713b135cb75ddee4c803643ea983971038b1abee92807131c7f1d", + "outputs/sample-ui.mp4": "407a130327cdce1833285fbe592c4c0ff95b2ac7e004718a756032204be8b320", + "outputs/video-probe-final.json": "6e85b73639860f8a240a93510aa5a147b08318ebab8cae488e57fdc8bc02088b" + }, + "capability": "stageshow", + "checks": { + "build": "passed offline in VM", + "caption_escape": "markup label round-tripped; zero inserted tags or executed handlers", + "compact": "390px, 358px design, no overflow; passed gate", + "dependencies": "exact lockfile npm ci --ignore-scripts; Debian signed-index version pins and SHA-256 list retained", + "failed_rehearsal": "exit 1 preserved in process and report; no success claim", + "official_node_sha256": "1c4a9933a5e45bc88f54f70b5f91232c127ec49f1a5989d23fb85824c7adf9b7", + "output_containment": "all named exports under approved validation outputs; no source helper/lockfile mutation, output symlink or HTTP listener; arbitrary hostile --out paths are not a supported safety boundary", + "reduced_motion": "wide and compact posters stable; final success state", + "representative_loop": "complete sampling, no findings, rehearsal passed with unchanged thresholds", + "representative_still": "passed gate", + "shutdown": "no remaining browser/node/listener processes; VM stopped", + "typecheck": "passed offline in VM", + "video": { + "decode": "both entire streams decoded without errors", + "loop": { + "codec": "h264", + "decoded_frames": 200, + "dimensions": [ + 640, + 360 + ], + "duration_seconds": 8, + "fps": 25, + "states": "saved/reset twice" + }, + "native_recording": "official Playwright VP8 encoder; native tail trimmed when producing declared-duration H.264 exports", + "sample": { + "codec": "h264", + "decoded_frames": 300, + "dimensions": [ + 640, + 480 + ], + "duration_seconds": 12, + "final_state": "Account created", + "fps": 25 + }, + "visual_inspection": "decoded sequence, final frame and loop contact sheets inspected by primary; fresh independent review recorded separately" + }, + "visual_review": "Primary decoded-frame inspection and independent Sol High review verified both MP4s; corrected still/compact evidence reviewed separately before install." + }, + "isolation": { + "chromium_sandbox": "verified PID namespace and additional renderer seccomp; no unsandboxed retry", + "content": "fully inlined synthetic local file pages; no listener", + "environment": "allowlisted env -i", + "host_mounts": [], + "image_digest": "sha512:52eb678130e85a9bf9f3cf4f7181e060fa60a5db45373717474c5ff9305645c5e6cab8b05ca042bcfa7921d2ca29cf9fd2e815f75abe2d168557ad7207af127b", + "network_probes": { + "external_browser_navigation": "denied", + "ipv4": "EPERM", + "ipv6": "EPERM" + }, + "private_credentials": false, + "privilege_elevation_used": false, + "signed_in_state": false, + "test_network": "inherited seccomp denies all non-UNIX/non-netlink sockets and io_uring_setup; NNP; no inherited sockets", + "vm": "Lima 2.2.0 VZ Debian 12 arm64" + }, + "limitations": [ + "Representative routes only; full retained example catalog not certified.", + "Exploratory form/loading helper scans reported sampling gaps and/or compact framing findings; preserved as failed/inconclusive.", + "film.mjs exports PNGs; encoded video remains a separate recording/decode step.", + "Host-side preparatory typecheck/build ran before VM acceptance; authoritative typecheck/build were repeated under the VM offline rule." + ], + "recorded_date": "2026-11-09", + "schema_version": 1, + "scope": "representative synthetic browser/video validation; local runtime installation separately receipted", + "source": { + "browser_revision": "1140", + "browser_version": "130.0.6723.31", + "helper_sha256": { + "package-lock.json": "092ee4aa51f4f38df3c5ba35708ef288a533a6c9b7078fcb5b71897d6ac5e731", + "scripts/browser.mjs": "48e9ee06dc76b378bf6f3b701f72a844a2db33f5056086cd5ab2642fa394d271", + "scripts/film.mjs": "354200847958a4573eeb0da9ba4e061fc4f2ec1eecebf3df9b91f05e747f08a3" + }, + "playwright_core": "1.48.2", + "upstream_revision": "802057409787133703c56001080169e347305572" + }, + "status": "passed", + "thread_id": "01a1c1bb-c674-7013-a1ee-dcaf8ede8354" +} diff --git a/artifacts/validation/stageshow/2026-11-09.json b/artifacts/validation/stageshow/2026-11-09.json new file mode 100644 index 0000000..d3a3f44 --- /dev/null +++ b/artifacts/validation/stageshow/2026-11-09.json @@ -0,0 +1,53 @@ +{ + "activation_performed": false, + "authorization": "User requested local availability in Claude and Codex. Isolated validation and local installation are authorized; no additional confirmation of those actions is required.", + "browser_video_test": "not run", + "capability": "stageshow", + "observed_blockers": [ + { + "check": "dependency setup", + "result": "curl exit 6: registry.npmjs.org could not be resolved" + }, + { + "check": "offline validation assets", + "result": "No cached matching browser or Lima VM image; package node_modules absent" + }, + { + "check": "normal runtime compiler", + "result": "refusing to stage a dirty source tree" + }, + { + "check": "runtime stage write probe", + "result": "Operation not permitted" + }, + { + "check": "Claude runtime parent write probe", + "result": "Operation not permitted" + }, + { + "check": "Codex runtime parent write probe", + "result": "Operation not permitted" + }, + { + "check": "runtime receipt write probe", + "result": "Operation not permitted" + } + ], + "preserved": { + "browser_sandbox_required": true, + "candidate_lifecycle": true, + "isolated_video_acceptance_required": true, + "prior_changes": true, + "prior_receipt": "artifacts/validation/stageshow/2026-11-01.json" + }, + "recorded_at": "2026-11-09T19:09:17.331164+00:00", + "resume": [ + "Run setup with access to official dependency and browser-image hosts, or supply verified offline assets.", + "Validate a real synthetic UI video in isolation with the browser sandbox enabled and test network access denied.", + "Prepare a clean isolated source commit containing only the reviewed integration changes; preserve the dirty working checkout.", + "Use the normal compiler and atomic installer with permitted runtime staging, Claude/Codex target and receipt writes." + ], + "runtime_installation_performed": false, + "schema_version": 1, + "status": "blocked-by-execution-environment" +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/audit-hooks.mjs b/artifacts/validation/stageshow/local-2026-11-09/audit-hooks.mjs new file mode 100644 index 0000000..10dac9a --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/audit-hooks.mjs @@ -0,0 +1,21 @@ +// Validation-only observer: actual film source/launcher stay byte-for-byte intact. +import {chromium} from 'playwright-core'; +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +const original=chromium.launch.bind(chromium); +chromium.launch=async options=>{ + assert.equal(options.chromiumSandbox,true); + const browser=await original(options);const newPage=browser.newPage.bind(browser); + browser.newPage=async options=>{ + const page=await newPage(options);const setContent=page.setContent.bind(page); + page.setContent=async html=>{ + const result=await setContent(html); + const payloads=await page.locator('figcaption').allTextContents(); + const injected=await page.locator('figcaption img, figcaption script').count(); + const executed=await page.evaluate(()=>window.__captionExecuted||0); + assert.equal(injected,0);assert.equal(executed,0); + if(process.env.CAPTION_AUDIT)fs.writeFileSync(process.env.CAPTION_AUDIT,JSON.stringify({payloads,injected,executed,escaped:html.includes('<img')},null,2)); + return result; + };return page; + };return browser; +}; diff --git a/artifacts/validation/stageshow/local-2026-11-09/browser-acceptance.mjs b/artifacts/validation/stageshow/local-2026-11-09/browser-acceptance.mjs new file mode 100644 index 0000000..7435e5f --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/browser-acceptance.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {launch} from './scripts/browser.mjs'; +const out=new URL('../outputs/',import.meta.url).pathname; +fs.mkdirSync(out,{recursive:true}); +const url=new URL('./acceptance.html',import.meta.url).href; +const result={browser:null,sandbox:null,network:[],pages:[],durationTargetMs:12000}; +// Kernel filter must deny both IP families before the browser starts. +const net=await import('node:net'); +for(const host of ['1.1.1.1','::1']){ + const error=await new Promise(resolve=>{const s=net.connect({host,port:443});s.on('error',e=>resolve(e.code));s.on('connect',()=>{s.destroy();resolve('CONNECTED')});}); + assert.equal(error,'EPERM'); result.network.push({host,error}); +} +const browser=await launch(); +try{ + result.browser=browser.version();assert.equal(result.browser,'130.0.6723.31'); + const page=await browser.newPage(); + await page.goto('data:text/html,

Sandbox validation

'); + const renderers=[]; + for(const pid of fs.readdirSync('/proc').filter(x=>/^\d+$/.test(x))){ + try{const command=fs.readFileSync(`/proc/${pid}/cmdline`,'utf8'); + if(!command.includes('--type=renderer'))continue; + const status=fs.readFileSync(`/proc/${pid}/status`,'utf8'); + renderers.push({pid,command:command.split('\0'),status}); + }catch{} + } + assert(renderers.length>0); + for(const r of renderers){ + assert.match(r.status,/Seccomp:\s+2/);assert.match(r.status,/NoNewPrivs:\s+1/); + assert(Number(r.status.match(/Seccomp_filters:\s+(\d+)/)?.[1])>=2); + assert(r.status.match(/NSpid:([^\n]+)/)[1].trim().split(/\s+/).length>=2); + } + result.sandbox={renderers,verified:'renderer PID namespace + additional Chromium seccomp filter + NNP, no sandbox-disabling flags'}; + const cdp=await browser.newBrowserCDPSession(); + result.commandLine=(await cdp.send('Browser.getBrowserCommandLine')).arguments; + assert(!result.commandLine.some(x=>/--no-sandbox|--disable-seccomp-filter-sandbox/.test(x))); + await assert.rejects(page.goto('https://example.com',{timeout:3000})); + await page.close(); + const context=await browser.newContext({viewport:{width:640,height:480},recordVideo:{dir:out,size:{width:640,height:480}},reducedMotion:'no-preference'}); + const started=Date.now();const clip=await context.newPage();const errors=[];const requests=[]; + clip.on('pageerror',e=>errors.push(e.message));clip.on('request',r=>requests.push(r.url())); + await clip.goto(url);await clip.locator('.ss-stage').waitFor(); + for(const at of [0,3000,7000,11000]){ + await clip.waitForTimeout(Math.max(0,started+at-Date.now())); + const state=await clip.locator('.ss-stage').innerText(); + result.pages.push({at:Date.now()-started,state}); + await clip.screenshot({path:out+`video-${at}.png`}); + } + assert.equal(await clip.locator('[data-target="create"]').getAttribute('data-done'),'true'); + assert.equal(errors.length,0,errors.join('\n'));assert(requests.every(x=>x.startsWith('file:'))); + await clip.waitForTimeout(Math.max(0,started+12000-Date.now())); + const video=clip.video();await context.close();await video.saveAs(out+'sample-ui.webm'); + result.recordedWallMs=Date.now()-started;result.resources=requests;result.pageErrors=errors; + for(const width of [640,390]){ + const p=await browser.newPage({viewport:{width,height:500},reducedMotion:'reduce'});await p.goto(url);await p.waitForTimeout(500); + const state=await p.locator('[data-target="create"]').getAttribute('data-done');assert.equal(state,'true'); + const layout=await p.locator('.ss-stage').getAttribute('data-layout');assert.equal(layout,width===390?'compact':'wide'); + assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)); + const a=await p.screenshot({path:out+`reduced-${width}.png`});await p.waitForTimeout(700);const b=await p.screenshot();assert(a.equals(b),'reduced motion must be stable'); + result.pages.push({reduced:true,width,layout,state,stable:true});await p.close(); + } + result.passed=true; +}finally{ + await browser.close();result.browserClosed=!browser.isConnected(); + fs.writeFileSync(out+'browser-acceptance.json',JSON.stringify(result,null,2)+'\n'); +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/browser-download.log b/artifacts/validation/stageshow/local-2026-11-09/browser-download.log new file mode 100644 index 0000000..ccbcbb7 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/browser-download.log @@ -0,0 +1,12 @@ +Downloading Chromium 130.0.6723.31 (playwright build v1140) from https://playwright.azureedge.net/builds/chromium/1140/chromium-mac-arm64.zip +| | 0% of 140.8 MiB +|■■■■■■■■ | 10% of 140.8 MiB +|■■■■■■■■■■■■■■■■ | 20% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■ | 30% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 40% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 50% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 60% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 70% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 80% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ | 90% of 140.8 MiB +|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 140.8 MiB diff --git a/artifacts/validation/stageshow/local-2026-11-09/browser-test.log b/artifacts/validation/stageshow/local-2026-11-09/browser-test.log new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/validation/stageshow/local-2026-11-09/build-offline.log b/artifacts/validation/stageshow/local-2026-11-09/build-offline.log new file mode 100644 index 0000000..89fadb5 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/build-offline.log @@ -0,0 +1,211 @@ + +> stageshow@0.1.0 build +> vite build playground --config playground/vite.config.ts + +vite v6.4.5 building for production... +transforming... +✓ 1832 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.38 kB │ gzip: 0.28 kB +dist/assets/google-sheets-C6gOto98.svg 5.07 kB │ gzip: 0.95 kB +dist/assets/google-slides-B6uumWEX.svg 5.08 kB │ gzip: 0.95 kB +dist/assets/fuji-6u2eUW7w.jpg 23.67 kB +dist/assets/kawaguchiko-ZVc1ITuN.jpg 30.23 kB +dist/assets/preview-BtPcMLHE.css 0.27 kB │ gzip: 0.23 kB +dist/assets/index-BMNMwAK1.css 0.50 kB │ gzip: 0.30 kB +dist/assets/index-WKcunGLc.css 0.57 kB │ gzip: 0.34 kB +dist/assets/index-DJV5L_eC.css 0.70 kB │ gzip: 0.40 kB +dist/assets/index-Btff5qfu.css 0.71 kB │ gzip: 0.36 kB +dist/assets/index-CkYXjZ9w.css 0.73 kB │ gzip: 0.42 kB +dist/assets/index-mEYhPiZ3.css 0.81 kB │ gzip: 0.43 kB +dist/assets/index-sVljeUwB.css 0.82 kB │ gzip: 0.44 kB +dist/assets/index-C-WfQKN3.css 0.86 kB │ gzip: 0.42 kB +dist/assets/index-Z-xdbY0f.css 0.88 kB │ gzip: 0.45 kB +dist/assets/index-CWW18H9B.css 0.95 kB │ gzip: 0.46 kB +dist/assets/index-BB3vhgYC.css 1.01 kB │ gzip: 0.46 kB +dist/assets/index-DI210Dwb.css 1.11 kB │ gzip: 0.52 kB +dist/assets/index-CN1oFMiv.css 1.12 kB │ gzip: 0.54 kB +dist/assets/index-BBcAHwHh.css 1.18 kB │ gzip: 0.44 kB +dist/assets/index-C5dlaaEf.css 1.25 kB │ gzip: 0.58 kB +dist/assets/index-tBx-WLPU.css 1.35 kB │ gzip: 0.61 kB +dist/assets/index-DXicioAR.css 1.49 kB │ gzip: 0.69 kB +dist/assets/index-zXiq_2JM.css 1.55 kB │ gzip: 0.64 kB +dist/assets/index-D9i7dlpw.css 1.62 kB │ gzip: 0.58 kB +dist/assets/index-BnOSLZr1.css 1.88 kB │ gzip: 0.72 kB +dist/assets/index-DOQPcLFd.css 1.90 kB │ gzip: 0.78 kB +dist/assets/index-e-lqpHu8.css 1.97 kB │ gzip: 0.74 kB +dist/assets/index-CIQP2Yrs.css 1.97 kB │ gzip: 0.81 kB +dist/assets/index-C0nyNUTb.css 2.05 kB │ gzip: 0.86 kB +dist/assets/index-BLeagJA2.css 2.08 kB │ gzip: 0.78 kB +dist/assets/index-7avUzOCM.css 2.09 kB │ gzip: 0.87 kB +dist/assets/index-C3XsDPCF.css 2.11 kB │ gzip: 0.87 kB +dist/assets/index-B_pE6vfB.css 2.20 kB │ gzip: 0.84 kB +dist/assets/index-BvsNKx75.css 2.32 kB │ gzip: 0.81 kB +dist/assets/index-DaJDlkXk.css 2.35 kB │ gzip: 0.78 kB +dist/assets/index-C7xKh9wp.css 2.63 kB │ gzip: 1.06 kB +dist/assets/index-BRVWJzQW.css 2.77 kB │ gzip: 0.97 kB +dist/assets/index-GSPMmA7o.css 3.22 kB │ gzip: 1.14 kB +dist/assets/index-BhQYk_j_.css 3.38 kB │ gzip: 1.14 kB +dist/assets/index-CDVYuwwk.css 3.53 kB │ gzip: 1.15 kB +dist/assets/index-nagszGfi.css 3.69 kB │ gzip: 1.28 kB +dist/assets/index-qwVGhSiz.css 3.84 kB │ gzip: 1.17 kB +dist/assets/index-BRXWcRUe.css 4.14 kB │ gzip: 1.49 kB +dist/assets/index-Di2oLIK4.css 4.40 kB │ gzip: 1.40 kB +dist/assets/index-CIDlKy9v.css 4.45 kB │ gzip: 1.36 kB +dist/assets/index-ChZ75Max.css 4.88 kB │ gzip: 1.46 kB +dist/assets/index-tebhUVRK.css 4.95 kB │ gzip: 1.58 kB +dist/assets/index-B5udcqeD.css 5.60 kB │ gzip: 1.67 kB +dist/assets/index-C-Vqya12.css 5.63 kB │ gzip: 1.80 kB +dist/assets/index-FZMtGqxO.css 5.71 kB │ gzip: 1.77 kB +dist/assets/index-1H1mqjB8.css 5.91 kB │ gzip: 1.83 kB +dist/assets/index-CIkEmTmZ.css 5.96 kB │ gzip: 1.73 kB +dist/assets/viewer-Bzq4_yHu.css 6.01 kB │ gzip: 1.95 kB +dist/assets/index-D5KfkQeT.css 6.32 kB │ gzip: 1.76 kB +dist/assets/index-CsaMaQmR.css 6.72 kB │ gzip: 1.97 kB +dist/assets/index-BAVb0BmT.css 6.98 kB │ gzip: 2.20 kB +dist/assets/index-CRrUTti0.css 7.26 kB │ gzip: 2.35 kB +dist/assets/index-BgRMxk_x.css 7.33 kB │ gzip: 2.16 kB +dist/assets/index-BpWJBKE3.css 7.41 kB │ gzip: 1.85 kB +dist/assets/index-BnmlW51U.css 7.54 kB │ gzip: 1.94 kB +dist/assets/index-CktOeJNl.css 8.02 kB │ gzip: 2.09 kB +dist/assets/index-DaMxCVDY.css 8.42 kB │ gzip: 2.51 kB +dist/assets/index-BywSxTv1.css 8.56 kB │ gzip: 2.63 kB +dist/assets/index-B-xGLnU5.css 8.63 kB │ gzip: 2.55 kB +dist/assets/index-DSpZcqt3.css 9.52 kB │ gzip: 2.56 kB +dist/assets/index-D3jS-pTN.css 9.74 kB │ gzip: 2.52 kB +dist/assets/index-Bxq8GSwZ.css 9.79 kB │ gzip: 2.65 kB +dist/assets/index-CUJvBIJv.css 10.61 kB │ gzip: 2.65 kB +dist/assets/index-BjMmjRnb.css 15.44 kB │ gzip: 3.77 kB +dist/assets/index-t9PtY9ee.css 25.46 kB │ gzip: 5.20 kB +dist/assets/contrasts-DjgXvEt5.css 67.27 kB │ gzip: 12.82 kB +dist/assets/chart-D7j8BSt6.css 74.43 kB │ gzip: 15.13 kB +dist/assets/Toggle-DH9xMdfD.js 0.29 kB │ gzip: 0.23 kB +dist/assets/chevron-down-CDXDCLUy.js 0.31 kB │ gzip: 0.25 kB +dist/assets/chevron-right-CBbg3seD.js 0.31 kB │ gzip: 0.25 kB +dist/assets/chevron-left-D6yTe_0r.js 0.31 kB │ gzip: 0.25 kB +dist/assets/plus-4b9ZT-Gr.js 0.33 kB │ gzip: 0.26 kB +dist/assets/x-DH73QEN6.js 0.33 kB │ gzip: 0.26 kB +dist/assets/arrow-left-9lS-pYeG.js 0.34 kB │ gzip: 0.27 kB +dist/assets/search-Cbkx1iiu.js 0.35 kB │ gzip: 0.27 kB +dist/assets/clock-BwOd0xPI.js 0.36 kB │ gzip: 0.28 kB +dist/assets/Meter-5oSEw5mx.js 0.37 kB │ gzip: 0.28 kB +dist/assets/Menu-BItRuhLp.js 0.37 kB │ gzip: 0.27 kB +dist/assets/Badge-D6mejYQ6.js 0.38 kB │ gzip: 0.26 kB +dist/assets/pause-BV1zeNDR.js 0.39 kB │ gzip: 0.27 kB +dist/assets/file-DWQ1ZC3q.js 0.40 kB │ gzip: 0.29 kB +dist/assets/ellipsis-BcdHwcKp.js 0.40 kB │ gzip: 0.27 kB +dist/assets/folder-D7JVyY8K.js 0.41 kB │ gzip: 0.30 kB +dist/assets/type-CRHtIOBo.js 0.42 kB │ gzip: 0.30 kB +dist/assets/app-window-e4mgBJtT.js 0.44 kB │ gzip: 0.31 kB +dist/assets/image-SDs3cGWp.js 0.45 kB │ gzip: 0.33 kB +dist/assets/mic-XSrj4FIE.js 0.45 kB │ gzip: 0.32 kB +dist/assets/git-branch-DzdNWBDe.js 0.45 kB │ gzip: 0.32 kB +dist/assets/inbox-R6j6o4UJ.js 0.47 kB │ gzip: 0.34 kB +dist/assets/house-DvZxuiBV.js 0.47 kB │ gzip: 0.32 kB +dist/assets/States-DsWfAkaM.js 0.47 kB │ gzip: 0.32 kB +dist/assets/Segmented-CWaXIoYJ.js 0.48 kB │ gzip: 0.33 kB +dist/assets/list-pC7aY4XJ.js 0.48 kB │ gzip: 0.29 kB +dist/assets/square-pen-DZxXkyrB.js 0.50 kB │ gzip: 0.35 kB +dist/assets/tag-W3AqdRgu.js 0.51 kB │ gzip: 0.36 kB +dist/assets/list-ordered-DGh1gOAH.js 0.51 kB │ gzip: 0.32 kB +dist/assets/file-text-GRTeCqRD.js 0.51 kB │ gzip: 0.32 kB +dist/assets/Spinner-DYyN-rp9.js 0.52 kB │ gzip: 0.35 kB +dist/assets/Facepile-DDzwswcs.js 0.60 kB │ gzip: 0.42 kB +dist/assets/layers-CuRLVtyV.js 0.60 kB │ gzip: 0.35 kB +dist/assets/Popover-CAlF461p.js 0.61 kB │ gzip: 0.39 kB +dist/assets/Toast-fcx7lzJP.js 0.65 kB │ gzip: 0.41 kB +dist/assets/Check-B_2qDvF9.js 0.68 kB │ gzip: 0.38 kB +dist/assets/user-h3K1lIem.js 0.75 kB │ gzip: 0.39 kB +dist/assets/bell-Cnehlm9w.js 0.75 kB │ gzip: 0.40 kB +dist/assets/Collapse-DH0-dLlq.js 0.87 kB │ gzip: 0.52 kB +dist/assets/Avatar-BhvUqnLt.js 0.87 kB │ gzip: 0.60 kB +dist/assets/share-DDvO90JV.js 0.90 kB │ gzip: 0.41 kB +dist/assets/BrandMark-Brt4HwD6.js 0.98 kB │ gzip: 0.61 kB +dist/assets/index-BNVnzbwa.js 1.03 kB │ gzip: 0.58 kB +dist/assets/Row-LDXN40Zt.js 1.06 kB │ gzip: 0.49 kB +dist/assets/index-BhU3dYIQ.js 1.13 kB │ gzip: 0.58 kB +dist/assets/monitor-Dg5xjMxR.js 1.14 kB │ gzip: 0.47 kB +dist/assets/Camera-CE2SJRw4.js 1.22 kB │ gzip: 0.70 kB +dist/assets/index-17Eal28G.js 1.26 kB │ gzip: 0.48 kB +dist/assets/index-DULxjZmt.js 1.30 kB │ gzip: 0.69 kB +dist/assets/index-BItmLtVQ.js 1.33 kB │ gzip: 0.63 kB +dist/assets/star-Ccnmopt9.js 1.38 kB │ gzip: 0.54 kB +dist/assets/Fly-6m4FuaYB.js 1.40 kB │ gzip: 0.77 kB +dist/assets/index-DJwEv1B6.js 1.47 kB │ gzip: 0.78 kB +dist/assets/index-DahCuGPY.js 1.49 kB │ gzip: 0.70 kB +dist/assets/index-DyNMKAPE.js 1.57 kB │ gzip: 0.72 kB +dist/assets/createLucideIcon-CevK7Nqt.js 1.58 kB │ gzip: 0.66 kB +dist/assets/index-CBD-vhmv.js 1.62 kB │ gzip: 0.77 kB +dist/assets/settings-aymoTyAq.js 1.64 kB │ gzip: 0.60 kB +dist/assets/index-B7JFiZZa.js 1.82 kB │ gzip: 0.80 kB +dist/assets/index-t5-HV-fJ.js 1.85 kB │ gzip: 0.86 kB +dist/assets/index-BSmWQvsJ.js 1.85 kB │ gzip: 0.89 kB +dist/assets/index-pT3XKYUr.js 1.88 kB │ gzip: 0.91 kB +dist/assets/Field-CbupDgeD.js 1.91 kB │ gzip: 0.92 kB +dist/assets/Swap-BfCSa3-S.js 1.91 kB │ gzip: 0.92 kB +dist/assets/Follow-B212EkR6.js 2.14 kB │ gzip: 1.09 kB +dist/assets/index-BkVOqeck.js 2.38 kB │ gzip: 1.21 kB +dist/assets/index-Dp3tooaZ.js 2.42 kB │ gzip: 0.84 kB +dist/assets/Count-BKnLxNXk.js 2.43 kB │ gzip: 1.20 kB +dist/assets/index-BjTWbvYA.js 2.44 kB │ gzip: 0.93 kB +dist/assets/index-GmAy1Lag.js 2.59 kB │ gzip: 1.06 kB +dist/assets/index-XhiyJi1L.js 2.64 kB │ gzip: 1.16 kB +dist/assets/index-D46kave6.js 2.71 kB │ gzip: 1.09 kB +dist/assets/index-wYojaRhh.js 2.73 kB │ gzip: 1.12 kB +dist/assets/index-DukhqrQd.js 2.78 kB │ gzip: 1.32 kB +dist/assets/index-Ccu7ncj_.js 2.78 kB │ gzip: 1.32 kB +dist/assets/index-STPqlYZ-.js 3.08 kB │ gzip: 1.24 kB +dist/assets/index-JMQFwPnM.js 3.10 kB │ gzip: 1.30 kB +dist/assets/index-DbBpfK61.js 3.13 kB │ gzip: 1.26 kB +dist/assets/index-BHDZipZT.js 3.24 kB │ gzip: 1.45 kB +dist/assets/index-DmMHHpIW.js 3.52 kB │ gzip: 1.74 kB +dist/assets/Connector-BLcn0yKH.js 3.79 kB │ gzip: 1.74 kB +dist/assets/index-DQ1aggjS.js 4.06 kB │ gzip: 1.58 kB +dist/assets/index-qFTv_boN.js 4.08 kB │ gzip: 1.68 kB +dist/assets/chart-C1OCzOoE.js 4.35 kB │ gzip: 1.91 kB +dist/assets/index-DbDdEsYQ.js 4.42 kB │ gzip: 1.69 kB +dist/assets/index-DSZ-XoXb.js 4.55 kB │ gzip: 1.61 kB +dist/assets/index-uB5zMjp6.js 4.68 kB │ gzip: 1.82 kB +dist/assets/index-CaMLOwNA.js 4.71 kB │ gzip: 1.84 kB +dist/assets/index-BfpUTDPT.js 5.18 kB │ gzip: 2.33 kB +dist/assets/index-Bw0erUII.js 5.30 kB │ gzip: 2.06 kB +dist/assets/window-j2-AUrNc.js 5.40 kB │ gzip: 1.59 kB +dist/assets/index-q0VC-rZK.js 5.44 kB │ gzip: 1.79 kB +dist/assets/index-D1jkxcac.js 5.51 kB │ gzip: 2.33 kB +dist/assets/index-C29QqZEr.js 5.83 kB │ gzip: 2.27 kB +dist/assets/index-C0AxiUF2.js 5.93 kB │ gzip: 2.33 kB +dist/assets/index-DtnBkCud.js 6.16 kB │ gzip: 2.62 kB +dist/assets/index-DvJ4xvR6.js 6.23 kB │ gzip: 2.30 kB +dist/assets/index-DApFXPSI.js 6.74 kB │ gzip: 2.40 kB +dist/assets/phone-Ebq3SgOF.js 7.37 kB │ gzip: 2.83 kB +dist/assets/index-DzL5-cGK.js 7.57 kB │ gzip: 2.81 kB +dist/assets/index-CPGreSAx.js 7.79 kB │ gzip: 2.40 kB +dist/assets/index-C_PdLQB-.js 8.03 kB │ gzip: 3.08 kB +dist/assets/index-iCrr1hxc.js 8.07 kB │ gzip: 3.08 kB +dist/assets/index-B8Iv4zFm.js 8.17 kB │ gzip: 3.32 kB +dist/assets/Icon-Cno-jIB6.js 8.59 kB │ gzip: 3.06 kB +dist/assets/index-dKLDTUYt.js 8.65 kB │ gzip: 3.23 kB +dist/assets/index-NBtoNPoJ.js 8.66 kB │ gzip: 2.91 kB +dist/assets/index-Bw0kOyMt.js 8.68 kB │ gzip: 3.31 kB +dist/assets/index-BA2viUDK.js 8.82 kB │ gzip: 3.92 kB +dist/assets/index-BeVyuVqi.js 8.94 kB │ gzip: 3.00 kB +dist/assets/index-o9bFnczy.js 9.42 kB │ gzip: 3.22 kB +dist/assets/index-BbxfOB_q.js 9.74 kB │ gzip: 2.82 kB +dist/assets/index-CC0nBO__.js 10.63 kB │ gzip: 4.02 kB +dist/assets/index-DutvMX6V.js 11.33 kB │ gzip: 3.18 kB +dist/assets/index-C39hyzk6.js 11.54 kB │ gzip: 4.34 kB +dist/assets/index-BUBvd-i3.js 11.73 kB │ gzip: 3.31 kB +dist/assets/index-E1FFadmz.js 12.65 kB │ gzip: 4.47 kB +dist/assets/index-CD7pdwMG.js 14.04 kB │ gzip: 4.05 kB +dist/assets/index-t17EbUAR.js 14.11 kB │ gzip: 4.13 kB +dist/assets/index-zkEASSt3.js 14.16 kB │ gzip: 3.79 kB +dist/assets/index-E3vR2dk7.js 15.48 kB │ gzip: 3.82 kB +dist/assets/preview-9vlU2Ys7.js 15.85 kB │ gzip: 4.43 kB +dist/assets/index-D-tLFkDb.js 16.68 kB │ gzip: 6.34 kB +dist/assets/viewer-BQau-TCz.js 19.11 kB │ gzip: 5.98 kB +dist/assets/index-CWCb39Fv.js 29.53 kB │ gzip: 8.15 kB +dist/assets/contrasts-lYeVirZF.js 70.17 kB │ gzip: 19.78 kB +dist/assets/chart-AUUfhDck.js 126.98 kB │ gzip: 33.51 kB +dist/assets/client-DiRHWdFC.js 142.78 kB │ gzip: 45.72 kB +✓ built in 2.63s diff --git a/artifacts/validation/stageshow/local-2026-11-09/build.log b/artifacts/validation/stageshow/local-2026-11-09/build.log new file mode 100644 index 0000000..b420eb5 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/build.log @@ -0,0 +1,211 @@ + +> stageshow@0.1.0 build +> vite build playground --config playground/vite.config.ts + +vite v6.4.5 building for production... +transforming... +✓ 1832 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html 0.38 kB │ gzip: 0.28 kB +dist/assets/google-sheets-C6gOto98.svg 5.07 kB │ gzip: 0.95 kB +dist/assets/google-slides-B6uumWEX.svg 5.08 kB │ gzip: 0.95 kB +dist/assets/fuji-6u2eUW7w.jpg 23.67 kB +dist/assets/kawaguchiko-ZVc1ITuN.jpg 30.23 kB +dist/assets/preview-BtPcMLHE.css 0.27 kB │ gzip: 0.23 kB +dist/assets/index-BMNMwAK1.css 0.50 kB │ gzip: 0.30 kB +dist/assets/index-WKcunGLc.css 0.57 kB │ gzip: 0.34 kB +dist/assets/index-DJV5L_eC.css 0.70 kB │ gzip: 0.39 kB +dist/assets/index-Btff5qfu.css 0.71 kB │ gzip: 0.36 kB +dist/assets/index-CkYXjZ9w.css 0.73 kB │ gzip: 0.42 kB +dist/assets/index-mEYhPiZ3.css 0.81 kB │ gzip: 0.43 kB +dist/assets/index-sVljeUwB.css 0.82 kB │ gzip: 0.44 kB +dist/assets/index-C-WfQKN3.css 0.86 kB │ gzip: 0.42 kB +dist/assets/index-Z-xdbY0f.css 0.88 kB │ gzip: 0.45 kB +dist/assets/index-CWW18H9B.css 0.95 kB │ gzip: 0.46 kB +dist/assets/index-BB3vhgYC.css 1.01 kB │ gzip: 0.46 kB +dist/assets/index-DI210Dwb.css 1.11 kB │ gzip: 0.52 kB +dist/assets/index-CN1oFMiv.css 1.12 kB │ gzip: 0.54 kB +dist/assets/index-BBcAHwHh.css 1.18 kB │ gzip: 0.44 kB +dist/assets/index-C5dlaaEf.css 1.25 kB │ gzip: 0.58 kB +dist/assets/index-tBx-WLPU.css 1.35 kB │ gzip: 0.61 kB +dist/assets/index-DXicioAR.css 1.49 kB │ gzip: 0.68 kB +dist/assets/index-zXiq_2JM.css 1.55 kB │ gzip: 0.64 kB +dist/assets/index-D9i7dlpw.css 1.62 kB │ gzip: 0.58 kB +dist/assets/index-BnOSLZr1.css 1.88 kB │ gzip: 0.72 kB +dist/assets/index-DOQPcLFd.css 1.90 kB │ gzip: 0.78 kB +dist/assets/index-e-lqpHu8.css 1.97 kB │ gzip: 0.74 kB +dist/assets/index-CIQP2Yrs.css 1.97 kB │ gzip: 0.81 kB +dist/assets/index-C0nyNUTb.css 2.05 kB │ gzip: 0.85 kB +dist/assets/index-BLeagJA2.css 2.08 kB │ gzip: 0.78 kB +dist/assets/index-7avUzOCM.css 2.09 kB │ gzip: 0.87 kB +dist/assets/index-C3XsDPCF.css 2.11 kB │ gzip: 0.87 kB +dist/assets/index-B_pE6vfB.css 2.20 kB │ gzip: 0.84 kB +dist/assets/index-BvsNKx75.css 2.32 kB │ gzip: 0.80 kB +dist/assets/index-DaJDlkXk.css 2.35 kB │ gzip: 0.78 kB +dist/assets/index-C7xKh9wp.css 2.63 kB │ gzip: 1.06 kB +dist/assets/index-BRVWJzQW.css 2.77 kB │ gzip: 0.97 kB +dist/assets/index-GSPMmA7o.css 3.22 kB │ gzip: 1.13 kB +dist/assets/index-BhQYk_j_.css 3.38 kB │ gzip: 1.14 kB +dist/assets/index-CDVYuwwk.css 3.53 kB │ gzip: 1.14 kB +dist/assets/index-nagszGfi.css 3.69 kB │ gzip: 1.27 kB +dist/assets/index-qwVGhSiz.css 3.84 kB │ gzip: 1.17 kB +dist/assets/index-BRXWcRUe.css 4.14 kB │ gzip: 1.49 kB +dist/assets/index-Di2oLIK4.css 4.40 kB │ gzip: 1.40 kB +dist/assets/index-CIDlKy9v.css 4.45 kB │ gzip: 1.36 kB +dist/assets/index-ChZ75Max.css 4.88 kB │ gzip: 1.45 kB +dist/assets/index-tebhUVRK.css 4.95 kB │ gzip: 1.58 kB +dist/assets/index-B5udcqeD.css 5.60 kB │ gzip: 1.67 kB +dist/assets/index-C-Vqya12.css 5.63 kB │ gzip: 1.80 kB +dist/assets/index-FZMtGqxO.css 5.71 kB │ gzip: 1.76 kB +dist/assets/index-1H1mqjB8.css 5.91 kB │ gzip: 1.84 kB +dist/assets/index-CIkEmTmZ.css 5.96 kB │ gzip: 1.72 kB +dist/assets/viewer-Bzq4_yHu.css 6.01 kB │ gzip: 1.94 kB +dist/assets/index-D5KfkQeT.css 6.32 kB │ gzip: 1.76 kB +dist/assets/index-CsaMaQmR.css 6.72 kB │ gzip: 1.98 kB +dist/assets/index-BAVb0BmT.css 6.98 kB │ gzip: 2.20 kB +dist/assets/index-CRrUTti0.css 7.26 kB │ gzip: 2.34 kB +dist/assets/index-BgRMxk_x.css 7.33 kB │ gzip: 2.17 kB +dist/assets/index-BpWJBKE3.css 7.41 kB │ gzip: 1.86 kB +dist/assets/index-BnmlW51U.css 7.54 kB │ gzip: 1.93 kB +dist/assets/index-CktOeJNl.css 8.02 kB │ gzip: 2.09 kB +dist/assets/index-DaMxCVDY.css 8.42 kB │ gzip: 2.50 kB +dist/assets/index-BywSxTv1.css 8.56 kB │ gzip: 2.62 kB +dist/assets/index-B-xGLnU5.css 8.63 kB │ gzip: 2.54 kB +dist/assets/index-DSpZcqt3.css 9.52 kB │ gzip: 2.55 kB +dist/assets/index-D3jS-pTN.css 9.74 kB │ gzip: 2.51 kB +dist/assets/index-Bxq8GSwZ.css 9.79 kB │ gzip: 2.62 kB +dist/assets/index-CUJvBIJv.css 10.61 kB │ gzip: 2.64 kB +dist/assets/index-BjMmjRnb.css 15.44 kB │ gzip: 3.79 kB +dist/assets/index-t9PtY9ee.css 25.46 kB │ gzip: 5.22 kB +dist/assets/contrasts-DjgXvEt5.css 67.27 kB │ gzip: 12.86 kB +dist/assets/chart-D7j8BSt6.css 74.43 kB │ gzip: 15.11 kB +dist/assets/Toggle-DH9xMdfD.js 0.29 kB │ gzip: 0.23 kB +dist/assets/chevron-down-CDXDCLUy.js 0.31 kB │ gzip: 0.25 kB +dist/assets/chevron-right-CBbg3seD.js 0.31 kB │ gzip: 0.25 kB +dist/assets/chevron-left-D6yTe_0r.js 0.31 kB │ gzip: 0.25 kB +dist/assets/plus-4b9ZT-Gr.js 0.33 kB │ gzip: 0.26 kB +dist/assets/x-DH73QEN6.js 0.33 kB │ gzip: 0.26 kB +dist/assets/arrow-left-9lS-pYeG.js 0.34 kB │ gzip: 0.27 kB +dist/assets/search-Cbkx1iiu.js 0.35 kB │ gzip: 0.27 kB +dist/assets/clock-BwOd0xPI.js 0.36 kB │ gzip: 0.27 kB +dist/assets/Meter-5oSEw5mx.js 0.37 kB │ gzip: 0.28 kB +dist/assets/Menu-BItRuhLp.js 0.37 kB │ gzip: 0.27 kB +dist/assets/Badge-D6mejYQ6.js 0.38 kB │ gzip: 0.26 kB +dist/assets/pause-BV1zeNDR.js 0.39 kB │ gzip: 0.27 kB +dist/assets/file-DWQ1ZC3q.js 0.40 kB │ gzip: 0.29 kB +dist/assets/ellipsis-BcdHwcKp.js 0.40 kB │ gzip: 0.27 kB +dist/assets/folder-D7JVyY8K.js 0.41 kB │ gzip: 0.30 kB +dist/assets/type-CRHtIOBo.js 0.42 kB │ gzip: 0.30 kB +dist/assets/app-window-e4mgBJtT.js 0.44 kB │ gzip: 0.31 kB +dist/assets/image-SDs3cGWp.js 0.45 kB │ gzip: 0.33 kB +dist/assets/mic-XSrj4FIE.js 0.45 kB │ gzip: 0.32 kB +dist/assets/git-branch-DzdNWBDe.js 0.45 kB │ gzip: 0.32 kB +dist/assets/inbox-R6j6o4UJ.js 0.47 kB │ gzip: 0.34 kB +dist/assets/house-DvZxuiBV.js 0.47 kB │ gzip: 0.32 kB +dist/assets/States-DsWfAkaM.js 0.47 kB │ gzip: 0.32 kB +dist/assets/Segmented-CWaXIoYJ.js 0.48 kB │ gzip: 0.33 kB +dist/assets/list-pC7aY4XJ.js 0.48 kB │ gzip: 0.29 kB +dist/assets/square-pen-DZxXkyrB.js 0.50 kB │ gzip: 0.34 kB +dist/assets/tag-W3AqdRgu.js 0.51 kB │ gzip: 0.35 kB +dist/assets/list-ordered-DGh1gOAH.js 0.51 kB │ gzip: 0.32 kB +dist/assets/file-text-GRTeCqRD.js 0.51 kB │ gzip: 0.32 kB +dist/assets/Spinner-DYyN-rp9.js 0.52 kB │ gzip: 0.35 kB +dist/assets/Facepile-DDzwswcs.js 0.60 kB │ gzip: 0.42 kB +dist/assets/layers-CuRLVtyV.js 0.60 kB │ gzip: 0.34 kB +dist/assets/Popover-CAlF461p.js 0.61 kB │ gzip: 0.39 kB +dist/assets/Toast-fcx7lzJP.js 0.65 kB │ gzip: 0.41 kB +dist/assets/Check-B_2qDvF9.js 0.68 kB │ gzip: 0.38 kB +dist/assets/user-h3K1lIem.js 0.75 kB │ gzip: 0.39 kB +dist/assets/bell-Cnehlm9w.js 0.75 kB │ gzip: 0.40 kB +dist/assets/Collapse-DH0-dLlq.js 0.87 kB │ gzip: 0.52 kB +dist/assets/Avatar-BhvUqnLt.js 0.87 kB │ gzip: 0.60 kB +dist/assets/share-DDvO90JV.js 0.90 kB │ gzip: 0.41 kB +dist/assets/BrandMark-Brt4HwD6.js 0.98 kB │ gzip: 0.61 kB +dist/assets/index-BNVnzbwa.js 1.03 kB │ gzip: 0.58 kB +dist/assets/Row-LDXN40Zt.js 1.06 kB │ gzip: 0.49 kB +dist/assets/index-BhU3dYIQ.js 1.13 kB │ gzip: 0.58 kB +dist/assets/monitor-Dg5xjMxR.js 1.14 kB │ gzip: 0.47 kB +dist/assets/Camera-CE2SJRw4.js 1.22 kB │ gzip: 0.70 kB +dist/assets/index-17Eal28G.js 1.26 kB │ gzip: 0.48 kB +dist/assets/index-DULxjZmt.js 1.30 kB │ gzip: 0.69 kB +dist/assets/index-BItmLtVQ.js 1.33 kB │ gzip: 0.63 kB +dist/assets/star-Ccnmopt9.js 1.38 kB │ gzip: 0.54 kB +dist/assets/Fly-6m4FuaYB.js 1.40 kB │ gzip: 0.76 kB +dist/assets/index-DJwEv1B6.js 1.47 kB │ gzip: 0.78 kB +dist/assets/index-DahCuGPY.js 1.49 kB │ gzip: 0.70 kB +dist/assets/index-DyNMKAPE.js 1.57 kB │ gzip: 0.72 kB +dist/assets/createLucideIcon-CevK7Nqt.js 1.58 kB │ gzip: 0.66 kB +dist/assets/index-CBD-vhmv.js 1.62 kB │ gzip: 0.77 kB +dist/assets/settings-aymoTyAq.js 1.64 kB │ gzip: 0.60 kB +dist/assets/index-B7JFiZZa.js 1.82 kB │ gzip: 0.79 kB +dist/assets/index-t5-HV-fJ.js 1.85 kB │ gzip: 0.85 kB +dist/assets/index-BSmWQvsJ.js 1.85 kB │ gzip: 0.89 kB +dist/assets/index-pT3XKYUr.js 1.88 kB │ gzip: 0.91 kB +dist/assets/Field-CbupDgeD.js 1.91 kB │ gzip: 0.92 kB +dist/assets/Swap-BfCSa3-S.js 1.91 kB │ gzip: 0.92 kB +dist/assets/Follow-B212EkR6.js 2.14 kB │ gzip: 1.08 kB +dist/assets/index-BkVOqeck.js 2.38 kB │ gzip: 1.20 kB +dist/assets/index-Dp3tooaZ.js 2.42 kB │ gzip: 0.83 kB +dist/assets/Count-BKnLxNXk.js 2.43 kB │ gzip: 1.19 kB +dist/assets/index-BjTWbvYA.js 2.44 kB │ gzip: 0.93 kB +dist/assets/index-GmAy1Lag.js 2.59 kB │ gzip: 1.06 kB +dist/assets/index-XhiyJi1L.js 2.64 kB │ gzip: 1.16 kB +dist/assets/index-D46kave6.js 2.71 kB │ gzip: 1.08 kB +dist/assets/index-wYojaRhh.js 2.73 kB │ gzip: 1.12 kB +dist/assets/index-DukhqrQd.js 2.78 kB │ gzip: 1.30 kB +dist/assets/index-Ccu7ncj_.js 2.78 kB │ gzip: 1.31 kB +dist/assets/index-STPqlYZ-.js 3.08 kB │ gzip: 1.24 kB +dist/assets/index-JMQFwPnM.js 3.10 kB │ gzip: 1.29 kB +dist/assets/index-DbBpfK61.js 3.13 kB │ gzip: 1.26 kB +dist/assets/index-BHDZipZT.js 3.24 kB │ gzip: 1.45 kB +dist/assets/index-DmMHHpIW.js 3.52 kB │ gzip: 1.74 kB +dist/assets/Connector-BLcn0yKH.js 3.79 kB │ gzip: 1.72 kB +dist/assets/index-DQ1aggjS.js 4.06 kB │ gzip: 1.57 kB +dist/assets/index-qFTv_boN.js 4.08 kB │ gzip: 1.69 kB +dist/assets/chart-C1OCzOoE.js 4.35 kB │ gzip: 1.90 kB +dist/assets/index-DbDdEsYQ.js 4.42 kB │ gzip: 1.69 kB +dist/assets/index-DSZ-XoXb.js 4.55 kB │ gzip: 1.61 kB +dist/assets/index-uB5zMjp6.js 4.68 kB │ gzip: 1.82 kB +dist/assets/index-CaMLOwNA.js 4.71 kB │ gzip: 1.83 kB +dist/assets/index-BfpUTDPT.js 5.18 kB │ gzip: 2.31 kB +dist/assets/index-Bw0erUII.js 5.30 kB │ gzip: 2.05 kB +dist/assets/window-j2-AUrNc.js 5.40 kB │ gzip: 1.59 kB +dist/assets/index-q0VC-rZK.js 5.44 kB │ gzip: 1.78 kB +dist/assets/index-D1jkxcac.js 5.51 kB │ gzip: 2.33 kB +dist/assets/index-C29QqZEr.js 5.83 kB │ gzip: 2.25 kB +dist/assets/index-C0AxiUF2.js 5.93 kB │ gzip: 2.33 kB +dist/assets/index-DtnBkCud.js 6.16 kB │ gzip: 2.62 kB +dist/assets/index-DvJ4xvR6.js 6.23 kB │ gzip: 2.29 kB +dist/assets/index-DApFXPSI.js 6.74 kB │ gzip: 2.41 kB +dist/assets/phone-Ebq3SgOF.js 7.37 kB │ gzip: 2.81 kB +dist/assets/index-DzL5-cGK.js 7.57 kB │ gzip: 2.80 kB +dist/assets/index-CPGreSAx.js 7.79 kB │ gzip: 2.40 kB +dist/assets/index-C_PdLQB-.js 8.03 kB │ gzip: 3.08 kB +dist/assets/index-iCrr1hxc.js 8.07 kB │ gzip: 3.05 kB +dist/assets/index-B8Iv4zFm.js 8.17 kB │ gzip: 3.31 kB +dist/assets/Icon-Cno-jIB6.js 8.59 kB │ gzip: 3.05 kB +dist/assets/index-dKLDTUYt.js 8.65 kB │ gzip: 3.21 kB +dist/assets/index-NBtoNPoJ.js 8.66 kB │ gzip: 2.90 kB +dist/assets/index-Bw0kOyMt.js 8.68 kB │ gzip: 3.27 kB +dist/assets/index-BA2viUDK.js 8.82 kB │ gzip: 3.92 kB +dist/assets/index-BeVyuVqi.js 8.94 kB │ gzip: 2.99 kB +dist/assets/index-o9bFnczy.js 9.42 kB │ gzip: 3.21 kB +dist/assets/index-BbxfOB_q.js 9.74 kB │ gzip: 2.81 kB +dist/assets/index-CC0nBO__.js 10.63 kB │ gzip: 4.00 kB +dist/assets/index-DutvMX6V.js 11.33 kB │ gzip: 3.19 kB +dist/assets/index-C39hyzk6.js 11.54 kB │ gzip: 4.27 kB +dist/assets/index-BUBvd-i3.js 11.73 kB │ gzip: 3.28 kB +dist/assets/index-E1FFadmz.js 12.65 kB │ gzip: 4.44 kB +dist/assets/index-CD7pdwMG.js 14.04 kB │ gzip: 4.04 kB +dist/assets/index-t17EbUAR.js 14.11 kB │ gzip: 4.12 kB +dist/assets/index-zkEASSt3.js 14.16 kB │ gzip: 3.77 kB +dist/assets/index-E3vR2dk7.js 15.48 kB │ gzip: 3.81 kB +dist/assets/preview-9vlU2Ys7.js 15.85 kB │ gzip: 4.44 kB +dist/assets/index-D-tLFkDb.js 16.68 kB │ gzip: 6.29 kB +dist/assets/viewer-BQau-TCz.js 19.11 kB │ gzip: 5.98 kB +dist/assets/index-CWCb39Fv.js 29.53 kB │ gzip: 8.13 kB +dist/assets/contrasts-lYeVirZF.js 70.17 kB │ gzip: 19.77 kB +dist/assets/chart-AUUfhDck.js 126.98 kB │ gzip: 33.58 kB +dist/assets/client-DiRHWdFC.js 142.78 kB │ gzip: 45.68 kB +✓ built in 1.52s diff --git a/artifacts/validation/stageshow/local-2026-11-09/compact-final.log b/artifacts/validation/stageshow/local-2026-11-09/compact-final.log new file mode 100644 index 0000000..9128d44 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/compact-final.log @@ -0,0 +1,4 @@ +still -> /home/maroun.guest/validation/outputs/representative-compact.png +coverage stage-1: 1 frames, 0.00s, largest gap 0.0ms (complete) +film: no-issues-detected; still capture only, motion was not judged +rehearsal passed (necessary, not sufficient: references/verify.md and the fresh eye in review.md still stand) diff --git a/artifacts/validation/stageshow/local-2026-11-09/deb-pins.txt b/artifacts/validation/stageshow/local-2026-11-09/deb-pins.txt new file mode 100644 index 0000000..a7d930b --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/deb-pins.txt @@ -0,0 +1,56 @@ +alsa-topology-conf=1.2.5.1-2 +libasound2-data=1.2.8-1 +libasound2=1.2.8-1+b1 +alsa-ucm-conf=1.2.8-1 +at-spi2-common=2.46.0-5 +libxau6=1:1.0.9-1 +libxdmcp6=1:1.1.2-3 +libxcb1=1.15-1 +libx11-data=2:1.8.4-2+deb12u2 +libx11-6=2:1.8.4-2+deb12u2 +libxext6=2:1.3.4-1+b1 +libxi6=2:1.8-1+b1 +libatspi2.0-0=2.46.0-5 +x11-common=1:7.7+23 +libxtst6=2:1.2.3-1.1 +dbus-user-session=1.14.10-1~deb12u1 +libdconf1=0.40.0-4 +dconf-service=0.40.0-4 +dconf-gsettings-backend=0.40.0-4 +gsettings-desktop-schemas=43.0-1 +at-spi2-core=2.46.0-5 +fonts-dejavu-core=2.37-6 +fonts-liberation=1:1.07.4-11 +fontconfig-config=2.14.1-4 +libfontconfig1=2.14.1-4 +fontconfig=2.14.1-4 +libatk1.0-0=2.46.0-5 +libatk-bridge2.0-0=2.46.0-5 +libavahi-common-data=0.8-10+deb12u1 +libavahi-common3=0.8-10+deb12u1 +libavahi-client3=0.8-10+deb12u1 +libpixman-1-0=0.42.2-1 +libxcb-render0=1.15-1 +libxcb-shm0=1.15-1 +libxrender1=1:0.9.10-1.1 +libcairo2=1.16.0-7 +libcups2=2.4.2-3+deb12u9 +libdatrie1=0.2.13-2+b1 +libdrm-common=2.4.114-1 +libdrm2=2.4.114-1+b1 +libfribidi0=1.0.8-2.1 +libwayland-server0=1.21.0-1 +libgbm1=22.3.6-1+deb12u2 +libgraphite2-3=1.3.14-1+deb12u1 +libharfbuzz0b=6.0.0+dfsg-3 +libnspr4=2:4.35-1 +libnss3=2:3.87.1-1+deb12u4 +libthai-data=0.1.29-1 +libthai0=0.1.29-1 +libpango-1.0-0=1.50.12+ds-1 +libxcomposite1=1:0.4.5-1 +libxdamage1=1:1.1.6-1 +libxfixes3=1:6.0.0-2 +xkb-data=2.35.1-1 +libxkbcommon0=1.5.0-1 +libxrandr2=2:1.5.2-2+b1 diff --git a/artifacts/validation/stageshow/local-2026-11-09/deb-sha256.txt b/artifacts/validation/stageshow/local-2026-11-09/deb-sha256.txt new file mode 100644 index 0000000..327a6cf --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/deb-sha256.txt @@ -0,0 +1,56 @@ +1e4503ae66ad9cbe2a024ec403cb453af07ff1d07a62e795aab24539a307ad8a alsa-topology-conf_1.2.5.1-2_all.deb +abc7ac211bde60fb90fae9f8dc3f207818d6faadb4b3f76037785235f9284e67 alsa-ucm-conf_1.2.8-1_all.deb +441bc1c6cefdc01f519310abf55823deb868898480dee09a05aa44e8c83cabc2 at-spi2-common_2.46.0-5_all.deb +b148eb6f97380148b3fd6d582d528ca80a135fae0bdab3348974f999d60e8b0c at-spi2-core_2.46.0-5_arm64.deb +98daefc769d77230c07c50bd9c3b3c593c91af55e03c0116d3c83d2083b66b3f dbus-user-session_1.14.10-1~deb12u1_arm64.deb +2a7af3c4aed1421a0ce5c82e8ec16d40fffbf6cc313dce1ed261a499aa68ccdf dconf-gsettings-backend_0.40.0-4_arm64.deb +48330ba150692aa402877587d85c67ffc92c34880826bfe746fad0cc60864376 dconf-service_0.40.0-4_arm64.deb +297b5a63f9214ffebe08892cdf2effdaf2b408e719811d5e8e98870f710cd35c fontconfig-config_2.14.1-4_arm64.deb +5fe5ce908c360fb5e9376ce26484477948f8182c982fee495b8ab024a0f22c6f fontconfig_2.14.1-4_arm64.deb +8892669e51aab4dc56682c8e39d8ddb7d70fad83c369344e1e240bf3ca22bb76 fonts-dejavu-core_2.37-6_all.deb +efd381517f958b01969343634ffcbdd60056be7779af84c6f53a005090430204 fonts-liberation_1%3a1.07.4-11_all.deb +15cc7142c3ddea0551b834c53c4d3b5cd8f5485e695100966877f2be50def7af gsettings-desktop-schemas_43.0-1_all.deb +fe0780d2d3674b2977e0acb0d48b448ad72ba1642564b7dc537f55e839984c2d libasound2-data_1.2.8-1_all.deb +9fa889400fcee4b92c8f4a2fafbb7f2cd33444d9ec1665a71002ab67c06114bb libasound2_1.2.8-1+b1_arm64.deb +3f2229723e1f8337282f74c44f139eda2aab5829fc0bca9adb2976f3a8d83b44 libatk-bridge2.0-0_2.46.0-5_arm64.deb +c7508adb33e42c35b516e8456d801d5d246e36f1ebb2f1ebb8d2ce7a70ce1d2b libatk1.0-0_2.46.0-5_arm64.deb +5408f7d48e84f3b140fb4496215078cb9da997937e3702086476690374d60311 libatspi2.0-0_2.46.0-5_arm64.deb +1bd0aa97b536ae39a90a44988b4fbb903352aa7c91d7cd914727b82f39ff0d81 libavahi-client3_0.8-10+deb12u1_arm64.deb +9fb985c54d65586b1e13d734852c9cc26d91f2ea4922b90cb6d684a52a46f449 libavahi-common-data_0.8-10+deb12u1_arm64.deb +f55666f45c6ccf6853fd444524ff2c255292cbb3c5b49d8aa0afffa3510a9d9f libavahi-common3_0.8-10+deb12u1_arm64.deb +48b5c5ae1972ea0757c6285463d611d0ca32c9c808957961e5c7924126f30289 libcairo2_1.16.0-7_arm64.deb +41c9220d57d9eb3b5245f17038c830053f12176f6eef611c6f6941fcbf6d507f libcups2_2.4.2-3+deb12u9_arm64.deb +d7c52aa971278e7f298b7e74f8ed50ad25579f9b344212fd4a91744cffa58cc9 libdatrie1_0.2.13-2+b1_arm64.deb +8b657994e74328804b2f7d65ec11a293be962e9b5f47428912190d46199101fd libdconf1_0.40.0-4_arm64.deb +32f9664138b38b224383c6986457d5ad2ec8efd559b1a0ce7749405f7a451aad libdrm-common_2.4.114-1_all.deb +f5f15a46d02cf5d9fa52d4f1c54b8cf80c398711ad771a9938b12399b8d8090c libdrm2_2.4.114-1+b1_arm64.deb +4836d9deca824883f0de33d4f8f3718ba776c6e7c80cd7c418b5a0f86c009b9e libfontconfig1_2.14.1-4_arm64.deb +8ab3adcf64b318252d37669468fecb8e0478e1cf5f096d065611ac7dda68ccf4 libfribidi0_1.0.8-2.1_arm64.deb +f5c8fdddbf365259d74af270fb10f30d7fddb3fbe7b2ff62f0fdd556f8db0dc8 libgbm1_22.3.6-1+deb12u2_arm64.deb +fca82a3f01cb2cfe607efb6f21f489081716ddcf8a71e2f588d3d67ad39107a2 libgraphite2-3_1.3.14-1+deb12u1_arm64.deb +64b1d4aa672dc4eda5e11b9ff8061122060fc7aba6ad16908c89a269ffa174ee libharfbuzz0b_6.0.0+dfsg-3_arm64.deb +3aa6bc5a1a3f83627f735b9712eed74ed2c345ae9148e9d876887a97982ae28d libnspr4_2%3a4.35-1_arm64.deb +aaa1b291b6330590079a0eb1404650c7c4f6c788f9213ab6911149dc22b2b6d0 libnss3_2%3a3.87.1-1+deb12u4_arm64.deb +88afa1481f9e520a3068e85dad776ed09a3751c7638a877333af6cec927db3ee libpango-1.0-0_1.50.12+ds-1_arm64.deb +a18db1a3bdbad168c7168b528bbb07b47316ff9fe977206a780dcc21b376e501 libpixman-1-0_0.42.2-1_arm64.deb +eed65a75269411e47d7b393d82bc30471da5c499e9f311abbfd8c54ca1a42d9e libthai-data_0.1.29-1_all.deb +1374f50f4e96397f2830f3fdf09e95a5fe8e4d584fdc70dd187eec5f8832d08c libthai0_0.1.29-1_arm64.deb +4d99e7e0503fdaa999e67d47d8612c0465e775eafa47819573da50bdb09064f2 libwayland-server0_1.21.0-1_arm64.deb +d1d533e983582282a9ea82c87ac5ce715a9b67bd6d1acbd2439a11c63c36549b libx11-6_2%3a1.8.4-2+deb12u2_arm64.deb +987a848aeb1c358e4186368871b0526f10bb14c6b53214ab3bf8b69abb830191 libx11-data_2%3a1.8.4-2+deb12u2_all.deb +36c2bf400641a80521093771dc2562c903df4065f9eb03add50d90564337ea6c libxau6_1%3a1.0.9-1_arm64.deb +8d431caeae9dd0f7490caef1d66de0b7a7d3817afe021b6d55bedbba7b19bf43 libxcb-render0_1.15-1_arm64.deb +825931950d42f51cd795cf2e5b9399d840225491e95ec530e0c5a9145c4ba5a0 libxcb-shm0_1.15-1_arm64.deb +041d9a68415c3ccf3ce8f4f8b88e4bbfb5dc1f0d97013c6ef8423e620ea50f84 libxcb1_1.15-1_arm64.deb +cfe39326fdb822e9d060ed5eb3f95b14459dd6b73793c5290000f9b27f8bad37 libxcomposite1_1%3a0.4.5-1_arm64.deb +489d20cf78c8ee4428f3e26c5b3edcd87a71b5beb6bed992c45f47c6d01a1c92 libxdamage1_1%3a1.1.6-1_arm64.deb +e92569ac33247261aa09adfadc34ced3994ac301cf8b58de415a2d5dbf15ccfc libxdmcp6_1%3a1.1.2-3_arm64.deb +5e9c0ad606eb4674c645fe8e0e64330c47d2729f7a59ed569848610efd5d5b62 libxext6_2%3a1.3.4-1+b1_arm64.deb +d47bda8fed01b19b41d503e2df05d9166c58e30e2376f2f8784ceb7a834befe6 libxfixes3_1%3a6.0.0-2_arm64.deb +b26383c9929d8bfba051eee22301e064d17039ed022bbf31990feb0d7a03b36c libxi6_2%3a1.8-1+b1_arm64.deb +ec518d8a19796a399ab95e7bc4dfbb6bd2ed8e151f77b222df26208db412d852 libxkbcommon0_1.5.0-1_arm64.deb +f0bc7f79fee182caae176652fdb1bf349d13b9591b10ad4dc1c896521da8e49f libxrandr2_2%3a1.5.2-2+b1_arm64.deb +f260193deb4eaf0b410a1c61e1f84804366ee3372b8bad9f38005f7dbcd89aae libxrender1_1%3a0.9.10-1.1_arm64.deb +ef2a1b9ac3f2620d96e17744e41c4eefb7f491eee3bcc5bc1e592180b2d94128 libxtst6_2%3a1.2.3-1.1_arm64.deb +fc97c2f4495eb33a77501c7960928c0d2001e5c4b2aa438f1713e2082c23bacd x11-common_1%3a7.7+23_all.deb +28a79c61b785f403da93af58c39aed13c022032935e3e54362c6707169cfe982 xkb-data_2.35.1-1_all.deb diff --git a/artifacts/validation/stageshow/local-2026-11-09/film-final.log b/artifacts/validation/stageshow/local-2026-11-09/film-final.log new file mode 100644 index 0000000..e53821e --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/film-final.log @@ -0,0 +1,4 @@ +8 frames every 1000ms -> /home/maroun.guest/validation/outputs/representative-loop-final.png +coverage stage-1: 468 frames, 7.78s, largest gap 23.4ms (complete) +film: no-issues-detected; watch the motion and inspect every image before approval +rehearsal passed (necessary, not sufficient: references/verify.md and the fresh eye in review.md still stand) diff --git a/artifacts/validation/stageshow/local-2026-11-09/film-run-representative.log b/artifacts/validation/stageshow/local-2026-11-09/film-run-representative.log new file mode 100644 index 0000000..ffc4a67 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/film-run-representative.log @@ -0,0 +1,8 @@ +{"case": "representative-still", "exitCode": 0, "expectedFailure": false} +{"case": "representative-compact", "exitCode": 0, "expectedFailure": false} +{"case": "representative-loop", "exitCode": 2, "expectedFailure": false} +{"case": "failure", "exitCode": 1, "expectedFailure": true} +Traceback (most recent call last): + File "/home/maroun.guest/validation/runtime/../run-films.py", line 39, in + assert all(case['exitCode'] == 0 for case in results[:-1]), results +AssertionError: [{'case': 'representative-still', 'exitCode': 0, 'expectedFailure': False}, {'case': 'representative-compact', 'exitCode': 0, 'expectedFailure': False}, {'case': 'representative-loop', 'exitCode': 2, 'expectedFailure': False}, {'case': 'failure', 'exitCode': 1, 'expectedFailure': True}] diff --git a/artifacts/validation/stageshow/local-2026-11-09/film-run.log b/artifacts/validation/stageshow/local-2026-11-09/film-run.log new file mode 100644 index 0000000..4412c2b --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/film-run.log @@ -0,0 +1,17 @@ +{"case": "still", "exitCode": 0, "expectedFailure": false} +{"case": "compact", "exitCode": 1, "expectedFailure": false} +{"case": "poster", "exitCode": 0, "expectedFailure": false} +{"case": "animated", "exitCode": 2, "expectedFailure": false} +{"case": "loop", "exitCode": 2, "expectedFailure": false} +{"case": "failure", "exitCode": 2, "expectedFailure": true} +Traceback (most recent call last): + File "/home/maroun.guest/validation/runtime/../run-films.py", line 33, in + caption = json.loads((out / 'caption-audit.json').read_text()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/lib/python3.11/pathlib.py", line 1059, in read_text + with self.open(mode='r', encoding=encoding, errors=errors) as f: + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/lib/python3.11/pathlib.py", line 1045, in open + return io.open(self, mode, buffering, encoding, errors, newline) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +FileNotFoundError: [Errno 2] No such file or directory: '/home/maroun.guest/validation/outputs/caption-audit.json' diff --git a/artifacts/validation/stageshow/local-2026-11-09/fixture-entry.tsx b/artifacts/validation/stageshow/local-2026-11-09/fixture-entry.tsx new file mode 100644 index 0000000..5f5145f --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/fixture-entry.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import {createRoot} from 'react-dom/client' +import Form from './examples/form-error' +import Static from './examples/static-card' +import Loading from './examples/loading' +import {Stage} from './kit/stage' +import {Window} from './kit/window' +import './acceptance-scene.css' +const mode=new URLSearchParams(location.search).get('mode')||'form' +const payload=` & 'single' "double"` +function Probe(){return {await a.wait(1200);a.mark(payload);await a.wait(1300)}} play="once">{()=> Clipped action}} +function Scene(){return {await a.wait(400);await a.click('save');a.mark('saved');a.put({saved:true});await a.wait(1400);a.mark('reset');a.put({saved:false});await a.wait(1000)}} className="validation-scene">{(s,rect,layout)=>{ + const content=

Release preview

One local draft, ready to share.

; + return layout==='compact'?
{content}
:{content} + }}} +createRoot(document.getElementById('root')!).render(mode==='static'?:mode==='loop'?:mode==='failure'?:mode==='scene'?:
); diff --git a/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css b/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css new file mode 100644 index 0000000..38dd0f6 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css @@ -0,0 +1,11 @@ +.validation-scene .ss-stage{background:#f0efed;overflow:hidden} +.validation-window{position:absolute;left:28px;top:28px;width:600px;height:340px} +.validation-content{width:440px;padding:24px;box-sizing:border-box} +.validation-content h2{font:600 20px system-ui;margin:0 0 12px;color:#242424} +.validation-content p{font:13px system-ui;margin:0 0 24px;color:#666} +.validation-track{height:8px;background:#e8e7e5;border-radius:4px;margin-bottom:24px;overflow:hidden} +.validation-progress{height:8px;background:#20865d;transform-origin:left;transition:transform .6s ease} +.validation-save{height:40px;width:100%;border:0;border-radius:8px;background:#2872df;color:white;display:grid;place-items:center;font:600 13px system-ui;position:relative} +.validation-save span{grid-area:1/1;transition:opacity .3s ease} +.validation-card{position:absolute;left:24px;top:24px;width:310px;height:300px;background:white;border-radius:12px} +.validation-card .validation-content{width:100%} diff --git a/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml b/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml new file mode 100644 index 0000000..7fe2288 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml @@ -0,0 +1,3 @@ + + +/home/maroun.guest/validation/sysroot/usr/share/fonts/home/maroun.guest/validation/font-cache diff --git a/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log b/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log new file mode 100644 index 0000000..be22b4f --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log @@ -0,0 +1,87 @@ + linux-vdso.so.1 (0x0000ffffaa65c000) + libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff9a840000) + libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffff9a810000) + libglib-2.0.so.0 => /lib/aarch64-linux-gnu/libglib-2.0.so.0 (0x0000ffff9a6b0000) + libgobject-2.0.so.0 => /lib/aarch64-linux-gnu/libgobject-2.0.so.0 (0x0000ffff9a630000) + libnss3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnss3.so (0x0000ffff9a4d0000) + libnssutil3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnssutil3.so (0x0000ffff9a480000) + libsmime3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libsmime3.so (0x0000ffff9a430000) + libnspr4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnspr4.so (0x0000ffff9a3d0000) + libdbus-1.so.3 => /lib/aarch64-linux-gnu/libdbus-1.so.3 (0x0000ffff9a350000) + libatk-1.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatk-1.0.so.0 (0x0000ffff9a300000) + libatk-bridge-2.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatk-bridge-2.0.so.0 (0x0000ffff9a2a0000) + libcups.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libcups.so.2 (0x0000ffff9a1e0000) + libgio-2.0.so.0 => /lib/aarch64-linux-gnu/libgio-2.0.so.0 (0x0000ffff99fd0000) + libexpat.so.1 => /lib/aarch64-linux-gnu/libexpat.so.1 (0x0000ffff99f80000) + libdrm.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libdrm.so.2 (0x0000ffff99f40000) + libxcb.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb.so.1 (0x0000ffff99ef0000) + libxkbcommon.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxkbcommon.so.0 (0x0000ffff99e80000) + libX11.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libX11.so.6 (0x0000ffff99d20000) + libXcomposite.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXcomposite.so.1 (0x0000ffff99d00000) + libXdamage.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXdamage.so.1 (0x0000ffff99cd0000) + libXext.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXext.so.6 (0x0000ffff99c90000) + libXfixes.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXfixes.so.3 (0x0000ffff99c60000) + libXrandr.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXrandr.so.2 (0x0000ffff99c40000) + libgbm.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libgbm.so.1 (0x0000ffff99c10000) + libpango-1.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libpango-1.0.so.0 (0x0000ffff99b80000) + libcairo.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libcairo.so.2 (0x0000ffff99a50000) + libudev.so.1 => /lib/aarch64-linux-gnu/libudev.so.1 (0x0000ffff99a00000) + libasound.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libasound.so.2 (0x0000ffff998e0000) + libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff99830000) + libatspi.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatspi.so.0 (0x0000ffff997d0000) + libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff99790000) + libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff995e0000) + /lib/ld-linux-aarch64.so.1 (0x0000ffffaa610000) + libpcre2-8.so.0 => /lib/aarch64-linux-gnu/libpcre2-8.so.0 (0x0000ffff99530000) + libffi.so.8 => /lib/aarch64-linux-gnu/libffi.so.8 (0x0000ffff99500000) + libplc4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libplc4.so (0x0000ffff994d0000) + libplds4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libplds4.so (0x0000ffff994a0000) + libsystemd.so.0 => /lib/aarch64-linux-gnu/libsystemd.so.0 (0x0000ffff993b0000) + libgssapi_krb5.so.2 => /lib/aarch64-linux-gnu/libgssapi_krb5.so.2 (0x0000ffff99340000) + libavahi-common.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libavahi-common.so.3 (0x0000ffff99310000) + libavahi-client.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libavahi-client.so.3 (0x0000ffff992e0000) + libgnutls.so.30 => /lib/aarch64-linux-gnu/libgnutls.so.30 (0x0000ffff99090000) + libz.so.1 => /lib/aarch64-linux-gnu/libz.so.1 (0x0000ffff99050000) + libgmodule-2.0.so.0 => /lib/aarch64-linux-gnu/libgmodule-2.0.so.0 (0x0000ffff99020000) + libmount.so.1 => /lib/aarch64-linux-gnu/libmount.so.1 (0x0000ffff98f90000) + libselinux.so.1 => /lib/aarch64-linux-gnu/libselinux.so.1 (0x0000ffff98f40000) + libXau.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXau.so.6 (0x0000ffff98f20000) + libXdmcp.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXdmcp.so.6 (0x0000ffff98f00000) + libXrender.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXrender.so.1 (0x0000ffff98ee0000) + libwayland-server.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libwayland-server.so.0 (0x0000ffff98ea0000) + libfribidi.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libfribidi.so.0 (0x0000ffff98e70000) + libthai.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libthai.so.0 (0x0000ffff98e50000) + libharfbuzz.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libharfbuzz.so.0 (0x0000ffff98d30000) + libpixman-1.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libpixman-1.so.0 (0x0000ffff98c70000) + libfontconfig.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libfontconfig.so.1 (0x0000ffff98c00000) + libfreetype.so.6 => /lib/aarch64-linux-gnu/libfreetype.so.6 (0x0000ffff98b20000) + libpng16.so.16 => /lib/aarch64-linux-gnu/libpng16.so.16 (0x0000ffff98ac0000) + libxcb-shm.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb-shm.so.0 (0x0000ffff98a90000) + libxcb-render.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb-render.so.0 (0x0000ffff98a60000) + libXi.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXi.so.6 (0x0000ffff98a30000) + libcap.so.2 => /lib/aarch64-linux-gnu/libcap.so.2 (0x0000ffff98a00000) + libgcrypt.so.20 => /lib/aarch64-linux-gnu/libgcrypt.so.20 (0x0000ffff98900000) + liblzma.so.5 => /lib/aarch64-linux-gnu/liblzma.so.5 (0x0000ffff988b0000) + libzstd.so.1 => /lib/aarch64-linux-gnu/libzstd.so.1 (0x0000ffff987f0000) + liblz4.so.1 => /lib/aarch64-linux-gnu/liblz4.so.1 (0x0000ffff987a0000) + libkrb5.so.3 => /lib/aarch64-linux-gnu/libkrb5.so.3 (0x0000ffff986b0000) + libk5crypto.so.3 => /lib/aarch64-linux-gnu/libk5crypto.so.3 (0x0000ffff98660000) + libcom_err.so.2 => /lib/aarch64-linux-gnu/libcom_err.so.2 (0x0000ffff98630000) + libkrb5support.so.0 => /lib/aarch64-linux-gnu/libkrb5support.so.0 (0x0000ffff98600000) + libp11-kit.so.0 => /lib/aarch64-linux-gnu/libp11-kit.so.0 (0x0000ffff984b0000) + libidn2.so.0 => /lib/aarch64-linux-gnu/libidn2.so.0 (0x0000ffff98460000) + libunistring.so.2 => /lib/aarch64-linux-gnu/libunistring.so.2 (0x0000ffff98290000) + libtasn1.so.6 => /lib/aarch64-linux-gnu/libtasn1.so.6 (0x0000ffff98250000) + libnettle.so.8 => /lib/aarch64-linux-gnu/libnettle.so.8 (0x0000ffff981e0000) + libhogweed.so.6 => /lib/aarch64-linux-gnu/libhogweed.so.6 (0x0000ffff98170000) + libgmp.so.10 => /lib/aarch64-linux-gnu/libgmp.so.10 (0x0000ffff980d0000) + libblkid.so.1 => /lib/aarch64-linux-gnu/libblkid.so.1 (0x0000ffff98050000) + libbsd.so.0 => /lib/aarch64-linux-gnu/libbsd.so.0 (0x0000ffff98010000) + libdatrie.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libdatrie.so.1 (0x0000ffff97fe0000) + libgraphite2.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libgraphite2.so.3 (0x0000ffff97fa0000) + libbrotlidec.so.1 => /lib/aarch64-linux-gnu/libbrotlidec.so.1 (0x0000ffff97f70000) + libgpg-error.so.0 => /lib/aarch64-linux-gnu/libgpg-error.so.0 (0x0000ffff97f20000) + libkeyutils.so.1 => /lib/aarch64-linux-gnu/libkeyutils.so.1 (0x0000ffff97ef0000) + libresolv.so.2 => /lib/aarch64-linux-gnu/libresolv.so.2 (0x0000ffff97ec0000) + libmd.so.0 => /lib/aarch64-linux-gnu/libmd.so.0 (0x0000ffff97e90000) + libbrotlicommon.so.1 => /lib/aarch64-linux-gnu/libbrotlicommon.so.1 (0x0000ffff97e50000) diff --git a/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt b/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt new file mode 100644 index 0000000..1f819df --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt @@ -0,0 +1,150 @@ +(sd-pam) +COMMAND +acpi_thermal_pm +agetty +blkcg_punt_bio +cpuhp/0 +cpuhp/1 +cpuhp/2 +cpuhp/3 +dbus-daemon +ext4-rsv-conver +hwrng +inet_frag_wq +ipv6_addrconf +irq/14-ACPI:Event +jbd2/vda1-8 +kauditd +kblockd +kcompactd0 +kdevtmpfs +khugepaged +khungtaskd +khvcd +kintegrityd +ksmd +ksoftirqd/0 +ksoftirqd/1 +ksoftirqd/2 +ksoftirqd/3 +kstrp +kswapd0 +kthreadd +kthrotld +kworker/0:0-events +kworker/0:0H-events_highpri +kworker/0:1-inet_frag_wq +kworker/0:1H-kblockd +kworker/0:2-mm_percpu_wq +kworker/1:0-events +kworker/1:0H-events_highpri +kworker/1:1-mm_percpu_wq +kworker/1:1H-kblockd +kworker/1:3-inet_frag_wq +kworker/2:0-events +kworker/2:0H-events_highpri +kworker/2:1-inet_frag_wq +kworker/2:1H-kblockd +kworker/2:2-mm_percpu_wq +kworker/3:0-events +kworker/3:0H-events_highpri +kworker/3:1-events +kworker/3:1H-kblockd +kworker/3:2-inet_frag_wq +kworker/u8:0-ext4-rsv-conversion +kworker/u8:2-events_unbound +kworker/u8:3-events_unbound +kworker/u9:0 +migration/0 +migration/1 +migration/2 +migration/3 +mld +mm_percpu_wq +netns +nvme-delete-wq +nvme-reset-wq +nvme-wq +oom_reaper +ps +rcu_gp +rcu_par_gp +rcu_sched +rcu_tasks_rude_kthread +rcu_tasks_trace_kthread +sh +slub_flushwq +sort +sshd +systemd +systemd-journal +systemd-logind +systemd-network +systemd-resolve +systemd-timesyn +systemd-udevd +tpm_dev_wq +unattended-upgr +uniq +writeback +authorized_keys +092ee4aa51f4f38df3c5ba35708ef288a533a6c9b7078fcb5b71897d6ac5e731 runtime/package-lock.json +48e9ee06dc76b378bf6f3b701f72a844a2db33f5056086cd5ab2642fa394d271 runtime/scripts/browser.mjs +354200847958a4573eeb0da9ba4e061fc4f2ec1eecebf3df9b91f05e747f08a3 runtime/scripts/film.mjs +fd0b74f8a95669cc79ff7635d05329413bf6859fa5db35cf8de3b270981f977c browsers/chromium-1140/chrome-linux/chrome +alsa-topology-conf=1.2.5.1-2 +libasound2-data=1.2.8-1 +libasound2=1.2.8-1+b1 +alsa-ucm-conf=1.2.8-1 +at-spi2-common=2.46.0-5 +libxau6=1:1.0.9-1 +libxdmcp6=1:1.1.2-3 +libxcb1=1.15-1 +libx11-data=2:1.8.4-2+deb12u2 +libx11-6=2:1.8.4-2+deb12u2 +libxext6=2:1.3.4-1+b1 +libxi6=2:1.8-1+b1 +libatspi2.0-0=2.46.0-5 +x11-common=1:7.7+23 +libxtst6=2:1.2.3-1.1 +dbus-user-session=1.14.10-1~deb12u1 +libdconf1=0.40.0-4 +dconf-service=0.40.0-4 +dconf-gsettings-backend=0.40.0-4 +gsettings-desktop-schemas=43.0-1 +at-spi2-core=2.46.0-5 +fonts-dejavu-core=2.37-6 +fonts-liberation=1:1.07.4-11 +fontconfig-config=2.14.1-4 +libfontconfig1=2.14.1-4 +fontconfig=2.14.1-4 +libatk1.0-0=2.46.0-5 +libatk-bridge2.0-0=2.46.0-5 +libavahi-common-data=0.8-10+deb12u1 +libavahi-common3=0.8-10+deb12u1 +libavahi-client3=0.8-10+deb12u1 +libpixman-1-0=0.42.2-1 +libxcb-render0=1.15-1 +libxcb-shm0=1.15-1 +libxrender1=1:0.9.10-1.1 +libcairo2=1.16.0-7 +libcups2=2.4.2-3+deb12u9 +libdatrie1=0.2.13-2+b1 +libdrm-common=2.4.114-1 +libdrm2=2.4.114-1+b1 +libfribidi0=1.0.8-2.1 +libwayland-server0=1.21.0-1 +libgbm1=22.3.6-1+deb12u2 +libgraphite2-3=1.3.14-1+deb12u1 +libharfbuzz0b=6.0.0+dfsg-3 +libnspr4=2:4.35-1 +libnss3=2:3.87.1-1+deb12u4 +libthai-data=0.1.29-1 +libthai0=0.1.29-1 +libpango-1.0-0=1.50.12+ds-1 +libxcomposite1=1:0.4.5-1 +libxdamage1=1:1.1.6-1 +libxfixes3=1:6.0.0-2 +xkb-data=2.35.1-1 +libxkbcommon0=1.5.0-1 +libxrandr2=2:1.5.2-2+b1 diff --git a/artifacts/validation/stageshow/local-2026-11-09/loop-video.log b/artifacts/validation/stageshow/local-2026-11-09/loop-video.log new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs b/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs new file mode 100644 index 0000000..0d6f941 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {launch} from './scripts/browser.mjs'; +const out=new URL('../outputs/',import.meta.url).pathname; +const browser=await launch(); +try{ + const context=await browser.newContext({viewport:{width:640,height:360},recordVideo:{dir:out,size:{width:640,height:360}}}); + const p=await context.newPage();const errors=[];p.on('pageerror',e=>errors.push(e.message)); + await p.goto(new URL('./acceptance.html?mode=scene',import.meta.url).href);const start=Date.now();const samples=[]; + for(let at=0;at<=7200;at+=600){ + await p.waitForTimeout(Math.max(0,start+at-Date.now())); + samples.push(await p.locator('.ss-stage').getAttribute('data-mark')); + } + await p.waitForTimeout(Math.max(0,start+8000-Date.now())); + const video=p.video();await context.close();await video.saveAs(out+'loop-ui.webm'); + assert.equal(errors.length,0);const marks=samples.filter((s,i)=>s&&s!==samples[i-1]); + assert(marks.filter(x=>x==='saved').length>=2);assert(marks.filter(x=>x==='reset').length>=2); + fs.writeFileSync(out+'loop-video.json',JSON.stringify({samples,marks,errors,targetDurationMs:8000,passed:true},null,2)); +}finally{await browser.close()} diff --git a/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log b/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log new file mode 100644 index 0000000..703c77c --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log @@ -0,0 +1,2 @@ + +added 75 packages in 2s diff --git a/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py b/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py new file mode 100644 index 0000000..51e7640 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Inherited unprivileged seccomp: only UNIX/netlink sockets, never IP sockets.""" +import ctypes +import errno +import os +import platform +import sys + +if platform.machine() != 'aarch64': + raise SystemExit('offline filter supports only reviewed Linux aarch64') + +class Filter(ctypes.Structure): + _fields_ = [('code', ctypes.c_ushort), ('jt', ctypes.c_ubyte), + ('jf', ctypes.c_ubyte), ('k', ctypes.c_uint)] + +class Program(ctypes.Structure): + _fields_ = [('len', ctypes.c_ushort), ('filter', ctypes.POINTER(Filter))] + +# Audit architecture check; socket syscall=198; domain is argument zero. +# Every newly created socket must be AF_UNIX or AF_NETLINK. No IP sockets are +# inherited from this fresh SSH command. NNP prevents exec privilege gains. +raw = [(0x20, 0, 0, 4), (0x15, 1, 0, 0xc00000b7), + (0x06, 0, 0, 0x80000000), (0x20, 0, 0, 0), + (0x15, 0, 1, 425), (0x06, 0, 0, 0x00050000 | errno.EPERM), + (0x15, 0, 5, 198), (0x20, 0, 0, 16), + (0x15, 3, 0, 1), (0x15, 2, 0, 16), + (0x06, 0, 0, 0x00050000 | errno.EPERM), + (0x06, 0, 0, 0x80000000), (0x06, 0, 0, 0x7fff0000)] +filters = (Filter * len(raw))(*(Filter(*v) for v in raw)) +program = Program(len(raw), filters) +libc = ctypes.CDLL(None, use_errno=True) +# No inherited IP socket may bypass the socket-creation rule. SSH gives this +# command only pipes for stdio; close every other inherited descriptor. +for fd in range(3): + if os.readlink('/proc/self/fd/' + str(fd)).startswith('socket:'): + raise SystemExit('refusing inherited socket on stdio') +os.closerange(3, 65536) +for args in [(38, 1, 0, 0, 0), (22, 2, ctypes.byref(program), 0, 0)]: + if libc.prctl(*args): + raise OSError(ctypes.get_errno(), 'offline seccomp enforcement failed') +os.execvp(sys.argv[1], sys.argv[1:]) diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log new file mode 100644 index 0000000..70135ef --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log @@ -0,0 +1,9 @@ +16 frames every 700ms -> /home/maroun.guest/validation/outputs/animated.png +coverage stage-1: 674 frames, 11.63s, largest gap 170.3ms (inconclusive) + incomplete: 10 sampling gap(s) exceeded 27ms; motion during those gaps was not judged +[stage-1] lopsided: span.fe-button "Create accountCreati" has 106px on the left and 112px on the right (8 frames from 5.6s) +[stage-1] jump: ss-field__chars "m" width +8.0px in one frame at 1.04s [type], 12 times +[stage-1] jump: ss-field__text width -22.0px in one frame at 3.80s [fix] +[stage-1] appears with no entrance: svg.ss-field__ok shows at full size and opacity in one frame; fade it in, or grow it out of what it comes from (a chip budding off a value scales from it as it fades in: Fly with held), never pop it in whole +film: inconclusive; watch the motion and inspect every image before approval +rehearsal not passed: inconclusive diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json new file mode 100644 index 0000000..194f021 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json @@ -0,0 +1,110 @@ +{ + "browser": "130.0.6723.31", + "sandbox": { + "renderers": [ + { + "pid": "1323", + "command": [ + "/home/maroun.guest/validation/browsers/chromium-1140/chrome-linux/chrome --type=renderer --headless=old --disable-dev-shm-usage --disable-back-forward-cache --disable-background-timer-throttling --disable-breakpad --enable-automation --force-color-profile=srgb --remote-debugging-pipe --allow-pre-commit-input --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --ozone-platform=headless --disable-databases --lang=en-US --num-raster-threads=2 --enable-main-frame-before-activation --renderer-client-id=4 --time-ticks-at-unix-epoch=-1794251841367838 --launch-time-ticks=291130229 --shared-files", + "" + ], + "status": "Name:\tchrome\nUmask:\t0022\nState:\tS (sleeping)\nTgid:\t1323\nNgid:\t0\nPid:\t1323\nPPid:\t1289\nTracerPid:\t0\nUid:\t502\t502\t502\t502\nGid:\t1000\t1000\t1000\t1000\nFDSize:\t128\nGroups:\t1000 \nNStgid:\t1323\t4\t1\nNSpid:\t1323\t4\t1\nNSpgid:\t1283\t0\t0\nNSsid:\t1283\t0\t0\nVmPeak:\t1212526620 kB\nVmSize:\t1211691176 kB\nVmLck:\t 0 kB\nVmPin:\t 0 kB\nVmHWM:\t 95732 kB\nVmRSS:\t 95732 kB\nRssAnon:\t 17572 kB\nRssFile:\t 78160 kB\nRssShmem:\t 0 kB\nVmData:\t 341044 kB\nVmStk:\t 268 kB\nVmExe:\t 175088 kB\nVmLib:\t 30076 kB\nVmPTE:\t 668 kB\nVmSwap:\t 0 kB\nHugetlbPages:\t 0 kB\nCoreDumping:\t0\nTHP_enabled:\t1\nThreads:\t9\nSigQ:\t0/15389\nSigPnd:\t0000000000000000\nShdPnd:\t0000000000000000\nSigBlk:\t0000000000000000\nSigIgn:\t0000000000001002\nSigCgt:\t0000000140014eed\nCapInh:\t0000000000000000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\nCapBnd:\t000001ffffffffff\nCapAmb:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\nSeccomp_filters:\t2\nSpeculation_Store_Bypass:\tvulnerable\nSpeculationIndirectBranch:\tunknown\nCpus_allowed:\tf\nCpus_allowed_list:\t0-3\nMems_allowed:\t0001\nMems_allowed_list:\t0\nvoluntary_ctxt_switches:\t18\nnonvoluntary_ctxt_switches:\t25\n" + } + ], + "verified": "renderer PID namespace + additional Chromium seccomp filter + NNP, no sandbox-disabling flags" + }, + "network": [ + { + "host": "1.1.1.1", + "error": "EPERM" + }, + { + "host": "::1", + "error": "EPERM" + } + ], + "pages": [ + { + "at": 239, + "state": "app.northwind.co/signup\nCreate your account\nWork email\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created" + }, + { + "at": 3005, + "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created" + }, + { + "at": 7009, + "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind.co\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created" + }, + { + "at": 11006, + "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind.co\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created" + }, + { + "reduced": true, + "width": 640, + "layout": "wide", + "state": "true", + "stable": true + }, + { + "reduced": true, + "width": 390, + "layout": "compact", + "state": "true", + "stable": true + } + ], + "durationTargetMs": 12000, + "commandLine": [ + "/home/maroun.guest/validation/browsers/chromium-1140/chrome-linux/chrome", + "--disable-field-trial-config", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-back-forward-cache", + "--disable-breakpad", + "--disable-client-side-phishing-detection", + "--disable-component-extensions-with-background-pages", + "--disable-component-update", + "--no-default-browser-check", + "--disable-default-apps", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker", + "--allow-pre-commit-input", + "--disable-hang-monitor", + "--disable-ipc-flooding-protection", + "--disable-popup-blocking", + "--disable-prompt-on-repost", + "--disable-renderer-backgrounding", + "--force-color-profile=srgb", + "--metrics-recording-only", + "--no-first-run", + "--enable-automation", + "--password-store=basic", + "--use-mock-keychain", + "--no-service-autorun", + "--export-tagged-pdf", + "--disable-search-engine-choice-screen", + "--unsafely-disable-devtools-self-xss-warnings", + "--headless=old", + "--hide-scrollbars", + "--mute-audio", + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4", + "--user-data-dir=/tmp/playwright_chromiumdev_profile-VHJKHY", + "--remote-debugging-pipe", + "--no-startup-window", + "--use-gl=angle", + "--use-angle=swiftshader-webgl", + "--ozone-platform=headless", + "--allow-pre-commit-input" + ], + "recordedWallMs": 12018, + "resources": [ + "file:///home/maroun.guest/validation/runtime/acceptance.html" + ], + "pageErrors": [], + "passed": true, + "browserClosed": true +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json new file mode 100644 index 0000000..38e86c0 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json @@ -0,0 +1,11 @@ +{ + "payloads": [ + "open", + " & 'single' \"double\"", + " & 'single' \"double\" +0.7s", + "end 2.20s" + ], + "injected": 0, + "executed": 0, + "escaped": true +} \ No newline at end of file diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log new file mode 100644 index 0000000..a9a7844 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log @@ -0,0 +1,7 @@ +still -> /home/maroun.guest/validation/outputs/compact.png +coverage stage-1: 1 frames, 0.00s, largest gap 0.0ms (complete) +[stage-1] lopsided: span.fe-button "Create accountCreati" has 67px on the left and 73px on the right (still) +[stage-1] framing: div.ss-window ends 16px short of the right edge; give it a full gutter (28px) or run it well past the edge so it is clearly masked (still) +[stage-1] framing: div.ss-window shows 326x392, narrower than tall for a desktop window; change the crop (bleed it) or the layout rather than squash the app (still) +film: issues; still capture only, motion was not judged +rehearsal failed: 2 never-intentional line(s) above diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png new file mode 100644 index 0000000..499b7c1 Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png new file mode 100644 index 0000000..87b496b Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png new file mode 100644 index 0000000..8096c18 Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json new file mode 100644 index 0000000..ebf5938 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json @@ -0,0 +1,35 @@ +{ + "status": "issues", + "mode": "motion", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit", + "coverage": { + "status": "complete", + "frames": 181, + "durationMs": 2999.29999999993, + "medianMs": 16.599999999976717, + "maxGapMs": 21.20000000006985, + "skippedGaps": 0, + "cameraFrames": 0, + "reasons": [], + "framingSamples": 4, + "layout": { + "controls": 0, + "paddedContainers": 0, + "compositionCrops": 0 + } + }, + "findings": [ + "unclipped: span runs 120px past the stage's right edge and nothing clips it; give the stage overflow: hidden so the crop is yours (4 frames from open)", + "framing: target span \"bad\" is cut by the crop; what the story touches stays whole in frame (4 frames from open)", + "framing: real words span \"Clipped action\" are cut by the crop; only filler bleeds, so pull them inside or make them a placeholder (4 frames from open)" + ], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": true, + "exitCode": 1 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json new file mode 100644 index 0000000..1ffb04b --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json @@ -0,0 +1,22 @@ +[ + { + "case": "representative-still", + "exitCode": 0, + "expectedFailure": false + }, + { + "case": "representative-compact", + "exitCode": 0, + "expectedFailure": false + }, + { + "case": "representative-loop", + "exitCode": 2, + "expectedFailure": false + }, + { + "case": "failure", + "exitCode": 1, + "expectedFailure": true + } +] diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json new file mode 100644 index 0000000..372d373 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json @@ -0,0 +1,20 @@ +{ + "programs": [ + + ], + "stream_groups": [ + + ], + "streams": [ + { + "codec_name": "h264", + "width": 640, + "height": 360, + "r_frame_rate": "25/1", + "nb_read_frames": "200" + } + ], + "format": { + "duration": "8.000000" + } +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4 b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4 new file mode 100644 index 0000000..14b4d0a Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4 differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json new file mode 100644 index 0000000..8afd1b2 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json @@ -0,0 +1,27 @@ +{ + "samples": [ + null, + null, + "saved", + "saved", + "saved", + "reset", + "reset", + "saved", + "saved", + "saved", + "reset", + "reset", + "saved" + ], + "marks": [ + "saved", + "reset", + "saved", + "reset", + "saved" + ], + "errors": [], + "targetDurationMs": 8000, + "passed": true +} \ No newline at end of file diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log new file mode 100644 index 0000000..e24656c --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log @@ -0,0 +1,7 @@ +24 frames every 600ms -> /home/maroun.guest/validation/outputs/loop.png +coverage stage-1: 869 frames, 14.65s, largest gap 54.6ms (inconclusive) + incomplete: 33 sampling gap(s) exceeded 27ms; motion during those gaps was not judged +[stage-1] framing: div.ss-window ends 16px short of the right edge; give it a full gutter (40px) or run it well past the edge so it is clearly masked (24 frames from 0.0s) +[stage-1] framing: div.ss-window shows 326x360, narrower than tall for a desktop window; change the crop (bleed it) or the layout rather than squash the app (24 frames from 0.0s) +film: inconclusive; watch the motion and inspect every image before approval +rehearsal failed: 2 never-intentional line(s) above diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json new file mode 100644 index 0000000..a372c38 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json @@ -0,0 +1,30 @@ +{ + "status": "issues", + "mode": "still", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit validation-scene", + "coverage": { + "status": "complete", + "frames": 1, + "durationMs": 0, + "maxGapMs": 0, + "reasons": [], + "framingSamples": 1, + "layout": { + "controls": 1, + "paddedContainers": 1, + "compositionCrops": 0 + } + }, + "findings": [ + "unclipped: div.validation-card runs 44px past the stage's bottom edge and nothing clips it; give the stage overflow: hidden so the crop is yours (still)" + ], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": false, + "exitCode": 0 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json new file mode 100644 index 0000000..e90d69e --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json @@ -0,0 +1,28 @@ +{ + "status": "no-issues-detected", + "mode": "still", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit validation-scene", + "coverage": { + "status": "complete", + "frames": 1, + "durationMs": 0, + "maxGapMs": 0, + "reasons": [], + "framingSamples": 1, + "layout": { + "controls": 1, + "paddedContainers": 1, + "compositionCrops": 0 + } + }, + "findings": [], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": false, + "exitCode": 0 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png new file mode 100644 index 0000000..e7400f8 Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json new file mode 100644 index 0000000..f69044f --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json @@ -0,0 +1,31 @@ +{ + "status": "no-issues-detected", + "mode": "motion", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit validation-scene", + "coverage": { + "status": "complete", + "frames": 468, + "durationMs": 7779, + "medianMs": 16.699999999953434, + "maxGapMs": 23.400000000023283, + "skippedGaps": 0, + "cameraFrames": 0, + "reasons": [], + "framingSamples": 8, + "layout": { + "controls": 1, + "paddedContainers": 1, + "compositionCrops": 0 + } + }, + "findings": [], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": true, + "exitCode": 0 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png new file mode 100644 index 0000000..57cca89 Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json new file mode 100644 index 0000000..d3c7681 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json @@ -0,0 +1,30 @@ +{ + "status": "issues", + "mode": "still", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit validation-scene", + "coverage": { + "status": "complete", + "frames": 1, + "durationMs": 0, + "maxGapMs": 0, + "reasons": [], + "framingSamples": 1, + "layout": { + "controls": 1, + "paddedContainers": 1, + "compositionCrops": 0 + } + }, + "findings": [ + "unclipped: div.ss-window runs 68px past the stage's right edge and nothing clips it; give the stage overflow: hidden so the crop is yours (still)" + ], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": false, + "exitCode": 0 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json new file mode 100644 index 0000000..e90d69e --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json @@ -0,0 +1,28 @@ +{ + "status": "no-issues-detected", + "mode": "still", + "stages": [ + { + "key": "stage-1", + "label": "ss-fit validation-scene", + "coverage": { + "status": "complete", + "frames": 1, + "durationMs": 0, + "maxGapMs": 0, + "reasons": [], + "framingSamples": 1, + "layout": { + "controls": 1, + "paddedContainers": 1, + "compositionCrops": 0 + } + }, + "findings": [], + "manual": [] + } + ], + "errors": [], + "manualReviewRequired": false, + "exitCode": 0 +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png new file mode 100644 index 0000000..abba5b9 Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4 b/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4 new file mode 100644 index 0000000..f7d16fe Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4 differ diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json new file mode 100644 index 0000000..a302419 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json @@ -0,0 +1,20 @@ +{ + "programs": [ + + ], + "stream_groups": [ + + ], + "streams": [ + { + "codec_name": "h264", + "width": 640, + "height": 480, + "r_frame_rate": "25/1", + "nb_read_frames": "300" + } + ], + "format": { + "duration": "12.000000" + } +} diff --git a/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json b/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json new file mode 100644 index 0000000..4640478 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json @@ -0,0 +1,20 @@ +[ + { + "path": "artifacts/validation/stageshow/local-2026-11-09/vm-final.json", + "original_sha256": "8fe9cb0b5a9f65ac1cf7ce55b04ba1920fc7ea94f3253628b20f8969d99b7dc4", + "published_sha256": "24a1e4627b52300169ca453dcc52a3f4af642122e60899f9785384470793d854", + "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit" + }, + { + "path": "artifacts/validation/stageshow/local-2026-11-09/vm-start.log", + "original_sha256": "635ae64e0af5a36068995bc340ba823c176f9c30617bd851910a01ef1021b3d8", + "published_sha256": "50877a796aff049d2323b2e451bc4302fe8797b28b067dfd7186057e1bad0c2c", + "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit" + }, + { + "path": "artifacts/validation/stageshow/local-2026-11-09/vm-restart.log", + "original_sha256": "0f754d89c3fa1eae5597454cc04d8df6c31dc87f357c785a3b4546bc345ec6f7", + "published_sha256": "e0234777cc5e45e64370d2142404216db4d100a57b7acf8812ccf4b319304920", + "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit" + } +] diff --git a/artifacts/validation/stageshow/local-2026-11-09/run-films.py b/artifacts/validation/stageshow/local-2026-11-09/run-films.py new file mode 100644 index 0000000..038047d --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/run-films.py @@ -0,0 +1,39 @@ +"""Run exact retained film helpers, including deliberate gate and caption probes.""" +import json +import os +from pathlib import Path +import subprocess + +base = Path(__file__).resolve().parent +runtime = base / 'runtime' +out = base / 'outputs' +out.mkdir(exist_ok=True) +url = (runtime / 'acceptance.html').as_uri() +cases = [ + ('representative-still', '?mode=scene', ['--still', '--gate']), + ('representative-compact', '?mode=scene', ['--still', '--reduced', '--width', '390', '--gate']), + ('representative-loop', '?mode=scene', ['--every', '1000', '--count', '8', '--gate']), + ('failure', '?mode=failure', ['--states', '--states-for', '2200', '--every', '300', '--gate']), +] +results = [] +for name, query, args in cases: + env = dict(os.environ) + if name == 'failure': + env['NODE_OPTIONS'] = '--import=' + str(runtime / 'audit-hooks.mjs') + env['CAPTION_AUDIT'] = str(out / 'caption-audit.json') + command = ['node', 'scripts/film.mjs', url + query, '--out', str(out / (name + '.png')), + '--report', str(out / (name + '.json')), *args] + result = subprocess.run(command, cwd=runtime, env=env, text=True, capture_output=True, timeout=90) + (out / (name + '.log')).write_text(result.stdout + result.stderr) + results.append({'case': name, 'exitCode': result.returncode, 'expectedFailure': name == 'failure'}) + print(json.dumps(results[-1]), flush=True) +(out / 'film-results.json').write_text(json.dumps(results, indent=2) + '\n') +caption = json.loads((out / 'caption-audit.json').read_text()) +assert caption['escaped'] and caption['injected'] == 0 and caption['executed'] == 0 +assert any(' /home/maroun.guest/validation/outputs/representative-still.png +coverage stage-1: 1 frames, 0.00s, largest gap 0.0ms (complete) +film: no-issues-detected; still capture only, motion was not judged +rehearsal passed (necessary, not sufficient: references/verify.md and the fresh eye in review.md still stand) diff --git a/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log b/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log new file mode 100644 index 0000000..1f90159 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log @@ -0,0 +1,3 @@ + +> stageshow@0.1.0 typecheck +> tsc --noEmit -p playground diff --git a/artifacts/validation/stageshow/local-2026-11-09/typecheck.log b/artifacts/validation/stageshow/local-2026-11-09/typecheck.log new file mode 100644 index 0000000..1f90159 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/typecheck.log @@ -0,0 +1,3 @@ + +> stageshow@0.1.0 typecheck +> tsc --noEmit -p playground diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-final.json b/artifacts/validation/stageshow/local-2026-11-09/vm-final.json new file mode 100644 index 0000000..477bb9f --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/vm-final.json @@ -0,0 +1 @@ +{"name":"stack-stageshow-20261109","hostname":"lima-stack-stageshow-20261109","status":"Stopped","dir":"$HOME/.lima/stack-stageshow-20261109","vmType":"vz","arch":"aarch64","cpus":4,"memory":4294967296,"disk":12884901888,"sshLocalPort":51575,"sshConfigFile":"$HOME/.lima/stack-stageshow-20261109/ssh.config","config":{"vmType":"vz","os":"Linux","arch":"aarch64","images":[{"location":"https://cloud.debian.org/images/cloud/bookworm/20260712-2537/debian-12-genericcloud-arm64-20260712-2537.qcow2","arch":"aarch64","digest":"sha512:52eb678130e85a9bf9f3cf4f7181e060fa60a5db45373717474c5ff9305645c5e6cab8b05ca042bcfa7921d2ca29cf9fd2e815f75abe2d168557ad7207af127b"}],"cpus":4,"memory":"4GiB","disk":"12GiB","mountInotify":false,"ssh":{"localPort":0,"loadDotSSHPubKeys":false,"forwardAgent":false,"forwardX11":false,"forwardX11Trusted":false},"firmware":{"legacyBIOS":false},"audio":{"device":"","interface":""},"video":{"display":"none"},"upgradePackages":false,"containerd":{"system":false,"user":false,"archives":[{"location":"https://github.com/containerd/nerdctl/releases/download/v2.3.5/nerdctl-full-2.3.5-linux-amd64.tar.gz","arch":"x86_64","digest":"sha256:b697295c623639734aaab737523c808fd3cc8d3046039fd94fff1744e4c317aa"},{"location":"https://github.com/containerd/nerdctl/releases/download/v2.3.5/nerdctl-full-2.3.5-linux-arm64.tar.gz","arch":"aarch64","digest":"sha256:6e4b687f1d138e750a3c8372abc0f81d3d7490b6359c48c0562fc7dfe98859b2"}]},"guestInstallPrefix":"/usr/local","hostResolver":{"enabled":false,"ipv6":false},"propagateProxyEnv":false,"caCerts":{"removeDefaults":false},"plain":true,"timezone":"","nestedVirtualization":false,"user":{"name":"maroun","comment":"Maroun","home":"/home/maroun.guest","shell":"/bin/bash","uid":502,"passwordlessSudo":true},"tpm":false},"sshAddress":"127.0.0.1","protected":false,"limaVersion":"2.2.0","HostOS":"darwin","HostArch":"aarch64","LimaHome":"$HOME/.lima","IdentityFile":"$HOME/.lima/_config/user"} diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log b/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log new file mode 100644 index 0000000..5ae59dd --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log @@ -0,0 +1,15 @@ +time="2026-11-09T11:30:00-08:00" level=info msg="Using the existing instance `stack-stageshow-20261109`" +time="2026-11-09T11:30:00-08:00" level=info msg="Starting the instance `stack-stageshow-20261109` with internal VM driver `vz`" +time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] hostagent socket created at $HOME/.lima/stack-stageshow-20261109/ha.sock" +time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] Starting VZ (hint: to watch the boot progress, see `$HOME/.lima/stack-stageshow-20261109/serial*.log`)" +time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] [VZ] - vm state change: running" +time="2026-11-09T11:30:06-08:00" level=info msg="[hostagent] SSH server does not seem to be running on vsock port, using usernet forwarder" +time="2026-11-09T11:30:07-08:00" level=info msg="SSH Local Port: 49747" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running." +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the essential requirement 1 of 2: `ssh`" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The essential requirement 1 of 2 is satisfied" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the essential requirement 2 of 2: `Explicitly start ssh ControlMaster`" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The essential requirement 2 of 2 is satisfied" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the final requirement 1 of 1: `boot scripts must have finished`" +time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The final requirement 1 of 1 is satisfied" +time="2026-11-09T11:30:07-08:00" level=info msg="READY. Run `ssh -F `$HOME/.lima/stack-stageshow-20261109/ssh.config` lima-stack-stageshow-20261109` to open the shell." diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-start.log b/artifacts/validation/stageshow/local-2026-11-09/vm-start.log new file mode 100644 index 0000000..066a860 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/vm-start.log @@ -0,0 +1,15 @@ +time="2026-11-09T11:17:19-08:00" level=info msg="Using the existing instance `stack-stageshow-20261109`" +time="2026-11-09T11:17:19-08:00" level=info msg="Starting the instance `stack-stageshow-20261109` with internal VM driver `vz`" +time="2026-11-09T11:17:19-08:00" level=info msg="[hostagent] hostagent socket created at $HOME/.lima/stack-stageshow-20261109/ha.sock" +time="2026-11-09T11:17:19-08:00" level=info msg="[hostagent] Starting VZ (hint: to watch the boot progress, see `$HOME/.lima/stack-stageshow-20261109/serial*.log`)" +time="2026-11-09T11:17:20-08:00" level=info msg="[hostagent] [VZ] - vm state change: running" +time="2026-11-09T11:17:26-08:00" level=info msg="[hostagent] SSH server does not seem to be running on vsock port, using usernet forwarder" +time="2026-11-09T11:17:27-08:00" level=info msg="SSH Local Port: 59973" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running." +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the essential requirement 1 of 2: `ssh`" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The essential requirement 1 of 2 is satisfied" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the essential requirement 2 of 2: `Explicitly start ssh ControlMaster`" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The essential requirement 2 of 2 is satisfied" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the final requirement 1 of 1: `boot scripts must have finished`" +time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The final requirement 1 of 1 is satisfied" +time="2026-11-09T11:17:27-08:00" level=info msg="READY. Run `ssh -F `$HOME/.lima/stack-stageshow-20261109/ssh.config` lima-stack-stageshow-20261109` to open the shell." diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log b/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log new file mode 100644 index 0000000..4939fa3 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log @@ -0,0 +1,9 @@ +time="2026-11-09T11:30:21-08:00" level=info msg="Sending SIGINT to hostagent process 52513" +time="2026-11-09T11:30:21-08:00" level=info msg="Waiting for the host agent and the driver processes to shut down" +time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Received SIGINT, shutting down the host agent" +time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Shutting down the host agent" +time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Shutting down VZ" +time="2026-11-09T11:30:24-08:00" level=info msg="[hostagent] [VZ] - vm state change: stopped" +time="2026-11-09T11:30:24-08:00" level=error msg="[hostagent] accept tcp 127.0.0.1:49747: use of closed network connection" +time="2026-11-09T11:30:24-08:00" level=info msg="Waiting for the instance to shut down" +time="2026-11-09T11:30:25-08:00" level=info msg="The instance stack-stageshow-20261109 has shut down" diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm.yaml b/artifacts/validation/stageshow/local-2026-11-09/vm.yaml new file mode 100644 index 0000000..c606531 --- /dev/null +++ b/artifacts/validation/stageshow/local-2026-11-09/vm.yaml @@ -0,0 +1,22 @@ +vmType: vz +arch: aarch64 +cpus: 4 +memory: 4GiB +disk: 12GiB +images: +- location: https://cloud.debian.org/images/cloud/bookworm/20260712-2537/debian-12-genericcloud-arm64-20260712-2537.qcow2 + arch: aarch64 + digest: sha512:52eb678130e85a9bf9f3cf4f7181e060fa60a5db45373717474c5ff9305645c5e6cab8b05ca042bcfa7921d2ca29cf9fd2e815f75abe2d168557ad7207af127b +plain: true +mounts: [] +containerd: + system: false + user: false +ssh: + loadDotSSHPubKeys: false + forwardAgent: false + forwardX11: false +propagateProxyEnv: false +hostResolver: + enabled: false +portForwards: [] diff --git a/config/runtime-targets.json b/config/runtime-targets.json index 8126a7a..6bdeeba 100644 --- a/config/runtime-targets.json +++ b/config/runtime-targets.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "catalog_digest": "aa9bf5a311f1ada9ee219cc5d40c93b1928808a04d40ed97e0d3654a9fe1762a", + "catalog_digest": "f5aea9cb9218e379ee2b5c03a9b0967dd7701c840e5a5cdd48bddfe7c6ec9a8a", "targets": [ { "name": "claude", diff --git a/registry/capabilities.json b/registry/capabilities.json index 8d106c0..009a997 100644 --- a/registry/capabilities.json +++ b/registry/capabilities.json @@ -9234,6 +9234,77 @@ "validation_class": "high-risk", "visibility": "extended" }, + { + "artifact_type": "skill", + "audit_status": "reviewed", + "canonical_name": "stageshow", + "commands": [ + "stack.design" + ], + "context": { + "inputs": [ + "request", + "project-design-contract", + "permitted-content" + ], + "outputs": [ + "product-illustration-source", + "verification-limitations" + ] + }, + "disposition": { + "evidence_gap": null, + "next_review_trigger": "Revalidate on helper, dependency/browser, safety contract or runtime export changes; rehearse each project copy.", + "status": "keep" + }, + "domain": "design", + "family": "design", + "lifecycle": "active", + "overlaps": [ + "emil-design-eng", + "review-animations", + "design-intelligence" + ], + "ownership": { + "package": "imported-skills", + "provider": "benjitaylor", + "source_path": "skills/imported/benjitaylor/stageshow/SKILL.md" + }, + "provenance": { + "license": "MIT; retained artwork has separate MIT/Apache-2.0/CC0 notices; excluded artwork and trademark rights are not covered", + "posture": "imported-upstream", + "source_identity": "github:benjitaylor/skills:stageshow@802057409787133703c56001080169e347305572" + }, + "purpose": "Create authored still or animated product UI illustrations and cursor stories with the pinned Stageshow React kit and permitted references.", + "role": "workflow", + "runtimes": { + "publish_targets": [ + "claude", + "codex" + ], + "supported": [ + "claude", + "codex" + ] + }, + "schema_version": 1, + "source": { + "skill_path": "skills/imported/benjitaylor/stageshow/SKILL.md" + }, + "trust_class": "local-mutation", + "validation": { + "evidence": [ + "skills/imported/benjitaylor/stageshow/STACK-REVIEW.md", + "skills/imported/benjitaylor/stageshow/source.json", + "tests/test_stageshow_intake.py", + "tests/test_stageshow_browser_launcher.py", + "artifacts/validation/stageshow/2026-11-09-accepted.json" + ], + "status": "validated" + }, + "validation_class": "representative-route", + "visibility": "extended" + }, { "alias_of": "cdo-taste-skill", "artifact_type": "skill", @@ -9996,7 +10067,7 @@ "catalog_kind": "stack-capability-catalog", "schema_version": 1, "summary": { - "callable_entrypoint_count": 158, - "capability_count": 158 + "callable_entrypoint_count": 159, + "capability_count": 159 } } diff --git a/registry/migrations/2026-07-23-skill-layout.json b/registry/migrations/2026-07-23-skill-layout.json index 0702174..f1c99eb 100644 --- a/registry/migrations/2026-07-23-skill-layout.json +++ b/registry/migrations/2026-07-23-skill-layout.json @@ -731,6 +731,11 @@ "destination": "skills/delivery/stack-refresh-pr-mode/SKILL.md", "source": "skills/stack-refresh-pr-mode/SKILL.md" }, + { + "canonical_name": "stageshow", + "destination": "skills/imported/benjitaylor/stageshow/SKILL.md", + "source": "skills/imported/benjitaylor/stageshow/SKILL.md" + }, { "canonical_name": "taste-skill-suite-taste-skill", "destination": "skills/imported/taste-skill-suite/taste-skill/SKILL.md", diff --git a/registry/routing-rules.json b/registry/routing-rules.json index df2c9f3..b5b16ff 100644 --- a/registry/routing-rules.json +++ b/registry/routing-rules.json @@ -6,7 +6,7 @@ "rules": [ {"command": "stack.explore", "intents": ["explore", "idea", "strategy", "research"]}, {"command": "stack.plan", "intents": ["plan", "brainstorm", "specification"]}, - {"command": "stack.design", "intents": ["design", "ui", "motion", "critique"]}, + {"command": "stack.design", "intents": ["design", "ui", "motion", "critique", "stageshow", "showcase video", "showcase work", "showcase our work", "showcase my work", "product demo", "ui walkthrough", "demo video"]}, {"command": "stack.design.intelligence", "subcommand": "collect", "intents": ["collect design inspiration", "collect design intelligence", "collect bookmarks"]}, {"command": "stack.design.intelligence", "subcommand": "digest", "intents": ["digest design intelligence", "weekly design digest", "summarize design inspiration"]}, {"command": "stack.design.intelligence", "subcommand": "retrieve", "intents": ["retrieve inspiration", "relevant inspiration", "show me relevant inspiration", "inspiration for this screen", "task context retrieval"], "requires_context": ["task-context"]}, diff --git a/skills/design/cdo/SKILL.md b/skills/design/cdo/SKILL.md index 1d72bd4..e3f20c8 100644 --- a/skills/design/cdo/SKILL.md +++ b/skills/design/cdo/SKILL.md @@ -15,6 +15,11 @@ Enrich plan files with design specifications for compound-engineering execution. --- +For product demos, UI walkthroughs and videos showcasing product work, consider +Stageshow automatically through the runtime skill catalog. Read the showcase +video route in `instructions/skill-routing.md`; actual video needs recording, +decoding and visual inspection beyond PNG filming. + ## Input CDO reads a **plan file** from CPO: diff --git a/skills/design/cdo/instructions/skill-routing.md b/skills/design/cdo/instructions/skill-routing.md index a3f1e08..d17c899 100644 --- a/skills/design/cdo/instructions/skill-routing.md +++ b/skills/design/cdo/instructions/skill-routing.md @@ -68,3 +68,21 @@ Run after EVERY code generation: 6. **FAVICON** — For new projects: generate complete favicon set **Quality gate:** "Would a staff engineer approve this?" — if not, iterate. + +## Showcase videos + +For videos that showcase product or UI work, consider Stageshow before authoring +the scenes from scratch. Adapt its scene composition, cursor choreography and +motion to the project's actual design and the permitted content. + +Check its catalog lifecycle and validation status before using its runtime. +When it is active and validated, load it through the runtime's skill catalog. +When it is a held candidate, use only the source guidance permitted by its +integration contract and report the missing video validation. When it is absent, +report that gap. A showcase-video request does not lift an execution hold or +authorize installation or publication. + +Stageshow's current filming helper exports PNG stills and contact sheets. Actual +video recording and encoding are separate delivery steps. Deliver a requested +video only after exporting and inspecting the clip; a build, still or contact +sheet does not establish that the video works. diff --git a/skills/imported/benjitaylor/stageshow/LICENSE b/skills/imported/benjitaylor/stageshow/LICENSE new file mode 100644 index 0000000..5c9588b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benji Taylor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/imported/benjitaylor/stageshow/NOTICE.md b/skills/imported/benjitaylor/stageshow/NOTICE.md new file mode 100644 index 0000000..23205e7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/NOTICE.md @@ -0,0 +1,26 @@ +# Notices + +Stageshow source is MIT licensed; see `LICENSE`. + +Product and operating-system names, familiar device geometry, app icons, and +illustrated interfaces are used as examples. Those names and marks remain +with their owners; the Stageshow license does not grant trademark rights or +imply endorsement. Adapt examples to the product you are illustrating. + +The Radix-inspired look uses publicly documented color and spacing values; +its source is identified in `examples/looks/radix.css`. The app icons in `assets/app-icons.ts` are artwork from their respective app +bundles, as identified in that file; they are not covered by the Stageshow MIT +license. They remain illustrative references to those products. The Wikipedia +study includes two local photographs dedicated under CC0 1.0 (also used +in the Finder, Telegram and annotation studies); their authors, +original source pages and modifications are recorded in +[`examples/wikipedia/PHOTOS.md`](examples/wikipedia/PHOTOS.md). Other scenes +use CSS and vector illustrations. No third-party fonts are bundled, and the +playground does not fetch fonts or photographs from external services at runtime. + +The local brand registry in `kit/brands/` includes SVG artwork from SVGL (MIT), payment-icons (Apache 2.0), Cryptocurrency Icons (CC0 1.0), and official Linear, American Express and Circle assets. Per-file sources, hashes and license files are listed in [`kit/brands/SOURCES.md`](kit/brands/SOURCES.md). The original artwork and brand usage terms remain with their respective owners. + +`Phone frame="photo"` is optional. Supply your own permitted bezel image via +`--ss-phone-photo-light: url(...)` and optionally `--ss-phone-photo-dark`. +Without it, the kit uses its CSS-drawn `pro` frame. No bezel photographs are +bundled. diff --git a/skills/imported/benjitaylor/stageshow/README.md b/skills/imported/benjitaylor/stageshow/README.md new file mode 100644 index 0000000..fc1c5cc --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/README.md @@ -0,0 +1,75 @@ +# Stageshow + +A skill and a small React kit for product illustrations, cursor stories, and +motion design. Start with `SKILL.md`; follow its links for the task at hand. + +## Preview + +Requires Node 20.19 or later. From this folder: + +```sh +npm install +npm run dev +``` + +On macOS and Linux, open **http://stageshow.localhost:1355**. On Windows, +Vite starts directly; open the local URL it prints (normally **http://127.0.0.1:5173**). +Portless is installed automatically on supported platforms. If npm skipped +optional dependencies on macOS or Linux, run `npm install --include=optional`. + +The playground includes the gold +examples, nine visual styles, and labeled bad/good teaching pairs. +Pause, replay, slow down, or advance one frame. Phone changes the available +width to 358px; Reduced motion shows each scene's still. The viewer and its +controls stay outside the example's isolated clock. + +## Use the kit + +Copy `kit/` into your React project. Its components import their own CSS: + +```tsx +import { Stage, Window, type Act } from './kit' +``` + +Copy an example as a starting point. Keep its relative kit imports, copy any +assets it imports, and adapt the composition and look to your product. These +are authored illustrations: a scripted `a.click()` draws feedback; `a.set()` +changes the scene. They do not automate real application controls. + +Real-product studies (Figma, Linear, Slack, Raycast and others) are included in +`examples/` and listed under **Real products** in the playground. Some import +`lucide-react`, which the playground installs. When copying a study into a +different project, keep its local assets and install that icon dependency or +adapt the icons. + +The kit supports React 18 and later. It needs no third-party animation runtime. The +playground uses Vite; filming uses `playwright-core` and locally installed +Chrome (or `CHROME_PATH`). Those tools are not runtime dependencies of a demo. + +## Verify + +```sh +npm run typecheck +npm run check:css +node scripts/film.mjs '' --select '.your-demo' --gate +node scripts/film.mjs '' --select '.your-demo' --width 390 --gate +node scripts/film.mjs '' --select '.your-demo' --states --gate +``` + +For the bundled viewer, use `?capture=hero-demo` as the filming URL. The ordinary +viewer contains an iframe; use the direct capture route on native clocks. See +`references/rehearse.md` for coverage, phone, hover, reduced-motion, and visual +review. No automated clean result replaces watching the motion. + +## Contents + +- `kit/`: portable React source and styles. +- `examples/`: gold scenes and reference specimens. +- `teaching/`: explicitly flawed/corrected pairs, separate from gold. +- `references/`: design judgment, APIs, and verification guidance. +- `assets/`: local icon assets used by examples. +- `scripts/`: verification tools, independent of the host's markup. +- `playground/`: the standalone local viewer. + +The skill, kit, examples and verification tools are included in this folder. +See `NOTICE.md` for third-party names and optional assets. diff --git a/skills/imported/benjitaylor/stageshow/SKILL.md b/skills/imported/benjitaylor/stageshow/SKILL.md new file mode 100644 index 0000000..21ddf4e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/SKILL.md @@ -0,0 +1,198 @@ +--- +name: stageshow +description: Build faux product UI that looks like a real product, as a still illustration or an animated demo with a cursor. Browser and app windows, dashboards, phones, notifications, terminals, bento grids and cursor walkthroughs, framed, masked and cropped the way a marketing page, feature card or docs hero needs them. Use whenever someone wants a fake or mocked UI, a product demo or walkthrough animation, a hero or feature-card illustration of an app, a product shot in the style of a polished marketing site, or an animated cursor doing things in an interface, even if they never say "faux UI". React and plain CSS. +--- + +## Stack integration contract + +This is a pinned, active Stack workflow for authored product UI illustrations +and videos. Isolated synthetic browser/video validation passed on November 9, +2026 with matching Chromium and its sandbox enabled. Validation covers the +recorded representative routes, not every retained example or arbitrary host. +Read [Stack review and adaptations](STACK-REVIEW.md) before using the resources. + +- Follow the user's task and the receiving project's design contract. These + illustration presets do not govern a shipping product's real interaction UI. +- Work in an authorized project copy. Keep this imported package unchanged; + copy examples and required kit files into the project rather than editing Stack. +- Resolve existing action-specific authorization before setup or execution. + Invoking this skill does not authorize npm/npx installs, lifecycle scripts, + browser downloads, imported helper execution, a Vite/Portless listener, proxy + changes, or external calls. Request missing approval for the exact command, + target and effects; do not repeat approval already granted for that action. +- Local source patches keep rehearsal failures in the final exit status, escape + contact-sheet labels and explicitly enable the Chromium sandbox. The filming + helpers passed representative isolated checks. Their exit code or + `rehearsal passed` text alone is not acceptance evidence. When blocked, hand + over the component/storyboard and state precisely what was not checked. +- For any later authorized preview, use reviewed local content, an explicit + loopback origin/port and serving directory, bounded resources and a trusted + isolated browser executable. Do not reuse a signed-in profile, cookies, + credentials or authentication state. Review navigation, redirects and resource + origins before allowing network access; do not pass arbitrary work URLs. +- Use synthetic or expressly permitted work content. Keep captures, DOM labels, + logs and exports in an approved local output directory without overwriting + unrelated files. Do not upload, publish, share or send work material unless + that exact destination and action are authorized. A local export is not a + request for publication. +- Preserve LICENSE, NOTICE.md, artwork sources and applicable separate licenses. + App-bundle icons and five official-only brand assets are omitted. Use only + available examples/marks or user-supplied permitted artwork; do not fetch the + omitted artwork automatically or imply a brand endorsement. Phone status + symbols are generic Stack-authored geometry, not Apple's copied glyph paths. + +Filming remains strict: sampling gaps are inconclusive, and retained examples +can have layout or motion findings. Fix and recheck each project copy. +`film.mjs` produces PNGs, not encoded video: record an actual clip separately, +decode it, inspect its motion and final state, and verify duration/dimensions. +The accepted sample and limitations are recorded in `STACK-REVIEW.md`. + +The upstream workflow below is conditional on this contract. Commands are +reference instructions, not authorization to run them. The omitted examples +are `hero-demo`, `bento`, `phones`, `parts` and `kit-extras`; choose a retained +example instead. `source.json` records the exact subset and every adaptation. + +# Stageshow + +Miniature product UI: a believable interface, framed in its space, as one still or a short animation in which one thing happens. The viewer recognizes the screen and sees the thing happen. + +## The one idea + +**It reads as a real product, simplified to what the viewer needs, framed with intent, and every motion has a cause.** + +Five ways it fails: + +- **Too much.** Real text in every row, numbers everywhere, everything packed. The more detail, the more it looks generated. +- **Wireframe.** Two gray blobs and a button. Built too small, or stripped of what made it recognizable. +- **Boxed.** A whole window shrunk into a card with even margins: a screenshot pasted in, not a product shot. +- **Flat.** Every object the same weight in the same bordered card. The eye has nowhere to land. +- **Bland.** A gray window with a cursor, whatever the brief said. The brief's look and energy (a blueprint, glass over color, a Dynamic Island, a mascot) are the point; take what it asks for and build it to the rules, as `examples/hero-demo` does with its typed popup and terminal satellite (a satellite is a smaller second object, a receipt, phone or detail card, straddling the hero's edge). + +The fix: a full, structured screen in quiet grays, real words only on what the story is about, placeholder lines for the rest, room to breathe, a crop that puts the eye where the story is. Simplify the words, not the context. An empty corner, a void under a list, rows spread to fill a pane, a window floating on a plate (a backdrop or tinted field behind it): unfinished, not calm. + +These are authored illustrations, not browser automation: `a.click()` draws the cursor pressing, `a.set()` changes the scene. + +## How hard to press + +Build it the way a product designer builds a launch page they will be judged on. The gold examples in `examples/` are the bar, not a reference: if yours is the emptier, flatter or smaller one beside the closest gold, that is the first fix. Then look at the frame the way the most exacting designer you know would, the one who screenshots the one wrong thing and sends it back with "what is this?": a dot off its avatar, a unit on its own line, a grabber under the island, copy touching a card. If you can picture that screenshot, you have found the next fix. + +## Workflow + +Most of the quality is decided before any code. Write the plan first: where it sits, the focal object, the action, the payoff, what can be left out. + +1. **Medium and place.** Still or animation (loop, once in view, or hover). The page, its ground, its width. Design at the size it will be seen, about 720 × 340 for a hero beside copy (`hero-demo`), 900 to 1100 wide for a full-width hero (`linear`), 400 × 300 for a card, shown at 1:1; never under about 320 wide for a demo, though a bento tile or a satellite card may be smaller. Every demo that can sit in a phone column ships a `compact` design at the column's width (358), a redesign not a squeeze (`references/framing.md`, Small formats and the page). A grid of tiles is one set: plan it with `references/bento.md`. +2. **Look.** With the product's code or site at hand, map its tokens to the kit's (`references/looks.md`); if the developer asks for their own components, use them inside the stage instead of the kit's small parts. Otherwise a theme (light, dark, blueprint, glass), a palette (accent, mono) and a fidelity: `minimal` is all bars, `standard` is words where they carry meaning and bars elsewhere, `realistic` is real content everywhere. Default light, accent, standard; go realistic at hero scale, for a named real product, or a still whose subject is a list or page. Write filler once with `Text filler` and the fidelity decides. The product's own icons, its real chrome, the content that makes it recognizable. A brief with a style (kawaii, paper, a poster color) gets that style in full, still built to these rules. +3. **The story.** Its shape (`references/motion.md`, Story): how it feels to use, a flow, or what the system does. Three to five beats and a payoff, 10 to 16 seconds. No cursor move you can't explain. Data never rewinds in view without a cause. Between a richer story and a calmer frame, choose the frame. +4. **Start from an example.** Find the closest gold example (the Gold table below) and film it (`node scripts/film.mjs "/?capture=" --select '#example' --still --out gold.png`; `npm run dev` serves the preview). Copy its folder into your project as the starting point; don't rewrite it from a glance. The quickest loop, in an authorized project copy: copy it to `examples//` there, where the preview picks it up and `?capture=` films it, then move it into the host when it passes and fix its kit import path. Keep its structure: the stage size, the crop and bleed, row and type sizes, spacing, the script's beats, the kit parts. Now read `references/craft.md` in full: it is short, and it holds the still-frame rules the gold can't show you, the cases your gold doesn't happen to contain. Then replace its look and content with the brief's product: the tokens from step 2, the product's own icons, chrome and words. A copy that only changes the name is not done: it should read as the brief's product, not the gold's. If no gold tells your story, choose in two parts: the layout first (pick it from the table in `references/framing.md`; each layout names its example), and copy that example even though its story differs; then borrow the motion for your beats from the example whose story is closest, and build what neither has in the same style. Then frame it: crop to the panel the story happens in, judged in the final visible crop (`references/framing.md` has the rules and the layout table). The short form: one gutter, a full one or well past; only filler bleeds; what opens stays in; windows keep their shape; the page is the ground, so no plate under a hero on a white or dark page and no window whole with air on four sides; `unframed` only when the developer asks; no dead corners. Film one still at both widths beside the gold's; if yours is emptier, flatter or more boxed, fix the frame before any motion. +5. **The screen.** Count the real strings, usually 3 to 7 (`references/craft.md`, Restraint): a budget for words, not elements. Build with the kit's tokens, `Bar`, `Text` and `Row`. Fit every control's text before it moves. Mark what the cursor touches with `data-target` and `data-cursor`; measure with `rect()`, never hard-code. +6. **Choreograph.** The `Act` API. Keep what appears mounted and toggle it so exits animate (`Bubble` and `Fly` hand off explicitly). Animate the relationship, not just the object: a new row carries the rows below it, a surface that resizes never stretches its text. Beats overlap, groups stagger, the payoff holds, the loop ends where it began or resets with a visible cause. +7. **Rehearse.** Set `poster` first: most people see only that frame (a thumbnail, reduced motion, a screenshot), so it is the beat that tells the story on its own, never one caught mid-change, with the result left on screen rather than tidied away (`references/motion.md`, Story). Then film the loop, not just a still: the contact sheet at the shown width and at 390, and step through every press, menu, morph and the loop seam (`references/rehearse.md`). A still can't show a pop. Read every frame, fix the largest visible fault first (framing, then hierarchy, then motion, then polish), re-film. Then hold it next to the closest gold and answer the whole-composition and style pairs in `references/teaching.md` as questions about your still (what does the eye land on first, and is it the result? is one window, tile or device clearly leading?); any no is the largest fault. `film.mjs --gate` ending `rehearsal passed` is necessary, not sufficient. Then run the last pass in `references/rehearse.md`: a fresh-eye review (`references/review.md`) and `REHEARSAL.md` beside the component (template in `references/verify.md`, Hand over): the gold you copied, what you changed, what you did not check. + +Faults that come back every round, each with its rule; check them before handing over: + +- **Content that swaps** instead of moving (`references/motion.md`, Changing text and values). +- **Gray bars where the eye lands;** filler belongs at the edges (`references/craft.md`, Restraint). +- **A plate behind the window, or a window floating** with air on four sides; the window or phone fills at least two thirds of the stage and runs off an edge (`references/framing.md`, rule 5 and Small formats and the page). +- **Empty bands, cut controls and shadows** (`references/craft.md`, Restraint; `references/framing.md`, rule 4). +- **Stock AI and made-up logos:** sparkles, purple, a letter in a colored square (`references/craft.md`, Icons and logos). +- **Busy rows and uneven insets** (`references/craft.md`, Restraint and Spacing and alignment). +- **Long holds and a reset with no cause** (`references/motion.md`, Loops). +- **A selection that isn't under the I-beam** (`references/motion.md`, Drags and selection). +- **A status bar or home indicator drawn by hand;** the Phone draws both (`references/craft.md`, Chrome and devices). +- **A centered amount centered with its sign** (`references/craft.md`, Spacing and alignment). + +## Examples + +Film the closest one before you start (step 4). The gold examples are the quality bar; product studies are for borrowing an interaction, never another product's identity; specimen sheets are catalogs, not compositions. Only after the Stack setup and execution gates are satisfied, the upstream preview uses `npm install` and `npm run dev`. `teaching/` holds bad/good pairs (`references/teaching.md`) for diagnosing a problem; the bad halves are never starting points. + +### Gold + +| Need | Example | What to learn | +|---|---|---| +| Still or feature card | `static-card` | Quiet hierarchy, a hard crop, one satellite, real words only on the selected customer, its neighbors and the total | +| Dark still | `dark-bento` | One signal said once; album art keeps it a music app | +| Animated hero, a gesture | `hero-demo` | Five beats, named targets, an annotation, a terminal coming forward | +| Choreography, no cursor | `payout-flow` | Slow overlapping arrivals, reading time, a payoff that holds | +| Content changing in place | `choreography` | Retained tracks, soft masks, dependent timing | +| AI answer | `ai-chat` | The question sent, Thinking, the answer streaming in with its height, sources last; no sparkles, no purple | +| AI with cards | `ai-cards` | A multiple-choice card answered and kept as the record, a card of options opening in the thread, booked from its own button | +| Form validation | `form-error` | An error on the field that caused it, cleared by fixing it, then the button confirms on itself | +| Drag to reorder | `reorder` | A row lifted by its grip, the others making room, dropped into place | +| Chart scrub | `chart-scrub` | A hairline and dot that follow the pointer, the headline value rolling to the day under it | +| Edit in a table | `table-edit` | A cell edited in place, then the total it feeds rolls a beat later | +| Loading to content | `loading` | A skeleton at the content's own geometry, rows resolving top to bottom, real chrome throughout | +| Dark hero | `dark-hero` | Depth from surface steps on black, one lit color, a status that changes on its own pill | +| Live collaborators | `presence` | Each person one color everywhere, names riding their cursors, work shown in the doc's own marks | +| System story | `subscriptions` | A chip flies into a row, MRR rolls, a second scene is the reset | +| Close-up | `close-up` | One control at 1.4x with its window's chrome, the row whole, a large cursor to match | +| Many of one | `many-of-one` | A grid of one thing off every edge, one tile in focus by depth, the only one with words | +| Fade crop | `fade-crop` | A window fading out at its foot, over filler, instead of running off an edge | +| Panel over product | `panel-annotation` | A settings panel over the product's margin, one leader from the setting to what it changed | +| Overlapping windows | `overlap` | A card in front of a window, its edge on the window's sidebar line | +| Stacked cards | `stacked-cards` | Two components at one width, a small gap, the lower one off the bottom | +| Opposite corners | `opposite-corners` | A bento tile: copy bottom-left, the product hung from the top-right corner | +| Tilted stack | `tilted-stack` | A loose pile of paper, small alternating angles, only the top card has words | +| Corner chip | `corner-chip` | A small chip centered on a window's corner, half outside | +| Monochrome | `monochrome` | Other companies' products in a row, grayscale, faded at the foot | +| Row of windows | `window-row` | Sessions side by side at one height: focus by depth, neighbors cut by the stage, only filler cropped | +| Window and phone | `device-pair` | A dashboard and a lock screen telling one event; the phone straddles over margin, never over the figure | +| Related objects | `story` | An invoice, a plan, a connector, rolling totals | +| Calendar drag | `calendar` | A populated week, one focal event, a measured pickup | +| Free dragging | `freeform-drag` | Grab offset, a measured grid, a drop slot that snaps | +| Phone app | `phone-touch` | A card opens into its screen as one object, and the loop folds back | +| Dynamic Island | `dynamic-island` | One black shape, its content a separate layer | +| Messages | `messages` | Composer, typing, receipts, a bubble's lifetime | +| Cursors | `cursors`, `cursor-morph`, `cursor-resize` | Five styles, element-driven kinds, resize edges and corners, no cursor at all | +| Annotations | `annotations` | Leaders, dimensions, flows and a moving target; `references/annotations.md` | +| Feature grid | `bento` | One hero, one window, a stat, an object, a diagram | +| Dark ruled grid | `ruled-bento` | Shared rules and technical imagery | +| Consumer grid | `staggered-bento` | Offset tiles, graphic art, physical objects | +| Image-led hero grid | `hero-grid` | Different grounds and materials composed as one picture | +| Upload | `upload` | A file dragged in from the desktop, an upload row with progress that settles as a file row | +| Share | `share` | A share popover over a document: an email becomes a chip, a role menu, a person added to the list | +| Swipe | `swipe` | A phone swipe to archive: press, swipe, a throw at the finger's speed, the gap closes | + +### Product studies + +Recognizable products (Figma, Linear, Slack, Raycast, Finder, Spotify, Notion, Gmail, Google's apps, Office, Wikipedia, Telegram, iMessage): read `references/products.md` first. Borrow the interaction, not another product's identity. + +| Need | Example | What to learn | +|---|---|---| +| Design tool | `figma` | Canvas selection, a color picker, a second cursor | +| Issue tracker | `linear` | Command menu and assignment | +| Chat | `slack` | Messages, a reaction, a thread reply | +| Launcher | `raycast` | Search and clipboard history over a blurred desktop | +| Files | `finder`, `finder-glass` | Quick Look over a file list; a file moved into a folder; the same window in frosted glass | +| Music, dark | `spotify` | A dark desktop app with album art | +| Docs and editors | `notion`, `google-docs` | A slash command; a comment and its reply | +| Email | `gmail` | Hover actions, archive, an undo toast | +| Calendar, real app | `google-calendar` | Drag to create an event, the quick-create popup | +| Office | `powerpoint`, `excel` | Slide editing; AutoSum and a chart | +| Browser page | `wikipedia` | Search suggestions in a browser | +| Phone conversations | `telegram`, `imessage` | Native chat apps on an iPhone | + +### Specimen and reference sheets + +Catalogs of parts and grounds, described in `references/kit.md` (Specimen sheets). Look up a part here; compose from a gold example. + +| Need | Example | What to learn | +|---|---|---| +| Kit specimens | `chrome`, `phones`, `controls`, `parts`, `kit-extras` | Every frame and part | +| Glass | `glass` | Frosted surfaces over a color field | +| Backgrounds | `backgrounds` | One interface over twenty grounds; `references/backgrounds.md` | +| Logos | `brand-marks` | Real marks, optically sized, on light and dark; `references/brands.md` | +| A product's look | `looks` | Type, shape and color working together | + +## The kit + +Copy `kit/` into the project and import from `kit/index.ts`. React, react-dom and lucide-react (for `Icon`, which `Field` and `Chip` use); each part brings its own CSS. It does the parts that are hard to get right by hand and leaves the product's look to you. `references/kit.md` has every part's props and rules; read the entries for the parts you use. + +- **The stage.** `Stage`: design size and scale-to-fit, `compact`, scene state, playback, the look, the cursor. The script gets an `Act` (`set`, `wait`, `move`, `click`, `type` and the rest: `references/kit.md`, Act). +- **Frames.** `Window`, `Phone` with `LockScreen` and `Notification`, `Keyboard`, `Thread` with `Bubble`, `Stamp`, `Receipt`. Real geometry; never draw a window, device or bubble by hand. +- **Motion.** `Collapse`, `Roll`, `Swap`, `Count`, `Connector`, `Fly`, `Follow`, `States`, `Stream`, `Popover`, `Scroll`, `Camera`, `Expand`. They handle the frames a cold hand gets wrong. +- **Small parts.** `Bar`, `Text`, `Row` (every list is made of it), `Avatar`, `Pin`, `Icon`, `Menu`, `Field`, `Segmented`, `Toggle`, `Tabs`, `Badge`, `Chip`, `BrandMark`, `Code`, `Chart`. Quiet starting points; restyle to the product, never reshape a kit part's geometry from outside. +- **`stageshow.css`**: tokens, looks, edge fades, chrome, phones, keycaps. + +## The references + +`references/framing.md`: the framing rules and the layout table. `references/craft.md`: the still frame (restraint, type, spacing, icons, color, chrome). `references/motion.md`: story, loops, pace, the cursor and drags. `references/verify.md`: the checklist with a you-see / you-do table. `references/rehearse.md`: how to film, and the film lines that are never intentional. diff --git a/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md b/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md new file mode 100644 index 0000000..f67bba7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md @@ -0,0 +1,176 @@ +# Stageshow source intake + +Source: https://github.com/benjitaylor/skills/tree/802057409787133703c56001080169e347305572/stageshow +Author: Benji Taylor. Upstream version: 0.1.0. Revision: `802057409787133703c56001080169e347305572`. + +## Decision and scope + +The user explicitly requested this skill for Stack and work use. Retain its +authored product-illustration guidance, React kit and permitted examples as one +design workflow under `stack.design`. It complements CDO/Emil motion guidance; +it does not become a general product-design authority or real-app automation. +The initial import remained `candidate` until isolated validation. The November 9 +acceptance below supersedes that hold and authorizes local Claude/Codex exports. +Repository activation and actual runtime installation remain separate evidence. + +## Provenance and adaptations + +Every retrieved file was verified against GitHub's immutable Git blob identity. +`source.json` records upstream and imported SHA-256 hashes, omissions and changes. +LICENSE and upstream NOTICE.md are preserved unchanged. The retained brand +artwork retains its per-file sources and MIT, Apache 2.0 or CC0 license texts; +these do not grant trademark rights. Retained photographs keep PHOTOS.md. + +- Add the Stack integration contract to SKILL.md; make setup and editing-copy + instructions conditional on authorization and the runtime hold. +- Omit app-bundle icon artwork, its three dependent examples, hero-demo's + unlicensed copied toolbar artwork, and kit-extras' omitted dependencies. +- Omit official-only Linear, American Express and Circle artwork whose usage + terms are not bundled, and remove their three brand-registry entries. +- Replace only Phone's three private status glyph functions with newly authored + generic geometry. Keep the component API and CSS classes. Native glyph + fidelity is deliberately not claimed. +- Filter the playground catalog by available example modules so omitted + resources are not offered as runnable examples. Upstream example tables and + notices describe the original collection; the Stack contract and source + manifest control this imported subset. +- Normalize one retained SVG line ending and one Markdown EOF blank line for + repository formatting checks; the SVG records its modification and its artwork + geometry is unchanged. +- Apply two local safety/reliability patches in scripts/film.mjs: fold a + failed gate into the report's final exit status, and HTML-escape labels before + contact-sheet interpolation. Do not print a passed gate for a nonzero report. + At initial intake, all other helper source, package metadata and the lockfile + were preserved, and no upstream setup, dependency lifecycle, build, browser, + preview or filming code was run. See the follow-up below for the later launcher + patch and validation attempts. + +## Runtime hold and evidence limits + +At the pinned upstream revision, scripts/film.mjs overwrites a failed gate exit +status and interpolates page-controlled labels into contact-sheet HTML. The two +local patches address those source paths: a gate failure raises result.exitCode +before report serialization and final assignment; all five HTML delimiter +characters in labels are escaped before interpolation. Static/stdlib regression +checks cover the source policy, failure-status combinations and markup-bearing +labels without importing or executing the tool. These are not reproduced browser +outcomes. State capture still observes only the first selected stage; that behavior +is unchanged. Do not use the helpers as acceptance evidence until independent +representative runtime validation passes. + +The preview uses Portless/Vite, which starts a local listener; its Vite config +allows the parent directory. Filming launches installed Chrome through +playwright-core (or CHROME_PATH), accepts arbitrary URLs and writes captures and +reports. Setup may download dependencies and run transitive lifecycle scripts. +No browser downloader is configured in scripts/browser.mjs. None of these +effects is authorized merely by importing or invoking the skill. + +## Promotion evidence still needed + +In a separately authorized isolated project: review the exact setup actions, +serving directory/origin and permitted synthetic content; validate the local +filming patches; typecheck/build the retained subset; verify a representative still, +loop, compact layout, reduced-motion state, output containment, truthful gate +failure, and clean browser/server shutdown. Record independent visual review. +Then review activation and managed Claude/Codex publication separately. + +## Validation follow-up: 2026-11-01 + +The user authorized continuing isolated validation with synthetic content, a +matching official browser, its sandbox enabled and no test network access. +They reported that dependency installation, typecheck and build passed in a +prior attempt, but the disposable VM's browser-image download failed because +guest DNS did not work. That VM was removed. The newer setup receipts were not +located in this checkout or the available saved-file search, so those results +remain user-reported rather than independently verified here. The denied +`/mnt/lima-cidata/network-config` read was not repeated. No privilege elevation +or attempt to bypass that denial was made. + +Source review found that `scripts/browser.mjs` omitted `chromiumSandbox`, which +Playwright defaults to false. A third local patch explicitly sets it to true +for both the Chrome channel and `CHROME_PATH`. No retry with weaker options is +provided. `tests/test_stageshow_browser_launcher.py` executes that wrapper with +a fake Playwright module: default selection, explicit path, launch failure and +missing dependency. The three sandbox assertions failed before the patch; all +four tests pass afterward. These tests never launch a browser and do not prove +that a platform can run its sandbox. + +A new Lima VM definition used plain mode, no host mounts, no SSH agent/public +key forwarding, no proxy environment propagation and no container runtime. +Creation stopped before boot because the current execution permissions denied +Lima's image-cache write. Separate HTTPS probes could not resolve the official +Debian image and Microsoft browser-image hosts from this shell. The unbooted VM +definition was deleted. No browser, listener or test network was started. + +The proposed next route is to fetch verified VM/browser assets during setup in +an environment that permits those downloads, copy only the pinned package and +synthetic fixture into a fresh VM, and run validation with enforced network +denial. That route has not been executed or validated here. Keep the browser +sandbox enabled and stop on any isolation failure; do not change host security +settings, reuse a signed-in browser, or retry the denied diagnostic read. + +`film.mjs` exports PNG stills/contact sheets, not an encoded video. Acceptance +also requires recording an actual synthetic UI clip with the matching browser +and verifying the resulting video: successful decode, duration, dimensions, +changing frames, expected final state and visual review. No video artifact or +browser acceptance receipt exists from this follow-up. The candidate and empty +publication-target list remain unchanged. After validation, use the documented +catalog/compiler procedure; do not bypass its clean-source requirements or +publish runtimes under this task's prohibition on publication. + +The bounded follow-up receipt is +`artifacts/validation/stageshow/2026-11-01.json`. + +## Accepted isolated validation: 2026-11-09 + +Maroun authorized official pinned setup downloads, isolated synthetic tests and +normal local installation into both Stack runtimes in Codex chat +`01a1c1bb-c674-7013-a1ee-dcaf8ede8354`. That run prohibited external publication. + +The accepted receipt is `artifacts/validation/stageshow/2026-11-09-accepted.json`. +A disposable Lima 2.2.0 Debian 12 arm64 VM used a digest-pinned official image, +no host shares, no SSH agent/private-key forwarding, an allowlisted environment +and no signed-in state. Node 22.22.3 matched the official SHA-256 list. Exact +lockfile dependencies used `npm ci --ignore-scripts`; matching Playwright-core +1.48.2 installed official Chromium 130.0.6723.31 (revision 1140). Signed Debian +packages were version-pinned and extracted into the non-root user's directory; +no sudo or privilege elevation was used. + +An inherited seccomp rule with no-new-privileges denied IP sockets and io_uring +setup before all acceptance commands. IPv4/IPv6 socket attempts returned EPERM +and an external browser navigation failed. Synthetic pages were fully inlined +local files, so no HTTP server or external resource was needed. Renderer status +proved a PID namespace and additional Chromium seccomp filtering; actual launch +arguments omitted sandbox-disabling switches. The source launcher was unchanged. + +Acceptance includes a decoded 12-second 640×480 H.264 UI clip (300 frames at +25 fps), showing typing, error, correction, submission and “Account created”; +an 8-second 640×360 loop (200 frames) traversing saved/reset twice; wide and +390px compact reduced-motion posters stable across captures; representative +stills; and a complete, no-findings loop rehearsal under unchanged thresholds. +Native Playwright recordings include encoder tail time; delivered clips trim +only that tail to the declared duration. Decoded frames were visually inspected. + +A deliberately clipped target returned exit 1 in both the process and report, +printed rehearsal failure and never printed success. A markup-bearing state +label round-tripped as literal caption text, with zero injected elements or +executed handlers. Outputs stayed under the designated validation output root; +source helper/lockfile hashes matched afterward. Browsers closed and the VM +stopped. No prohibited diagnostic was retried or host security setting changed. + +Limits: this validates representative routes, not the whole example catalog. +Exploratory retained form/loading examples produced compact framing findings +and/or motion-sampling gaps on the VM. Those reports are preserved; they were +not reclassified or used as passed gates. Project copies still need rehearsal +and visual review. The filming helper remains a PNG exporter; actual encoded +video requires the separate recording/decoding step. Runtime installation is +verified by the owner-only normal installer receipt, not by this source note. + +## Canonical publication authorization: 2026-11-12 + +After the source-only acceptance, the owner authorized publishing and merging +this reviewed integration, then installing both local Stack runtimes through +the normal procedure. The canonical-main provenance gate remains unchanged. +The original local-only commit and raw evidence remain preserved locally; +published VM diagnostics redact host home paths. Runtime availability still +requires successful canonical installation and both discovery checks. diff --git a/skills/imported/benjitaylor/stageshow/capability.json b/skills/imported/benjitaylor/stageshow/capability.json new file mode 100644 index 0000000..7a9dc69 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/capability.json @@ -0,0 +1,71 @@ +{ + "artifact_type": "skill", + "audit_status": "reviewed", + "canonical_name": "stageshow", + "commands": [ + "stack.design" + ], + "context": { + "inputs": [ + "request", + "project-design-contract", + "permitted-content" + ], + "outputs": [ + "product-illustration-source", + "verification-limitations" + ] + }, + "disposition": { + "evidence_gap": null, + "next_review_trigger": "Revalidate on helper, dependency/browser, safety contract or runtime export changes; rehearse each project copy.", + "status": "keep" + }, + "domain": "design", + "family": "design", + "lifecycle": "active", + "overlaps": [ + "emil-design-eng", + "review-animations", + "design-intelligence" + ], + "ownership": { + "package": "imported-skills", + "provider": "benjitaylor", + "source_path": "skills/imported/benjitaylor/stageshow/SKILL.md" + }, + "provenance": { + "license": "MIT; retained artwork has separate MIT/Apache-2.0/CC0 notices; excluded artwork and trademark rights are not covered", + "posture": "imported-upstream", + "source_identity": "github:benjitaylor/skills:stageshow@802057409787133703c56001080169e347305572" + }, + "purpose": "Create authored still or animated product UI illustrations and cursor stories with the pinned Stageshow React kit and permitted references.", + "role": "workflow", + "runtimes": { + "publish_targets": [ + "claude", + "codex" + ], + "supported": [ + "claude", + "codex" + ] + }, + "schema_version": 1, + "source": { + "skill_path": "skills/imported/benjitaylor/stageshow/SKILL.md" + }, + "trust_class": "local-mutation", + "validation": { + "evidence": [ + "skills/imported/benjitaylor/stageshow/STACK-REVIEW.md", + "skills/imported/benjitaylor/stageshow/source.json", + "tests/test_stageshow_intake.py", + "tests/test_stageshow_browser_launcher.py", + "artifacts/validation/stageshow/2026-11-09-accepted.json" + ], + "status": "validated" + }, + "validation_class": "representative-route", + "visibility": "extended" +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css b/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css new file mode 100644 index 0000000..3fdebbb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css @@ -0,0 +1,273 @@ +.aic .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top and sides, off the bottom just under the composer */ +.aic-win { + position: absolute; + top: 28px; + left: 40px; + width: 560px; + height: 540px; +} + +.aic-body { + display: grid; + grid-template-rows: 1fr auto; + height: 100%; + padding: 16px 20px 56px; + background: var(--ss-surface); +} + +/* Anchored at the bottom over the composer; the earlier turn goes up out of + the window as the cards open */ +.aic-thread { + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 14px; + min-height: 0; + padding-bottom: 20px; + overflow: hidden; + /* The earlier turns fade as they go up out of the window, never sliced + by its edge */ + mask-image: linear-gradient(to bottom, transparent, #000 24px); +} + +/* Everything in the thread rides up as the cards open: each keeps a layer + of its own, so its words don't step on whole pixels as it moves */ +.aic-thread > *, +.aic-choice { + will-change: transform; +} + +.aic-past { + display: grid; + gap: 8px; +} + +.aic-past-q { + justify-self: end; +} + +.aic-q { + display: flex; + justify-content: flex-end; +} + +.aic-q .ss-text { + padding: 8px 14px; + border-radius: 16px; + background: var(--ss-ink-1); + color: var(--ss-text-strong); + font-size: 13px; +} + +.aic-a { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; +} + +.aic-line { + white-space: normal; + text-wrap: pretty; +} + +.aic-line, +.aic-thinking { + color: var(--ss-text-strong); + font-size: 13px; +} + +.aic-thinking { + color: var(--ss-text); +} + +/* Multiple choice: one row of fixed options. A pick fills one and draws its + check; the rest go quiet but stay, as the record of the answer */ +.aic-choices { + display: flex; + gap: 8px; +} + +.aic-choice { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + /* The round check reads as further in than a letter: its side is 2px + tighter, so both ends look the same */ + padding: 0 12px 0 10px; + border-radius: 10px; + box-shadow: inset 0 0 0 1px var(--ss-line); + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 500; + transition: background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease, opacity 0.3s ease; +} + +.aic-reply { + display: grid; + grid-template-columns: minmax(0, 1fr); +} + +/* The pick is marked in the accent, not the success green: choosing is not + finishing */ +.aic-choice { + --ss-success: var(--ss-accent); +} + +.aic-choice__mark { + display: grid; + color: var(--ss-text); +} + +.aic-choice__mark .ss-check:not([data-on]) .ss-check__ring { + stroke: var(--ss-line-strong, var(--ss-line)); +} + +.aic-choice[data-on] { + background: var(--ss-accent-soft); + box-shadow: inset 0 0 0 1.5px var(--ss-accent); + color: var(--ss-accent-text); +} + +.aic-choices[data-answered] .aic-choice:not([data-on]) { + opacity: 0.45; +} + +/* The flights card: rows of one height, a radio, the price on the right */ +.aic-card { + display: grid; + gap: 2px; + padding: 6px; + border-radius: 14px; + box-shadow: inset 0 0 0 1px var(--ss-line); +} + +.aic-flight { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 12px; + height: 46px; + padding: 0 14px 0 12px; + border-radius: 9px; + transition: background-color 0.25s ease, box-shadow 0.25s ease, opacity 0.3s ease; +} + +.aic-flight[data-on] { + background: var(--ss-accent-soft); + box-shadow: inset 0 0 0 1.5px var(--ss-accent); +} + +.aic-card[data-booked] .aic-flight:not([data-on]) { + opacity: 0.45; +} + +.aic-radio { + width: 16px; + height: 16px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px var(--ss-line-strong, var(--ss-line)); + transition: box-shadow 0.25s cubic-bezier(0.22, 1, 0.36, 1); +} + +.aic-flight[data-on] .aic-radio { + box-shadow: inset 0 0 0 5px var(--ss-accent); +} + +.aic-flight__main { + display: grid; + gap: 5px; +} + +.aic-times { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-box: trim-both cap alphabetic; +} + +.aic-arrow { + color: var(--ss-text); + font-weight: 400; +} + +.aic-airline { + color: var(--ss-text); + font-size: 11px; + text-box: trim-both cap alphabetic; +} + +.aic-price { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* The card's action: one cell, every label a layer in it that crossfades, + so nothing moves sideways; the fill eases from quiet to accent to done */ +.aic-book { + display: grid; + grid-template-areas: 'stack'; + place-items: center; + height: 38px; + margin-top: 4px; + border-radius: 9px; + background: var(--ss-ink-1); + color: var(--ss-text); + font-size: 13px; + font-weight: 600; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.aic-book[data-ready] { + background: var(--ss-accent); + color: #fff; +} + +.aic-book[data-done] { + background: var(--ss-success); +} + +.aic-layer { + grid-area: stack; + display: inline-flex; + align-items: center; + gap: 8px; + opacity: 0; + filter: blur(2px); + transition: opacity 0.25s ease, filter 0.25s ease; + text-box: trim-both cap alphabetic; +} + +.aic-layer[data-on] { + opacity: 1; + filter: none; + transition-delay: 0.08s; +} + +.aic-book .ss-check__ring { stroke: rgb(255 255 255 / 0.45); } +.aic-book .ss-check__arc { stroke: #fff; } +.aic-book .ss-check__fill { fill: #fff; } +.aic-book .ss-check__tick { stroke: var(--ss-success); } + +/* Narrow: a gutter each side, the window the stage's width */ +.aic .ss-stage[data-layout='compact'] .aic-win { + left: 16px; + width: 326px; + height: 600px; +} + +.aic .ss-stage[data-layout='compact'] .aic-body { + padding-inline: 14px; +} + +.aic .ss-stage[data-layout='compact'] .aic-choice { + padding: 0 10px 0 8px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx b/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx new file mode 100644 index 0000000..937a535 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx @@ -0,0 +1,133 @@ +import { Bar, Check, Collapse, Field, Stage, Text, Window, type Act } from '../../kit' +import './cards.css' + +// An assistant that answers with cards you act on. Beats: asked for a flight, +// it asks which day with a multiple-choice card; a day is picked; it searches, +// then a card of three flights opens; one is picked and booked from the card, +// its button going busy, then Booked. Plays once; the still is the payoff. +// +// What makes interactive cards read: +// - A card is part of the answer, not a popup: it opens in the thread with +// its own height (Collapse with a drop reveal) and the thread moves up as +// one, the way the text above it did. +// - An answered question stays on the page as the record: the chosen option +// keeps its fill and a check, the others go quiet. Nothing is removed, so +// nothing below jumps. +// - The card's own button says what will happen with the choice ("Book for +// £142"), then confirms on itself: busy, then Booked. No toast, no second +// card. Every label sits in the button's one cell and crossfades, so +// nothing in it moves sideways. +// - Every option has a fixed size from the start; a pick changes color and +// a mark, never a width. +// - The earlier turn is placeholder: the eye lands on the cards. +// +// Real strings: the request, the assistant's two lines, the days, the three +// flights, the button. + +const DAYS = ['Mon 13', 'Tue 14', 'Wed 15'] +const FLIGHTS = [ + { dep: '07:10', arr: '09:55', line: 'Halcyon', price: 168 }, + { dep: '08:40', arr: '11:25', line: 'Northwind', price: 142 }, + { dep: '13:20', arr: '16:05', line: 'Aster', price: 155 }, +] + +type S = { day: number | null; phase: 'ask' | 'search' | 'flights'; pick: number | null; book: 'idle' | 'busy' | 'done' } + +const initial: S = { day: null, phase: 'ask', pick: null, book: 'idle' } +const poster: S = { day: 1, phase: 'flights', pick: 1, book: 'done' } + +async function script(a: Act) { + await a.wait(900) + a.mark('day') + await a.click({ at: 'day-1', x: 0.82, y: 0.7 }) + a.put({ day: 1 }) + await a.wait(500) + a.mark('search') + a.put({ phase: 'search' }) + await a.wait(1200) + a.mark('flights') + a.put({ phase: 'flights' }) + await a.wait(1100) + a.mark('pick') + await a.click({ at: 'flight-1', x: 0.4, y: 0.5 }) + a.put({ pick: 1 }) + await a.wait(650) + a.mark('book') + await a.click({ at: 'book', x: 0.72, y: 0.7 }) + a.put({ book: 'busy' }) + await a.wait(1200) + a.mark('done') + a.put({ book: 'done' }) + await a.move({ wide: { x: 612, y: 430 }, compact: { x: 330, y: 300 } }) + await a.wait(2600) +} + +export default function AiCards() { + return ( + + {(s) => { + const pick = s.pick === null ? null : FLIGHTS[s.pick]! + return ( + +
+
+ {/* Earlier turns as placeholder: they fill the window + at the start and go up out of it as the cards open */} + {[[130, '70%', '48%'], [150, '76%', '52%'], [190, '84%', '64%']].map(([q, a1, a2], i) => ( +
+ + + +
+ ))} +
Find me a flight to Lisbon next week
+
+ Which day do you want to fly out? +
+ {DAYS.map((d, i) => ( + + + {d} + + ))} +
+
+ {/* Searching and the flights share one slot in the thread, so + the gap above them is the thread's one gap */} +
+ + Searching flights + + +
+ Three nonstops on Tuesday, all 2h 45m: +
+ {FLIGHTS.map((f, i) => ( + + + + {f.dep} → {f.arr} + {f.line} · LHR to LIS + + £{f.price} + + ))} + + Choose a flight + Book for £{pick?.price ?? 142} + + Booked + +
+
+
+
+
+ +
+
+ ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css b/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css new file mode 100644 index 0000000..0f889a5 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css @@ -0,0 +1,111 @@ +.ac .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top and sides, off the bottom just under the composer */ +.ac-win { + position: absolute; + top: 28px; + left: 56px; + width: 528px; + height: 420px; +} + +.ac-body { + display: grid; + grid-template-rows: 1fr auto; + height: 100%; + /* The composer ends 28px above the stage's edge: the window runs off + below it, never at it */ + padding: 16px 20px 56px; + background: var(--ss-surface); +} + +/* The thread sits at the bottom, over the composer, the way chat apps + anchor it; older content goes up out of the window */ +.ac-thread { + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 14px; + min-height: 0; + padding-bottom: 20px; + overflow: hidden; +} + +.ac-turn--past { + display: grid; + gap: 8px; +} + +.ac-past-q { + justify-self: end; +} + +.ac-q { + display: flex; + justify-content: flex-end; +} + +.ac-q .ss-text { + max-width: 76%; + white-space: normal; + text-wrap: pretty; + padding: 8px 14px; + border-radius: 16px; + background: var(--ss-ink-1); + color: var(--ss-text-strong); + font-size: 13px; +} + +.ac-a { + display: grid; + gap: 10px; + opacity: 0; + transition: opacity 0.25s ease; +} + +.ac-a[data-on] { + opacity: 1; +} + +.ac-thinking { + color: var(--ss-text); + font-size: 13px; +} + +.ac-stream { + color: var(--ss-text-strong); + font-size: 13px; + line-height: 1.55; +} + +.ac-sources { + display: flex; + flex-wrap: wrap; + gap: 6px; + opacity: 0; + translate: 0 4px; + transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} + +.ac-sources[data-on] { + opacity: 1; + translate: 0 0; +} + +.ac-source { + padding: 4px 10px; + border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--ss-line); + color: var(--ss-text); + font-size: 11px; +} + +/* Narrow: a gutter each side, the window full width of the stage */ +.ac .ss-stage[data-layout='compact'] .ac-win { + left: 16px; + width: 326px; + height: 520px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx b/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx new file mode 100644 index 0000000..011c291 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx @@ -0,0 +1,134 @@ +import { + Bar, + Collapse, + Field, + Stage, + States, + Stream, + Text, + Window, + type Act, +} from "../../kit"; +import "./chat.css"; + +// An assistant answering in a desktop window. Beats: a question is typed into +// the composer and sent; it lands as the user's message; a short Thinking +// state; the answer streams in word by word; its sources arrive under it. +// Plays once; the still is the payoff. +// +// What makes streaming read: +// - Every word is laid out from the start (Stream), so the answer grows in +// place: no line reflows, the window never jumps as text arrives. +// - The answer streams fast enough to feel like a model, slow enough to +// read: about 25 words a second, a beat after Thinking. +// - The sent message carries what follows (Collapse with a drop reveal), so +// the thread moves as one, and the composer empties as it sends. +// - The earlier turn is placeholder: the eye lands on the last exchange. +// +// Real strings: the question, the answer, the two sources, the placeholder. + +const Q = "Why did checkout conversion drop last week?"; +const A = + "Conversion fell from 3.4% to 2.7% after the new address step went live on Tuesday. Mobile Safari accounts for most of the drop: the postcode field rejects spaces, so UK customers see an error and leave. Allowing spaces should recover most of it."; +const WORDS = A.split(" ").length; + +type S = { + draft: string; + sent: boolean; + phase: "idle" | "thinking" | "answer"; + words: number; + sources: boolean; +}; + +const initial: S = { + draft: "", + sent: false, + phase: "idle", + words: 0, + sources: false, +}; +const poster: S = { + draft: "", + sent: true, + phase: "answer", + words: WORDS, + sources: true, +}; + +async function script(a: Act) { + await a.wait(700); + a.mark("type"); + await a.type("draft", Q, { interval: 38 }); + await a.wait(400); + a.mark("send"); + a.put({ sent: true, draft: "" }); + await a.wait(450); + a.put({ phase: "thinking" }); + await a.wait(1100); + a.mark("stream"); + a.put({ phase: "answer" }); + for (let i = 1; i <= WORDS; i++) { + a.put({ words: i }); + await a.wait(40); + } + await a.wait(300); + a.mark("sources"); + a.put({ sources: true }); + await a.wait(2600); +} + +export default function AiChat() { + return ( + + {(s) => ( + +
+
+
+ + + +
+ +
+ {Q} +
+
+ {/* Thinking is one line; the answer opens to its own height as + Thinking closes, so the thread grows once, smoothly, and the + earlier turns move up with it. Before the send neither takes + any room */} + + Thinking + + +
+ +
+ Checkout funnel, last 14 days + Errors by field +
+
+
+
+ +
+
+ )} +
+ ); +} diff --git a/skills/imported/benjitaylor/stageshow/examples/annotations/annotations.css b/skills/imported/benjitaylor/stageshow/examples/annotations/annotations.css new file mode 100644 index 0000000..9f41679 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/annotations/annotations.css @@ -0,0 +1,190 @@ +.an-gallery { width: 100%; max-width: 600px; margin-inline: auto; color: #30323a; font: 12px/1.5 system-ui, sans-serif; } +.an-picker { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.an-picker label { color: #747780; } +.an-picker select { appearance: none; box-sizing: border-box; flex: 1; min-width: 0; max-width: 270px; height: 34px; border: 1px solid #e1e3e8; border-radius: 6px; padding: 0 32px 0 10px; background-color: #fff; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%23787870' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 11px center; color: #383b43; font: inherit; line-height: 18px; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease; } +.an-picker select:hover { border-color: #c3c8d0; } +.an-picker select:focus-visible { outline: 2px solid transparent; border-color: #9eb5c8; box-shadow: 0 0 0 2px #3e9fff18; } +.an-picker button:focus-visible { outline: 1.5px solid #7ca9d1; outline-offset: 2px; } +@media (forced-colors: active) { .an-picker select { appearance: auto; background-image: none; padding-right: 10px; } .an-picker select:focus-visible { outline-color: Highlight; } } +.an-picker button { display: grid; place-items: center; flex: none; width: 34px; height: 34px; padding: 0; border: 1px solid #e1e3e8; border-radius: 6px; background: #fff; color: #6f7480; transition: color 160ms ease, background-color 160ms ease, transform 140ms ease; } +.an-picker button:hover { color: #363c48; background: #f3f5f8; } +.an-picker button:active { transform: scale(.96); } +.an-description { margin: 15px 0 0; color: #777d87; font-size: 12px; line-height: 1.65; } +.an-study .ss-stage { overflow: visible; background: #f5f6f8; border-radius: 12px; --ss-font: system-ui, sans-serif; } +.an-product-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 45px; padding: 12px 16px; border-bottom: 1px solid #edf0f3; font-size: 13px; } +.an-product-head strong { color: #292d34; font-weight: 600; } +.an-product-head > span { display: flex; align-items: center; gap: 5px; color: #68717d; font-size: 11px; } +.an-chart-panel { position: absolute; inset: 42px 28px auto; height: 296px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e05, 0 4px 12px #17202e04; } +.an-chart-total { position: absolute; top: 62px; left: 20px; font-size: 28px; line-height: 36px; letter-spacing: -.6px; font-weight: 600; color: #292d34; } +.an-chart-total > span { display: block; color: #68717d; font: 11px/17px var(--ss-font); letter-spacing: 0; } +.an-chart-note { position: absolute; top: 72px; right: 18px; display: grid; gap: 3px; width: 136px; padding: 9px 11px; background: #fff; border: 1px solid #dfe3e8; border-radius: 5px; box-shadow: 0 1px 2px #17202e06, 0 3px 8px #17202e05; opacity: 0; transition: opacity 240ms ease; } +.an-chart-note[data-visible] { opacity: 1; transition-delay: 1000ms; } +.an-chart-note strong { font-size: 12px; line-height: 17px; font-weight: 550; color: #292d34; } +.an-chart-note > span { color: #59616e; font-size: 11px; line-height: 16px; } +.an-plot { position: absolute; left: 20px; right: 20px; top: 145px; height: 112px; } +.an-plot > svg { display: block; width: 100%; height: 100%; overflow: visible; } +.an-chart-grid { fill: none; stroke: #edf0f3; stroke-width: 1; } +.an-chart-area { fill: #eef3fa; } +.an-chart-line { fill: none; stroke: #6083b7; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } +.an-chart-point { position: absolute; left: 65%; top: 21.428571%; width: 7px; height: 7px; border: 1.5px solid #fff; border-radius: 50%; background: #6083b7; transform: translate(-50%, -50%); box-shadow: 0 0 0 1px #6083b7; transition: box-shadow 200ms ease; } +.an-chart-point[data-selected] { box-shadow: 0 0 0 4px #e5edf9; } +.an-chart-axis { position: absolute; left: 20px; right: 20px; bottom: 14px; display: flex; justify-content: space-between; color: #68717d; font-size: 11px; } +.an-chart-rest { position: absolute; left: 20px; top: 124px; width: 24px; height: 14px; } +.an-chart-leader path { stroke-width: 1; } +.an-canvas-panel { position: absolute; inset: 32px 16px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fafbfd; background-image: radial-gradient(#dce2eb .65px, transparent .65px); background-size: 14px 14px; } +.an-canvas-panel .an-product-head { background: #fff; border-radius: 5px 5px 0 0; } +.an-canvas-rest { position: absolute; right: 15px; bottom: 15px; width: 28px; height: 25px; } +.an-selection { position: absolute; } +.an-design-card { display: flex; flex-direction: column; justify-content: space-between; width: 100%; height: 100%; padding: 13px; border: 1px solid #dfe3e8; border-radius: 5px; background: #fff; box-shadow: 0 2px 5px #17202e07; } +.an-card-project { display: block; font-size: 10px; line-height: 14px; color: #68717d; } +.an-design-card > div > strong { display: block; margin-top: 3px; font-size: 13px; line-height: 18px; letter-spacing: -.1px; font-weight: 600; color: #292d34; white-space: nowrap; } +.an-design-card .an-card-completion { display: flex; align-items: baseline; gap: 7px; } +.an-design-card .an-card-completion > strong { margin: 0; font-size: 25px; line-height: 30px; letter-spacing: -.5px; font-weight: 550; font-variant-numeric: tabular-nums; } +.an-card-completion > strong > span { color: #9097a2; font-size: 17px; } +.an-card-completion > span { color: #68717d; font-size: 10px; line-height: 14px; } +.an-card-bottom footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; font-size: 10px; line-height: 14px; color: #68717d; white-space: nowrap; } +.an-card-progress { height: 3px; border-radius: 2px; background: #e9edf3; overflow: hidden; } +.an-card-progress > i { display: block; width: 75%; height: 100%; border-radius: inherit; background: #7387b2; } +.an-selection-ring { position: absolute; inset: -3px; border: 1px solid #8796d0; pointer-events: none; } +.an-resize-handle { position: absolute; right: -6px; bottom: -6px; width: 7px; height: 7px; border: 1px solid #8796d0; background: #fff; } +.an-selection-ring, .an-resize-handle, .an-width-guide, .an-height-guide, .an-layer-label { opacity: 0; transition: opacity 200ms ease; } +.an-selection[data-selected] :is(.an-selection-ring, .an-resize-handle, .an-width-guide, .an-height-guide, .an-layer-label) { opacity: 1; } +.an-width-guide { position: absolute; left: 0; right: 0; top: calc(100% + 18px); height: 7px; border-inline: 1px solid #a9b4d9; } +.an-width-guide::before { content: ''; position: absolute; top: 3px; left: 0; right: 0; border-top: 1px solid #a9b4d9; } +.an-width-guide > span { position: absolute; left: 50%; top: -5px; transform: translateX(-50%); background: #fafbfd; padding: 0 7px; color: #5c6fa8; font-size: 11px; line-height: 17px; white-space: nowrap; font-variant-numeric: tabular-nums; } +.an-height-guide { position: absolute; top: 0; bottom: 0; left: calc(100% + 14px); width: 7px; border-block: 1px solid #a9b4d9; } +.an-height-guide::before { content: ''; position: absolute; left: 3px; top: 0; bottom: 0; border-left: 1px solid #a9b4d9; } +.an-height-guide > span { position: absolute; top: 50%; left: 9px; transform: translateY(-50%); color: #5c6fa8; font-size: 11px; line-height: 17px; font-variant-numeric: tabular-nums; } +/* These centers move fractionally as the box is resized. Keep the labels on + their own lasting layer through the final frame, including the return. */ +.an-width-guide > span, .an-height-guide > span { will-change: transform; } +.an-layer-label { position: absolute; top: -24px; left: 0; color: #5c6fa8; font-size: 11px; } +.an-flow-title { display: flex; align-items: center; justify-content: space-between; position: absolute; left: 28px; right: 28px; top: 28px; } +.an-flow-title strong { font-size: 13px; font-weight: 600; color: #292d34; } +.an-flow-title > span { font-size: 11px; color: #68717d; transition: color 160ms ease, opacity 100ms ease; } +.an-flow-title > span[data-hover] { color: #292d34; } +.an-flow-title > span[data-pressed] { opacity: .55; } +.an-flow-node { position: absolute; padding: 15px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e06, 0 5px 16px #17202e05; } +.an-node-name { display: flex; align-items: center; gap: 7px; } +.an-node-name > strong { color: #292d34; font-size: 13px; line-height: 19px; font-weight: 600; } +.an-flow-node code, .an-endpoint { display: block; margin-top: 7px; font: 11px/16px var(--ss-mono); color: #68717d; } +.an-send { display: inline-flex; align-items: center; justify-content: center; height: 28px; padding: 0 9px; border: 1px solid #dce0e6; border-radius: 5px; background: #fff; color: #3e4550; font-size: 11px; line-height: 16px; font-weight: 550; box-shadow: 0 1px 2px #17202e06; transition: background-color 160ms ease; } +.an-send[data-hover] { background: #f5f6f8; } +.an-send[data-pressed] { background: #e8ebef; } +.an-receipt { display: flex; align-items: center; gap: 6px; height: 18px; margin-top: 16px; color: #68717d; font-size: 11px; line-height: 16px; transition: color 220ms ease; } +.an-flow-destination[data-received] .an-receipt { color: #397767; } +.an-flow-source { height: 184px; } +.an-flow-destination { height: 134px; } +.an-flow--compact .an-flow-source { height: 184px; } +.an-flow--compact .an-flow-destination { height: 134px; } +.an-event-kind { margin-left: auto; color: #818894; font-size: 10px; line-height: 16px; } +.an-event-record { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 17px; height: 32px; } +.an-event-record > span { color: #68717d; font-size: 11px; } +.an-event-record b { color: #3e4550; font-weight: 500; } +.an-event-record > strong { color: #292d34; font-size: 22px; line-height: 28px; font-weight: 550; letter-spacing: -.45px; } +.an-event-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 13px; margin-top: 14px; border-top: 1px solid #edf0f3; } +.an-event-footer > span { color: #818894; font-size: 10px; line-height: 14px; white-space: nowrap; } +.an-flow-destination .an-node-name > .ss-icon { color: #498576; } +.an-flow-destination .an-endpoint { font-family: var(--ss-font); } +.an-receipt-amount { display: flex; align-items: baseline; gap: 8px; margin-top: 5px; color: #292d34; font-size: 15px; line-height: 20px; font-weight: 550; opacity: 0; transform: translateY(3px); will-change: transform; transition: opacity 250ms ease, transform 350ms cubic-bezier(.22,1,.36,1); } +.an-receipt-amount > span { font-size: 10px; font-weight: 400; color: #818894; } +.an-receipt-amount[data-visible] { opacity: 1; transform: translateY(0); } +.an-flow .ss-connector path { stroke-width: 1.2; } +.an-flow .ss-connector__label { font-size: 11px; font-weight: 550; box-shadow: 0 0 0 1px #dfe5e5, 0 1px 2px #2541370d, 0 3px 6px #2541370b; color: #397767; background: #fff; } +.an-canvas-actions { display: flex; align-items: center; gap: 12px; color: #59616e; font-size: 11px; } +.an-canvas-actions > span { transition: color 160ms ease, opacity 100ms ease; } +.an-canvas-actions > span[data-hover] { color: #292d34; } +.an-canvas-actions > span[data-pressed] { opacity: .55; } +.an-moving-layer { z-index: 2; } +.an-layer-art { position: absolute; inset: 0; overflow: hidden; border-radius: 3px; background: #d8e0e6; box-shadow: 0 1px 2px #17202e12; } +.an-layer-art > img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center 25%; } +.an-follow-outline { position: absolute; inset: -3px; border: 1px solid #7387b2; } +.an-attached-note { position: absolute; right: 0; bottom: calc(100% + 18px); display: grid; gap: 3px; width: 158px; padding: 9px 10px; border: 1px solid #dfe3e8; border-radius: 5px; background: #fff; box-shadow: 0 1px 2px #17202e06, 0 3px 8px #17202e05; } +.an-attached-note strong { font-size: 12px; line-height: 17px; font-weight: 550; color: #292d34; } +.an-attached-note > span { font-size: 11px; line-height: 16px; color: #59616e; white-space: nowrap; } +.an-attached-leader { position: absolute; right: 14px; top: -18px; width: 1.5px; height: 18px; overflow: visible; } +.an-attached-leader path { fill: none; stroke: #7387b2; stroke-width: 1; } +.an-attached-leader circle { fill: #7387b2; } +.an-follow-outline, .an-attached-note, .an-attached-leader { opacity: 0; transition: opacity 200ms ease; } +.an-follow-outline[data-visible], .an-attached-note[data-visible], .an-attached-leader[data-visible] { opacity: 1; } +.an-chart--compact .an-chart-panel { inset: 51px 16px auto; height: 318px; } +.an-chart--compact .an-chart-total { left: 16px; top: 62px; font-size: 25px; } +.an-chart--compact .an-chart-note { top: 116px; right: 14px; width: 128px; } +.an-chart--compact .an-plot { left: 16px; right: 16px; top: 185px; height: 92px; } +.an-chart--compact .an-chart-axis { left: 16px; right: 16px; } + +/* Shared work branches once, then each destination receives its own result. */ +.an-asset-source { position: absolute; padding: 13px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e05, 0 4px 12px #17202e04; } +.an-asset-file { display: flex; align-items: center; gap: 9px; } +.an-file-preview { flex: none; color: #68717d; } +.an-asset-file strong, .an-asset-file span { display: block; } +.an-asset-file strong { color: #292d34; font-size: 13px; font-weight: 600; line-height: 18px; } +.an-asset-file span { margin-top: 1px; color: #68717d; font-size: 11px; line-height: 15px; white-space: nowrap; } +.an-asset-action { display: inline-flex; align-items: center; justify-content: center; height: 27px; padding: 0 10px; margin-top: 10px; border-radius: 5px; border: 1px solid #dce0e6; background: #fff; color: #3e4550; font-size: 11px; line-height: 16px; font-weight: 550; box-shadow: 0 1px 2px #17202e06; transition: background-color 160ms ease; } +.an-asset-action[data-hover] { background: #f5f6f8; } +.an-asset-action[data-pressed] { background: #e8ebef; } +.an-asset-output { position: absolute; display: block; align-content: start; text-align: center; } +.an-job-pill { display: flex; align-items: center; justify-content: center; width: 120px; height: 32px; margin-inline: auto; padding: 0 11px; border: 1px solid #dfe3e8; border-radius: 18px; background: #fff; box-shadow: 0 2px 4px #17202e0c, 0 1px 2px #17202e05; color: #59616e; font-size: 13px; line-height: 18px; font-weight: 550; transition: color 220ms ease, border-color 220ms ease, background-color 220ms ease; } +/* The visible group owns centering. An absent check reserves neither width + nor gap; opening its slot carries the persistent label with it. */ +.an-job-mark { position: relative; flex: none; width: 0; height: 12px; transition: width 260ms cubic-bezier(.22, 1, .36, 1); } +.an-job-check { position: absolute; top: 0; left: 8px; opacity: 0; transition: opacity 220ms ease; } +.an-asset-output[data-ready] .an-job-pill { color: #397767; border-color: #acc9bd; background: #fcfefd; } +.an-asset-output[data-ready] .an-job-mark { width: 20px; } +.an-asset-output[data-ready] .an-job-check { opacity: 1; } +.an-asset-output[data-compact] .an-job-pill { width: 72px; height: 32px; padding: 0 7px; } +.an-asset-output[data-compact] .an-job-mark { display: none; } +.an-branch .ss-connector path { stroke-width: 1.1; } +.an-branch .ss-connector__arrow { stroke-width: 1.1; } +.an-branch .ss-connector__dot { r: 2.5px; fill: #8659cc; stroke: #fff; stroke-width: 1.5; filter: drop-shadow(0 1px 1px #39245f26); } + +/* A fixed route communicates topology; only its dash phase communicates the + ongoing feed. Native offscreen and viewer pause suspend this CSS animation. */ +.an-stream-node { position: absolute; height: 118px; padding: 14px; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e06, 0 5px 16px #17202e05; } +.an-stream-source-title { display: flex; align-items: center; gap: 9px; height: 34px; } +.an-stream-source-title strong { display: block; color: #292d34; font-size: 13px; line-height: 19px; font-weight: 600; } +.an-stream-source-title span:not(.an-stream-source-icon) { color: #68717d; font-size: 10px; line-height: 14px; } +.an-stream-source-icon { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border: 1px solid #deebe6; border-radius: 7px; color: #397767; background: #f3f8f6; } +.an-stream-warehouse { position: absolute; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e06, 0 5px 16px #17202e05; overflow: hidden; } +.an-stream-warehouse > header { display: flex; justify-content: space-between; align-items: center; height: 44px; padding: 0 14px; } +.an-stream-warehouse > header > strong { font-size: 13px; font-weight: 600; color: #292d34; } +.an-stream-warehouse > header > span { font-size: 10px; color: #818894; } +.an-stream-columns { display: flex; align-items: center; justify-content: space-between; padding: 0 14px; height: 28px; background: #fafbfc; border-block: 1px solid #edf0f3; color: #818894; font-size: 10px; } +.an-stream-record, .an-stream-history { display: flex; justify-content: space-between; align-items: center; gap: 8px; height: 31px; padding: 0 14px; font-size: 11px; line-height: 16px; color: #68717d; } +.an-stream-record { background: #fafbfc; color: #68717d; transition: color 250ms ease, background-color 250ms ease; } +.an-stream-record[data-visible] { background: #f0f7f4; color: #397767; } +.an-stream-record > span { display: flex; align-items: center; gap: 6px; white-space: nowrap; } +.an-stream-record i { flex: none; width: 4px; height: 4px; border-radius: 50%; background: currentColor; } +.an-stream-history { color: #89919b; } +.an-stream-warehouse time { font-size: 10px; font-variant-numeric: tabular-nums; color: #89919b; } +.an-stream-connect { display: inline-flex; align-items: center; justify-content: center; margin-top: 12px; height: 28px; padding: 0 10px; border: 1px solid #dce0e6; border-radius: 5px; background: #fff; color: #3e4550; font-size: 11px; line-height: 16px; font-weight: 550; box-shadow: 0 1px 2px #17202e06; transition: background-color 160ms ease; } +.an-stream-connect[data-hover] { background: #f5f6f8; } +.an-stream-connect[data-pressed] { background: #e8ebef; } +.an-stream-receiving { transition: color 220ms ease; display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 14px; border-top: 1px solid #edf0f3; color: #68717d; font-size: 11px; line-height: 16px; } +.an-stream-status > i, .an-stream-receiving > i { display: inline-block; flex: none; width: 4px; height: 4px; border-radius: 50%; background: currentColor; } +.an-flow-title .an-stream-status { transition: color 220ms ease; display: flex; align-items: center; gap: 5px; font-size: 11px; } +.an-stream-status[data-live], .an-stream-receiving[data-live] { color: #397767; } +.an-stream-route { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; fill: none; pointer-events: none; opacity: 0; } +.an-stream-route[data-ready] { opacity: 1; } +.an-stream-reveal { stroke: #fff; stroke-width: 3; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset var(--an-stream-reveal-duration) linear; } +.an-stream-route[data-active] .an-stream-reveal { stroke-dashoffset: 0; } +.an-stream-underlay { stroke: #d8e3e5; stroke-width: 1; } +.an-stream-phase { stroke: #0d9488; stroke-width: 1.5; stroke-dasharray: .1 3.9; stroke-linecap: round; opacity: 0; transition: opacity 250ms ease; } +.an-stream-arrow { stroke: #0d9488; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transition: opacity 250ms ease; } +.an-stream-route[data-active] .an-stream-phase { opacity: 1; animation: an-stream-phase 1800ms linear infinite; } +.an-stream-route[data-arrived] .an-stream-arrow { opacity: 1; } +.an-stream-rate { position: absolute; font-size: 11px; line-height: 16px; color: #397767; opacity: 0; transition: opacity 250ms ease; white-space: nowrap; } +.an-stream-rate[data-visible] { opacity: 1; } +.an-stream-rest { position: absolute; width: 20px; height: 20px; } +@keyframes an-stream-phase { to { stroke-dashoffset: -24; } } +@media (prefers-reduced-motion: reduce) { + .an-gallery *, .an-gallery *::before, .an-gallery *::after { transition-duration: 0s !important; transition-delay: 0s !important; } + .an-stream-route[data-active] .an-stream-phase { animation: none; } +} + +/* At the narrowest canvas, reserve space for the whole measurement, not + just its card. The label interrupts its guide instead of crowding the edge. */ +.an-measure--narrow .an-height-guide { left: calc(100% + 12px); } +.an-measure--narrow .an-height-guide > span { left: 3px; padding: 0 4px; background: #fafbfd; transform: translate(-50%, -50%); } + +.an-composed-rest { position: absolute; width: 20px; height: 20px; } diff --git a/skills/imported/benjitaylor/stageshow/examples/annotations/composed.css b/skills/imported/benjitaylor/stageshow/examples/annotations/composed.css new file mode 100644 index 0000000..2accc30 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/annotations/composed.css @@ -0,0 +1,81 @@ +/* Composed relationships: real usage rows feed one invoice, while a selected + payout links to its settlement and a quieter explanation. */ +.an-collection-scene, .an-detail-scene { position: absolute; inset: 0; } +.an-payout-panel, .an-settlement-detail { position: absolute; border: 1px solid #dfe3e8; border-radius: 6px; background: #fff; box-shadow: 0 1px 2px #17202e05, 0 4px 12px #17202e04; } +.an-usage-monitor, .an-usage-invoice { position: absolute; border: 1px solid #dfe3e8; background: #fff; color: #292d34; } +.an-usage-monitor { z-index: 3; padding: 14px; border-radius: 6px; box-shadow: 0 2px 5px #17202e07, 0 12px 28px #17202e0a; } +.an-usage-monitor > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; height: 18px; } +.an-usage-monitor > header > strong { font-size: 13px; font-weight: 600; } +.an-usage-monitor > header > span { color: #68717d; font-size: 11px; } +.an-usage-sources { margin: 12px -14px 0; } +.an-usage-source { display: flex; align-items: center; justify-content: space-between; gap: 10px; height: 36px; padding: 0 14px; border-top: 1px solid #edf0f3; } +.an-usage-source > span { display: flex; align-items: center; gap: 6px; color: #59616e; font-size: 12px; line-height: 18px; white-space: nowrap; } +.an-source-cue { display: inline-block; width: 4px; height: 4px; flex: none; border-radius: 50%; } +.an-usage-source > strong { font-size: 13px; line-height: 18px; font-weight: 550; font-variant-numeric: tabular-nums; white-space: nowrap; } +.an-usage-sync { display: inline-flex; align-items: center; justify-content: center; margin-top: 12px; height: 28px; padding: 0 10px; border: 1px solid #dce0e6; border-radius: 5px; background: #fff; color: #3e4550; box-shadow: 0 1px 2px #17202e06; font-size: 11px; line-height: 16px; font-weight: 550; transition: background-color 160ms ease; } +.an-usage-sync[data-hover] { background: #f5f6f8; } +.an-usage-sync[data-pressed] { background: #e8ebef; } +.an-usage-invoice { z-index: 1; padding: 20px; border-radius: 3px; box-shadow: 0 1px 2px #17202e08, 0 6px 18px #17202e05; } +.an-usage-invoice > header > strong { display: block; font-size: 18px; line-height: 24px; font-weight: 600; letter-spacing: -.3px; } +.an-usage-invoice > header > span { display: block; margin-top: 4px; color: #68717d; font-size: 11px; line-height: 16px; font-variant-numeric: tabular-nums; } +.an-invoice-recipient { display: grid; gap: 3px; margin-top: 20px; font-size: 11px; line-height: 16px; color: #68717d; } +.an-invoice-recipient > strong { color: #3e4550; font-size: 13px; line-height: 18px; font-weight: 550; } +.an-invoice-balance { margin-top: 24px; } +.an-invoice-balance-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 16px; font-size: 11px; line-height: 16px; color: #59616e; white-space: nowrap; } +.an-invoice-live { display: flex; align-items: center; gap: 5px; color: #68717d; } +.an-invoice-status-dot { width: 4px; height: 4px; flex: none; border-radius: 50%; background: #a0a7b2; transition: background-color 200ms ease; } +.an-invoice-status-dot[data-ready] { background: #0d9488; } +.an-invoice-total { margin-top: 4px; min-height: 36px; } +.an-invoice-total > .ss-states { font-size: 28px; line-height: 28px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.7px; } +.an-invoice-lines { margin-top: 16px; } +.an-invoice-lines > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; height: 28px; border-top: 1px solid #edf0f3; font-size: 12px; } +.an-invoice-lines > div > span:first-child { color: #59616e; white-space: nowrap; } +.an-invoice-lines .ss-states { font-variant-numeric: tabular-nums; } +.an-fine-dotted .ss-connector__line { stroke-width: 1.5; stroke-dasharray: .1 4.9 !important; stroke-linecap: round; animation: an-composed-phase 1800ms linear infinite paused; } +.an-collection-scene[data-live] .an-fine-dotted .ss-connector__line, .an-detail-scene[data-transmitting] .an-fine-dotted .ss-connector__line { animation-play-state: running; } +.an-usage-feed--0 .ss-connector__line { stroke: #3478e5; } +.an-usage-feed--1 .ss-connector__line { stroke: #3478e5; } +.an-usage-feed--2 .ss-connector__line { stroke: #3478e5; } +.an-usage-delivery .ss-connector__line, .an-usage-delivery .ss-connector__arrow { stroke: #3478e5; stroke-width: 1.25; } +.an-usage-junction { position: absolute; inset: 0; z-index: 21; width: 100%; height: 100%; overflow: visible; pointer-events: none; } +.an-usage-junction > circle { fill: #3478e5; stroke: #fff; stroke-width: 2; filter: drop-shadow(0 1px 1px #153f7826); opacity: 0; transform: scale(.65); transform-box: fill-box; transform-origin: center; transition: opacity 180ms ease, transform 220ms ease; } +.an-usage-junction[data-visible] > circle { opacity: 1; transform: scale(1); } +.an-payout-panel { overflow: hidden; } +.an-payout-panel > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; height: 62px; padding: 0 16px; border-bottom: 1px solid #edf0f3; } +.an-payout-panel > header > div { display: grid; gap: 3px; } +.an-payout-panel > header strong { color: #292d34; font-size: 13px; line-height: 18px; font-weight: 600; } +.an-payout-panel > header > div > span { color: #68717d; font-size: 11px; line-height: 16px; } +.an-payout-panel > header > span { color: #68717d; font-size: 11px; transition: color 160ms ease, opacity 100ms ease; } +.an-payout-panel > header > span[data-hover] { color: #326be6; } +.an-payout-panel > header > span[data-pressed] { opacity: .55; } +.an-payout-columns { display: flex; align-items: center; justify-content: space-between; height: 28px; padding: 0 16px; background: #fafbfc; color: #68717d; font-size: 11px; } +.an-payout-row { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 44px; padding: 0 16px; border-top: 1px solid #edf0f3; color: #3e4550; transition: color 200ms ease; } +/* The row's fill is inset the same 4px on every side, its corners the panel's 6px less that inset, rounded up */ +.an-payout-row::before { content: ''; position: absolute; inset: 4px; border-radius: 4px; background: transparent; transition: background-color 200ms ease; } +.an-payout-row > span { position: relative; display: flex; align-items: center; gap: 10px; font-size: 12px; white-space: nowrap; } +.an-payout-row small { color: #68717d; font-size: 11px; } +.an-payout-row > strong { position: relative; color: #292d34; font-size: 13px; font-weight: 550; font-variant-numeric: tabular-nums; white-space: nowrap; transition: color 200ms ease; } +.an-payout-row[data-hover]::before { background: #f6f9ff; } +.an-payout-row[data-selected] { color: #326be6; } +.an-payout-row[data-selected]::before { background: #eff4ff; } +.an-payout-row[data-selected] > strong { color: #326be6; } +.an-payout-row[data-pressed]::before { background: #e2ebff; } +.an-settlement-detail { z-index: 2; height: 184px; padding: 18px; box-shadow: 0 2px 5px #17202e07, 0 10px 24px #17202e09; opacity: 0; transition: opacity 250ms ease; } +.an-settlement-detail[data-visible] { opacity: 1; } +.an-settlement-detail > header { display: flex; align-items: center; justify-content: space-between; color: #68717d; font-size: 11px; line-height: 16px; } +.an-settlement-detail > header .ss-icon { color: #397767; } +.an-settlement-amount { display: block; margin-top: 10px; color: #292d34; font-size: 25px; line-height: 32px; font-weight: 600; letter-spacing: -.6px; font-variant-numeric: tabular-nums; } +.an-settlement-subtitle { display: block; margin-top: 4px; color: #68717d; font-size: 11px; line-height: 16px; } +.an-settlement-breakdown { display: grid; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #edf0f3; } +.an-settlement-breakdown > div { display: flex; justify-content: space-between; gap: 10px; color: #59616e; font-size: 11px; line-height: 16px; font-variant-numeric: tabular-nums; } +.an-record-link .ss-connector__dot { r: 3px; fill: #326be6; stroke: #fff; stroke-width: 2; filter: drop-shadow(0 1px 1px #153f7826); } +.an-record-link .ss-connector__line { stroke: #326be6; } +.an-detail-leader .ss-connector__line { stroke: #929ba8; stroke-width: 1; } +.an-arrival-note { position: absolute; display: grid; align-content: center; justify-items: center; gap: 3px; min-height: 36px; opacity: 0; transition: opacity 220ms ease; } +.an-arrival-note[data-visible] { opacity: 1; } +.an-arrival-note > strong { color: #292d34; font-size: 12px; line-height: 17px; font-weight: 550; } +.an-arrival-note > span { color: #59616e; font-size: 11px; line-height: 16px; white-space: nowrap; } +@media (prefers-reduced-motion: reduce) { .an-fine-dotted .ss-connector__line { animation: none; } } + + +@keyframes an-composed-phase { to { stroke-dashoffset: -25; } } diff --git a/skills/imported/benjitaylor/stageshow/examples/annotations/composed.tsx b/skills/imported/benjitaylor/stageshow/examples/annotations/composed.tsx new file mode 100644 index 0000000..e957966 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/annotations/composed.tsx @@ -0,0 +1,123 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Connector, Icon, Stage, States, type Act } from '../../kit' +import './composed.css' + + +// The compact output detours around the incoming routes. Keep this one path, +// rather than drawing separate segments simultaneously. Its arrow follows the +// real transition completion, including a paused host and reduced motion. +function UsageDelivery({ path, to, show }: { path: string; to: { x: number; y: number }; show: boolean }) { + const line = useRef(null), [arrived, setArrived] = useState(false) + useLayoutEffect(() => { + const element = line.current + setArrived(false) + if (!element || !show) return + const check = () => { if (Math.abs(parseFloat(getComputedStyle(element).strokeDashoffset)) < .0001) setArrived(true) } + const finish = (event: TransitionEvent) => { if (event.target === element && event.propertyName === 'stroke-dashoffset') check() } + element.addEventListener('transitionend', finish); element.addEventListener('transitioncancel', finish); check() + return () => { element.removeEventListener('transitionend', finish); element.removeEventListener('transitioncancel', finish) } + }, [path, show]) + const arrow = `M${to.x+6} ${to.y-4}L${to.x} ${to.y}L${to.x+6} ${to.y+4}` + return +} + +type Collection = { phase: number } +async function collectUsage(a: Act) { + a.mark('start'); await a.wait(850) + await a.move({ at: 'sync-usage', x: .92, y: .85 }); await a.click() + a.mark('collect'); await a.set({ phase: 1 }) + await a.move('usage-rest', { duration: 350 }); await a.wait(750) + a.mark('post'); await a.set({ phase: 2 }); await a.wait(1100) + a.mark('invoice'); await a.set({ phase: 3 }) + await a.wait(3050); a.mark('done') + // The first invoice update is complete; its three live sources stay connected. +} + +/** Three live usage sources feed one invoice; the junction is an aggregation, + * not a fourth decorative service. The result waits for the final delivery. */ +export function CollectionStudy({ width }: { width: number }) { + const compact = width < 520 + const rows = [ + { name: 'API requests', meter: 'API calls', value: '24.8k', amount: '$124.00', colour: '#3478e5' }, + { name: 'Storage', meter: 'Storage', value: '18.2 GB', amount: '$18.20', colour: '#3478e5' }, + { name: 'Background jobs', meter: 'Jobs', value: '312', amount: '$46.80', colour: '#3478e5' }, + ] + return + {(s, rect) => { + const sources = rows.map((_, i) => rect(`usage-source-${i}`)), invoice = rect('usage-invoice'), field = rect('usage-invoice-field') + const junction = { x: sources[1].x + sources[1].width + 24, y: sources[1].y + sources[1].height / 2 } + const destination = { x: invoice.x + invoice.width, y: field.y + field.height / 2 } + const ready = sources.every(r => r.width > 0) && invoice.width > 0 && field.width > 0 + const outer = width - 16, radius = 6, direction = destination.y > junction.y ? 1 : -1 + const delivery = `M${junction.x} ${junction.y}H${outer-radius}Q${outer} ${junction.y} ${outer} ${junction.y+direction*radius}V${destination.y-direction*radius}Q${outer} ${destination.y} ${outer-radius} ${destination.y}H${destination.x}` + return
= 1 || undefined}> +
+
Invoice#0084 · October 2026
+
Bill toCoast workspace
+
+
Amount due
+
+
+
{rows.map(row =>
{row.name}
)}
+
+
+
UsageOctober
+
{rows.map((row, i) =>
{row.meter}{row.value}
)}
+
+
+ {sources.map((source, i) => = 1} />)} + = 2} /> + = 2 || undefined} aria-hidden> + +
+ }} +
+} + +type Detail = { phase: number; selected: boolean; clearing: boolean } +async function inspectPayout(a: Act) { + a.mark('start'); await a.wait(850) + await a.move({ at: 'payout-row', x: .2, y: .5 }); await a.click() + a.mark('link'); await a.set({ selected: true, phase: 1 }) + await a.move('payout-rest', { duration: 350 }); await a.wait(750) + a.mark('detail'); await a.set({ phase: 2 }); await a.wait(920) + a.mark('leader'); await a.set({ phase: 3 }); await a.wait(1100) + a.mark('explanation'); await a.set({ phase: 4 }); await a.wait(2300) + await a.move('clear-payout'); await a.click(); a.mark('clear') + await a.set({ clearing: true }); await a.wait(400) + await a.set({ selected: false, clearing: false, phase: 0 }); await a.wait(600); a.mark('done') +} + +/** A linked record and a field explanation use different line treatments. + * The payout list remains the main product view through the entire story. */ +export function DetailStudy({ width }: { width: number }) { + const compact = width < 520 + return + {(s, rect) => { + const row = rect('payout-row'), detail = rect('settlement-detail'), note = rect('arrival-note') + const from = { x: row.x + row.width / 2, y: row.y + row.height } + const to = compact ? { x: detail.x + detail.width / 2, y: detail.y } : { x: detail.x + detail.width, y: detail.y + detail.height - 34 } + const leaderFrom = { x: detail.x + detail.width / 2, y: detail.y + detail.height } + const leaderTo = { x: note.x + note.width / 2, y: note.y } + const measured = row.width > 0 && detail.width > 0 && note.width > 0 && !s.clearing + return
+
+
PayoutsChecking •0814
Clear detail
+
CreatedAmount
+
Oct 18 Paid$740.00
+
Oct 22 Paid$920.00
+
Today Scheduled$1,284.60
+
+
= 2 && !s.clearing || undefined} style={compact ? { left: 32, top: 314, width: width - 64 } : { left: 28, top: 150, width: 224 }}> +
Settlement
+ $1,284.60Net to your account +
Payments$1,320.00
Fees−$35.40
+
+
= 4 && !s.clearing || undefined} style={compact ? { left: 32, top: 536, width: width - 64 } : { left: 28, top: 372, width: 224 }}>Expected arrivalOct 27 · Checking •0814
+ = 1} /> + = 3} /> + +
+ }} +
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx b/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx new file mode 100644 index 0000000..2b2780c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx @@ -0,0 +1,281 @@ +import { useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { Connector, Follow, Icon, Stage, States, type Act, type Rect } from '../../kit' +import './annotations.css' +import { CollectionStudy, DetailStudy } from './composed' + +const lakePhoto = new URL('../wikipedia/kawaguchiko.jpg', import.meta.url).href + +const studies = [ + { id: 'collect', title: 'Usage into an invoice', note: 'Connect three usage sources to one invoice. Blue dots keep flowing after its first update; a matching shared delivery shows where those inputs meet.' }, + { id: 'detail', title: 'A payout and its detail', note: 'Select one payout. A dotted link opens its settlement; a quieter solid leader explains arrival without replacing the product view.' }, + { id: 'chart', title: 'A chart point', note: 'Select one result. The leader joins the measured point to its explanation, then leaves when the selection clears.' }, + { id: 'measure', title: 'Selection guides', note: 'Resize a card by its handle. Its guides and dimensions belong to the same box, including on the return drag.' }, + { id: 'flow', title: 'A request and its reply', note: 'Send a billing event. The solid request points out; the dashed acknowledgement returns only after the receipt arrives.' }, + { id: 'branch', title: 'A shared processing graph', note: 'Store one asset, then send it along a shared trunk to three jobs. Each branch meets its destination cleanly; Clear jobs dismisses the result.' }, + { id: 'stream', title: 'An ongoing event stream', note: 'Connect the feed once. A quiet route stays fixed while moving dashes show events arriving in the activity table; reduced motion keeps its direction visible.' }, + { id: 'follow', title: 'Following a layer', note: 'Arrange a selected layer. Its annotation travels and resizes with the object, then fades intact when selection clears.' }, +] as const +type Study = typeof studies[number]['id'] +const initialStudy = (): Study => { + const value = typeof window === 'undefined' ? null : new URL(window.location.href).searchParams.get('annotation') + return studies.find(study => study.id === value)?.id ?? 'collect' +} +const point = (r: Rect) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 }) + +type ChartState = { selected: boolean } +async function chartScript(a: Act) { + a.mark('start'); await a.wait(650) + await a.move('point'); await a.click(); a.mark('callout') + await a.set({ selected: true }); await a.move('chart-rest', { duration: 360 }); await a.wait(2440) + await a.click(); a.mark('clear') + await a.set({ selected: false }); await a.wait(700); a.mark('done') + await a.wait(700) +} +function ChartStudy({ width }: { width: number }) { + return + {(s, rect) => { + const p = rect('point'), note = rect('chart-note') + return <> +
+
API requestsLast 7 days
+
8,642 this week
+
Release v21,840 requests
+
+ + + + + + +
+
MonWedFriSun
+ +
+ {/* Meet the bottom edge vertically. A vertical-first elbow would + finish horizontally along the note's border. */} + 0 && note.width > 0} /> + + }} +
+} + +type MeasureState = { selected: boolean; width: number; height: number } +function MeasureStudy({ width }: { width: number }) { + const compact = width < 480 + const start = { width: compact ? 168 : 256, height: compact ? 136 : 150 } + const finish = { width: compact ? 188 : 304, height: compact ? 160 : 180 } + const x = compact ? width < 330 ? 36 + (width - 288) / 2 : Math.max(36, Math.round((width - finish.width - 48) / 2)) : (width - finish.width) / 2 - 14 + const script = useMemo(() => async (a: Act) => { + a.mark('start'); await a.wait(650); await a.move('measure-card'); await a.click() + await a.set({ selected: true }); await a.wait(500) + await a.move('resize'); const from = a.center('resize'); a.mark('resize') + await a.drag({ x: from.x + finish.width - start.width, y: from.y + finish.height - start.height }, (p, origin) => a.put({ width: start.width + p.x - origin.x, height: start.height + p.y - origin.y }), { duration: 1400 }) + await a.set(finish); await a.wait(2100) + await a.move('resize'); const back = a.center('resize'); a.mark('restore') + await a.drag({ x: back.x - finish.width + start.width, y: back.y - finish.height + start.height }, (p, origin) => a.put({ width: finish.width + p.x - origin.x, height: finish.height + p.y - origin.y }), { duration: 1400 }) + await a.set(start); await a.move('measure-rest'); await a.click() + await a.set({ selected: false }); await a.wait(650); a.mark('done'); await a.wait(650) + }, [compact]) + return + {s => <> +
Board layoutProject card
+
+
Website refreshRelease checklist
6 / 8tasks complete
Design team
+
+
{Math.round(s.width)} px
+
{Math.round(s.height)}
+ Project +
+ } + +} + +type FlowState = { step: number } +async function flowScript(a: Act) { + a.mark('start'); await a.wait(650); await a.move({ at: 'send', x: .92, y: .85 }); await a.click() + a.mark('request'); await a.set({ step: 1 }) + await a.move('flow-rest', { duration: 350 }); await a.wait(1050) + a.mark('reply'); await a.set({ step: 2 }); await a.wait(1100) + a.mark('acknowledged'); await a.set({ step: 3 }); await a.wait(2200) + await a.move('clear-trace'); await a.click(); a.mark('clear') + await a.set({ step: 0 }); await a.wait(750); a.mark('done'); await a.wait(650) +} +function FlowStudy({ width }: { width: number }) { + const compact = width < 480 + return + {(s, rect) => { + const a = rect('billing'), b = rect('workspace') + const from = compact ? { x: a.x + a.width / 2 - 28, y: a.y + a.height } : { x: a.x + a.width, y: a.y + a.height / 2 - 18 } + const to = compact ? { x: b.x + b.width / 2 - 28, y: b.y } : { x: b.x, y: b.y + b.height / 2 - 18 } + const backFrom = compact ? { x: to.x + 56, y: to.y } : { x: to.x, y: to.y + 40 } + const backTo = compact ? { x: from.x + 56, y: from.y } : { x: from.x, y: from.y + 40 } + return <> +
Webhook deliveryClear trace
+
+
Invoice paidEvent
invoice.paid +
Invoice 0084$189.00
+
Today, 14:32
+
+
= 2 || undefined} style={compact ? { left: 32, top: 330, width: width - 64 } : { left: width - 196, top: 115, width: 164 }}> +
Activity
Billing notifications +
= 2 ? 'received' : 'waiting'} states={{ waiting: 'Waiting for event', received: 'Payment received' }} />
= 2 || undefined}>$189.00 Invoice 0084
+
+ = 1 && a.width > 0} arrow label="POST" labelAt={compact ? .37 : .5} /> + = 2 && b.width > 0} arrow dashed label="200 OK" labelAt={compact ? .37 : .5} /> + + + }} +
+} + +type FollowState = { selected: boolean; arranged: boolean } +async function followScript(a: Act) { + a.mark('start'); await a.wait(650); await a.move('layer'); await a.click() + await a.set({ selected: true }); await a.wait(1000) + await a.move('arrange'); await a.click(); a.mark('arrange') + await a.set({ arranged: true }); await a.wait(2600) + await a.move('restore'); await a.click(); a.mark('restore') + await a.set({ arranged: false }); await a.wait(1400) + await a.move('follow-rest'); await a.click(); a.mark('clear') + await a.set({ selected: false }); await a.wait(650); a.mark('done'); await a.wait(650) +} +function FollowStudy({ width }: { width: number }) { + const compact = width < 480 + const start = { x: compact ? 36 : 90, y: compact ? 181 : 164, width: compact ? 168 : 188, height: 110 } + const end = { x: compact ? width - 228 : width - 302, y: compact ? 224 : 176, width: compact ? 192 : 242, height: compact ? 128 : 142 } + return + {s => <> +
Travel board
ArrangeReset
+ {/* Object, leader and note share Follow's changing coordinate space. + Reading the destination rect for an independent line would detach it + while Follow is still traveling toward that destination. */} + +
Mount Fuji reflected in Lake Kawaguchi
+
+
Lake KawaguchiKeep the reflection visible
+ + + } + +} + + +type BranchState = { phase: number; clearing: boolean } +async function branchScript(a: Act) { + a.mark('start'); await a.wait(650); await a.move({ at: 'process-asset', x: .92, y: .85 }); await a.click() + a.mark('upload'); await a.set({ phase: 1 }) + await a.move('graph-rest', { duration: 350 }); await a.wait(750) + a.mark('spine'); await a.set({ phase: 2 }); await a.wait(1100) + a.mark('branches'); await a.set({ phase: 3 }); await a.wait(1100) + a.mark('ready'); await a.set({ phase: 4 }); await a.wait(2300) + await a.move('clear-jobs'); await a.click(); a.mark('clear') + await a.set({ clearing: true }); await a.wait(350) + await a.set({ phase: 0, clearing: false }); await a.wait(700); a.mark('done'); await a.wait(650) +} +function BranchStudy({ width }: { width: number }) { + const compact = width < 520 + const names = ['Preview', 'Search', 'Archive'] + return + {(s, rect) => { + const source = rect('asset-source'), outputs = names.map((_, i) => rect(`asset-output-${i}`)) + const ports = outputs.map(r => compact ? { x: r.x + r.width / 2, y: r.y } : { x: r.x, y: r.y + r.height / 2 }) + const start = compact ? { x: source.x + source.width / 2, y: source.y + source.height } : { x: source.x + source.width, y: source.y + source.height / 2 } + const junction = compact ? { x: start.x, y: 216 } : { x: width - 278, y: ports[1].y } + const ends = compact ? ports.map(p => ({ x: p.x, y: junction.y })) : ports.map(p => ({ x: junction.x, y: p.y })) + const measured = source.width > 0 && outputs.every(r => r.width > 0) && !s.clearing + return <> +
Asset processingClear jobs
+
+
cover.jpg2.4 MB · Image
+
+
+ {names.map((name, i) =>
= (i === 1 ? 3 : 4) || undefined} aria-label={`${name}: ${s.phase >= (i === 1 ? 3 : 4) ? 'ready' : 'waiting'}`} style={compact ? { left: 16 + i * (width - 104) / 2, top: 254, width: 72, height: 48 } : { left: width - 214, top: 64 + i * 80, width: 180, height: 48 }}> + {name} +
)} + {/* Travel reaches the center first. Two non-overlapping spine halves + grow out from there; outer branches wait for those arrivals. + Connector owns each endpoint marker's actual arrival timing. */} + = 1} /> + {[0, 2].map(i => = 2} />)} + {ports.map((port, i) => = (i === 1 ? 2 : 3)} />)} + + + }} +
+} + +const STREAM_REVEAL_MS = 700 +type StreamState = { connected: boolean; receiving: boolean } +async function streamScript(a: Act) { + a.mark('start'); await a.wait(800); await a.move({ at: 'connect-stream', x: .92, y: .85 }); await a.click() + a.mark('connected'); await a.set({ connected: true }) + await a.move('stream-rest', { duration: 350 }); await a.wait(STREAM_REVEAL_MS + 100 - 350) + a.mark('live'); await a.set({ receiving: true }); await a.wait(800) + await a.wait(2250) + // The connection stays open. Only the line's phase continues; there is no + // polling script or invisible reset of the successful connection. + await a.wait(800); a.mark('done') +} +function StreamStudy({ width }: { width: number }) { + const compact = width < 520 + const revealId = `an-stream-${useId().replace(/:/g, '')}` + return + {(s, rect) => { + const a = rect('stream-source'), b = rect('stream-destination') + const from = compact ? { x: a.x + a.width / 2, y: a.y + a.height } : { x: a.x + a.width, y: a.y + a.height / 2 } + const to = compact ? { x: b.x + b.width / 2, y: b.y } : { x: b.x, y: from.y } + const path = `M${from.x} ${from.y}L${to.x} ${to.y}` + const arrow = compact ? `M${to.x - 3.5} ${to.y - 5}L${to.x} ${to.y}L${to.x + 3.5} ${to.y - 5}` : `M${to.x - 5} ${to.y - 3.5}L${to.x} ${to.y}L${to.x - 5} ${to.y + 3.5}` + return <> +
Event stream
+
+
CheckoutProduction
+
+
+
+
EventsLive feed
+
EventTime
+
+
Customer created
+
Session started
+
+
+ 0 && b.width > 0 || undefined} style={{ '--an-stream-reveal-duration': `${STREAM_REVEAL_MS}ms` } as CSSProperties} aria-hidden> + + + + + + 1.2k / sec + + + }} +
+} + +export default function Annotations() { + const [selected, choose] = useState(initialStudy), [revision, replay] = useState(0), [width, setWidth] = useState(null) + const host = useRef(null), id = useId() + const selectStudy = (value: Study) => { + choose(value) + const url = new URL(location.href) + url.searchParams.set('annotation', value) + history.replaceState(null, '', url) + if (parent !== window) parent.postMessage({ type: 'stageshow:status', annotation: value }, location.origin) + } + useLayoutEffect(() => { + const node = host.current + if (!node) return + const read = () => setWidth(Math.min(600, Math.floor(node.getBoundingClientRect().width))) + read(); const observer = new ResizeObserver(read); observer.observe(node) + return () => observer.disconnect() + }, []) + const active = studies.find(study => study.id === selected)! + return
+
+
+ {width !== null && (selected === 'collect' ? : selected === 'detail' ? : selected === 'chart' ? : selected === 'measure' ? : selected === 'flow' ? : selected === 'branch' ? : selected === 'stream' ? : )} +
+

{active.note}

+
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css b/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css new file mode 100644 index 0000000..f9504b6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css @@ -0,0 +1,81 @@ +.bg-gallery { + container-type: inline-size; + color: #242831; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + + & .bg-intro { max-width: 480px; margin: 0 0 28px; } + & .bg-intro h1 { margin: 0 0 10px; font-size: 26px; line-height: 32px; font-weight: 600; letter-spacing: -.035em; } + & .bg-intro p { margin: 0; color: #72747c; font-size: 14px; line-height: 21px; text-wrap: pretty; } + & .bg-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 30px 20px; } + @container (min-width: 640px) { & .bg-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + & .bg-study { min-width: 0; } + & .bg-picture { overflow: hidden; border-radius: 14px; } + & .bg-study h2 { margin: 13px 0 5px; font-size: 14px; line-height: 20px; font-weight: 600; letter-spacing: -.015em; } + & .bg-study > p { max-width: 350px; margin: 0; font-size: 12px; line-height: 18px; color: #72747c; text-wrap: pretty; } + & .ss-stage { background: transparent; } + & .bg-ground { position: absolute; inset: 0; display: grid; place-items: center; overflow: hidden; } + & .bg-ground--tint { background: #dfe2f4; } + & .bg-ground--field { background: linear-gradient(110deg, #ebaa91 0 32%, #d9e5e8 32% 100%); } + & .bg-ground--light { background: radial-gradient(ellipse at 0% 0%, #b6cebc, transparent 62%), radial-gradient(ellipse at 100% 100%, #e8b9a3, transparent 65%), #f5efdb; } + & .bg-ground--grid { background-color: #e6eef5; background-image: linear-gradient(#4a789915 1px, transparent 1px), linear-gradient(90deg, #4a789915 1px, transparent 1px); background-size: 24px 24px; background-position: center; } + & .bg-ground--architecture { background: #e9e6dc; } + & .bg-lines { position: absolute; inset: 0; fill: none; stroke: #919c8c; stroke-width: .8; opacity: .5; } + & .bg-ground--dark { background: radial-gradient(ellipse at 20% 0%, #454b50, transparent 75%), #191d21; } + & .bg-ground--paper { background: repeating-linear-gradient(0deg, #a9936506 0 1px, transparent 1px 4px), repeating-linear-gradient(92deg, #ffffff18 0 1px, transparent 1px 7px), #eee6d3; } + & .bg-ground--graphic { background: #d9e5e8; } + & .bg-geometry { position: absolute; inset: 0; overflow: hidden; } + & .bg-geometry > span { position: absolute; width: 260px; height: 260px; right: -100px; top: -155px; border: 36px solid #263a42; border-radius: 50%; box-sizing: border-box; } + & .bg-geometry > i { position: absolute; width: 260px; height: 260px; left: -125px; bottom: -170px; border-radius: 50%; background: #d1a23f; } + + /* A dot grid that fades toward the edges, so the card sits in a clear middle */ + & .bg-ground--dots { background: #f4f4f2; } + & .bg-ground--dots::before { content: ''; position: absolute; inset: 0; background-image: radial-gradient(circle, #9a9a94 1px, transparent 1.4px); background-size: 16px 16px; background-position: center; mask-image: radial-gradient(ellipse 75% 80% at 50% 50%, #000 30%, transparent 85%); opacity: .7; } + & .bg-ground--halftone { background: #f2ede4; } + & .bg-halftone { position: absolute; inset: 0; fill: #d4673f; } + & .bg-ground--blueprint { background-color: #1d3f72; background-image: linear-gradient(#ffffff2e 1px, transparent 1px), linear-gradient(90deg, #ffffff2e 1px, transparent 1px), linear-gradient(#ffffff12 1px, transparent 1px), linear-gradient(90deg, #ffffff12 1px, transparent 1px); background-size: 80px 80px, 80px 80px, 16px 16px, 16px 16px; background-position: center; } + & .bg-ground--grain { background: linear-gradient(160deg, #f6c7a8 0%, #d9a3c4 55%, #a9a7dc 100%); } + & .bg-grain { position: absolute; inset: 0; opacity: .7; mix-blend-mode: soft-light; } + & .bg-ground--conic { background: conic-gradient(from 160deg at 104% -12%, #ffcf70, #ff7f6e, #c86fd8, #6f8ff0, #6fd6c8, #ffcf70); } + & .bg-ground--stripes { background: repeating-linear-gradient(135deg, #1f2a3a10 0 1px, transparent 1px 7px), #eef0ec; } + + & .bg-ground--diagonal { background: linear-gradient(130deg, #6253e6 0%, #aa51d2 32%, #e96d94 68%, #ffa36e 100%); } + & .bg-ground--radial { background: radial-gradient(ellipse 110% 130% at 4% 7%, #fff2a0 0%, #ffd162 24%, #ff9557 48%, #ee587a 74%, #b83f78 100%); } + & .bg-ground--mesh { background: radial-gradient(ellipse 85% 90% at 3% 10%, #b4efb4 0%, #7ad8c8 32%, transparent 74%), radial-gradient(ellipse 90% 100% at 100% 90%, #bba0ed 0%, #9981df 38%, transparent 76%), linear-gradient(135deg, #78cfdc, #5c8ed3 58%, #7270c3); } + & .bg-ground--night { background: radial-gradient(ellipse 95% 100% at 0% 100%, #187f83 0%, #173d57 42%, transparent 78%), radial-gradient(ellipse 95% 110% at 100% 0%, #a84179 0%, #532858 40%, transparent 78%), #171a34; } + & .bg-ground--bands { background: linear-gradient(122deg, #1c315f 0% 47%, #3d74bd 47% 82%, #cbdfee 82% 100%); } + & .bg-ground--spectrum { background: linear-gradient(112deg, #17a99c 0%, #369bbd 20%, #6268cf 42%, #aa60c6 63%, #eb7f9c 83%, #f5af69 100%); } + + /* Above any texture the ground draws */ + & .bg-digest { position: relative; z-index: 1; width: 232px; box-sizing: border-box; padding: 16px; border-radius: 12px; color: #323840; background: #fff; box-shadow: 0 1px 1px #1c25320a, 0 8px 24px -12px #24344235; } + & .bg-digest-heading { display: flex; align-items: center; gap: 10px; } + & .bg-digest-icon { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 9px; color: #687086; background: #f0f2f7; } + & .bg-digest-heading > div { display: flex; flex-direction: column; gap: 4px; } + & .bg-digest-heading strong { font-size: 14px; line-height: 17px; font-weight: 600; letter-spacing: -.025em; } + & .bg-digest-heading > div > span { font-size: 10px; line-height: 13px; color: #787f89; } + & .bg-digest-recipient { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 19px; padding-bottom: 14px; border-bottom: 1px solid #edf0f2; font-size: 11px; line-height: 16px; } + & .bg-digest-recipient > span { color: #7c838c; } + & .bg-digest-recipient > strong { display: flex; align-items: center; gap: 6px; font-weight: 500; } + & .bg-digest-count { display: inline-grid; place-items: center; width: 17px; height: 17px; border-radius: 5px; font-size: 10px; color: #6b7280; background: #f1f3f5; } + & .bg-digest-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 13px; font-size: 10px; line-height: 14px; } + & .bg-digest-footer > span { display: flex; align-items: center; gap: 5px; } + & .bg-digest-footer > span > i { width: 5px; height: 5px; border-radius: 50%; background: #42a47d; } + & .bg-digest-button { padding: 5px 7px; border-radius: 5px; background: #f2f4f6; color: #525d6b; font-weight: 500; } + & .bg-ground--dark .bg-digest { background: linear-gradient(145deg, #3c4248, #30353b); color: #f0f2f3; box-shadow: inset 0 1px 0 #ffffff26, inset 0 0 0 1px #ffffff0b, 0 16px 28px -14px #000b; } + & .bg-ground--dark .bg-digest-icon { color: #c6ced8; background: #ffffff0d; } + & .bg-ground--dark .bg-digest-heading > div > span, + & .bg-ground--dark .bg-digest-recipient > span { color: #b0b8c1; } + & .bg-ground--dark .bg-digest-recipient { border-color: #ffffff13; } + & .bg-ground--dark .bg-digest-count, + & .bg-ground--dark .bg-digest-button { color: #cbd2d9; background: #ffffff0c; } + & .bg-ground--paper .bg-digest { background: #fffdf8; box-shadow: 0 1px 2px #6e502b17, 0 12px 24px -15px #6e502b50; } + & .bg-ground--night .bg-digest { box-shadow: 0 1px 0 #ffffff80, 0 18px 32px -14px #080e2ba6; } + & .bg-ground--bands .bg-digest { background: #f1f6ffb8; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #243348; box-shadow: inset 0 1px 0 #ffffffb3, inset 0 0 0 1px #ffffff4d, 0 12px 28px -14px #10254c66; } + & .bg-ground--bands .bg-digest-icon, + & .bg-ground--bands .bg-digest-button, + & .bg-ground--bands .bg-digest-count { color: #44566d; background: #ffffff4d; } + & .bg-ground--bands .bg-digest-heading > div > span, + & .bg-ground--bands .bg-digest-recipient > span { color: #3b5068; } + & .bg-ground--bands .bg-digest-recipient { border-color: #344b6a26; } + & .bg-ground--blueprint .bg-digest { box-shadow: 0 1px 0 #ffffff40, 0 18px 32px -14px #04122ca6; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx b/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx new file mode 100644 index 0000000..caaed30 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx @@ -0,0 +1,85 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Icon, Stage } from '../../kit' +import './backgrounds.css' + +// The object stays constant so the ground's contribution is easy to see. +// These are authored examples, not a fixed set of themes or presets. +const studies = [ + { id: 'diagonal', title: 'Diagonal gradient', note: 'Violet moves through pink into coral. A white surface keeps the content quiet.' }, + { id: 'radial', title: 'Radial light', note: 'A broad yellow light meets orange and rose at the edges, without a glow around the card.' }, + { id: 'mesh', title: 'Blended color fields', note: 'Mint, blue and violet meet in large fields. Their lightness changes gradually behind the UI.' }, + { id: 'night', title: 'Color on dark', note: 'Teal and magenta light sit at opposite edges. The clear foreground holds its contrast.' }, + { id: 'bands', title: 'Bands and glass', note: 'Broad ink-blue and cobalt bands leave a narrow pale edge. Their tint stays visible through the glass.' }, + { id: 'spectrum', title: 'Multicolor sweep', note: 'A wide sweep connects cool and warm hues. The card supplies one stable reading area.' }, + { id: 'tint', title: 'Tint', note: 'Lavender gives a white surface enough separation without a heavy shadow.' }, + { id: 'field', title: 'Two color fields', note: 'A warm band and a cool field meet behind the card, away from its content.' }, + { id: 'dots', title: 'Dot grid', note: 'A fine dot grid, fading out toward the edges, gives a canvas feel. The dots stay lighter than any line in the card.' }, + { id: 'halftone', title: 'Halftone', note: 'Dots grow toward one corner and vanish toward the card, so the texture frames the UI rather than sitting under it.' }, + { id: 'blueprint', title: 'Blueprint', note: 'Major and minor rules on deep blue. Two weights of line give scale; the white card is the only solid thing.' }, + { id: 'grain', title: 'Grain', note: 'A soft two-color gradient with fine grain over it, so the color reads as a material, not a screen fill.' }, + { id: 'conic', title: 'Conic sweep', note: 'Color turns around a point off the card’s corner. The sweep gives direction without a hard band.' }, + { id: 'stripes', title: 'Fine stripes', note: 'Hairline diagonals on a pale ground: texture you feel more than see, quieter than the card’s own divider.' }, + { id: 'light', title: 'Soft light', note: 'Sage, peach and cream share a light value, with a calm center for the UI.' }, + { id: 'grid', title: 'Grid', note: 'Fine blue rules establish scale. Their contrast stays below the product’s.' }, + { id: 'architecture', title: 'Architectural lines', note: 'A few precise edges give the scene direction without filling every gap.' }, + { id: 'dark', title: 'Dark material', note: 'A lifted graphite surface separates from the ground through value and edge light.' }, + { id: 'paper', title: 'Warm paper', note: 'Low contrast fibers and a warm shadow support a quiet, tangible surface.' }, + { id: 'graphic', title: 'Graphic geometry', note: 'Ink, ochre and pale blue frame the object with deliberate, substantial crops.' }, +] as const + +function Digest() { + return
+
Weekly digestEvery Friday at 09:00
+
RecipientsDesign team 6
+
ScheduledPreview
+
+} + +// A real halftone: one dot per cell on a 10px grid, each sized by its distance +// from the top right corner, down to nothing before it reaches the card +function Halftone() { + const dots: { x: number; y: number; r: number }[] = [] + for (let y = 5; y < 252; y += 10) for (let x = 5; x < 400; x += 10) { + const d = Math.hypot(400 - x, y) / 260 + const r = 3.4 * (1 - d) ** 1.4 + if (r > 0.35) dots.push({ x, y, r }) + } + return + {dots.map((d, i) => )} + +} + +function Study({ study }: { study: typeof studies[number] }) { + const ref = useRef(null) + const [width, setWidth] = useState(288) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => setWidth(node.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return
+
+ + {() =>
+ {study.id === 'architecture' && } + {study.id === 'graphic' &&
} + {study.id === 'halftone' && } + {study.id === 'grain' && } + +
} +
+
+

{study.title}

{study.note}

+
+} + +export default function Backgrounds() { + return
+

Backgrounds

The same interface on twenty different grounds.

+
{studies.map(study => )}
+
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/brand-marks/brands.css b/skills/imported/benjitaylor/stageshow/examples/brand-marks/brands.css new file mode 100644 index 0000000..25be06c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/brand-marks/brands.css @@ -0,0 +1,10 @@ +.bm-gallery { display: grid; gap: 32px; } +.bm-sheet { min-width: 0; } +.bm-sheet > h2 { margin: 0 0 12px; color: #303238; font: 550 14px/20px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +.bm-frame { width: 100%; overflow: hidden; border-radius: 10px; } +.bm-content { display: grid; gap: 28px; height: 100%; background: var(--ss-canvas); } +.bm-group { min-width: 0; } +.bm-group h3 { margin: 0 0 16px; color: var(--ss-text); font-size: 13px; line-height: 20px; font-weight: 500; } +.bm-grid { display: grid; row-gap: 20px; } +.bm-item { display: flex; min-width: 0; height: 82px; flex-direction: column; align-items: center; gap: 10px; } +.bm-item > span:last-child { max-width: 100%; height: 32px; padding-inline: 4px; color: var(--ss-text); font-size: 12px; line-height: 16px; text-align: center; } diff --git a/skills/imported/benjitaylor/stageshow/examples/brand-marks/index.tsx b/skills/imported/benjitaylor/stageshow/examples/brand-marks/index.tsx new file mode 100644 index 0000000..633873b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/brand-marks/index.tsx @@ -0,0 +1,49 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { BrandMark, Stage, brandMarks, type BrandName } from '../../kit' +import './brands.css' + +const names = Object.keys(brandMarks) as BrandName[] +const groups = ['Payments', 'Banks', 'Apps', 'Crypto'] as const + +function Sheet({ theme }: { theme: 'light' | 'dark' }) { + const ref = useRef(null) + const [width, setWidth] = useState(0) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => setWidth(node.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + const columns = width >= 600 ? 6 : 3 + const padding = width >= 600 ? 24 : 16 + const gridHeight = groups.reduce((total, group) => { + const rows = Math.ceil(names.filter(name => brandMarks[name].category === group).length / columns) + return total + rows * 82 + (rows - 1) * 20 + }, 0) + const height = padding * 2 + (groups.length - 1) * 28 + groups.length * 36 + gridHeight + return
+

{theme === 'light' ? 'Light' : 'Dark'}

+
+ {width > 0 && + {() =>
+ {groups.map(group =>
+

{group}

+
+ {names.filter(name => brandMarks[name].category === group).map(brand =>
+ + {brandMarks[brand].name} +
)} +
+
)} +
} +
} +
+
+} + +export default function BrandMarks() { + return
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/calendar/calendar.css b/skills/imported/benjitaylor/stageshow/examples/calendar/calendar.css new file mode 100644 index 0000000..a273a38 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/calendar/calendar.css @@ -0,0 +1,320 @@ +@keyframes cal-spin { + to { + transform: rotate(360deg); + } +} + +/* Well-gray stage; the window sits in from the top-left on one gutter and runs + off the right (Friday, cut by more than half) and the bottom (the late + afternoon). */ +.cal .ss-stage { + --gutter: 28px; + overflow: hidden; + background: var(--ss-well); +} + +.cal-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + width: 740px; + height: 420px; + /* The hero shadow already carries the ring */ + box-shadow: var(--ss-shadow-hero); +} + +.cal-window[data-layout='compact'] { + left: 20px; + width: 600px; + height: 400px; +} + +/* Compact type is set for the phone's column, drawn close to 1:1 */ +.cal-window[data-layout='compact'] .cal-hours { + width: 48px; + font-size: 10.5px; +} + +.cal-window[data-layout='compact'] .cal-head { + margin-left: 48px; +} + +.cal-window[data-layout='compact'] .cal-event__time { + font-size: 10.5px; +} + +.cal-window .ss-window__body { + background: var(--ss-surface); +} + +/* ---- Sidebar ---- */ + +.cal-side { + display: flex; + flex-direction: column; + gap: 22px; + padding: 10px 16px; +} + +.cal-mini { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cal-mini__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + row-gap: 4px; + margin: 0 -3px; +} + +.cal-mini__grid span { + position: relative; + height: 14px; +} + +.cal-mini__grid span::after { + content: ''; + position: absolute; + inset: 5px; + left: calc(50% - 2px); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ss-ink-4); +} + +.cal-mini__grid span[data-week] { + background: var(--ss-ink-1); +} + +.cal-mini__grid span[data-week]:first-of-type, +.cal-mini__grid span:nth-child(22) { + border-radius: 7px 0 0 7px; +} + +.cal-mini__grid span:nth-child(28) { + border-radius: 0 7px 7px 0; +} + +.cal-mini__grid span[data-today]::after { + background: var(--ss-text-strong); +} + +.cal-cals { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cal-cal { + display: flex; + align-items: center; + gap: 8px; +} + +.cal-cal__sw { + width: 10px; + height: 10px; + border-radius: 3px; + background: var(--ss-ink-4); +} + +.cal-cal__sw[data-accent] { + background: var(--ss-accent); +} + +/* ---- Toolbar ---- */ + +.cal-tools { + display: flex; + align-items: center; + gap: 6px; + color: var(--ss-icon); +} + +.cal-today { + margin-right: 4px; + padding: 4px 9px; + border-radius: 6px; + box-shadow: inset 0 0 0 1px var(--ss-line); + font: 500 11px/1.3 var(--ss-font); + color: var(--ss-text-strong); +} + +.cal-prev { + transform: scaleX(-1); +} + +/* ---- Week grid ---- */ + +.cal-head { + display: grid; + grid-template-columns: repeat(5, 108px); + margin-left: 44px; + padding: 2px 0 10px; + border-bottom: 1px solid var(--ss-line); + font: 500 11px/1.3 var(--ss-font); + color: var(--ss-text); +} + +.cal-head span { + padding-left: 8px; +} + +.cal-head span[data-today] { + color: var(--ss-text-strong); + font-weight: 600; +} + +.cal-body { + display: flex; + padding-top: 8px; +} + +.cal-hours { + display: grid; + grid-auto-rows: 40px; + width: 44px; + padding-right: 8px; + font: 500 9px/1 var(--ss-font); + color: var(--ss-text-faint); + text-align: right; +} + +.cal-hours span { + transform: translateY(-4px); +} + +.cal-grid { + position: relative; + width: 540px; + height: 280px; + background: + linear-gradient(var(--ss-line) 1px, transparent 1px) 0 0 / 100% 40px, + linear-gradient(90deg, var(--ss-line) 1px, transparent 1px) 0 0 / 108px 100%; +} + +.cal-busy { + position: absolute; + padding: 9px 9px; + border-radius: 6px; + background: var(--ss-ink-1); +} + +/* ---- The drop slot: appears in the landing cell and jumps, never travels ---- */ + +.cal-slot { + position: absolute; + top: 0; + left: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + border: 1.5px dashed color-mix(in srgb, var(--ss-accent) 55%, transparent); + border-radius: 6px; + background: var(--ss-accent-soft); +} + +.cal-slot[data-show] { + opacity: 1; +} + +/* ---- The meeting ---- */ + +.cal-event { + position: absolute; + top: 0; + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + padding: 10px 9px 13px 9px; + border-left: 3px solid var(--ss-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--ss-accent) 11%, var(--ss-surface)); + transition: + translate 0.8s cubic-bezier(0.45, 0, 0.1, 1), + box-shadow 0.2s ease, + scale 0.2s ease, + rotate 0.2s ease; +} + +/* Carried: no travel transition. Independent translate keeps the lift's + scale and rotation from multiplying the card's grid coordinates. */ +.cal-event[data-dragging] { + transition: box-shadow 0.2s ease, scale 0.2s ease, rotate 0.2s ease; +} + +.cal-event[data-lifted] { + box-shadow: var(--ss-shadow-lift); + scale: 1.03; + rotate: -1.5deg; +} + +.cal-event__title { + font: 600 11px/1.3 var(--ss-font); + color: color-mix(in srgb, var(--ss-accent) 70%, #000); +} + +.cal-event__time { + font: 500 10px/1.3 var(--ss-font); + color: color-mix(in srgb, var(--ss-accent) 55%, var(--ss-text)); +} + +.cal-event__people { + display: flex; + gap: 7px; + margin-top: auto; + padding-left: 2px; +} + +.cal-person { + position: relative; + display: grid; + place-items: center; + width: 20px; + height: 20px; +} + +.cal-person .ss-avatar { + box-shadow: 0 0 0 1.5px var(--ss-surface); +} + +/* Availability ring: a gray arc turns while it checks, then closes into a + full green ring as the avatar fills green. One mark, pending to done. */ +.cal-ring { + position: absolute; + top: -3px; + left: -3px; + overflow: visible; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + animation: cal-spin 0.9s linear infinite; +} + +.cal-ring__arc { + fill: none; + stroke: var(--ss-icon-faint); + stroke-width: 1.5; + stroke-linecap: round; + stroke-dasharray: 0.28 1; + transition: stroke-dasharray 0.45s cubic-bezier(0.25, 0.8, 0.25, 1), stroke 0.2s ease; +} + +.cal-ring[data-state='pending'], +.cal-ring[data-state='ok'] { + opacity: 1; +} + +.cal-ring[data-state='ok'] .cal-ring__arc { + stroke: var(--ss-success); + stroke-dasharray: 1 0; +} + +.cal-ring[data-state='idle'] .cal-ring__arc { + /* Fades out intact, then resets unseen */ + transition: stroke-dasharray 0s 0.3s, stroke 0s 0.3s; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/calendar/index.tsx b/skills/imported/benjitaylor/stageshow/examples/calendar/index.tsx new file mode 100644 index 0000000..2690abe --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/calendar/index.tsx @@ -0,0 +1,215 @@ +import { Stage, Window, Bar, Avatar, Icon, Roll, Camera, cellOf, type Act, type Point, type Rect } from '../../kit' +import './calendar.css' + +// Calendar hero, a week view with no sidebar so four days fit the column +// whole and Friday runs off the right: a meeting is dragged from Tuesday afternoon to Wednesday +// morning; once it lands, each attendee's availability is checked and their +// avatar turns green. Cmd+Z puts it back, a caused reset, and the loop begins +// again from the same frame. + +const COLS = 5 +const ROWS = 7 +const COL_W = 108 +const ROW_H = 40 +const SPAN = 2 +const GRIP = { x: 0.76, y: 0.28 } +const START = { col: 1, row: 4 } // Tue 1 PM +const END = { col: 2, row: 1 } // Wed 10 AM + +type Cell = { col: number; row: number } +type Avail = 'idle' | 'pending' | 'ok' +// `at` is the carried card's top-left in grid pixels +type S = { cell: Cell; lifted: boolean; at: Point | null; target: Cell | null; avail: Avail[]; focus: Rect | null } + +const DAYS = ['Mon 19', 'Tue 20', 'Wed 21', 'Thu 22', 'Fri 23'] +const HOURS = ['9 AM', '10 AM', '11 AM', '12 PM', '1 PM', '2 PM', '3 PM'] +const PEOPLE = ['Maya', 'Jonah', 'Priya'] +// [col, row, rows, bar width] +const BUSY: [number, number, number, number][] = [ + [0, 0, 1, 46], + [0, 3, 2, 58], + [1, 1, 1, 40], + [2, 4, 1, 52], + [3, 0, 2, 62], + [3, 4, 1, 44], + [4, 2, 1, 50], +] + +const hour = (row: number) => 9 + row +const label = (h: number) => `${h > 12 ? h - 12 : h} ${h >= 12 ? 'PM' : 'AM'}` +const span = (c: Cell) => { + const a = hour(c.row) + const b = a + SPAN + return a < 12 === b < 12 ? `${label(a).split(' ')[0]} – ${label(b)}` : `${label(a)} – ${label(b)}` +} + +// A cell in grid pixels; offset by the grid's rect for stage pixels +const cellRect = (grid: { x: number; y: number }, c: Cell): Rect => ({ + x: grid.x + c.col * COL_W + 3, + y: grid.y + c.row * ROW_H + 2, + width: COL_W - 6, + height: SPAN * ROW_H - 4, +}) + +const idle: Avail[] = ['idle', 'idle', 'idle'] + +// Compact starts the week at Tuesday, so the two days the story uses are +// whole at the phone's width: cells stay in whole-week columns in state, and +// are drawn `o` columns to the left +const shift = (c: Cell, o: number): Cell => ({ ...c, col: c.col - o }) + +async function carry(a: Act, from: Cell, to: Cell, o: number) { + const grid = a.rect('grid', { layout: true }) + const card = cellRect(grid, shift(from, o)) + // Hold the upper body of the meeting, leaving its attendee row clear. + const grab = { x: card.x + card.width * GRIP.x, y: card.y + card.height * GRIP.y } + await a.move(grab) + await a.wait(150) + const dest = cellRect(grid, shift(to, o)) + let prev: Cell = from + await a.drag( + { x: dest.x + (grab.x - card.x), y: dest.y + (grab.y - card.y) }, + (p) => { + const at = { x: p.x - (grab.x - card.x), y: p.y - (grab.y - card.y) } + // The card's top-center decides the cell, with sticky borders + const col = cellOf(at.x + card.width / 2 - grid.x, COL_W, prev.col - o) + o + const row = cellOf(at.y + ROW_H / 2 - grid.y, ROW_H, prev.row) + prev = { col: Math.max(o, Math.min(COLS - 1, col)), row: Math.max(0, Math.min(ROWS - SPAN, row)) } + // Lift only once drag has pressed and closed the hand. The callback + // paints the card and cursor together from the first movement frame. + a.put({ lifted: true, at: { x: at.x - grid.x, y: at.y - grid.y }, target: prev }) + }, + { duration: 1300 }, + ) + await a.set({ lifted: false, at: null, target: null, cell: prev }) +} + +const script = async (a: Act) => { + await a.wait(700) + a.mark('drag') + // Match the rendered week: compact starts at Tuesday, wide starts at Monday. + const o = a.layout === 'compact' ? 1 : 0 + await carry(a, START, END, o) + // Step the pointer off the card so the avatars are in clear view + await a.move('aside') + await a.wait(150) + a.mark('check') + // Push in on the meeting while its attendees are checked (wide only: the + // compact crop is already close). The view keeps the month title in. + const grid = a.rect('grid', { layout: true }) + const card = cellRect(grid, shift(END, o)) + const wide = o === 0 + if (wide) a.set({ focus: { x: card.x + card.width / 2 - 243, y: 20, width: 486, height: 267 } }) + await a.wait(wide ? 500 : 150) + await a.stagger([0, 1, 2], (i) => a.set((s) => ({ avail: s.avail.map((v, j) => (j === i ? 'pending' : v)) })), 70) + await a.wait(650) + await a.stagger([0, 1, 2], (i) => a.set((s) => ({ avail: s.avail.map((v, j) => (j === i ? 'ok' : v)) })), 420) + await a.wait(2000) + await a.set({ focus: null }) + await a.wait(1100) + // Undo: the meeting goes back and its availability is unknown again + a.mark('undo') + await a.key('cmd+z') + await a.set({ cell: START, avail: idle }) + await a.wait(900) + await a.move('rest') + await a.wait(500) +} + +function Ring({ state }: { state: Avail }) { + return ( + + + + ) +} + +export default function Calendar() { + return ( + + width={550} + height={360} + initial={{ cell: START, lifted: false, at: null, target: null, avail: idle, focus: null }} + script={script} + poster={{ cell: END, lifted: false, at: null, target: null, avail: ['ok', 'ok', 'ok'], focus: null }} + cursor={{ at: 'rest' }} + compact={{ below: 560, width: 358, height: 380 }} + keys="bottom-right" + className="cal" + > + {(s, _rect, layout) => { + const compact = layout === 'compact' + const o = compact ? 1 : 0 + const origin = { x: 0, y: 0 } + const home = cellRect(origin, shift(s.cell, o)) + const card = s.at ? { ...home, x: s.at.x, y: s.at.y } : home + const shown = s.target ?? s.cell + const slot = s.target && (s.target.col !== s.cell.col || s.target.row !== s.cell.row) + const slotR = cellRect(origin, shift(slot ? s.target! : s.cell, o)) + return ( + + + Today + + + + } + > +
+ {DAYS.slice(o).map((d, i) => ( + + {d} + + ))} +
+
+
+ {HOURS.map((h) => ( + {h} + ))} +
+
+ {BUSY.filter(([c]) => c >= o).map(([c, r, n, w], i) => ( + + + + ))} +
+
+ Roadmap sync + + {span(shown)} + + + {PEOPLE.map((p, i) => ( + + + + + ))} + +
+
+
+ + + + + + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx new file mode 100644 index 0000000..49f0e41 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx @@ -0,0 +1,133 @@ +import { useRef } from 'react' +import { Chart, Roll, Stage, Window, chartPoints, type Act } from '../../kit' +import './scrub.css' + +// Scrubbing a chart: the pointer runs along a revenue line and the headline +// reads out each day it passes. Beats: the pointer comes onto the chart, a +// hairline and a dot pick up the nearest day, the headline's label and value +// change to that day, the pointer runs right to the peak and rests; then it +// leaves and the headline returns to the total. Loops. +// +// What makes a scrub read as one gesture: +// - The headline is the readout. No tooltip box over the line: the value +// that is already the biggest thing on the card changes, so the eye stays +// where it already was. +// - Only the digits that change roll, and they roll quickly (UI feedback, +// not a story beat): $4,920 to $5,240 holds the "$" and moves the rest. +// - One stroke. The pointer runs the line in a single eased move (a path +// along the points, not hop after hop), and the day under it updates as it +// goes; a run of short moves reads as stuttering. +// - The readout is discrete, the motion is not: the value is always a real +// day (the nearest one), while the hairline sits at the pointer and the +// dot rides the drawn curve under it, read off the line's own path. A dot +// snapping day to day trails the hand. +// - Over a plot the pointer is a crosshair, as charting apps show it. +// - The day under the pointer is the only thing that's emphasized. The line +// doesn't dim; the axis doesn't move. +// +// Real strings: the card's title, the range, the day names, every value. + +type S = { day: number | null; x: number | null } + +const DAYS = [3240, 3610, 3480, 4120, 3950, 4630, 4410, 5240, 4920, 5810, 5530, 6320, 6050, 6890] +const NAMES = ['Oct 15', 'Oct 16', 'Oct 17', 'Oct 18', 'Oct 19', 'Oct 20', 'Oct 21', 'Oct 22', 'Oct 23', 'Oct 24', 'Oct 25', 'Oct 26', 'Oct 27', 'Oct 28'] +const TOTAL = DAYS.reduce((n, d) => n + d, 0) +const money = (n: number) => `$${n.toLocaleString('en-US')}` + +const W = { wide: 492, compact: 280 } +const H = 150 + +async function script(a: Act) { + await a.wait(900) + const box = a.rect('plot') + const pts = chartPoints({ data: DAYS, width: box.width, height: H, dot: false }) + const at = (i: number) => ({ x: box.x + pts[i]!.x, y: box.y + pts[i]!.y + 18 }) + // Onto the line a third of the way along, then a steady run to the peak + a.mark('hover') + await a.move(at(4), { duration: 700 }) + a.put({ day: 4, x: pts[4]!.x }) + await a.wait(700) + a.mark('scrub') + // One eased stroke, straight as a hand runs, from day 4 to the last. The + // hairline follows the pointer; the readout is the day nearest it + await a.move(at(DAYS.length - 1), { + duration: 1500, + path: (e, from, to) => ({ x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }), + onMove: (pt) => { + const x = Math.max(0, Math.min(box.width, pt.x - box.x)) + let near = 0 + for (let i = 1; i < pts.length; i++) if (Math.abs(pts[i]!.x - x) < Math.abs(pts[near]!.x - x)) near = i + a.put({ day: near, x }) + }, + }) + await a.wait(1900) + // Off the chart: the headline returns to the total + a.mark('leave') + // The readout lets go as the pointer crosses the plot's edge; the hairline + // and dot fade where they were, rather than jumping as they go + let gone = false + await a.move({ x: box.x + box.width + 40, y: box.y + box.height + 60 }, { + duration: 600, + onMove: (pt) => { + if (!gone && (pt.x > box.x + box.width || pt.y > box.y + box.height)) { + gone = true + a.put({ day: null }) + } + }, + }) + await a.wait(1600) +} + +// The y of the drawn line at x: a short search along the path's length +function yOn(path: SVGPathElement | null | undefined, x: number) { + if (!path) return null + let lo = 0 + let hi = path.getTotalLength() + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2 + if (path.getPointAtLength(mid).x < x) lo = mid + else hi = mid + } + return path.getPointAtLength(hi).y +} + +export default function ChartScrub() { + const plot = useRef(null) + return ( + width={640} height={400} compact={{ below: 640, width: 358, height: 400 }} initial={{ day: null, x: null }} poster={{ day: 13, x: null }} script={script} cursor={{ at: { x: 560, y: 360 } }} className="cs"> + {(s, _rect, layout) => { + const w = layout === 'compact' ? W.compact : W.wide + const pts = chartPoints({ data: DAYS, width: w, height: H, dot: false }) + // The hairline at the pointer (or on the day, for the still); the dot + // on the curve beneath it + const hx = s.x ?? (s.day === null ? null : pts[s.day]!.x) + const on = s.day !== null + const hy = hx === null ? null : (yOn(plot.current?.querySelector('.ss-chart__line'), hx) ?? pts[s.day ?? 0]!.y) + const p = hx === null || hy === null ? null : { x: hx, y: hy } + return ( + +
+
+
+ {s.day === null ? 'Last 14 days' : NAMES[s.day]!} +
+
+ {money(s.day === null ? TOTAL : DAYS[s.day]!)} +
+
+
+ + + +
+
+ Oct 15 + Oct 28 +
+
+
+ ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/chart-scrub/scrub.css b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/scrub.css new file mode 100644 index 0000000..1e1a9f4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/scrub.css @@ -0,0 +1,92 @@ +.cs .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* One window, inset at the top and running off the bottom */ +.cs-win { + position: absolute; + top: 40px; + left: 50%; + width: 560px; + translate: -50% 0; +} + +.cs .ss-stage[data-layout='compact'] .cs-win { + top: 16px; + width: 326px; +} + +.cs-body { + display: grid; + gap: 18px; + /* Taller than the stage leaves room for, so the window runs off the bottom */ + min-height: 420px; + align-content: start; + padding: 26px 34px 60px; + background: var(--ss-surface); +} + +.cs .ss-stage[data-layout='compact'] .cs-body { + padding: 22px 23px 60px; +} + +.cs-head { + display: grid; + gap: 6px; +} + +.cs-label { + color: var(--ss-text); + font-size: 13px; +} + +.cs-value { + color: var(--ss-text-strong); + font-size: 30px; + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +/* The day under the pointer: a hairline and a dot, jumping day to day */ +.cs-plot { + position: relative; +} + +.cs-hair { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + margin-left: -0.5px; + background: var(--ss-line); + opacity: 0; + transition: opacity 0.15s ease; +} + +.cs-dot { + position: absolute; + width: 9px; + height: 9px; + margin: -4.5px 0 0 -4.5px; + border: 2px solid var(--ss-surface); + border-radius: 50%; + background: var(--ss-accent); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.18); + opacity: 0; + transition: opacity 0.15s ease; +} + +.cs-hair[data-on], +.cs-dot[data-on] { + opacity: 1; +} + +.cs-axis { + display: flex; + justify-content: space-between; + margin-top: -8px; + color: var(--ss-text-faint); + font-size: 11px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/choreography/choreography.css b/skills/imported/benjitaylor/stageshow/examples/choreography/choreography.css new file mode 100644 index 0000000..cc3c46f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/choreography/choreography.css @@ -0,0 +1,81 @@ +.cy-demo, .cy-comparison { + --cy-curve: cubic-bezier(.72, 0, .22, 1); + --cy-duration: 900ms; + color: #293039; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; + + & .ss-stage { background: transparent; } + & .cy-scene { position: absolute; inset: 0; background: #e6ebed; border-radius: 14px; } + & .cy-checkout { position: absolute; display: flex; flex-direction: column; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px #1b29341a, 0 16px 40px -20px #1b293443; } + & .cy-body { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; padding: 0 16px 16px; } + & .cy-order { display: flex; align-items: center; gap: 10px; flex: none; box-sizing: border-box; height: 72px; padding: 16px; } + & .cy-summary-heading, & .cy-form-heading, & .cy-quantity, & .cy-total > span:first-child, & .cy-notebook b { display: none; } + & .cy-notebook { position: relative; flex: none; display: block; width: 30px; height: 40px; border-radius: 2px 4px 4px 2px; background: #607d72; box-shadow: inset 3px 0 #3e6054, inset -1px 0 #fff4, 0 2px 3px #152f2417; } + & .cy-notebook i { position: absolute; top: 9px; bottom: 9px; left: 12px; right: 8px; border: 1px solid #d5d9bf88; } + & .cy-product { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; } + & .cy-product strong { font-size: 12px; line-height: 16px; font-weight: 600; white-space: nowrap; } + & .cy-product > span { font-size: 10px; line-height: 13px; color: #848d93; } + & .cy-roll { display: inline-block; flex: none; height: var(--cy-row, 22px); overflow: hidden; mask-image: linear-gradient(transparent, #000 3px, #000 calc(100% - 3px), transparent); } + & .cy-vertical-track { display: grid; grid-auto-rows: var(--cy-row, 22px); transform: translate3d(0, calc(var(--cy-index) * -1 * var(--cy-row, 22px)), 0); transition: transform var(--cy-duration) var(--cy-curve); } + & .cy-roll-row { display: flex; align-items: center; gap: 5px; height: var(--cy-row, 22px); font-size: 11px; line-height: 14px; white-space: nowrap; } + & .cy-price { width: 60px; font-variant-numeric: tabular-nums; font-weight: 600; } + & .cy-total { display: flex; align-items: center; gap: 8px; } + & .cy-total .cy-price .cy-roll-row { justify-content: flex-end; } + & .cy-currency { width: 154px; } + & .cy-currency .cy-roll-row { justify-content: flex-end; } + & .cy-currency b { color: #adb3b7; font-weight: 400; } + & .cy-locale { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex: none; height: 22px; margin-bottom: 10px; font-size: 10px; color: #737d84; } + & .cy-locale .cy-roll-row { color: #48535d; font-size: 10px; } + & .cy-label { display: block; font-size: 10px; line-height: 12px; color: #78828a; } + & .cy-method { flex: none; margin-bottom: 10px; } + & .cy-method > .cy-label { margin-bottom: 4px; } + & .cy-method-control { display: flex; align-items: center; gap: 7px; box-sizing: border-box; height: 30px; padding: 0 8px; border: 1px solid #dfe4e7; border-radius: 5px; color: #515c65; } + & .cy-method-icon { width: 14px; } + & .cy-method-name { flex: 1; } + & .cy-address-viewport { position: relative; flex: none; height: 174px; margin-inline: -14px; overflow: hidden; mask-image: linear-gradient(90deg, transparent, #000 14px, #000 calc(100% - 14px), transparent); } + & .cy-address-track { display: grid; grid-template-columns: repeat(3, 100%); width: 100%; transform: translate3d(calc(var(--cy-index) * -100%), 0, 0); transition: transform var(--cy-duration) var(--cy-curve); } + & .cy-address { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 8px; padding: 0 14px; box-sizing: border-box; } + & .cy-field { grid-column: 1 / -1; min-width: 0; } + & .cy-field[data-half] { grid-column: auto; } + & .cy-field-value { display: block; box-sizing: border-box; height: 22px; margin-top: 3px; padding: 3px 7px; border: 1px solid #e1e5e8; border-radius: 4px; color: #4b555d; font-size: 11px; line-height: 14px; white-space: nowrap; } + & .cy-footer { display: flex; flex-direction: column; gap: 6px; flex: none; margin-top: auto; } + & .cy-pay { position: relative; display: flex; justify-content: center; align-items: center; gap: 4px; height: 38px; border-radius: 6px; background: #456c5b; color: white; font-size: 11px; font-weight: 600; } + & .cy-pay > .ss-icon { position: absolute; right: 12px; opacity: .7; } + & .cy-pay-label { width: 100%; } + & .cy-pay-label .cy-roll-row { justify-content: center; } + & .cy-footer > span { font-size: 10px; line-height: 12px; text-align: center; color: #8a9399; } + & .cy-checkout[data-coordinated] .cy-price .cy-vertical-track, + & .cy-checkout[data-coordinated] .cy-pay-label .cy-vertical-track { transition-delay: 100ms; } + & .cy-checkout[data-coordinated] .cy-method .cy-vertical-track { transition-delay: 220ms; } + & .cy-checkout[data-coordinated] .cy-address-track { transition-delay: 420ms; } + + & .cy-checkout[data-layout='wide'] { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + + & .cy-order { height: 100%; flex-direction: column; align-items: flex-start; gap: 0; padding: 26px 24px; background: #f3f5f3; border-right: 1px solid #e9eeea; } + & .cy-summary-heading { display: block; font-size: 12px; line-height: 16px; font-weight: 600; color: #4c5851; } + & .cy-notebook { width: 82px; height: 112px; margin-top: 24px; margin-bottom: 20px; border-radius: 3px 6px 6px 3px; box-shadow: inset 5px 0 #3e6054, inset -1px 0 #fff4, 0 5px 10px #152f2420; } + & .cy-notebook i { top: 15px; bottom: 15px; left: 18px; right: 13px; border-color: #d5d9bf55; } + & .cy-notebook b { display: block; position: absolute; top: 37px; left: 18px; right: 13px; text-align: center; font-size: 10px; line-height: 14px; letter-spacing: .06em; font-weight: 500; color: #e2dfc7; } + & .cy-product { flex: none; gap: 5px; } + & .cy-product strong { font-size: 14px; line-height: 18px; } + & .cy-product > span { font-size: 11px; line-height: 15px; color: #738077; } + & .cy-quantity { display: block; margin-top: 7px; } + & .cy-total { justify-content: space-between; width: 100%; margin-top: auto; } + & .cy-total > span:first-child { display: inline; font-size: 12px; line-height: 16px; color: #637269; } + & .cy-price { --cy-row: 28px; width: 86px; } + & .cy-price .cy-roll-row { font-size: 17px; line-height: 20px; letter-spacing: -.02em; } + & .cy-body { padding: 24px; } + & .cy-form-heading { display: block; margin: 0 0 16px; font-size: 14px; line-height: 18px; font-weight: 600; } + } +} +.cy-demo { width: 100%; max-width: 656px; margin-inline: auto; } +.cy-comparison { + width: 100%; + & .cy-pair-host { width: 100%; max-width: 704px; margin-inline: auto; } + & .cy-pair { position: absolute; inset: 0; } + & .cy-pair-cell { position: absolute; height: 476px; border-radius: 12px; background: #e6ebed; } + & .cy-pair-label { position: absolute; left: 16px; top: 12px; font-size: 11px; line-height: 15px; color: #65717a; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/choreography/index.tsx b/skills/imported/benjitaylor/stageshow/examples/choreography/index.tsx new file mode 100644 index 0000000..afe0034 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/choreography/index.tsx @@ -0,0 +1,115 @@ +import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react' +import { Icon, Stage, type Act } from '../../kit' +import './choreography.css' + +type Locale = { country: string; currency: string; price: string; method: string; fields: { label: string; value: string; half?: boolean }[] } +const locales: Locale[] = [ + { country: 'United States', currency: 'USD', price: '$38.00', method: 'Card', fields: [ + { label: 'Name', value: 'Alex Morgan' }, { label: 'Street address', value: '24 Maple Avenue' }, { label: 'City', value: 'Portland', half: true }, { label: 'ZIP code', value: '97205', half: true }, + ] }, + { country: 'Germany', currency: 'EUR', price: '€35.00', method: 'Bank transfer', fields: [ + { label: 'Name', value: 'Alex Morgan' }, { label: 'Street and number', value: 'Lindenstraße 18' }, { label: 'Postcode', value: '10115', half: true }, { label: 'City', value: 'Berlin', half: true }, + ] }, + { country: 'Canada', currency: 'CAD', price: 'CA$52.00', method: 'Card', fields: [ + { label: 'Name', value: 'Alex Morgan' }, { label: 'Street address', value: '18 Cedar Lane' }, { label: 'City', value: 'Victoria', half: true }, { label: 'Province', value: 'BC', half: true }, { label: 'Postal code', value: 'V8V 1A1' }, + ] }, +] + +type State = { locale: number } +const initial: State = { locale: 0 } +const poster: State = { locale: 2 } + +// Starts overlap. The currency identifies the change before the dependent +// price, payment method and address respond. The final destination holds. +export async function localeSequence(a: Act) { + await a.wait(1600) + a.mark('germany') + await a.set({ locale: 1 }) + await a.wait(3300) + a.mark('canada') + await a.set({ locale: 2 }) + await a.wait(2900) +} + +function useWidth(initialWidth: number) { + const ref = useRef(null) + const [width, setWidth] = useState(initialWidth) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => { if (node.clientWidth) setWidth(node.clientWidth) } + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] as const +} + +function Bank() { + return +} + +function Price({ locale }: State) { + return {locales.map(item => {item.price})} +} + +function Checkout({ locale, width, good = true, top = 24, wide = false }: State & { width: number; good?: boolean; top?: number; wide?: boolean }) { + return
+ +
+
Delivery details
+
Ship to{locales.map(item => {item.country}·{item.currency})}
+
+ Payment method +
+ {locales.map(item => {item.method === 'Card' ? : })} + {locales.map(item => {item.method})} + +
+
+
+
{locales.map((item, index) =>
+ {item.fields.map(field =>
{field.label}{field.value}
)} +
)}
+
+
+
{locales.map(item => Pay {item.price})}
+ Shipping included +
+
+
+} + +export const choreographyLessons = [{ + id: 'dependent-starts', title: 'Related changes overlap', note: 'The same checkout, curve and duration. On the right, currency leads price, then payment method and address. Watch the outer frame and Pay button stay in place.', bad: 'Every layer starts together', good: 'Dependent responses overlap', +}] as const + +export function ChoreographyComparison({ lessonId = 'dependent-starts' }: { lessonId?: string }) { + const [ref, width] = useWidth(704) + const [revision, replay] = useState(0) + if (lessonId !== 'dependent-starts') return null + const stacked = width < 620 + const cell = stacked ? width : (width - 24) / 2 + return
+

{choreographyLessons[0].title}

{choreographyLessons[0].note}

+
key={revision} width={width} height={stacked ? 976 : 476} initial={initial} poster={poster} play="once" script={localeSequence}> + {state =>
{[false, true].map((good, i) =>
+ {good ? choreographyLessons[0].good : choreographyLessons[0].bad} +
)}
} +
+
+} + +export default function Choreography() { + const [ref, width] = useWidth(288) + return
width={width} height={468} initial={initial} poster={poster} play="once" script={localeSequence}> + {state =>
= 560} />
} +
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/chrome/chrome.css b/skills/imported/benjitaylor/stageshow/examples/chrome/chrome.css new file mode 100644 index 0000000..487ef21 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/chrome/chrome.css @@ -0,0 +1,39 @@ +.wc-gallery { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(288px, 100%), 332px)); + justify-content: center; + gap: 28px 24px; + color: #34383f; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + + & .wc-figure { min-width: 0; margin: 0; } + & .wc-picture { overflow: hidden; border-radius: 12px; } + & .ss-stage { background: transparent; } + & .wc-ground { position: absolute; inset: 0; display: grid; place-items: center; background: #edeef1; } + & .wc-ground[data-material='glass'] { background: linear-gradient(115deg, #a8b2db 0 27%, #d2d9ed 27% 65%, #e8cbbd 65%); } + & .wc-ground[data-material='card'] { background: #e8edf2; } + & .wc-ground[data-material='terminal'] { background: #e0e3e7; } + & .wc-content { display: grid; padding: 8px; font-size: 11px; line-height: 15px; } + & .wc-file-row { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; align-items: center; gap: 8px; height: 28px; padding: 0 8px; border-radius: 4px; color: #525d6a; } + & .wc-file-row[data-selected] { background: #e9edf3; color: #364250; } + & .wc-file-row > span { white-space: nowrap; } + & .wc-file-row > .ss-icon { color: #818c9b; } + & .wc-file-size { color: #8b939e; font-size: 10px; font-variant-numeric: tabular-nums; } + & .wc-tools { display: flex; gap: 10px; align-items: center; color: #828b98; } + & .wc-tall-tool { display: inline-flex; align-items: center; gap: 5px; height: 32px; color: #6d7889; font-size: 12px; line-height: 16px; font-weight: 500; white-space: nowrap; } + & .wc-tall-tool__icon { display: grid; place-items: center; flex: none; width: 16px; height: 16px; } + /* This specimen uses one row of controls. Keep the native light position, + but don't retain a second row's empty space above the file list. */ + & .wc-window[data-bar='tall'] > .ss-window__bar, + & .wc-window[data-bar='tall'] .ss-window__main > .ss-window__bar { height: 48px; } + & .wc-side { display: grid; padding: 8px; font-size: 10px; line-height: 17px; color: #7c8592; } + & .wc-side > span { display: flex; align-items: center; height: 28px; padding: 0 5px; border-radius: 4px; } + & .wc-side > span[data-active] { background: #e9edf3; color: #4b5564; } + /* The address is supplied content. Keep it readable in the small specimen. */ + & .wc-address { display: block; font-size: 10px; line-height: 14px; } + & .wc-terminal { display: grid; gap: 8px; padding: 3px 2px; font-size: 12px; line-height: 19px; } + & .wc-terminal > span { color: #a3a6ad; } + & .wc-figure figcaption { display: flex; flex-direction: column; gap: 4px; margin-top: 12px; } + & .wc-figure figcaption strong { font-size: 13px; line-height: 18px; font-weight: 600; } + & .wc-figure figcaption > span { font-size: 11px; line-height: 16px; color: #7c828b; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/chrome/index.tsx b/skills/imported/benjitaylor/stageshow/examples/chrome/index.tsx new file mode 100644 index 0000000..d5f45ef --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/chrome/index.tsx @@ -0,0 +1,68 @@ +import { useLayoutEffect, useRef, useState } from 'react' +import { Icon, Prompt, Stage, Window, type WindowProps } from '../../kit' +import './chrome.css' + +// One native-size figure per option. The page reflows; the chrome does not +// shrink as part of a large sheet. All frame measurements come from Window. +const files = [{ name: 'Brief.pdf', size: '24 KB' }, { name: 'Notes.md', size: '4 KB' }, { name: 'Sketch.fig', size: '86 KB' }, { name: 'Assets.zip', size: '2 MB' }] +function Content({ short = false }: { short?: boolean }) { + return
{files.slice(0, short ? 3 : 4).map((file, i) =>
{file.name}{file.size}
)}
+} + +function Tool() { + return +} + +function TallTool() { + return New folder +} + +function Side() { + return
All filesSharedArchive
+} + +type Specimen = { id: string; title: string; note: string; props: WindowProps; cool?: boolean } +const specimens: Specimen[] = [ + { id: 'glass', title: 'Glass', note: 'Frosted frame and inset page.', props: { chrome: 'glass', url: 'files.example' } }, + { id: 'card', title: 'Card', note: 'Pale address field and a soft lifted edge.', props: { chrome: 'card', url: 'files.example' } }, + { id: 'hairline', title: 'Hairline', note: 'A short bar with a fine separator.', props: { chrome: 'hairline', url: files.example } }, + { id: 'terminal', title: 'Terminal', note: 'One dark surface, without a bar divider.', props: { chrome: 'terminal', title: 'workspace — zsh', lights: 'grey' } }, + { id: 'browser', title: 'Browser', note: 'Native address bar.', props: { url: 'files.example' } }, + { id: 'tabs', title: 'Browser with tabs', note: 'Tabs and back/forward controls.', props: { url: 'files.example', nav: true, tabs: ['Files', 'Docs'] } }, + { id: 'app', title: 'App toolbar', note: 'Title and controls in one bar.', props: { title: 'Files', toolbar: } }, + { id: 'sidebar', title: 'Sidebar', note: 'Lights in a full-height sidebar.', props: { title: 'Files', toolbar: , sidebarWidth: 96, sidebar: } }, + { id: 'large-lights', title: 'Larger lights', note: 'The native 12px traffic lights.', cool: true, props: { title: 'Files', toolbar: , lightsSize: 'large' } }, + { id: 'floating-sidebar', title: 'Floating sidebar', note: 'An inset panel with nested corners.', cool: true, props: { title: 'Files', toolbar: , sidebarWidth: 96, sidebarStyle: 'floating', sidebar: } }, + { id: 'tall-toolbar', title: 'Tall toolbar', note: 'A taller bar with inline controls and larger lights.', cool: true, props: { title: 'Files', toolbar: , lightsSize: 'large', barSize: 'tall' } }, + { id: 'combined', title: 'Combined', note: 'Floating sidebar, tall bar and larger lights.', cool: true, props: { title: 'Files', toolbar: , sidebarWidth: 96, sidebarStyle: 'floating', sidebar: , lightsSize: 'large', barSize: 'tall' } }, +] + +function Figure({ item }: { item: Specimen }) { + const ref = useRef(null) + const [width, setWidth] = useState(288) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => { if (node.clientWidth) setWidth(node.clientWidth) } + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return
+
+ + {() =>
+ + {item.id === 'terminal' ?
pwd/workspace
: } +
+
} +
+
+
{item.title}{item.note}
+
+} + +export default function Chrome() { + return
{specimens.map(item =>
)}
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/close-up/close.css b/skills/imported/benjitaylor/stageshow/examples/close-up/close.css new file mode 100644 index 0000000..f306bf5 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/close-up/close.css @@ -0,0 +1,51 @@ +.cu .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The whole window at 1.4x, inset 18px top and sides, running off the + bottom: the subject row is whole across, label and toggle */ +.cu-zoom { + position: absolute; + top: 18px; + left: 18px; + transform: scale(1.4); + transform-origin: 0 0; +} + +.cu-win { + width: 260px; + height: 240px; +} + +.cu-list { + padding: 6px 16px; +} + +.cu-row { + padding: 12px 0; + border-top: 1px solid var(--ss-line); + border-radius: 0; +} + +.cu-row:first-child { + border-top: 0; +} + +.cu-title { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 500; +} + +.cu-sub { + color: var(--ss-text); + font-size: 11px; +} + +/* Narrow: a touch less zoom, so the row stays whole across with the same + inset each side */ +.cu .ss-stage[data-layout='compact'] .cu-zoom { + left: 12px; + transform: scale(1.3); +} diff --git a/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx b/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx new file mode 100644 index 0000000..016c735 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx @@ -0,0 +1,76 @@ +import { Bar, Row, Stage, States, Text, Toggle, Window, type Act } from '../../kit' +import './close.css' + +// A close-up: one setting, drawn at 1.4 times true size and cropped hard, for +// a feature card about a single control. Beats: the cursor clicks the toggle, +// it turns on, the line under it changes to say what that did. Plays once; +// the still is the payoff. +// +// What makes a close-up read as a close-up, not a small screen: +// - The whole window is scaled, chrome and all, not the control alone: the +// traffic lights and the title bar at the top left say this is a crop of a +// real window. A bare toggle at twice its size reads as an icon. +// - The window runs off the bottom only. The subject row is whole across, +// label and toggle: a close-up that crops its own control has lost the +// point. The row above and below it are context. +// - The cursor is the large style, so it matches the zoom; a true-size arrow +// on a 1.4x window reads as a speck. +// - One real row. Its neighbors keep their anatomy as placeholder lines and +// toggles that stay off: an accent on filler would be the heaviest mark in +// the frame on a row with no words. +// +// Real strings: the window title, the setting, its two descriptions. + +type S = { on: boolean } + +const initial: S = { on: false } +const poster: S = { on: true } + +async function script(a: Act) { + await a.wait(900) + a.mark('click') + await a.click({ at: 'toggle', x: 0.5, y: 0.5 }) + a.put({ on: true }) + // Off the knob, so the payoff isn't under the pointer + await a.wait(500) + await a.move({ at: 'toggle', x: 0.5, y: 2.6 }) + await a.wait(2100) +} + +export default function CloseUp() { + return ( + + {(s) => ( +
+ +
+ } subtitle={}> + + + Two-factor authentication} + subtitle={ + Ask for a code at sign in, + on: On for every sign in, + }} + /> + } + > + + + } subtitle={}> + + +
+
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/controls/controls.css b/skills/imported/benjitaylor/stageshow/examples/controls/controls.css new file mode 100644 index 0000000..393d893 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/controls/controls.css @@ -0,0 +1,155 @@ +/* A specimen sheet: an even grid of tiles, one part each. Not a composition to copy. */ +.ct .ss-stage { + background: var(--ss-well); +} + +.ct-grid { + position: absolute; + inset: 24px; + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 16px; +} + +.ct-tile { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 16px; + border-radius: 14px; + background: var(--ss-raised); + box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint); +} + +.ct-name { + font: 500 10.5px/14px ui-monospace, 'SF Mono', monospace; + color: var(--ss-text-faint); +} + +.ct-body { + display: grid; + flex: 1; + align-content: center; + gap: 14px; + min-height: 0; +} + +.ct-switch { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12.5px; + color: var(--ss-text-strong); +} + +.ct-code { + margin: 0 -16px; +} + +.ct-cols { + display: grid; + /* Fixed equal columns: sized by content, they'd shift as the card changes column */ + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ct-col { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px; + border-radius: 10px; + background: var(--ss-ink-1); +} + +.ct-card { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 8px; + height: 50px; + padding: 10px; + border-radius: 8px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint); +} + +.ct-card .ss-badge { + height: 14px; + padding: 0 5px; + font-size: 9px; +} + +.ct-carry { + position: absolute; + z-index: 10; + isolation: isolate; +} + +/* The whole card tilts and lifts, contents and all: a surface that tilts + under level text reads as a bug. It keeps its own layer from the start + (will-change), so its text never hops onto the pixel grid as it lands */ +.ct-carry .ct-card { + background: var(--ss-surface); + box-shadow: var(--ss-shadow-float), 0 0 0 1px var(--ss-line-faint); + transform: rotate(-2deg); + transform-origin: 72% 24%; + will-change: transform; + transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; +} + +.ct-carry[data-settle] .ct-card { + box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint); + transform: none; +} + +.ct-list { + height: 140px; +} + +.ct-row { + display: flex; + align-items: center; + gap: 9px; + height: 28px; + font-size: 12.5px; + color: var(--ss-text-strong); +} + +.ct-rest { + position: absolute; + right: 40px; + bottom: 40px; +} + +.ct-fade { + transition: opacity 0.2s ease; +} + +.ct-fade[data-out] { + opacity: 0; +} + +/* The card returning to A fades in as its slot opens, not after */ +.ct-fade-in { + animation: ct-in 0.45s ease-out both; +} + +@keyframes ct-in { + from { opacity: 0; } +} + +/* Keep specimen labels at their authored size on a narrow host. */ +.ct .ss-stage[data-layout="compact"] .ct-grid { + inset: 16px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(6, 200px); +} + +.ct .ss-stage[data-layout="compact"] .ct-code-focus { + width: max-content; + max-width: 100%; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx b/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx new file mode 100644 index 0000000..1fd19af --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx @@ -0,0 +1,201 @@ +import { Badge, Bar, Camera, Chart, Code, Field, Segmented, Scroll, Slot, Stage, Tabs, Toggle, type Act, type Rect } from '../../kit' +import type { ReactNode } from 'react' +import './controls.css' + +// A specimen sheet for the controls, Code, Chart, Slot, scrolling and the +// camera, one tile each. A catalog, not a composition: for framing, copy the +// gold examples (hero-demo, static-card, story, payout-flow). + +const DATA = [12, 18, 15, 24, 22, 31, 28, 36] +const MORE = [12, 18, 15, 24, 22, 31, 34, 44] +const DIFF = [ + { text: 'function load(id) {' }, + { text: ' let r = fetch(id)', kind: 'del' as const }, + { text: ' let r = cache.get(id)', kind: 'add' as const }, + { text: ' return r.json()' }, + { text: '}' }, +] +const ROWS = ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli', 'Vandelay', 'Stark', 'Wayne'] + +type S = { + email: string + focus: boolean + seg: number + on: boolean + tab: number + lines: number + data: number[] + carry: { x: number; y: number; w: number; settle?: boolean } | null + moved: boolean + leaving: boolean + focusTile: Rect | null +} + +const initial: S = { email: '', focus: false, seg: 0, on: false, tab: 0, lines: 0, data: DATA, carry: null, moved: false, leaving: false, focusTile: null } +const POSTER: S = { ...initial, email: 'ada@acme.dev', seg: 1, on: true, tab: 1, lines: 5, data: MORE, moved: true } + +async function script(a: Act) { + await a.wait(500) + a.mark('field') + await a.click('email') + await a.set({ focus: true }) + await a.type('email', 'ada@acme.dev') + await a.wait(300) + + a.mark('controls') + // Pressed beside the word, never on it + await a.click({ at: 'seg-1', x: 0.84, y: 0.7 }) + await a.set({ seg: 1, focus: false }) + await a.click('toggle') + await a.set({ on: true }) + await a.click('tab-1') + await a.set({ tab: 1 }) + + a.mark('code') + a.move('rest') + for (let i = 1; i <= DIFF.length; i++) { + a.put({ lines: i }) + await a.wait(140) + } + await a.set({ data: MORE }) + await a.wait(600) + + a.mark('slot') + // Picked up off center, as a hand does; the grab offset comes from the + // same fractions, so the card never jumps under the hand + const GRAB = { x: 0.72, y: 0.24 } + await a.move({ at: 'card', ...GRAB }) + const card = a.rect('card') + // Aim at where the card will sit once the slot has opened (the column's + // first place), not at the slot itself, which is still opening mid-drag + const col = a.rect('col-b') + const end = { x: col.x + 6, y: col.y + 6 } + const grab = { x: card.width * GRAB.x, y: card.height * GRAB.y } + await a.drag({ x: end.x + grab.x, y: end.y + grab.y }, (at) => { + a.put({ carry: { x: at.x - grab.x, y: at.y - grab.y, w: card.width } }) + }) + // Settle the tilt and lift in place, then swap in the real card, unmoved + await a.set({ carry: { ...end, w: card.width, settle: true } }) + await a.wait(220) + await a.set({ carry: null, moved: true }) + await a.wait(400) + + a.mark('scroll') + await a.scroll('list', 'row-5') + await a.wait(500) + + a.mark('camera') + await a.move('rest') + await a.set({ focusTile: a.rect(a.layout === 'compact' ? 'code-detail' : 't-code', { layout: true }) }) + await a.wait(2200) + await a.set({ focusTile: null }) + await a.wait(1400) + + a.mark('exit') + // Undo on screen, never at the seam: the list scrolls back, the card fades + // out of B as its slot closes and back into A as that one opens + await a.scroll('list', 0) + await a.set({ leaving: true }) + await a.wait(220) + await a.set({ ...initial, data: DATA }) + await a.move('rest') + await a.wait(900) +} + +function Tile({ name, target, children }: { name: string; target?: string; children: ReactNode }) { + return ( +
+ {name} +
{children}
+
+ ) +} + +function Card({ ghost }: { ghost?: boolean }) { + return ( +
+ + Review +
+ ) +} + +export default function Controls() { + return ( + specimen width={760} height={460} compact={{ below: 600, width: 358, height: 1312 }} initial={initial} poster={POSTER} script={script} cursor={{ at: 'rest', style: 'arrow', ripple: true }} className="ct"> + {(s, _, layout) => ( + <> + +
+ + + + + + +
+ Auto-renew + +
+ +
+ + +
+ +
+
+ + + + + + + +
+
+ + {!s.moved && !s.carry && ( +
+ +
+ )} +
+ +
+
+ + {s.moved && ( +
+ +
+ )} +
+ +
+
+
+ + + + {ROWS.map((r, i) => ( +
+ + {r} +
+ ))} +
+
+
+ {s.carry && ( +
+ +
+ )} +
+ + + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css b/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css new file mode 100644 index 0000000..90aadd2 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css @@ -0,0 +1,60 @@ +.cc .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top-left, off the bottom; its top-right corner at (412, 56) */ +.cc-win { + position: absolute; + top: 56px; + left: 40px; + width: 372px; + height: 340px; +} + +.cc-list { + display: grid; + padding: 8px; + background: var(--ss-surface); + height: 100%; + align-content: start; +} + +/* The chip: its center on the window's corner, a level above */ +.cc-chip { + position: absolute; + top: 56px; + left: 412px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px 6px 6px; + border-radius: 999px; + background: var(--ss-raised, var(--ss-surface)); + box-shadow: var(--ss-shadow-satellite); + opacity: 0; + translate: -50% calc(-50% + 8px); + transition: opacity 0.4s ease, translate 0.8s cubic-bezier(0.22, 1, 0.36, 1); +} + +.cc-chip[data-on] { + opacity: 1; + translate: -50% -50%; +} + +.cc-chip__text { + color: var(--ss-text-strong); + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +/* Narrow: the window narrower, its corner pulled in so the chip stays whole */ +.cc .ss-stage[data-layout='compact'] .cc-win { + left: 16px; + width: 240px; +} + +.cc .ss-stage[data-layout='compact'] .cc-chip { + left: 256px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx b/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx new file mode 100644 index 0000000..a625e6a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx @@ -0,0 +1,55 @@ +import { Avatar, Bar, Facepile, Row, Stage, Text, Window, type Act } from '../../kit' +import './chip.css' + +// A satellite chip pinned to a corner: a support inbox with "3 agents +// online" hanging off its top-right corner. Beats: the chip rises into its +// corner after the window has settled. Plays once; the still is the payoff. +// +// What makes a corner chip read as pinned, not floating: +// - Its center sits on the window's corner, or within a few px of it: about +// half outside, half over the chrome. Over the window's middle it covers +// content; well clear of the corner it floats off. +// - It sits over chrome or filler only, never over the window's words. +// - It is small and says one thing, with one visual (the agents' faces), on +// a raised surface a level above the window. +// - The window is pinned top-left and runs off the bottom; the chip's corner +// keeps a gutter to the stage's right edge so the chip is whole. +// +// Real strings: the chip, the inbox title and the top conversation. + +type S = { on: boolean } + +async function script(a: Act) { + await a.wait(600) + a.put({ on: true }) + await a.wait(2600) +} + +export default function CornerChip() { + return ( + + {(s) => ( + <> + +
+ } + title={Priya Nair} + meta={2m} + subtitle={Can I move my plan to annual billing?} + /> + {[180, 150, 200, 140].map((w, i) => ( + } title={} meta={} subtitle={} /> + ))} +
+
+
+ + 3 agents online +
+ + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md new file mode 100644 index 0000000..2432b98 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md @@ -0,0 +1,15 @@ +# Cursor morph + +The cursor picks its own style: real, large, circle, arrow, then real again (the fifth, `touch`, is for phones). +Each silhouette is resampled to 72 perimeter points and morphed with the +same damped spring (damping ratio 0.62, angular frequency 15). Color and +stroke width are clamped while geometry can overshoot. Retargeting starts +from the current outline, and unmounting cancels the pending frame. + +The portal rides the kit cursor for position and press feedback. State lives +in the Stage; the shape does not keep a second selected-style state. Reduced +motion settles directly. The compact layout uses a 318px control in a 358px +stage, preserving 14px labels. + +Use this when a cursor changes *identity*. Ordinary pointer/hand/ibeam changes +should use the kit's cursor kinds. diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css new file mode 100644 index 0000000..dda65f6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css @@ -0,0 +1,52 @@ +.cm-stage .ss-stage { background: var(--ss-well); border-radius: 16px; } +/* Cursor styles share one moving outline. */ +.cm-frame { + position: absolute; + inset: 0; + display: grid; + place-items: center; +} + +.cm-seg.ss-seg { + width: 400px; + padding: 3px; + border-radius: 12px; +} + +.cm-stage [data-layout="compact"] .cm-seg.ss-seg { + width: 318px; +} + +.cm-seg .ss-seg__thumb { + top: 3px; + bottom: 3px; + left: 3px; + width: calc((100% - 6px) / var(--n)); + border-radius: 9px; +} + +.cm-seg .ss-seg__option { + height: 40px; + font-size: 14px; +} + +/* This demo draws its own cursor (cursor-morph.tsx) inside the kit's, which + still moves and presses it; the kit's glyphs stay hidden */ +.cm-stage .ss-cursor__press { + visibility: hidden; +} + +.cm-morph { + position: absolute; + top: -16px; + left: -16px; + overflow: visible; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28)); + transform-origin: 16px 16px; + transition: scale 0.26s cubic-bezier(0.22, 1, 0.36, 1); +} + +.cm-stage .ss-cursor[data-pressed="true"] .cm-morph { + scale: 0.86; + transition: scale 0.08s ease-out; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx new file mode 100644 index 0000000..2fe2575 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useReducedMotion } from '../../kit' + +export type MorphStyle = 'real' | 'large' | 'circle' | 'arrow' + +// One outline that becomes each cursor style in turn, for the cursor picker: +// the pointer swells into the large pointer, folds into the glass circle, +// then sharpens into the solid arrowhead. Every shape is resampled to the +// same points, starting at the hotspot and running the same way round, so a +// change of style moves each point to its place in the next shape. + +type Pt = [number, number] +type Look = { fill: number[]; stroke: number[]; sw: number } + +const N = 72 + +/** Points evenly along a closed polygon, from its first vertex */ +function sample(poly: Pt[], scale = 1): Pt[] { + const p = poly.map(([x, y]) => [x * scale, y * scale] as Pt) + const seg = p.map((a, i) => { + const b = p[(i + 1) % p.length] + return Math.hypot(b[0] - a[0], b[1] - a[1]) + }) + const total = seg.reduce((s, l) => s + l, 0) + const out: Pt[] = [] + let i = 0 + let walked = 0 + for (let k = 0; k < N; k++) { + const d = (k / N) * total + while (walked + seg[i] < d) walked += seg[i++] + const a = p[i] + const b = p[(i + 1) % p.length] + const t = seg[i] ? (d - walked) / seg[i] : 0 + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]) + } + return out +} + +// The macOS pointer, tip at the hotspot, down its left edge first +const POINTER: Pt[] = [[0, 0], [0, 16.5], [4, 12.8], [6.6, 18.6], [9.2, 17.5], [6.7, 11.8], [12, 11.8]] +// The solid arrowhead, the same way round +const HEAD: Pt[] = [[0, 0], [5.6, 15.5], [7.8, 8.8], [14.5, 6.6]] +// The glass circle about the hotspot, from its upper left, the same way round +const CIRCLE: Pt[] = Array.from({ length: N }, (_, k) => { + const a = ((225 - (k / N) * 360) * Math.PI) / 180 + return [Math.cos(a) * 11, Math.sin(a) * 11] as Pt +}) + +const SHAPES: Record = { + real: { pts: sample(POINTER), look: { fill: [0, 0, 0, 1], stroke: [255, 255, 255, 1], sw: 1.5 } }, + large: { pts: sample(POINTER, 1.5), look: { fill: [0, 0, 0, 1], stroke: [255, 255, 255, 1], sw: 2.2 } }, + circle: { pts: CIRCLE, look: { fill: [118, 118, 128, 0.34], stroke: [255, 255, 255, 0.55], sw: 0.8 } }, + arrow: { pts: sample(HEAD), look: { fill: [17, 17, 17, 1], stroke: [17, 17, 17, 1], sw: 2.6 } }, +} + +// A damped spring with a little overshoot, settled by about 0.5s +function spring(t: number) { + const z = 0.62 + const w = 15 + const wd = w * Math.sqrt(1 - z * z) + return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + ((z * w) / wd) * Math.sin(wd * t)) +} + +const rgba = (c: number[]) => `rgba(${c.map((v, i) => (i < 3 ? Math.round(v) : v.toFixed(3))).join(',')})` +const path = (pts: Pt[]) => `M${pts.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L')}Z` + +function Shape({ style }: { style: MorphStyle }) { + const ref = useRef(null) + const now = useRef({ pts: SHAPES[style].pts, look: SHAPES[style].look }) + const reduced = useReducedMotion() + const initial = useRef(now.current) + useLayoutEffect(() => { + const el = ref.current + const to = SHAPES[style] + if (!el || !to) return + if (reduced) { + now.current = to + el.setAttribute('d', path(to.pts)) + el.setAttribute('fill', rgba(to.look.fill)) + el.setAttribute('stroke', rgba(to.look.stroke)) + el.setAttribute('stroke-width', String(to.look.sw)) + return + } + const from = { pts: now.current.pts.map((p) => [...p] as Pt), look: now.current.look } + const mix = (a: number, b: number, k: number) => a + (b - a) * k + let raf = 0 + const t0 = performance.now() + const tick = (ms: number) => { + const t = Math.min((ms - t0) / 1000, 0.7) + const k = t >= 0.7 ? 1 : spring(t) + // Color and stroke don't overshoot + const c = Math.max(0, Math.min(1, k)) + const pts = from.pts.map((p, i) => [mix(p[0], to.pts[i][0], k), mix(p[1], to.pts[i][1], k)] as Pt) + const look = { + fill: from.look.fill.map((v, i) => mix(v, to.look.fill[i], c)), + stroke: from.look.stroke.map((v, i) => mix(v, to.look.stroke[i], c)), + sw: mix(from.look.sw, to.look.sw, c), + } + now.current = { pts, look } + el.setAttribute('d', path(pts)) + el.setAttribute('fill', rgba(look.fill)) + el.setAttribute('stroke', rgba(look.stroke)) + el.setAttribute('stroke-width', look.sw.toFixed(2)) + if (t < 0.7) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [style, reduced]) + // React keeps the initial attributes stable; the tween owns subsequent + // geometry, including an interrupted transition's current outline. + const s = initial.current + return ( + + + + ) +} + +/** Draws the morphing cursor inside the stage's own cursor, so it rides the + * kit's movement and press. `at` is any element inside the stage. */ +export function CursorMorph({ at, style }: { at: HTMLElement | null; style: MorphStyle }) { + const host = at?.closest('.ss-stage')?.querySelector('.ss-cursor') + return host ? createPortal(, host) : null +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx new file mode 100644 index 0000000..7a8397b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Segmented, Stage, type Act } from '../../kit' +import { CursorMorph, type MorphStyle } from './cursor-morph' +import './cursor-morph.css' + +// The cursor styles, picked by the cursor itself: four options, and each +// click turns the cursor into the one it chose. Round the four and back to +// the default, so the loop ends where it began. Here the cursor is one +// outline that morphs from style to style (cursor-morph.tsx). + +const STYLES: MorphStyle[] = ['real', 'large', 'circle', 'arrow'] +const LABELS = ['Real', 'Large', 'Circle', 'Arrow'] +const ORDER = [1, 2, 3, 0] +const REST = { x: 150, y: 150 } + +type S = { value: number } + +async function script(a: Act) { + await a.wait(600) + for (const k of ORDER) { + await a.click({ at: `opt-${k}`, x: 0.5, y: 0.62 }) + await a.set({ value: k }) + await a.wait(1000) + } + await a.move(REST) + await a.wait(600) +} + +export default function CursorMorphDemo() { + const frame = useRef(null) + const [at, setAt] = useState(null) + useEffect(() => setAt(frame.current), []) + return ( + + width={550} + height={200} + compact={{ below: 540, width: 358, height: 200 }} + initial={{ value: 0 }} + script={script} + cursor={{ at: REST, style: 'real' }} + className="cm-stage" + > + {(s) => ( +
+ `opt-${k}`)} /> + +
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-resize/cursor-resize.css b/skills/imported/benjitaylor/stageshow/examples/cursor-resize/cursor-resize.css new file mode 100644 index 0000000..c152616 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-resize/cursor-resize.css @@ -0,0 +1,80 @@ +.cr-canvas { + position: absolute; + inset: 0; + background: #f3f4f6; +} + +.cr-frame { + position: absolute; + box-shadow: 0 0 0 1.5px #0d99ff; +} + +.cr-fill { + position: absolute; + inset: 0; + border-radius: 1px; + background: #fff; +} + +/* Edges are 8px bands along each side; corners are the squares on top */ +.cr-handle { + position: absolute; + z-index: 1; +} + +.cr-handle[data-side='n'], +.cr-handle[data-side='s'] { + left: 6px; + right: 6px; + height: 8px; +} + +.cr-handle[data-side='n'] { top: -4px; } +.cr-handle[data-side='s'] { bottom: -4px; } + +.cr-handle[data-side='w'], +.cr-handle[data-side='e'] { + top: 6px; + bottom: 6px; + width: 8px; +} + +.cr-handle[data-side='w'] { left: -4px; } +.cr-handle[data-side='e'] { right: -4px; } + +.cr-handle:is([data-side='nw'], [data-side='ne'], [data-side='sw'], [data-side='se']) { + z-index: 2; + width: 8px; + height: 8px; + border: 1.5px solid #0d99ff; + border-radius: 1.5px; + background: #fff; +} + +.cr-handle[data-side='nw'] { left: -4px; top: -4px; } +.cr-handle[data-side='ne'] { right: -4px; top: -4px; } +.cr-handle[data-side='sw'] { left: -4px; bottom: -4px; } +.cr-handle[data-side='se'] { right: -4px; bottom: -4px; } + +.cr-size { + position: absolute; + left: 50%; + top: calc(100% + 10px); + transform: translateX(-50%); + height: 18px; + padding: 0 6px; + border-radius: 4px; + background: #0d99ff; + color: #fff; + font: 550 10px/18px var(--ss-font); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.cr-rest { + position: absolute; + left: 64px; + top: 252px; + width: 1px; + height: 1px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-resize/index.tsx b/skills/imported/benjitaylor/stageshow/examples/cursor-resize/index.tsx new file mode 100644 index 0000000..a5e1681 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursor-resize/index.tsx @@ -0,0 +1,57 @@ +import { Stage, type Act } from '../../kit' +import './cursor-resize.css' + +// The resize cursors, on one selected frame. The cursor starts as the arrow +// and turns into the triangles the moment it crosses a handle: north-south +// on the bottom edge, east-west on the right edge, and the two diagonals on +// the corners. Each handle says its kind with data-cursor; the script only +// moves and drags, and every drag is undone by another, so the loop starts +// where it ends. + +type S = { x: number; y: number; w: number; h: number; held: string | null } +const START: S = { x: 120, y: 70, w: 200, h: 140, held: null } + +const pull = async (a: Act, handle: string, dx: number, dy: number, apply: (s: S, mx: number, my: number) => Partial) => { + await a.move(handle) + const from = a.center(handle) + let base: S | null = null + await a.set((s) => { base = s; return { held: handle } }) + await a.drag({ x: from.x + dx, y: from.y + dy }, (p, o) => a.put(apply(base!, Math.round(p.x - o.x), Math.round(p.y - o.y))), { duration: 750, cursor: 'preserve' }) + await a.set({ held: null }) + await a.wait(350) +} + +const script = async (a: Act) => { + await a.wait(600) + await pull(a, 'edge-s', 0, 30, (s, _, my) => ({ h: s.h + my })) + await pull(a, 'edge-e', 40, 0, (s, mx) => ({ w: s.w + mx })) + await pull(a, 'corner-se', -40, -30, (s, mx, my) => ({ w: s.w + mx, h: s.h + my })) + await pull(a, 'corner-ne', 30, -24, (s, mx, my) => ({ w: s.w + mx, y: s.y + my, h: s.h - my })) + await pull(a, 'corner-ne', -30, 24, (s, mx, my) => ({ w: s.w + mx, y: s.y + my, h: s.h - my })) + await a.move('rest') + await a.wait(700) +} + +const HANDLES: [string, string, string][] = [ + ['edge-n', 'resize-ns', 'n'], ['edge-s', 'resize-ns', 's'], ['edge-w', 'resize-ew', 'w'], ['edge-e', 'resize-ew', 'e'], + ['corner-nw', 'resize-nwse', 'nw'], ['corner-se', 'resize-nwse', 'se'], ['corner-ne', 'resize-nesw', 'ne'], ['corner-sw', 'resize-nesw', 'sw'], +] + +export default function CursorResize() { + return ( + specimen width={440} height={300} initial={START} script={script} cursor={{ at: { x: 64, y: 252 } }}> + {(s) => ( +
+
+
+ {HANDLES.map(([target, kind, side]) => ( + + ))} + {s.w} × {s.h} +
+ +
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursors/cursors.css b/skills/imported/benjitaylor/stageshow/examples/cursors/cursors.css new file mode 100644 index 0000000..f9528e6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursors/cursors.css @@ -0,0 +1,150 @@ +/* All rules belong to this example, including its responsive variants. */ +.crs-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 340px)); + gap: 28px 24px; + justify-content: center; + padding: 0; + + & figure { margin: 0; } + + & figcaption { + margin-top: 10px; + color: #8a8a8e; + font: 12px/1.4 system-ui, sans-serif; + text-align: center; + } + + .crs .ss-stage { + overflow: hidden; + border-radius: 12px; + background: var(--ss-well); + font: 12px/1.4 var(--ss-font); + color: var(--ss-text-strong); + } + + /* Inset top and left, bleeding off the right and bottom */ + .crs-window { + position: absolute; + top: 24px; + left: 24px; + width: 340px; + height: 240px; + box-shadow: var(--ss-shadow-hero); + } + + .crs-body { + display: grid; + gap: 12px; + padding: 16px 18px; + } + + .crs-row { + display: grid; + grid-template-columns: 56px auto; + column-gap: 16px; + align-items: center; + justify-content: start; + min-height: 28px; + } + + .crs-label { color: var(--ss-text); } + + .crs-field { + position: relative; + display: flex; + align-items: center; + width: 168px; + height: 28px; + padding: 0 9px; + border-radius: 7px; + background: var(--ss-surface); + box-shadow: inset 0 0 0 1px var(--ss-line); + transition: box-shadow 0.2s ease; + } + + .crs-field[data-focus] { + box-shadow: inset 0 0 0 1px var(--ss-accent), 0 0 0 3px color-mix(in srgb, var(--ss-accent) 18%, transparent); + } + + .crs-value { white-space: pre; border-radius: 3px; transition: background 0.15s ease; } + .crs-value[data-select] { background: color-mix(in srgb, var(--ss-accent) 25%, transparent); } + .crs-placeholder { color: var(--ss-text-faint, #a1a1a6); } + + .crs-caret { + width: 1px; + height: 14px; + margin-left: 1px; + background: var(--ss-accent); + } + + .crs-slider { display: flex; align-items: center; gap: 12px; padding-left: 8px; } + + .crs-track { + position: relative; + height: 4px; + border-radius: 2px; + background: var(--ss-ink-2); + } + + .crs-fill { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: var(--ss-accent); } + + .crs-handle { + position: absolute; + top: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.2); + } + + .crs-pct { width: 28px; color: var(--ss-text); font-variant-numeric: tabular-nums; } + + .crs-link { color: var(--ss-accent); font-weight: 500; transition: color 0.2s ease; } + .crs-link[data-hover] { text-decoration: underline; text-underline-offset: 3px; } + .crs-note { height: 28px; } + .crs-note .ss-states__item:not([data-on]) { pointer-events: none; } + .crs-note-content { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; color: var(--ss-text); } + .crs-note-remove { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; color: var(--ss-text); transition: background 160ms ease, color 160ms ease; } + .crs-note-remove[data-hover] { background: var(--ss-ink-1); color: var(--ss-text-strong); } + .crs-note-remove[data-pressed] { background: var(--ss-ink-2); } + + .crs-seg { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + width: 168px; + padding: 3px; + border-radius: 8px; + background: var(--ss-ink-1); + font-size: 11px; + text-align: center; + } + + .crs-seg span { + padding: 5px 0; + border-radius: 5px; + color: var(--ss-text); + transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; + } + + .crs-seg span[data-on] { + background: var(--ss-surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + color: var(--ss-text-strong); + font-weight: 500; + } + + .crs-seg span[data-pressed] { background: var(--ss-ink-2); } + + + /* Programmatic changes glide; direct drags remain attached to the pointer. */ + .crs-window[data-system] .crs-fill { transition: width 450ms cubic-bezier(0.45, 0, 0.1, 1); } + .crs-window[data-system] .crs-handle { transition: left 450ms cubic-bezier(0.45, 0, 0.1, 1); } + .ss-stage[data-layout="compact"] .crs-window { left: 20px; width: 300px; } + .ss-stage[data-layout="compact"] .crs-body { padding-inline: 14px; } + .ss-stage[data-layout="compact"] .crs-row { grid-template-columns: 52px auto; } + .ss-stage[data-layout="compact"] :is(.crs-field, .crs-seg) { width: 152px; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/cursors/index.tsx b/skills/imported/benjitaylor/stageshow/examples/cursors/index.tsx new file mode 100644 index 0000000..5156d18 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/cursors/index.tsx @@ -0,0 +1,146 @@ +import { Stage, Window, States, Icon, type Act, type CursorStyle } from '../../kit' +import './cursors.css' + +// One small invoice form, driven four ways: the real macOS cursor, a larger +// one for small or busy embeds, a glass circle for a gestural, touch-like +// feel, and no cursor at all. With a cursor, it changes kind the way a real +// one does: an I-beam over the text field, a pointing hand on options and +// links, an open hand over the slider and a closed one while it's held. The +// script never sets a kind: each element says its cursor with data-cursor, and +// the cursor switches the moment it crosses that element's edge. +// Without one, the product changes state without a surrogate spotlight. Only +// the text input shows focus. Pick per site and per demo; not every story needs a hand. + +type S = { name: string; focus: string | null; typing: boolean; terms: number; disc: number; select: boolean; note: boolean } +const START: S = { name: '', focus: null, typing: false, terms: 0, disc: 0, select: false, note: false } + +const TRACK = { w: 112 } + +const withCursor = async (a: Act) => { + await a.wait(500) + // Text: an I-beam from the moment it's over the field + await a.move({ at: 'field', x: 0.3, y: 0.5 }) + await a.click() + a.put({ typing: true }) + await a.type('name', 'Ada Lovelace') + // Options: a pointing hand + await a.move({ at: 'net-1', x: 0.9, y: 0.7 }) + await a.click() + a.put({ terms: 1, typing: false }) + // Dragging: a closed hand while it's held + await a.move('handle') + const track = a.rect('track') + await a.drag({ at: 'track', x: 0.5, y: 0.5 }, (at) => a.put({ disc: Math.max(0, Math.min(1, (at.x - track.x) / track.width)) })) + await a.wait(300) + // A link + await a.move('add-note') + await a.click() + a.put({ note: true }) + await a.move('rest') + await a.wait(1400) + // Put it back by hand, so the reset has a cause + await a.move('handle') + await a.drag({ at: 'track', x: 0, y: 0.5 }, (at) => a.put({ disc: Math.max(0, Math.min(1, (at.x - track.x) / track.width)) })) + await a.click('remove-note') + a.put({ note: false }) + await a.click({ at: 'net-0', x: 0.9, y: 0.7 }) + a.put({ terms: 0 }) + await a.move({ at: 'field', x: 0.5, y: 0.5 }) + await a.click() + a.put({ select: true }) + await a.wait(350) + a.put({ name: '', select: false }) + await a.move('rest') + await a.wait(500) +} + +const withoutCursor = async (a: Act) => { + await a.wait(650) + await a.set({ focus: 'field', typing: true }) + await a.wait(350) + await a.type('name', 'Ada Lovelace') + await a.set({ focus: null, typing: false }) + await a.wait(500) + await a.set({ terms: 1 }) + await a.wait(650) + await a.set({ disc: 0.5 }) + await a.wait(900) + await a.set({ note: true }) + await a.wait(1600) +} + +function Scene({ style }: { style: CursorStyle | 'none' }) { + return ( + specimen + width={340} + height={240} + compact={{ below: 340, width: 288, height: 240 }} + initial={START} + poster={{ ...START, name: 'Ada Lovelace', terms: 1, disc: 0.5, note: true }} + script={style === 'none' ? withoutCursor : withCursor} + play={style === 'none' ? 'once' : 'loop'} + cursor={style === 'none' ? undefined : { at: 'rest', style }} + className="crs" + > + {(s, _r, layout) => ( + <> + +
+
+ Customer + + + {s.name} + + {s.typing && } + {!s.name && !s.typing && Search customers} + +
+
+ Terms + + {['Net 15', 'Net 30'].map((p, i) => ( + + {p} + + ))} + +
+
+ Discount + + + + + + {Math.round(s.disc * 20)}% + +
+
+ + Add a note, + added: Thank you, Ada., + }} /> +
+
+
+ + + )} + + ) +} + +export default function Cursors() { + return ( +
+ {(['real', 'large', 'circle', 'none'] as const).map((c) => ( +
+ +
{c === 'none' ? 'no cursor' : c}
+
+ ))} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dark-bento/bento.css b/skills/imported/benjitaylor/stageshow/examples/dark-bento/bento.css new file mode 100644 index 0000000..9e918c4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dark-bento/bento.css @@ -0,0 +1,218 @@ +/* All rules belong to this example, including its responsive variants. */ +.db-card { + /* The card is the stage. One gutter for every edge something sits near: + the window's top, and the satellite's left and bottom. */ + & .ss-stage { + --gutter: 22px; + /* One cool neutral family throughout: card, window, sidebar, satellite */ + --ss-well: #111113; + --ss-canvas: #151517; + --ss-surface: #1b1b1e; + --ss-chrome: #1b1b1e; + overflow: hidden; + border-radius: 18px; + background: + radial-gradient(120% 90% at 70% -10%, rgba(255, 255, 255, 0.06), transparent 60%), + var(--ss-well); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + } + + /* Pinned top-left, a gutter from the top; runs about a third past the right + and off the bottom. The left inset leaves room for the satellite. */ + .db-window { + position: absolute; + top: var(--gutter); + left: 88px; + width: 400px; + height: 300px; + background: var(--ss-surface); + border-radius: 12px; + box-shadow: var(--ss-shadow-hero); + } + + .db-window .ss-window__body { + display: flex; + } + + .db-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 4px; + width: 92px; + padding: 14px 8px; + border-right: 1px solid var(--ss-line); + background: var(--ss-canvas); + } + + .db-nav { + display: flex; + align-items: center; + gap: 8px; + height: 22px; + padding: 0 4px; + } + + .db-main { + flex: 1; + min-width: 0; + padding: 16px 16px 0; + } + + .db-header { + display: flex; + align-items: flex-end; + gap: 12px; + margin-bottom: 16px; + } + + .db-heading { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 2px; + } + + .db-title { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.3; + white-space: nowrap; + color: var(--ss-text-strong); + } + + /* ---- Album art: grayscale only, a little light in each so it reads as + a printed sleeve rather than a gray box ---- */ + + .db-art { + display: block; + flex-shrink: 0; + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); + } + + .db-art--1 { background: radial-gradient(circle at 30% 30%, #6a6a6e, #2a2a2d 70%); } + .db-art--2 { background: linear-gradient(160deg, #3c3c40 0%, #1f1f22 100%); } + .db-art--3 { background: radial-gradient(circle at 50% 120%, #d9d9dc 0%, #8a8a8e 28%, #2c2c30 70%); } + .db-art--4 { background: linear-gradient(200deg, #55555a, #232326 60%); } + .db-art--5 { background: radial-gradient(circle at 75% 25%, #4a4a4e, #1d1d20 65%); } + + /* The playlist cover: four sleeves, the way a shared mix shows its parts */ + .db-art--cover { + width: 48px; + height: 48px; + border-radius: 6px; + background: + radial-gradient(circle at 50% 120%, #d9d9dc 0%, #8a8a8e 28%, #2c2c30 70%) 0 0 / 50% 50% no-repeat, + radial-gradient(circle at 30% 30%, #6a6a6e, #2a2a2d 70%) 100% 0 / 50% 50% no-repeat, + linear-gradient(200deg, #55555a, #232326 60%) 0 100% / 50% 50% no-repeat, + radial-gradient(circle at 75% 25%, #4a4a4e, #1d1d20 65%) 100% 100% / 50% 50% no-repeat; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08), 0 6px 14px -6px rgba(0, 0, 0, 0.6); + } + + .db-art--tiny { width: 12px; height: 12px; border-radius: 3px; } + .db-art--row { width: 26px; height: 26px; } + .db-art--detail { width: 32px; height: 32px; border-radius: 6px; } + + /* ---- Faces: who the playlist is shared with ---- */ + + .db-people { + display: flex; + } + + /* Four people as gray photos; the kit's Avatar takes the shape and centering */ + .db-pile .ss-facepile__face:nth-child(1) .ss-avatar { background: linear-gradient(160deg, #f2f2f3, #b9b9bd); } + .db-pile .ss-facepile__face:nth-child(2) .ss-avatar { background: linear-gradient(160deg, #8e8e93, #5a5a5f); } + .db-pile .ss-facepile__face:nth-child(3) .ss-avatar { background: linear-gradient(160deg, #5e5e63, #3a3a3e); } + .db-pile .ss-facepile__face:nth-child(4) .ss-avatar { background: linear-gradient(160deg, #3e3e42, #2a2a2e); } + + .db-face.ss-avatar { + background: linear-gradient(160deg, #f2f2f3, #b9b9bd) !important; + color: #111113; + font-weight: 600; + } + + /* The new row's art carries a small badge of who added it */ + .db-row-art { + position: relative; + flex-shrink: 0; + } + + .db-face--row { + position: absolute; + right: -5px; + bottom: -5px; + box-shadow: 0 0 0 2px #29292c; + } + + + + /* ---- Track list ---- */ + + .db-list { + display: flex; + flex-direction: column; + } + + .db-row { + display: flex; + align-items: center; + gap: 10px; + height: 38px; + margin: 0 -8px; + padding: 0 8px; + border-radius: 8px; + } + + .db-row[data-new] { + background: #29292c; + } + + .db-lines { + display: flex; + flex-direction: column; + gap: 6px; + } + + /* ---- Satellite: the song that was just added. Straddles the window's + left edge over the sidebar, ends a gutter short of the sidebar divider + (88 + 92 = 180), and sits a level above the window. ---- */ + + .db-detail { + position: absolute; + left: var(--gutter); + bottom: var(--gutter); + width: 136px; + padding: 12px; + border-radius: 12px; + background: #222225; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.09), + 0 1px 0 0 rgba(255, 255, 255, 0.05) inset, + 0 24px 48px -12px rgba(0, 0, 0, 0.8), + 0 6px 14px -6px rgba(0, 0, 0, 0.6); + } + + .db-detail-track { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 12px; + border-bottom: 1px solid var(--ss-line); + } + + .db-detail-by { + display: flex; + align-items: center; + gap: 8px; + padding-top: 10px; + } + + .db-by { + font-size: 11px; + font-weight: 500; + line-height: 1.3; + color: var(--ss-text); + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dark-bento/index.tsx b/skills/imported/benjitaylor/stageshow/examples/dark-bento/index.tsx new file mode 100644 index 0000000..890597a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dark-bento/index.tsx @@ -0,0 +1,100 @@ +import { Avatar, Bar, Facepile, Stage, Text, Window } from '../../kit' +import './bento.css' + +// The bar for a dark, grayscale still: a bento card (360 × 280) for a music +// app, in a dark section of the page. Feature: shared playlists. +// +// Why it works: "shared" is said once, by one link. One row carries Mara's +// badge and the satellite explains it; nothing else repeats the idea. The rows +// keep album art, the thing that makes it a music app. The header is compact +// (cover beside title), so the list has room. Depth on dark comes from a +// lighter raised surface, not shadow. +// +// Story: a playlist that more than one person fills. The window shows the +// playlist with its listeners stacked beside the title; one row is new, and a +// satellite over the sidebar names who added it. Real strings: the playlist +// title and "Added by Mara". Everything else is a line or a shape. + +const TRACKS = [ + { title: 62, artist: 40, art: 1 }, + { title: 48, artist: 54, art: 2 }, + { added: true, art: 3 }, + { title: 70, artist: 36, art: 4 }, + { title: 56, artist: 46, art: 5 }, +] + +export default function DarkBento() { + return ( + <> + + {() => ( + <> + + + +
+
+ +
+ Sunday Slow +
+ {/* Cut out, not ringed: the gap shows the window behind */} + +
+
+
+ +
+ {TRACKS.map((t, i) => + t.added ? ( +
+ + + + +
+ + +
+
+ ) : ( +
+ +
+ + +
+
+ ), + )} +
+
+
+ + {/* Satellite: the song Mara just added, over the sidebar */} +
+
+ +
+ + +
+
+
+ + Added by Mara +
+
+ + )} +
+ + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dark-hero/hero.css b/skills/imported/benjitaylor/stageshow/examples/dark-hero/hero.css new file mode 100644 index 0000000..5f1a12a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dark-hero/hero.css @@ -0,0 +1,238 @@ +.dh .ss-stage { + overflow: hidden; + background: #09090b; +} + +/* The window inset top and left, off the right and the bottom */ +.dh-win { + position: absolute; + top: 48px; + left: 48px; + /* Off the right by a little: the columns end inside the stage, so only + the window's empty margin is cut */ + width: 744px; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06), 0 0 0 1px rgb(255 255 255 / 0.06); +} + +.dh .ss-stage[data-layout='compact'] .dh-win { + left: 16px; + width: 326px; +} + +.dh-side { + display: grid; + align-content: start; + gap: 6px; + padding: 14px 10px; +} + +.dh-nav { + display: flex; + align-items: center; + gap: 9px; + height: 28px; + padding: 0 8px; + border-radius: var(--ss-radius-sm); +} + +.dh-nav[data-active] { + background: var(--ss-ink-1); +} + +.dh-body { + min-height: 460px; + padding: 22px 66px 0 26px; + background: var(--ss-surface); +} + +.dh .ss-stage[data-layout='compact'] .dh-body { + padding: 18px 16px 0; +} + +.dh-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.dh-title { + margin: 0; + color: var(--ss-text-strong); + font-size: 16px; + font-weight: 600; +} + +.dh-proj { + display: flex; + align-items: center; + gap: 6px; + color: var(--ss-text); + font-size: 12px; +} + +.dh-list { + border-top: 1px solid var(--ss-line-faint); +} + +.dh-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 92px 36px; + align-items: center; + gap: 16px; + height: 62px; + border-bottom: 1px solid var(--ss-line-faint); + white-space: nowrap; +} + +.dh .ss-stage[data-layout='compact'] .dh-row { + grid-template-columns: minmax(0, 1fr) 84px 28px; + gap: 10px; +} + +.dh-lines { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + min-width: 0; +} + +.dh-msg { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 500; +} + +.dh-meta { + display: flex; + align-items: center; + gap: 12px; + height: 16px; + color: var(--ss-text); + font-size: 12px; +} + +/* 12px icons in a 16px line: whole pixels, no half-pixel hop as rows move */ +.dh-meta__item { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.dh-meta svg { + color: var(--ss-icon-faint); +} + +.dh-when { + color: var(--ss-text-faint); +} + +.dh .ss-stage[data-layout='compact'] .dh-when { + display: none; +} + +/* Current moves to the new row once it's Ready: one fades out, one in */ +.dh-current { + padding: 1px 7px; + border: 1px solid var(--ss-line); + border-radius: var(--ss-radius-pill); + color: var(--ss-text); + font-size: 10.5px; + font-weight: 500; + opacity: 0; + transition: opacity 0.35s ease; + /* Its row is pushed down as the new deploy opens above: a layer of its + own keeps the word from stepping inside its outline */ + will-change: transform; +} + +.dh-current[data-on] { + opacity: 1; +} + +.dh .ss-stage[data-layout='compact'] .dh-current { + display: none; +} + +.dh-badge { + justify-self: start; +} + + +.dh-badge .ss-spinner { + flex-shrink: 0; +} + +.dh-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* The old row's Ready is quiet, so the new one leads */ +.dh-badge--old { + color: var(--ss-text); +} + +.dh-time { + justify-self: end; + color: var(--ss-text); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.dh-row--filler > .ss-bar:last-child { + justify-self: end; +} + +/* Filler lines never run past their column */ +.dh-lines .ss-bar { + max-width: 100%; +} + +.dh-mark { + display: grid; + place-items: center; + width: 8px; + height: 8px; +} + +.dh-mark > * { + grid-area: 1 / 1; + transition: opacity 0.2s ease; +} + +.dh-mark[data-ready] .ss-spinner, +.dh-mark:not([data-ready]) .dh-dot { + opacity: 0; +} + +/* The words stack left-aligned in one cell whose width eases. The cell + never clips: the word that would be cut is already gone */ +.dh-label { + display: grid; + justify-items: start; + transition: width 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +.dh-word { + grid-area: 1 / 1; + white-space: nowrap; + opacity: 0; + transition: opacity 0.14s ease; +} + +.dh-word[data-on] { + opacity: 1; + transition: opacity 0.2s ease 0.12s; +} + +/* Shorter: the old word leaves, then the edge comes in */ +.dh-label[data-shrink] { + transition-delay: 0.1s; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dark-hero/index.tsx b/skills/imported/benjitaylor/stageshow/examples/dark-hero/index.tsx new file mode 100644 index 0000000..2a1cb79 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dark-hero/index.tsx @@ -0,0 +1,174 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { GitBranch, GitCommitHorizontal } from 'lucide-react' +import { Badge, Bar, Collapse, Spinner, Stage, Window, type Act } from '../../kit' +import './hero.css' + +// A dark desktop hero: a deploy lands at the top of a project's deployments +// and goes live. Beats: the new deploy opens in at the top and pushes the +// list down, Building with a spinner; its timer counts; the badge turns to +// Ready on the same pill and the timer settles on the build's time; the +// row's Current mark moves to it. After a 2s hold the new row closes the +// way it opened, handing Current back, and resets while closed. +// +// What makes a dark hero read: +// - The window is a step lighter than the page, its chrome a step lighter +// again, with a hairline on top, not a glow. Shadows don't show on black, +// so depth comes from those steps. +// - One color is lit: the success green, once, on the new row. Everything +// else is grays; the old rows' Ready pills are dimmed so the new one leads. +// - The status changes on one pill: the tone eases, the spinner and dot +// crossfade in one cell, and the pill narrows to fit "Ready" from its +// right edge only. The mark and the word never move; "Building" has faded +// before the edge reaches it, so no letter is ever cut. No second pill. +// - The list moves by its height, so the old rows slide down as one, never +// jump. +// +// Real strings: the project, the branch, the new commit, its status and time, +// and the row it replaced. The rest is placeholder at real line heights. + +type S = { open: boolean; ready: boolean; t: number } + +const initial: S = { open: false, ready: false, t: 0 } + +async function script(a: Act) { + await a.wait(900) + a.mark('deploy') + a.put({ open: true }) + for (let t = 1; t <= 4; t++) { + await a.wait(520) + a.put({ t: t * 9 }) + } + await a.wait(300) + a.mark('ready') + a.put({ ready: true, t: 38 }) + await a.wait(2000) + // The list loses what the loop added in one motion; the closed row's + // status and timer go back where nobody sees them + a.put({ open: false }) + await a.wait(700) + await a.snap({ ready: false, t: 0 }) +} + +const OLD = [ + { msg: 'Fix pricing copy', when: '2h ago', t: '41s' }, + { msg: null, when: null, t: null }, + { msg: null, when: null, t: null }, + { msg: null, when: null, t: null }, +] + +export default function DarkHero() { + return ( + width={760} height={440} compact={{ below: 760, width: 358, height: 440 }} theme="dark" initial={initial} poster={{ open: true, ready: true, t: 38 }} script={script} className="dh"> + {(s, _rect, layout) => ( + }> +
+
+
Deployments
+ + + northwind-web + +
+
+ + }> + + {OLD.map((r, i) => + r.msg ? ( + }>Ready} time={r.t!} current={!(s.open && s.ready)} /> + ) : ( +
+ + + + + + +
+ ), + )} +
+
+
+ )} + + ) +} + +// The status mark: spinner and dot share one 8px cell and crossfade, so +// the word beside it never moves; the cell is the spinner's own size, so +// the pill's padding reads the same on both sides +function Mark({ ready }: { ready: boolean }) { + return ( + + + + + ) +} + +// The status word. The pill keeps its left edge and only its right edge +// moves: the cell eases to the width of the word that is showing. Getting +// shorter, the old word fades out before the edge reaches it; getting +// longer, the edge opens first and the new word fades into the room. +function Label({ ready }: { ready: boolean }) { + const words = useRef([]) + const [w, setW] = useState([]) + useLayoutEffect(() => setW(words.current.map(e => e.offsetWidth)), []) + const on = ready ? 1 : 0 + const shrink = w.length === 2 && w[on]! < w[1 - on]! + return ( + + {['Building', 'Ready'].map((t, i) => ( + { if (e) words.current[i] = e }} className="dh-word" data-on={i === on || undefined}>{t} + ))} + + ) +} + +function Row({ lead, msg, hash, when, status, time, current }: { lead?: boolean; msg: string; hash: string; when: string; status: ReactNode; time: ReactNode; current: boolean }) { + return ( +
+ + + {msg} + Current + + {/* Each icon is held with its words, and the pairs sit a clear + gap apart, so no word runs into the next icon */} + + main + {hash} + {when} + + + {status} + {time} +
+ ) +} + +function Side() { + return ( + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx b/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx new file mode 100644 index 0000000..62ffd98 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx @@ -0,0 +1,83 @@ +import { Badge, Bar, Chart, Icon, LockScreen, Notification, Phone, Stage, Text, Window, WALLPAPER } from '../../kit' +import './payments.css' + +// "Get paid anywhere": a device pair. The dashboard is pinned top-left and +// runs off the bottom; the phone straddles its right edge over empty margin +// and placeholder rows, so the revenue figure and its chart stay clear. Real words: +// Revenue, the total, the change, and the notification. Narrow, the sidebar +// goes and both devices keep their true size: the window runs off the right, +// the phone stands a gutter in from it, whole across, its notification whole. + +const REVENUE = [18, 22, 20, 27, 25, 31, 29, 36, 34, 41, 39, 47] + +const ROWS = [ + { name: 74, amount: 40 }, + { name: 58, amount: 46 }, + { name: 82, amount: 36 }, + { name: 64, amount: 44 }, + { name: 70, amount: 38 }, +] + +const WALL = WALLPAPER + +export default function PaymentsStatic() { + return ( + + {() => ( + <> + + + +
+
+
+
+ Revenue + +
+
+ $12,604.80 + +12.4% +
+ + +
+ + +
+
+ +
+ +
+ + {ROWS.map((row, i) => ( +
+ + + +
+ ))} +
+
+
+ + + + {/* The product's own app sends it: the dashboard's bolt, not Mail */} + } title="Payment received" body="$249.00 from Oak & Ivy" /> + + + + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css b/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css new file mode 100644 index 0000000..38c42b0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css @@ -0,0 +1,150 @@ +/* The section background is the stage: light gray, no box. One gutter for + the window's top and left and the phone's right. */ +.ps-section .ss-stage { + --gutter: 32px; + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top-left, runs about a quarter past the bottom */ +.ps-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + width: 496px; + height: 460px; + background: var(--ss-surface); + border-radius: 10px; + box-shadow: var(--ss-shadow-hero); +} + +.ps-window .ss-window__body { + display: flex; +} + +.ps-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 4px; + width: 112px; + padding: 16px 10px; + border-right: 1px solid var(--ss-line); + background: var(--ss-canvas); +} + +.ps-logo { + /* A plain wordmark: a fictional company gets no invented mark */ + display: flex; + align-items: center; + height: 20px; + margin: 0 0 12px 6px; + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 600; +} + +.ps-nav { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + padding: 0 6px; + border-radius: 6px; +} + +.ps-nav[data-active] { + background: var(--ss-surface); + box-shadow: 0 0 0 1px var(--ss-line); +} + +.ps-main { + flex: 1; + min-width: 0; + padding: 20px 22px; +} + +.ps-top { + display: grid; + grid-template-columns: 224px; +} + +.ps-head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ps-label { + font-size: 12px; + font-weight: 500; + color: var(--ss-text); +} + +.ps-figure { + display: flex; + align-items: center; + gap: 8px; + margin: 8px 0 10px; +} + +.ps-total { + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--ss-text-strong); + font-variant-numeric: tabular-nums; +} + +.ps-chart { + display: block; + margin-top: 16px; +} + +.ps-axis { + display: flex; + justify-content: space-between; + margin-top: 8px; +} + +.ps-list { + margin-top: 28px; + padding-top: 18px; + border-top: 1px solid var(--ss-line); +} + +.ps-row { + display: flex; + align-items: center; + gap: 10px; + height: 34px; + border-bottom: 1px solid var(--ss-line-faint); +} + +/* Straddles the window's right edge, about half outside, over empty margin + and placeholder rows; its top sits in the upper half of the stage and the stage crops its + base. Stronger shadow than the window: a level above. */ +.ps-phone { + position: absolute; + top: 84px; + right: var(--gutter); + box-shadow: var(--ss-shadow-satellite), 0 30px 50px -20px rgba(50, 50, 93, 0.35); +} + +/* Narrow: the sidebar goes; the window runs off the right under the phone */ +.ps-section .ss-stage[data-layout='compact'] { + --gutter: 16px; +} + +.ps-section .ss-stage[data-layout='compact'] .ps-sidebar { + display: none; +} + +.ps-section .ss-stage[data-layout='compact'] .ps-window { + width: 420px; +} + +.ps-section .ss-stage[data-layout='compact'] .ps-phone { + top: 150px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx new file mode 100644 index 0000000..54f5565 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx @@ -0,0 +1,260 @@ +import { useId, useRef, type CSSProperties } from 'react' +import { Phone, LockScreen, Roll, Stage, Swap, type Act } from '../../kit' +import './island.css' + +// A food delivery's Live Activity in the Dynamic Island, on the top of an +// iPhone rising from the foot of the stage. The island is the story; the +// lock screen behind it supplies the device context. +// +// Everything is in screen points on the kit's iPhone (402 × 874), round the +// screen's island: x 153.5 to 248.5, y 14.5, 95 × 37 (craft.md, +// Chrome and devices). The island is one black shape whose box (left, top, +// width, height, radius) springs between presentations: underdamped with a +// small overshoot when it grows, critically damped and quicker when it +// shrinks, a beat late so the content has cleared. Content is a separate +// layer above the shape at its final positions, so nothing in it stretches: +// each item blurs, shrinks and fades out first, and the new items sharpen in +// once the shape is most of the way there. A second activity (a timer) buds +// off as a circle through a soft neck: a goo filter on the black shapes only. +// +// Real strings: the minutes, "Preparing" then "Picked up", "Sam is your courier", +// the order number. Everything else is a shape. + +/** A damped spring as a CSS linear() easing */ +function spring(zeta: number, omega: number, ms: number, n = 48) { + const wd = omega * Math.sqrt(1 - zeta * zeta) + const pts: string[] = [] + for (let i = 0; i <= n; i++) { + const t = (i / n) * (ms / 1000) + const x = i === n ? 1 : 1 - Math.exp(-zeta * omega * t) * (Math.cos(wd * t) + ((zeta * omega) / wd) * Math.sin(wd * t)) + pts.push(x.toFixed(4)) + } + return `linear(${pts.join(', ')})` +} + +// Grow: about 4% overshoot, settled by 0.5s. Shrink: no overshoot, 0.38s. +const GROW = { ease: spring(0.7, 15, 500), ms: 500 } +const SHRINK = { ease: spring(0.98, 19, 380), ms: 380 } + +type Mode = 'idle' | 'compact' | 'open' | 'split' +type Box = { l: number; t: number; w: number; h: number; r: number } + +const CX = 201 +const BASE: Box = { l: 153.5, t: 14.5, w: 95, h: 37, r: 18.5 } +const GEO: Record = { + idle: BASE, + // Wide enough that the trailing minutes clear the camera (at 248.5) + compact: { l: CX - 103, t: 14.5, w: 206, h: 37, r: 18.5 }, + // Minimal, with a second activity: the island grows on its leading side only + split: { l: 153.5 - 44, t: 14.5, w: 95 + 44, h: 37, r: 18.5 }, + // Expanded: inset 13 from the screen's sides and 10 from its top, so its + // corners run concentric with the display's (64 - 12 = 52) + open: { l: 13, t: 10, w: 376, h: 172, r: 52 }, +} +const SIZE: Record = { idle: 0, split: 1, compact: 2, open: 3 } +// The detached circle, 8pt clear of the island's trailing end +const BUD = { l: 248.5 + 8, t: 14.5, d: 37 } + +type S = { mode: Mode; eta: number; prog: number; label: string; press: boolean; timer: number } +const START: S = { mode: 'compact', eta: 8, prog: 0.22, label: 'Preparing', press: false, timer: 0 } +const POSTER: S = { ...START, mode: 'open', eta: 7, prog: 0.4 } + +// A takeaway bag, solid (small glyphs are filled), with its handle cut out +const BAG = + 'M8.2 7.2V6.6a3.8 3.8 0 0 1 7.6 0v.6h2.1c.8 0 1.4.6 1.5 1.4l.9 10.2a2 2 0 0 1-2 2.2H5.7a2 2 0 0 1-2-2.2l.9-10.2c.1-.8.7-1.4 1.5-1.4Zm1.8 0h4V6.6a2 2 0 0 0-4 0Z' + +function Bag({ size }: { size: number }) { + return ( + + + + ) +} + +const boxStyle = (b: Box, grow: boolean): CSSProperties => ({ + left: b.l, + top: b.t, + width: b.w, + height: b.h, + borderRadius: b.r, + transitionDuration: `${grow ? GROW.ms : SHRINK.ms}ms`, + transitionTimingFunction: grow ? GROW.ease : SHRINK.ease, + // Shrinking waits for the content to clear, so nothing paints past the edge + transitionDelay: grow ? '0ms' : '120ms', +}) + +function Island({ s }: { s: S }) { + const gooId = useId() + // Spring by direction: overshoot only when growing (an overshoot while + // shrinking would dip the shape inside the bezel's own island) + const last = useRef<{ mode: Mode; grow: boolean }>({ mode: s.mode, grow: true }) + if (last.current.mode !== s.mode) last.current = { mode: s.mode, grow: SIZE[s.mode] > SIZE[last.current.mode] } + const grow = last.current.grow + // In the plain pill, tuck the dormant circle inside its trailing cap. + // Its compact position would protrude beyond the narrower idle shape. + const budLeft = s.mode === 'split' ? BUD.l : s.mode === 'idle' ? BASE.l + BASE.w - BUD.d : BUD.l - 30 + const min = (n: number) => `${n} min` + return ( +
+ + + {/* The neck reaches only between nearby shapes. A wider blur keeps + pulling their facing edges even across the settled 8pt gap. */} + + + + +
+ + +
+ + {/* Compact: the bag leading, the minutes trailing */} + + + + {/* Minimal: only the bag, on the leading side */} + + + + + {min(s.eta)} + + + {/* The timer's circle and its ring */} + + + + 0 || undefined} + style={{ strokeDashoffset: 1 - s.timer }} + /> + + + + {/* Expanded: the whole body as one group, in once the shape is mostly there */} +
+
+ + + {s.label} + + + {min(s.eta)} + + + + + +
+ + + + + + + + + Sam is your courier + #2041 + +
+
+ ) +} + +/** + * The loop, about 16s, one thing at a time: + * 1.6s the minutes roll down in the compact island + * 3.0s a finger presses and holds on the island; it squeezes in a touch + * 3.9s it springs open into the Live Activity; the body sharpens in + * 5.6s the order is picked up: the label changes, the minutes roll and the bag rides up the track + * 8.0s it folds back to compact + * 9.4s a timer starts: the island goes minimal and the timer buds off + * as a circle through a soft neck; its ring fills + * 13.6s the timer ends: the circle is drawn back in, compact again + * 15.0s the order arrives: the island draws in to its plain pill, and + * the next order's activity grows out of it, so the loop meets itself + */ +const script = async (a: Act) => { + await a.wait(1600) + a.put({ eta: 7 }) + await a.wait(1400) + a.mark('press') + const r = a.rect('island') + const point = r ? { x: r.x + r.width * 0.5, y: r.y + r.height * 0.55 } : 'island' + await a.move(point, { duration: 420 }) + a.put({ press: true }) + // A long press: the finger holds where it landed + await a.drag(point, undefined, { duration: 420 }) + a.put({ press: false, mode: 'open' }) + a.mark('open') + // The finger lifts; a touch cursor shows only where it taps, so it goes + // back to where it started unseen, and the loop meets itself + void a.move(a.layout === 'compact' ? { x: 268, y: 400 } : { x: 470, y: 460 }, { duration: 300 }) + await a.wait(1700) + a.put({ label: 'Picked up', eta: 6, prog: 0.58 }) + await a.wait(2400) + a.mark('fold') + a.put({ mode: 'compact' }) + await a.wait(1400) + a.mark('timer') + a.put({ mode: 'split' }) + await a.wait(500) + a.put({ timer: 1 }) + await a.wait(3700) + a.mark('merge') + a.put({ mode: 'compact' }) + await a.wait(1400) + a.mark('end') + a.put({ mode: 'idle' }) + await a.wait(600) + a.snap({ ...START, mode: 'idle' }) + await a.wait(200) + a.put({ mode: 'compact' }) + await a.wait(900) +} + +export default function DynamicIsland() { + return ( + + width={640} + height={400} + compact={{ below: 560, width: 358, height: 340 }} + initial={START} + poster={POSTER} + script={script} + cursor={{ at: { wide: { x: 470, y: 460 }, compact: { x: 268, y: 400 } }, style: 'touch' }} + className="di" + > + {(s, _rect, layout) => { + const compact = layout === 'compact' + const pw = compact ? 326 : 470 + const w = compact ? 358 : 640 + return ( +
+ + + + + +
+ ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/dynamic-island/island.css b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/island.css new file mode 100644 index 0000000..7ae0d19 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/island.css @@ -0,0 +1,289 @@ +/* The Dynamic Island example. Island units are screen points. */ +.di { + --di-wall: radial-gradient(80% 40% at 70% 0%, rgba(176, 210, 255, 0.45), rgba(176, 210, 255, 0) 70%), + linear-gradient(178deg, #8fb3e6 0%, #5a7fc2 26%, #34508f 52%, #1d2b56 78%, #121a33 100%); + + .ss-stage { + overflow: hidden; + /* A calm, cool ground: pale at the top, a breath of blue low behind the phone */ + background: + radial-gradient(70% 60% at 50% 100%, rgba(120, 156, 222, 0.3), rgba(120, 156, 222, 0) 70%), + linear-gradient(180deg, #f4f6f9 0%, #e9edf3 100%); + } + + .di-device { + position: absolute; + filter: drop-shadow(0 40px 50px rgba(30, 45, 90, 0.2)) drop-shadow(0 10px 16px rgba(30, 45, 90, 0.1)); + } + + .di-lock .ss-lock__date, .di-lock .ss-lock__clock { + transition: opacity .18s ease, filter .18s ease; + } + /* The expanded activity covers the clock; clear it before the edge passes. */ + .di-lock[data-obscured] .ss-lock__date, .di-lock[data-obscured] .ss-lock__clock { + opacity: 0; + filter: blur(6px); + } + + .di-hit { + position: absolute; + z-index: 7; + } + + /* ---- The island ---- */ + + .di-island { + --di-tint: #5fe08a; + --di-accent: #4cd57c; + --di-accent-ink: #0b2a16; + --di-timer: #ff9f0a; + position: absolute; + inset: 0; + z-index: 6; + pointer-events: none; + /* The press: the whole island squeezes in a touch under the finger */ + transform-origin: 201px 33px; + /* Kept on its own layer for good, so the text rests exactly as it moved */ + will-change: transform, scale; + transition: scale 0.22s cubic-bezier(0.25, 1, 0.5, 1); + } + + .di-island[data-press] { + scale: 0.965; + } + + .di-island__defs { + position: absolute; + } + + .di-island__shapes { + position: absolute; + inset: 0; + } + + .di-island__shape, + .di-island__bud { + position: absolute; + background: #000; + will-change: left, width, height; + } + + .di-island__shape { + transition-property: left, top, width, height, border-radius; + } + + .di-island__bud { + border-radius: 50%; + scale: 0.4; + transition-property: left, scale; + transition-duration: 0.6s; + } + + .di-island__bud[data-on] { + scale: 1; + } + + /* Content: out fast and blurred, in sharpening once the shape is mostly there */ + .di-item { + position: absolute; + color: var(--di-tint); + opacity: 0; + scale: 0.88; + filter: blur(8px); + will-change: transform, filter, opacity; + transition: + opacity 0.12s ease-in, + scale 0.14s ease-in, + filter 0.12s ease-in; + } + + .di-item[data-on] { + opacity: 1; + scale: 1; + filter: blur(0); + transition: + opacity 0.24s ease-out 0.16s, + scale 0.4s cubic-bezier(0.25, 1, 0.5, 1) 0.16s, + filter 0.26s ease-out 0.16s; + } + + .di-trail[data-on], + .di-body[data-on] { + transition-delay: 0.2s; + } + + .di-timer[data-on] { + transition-delay: 0.45s; + } + + .di-lead { + display: grid; + width: 22px; + height: 22px; + } + + .di-trail { + display: flex; + align-items: center; + height: 37px; + font-size: 15.5px; + font-weight: 600; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; + } + + .di-timer { + display: grid; + width: 24px; + height: 24px; + color: var(--di-timer); + } + + .di-timer__arc { + transform: rotate(-90deg); + transform-origin: 12px 12px; + /* Filling runs the timer down; the reset happens unseen, with no transition */ + transition: none; + } + + .di-timer__arc[data-run] { + transition: stroke-dashoffset 3.6s linear 0.3s; + } + + .di-body { + display: flex; + flex-direction: column; + padding: 22px 26px 20px 28px; + color: #fff; + scale: 0.94; + transform-origin: 50% 20%; + } + + .di-body__top { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + .di-body__words { + display: flex; + flex-direction: column; + gap: 2px; + } + + .di-body__label { + white-space: nowrap; + font-size: 17px; + font-weight: 500; + color: #9d9fa6; + } + + .di-body__eta { + font-size: 38px; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + } + + .di-body__badge { + display: grid; + place-items: center; + width: 54px; + height: 54px; + border-radius: 50%; + background: var(--di-accent); + color: var(--di-accent-ink); + } + + /* The route: a dim track, the traveled part in the accent, the bag riding + the head of it in a small disc, home a ring at the end */ + .di-track { + position: relative; + height: 6px; + margin: 16px 0 0; + border-radius: 3px; + background: #26282b; + } + + .di-track__fill { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--p) * 100%); + border-radius: inherit; + background: var(--di-accent); + transition: width 1.4s cubic-bezier(0.45, 0, 0.1, 1); + } + + .di-track__rider { + position: absolute; + top: 50%; + left: calc(var(--p) * 100%); + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: #fff; + color: var(--di-accent-ink); + box-shadow: 0 0 0 3px #000; + translate: -50% -50%; + will-change: transform; + transition: left 1.4s cubic-bezier(0.45, 0, 0.1, 1); + } + + .di-track__end { + position: absolute; + top: 50%; + right: 0; + width: 14px; + height: 14px; + border-radius: 50%; + background: #000; + box-shadow: inset 0 0 0 3px var(--di-accent); + translate: 0 -50%; + } + + .di-body__foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + font-size: 15px; + font-weight: 500; + color: #9d9fa6; + } + + .di-body__order { + padding: 3px 8px; + border-radius: 6px; + box-shadow: inset 0 0 0 1.5px #44464b; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.03em; + color: #fff; + font-variant-numeric: tabular-nums; + } + + @media (prefers-reduced-motion: reduce) { + .di-item, + .di-item[data-on] { + filter: none; + scale: 1; + } + } + + /* With an activity showing, iOS makes room: the signal bars give way */ + .ss-phone__signal { + transition: opacity 0.2s ease; + } + + .ss-phone__screen:has(.di-island:not([data-mode="idle"])) .ss-phone__signal { + opacity: 0; + } +& .ss-stage[data-layout='compact'] { + --gutter: 16px; +} +} + +/* Phone layout: the phone fills the column, one 16px gutter each side */ diff --git a/skills/imported/benjitaylor/stageshow/examples/excel/excel.css b/skills/imported/benjitaylor/stageshow/examples/excel/excel.css new file mode 100644 index 0000000..e1942fd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/excel/excel.css @@ -0,0 +1,596 @@ +/* Excel for Mac, light. Grays are the kit's neutral family; the one accent is + Excel's green, on the selection and the active tab. Chart bars keep + Office's own series blue, since that is what a chart in Excel looks like. */ + +.xl-stage .ss-stage { + overflow: hidden; + background: var(--ss-well); + border-radius: 14px; + --xl-green: var(--ss-accent); + --xl-grid: #e3e3e3; + --xl-head: #f4f4f4; + --xl-head-sel: #e2e2e2; + --xl-ribbon: #f7f7f7; + --xl-series: #4472c4; + --xl-ink: #262628; + --xl-soft: #6b6b6e; +} + +.xl-window { + position: absolute; + left: 36px; + top: 36px; + width: 880px; + height: 540px; + box-shadow: var(--ss-shadow-hero); + font-family: var(--ss-font); +} + +.xl-window[data-layout="compact"] { + left: 18px; + top: 18px; + width: 460px; + height: 460px; +} + +.xl-window .ss-window__bar { + position: relative; + background: var(--xl-ribbon); + border-bottom: 0; +} + +.xl-window .ss-window__body { + display: flex; + flex-direction: column; + background: #fff; +} + +/* Title bar */ +.xl-quick { + display: flex; + align-items: center; + gap: 12px; + margin-left: 16px; +} + +.xl-autosave { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--xl-soft); +} + +.xl-switch { + position: relative; + width: 24px; + height: 14px; + border-radius: 7px; + background: #d6d6d8; +} + +.xl-switch::after { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; +} + +.xl-doc { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + font-size: 13px; + font-weight: 600; + color: var(--xl-ink); +} + +.xl-search { + display: flex; + align-items: center; + gap: 8px; + width: 200px; + height: 24px; + padding: 0 10px; + margin-right: 40px; + border-radius: 6px; + background: #fff; + box-shadow: 0 0 0 1px var(--ss-line); +} + +/* Ribbon */ +.xl-tabs { + display: flex; + flex-shrink: 0; + gap: 18px; + height: 30px; + padding: 0 16px; + background: var(--xl-ribbon); +} + +.xl-tab { + position: relative; + display: flex; + align-items: center; + font-size: 12px; + color: var(--xl-soft); + white-space: nowrap; +} + +.xl-tab[data-active] { + color: var(--xl-ink); + font-weight: 600; +} + +.xl-tab[data-active]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 3px; + height: 2px; + border-radius: 1px; + background: var(--xl-green); +} + +.xl-ribbon { + display: flex; + flex-shrink: 0; + align-items: stretch; + height: 64px; + padding: 6px 10px; + background: var(--xl-ribbon); + border-bottom: 1px solid var(--xl-grid); +} + +.xl-window[data-layout="compact"] .xl-ribbon { + height: 56px; +} + +.xl-group { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + border-right: 1px solid #e2e2e2; +} + +.xl-group:first-child { + padding-left: 6px; +} + +.xl-big { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.xl-big-label { + font-size: 11px; + color: var(--xl-soft); +} + +.xl-stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +.xl-font { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 8px; +} + +.xl-row { + display: flex; + align-items: center; + gap: 8px; +} + +.xl-field { + white-space: nowrap; + display: flex; + align-items: center; + justify-content: space-between; + height: 20px; + padding: 0 6px 0 7px; + border-radius: 4px; + background: #fff; + box-shadow: 0 0 0 1px #dcdcdc; + font-size: 11px; + color: var(--xl-ink); +} + +.xl-caret { + flex-shrink: 0; + color: #8a8a8d; +} + +.xl-glyph { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + font-size: 12px; + line-height: 1; + color: #4a4a4d; +} + +.xl-sep { + width: 1px; + height: 12px; + background: #e0e0e0; +} + +.xl-grid2 { + display: grid; + grid-template-columns: repeat(3, 14px); + gap: 10px 10px; + align-content: center; +} + +.xl-editing { + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 5px; +} + +.xl-autosum { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 6px 0 4px; + margin-left: -4px; + border-radius: 5px; + font-size: 11px; + color: var(--xl-ink); + transition: background 0.12s ease; +} + +.xl-autosum[data-hover] { + background: #e6e6e6; +} + +.xl-autosum[data-pressed] { + background: #dadada; +} + +.xl-sigma { + font-size: 16px; + line-height: 1; + font-weight: 500; + color: var(--xl-ink); +} + +.xl-quiet { + gap: 7px; +} + +/* Formula bar */ +.xl-fbar { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; + height: 30px; + padding: 0 10px; + border-bottom: 1px solid var(--xl-grid); + background: #fff; +} + +.xl-window[data-layout="compact"] .xl-fbar { + height: 28px; +} + +.xl-name { + display: flex; + align-items: center; + justify-content: space-between; + width: 78px; + height: 20px; + padding: 0 6px 0 7px; + border-radius: 4px; + box-shadow: 0 0 0 1px #dcdcdc; + font-size: 11px; + color: var(--xl-ink); + white-space: nowrap; +} + +.xl-fx-icons { + display: flex; + align-items: center; + gap: 10px; + padding: 0 4px; + font-size: 11px; + color: #a6a6a9; +} + +.xl-fx { + font-style: italic; + font-family: Georgia, "Times New Roman", serif; + font-size: 13px; + color: #6b6b6e; +} + +.xl-formula { + flex: 1; + display: flex; + align-items: center; + height: 20px; + padding-left: 10px; + border-left: 1px solid var(--xl-grid); + font-size: 12px; + color: var(--xl-ink); + white-space: nowrap; +} + +/* The sheet */ +.xl-sheet { + position: relative; + display: grid; + flex: 1; + min-height: 0; + overflow: hidden; + font-size: 12px; + color: var(--xl-ink); +} + +.xl-corner, +.xl-colhead, +.xl-rowhead { + display: flex; + align-items: center; + justify-content: center; + background: var(--xl-head); + border-right: 1px solid var(--xl-grid); + border-bottom: 1px solid var(--xl-grid); + font-size: 11px; + color: #77777a; + transition: background 0.1s ease, color 0.1s ease; +} + +.xl-colhead[data-sel], +.xl-rowhead[data-sel] { + background: var(--xl-head-sel); + color: var(--xl-green); + font-weight: 600; +} + +.xl-colhead[data-sel] { + box-shadow: inset 0 -2px 0 var(--xl-green); +} + +.xl-rowhead[data-sel] { + box-shadow: inset -2px 0 0 var(--xl-green); +} + +.xl-cell { + display: flex; + align-items: center; + padding: 0 6px; + border-right: 1px solid var(--xl-grid); + border-bottom: 1px solid var(--xl-grid); + white-space: nowrap; + overflow: hidden; + transition: background 0.1s ease; +} + +.xl-num { + justify-content: flex-end; + font-variant-numeric: tabular-nums; +} + +.xl-cell[data-row] { + justify-content: flex-start; +} + +.xl-num[data-row] { + justify-content: flex-end; +} + +.xl-cell[data-sel] { + background: #e6efe9; +} + +.xl-bold { + font-weight: 700; +} + +.xl-total { + font-weight: 700; + position: relative; + top: 3px; + opacity: 0; + /* Rises by top, not a transform, so the figure lands without a hop */ + transition: opacity 0.25s ease, top 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.xl-total[data-show] { + opacity: 1; + top: 0; +} + +.xl-selection { + position: absolute; + box-shadow: inset 0 0 0 2px var(--xl-green); + pointer-events: none; + transition: top 0.1s ease-out, height 0.1s ease-out; +} + +.xl-handle { + position: absolute; + right: -3px; + bottom: -3px; + width: 6px; + height: 6px; + background: var(--xl-green); + box-shadow: 0 0 0 1px #fff; +} + +/* The chart object, floating over the sheet as Excel draws it */ +.xl-chart { + position: absolute; + display: flex; + flex-direction: column; + padding: 12px 16px 14px; + background: #fff; + box-shadow: 0 0 0 1px #d4d4d4; + color: #595959; +} + +.xl-chart-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 14px; +} + +.xl-chart-title { + font-size: 13px; + color: #404040; +} + +.xl-chart-total { + font-size: 11px; + color: #7a7a7d; + white-space: nowrap; + position: relative; + top: 3px; + opacity: 0; + transition: opacity 0.4s ease, top 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.xl-chart-value { + display: inline-grid; + font-weight: 600; + color: #262628; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.xl-chart-value > * { + grid-area: 1 / 1; +} + +.xl-chart-value-size { + visibility: hidden; +} + +.xl-chart-total[data-show] { + opacity: 1; + top: 0; +} + +.xl-plot { + position: relative; + display: flex; + justify-content: flex-end; +} + +.xl-axis { + position: absolute; + left: 0; + top: 0; + bottom: 0; +} + +.xl-axis span { + position: absolute; + right: auto; + left: 0; + transform: translateY(50%); + font-size: 9px; + line-height: 1; + color: #8a8a8d; + font-variant-numeric: tabular-nums; +} + +.xl-bars { + position: relative; +} + +.xl-gridline { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: #e6e6e6; +} + +.xl-chart-svg { + position: relative; +} + +.xl-chart-svg .ss-chart__bar { + fill: var(--xl-series); +} + +.xl-label { + position: absolute; + /* Centered by a fixed translate; it rises by margin, so the label never + ends a transform transition */ + transform: translateX(-50%); + margin-top: 4px; + font-size: 10px; + line-height: 1.3; + color: #404040; + font-variant-numeric: tabular-nums; + opacity: 0; + transition: opacity 0.4s ease, margin-top 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.xl-label[data-show] { + opacity: 1; + margin-top: 0; +} + +.xl-cats { + position: relative; + align-self: flex-end; + height: 14px; + margin-top: 6px; +} + +.xl-cats span { + position: absolute; + top: 0; + transform: translateX(-50%); + font-size: 10px; + line-height: 1.3; + color: #6b6b6e; + white-space: nowrap; +} + +.xl-window[data-layout="compact"] .xl-chart { + padding: 10px 12px 12px; +} + +.xl-window[data-layout="compact"] .xl-chart-head { + flex-direction: column; + gap: 3px; + margin-bottom: 10px; +} + +.xl-window[data-layout="compact"] .xl-cats span, +.xl-window[data-layout="compact"] .xl-label { + font-size: 8.5px; +} + +/* Swap clamps the incoming text to the old box's width (max-width: 100%), + which cuts a longer formula; let it measure at its own width. */ +.xl-formula .ss-swap__in, +.xl-formula .ss-swap__out { + max-width: none; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/excel/index.tsx b/skills/imported/benjitaylor/stageshow/examples/excel/index.tsx new file mode 100644 index 0000000..ff9a9d7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/excel/index.tsx @@ -0,0 +1,376 @@ +import { Bar, Chart, Count, Stage, Swap, Window, type Act, type Layout } from '../../kit' +import './excel.css' + +// Excel for Mac: select B2:B7, click AutoSum, the total lands in B8, the +// chart beside the table gains its data labels and total, then a click on +// B8 shows =SUM(B2:B7) in the formula bar. Cmd+Z undoes it and a click back +// on B2 returns to the opening frame. + +const ROWS: [string, number][] = [ + ['North', 48200], + ['South', 39850], + ['East', 52410], + ['West', 61730], + ['Metro', 44960], + ['Web', 37040], +] +const SUM = ROWS.reduce((n, [, v]) => n + v, 0) // 284,190 +const SHARE = ROWS.map(([, v]) => Math.round((v / SUM) * 100)) +const fmt = (n: number) => n.toLocaleString('en-US') + +type S = { + from: number // anchor row (the active cell) + to: number // the row the selection reaches + dragging: boolean + total: boolean + labels: boolean + sum: number +} + +const initial: S = { from: 2, to: 2, dragging: false, total: false, labels: false, sum: 0 } + +// Geometry per layout. Columns: row header, A, B, then the rest. +const GEO = { + wide: { rowHead: 34, a: 112, b: 104, col: 76, cols: 9, rows: 16, row: 22, head: 20, chart: { left: 330, top: 30, width: 344, height: 236 }, plot: { w: 268, h: 158 } }, + compact: { rowHead: 28, a: 70, b: 78, col: 64, cols: 4, rows: 13, row: 22, head: 20, chart: { left: 188, top: 26, width: 196, height: 176 }, plot: { w: 152, h: 88 } }, +} + +async function script(a: Act) { + await a.wait(500) + + // 1. Select the column: press on B2 and drag down to B7. + await a.move({ at: 'b2', x: 0.42, y: 0.55 }) + await a.wait(250) + const rowAt = (y: number) => { + for (let r = 7; r >= 2; r--) if (y >= a.rect(`b${r}`).y) return r + return 2 + } + await a.drag({ at: 'b7', x: 0.42, y: 0.55 }, (at) => a.put({ to: rowAt(at.y), dragging: true }), { duration: 1000 }) + await a.set({ dragging: false }) + await a.wait(500) + + // 2. AutoSum: aim at the sigma, so the label stays readable. + a.mark('autosum') + await a.move({ at: 'autosum', x: 0.96, y: 0.7 }) + await a.wait(250) + await a.click() + await a.set({ total: true, sum: SUM }) + + // 3. The chart picks it up: data labels stagger in as the total counts. + await a.wait(300) + await a.set({ labels: true }) + await a.wait(1100) + + // 4. Click the total to read its formula. + a.mark('formula') + await a.move({ at: 'b8', x: 0.3, y: 0.62 }) + await a.wait(150) + await a.click() + await a.set({ from: 8, to: 8 }) + await a.wait(2400) + + // Undo, then back to B2: the opening frame, caused on screen. + a.mark('undo') + await a.key('cmd+z') + await a.set({ total: false, labels: false }) + await a.wait(500) + await a.snap({ sum: 0 }) + await a.wait(400) + await a.move({ at: 'b2', x: 0.35, y: 0.5 }) + await a.wait(120) + await a.click() + await a.set({ from: 2, to: 2 }) + await a.wait(900) +} + +export default function ExcelDemo() { + return ( + + {(s, _rect, layout) => } + + ) +} + +function Excel({ s, layout }: { s: S; layout: Layout }) { + const g = GEO[layout] + const compact = layout === 'compact' + const top = Math.min(s.from, s.to) + const bottom = Math.max(s.from, s.to) + const inSel = (r: number) => r >= top && r <= bottom + const multi = bottom > top + const name = s.dragging && multi ? `${bottom - top + 1}R x 1C` : `B${s.from}` + const formula = s.from === 8 ? (s.total ? '=SUM(B2:B7)' : '') : s.from >= 2 && s.from <= 7 ? String(ROWS[s.from - 2][1]) : '' + const letters = ['A', 'B', ...'CDEFGHIJK'.slice(0, g.cols).split('')] + const widths = [g.a, g.b, ...Array(g.cols).fill(g.col)] + + return ( + + + AutoSave + + + {!compact && [0, 1, 2, 3].map((i) => )} + + } + toolbar={ + <> + Q3 Sales + {!compact && ( + + + + + )} + + } + > + {/* Ribbon tabs */} +
+ {(compact ? ['Home', 'Insert', 'Draw', 'Formulas', 'Data'] : ['Home', 'Insert', 'Draw', 'Page Layout', 'Formulas', 'Data', 'Review', 'View', 'Automate']).map((t, i) => ( + + {t} + + ))} +
+ + {/* Ribbon */} +
+
+ + + Paste + + + + + +
+
+ + + Aptos Narrow + + + 12 + + + + B + I + U + {!compact && ( + <> + + + + + + )} + +
+ {!compact && ( + <> +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+ + + General + + + + {[0, 1, 2, 3].map((i) => ( + + ))} + +
+
+ {[52, 44, 40].map((w, i) => ( + + + + + ))} +
+ + )} +
+ + Σ + AutoSum + + + + + + + {!compact && ( + + + + + )} +
+
+ + {/* Formula bar */} +
+ + {name} + + + + ✕ + ✓ + fx + + + + {formula || ' '} + + +
+ + {/* The sheet */} +
`${w}px`).join(' ')}`, gridAutoRows: g.row, gridTemplateRows: `${g.head}px` }}> +
+ {letters.map((l) => ( +
+ {l} +
+ ))} + {Array.from({ length: g.rows }, (_, i) => { + const r = i + 1 + return ( + + ) + })} + + {/* The selection: one green frame with the fill handle, gliding as Excel's cell cursor does */} +
+ +
+ + +
+ + ) +} + +function Row({ r, n, sel, active, total }: { r: number; n: number; sel: boolean; active: boolean; total: boolean }) { + const data = r >= 2 && r <= 7 ? ROWS[r - 2] : null + let a: React.ReactNode = null + let b: React.ReactNode = null + if (r === 1) { + a = Region + b = Q3 Sales + } else if (data) { + a = data[0] + b = fmt(data[1]) + } else if (r === 8) { + a = Total + b = ( + + {fmt(SUM)} + + ) + } + return ( + <> +
+ {r} +
+
{a}
+
+ {b} +
+ {Array.from({ length: n - 2 }, (_, i) => ( +
+ ))} + + ) +} + +function ChartObject({ s, g }: { s: S; g: (typeof GEO)['wide'] }) { + const { w, h } = g.plot + const n = ROWS.length + const gap = Math.max(2, Math.round(w / n / 4)) + const bw = (w - gap * (n - 1)) / n + const MAX = 80000 + const ticks = [0, 20000, 40000, 60000, 80000] + return ( +
+
+ Q3 Sales by Region + + Total + {/* Reserve the final formatted width so the label stays still as digits arrive. */} + {fmt(SUM)} + + + +
+
+
+ {ticks.map((t) => ( + + {t === 0 ? '0' : `${t / 1000}k`} + + ))} +
+
+ {ticks.map((t) => ( + + ))} + v)} max={MAX} width={w} height={h} show className="xl-chart-svg" /> + {ROWS.map(([, v], i) => ( + + {SHARE[i]}% + + ))} +
+
+
+ {ROWS.map(([name], i) => ( + + {name} + + ))} +
+
+ ) +} + +function Caret() { + return ( + + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css b/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css new file mode 100644 index 0000000..4d81271 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css @@ -0,0 +1,80 @@ +.fc .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The mask lives on a wrapper with 40px of room, so the window keeps its + shadow on the top and sides. The last third fades to transparent. */ +.fc-mask { + position: absolute; + inset: 0; + padding: 40px 72px 0; + mask-image: linear-gradient(to bottom, #000 58%, transparent 96%); +} + +.fc-win { + width: 100%; + height: 420px; +} + +.fc-win .ss-window__body { + background: var(--ss-surface); +} + +.fc-body { + padding: 20px 24px; +} + +.fc-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} + +.fc-title { + color: var(--ss-text-strong); + font-size: 15px; + font-weight: 600; +} + +.fc-row { + padding: 10px 0; + border-top: 1px solid var(--ss-line); + /* A divided list, not a hover fill: square ends on the rule */ + border-radius: 0; +} + + +.fc-commit { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 500; +} + +.fc-branch, +.fc-age { + color: var(--ss-text); + font-size: 12px; +} + +/* Narrow: a gutter each side, the window keeps its size and runs off the + right; the commit and the status stay whole */ +.fc .ss-stage[data-layout='compact'] .fc-mask { + padding: 32px 0 0 16px; +} + +.fc .ss-stage[data-layout='compact'] .fc-win { + width: 420px; +} + +.fc .ss-stage[data-layout='compact'] .fc-age { + display: none; +} + +/* One lead column, so every row's text starts on the same line whatever the + status says */ +.fc-row .ss-row__lead { + width: 76px; + justify-content: flex-start; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx b/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx new file mode 100644 index 0000000..8dd39e0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx @@ -0,0 +1,58 @@ +import { Badge, Bar, Row, Stage, Text, Window } from '../../kit' +import './fade.css' + +// A fade crop: a deployments page that sinks into the page instead of running +// off an edge. A still. +// +// What makes the fade read as deliberate: +// - Only the foot fades. The top and both sides are whole and hard-edged, so +// the window is plainly a window, placed, not a smear. +// - It's a mask, not a color laid over the foot, so the window fades to +// transparent and dissolves into whatever it sits on. +// - Only filler is under the fade: the real rows sit in the top half, the +// placeholder rows carry on into the fade so the list reads as going on. +// - The mask is on a wrapper with room around the window, so the window +// keeps its shadow on the top and sides; a mask on the window itself would +// clip the shadow off. +// +// Real strings: the project, the newest deployment (status, branch, commit, +// age), and the one before it. + +const ROWS = [ + { status: 'Ready', branch: 'main', commit: 'Fix checkout redirect loop', age: '2m ago', tone: 'success' as const }, + { status: 'Ready', branch: 'pricing-copy', commit: 'New annual plan wording', age: '1h ago', tone: 'success' as const }, +] + +const FILL = [ + [132, 168], + [96, 140], + [148, 120], + [110, 176], +] + +export default function FadeCrop() { + return ( + + {() => ( +
+ +
+
+ Deployments + +
+
+ {ROWS.map((r) => ( + {r.status}} title={{r.commit}} subtitle={{r.branch}} meta={{r.age}} /> + ))} + {FILL.map(([a, b], i) => ( + } title={} subtitle={} meta={} /> + ))} +
+
+
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/figma/figma.css b/skills/imported/benjitaylor/stageshow/examples/figma/figma.css new file mode 100644 index 0000000..5b7f129 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/figma/figma.css @@ -0,0 +1,668 @@ +/* Figma: layers, canvas and the properties panel in one app window */ + +.fg .ss-stage { + overflow: hidden; + background: var(--ss-well); + font-family: var(--ss-font); +} + +.fg-window { + position: absolute; + left: 40px; + top: 40px; + width: 780px; + height: 540px; + box-shadow: var(--ss-shadow-hero); +} + +/* Narrow, the window keeps its size and runs off the left by 58px, so it + draws at true scale; the canvas takes the crop, the panel stays whole */ +.fg-window[data-layout="compact"] { + left: -58px; + top: 20px; + width: 400px; + height: 500px; +} + +.fg-icon { + flex-shrink: 0; + color: var(--ss-icon); +} + +/* ---- Toolbar ---- */ + +.fg-tools { + display: flex; + align-items: center; + gap: 2px; + margin-left: 14px; +} + +.fg-mark { + margin-right: 10px; +} + +.fg-tool { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 6px; + color: var(--ss-icon); +} + +.fg-tool[data-active] { + background: var(--ss-accent); + color: #fff; +} + +.fg-share-row { + display: flex; + align-items: center; + gap: 10px; + margin-right: 2px; +} + +.fg-share { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 11px; + border-radius: 6px; + background: var(--ss-accent); + color: #fff; + font-size: 11px; + font-weight: 550; +} + +/* ---- Body ---- */ + +.fg-body { + display: flex; + height: 100%; +} + +.fg-layers, +.fg-props { + flex-shrink: 0; + overflow: hidden; + background: var(--ss-surface); +} + +.fg-layers { + width: 184px; + border-right: 1px solid var(--ss-line); +} + +.fg-props { + width: 224px; + border-left: 1px solid var(--ss-line); +} + +.fg-window[data-layout="compact"] .fg-props { + width: 196px; +} + +/* ---- Layers ---- */ + +.fg-file { + display: flex; + flex-direction: column; + gap: 7px; + padding: 14px 14px 12px; +} + +.fg-file-name { + font-size: 12px; + font-weight: 600; + line-height: 1.3; + color: var(--ss-text-strong); +} + +.fg-tabs { + display: flex; + align-items: center; + gap: 14px; + height: 34px; + padding: 0 14px; + border-top: 1px solid var(--ss-line); + border-bottom: 1px solid var(--ss-line); +} + +.fg-tree { + display: flex; + flex-direction: column; + padding: 6px 6px; +} + +.fg-layer { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + padding-right: 8px; + border-radius: 5px; + transition: background 0.15s ease; +} + +.fg-layer[data-parent] { + background: color-mix(in srgb, var(--ss-accent) 6%, transparent); +} + +.fg-layer[data-selected] { + background: color-mix(in srgb, var(--ss-accent) 16%, transparent); +} + +.fg-layer-name { + font-size: 11px; + line-height: 1.3; + color: var(--ss-text-strong); +} + +.fg-layer[data-selected] .fg-icon { + color: var(--ss-accent-text); +} + +/* ---- Canvas ---- */ + +.fg-canvas { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; + background: #f5f5f5; +} + +.fg-frames { + position: absolute; + left: 24px; + top: 34px; +} + +.fg-window[data-layout="compact"] .fg-frames { + /* 20px in from the stage edge, past the 58px the window runs off */ + left: 78px; +} + +.fg-frame-wrap { + position: absolute; + display: flex; + flex-direction: column; + gap: 7px; +} + +.fg-frame-label { + font-size: 10px; + line-height: 1.3; + color: var(--ss-text); +} + +.fg-frame-label-bar { + margin: 3px 0 2px; +} + +.fg-frame { + position: relative; + background: #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05); +} + +.fg-desktop { + left: 0; + top: 0; +} + +.fg-desktop .fg-frame { + display: flex; + flex-direction: column; + width: 256px; + height: 196px; + padding: 14px 16px; +} + +.fg-nav { + display: flex; + align-items: center; + gap: 12px; +} + +.fg-nav-links { + display: flex; + gap: 10px; + margin: 0 auto; +} + +.fg-hero { + display: flex; + gap: 12px; + margin-top: 26px; +} + +.fg-hero-copy { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.fg-sub { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.fg-buttons { + display: flex; + gap: 6px; + margin-top: 12px; +} + +.fg-cta { + display: grid; + place-items: center; + width: 64px; + height: 22px; + border-radius: 6px; + transition: background-color 0.2s ease; +} + +.fg-cta[data-dragging], +.fg-swatch[data-dragging] { + transition: none; +} + +.fg-cta-label { + width: 34px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.85); +} + +.fg-hero-art { + position: relative; + width: 76px; + height: 84px; + overflow: hidden; + border-radius: 6px; + background: var(--ss-ink-1); +} + +.fg-art-sun { + position: absolute; + right: 14px; + top: 14px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--ss-ink-3); +} + +.fg-art-hill { + position: absolute; + left: -20px; + bottom: -40px; + width: 110px; + height: 80px; + border-radius: 50%; + background: var(--ss-ink-3); +} + +.fg-logos { + display: flex; + justify-content: space-between; + margin-top: auto; + padding: 0 6px; +} + +.fg-mobile { + left: 284px; + top: 0; +} + +.fg-mobile .fg-frame { + display: flex; + flex-direction: column; + width: 108px; + height: 196px; + padding: 12px; +} + +.fg-mobile .fg-nav { + justify-content: space-between; + margin-bottom: 22px; +} + +.fg-mobile-art { + height: 46px; + margin-top: 16px; + border-radius: 5px; + background: var(--ss-ink-1); +} + +.fg-row2 { + left: 0; + top: 240px; +} + +.fg-row2 .fg-frame { + width: 256px; + height: 200px; + padding: 20px 16px; +} + +.fg-cards { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.fg-card { + flex: 1; + height: 90px; + border-radius: 6px; + background: var(--ss-ink-1); +} + +.fg-blank { + position: absolute; + left: 300px; + top: 300px; + width: 50px; + height: 50px; +} + +/* Compact: the gap between the two rows of frames */ +.fg-window[data-layout="compact"] .fg-blank { + left: 98px; + top: 250px; + width: 120px; + height: 16px; +} + +/* ---- Canvas overlays ---- */ + +.fg-hover, +.fg-select { + position: absolute; + z-index: 5; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease; +} + +.fg-hover { + box-shadow: 0 0 0 1.5px var(--ss-accent); +} + +.fg-select { + box-shadow: 0 0 0 1px var(--ss-accent); +} + +.fg-hover[data-show], +.fg-select[data-show] { + opacity: 1; +} + +.fg-select i { + position: absolute; + width: 6px; + height: 6px; + margin: -3px; + background: #fff; + box-shadow: 0 0 0 1px var(--ss-accent); +} + +.fg-select i[data-c="tl"] { left: 0; top: 0; } +.fg-select i[data-c="tr"] { right: 0; top: 0; margin-right: -3px; } +.fg-select i[data-c="bl"] { left: 0; bottom: 0; margin-bottom: -3px; } +.fg-select i[data-c="br"] { right: 0; bottom: 0; margin-right: -3px; margin-bottom: -3px; } + +/* ---- Maya ---- */ + +.fg-maya { + position: absolute; + left: 0; + top: 0; + z-index: 8; + pointer-events: none; +} + +.fg-maya-arrow { + position: absolute; + left: -1.5px; + top: -1.5px; + overflow: visible; +} + +.fg-maya-arrow path { + fill: var(--ss-success); + stroke: #fff; + stroke-width: 1.4; + stroke-linejoin: round; +} + +.fg-maya-name { + position: absolute; + left: 12px; + top: 15px; + padding: 3px 7px; + border-radius: 4px 9px 9px 9px; + background: var(--ss-success); + color: #fff; + font-size: 10px; + font-weight: 550; + line-height: 1.3; + white-space: nowrap; +} + +/* ---- Properties ---- */ + +.fg-props-tabs { + display: flex; + align-items: center; + gap: 14px; + height: 40px; + padding: 0 14px; + border-bottom: 1px solid var(--ss-line); +} + +.fg-zoom { + margin-left: auto; +} + +.fg-props-body { + position: relative; +} + +.fg-props-layer { + position: absolute; + inset: 0 0 auto; + opacity: 0; + /* Rises by top, not transform, so its text lands without a hop */ + top: 6px; + visibility: hidden; + transition: opacity 0.2s ease, top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), visibility 0s 0.3s; +} + +.fg-props-layer[data-show] { + opacity: 1; + top: 0; + visibility: visible; + transition: opacity 0.2s ease, top 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.fg-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px 14px; + border-bottom: 1px solid var(--ss-line); +} + +.fg-section-head { + display: flex; + align-items: center; + justify-content: space-between; + height: 16px; +} + +.fg-section-title { + font-size: 11px; + font-weight: 600; + line-height: 1.3; + color: var(--ss-text-strong); +} + +.fg-row { + display: flex; + align-items: center; + gap: 10px; +} + +.fg-pair .fg-field { + flex: 1; +} + +.fg-field { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + padding: 0 8px; + border-radius: 5px; + background: #f5f5f5; + box-shadow: inset 0 0 0 1px transparent; + transition: box-shadow 0.15s ease; +} + +.fg-field-wide { + flex: 1; + min-width: 0; + padding-left: 6px; +} + +.fg-field[data-open] { + box-shadow: inset 0 0 0 1px var(--ss-accent); +} + +.fg-swatch { + flex-shrink: 0; + width: 14px; + height: 14px; + border-radius: 3px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + transition: background-color 0.2s ease; +} + +.fg-hex { + font-size: 11px; + line-height: 1.3; + color: var(--ss-text-strong); + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} + +/* ---- Color picker ---- */ + +.fg-picker { + padding: 0; + border-radius: 12px; + overflow: hidden; + /* The clip would cut a shadow drawn inside it, so it's drawn outside */ + --ss-popover-shadow: none; + filter: drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.3)) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.12)) drop-shadow(0 12px 24px rgba(0, 0, 0, 0.08)); +} + +.fg-picker-head { + display: flex; + align-items: center; + gap: 14px; + height: 38px; + padding: 0 12px; + border-bottom: 1px solid var(--ss-line); +} + +.fg-picker-x { + margin-left: auto; +} + +.fg-picker-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; +} + +.fg-sv { + position: relative; + height: 100px; + border-radius: 6px; + background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, var(--hue)); +} + +.fg-picker[data-layout="compact"] .fg-sv { + height: 76px; +} + +.fg-sv-knob { + position: absolute; + width: 12px; + height: 12px; + margin: -6px; + border-radius: 50%; + box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.fg-sliders { + display: flex; + align-items: center; + gap: 10px; +} + +.fg-tracks { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.fg-hue, +.fg-alpha { + position: relative; + height: 12px; + border-radius: 6px; +} + +.fg-hue { + background: linear-gradient(to right, #f00, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00); +} + +.fg-alpha { + background: + linear-gradient(to right, transparent, var(--c)), + repeating-conic-gradient(#e6e6e6 0 25%, #fff 0 50%) 0 0 / 8px 8px; +} + +.fg-hue-thumb { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + margin: -6px; + border-radius: 50%; + box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.fg-picker-hex { + display: flex; + gap: 8px; +} + +.fg-field-sm { + width: 52px; +} + +.fg-field-grow { + flex: 1; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/figma/index.tsx b/skills/imported/benjitaylor/stageshow/examples/figma/index.tsx new file mode 100644 index 0000000..53622a8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/figma/index.tsx @@ -0,0 +1,446 @@ +import { Eye, Frame, Image as ImageIcon, MessageCircle, Minus, MousePointer2, PenTool, Pipette, Plus, Square, Type, X } from 'lucide-react' +import { Bar, Facepile, Follow, Popover, Stage, Text, Window, box, type Act, type Point, BrandMark } from '../../kit' +import './figma.css' + +// Figma: select a button layer, drag its fill's hue in the color picker and +// watch the button change live, while Maya's cursor drifts around nearby. +// Loops by undoing the change (cmd+z) and deselecting. + +type S = { + hover: boolean + selected: boolean + picker: boolean + hue: number + dragging: boolean + /** Maya's cursor; null is her resting spot */ + maya: Point | null +} + +// The fill keeps its saturation and brightness; only the hue is dragged +const SAT = 0.66 +const VAL = 0.97 +const HUE_FROM = 252 +const HUE_TO = 336 + +function hex(h: number) { + const f = (n: number) => { + const k = (n + h / 60) % 6 + return VAL - VAL * SAT * Math.max(0, Math.min(k, 4 - k, 1)) + } + return [f(5), f(3), f(1)].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('').toUpperCase() +} + +// Maya's cursor waypoints, in canvas fractions resolved against the canvas rect +const MAYA = { + rest: { x: 0.8, y: 0.3 }, + a: { x: 0.86, y: 0.46 }, + b: { x: 0.74, y: 0.36 }, + c: { x: 0.6, y: 0.1 }, + d: { x: 0.47, y: 0.13 }, + e: { x: 0.62, y: 0.27 }, +} + +const initial: S = { hover: false, selected: false, picker: false, hue: HUE_FROM, dragging: false, maya: null } + +function mayaAt(c: { x: number; y: number; width: number; height: number }, p: Point): Point { + return { x: c.x + c.width * p.x, y: c.y + c.height * p.y } +} + +async function script(a: Act) { + const maya = (p: Point) => a.set({ maya: mayaAt(a.rect('canvas'), p) }) + await a.wait(500) + maya(MAYA.a) + + // 1. Select the button on the canvas + a.mark('select') + await a.move({ at: 'button', x: 0.82, y: 0.7 }) + await a.set({ hover: true }) + await a.wait(350) + await a.click() + await a.set({ hover: false, selected: true }) + maya(MAYA.b) + await a.wait(900) + + // 2. Open its fill + a.mark('fill') + await a.move('fill-swatch') + await a.wait(150) + await a.click() + await a.set({ picker: true }) + maya(MAYA.c) + await a.wait(700) + + // 3. Drag the hue; the button follows live + a.mark('drag') + await a.move('hue-thumb') + await a.wait(200) + const track = a.rect('hue-track') + const span = track.width - 12 + const end = { x: track.x + 6 + (span * HUE_TO) / 360, y: track.y + track.height / 2 } + await a.drag(end, (at) => { + const t = Math.max(0, Math.min(1, (at.x - track.x - 6) / span)) + a.put({ dragging: true, hue: Math.max(HUE_FROM, t * 360) }) + }) + await a.set({ dragging: false, hue: HUE_TO }) + maya(MAYA.d) + await a.wait(900) + + // 4. Click away: the picker closes, the change holds + a.mark('done') + await a.click('blank') + await a.set({ picker: false, selected: false }) + maya(MAYA.e) + await a.wait(2200) + + // 5. Undo, so the loop starts where it began + a.mark('undo') + await a.key('cmd+z') + await a.set({ hue: HUE_FROM }) + a.set({ maya: null }) + await a.wait(1600) +} + +const LAYERS: { depth: number; icon: 'frame' | 'type' | 'image' | 'rect'; w: number; name?: string }[] = [ + { depth: 0, icon: 'frame', w: 46 }, + { depth: 1, icon: 'rect', w: 28 }, + { depth: 1, icon: 'frame', w: 36 }, + { depth: 2, icon: 'type', w: 52 }, + { depth: 2, icon: 'type', w: 40 }, + { depth: 2, icon: 'frame', w: 0, name: 'Button' }, + { depth: 2, icon: 'image', w: 34 }, + { depth: 0, icon: 'frame', w: 40 }, + { depth: 0, icon: 'frame', w: 50 }, + { depth: 0, icon: 'frame', w: 38 }, +] + +function LayerIcon({ kind }: { kind: (typeof LAYERS)[number]['icon'] }) { + const p = { size: 13, strokeWidth: 1.6, className: 'fg-icon' } + if (kind === 'type') return + if (kind === 'image') return + if (kind === 'rect') return + return +} + +export default function FigmaDemo() { + return ( + + {(s, rect, layout) => { + const color = `#${hex(s.hue)}` + const fill = rect('fill-row') + const props = rect('props') + // Wide: the picker sits beside the panel, lifted so its sliders stay in + // view. Compact: there is no room beside it without covering the + // button, so it opens over the panel, under the fill row. + const picker = + layout === 'compact' + ? { left: props.x + 8, top: fill.y + fill.height + 6, width: props.width - 16 } + : { left: props.x - 8 - 220, top: Math.min(fill.y - 10, 480 - 24 - 244), width: 220 } + const btn = rect('button') + return ( + <> + + + + + + + + + + + + + + + + + + {layout === 'wide' && ( + + + + )} + + } + toolbar={ + + + Share + + } + > +
+ {layout === 'wide' && ( + + )} + +
+
+
+ Desktop +
+
+ + + + + + + +
+
+
+ + +
+ + +
+
+ + + + +
+
+
+ + +
+
+
+ {[30, 24, 34, 26].map((w, i) => ( + + ))} +
+
+
+ +
+ +
+
+ + +
+ + +
+ + +
+ +
+
+
+ +
+ +
+ +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+
+
+
+
+ + +
+ + + {/* Hover and selection on the canvas, from the measured button */} +
+
+ + + + +
+ + {/* Maya, on the canvas with us */} + + + + + Maya + + + {/* The color picker, beside the properties panel */} + +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + {hex(s.hue)} + + +
+
+
+ + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css b/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css new file mode 100644 index 0000000..eba792e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css @@ -0,0 +1,430 @@ +/* Finder in the Liquid Glass design (macOS Tahoe), column view, matched to a + real macOS 26 screenshot (2x, halved to points, then drawn at about 0.95). + Pinned with a bottom bleed on a blue Tahoe ground: the window sits one + gutter in from the top, left and right, so the whole toolbar shows, and + runs off the bottom. Everything is nested under .fgl so it can sit on a + page beside the finder (.fq) and figma (.fg) examples. + + The kit draws the macOS 26 chrome: sidebarStyle="floating" (the inset + panel, the window's 24 radius), lightsSize="large", barSize="tall" (the + 64px icon-and-label toolbar) and greys="cool" (the panel's cool gray). */ + +.fgl .ss-stage { + overflow: hidden; + border-radius: 14px; + /* One cool family throughout: the ground, the glass and the grays */ + --ss-text: #8a8b91; + --ss-text-strong: #1d1d1f; + --ss-text-faint: #a1a2a8; + --ss-icon: #3d3e44; + --ss-icon-faint: #b6b7bd; + --fgl-glass: rgba(255, 255, 255, 0.9); + --fgl-btn-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.07), inset 0 0.5px 0 rgba(255, 255, 255, 1), 0 1px 2px rgba(20, 30, 60, 0.05), 0 5px 16px rgba(20, 30, 60, 0.09); + /* A Tahoe wallpaper: deep blue, lighter toward the top left */ + background: + radial-gradient(110% 80% at 10% 0%, #cfe0f7 0%, rgba(207, 224, 247, 0) 62%), + radial-gradient(90% 110% at 100% 100%, #2f6fd0 0%, rgba(47, 111, 208, 0) 72%), + linear-gradient(165deg, #b4ccf0 0%, #7ea6e6 50%, #4f86dc 100%); +} + +/* ---- The window: a soft wide shadow, deeper than the kit's for the blue + ground ---- */ + +.fgl .fgl-window { + position: absolute; + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.16), + 0 24px 60px -10px rgba(10, 30, 80, 0.38), + 0 8px 20px -10px rgba(10, 30, 80, 0.24); +} + +/* The toolbar's buttons start close to the panel, as Finder sets them */ +.fgl .fgl-window .ss-window__main > .ss-window__bar { + padding-inline: 12px 14px; +} + +/* ---- Toolbar ---- */ + +.fgl .fgl-lead { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.fgl .fgl-tools { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.fgl .fgl-tool { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.fgl .fgl-tool__label { + font-size: 10.5px; + line-height: 13px; + color: var(--ss-text); + white-space: nowrap; +} + +.fgl .fgl-cap, +.fgl .fgl-round { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 32px; + border-radius: 16px; + background: var(--fgl-glass); + box-shadow: var(--fgl-btn-shadow); + color: var(--ss-icon); +} + +.fgl .fgl-cap { + gap: 4px; + padding: 0 11px 0 12px; +} + +.fgl .fgl-round { + width: 32px; +} + +.fgl .fgl-nav { + gap: 0; + padding: 0 4px; +} + +.fgl .fgl-nav > svg { + width: 30px; +} + +.fgl .fgl-nav__rule { + width: 1px; + height: 16px; + background: rgba(0, 0, 0, 0.1); +} + +.fgl .fgl-off { + color: var(--ss-icon-faint); +} + +.fgl .fgl-heading { + display: flex; + align-items: center; + height: 32px; + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ss-text-strong); + white-space: nowrap; +} + +/* ---- Sidebar ---- */ + +.fgl .fgl-side { + display: flex; + flex-direction: column; + padding: 4px 8px 0; +} + +.fgl .fgl-side__label { + display: block; + margin: 12px 10px 5px; + font-size: 10.5px; + font-weight: 600; + line-height: 13px; + color: var(--ss-text); +} + +.fgl .fgl-side__item { + display: flex; + align-items: center; + gap: 8px; + height: 27px; + padding: 0 10px 0 9px; + border-radius: 9px; + font-size: 12.5px; + color: var(--ss-text-strong); +} + +.fgl .fgl-side__icon { + display: grid; + place-items: center; + width: 18px; + color: var(--ss-icon); +} + +.fgl .fgl-side__cloud { + margin-left: auto; + color: var(--ss-text-faint); +} + +/* The current place: a soft gray capsule, as in an inactive or focused-away + sidebar */ +.fgl .fgl-side__item[data-current] { + background: rgba(0, 0, 0, 0.06); +} + +/* ---- Path bar: one rounded capsule with the folder centered, and a + ---- */ + +.fgl .fgl-body { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.fgl .fgl-path { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; + margin: 4px 14px 8px 12px; +} + +.fgl .fgl-path__tab { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + height: 26px; + border-radius: 13px; + background: rgba(0, 0, 0, 0.025); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.07); + font-size: 12px; + font-weight: 500; + color: var(--ss-text-strong); +} + +.fgl .fgl-path__add { + display: grid; + place-items: center; + flex-shrink: 0; + width: 26px; + height: 26px; + border-radius: 13px; + background: var(--fgl-glass); + box-shadow: var(--fgl-btn-shadow); + color: var(--ss-icon); +} + +/* ---- Columns, split by hairlines ---- */ + +.fgl .fgl-cols { + display: grid; + flex: 1; + grid-template-columns: repeat(2, minmax(0, 1fr)); + border-top: 1px solid rgba(0, 0, 0, 0.07); +} + +.fgl .fgl-cols:not([data-docs]) { + /* Compact: two narrow columns, so the second is in view and the rest of + the row stays blank, as Finder leaves it */ + grid-template-columns: 112px 112px; +} + +.fgl .fgl-cols[data-docs] { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.fgl .fgl-col { + display: flex; + flex-direction: column; + min-width: 0; + padding: 6px 0; + /* A layer it keeps, so the column's text never hops as it slides in */ + will-change: transform; + transition: opacity 0.16s ease, transform 0.16s ease; +} + +/* Every column closes with a hairline on its right, the last one too */ +.fgl .fgl-col { + border-right: 1px solid rgba(0, 0, 0, 0.07); +} + +/* The last column comes in with its folder's selection: a short slide from + the column before it, already moving on its first frame */ +.fgl .fgl-col:not([data-open]) { + opacity: 0; + transform: translateX(-10px); +} + +.fgl .fgl-col[data-open] { + transition: opacity 0.3s ease-out, transform 0.42s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.fgl .fgl-col:not([data-open]) .fgl-row { + visibility: hidden; + transition: visibility 0s 0.16s; +} + +.fgl .fgl-row { + display: flex; + align-items: center; + gap: 7px; + height: 21px; + margin: 0 7px; + padding: 0 7px 0 8px; + border-radius: 6px; + font-size: 12.5px; + color: var(--ss-text-strong); + transition: background-color 0.12s ease, color 0.12s ease; +} + +.fgl .fgl-row__name { + white-space: nowrap; +} + +.fgl .fgl-row__chev { + flex-shrink: 0; + margin-left: auto; + color: #a9aab0; + transition: color 0.12s ease; +} + +/* Focus moved on: the gray capsule */ +.fgl .fgl-row[data-sel="quiet"] { + background: rgba(0, 0, 0, 0.07); +} + +/* The column with focus: blue */ +.fgl .fgl-row[data-sel="focus"] { + background: var(--ss-accent); + color: #fff; +} + +.fgl .fgl-row[data-sel="focus"] .fgl-row__chev { + color: #fff; +} + +/* ---- File icons ---- */ + +.fgl .fgl-folder { + flex-shrink: 0; + color: #3f9ae8; + fill: #8cc7f5; +} + +.fgl .fgl-doc { + flex-shrink: 0; + color: #a7a8ae; + fill: #fff; +} + +.fgl .fgl-thumb { + display: block; + flex-shrink: 0; + width: 16px; + height: 12px; + border-radius: 2px; + overflow: hidden; + box-shadow: 0 0 0 1px #fff, 0 0 0 1.5px rgba(0, 0, 0, 0.14); +} + +.fgl .fgl-photo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* ---- Quick Look: zooms out of the thumbnail and back into it ---- */ + +.fgl .fgl-ql { + position: absolute; + z-index: 20; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 18px; + background: #f5f6f9; + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.16), 0 26px 60px -10px rgba(10, 30, 80, 0.4), 0 10px 22px -10px rgba(10, 30, 80, 0.24); + opacity: 0; + pointer-events: none; + will-change: transform; + --fgl-close: 0.34s cubic-bezier(0.4, 0, 0.2, 1); + --fgl-open: 0.46s cubic-bezier(0.3, 0.75, 0.1, 1); + /* Closing waits for the bar's words to fade before the box moves */ + transition: + left var(--fgl-close) 0.1s, top var(--fgl-close) 0.1s, width var(--fgl-close) 0.1s, height var(--fgl-close) 0.1s, border-radius var(--fgl-close) 0.1s, + opacity 0.2s ease-in 0.24s; +} + +.fgl .fgl-ql[data-open] { + opacity: 1; + transition: + left var(--fgl-open), top var(--fgl-open), width var(--fgl-open), height var(--fgl-open), border-radius var(--fgl-open), + opacity 0.24s ease-out; +} + +/* The bar shrinks with the window; its words fade out first and back in once + the zoom has landed: text moved by layout snaps to whole pixels, so it is + never seen while the box is still gliding */ +.fgl .fgl-ql__bar > * { + opacity: 0; + transition: opacity 0.12s ease; +} + +.fgl .fgl-ql[data-open] .fgl-ql__bar > * { + opacity: 1; + transition: opacity 0.18s ease 0.5s; +} + +.fgl .fgl-ql__bar { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + height: 38px; + overflow: hidden; + padding: 0 7px; + color: var(--ss-icon); + transition: height var(--fgl-close) 0.1s; +} + +.fgl .fgl-ql[data-open] .fgl-ql__bar { + transition: height var(--fgl-open); +} + +.fgl .fgl-ql__title + * { + margin-left: auto; +} + +.fgl .fgl-ql__round { + width: 26px; + height: 26px; +} + +/* Centered on the window, as macOS does, not in the space between two + buttons on the left and one on the right */ +.fgl .fgl-ql__title { + position: absolute; + left: 50%; + transform: translateX(-50%); + max-width: calc(100% - 140px); + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.fgl .fgl-ql__open { + height: 26px; + padding: 0 11px; + font-size: 11.5px; + color: var(--ss-text-strong); + white-space: nowrap; +} + +.fgl .fgl-ql__photo { + flex: 1; + min-height: 0; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx b/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx new file mode 100644 index 0000000..2426b93 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx @@ -0,0 +1,349 @@ +import { type ReactNode } from 'react' +import { + AppWindow, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + CircleArrowDown, + Clock, + Cloud, + Columns3, + Ellipsis, + File, + FileText, + Folder, + House, + LayoutGrid, + Maximize2, + Monitor, + Plus, + Search, + Share, + Tag, + Users, + X, +} from 'lucide-react' +import { Bar, Stage, Text, Window, type Act, type Layout, States } from '../../kit' +import './finder-glass.css' + +// Finder in the Liquid Glass design (macOS Tahoe, 26), in column view, drawn +// from a real macOS 26 screenshot: a floating rounded sidebar panel inset in +// the window that holds the larger traffic lights, a toolbar with no bar of +// its own (separate glass buttons, each with its label under it, the icon and +// text mode), the path bar as one rounded capsule with a round + beside it, +// and columns split by hairlines. +// +// The story: in films, a click on japan slides its column in and the title +// follows; a click picks a photo in it; Space opens Quick Look out of its +// thumbnail and Space again zooms it back; a click on films in the first +// column closes the japan column, which is exactly where the loop began. +// +// Icons: Finder has no web icon pack, so lucide is used deliberately for its +// SF Symbols-like outlines (one family, one stroke). + +type S = { lis: boolean; file: boolean; ql: boolean } + +const initial: S = { lis: false, file: false, ql: false } + +const FILE = 'Fuji.jpg' + +type Geo = { + win: { x: number; y: number; w: number; h: number } + side: number + /** The folders shown as columns, left to right: films, japan and before them, in wide, Documents */ + docs: boolean + ql: { x: number; y: number; w: number } +} + +const GEO: Record = { + wide: { win: { x: 40, y: 40, w: 780, h: 480 }, side: 196, docs: true, ql: { x: 328, y: 142, w: 270 } }, + compact: { win: { x: 20, y: 20, w: 460, h: 400 }, side: 132, docs: false, ql: { x: 20, y: 108, w: 232 } }, +} + +const QL_BAR = 38 +const qlHeight = (w: number) => QL_BAR + Math.round((w * 3) / 4) + +// Column rows: what each row is, and how long its placeholder name runs +type Kind = 'folder' | 'doc' | 'image' +type Row = { kind: Kind; name: number | string; target?: string } + +const DOCUMENTS: Row[] = [ + { kind: 'folder', name: 74 }, + { kind: 'folder', name: 96 }, + { kind: 'folder', name: 'films', target: 'films' }, + { kind: 'folder', name: 62 }, + { kind: 'doc', name: 104 }, + { kind: 'doc', name: 82 }, + { kind: 'doc', name: 92 }, +] + +const FILMS: Row[] = [ + { kind: 'folder', name: 58 }, + { kind: 'folder', name: 'japan', target: 'japan' }, + { kind: 'folder', name: 72 }, + { kind: 'doc', name: 96 }, + { kind: 'doc', name: 80 }, + { kind: 'doc', name: 88 }, +] + +const LISBON: Row[] = [ + { kind: 'image', name: 70 }, + { kind: 'image', name: 88 }, + { kind: 'image', name: FILE, target: 'file' }, + { kind: 'image', name: 64 }, + { kind: 'image', name: 80 }, +] + +async function script(a: Act) { + await a.wait(700) + + // 1. Open japan: its column slides in and the title follows + a.mark('folder') + await a.click({ at: 'japan', x: 0.62, y: 0.6 }) + await a.set({ lis: true }) + await a.wait(900) + + // 2. Pick the photo in it + a.mark('file') + await a.click({ at: 'file', x: 0.66, y: 0.6 }) + await a.set({ file: true }) + await a.wait(600) + + // 3. Space: Quick Look zooms out of the thumbnail + a.mark('open') + await a.key('space') + await a.set({ ql: true }) + await a.wait(2400) + + // 4. Space again: it zooms back into the row + a.mark('close') + await a.key('space') + await a.set({ ql: false }) + await a.wait(800) + + // 5. Back to films: the japan column closes, as at the start + a.mark('back') + // (compact has no first column: Back does it, aimed at the back half) + await a.click(a.layout === 'wide' ? { at: 'films', x: 0.66, y: 0.6 } : { at: 'back', x: 0.4, y: 0.56 }) + await a.set({ lis: false, file: false }) + await a.wait(1400) +} + +export default function FinderGlassDemo() { + return ( + + width={860} + height={440} + compact={{ below: 560, width: 400, height: 340 }} + initial={initial} + script={script} + accent="#0a6de6" + greys="cool" + cursor={{ at: { wide: { at: 'films', x: 0.66, y: 0.6 }, compact: { at: 'back', x: 0.4, y: 0.56 } } }} + className="fgl" + > + {(s, rect, layout) => { + const g = GEO[layout] + const wide = layout === 'wide' + const qh = qlHeight(g.ql.w) + // Quick Look closed sits on the thumbnail, shrunk to its width. It + // zooms by layout, not transform, so its text lands without a hop + const t = rect('thumb') + const k = t.width ? t.width / g.ql.w : 0.05 + const box = s.ql + ? { left: g.ql.x, top: g.ql.y, width: g.ql.w, height: qh, borderRadius: 18 } + : { left: t.x, top: t.y + t.height / 2 - (qh * k) / 2, width: g.ql.w * k, height: qh * k, borderRadius: 18 * k } + return ( + <> + } + lead={ + + + + + + + + + + + + + } + toolbar={ + wide ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : undefined + } + body={{ className: 'fgl-body', 'data-crop': true }} + > +
+ + + + + + +
+
+ {g.docs && ( + + )} + + +
+
+ +
+
+ + + + + + + {FILE} + {g.ql.w >= 300 && Open with Preview} + + + +
+ +
+ + ) + }} + + ) +} + +// Toolbar glyphs: one size and stroke in every glass button +const tb = { size: 16, strokeWidth: 1.6 } + +/** + * The folder's name in the title and the path bar: both names share one grid + * cell and crossfade, so the box is already as wide as the longer one and + * nothing beside it moves. + */ +function Names({ on, align = 'start' }: { on: boolean; align?: 'start' | 'center' }) { + return +} + +/** A toolbar item in icon-and-text mode: the glass button, its label under it. */ +function Tool({ label, children }: { label: string; children: ReactNode }) { + return ( + + {children} + {label} + + ) +} + +function Sidebar() { + const ic = { size: 16, strokeWidth: 1.6 } + const item = (icon: ReactNode, label: string, extra?: { current?: boolean; cloud?: boolean }) => ( +
+ {icon} + {label} + {extra?.cloud && } +
+ ) + return ( +
+ {item(, 'Recents')} + {item(, 'Shared')} + Favorites + {item(, 'Applications')} + {item(, 'Studio')} + {item(, 'Downloads')} + {item(, 'Desktop', { cloud: true })} + {item(, 'Documents', { current: true, cloud: true })} + Locations + {item(, 'iCloud Drive')} + {item(, 'mayachen')} +
+ ) +} + +type Sel = 'focus' | 'quiet' | undefined + +/** + * One column. `sel` maps a row's target to how it is selected: `focus` is the + * blue of the column with the keyboard, `quiet` the gray capsule a column + * keeps once focus has moved right. `open` brings the column in (it is always + * mounted, so it leaves as it came). + */ +function Column({ rows, sel, open = true }: { rows: Row[]; sel: Record; open?: boolean }) { + return ( +
+ {rows.map((r, i) => { + const state = r.target ? sel[r.target] : undefined + return ( +
+ + {typeof r.name === 'string' ? {r.name} : } + {r.kind === 'folder' && } +
+ ) + })} +
+ ) +} + +function FileIcon({ kind, thumb }: { kind: Kind; thumb?: boolean }) { + if (kind === 'folder') return + if (kind === 'doc') return + return ( + + + + ) +} + +// The same local photograph is shared by its thumbnail and Quick Look. +const PHOTO = new URL('../wikipedia/kawaguchiko.jpg', import.meta.url).href +const ALT_PHOTO = new URL('../wikipedia/fuji.jpg', import.meta.url).href +function Photo({ className, alt }: { className?: string; alt?: boolean }) { + return +} diff --git a/skills/imported/benjitaylor/stageshow/examples/finder/finder.css b/skills/imported/benjitaylor/stageshow/examples/finder/finder.css new file mode 100644 index 0000000..9350b80 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/finder/finder.css @@ -0,0 +1,376 @@ +/* Finder, list view. Corner bleed: the window sits one gutter in from the + top and left and runs off the right and bottom. */ + +.fq .ss-stage { + overflow: hidden; + border-radius: 14px; + background: var(--ss-well); +} + +.fq-window { + position: absolute; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-hero); +} + +/* ---- Toolbar ---- */ + +.fq-lead { + display: flex; + align-items: center; + gap: 10px; +} + +.fq-nav { + display: flex; + gap: 2px; + color: var(--ss-icon); +} + +.fq-nav-off { + color: var(--ss-icon-faint); +} + +.fq-heading { + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.fq-tools { + display: flex; + align-items: center; + gap: 16px; + color: var(--ss-icon); +} + +.fq-views { + display: flex; + gap: 2px; +} + +.fq-view { + display: grid; + place-items: center; + width: 26px; + height: 22px; + border-radius: 6px; +} + +.fq-view[data-on] { + background: var(--ss-ink-2); + color: var(--ss-text-strong); +} + +/* ---- Sidebar ---- */ + +.fq-side { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px 10px 0; +} + +.fq-side__label { + margin: 10px 8px 8px; +} + +.fq-side__label:first-child { + margin-top: 4px; +} + +.fq-side__item { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + padding: 0 8px; + border-radius: 6px; + font-size: 12.5px; + color: var(--ss-text-strong); + transition: background-color 0.12s ease, color 0.12s ease; +} + +.fq-side__icon { + display: grid; + place-items: center; + width: 16px; + color: var(--ss-icon); + transition: color 0.12s ease; +} + +.fq-side__item[data-current] { + background: var(--ss-ink-3); +} + +/* Lit while a drag is over it */ +.fq-side__item[data-over] { + background: var(--ss-accent); + color: #fff; +} + +.fq-side__item[data-over] .fq-side__icon { + color: #fff; +} + +/* ---- List ---- */ + +.fq-body { + overflow: hidden; +} + +.fq-list { + padding-top: 2px; + font-size: 12.5px; +} + +.fq-head, +.fq-row { + display: grid; + grid-template-columns: var(--cols); + align-items: center; + margin: 0 8px; +} + +.fq-head { + height: 28px; + font-size: 11px; + font-weight: 500; + color: var(--ss-text); +} + +.fq-head > span, +.fq-row > * { + padding-left: 10px; +} + +.fq-head > span + span { + border-left: 1px solid var(--ss-line); + line-height: 14px; +} + +.fq-row > .ss-bar { + margin-left: 10px; + padding: 0; +} + +.fq-rows { + position: relative; +} + +.fq-stripes { + position: absolute; + inset: 0 8px auto; + display: flex; + flex-direction: column; +} + +.fq-stripes > span { + height: 24px; + border-radius: 5px; +} + +.fq-stripes > span:nth-child(even) { + background: #f6f6f7; +} + +.fq-row { + position: relative; + height: 24px; + border-radius: 5px; + color: var(--ss-text-strong); +} + +.fq-row[data-sel] { + background: var(--ss-accent); + color: #fff; +} + +.fq-row[data-sel] .ss-bar { + background: rgba(255, 255, 255, 0.45) !important; +} + +.fq-name { + min-width: 0; +} + +.fq-name__drag { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; + vertical-align: middle; +} + +.fq-name__text { + white-space: nowrap; +} + +.fq-blank { + height: 64px; +} + +/* ---- File icons ---- */ + +.fq-folder { + flex-shrink: 0; + color: #4fa6ea; + fill: #8ccaf6; +} + +.fq-doc { + flex-shrink: 0; + color: #a9a9ae; + fill: #fff; +} + +.fq-thumb { + display: block; + flex-shrink: 0; + width: 16px; + height: 12px; + border-radius: 2px; + overflow: hidden; + box-shadow: 0 0 0 1px #fff, 0 0 0 1.5px rgba(0, 0, 0, 0.14); +} + +.fq-photo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +} + +/* ---- Quick Look: zooms out of the thumbnail and back into it ---- */ + +.fq-ql { + position: absolute; + z-index: 20; + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 12px; + background: #f4f4f5; + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.18), 0 24px 56px -12px rgba(30, 30, 60, 0.38), 0 10px 22px -10px rgba(0, 0, 0, 0.22); + opacity: 0; + pointer-events: none; + --fq-close: 0.34s cubic-bezier(0.4, 0, 0.2, 1); + --fq-open: 0.46s cubic-bezier(0.3, 0.75, 0.1, 1); + transition: + left var(--fq-close), top var(--fq-close), width var(--fq-close), height var(--fq-close), border-radius var(--fq-close), + opacity 0.2s ease-in 0.14s; +} + +.fq-ql[data-open] { + opacity: 1; + transition: + left var(--fq-open), top var(--fq-open), width var(--fq-open), height var(--fq-open), border-radius var(--fq-open), + opacity 0.24s ease-out; +} + +/* The bar shrinks with the window; its words fade out first and back in as + the zoom lands, so they're never seen squeezed */ +.fq-ql__bar > * { + opacity: 0; + transition: opacity 0.12s ease; +} + +.fq-ql[data-open] .fq-ql__bar > * { + opacity: 1; + transition: opacity 0.2s ease 0.26s; +} + +.fq-ql__bar { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 10px; + height: 38px; + overflow: hidden; + padding: 0 14px; + color: var(--ss-icon); + transition: height var(--fq-close); +} + +.fq-ql[data-open] .fq-ql__bar { + transition: height var(--fq-open); +} + +.fq-ql__btns { + display: flex; + align-items: center; + gap: 12px; +} + +.fq-ql__title { + flex: 1; + text-align: center; + font-size: 12.5px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.fq-ql__right { + display: flex; + align-items: center; + gap: 12px; +} + +.fq-ql__open { + padding: 3px 10px; + border-radius: 6px; + background: var(--ss-ink-2); + font-size: 11.5px; + line-height: 1.3; + color: var(--ss-text-strong); +} + +.fq-ql__photo { + flex: 1; + min-height: 0; +} + +/* ---- The drag image: the icon and its name on a selection pill ---- */ + +.fq-drag { + position: absolute; + top: 0; + left: 0; + z-index: 30; + pointer-events: none; + opacity: 0; +} + +.fq-drag[data-show] { + opacity: 0.82; +} + +.fq-drag__inner { + display: flex; + height: 24px; + align-items: center; + /* 1px gap + the pill's 6px padding puts the name exactly over the row's + name (7px after the thumbnail), so the pickup never doubles the text */ + gap: 1px; + font-size: 12.5px; +} + +.fq-drag[data-dropped] { + opacity: 0; + transition: opacity 0.22s ease-in; +} + +.fq-drag[data-dropped] .fq-drag__inner { + transform: scale(0.5); + transition: transform 0.24s cubic-bezier(0.4, 0, 0.2, 1); +} + +.fq-drag__name { + padding: 1px 6px; + border-radius: 4px; + background: var(--ss-accent); + color: #fff; + white-space: nowrap; + line-height: 1.35; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/finder/index.tsx b/skills/imported/benjitaylor/stageshow/examples/finder/index.tsx new file mode 100644 index 0000000..a1d29f7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/finder/index.tsx @@ -0,0 +1,329 @@ +import { useLayoutEffect, useRef, type ReactNode } from 'react' +import { + AppWindow, + ChevronLeft, + ChevronRight, + Clock, + Cloud, + Download, + File, + FileText, + Folder, + HardDrive, + LayoutGrid, + List, + Maximize2, + Monitor, + Search, + Share, + X, +} from 'lucide-react' +import { Bar, Collapse, Stage, Text, Window, type Act, type Layout, type Point } from '../../kit' +import './finder.css' + +// Finder, list view. The user selects a photo, presses Space and Quick Look +// zooms out of its thumbnail; Space again and it zooms back. Then they drag the +// file onto Wallpapers in the sidebar: the drag image rides with the arrow (as +// macOS draws it, no grabbing hand), Wallpapers lights up under it, the row +// leaves the list. Cmd+Z puts it back and a click in the empty list clears the +// selection, which is exactly where the loop began. +// +// Icons: Finder has no web icon pack, so lucide is used deliberately for its +// SF Symbols-like outline sidebar glyphs (one family, one stroke). + +type Ghost = { on: boolean; dropped: boolean; off: Point; at: Point } +type S = { sel: boolean; ql: boolean; moved: boolean; ghost: Ghost } + +const initial: S = { sel: false, ql: false, moved: false, ghost: { on: false, dropped: false, off: { x: 0, y: 0 }, at: { x: 0, y: 0 } } } + +const FILE = 'Fuji.jpg' +const DROP = 'Wallpapers' + +type Geo = { + win: { x: number; y: number; w: number; h: number } + side: number + rows: number + cols: string + ql: { x: number; y: number; w: number } +} + +const GEO: Record = { + wide: { win: { x: 40, y: 40, w: 880, h: 500 }, side: 180, rows: 10, cols: 'minmax(0, 1fr) 150px 76px 120px', ql: { x: 350, y: 66, w: 380 } }, + compact: { win: { x: 24, y: 24, w: 520, h: 420 }, side: 150, rows: 8, cols: '196px 140px 70px 110px', ql: { x: 68, y: 72, w: 264 } }, +} + +const QL_BAR = 38 +const qlHeight = (w: number) => QL_BAR + Math.round((w * 3) / 4) + +// The list: what each row is, and how long its placeholder name runs. +type Kind = 'folder' | 'doc' | 'image' | 'hero' +const ROWS: { kind: Kind; name: number; date: number; size: number; type: number }[] = [ + { kind: 'folder', name: 92, date: 88, size: 14, type: 40 }, + { kind: 'doc', name: 124, date: 80, size: 34, type: 72 }, + { kind: 'folder', name: 70, date: 92, size: 14, type: 40 }, + { kind: 'hero', name: 0, date: 84, size: 38, type: 64 }, + { kind: 'image', name: 108, date: 76, size: 36, type: 64 }, + { kind: 'doc', name: 86, date: 90, size: 30, type: 78 }, + { kind: 'folder', name: 116, date: 84, size: 14, type: 40 }, + { kind: 'doc', name: 98, date: 78, size: 40, type: 70 }, + { kind: 'image', name: 80, date: 88, size: 34, type: 64 }, + { kind: 'doc', name: 112, date: 82, size: 32, type: 74 }, +] + +async function script(a: Act) { + await a.wait(500) + + // 1. Select the photo: click the row, clear of its name + a.mark('select') + const row = a.rect('row-hero') + await a.click({ x: row.x + 176, y: row.y + row.height * 0.6 }) + await a.set({ sel: true }) + await a.wait(650) + + // 2. Space: Quick Look zooms out of the thumbnail + a.mark('open') + await a.key('space') + await a.set({ ql: true }) + await a.wait(2400) + + // 3. Space again: it zooms back into the row + a.mark('close') + await a.key('space') + await a.set({ ql: false }) + await a.wait(700) + + // 4. Drag the file by its icon onto Wallpapers in the sidebar + a.mark('drag') + const icon = a.rect('hero-drag') + const thumb = a.rect('thumb') + const grab = { x: thumb.x + thumb.width / 2, y: thumb.y + thumb.height / 2 + 1 } + await a.move(grab) + await a.wait(220) + const line = a.rect('row-hero') + const off = { x: grab.x - icon.x, y: grab.y - line.y } + // Finder keeps its arrow. Pickup and each carried position belong to the + // drag callback, so the image and cursor are painted together after press. + // Drop just past the folder's name, so the drag image never hides it. + const drop = a.rect('side-drop') + await a.drag({ x: drop.x + 114, y: drop.y + drop.height * 0.55 }, (at) => { + a.put({ ghost: { on: true, dropped: false, off, at } }) + }, { duration: 1150, cursor: 'preserve' }) + a.mark('drop') + await a.set((s) => ({ moved: true, ghost: { ...s.ghost, dropped: true } })) + await a.wait(1900) + + // 5. Cmd+Z: the move is undone and the file comes back, still selected + a.mark('undo') + await a.key('cmd+z') + await a.set({ moved: false, ghost: initial.ghost }) + await a.wait(1100) + + // 6. A click in the empty list clears the selection: back to the start + await a.click({ at: 'blank', x: 0.56, y: 0.5 }) + await a.set({ sel: false }) + await a.wait(900) +} + +export default function FinderDemo() { + return ( + + width={820} + height={420} + compact={{ below: 560, width: 400, height: 340 }} + initial={initial} + script={script} + accent="#0a6de6" + cursor={{ at: { at: 'blank', x: 0.56, y: 0.5 } }} + className="fq" + > + {(s, rect, layout) => { + const g = GEO[layout] + const qh = qlHeight(g.ql.w) + // Quick Look closed sits on the thumbnail, shrunk to its width. It + // zooms by layout, not transform, so its text lands without a hop + const t = rect('thumb') + const k = t.width ? t.width / g.ql.w : 0.05 + const box = s.ql + ? { left: g.ql.x, top: g.ql.y, width: g.ql.w, height: qh, borderRadius: 12 } + : { left: t.x, top: t.y + t.height / 2 - (qh * k) / 2, width: g.ql.w * k, height: qh * k, borderRadius: 12 * k } + return ( + <> + } + lead={ + + + + + + Downloads + + } + toolbar={ + + + + + + + + + + + + + } + body={{ className: 'fq-body', 'data-crop': true }} + > +
+
+ Name + Date Modified + Size + Kind +
+
+ {/* Stripes belong to positions, not to rows, as in AppKit: + a row that leaves slides the rest up over them */} +
+ {Array.from({ length: g.rows + 4 }, (_, i) => ( + + ))} +
+ {ROWS.slice(0, g.rows).map((r, i) => + r.kind === 'hero' ? ( + + + + ) : ( + + ), + )} +
+
+
+ + +
+
+ + + + + {FILE} + + {layout === 'wide' && Open with Preview} + + +
+ +
+ + + + + + {FILE} + + + ) + }} + + ) +} + +function Sidebar() { + const item = (icon: ReactNode, label: ReactNode, extra?: Record) => ( +
+ {icon} + {label} +
+ ) + const ic = { size: 15, strokeWidth: 1.6 } + return ( +
+ + {item(, )} + {item(, )} + {item(, )} + {item(, )} + {item(, Downloads, { 'data-current': true })} + {item(, {DROP}, { 'data-target': 'side-drop', 'data-drop': true })} + + {item(, )} + {item(, )} +
+ ) +} + +function Row({ r, hero, sel }: { r: (typeof ROWS)[number]; hero?: boolean; sel?: boolean }) { + return ( +
+ + + + {hero ? {FILE} : } + + + + {r.kind === 'folder' ? : } + +
+ ) +} + +function FileIcon({ kind }: { kind: Kind }) { + if (kind === 'folder') return + if (kind === 'doc') return + return ( + + + + ) +} + +// The same local photograph is shared by its thumbnail and Quick Look. +const PHOTO = new URL('../wikipedia/kawaguchiko.jpg', import.meta.url).href +const ALT_PHOTO = new URL('../wikipedia/fuji.jpg', import.meta.url).href +function Photo({ className, alt }: { className?: string; alt?: boolean }) { + return +} + +/** The drag image uses the same frame's callback point, with no second RAF. */ +function DragImage({ ghost, children }: { ghost: Ghost; children: ReactNode }) { + const ref = useRef(null) + const over = useRef(null) + const following = ghost.on && !ghost.dropped + const { x: ox, y: oy } = ghost.off + const { x, y } = ghost.at + useLayoutEffect(() => { + const root = ref.current?.closest('.ss-stage') + let next: HTMLElement | null = null + if (following && root) { + const box = root.getBoundingClientRect() + const scale = box.width / root.offsetWidth || 1 + const hit = document.elementFromPoint(box.left + x * scale, box.top + y * scale)?.closest('[data-drop]') ?? null + if (hit && root.contains(hit)) next = hit + } + if (next !== over.current) { + over.current?.removeAttribute('data-over') + next?.setAttribute('data-over', '') + over.current = next + } + }, [following, x, y]) + useLayoutEffect(() => () => over.current?.removeAttribute('data-over'), []) + return ( +
+
+ {children} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/form-error/form.css b/skills/imported/benjitaylor/stageshow/examples/form-error/form.css new file mode 100644 index 0000000..2d36349 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/form-error/form.css @@ -0,0 +1,127 @@ +.fe .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top and sides, off the bottom */ +.fe-win { + position: absolute; + top: 28px; + left: 60px; + width: 440px; + height: 420px; +} + +.fe-card { + display: grid; + align-content: start; + gap: 4px; + height: 100%; + padding: 28px 40px; + background: var(--ss-surface); +} + +.fe-title { + margin-bottom: 14px; + color: var(--ss-text-strong); + font-size: 18px; + font-weight: 600; +} + +.fe-group { + display: grid; + gap: 6px; +} + +/* One line, always there, so the error never moves the form */ +.fe-lane { + height: 18px; +} + +.fe-msg { + color: var(--ss-text); + font-size: 11px; +} + +.fe-msg--error { + color: var(--ss-danger-text, var(--ss-danger)); +} + +.fe-button { + display: grid; + place-items: center; + height: 38px; + margin-top: 6px; + border-radius: 8px; + background: var(--ss-accent); + color: #fff; + font-size: 13px; + font-weight: 600; + transition: background-color 0.3s ease; +} + +.fe-button[data-done] { + background: var(--ss-success); +} + +/* Layers in one cell, crossfading in place */ +.fe-button { + grid-template-areas: 'stack'; +} + +.fe-layer { + grid-area: stack; + display: inline-flex; + align-items: center; + gap: 8px; + opacity: 0; + filter: blur(2px); + transition: opacity 0.25s ease, filter 0.25s ease; + text-box: trim-both cap alphabetic; +} + +.fe-layer[data-on] { + opacity: 1; + filter: none; + transition-delay: 0.08s; +} + +.fe-labels { + display: grid; + justify-items: start; +} + +.fe-label { + grid-area: 1 / 1; + opacity: 0; + filter: blur(2px); + transition: opacity 0.25s ease, filter 0.25s ease; +} + +.fe-label[data-on] { + opacity: 1; + filter: none; + transition-delay: 0.08s; +} + +.fe-button .ss-check__ring { stroke: rgb(255 255 255 / 0.45); } +.fe-button .ss-check__arc { stroke: #fff; } +.fe-button .ss-check__fill { fill: #fff; } +.fe-button .ss-check__tick { stroke: var(--ss-success); } + +/* Narrow: a gutter each side */ +.fe .ss-stage[data-layout='compact'] .fe-win { + left: 16px; + width: 326px; +} + +.fe .ss-stage[data-layout='compact'] .fe-card { + padding: 24px 22px; +} + +/* The label trimmed to cap height, so the Check centers on the letters */ + +/* Air between one field's message lane and the next field's label */ +.fe-group + .fe-group { + margin-top: 8px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx b/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx new file mode 100644 index 0000000..48ad555 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx @@ -0,0 +1,97 @@ +import { Check, Field, Stage, States, Text, Window, type Act } from '../../kit' +import './form.css' + +// A sign-up form catching a mistake. Beats: an email is typed without its +// ending; the field turns to error and the line under it says why; the +// ending is typed and the field turns valid; Create account sends, and the +// button settles on done. Plays once; the still is the payoff. +// +// What makes validation read: +// - The message has a lane of its own under the field from the start: the +// hint and the error swap in it (States), so the form never grows or +// shifts when the error arrives. +// - The error is said by the ring and the line together, in the danger ink, +// and it leaves the moment the value is fixed: the ring turns calm with a +// check, the line goes back to the hint. +// - Nothing in the button moves sideways. It is full width, so every state +// fits where it is: the idle label and the busy group (check and label) +// are two layers in one place that crossfade, and inside the group the +// check holds still while "Creating account" crossfades to "Account +// created" beside it. Re-centering a group as its width eases slides the +// icon and the words across the button for no reason. +// +// Real strings: the title, the labels, the typed email, the hint and the +// error, the button. + +type S = { email: string; field: 'email' | 'none'; status: 'none' | 'error' | 'valid'; sending: boolean; done: boolean } + +const initial: S = { email: '', field: 'email', status: 'none', sending: false, done: false } +const poster: S = { email: 'maya@northwind.co', field: 'none', status: 'valid', sending: true, done: true } + +async function script(a: Act) { + await a.wait(700) + a.mark('type') + await a.type('email', 'maya@northwind', { interval: 55 }) + await a.wait(300) + a.mark('error') + a.put({ status: 'error' }) + await a.wait(1400) + a.mark('fix') + // Only the ending is typed: a.type would clear the field and start over + for (const end of ['maya@northwind.', 'maya@northwind.c', 'maya@northwind.co']) { + a.put({ email: end }) + await a.wait(110) + } + a.put({ status: 'valid' }) + await a.wait(700) + a.mark('send') + await a.click({ at: 'create', x: 0.5, y: 0.5 }) + a.put({ field: 'none', sending: true }) + await a.wait(1200) + a.put({ done: true }) + await a.move({ at: 'create', x: 0.75, y: 1.9 }) + await a.wait(2400) +} + +export default function FormError() { + return ( + + {(s) => ( + +
+ Create your account +
+ +
+ We'll send a link to confirm it, + error: Add the domain, like name@company.com, + }} + /> +
+
+
+ +
+
+ + Create account + + + {/* Left-aligned in a cell as wide as the longer label: the + shorter sits 2px off center, which no eye sees */} + + Creating account + Account created + + + +
+ + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css new file mode 100644 index 0000000..e112596 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css @@ -0,0 +1,131 @@ +/* All rules belong to this example, including its responsive variants. */ +.fd { + & .ss-stage { + overflow: hidden; + background: var(--ss-well); + } + + .fd-window { + position: absolute; + top: 24px; + left: 24px; + width: 560px; + height: 340px; + box-shadow: var(--ss-shadow-hero); + } + + .fd-window .ss-window__body { + background: var(--ss-surface); + } + + .fd-head { + display: grid; + grid-template-columns: repeat(5, 104px); + margin-left: 52px; + padding: 10px 0 8px; + border-bottom: 1px solid var(--ss-line); + font: 500 11px/1 var(--ss-font); + color: var(--ss-text); + } + + .fd-head span { + padding-left: 6px; + } + + .fd-body { + display: flex; + } + + .fd-hours { + display: grid; + grid-auto-rows: 40px; + width: 52px; + padding: 0 8px; + font: 500 10px/1 var(--ss-font); + color: var(--ss-text-faint); + text-align: right; + } + + .fd-hours span { + transform: translateY(-4px); + } + + .fd-grid { + position: relative; + width: 520px; + height: 240px; + background: + linear-gradient(var(--ss-line) 1px, transparent 1px) 0 0 / 100% 40px, + linear-gradient(90deg, var(--ss-line) 1px, transparent 1px) 0 0 / 104px 100%; + } + + .fd-busy { + position: absolute; + padding: 8px; + border-radius: 6px; + background: var(--ss-ink-1); + } + + /* The drop slot: where the card will land. Jumps between cells, only fades + in and out. */ + .fd-target { + position: absolute; + top: 0; + left: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + border: 1.5px dashed color-mix(in srgb, var(--ss-accent) 55%, transparent); + border-radius: 6px; + background: var(--ss-accent-soft); + } + + .fd-card { + position: absolute; + top: 0; + left: 0; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 7px 8px; + border-left: 3px solid var(--ss-accent); + border-radius: 6px; + background: color-mix(in srgb, var(--ss-accent) 12%, var(--ss-surface)); + font: 600 11px/1.3 var(--ss-font); + color: color-mix(in srgb, var(--ss-accent) 70%, #000); + /* Translate before scale, so lifting never scales its stage position. */ + will-change: translate; + transition: translate 0.32s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.2s ease, scale 0.2s ease; + } + + /* Carried: no position transition, so it stays exactly under the cursor */ + .fd-card[data-dragging] { + transition: box-shadow 0.2s ease, scale 0.2s ease; + } + + .fd-card[data-lifted] { + box-shadow: var(--ss-shadow-lift); + scale: 1.03; + } + + .fd-card__people { + display: flex; + } + + .fd-card__people .ss-avatar + .ss-avatar { + margin-left: -4px; + box-shadow: 0 0 0 1.5px var(--ss-surface); + } + + .fd-target[data-show] { + opacity: 1; + } + + /* The phone is a closer calendar viewport, not a smaller calendar. The two + days involved in the drag stay fully visible; neighboring days bleed. */ + & .ss-stage[data-layout='compact'] .fd-window { left: 16px; top: 16px; } + & .ss-stage[data-layout='compact'] .fd-head { transform: translateX(-104px); } + & .ss-stage[data-layout='compact'] .fd-grid { margin-left: -104px; } + & .ss-stage[data-layout='compact'] .fd-hours { position: relative; z-index: 1; flex: none; background: var(--ss-surface); } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx new file mode 100644 index 0000000..317e870 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx @@ -0,0 +1,135 @@ +import { Stage, Window, Bar, Avatar, cellOf, type Act, type Point, type Rect } from '../../kit' +import './drag.css' + +// Freeform drag with a drop target. The card follows the cursor exactly; the +// dashed slot shows where it will land. As in real calendars, the slot never +// travels: it appears in the landing cell, jumps straight to the next cell +// when that changes, and is hidden while the card is still over its own cell. +// Anything that slides after the card reads as a second object chasing it. +// cellOf makes cell borders sticky, so a drag along a border doesn't flicker. + +const COLS = 4 +const ROWS = 6 +const COL_W = 104 +const ROW_H = 40 +const START = { col: 1, row: 3 } +const END = { col: 2, row: 1 } + +type Cell = { col: number; row: number } +type S = { cell: Cell; lifted: boolean; at: Point | null; target: Cell | null } + +const cellRect = (grid: Rect, c: Cell): Rect => ({ + x: grid.x + c.col * COL_W + 3, + y: grid.y + c.row * ROW_H + 2, + width: COL_W - 6, + height: ROW_H * 2 - 4, +}) + +async function move(a: Act, from: Cell, to: Cell) { + const grid = a.rect('grid', { layout: true }) + const card = cellRect(grid, from) + const grab = { x: card.x + card.width * 0.35, y: card.y + 22 } + await a.move(grab) + const dest = cellRect(grid, to) + let prev: Cell = from + await a.drag( + { x: dest.x + (grab.x - card.x), y: dest.y + (grab.y - card.y) }, + (p) => { + const at = { x: p.x - (grab.x - card.x), y: p.y - (grab.y - card.y) } + // The card's center decides the cell, with sticky borders + const col = cellOf(at.x + card.width / 2 - grid.x, COL_W, prev.col) + const row = cellOf(at.y + ROW_H / 2 - grid.y, ROW_H, prev.row) + prev = { col: Math.max(0, Math.min(COLS - 1, col)), row: Math.max(0, Math.min(ROWS - 2, row)) } + // drag() has pressed and closed the hand before its first callback. + // Pick up and move in that same frame, never while the hand is open. + a.put({ lifted: true, at, target: prev }) + }, + { duration: 1100 }, + ) + await a.set({ lifted: false, at: null, target: null, cell: prev }) +} + +const script = async (a: Act) => { + await a.wait(500) + await move(a, START, END) + await a.wait(1200) + // Back again: a caused reset, and a second diagonal in the other direction + await move(a, END, START) + await a.move('rest') + await a.wait(600) +} + +// Friday runs off the window's right edge, so the grid never stops short +const DAYS = ['Mon 20', 'Tue 21', 'Wed 22', 'Thu 23', 'Fri 24'] +const HOURS = ['9 AM', '10 AM', '11 AM', '12 PM', '1 PM', '2 PM'] +const BUSY: [number, number, number][] = [ + [0, 0, 2], + [3, 1, 1], + [0, 4, 1], + [3, 3, 2], +] + +export default function FreeformDrag() { + return ( + + width={560} + height={340} + compact={{ below: 500, width: 358, height: 340 }} + initial={{ cell: START, lifted: false, at: null, target: null }} + poster={{ cell: END, lifted: false, at: null, target: null }} + script={script} + cursor={{ at: 'rest' }} + className="fd" + > + {(s, rect, layout) => { + const grid = rect('grid') + const card = s.at ? { ...cellRect(grid, s.cell), x: s.at.x, y: s.at.y } : cellRect(grid, s.cell) + return ( + <> + +
+ {DAYS.map((d) => ( + {d} + ))} +
+
+
+ {HOURS.map((h) => ( + {h} + ))} +
+
+ {BUSY.map(([c, r, span], i) => ( + + + + ))} +
+
+
+ {(() => { + const show = s.target && grid.width && (s.target.col !== s.cell.col || s.target.row !== s.cell.row) + const r = cellRect(grid, show ? s.target! : s.cell) + return
+ })()} +
+ Design review + + + + +
+ + + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/glass/glass.css b/skills/imported/benjitaylor/stageshow/examples/glass/glass.css new file mode 100644 index 0000000..16e68cc --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/glass/glass.css @@ -0,0 +1,93 @@ +.gl-window { + position: absolute; + top: 48px; + left: 64px; + width: 440px; + height: 300px; +} + +.gl-body { + display: grid; + gap: 18px; + padding: 20px 24px; +} + +.gl-head { + display: flex; + align-items: baseline; + gap: 10px; +} + +.gl-label { + font-size: 12px; + color: var(--ss-text); +} + +.gl-value { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ss-text-strong); +} + +.gl-head .ss-badge { + align-self: center; +} + +.gl-rows { + display: grid; + gap: 12px; +} + +.gl-row { + display: flex; + align-items: center; + gap: 10px; +} + +.gl-row > :last-child { + margin-left: auto; +} + +.gl-chip { + position: absolute; + top: 80px; + left: 456px; + display: flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border-radius: 17px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-float); + font-size: 12.5px; + font-weight: 500; + color: var(--ss-text-strong); +} + +.gl-chip .ss-icon { + color: var(--ss-accent); +} + +.gl-menu { + top: 230px; + left: 420px; + width: 168px; +} + +.gl .ss-stage[data-layout="compact"] .gl-window { + top: 32px; + left: 16px; + width: 318px; +} + +.gl .ss-stage[data-layout="compact"] .gl-chip { + top: 200px; + left: 190px; +} + +.gl .ss-stage[data-layout="compact"] .gl-menu { + top: 252px; + left: 166px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/glass/index.tsx b/skills/imported/benjitaylor/stageshow/examples/glass/index.tsx new file mode 100644 index 0000000..893891f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/glass/index.tsx @@ -0,0 +1,43 @@ +import { Badge, Bar, Chart, Icon, Popover, Menu, Stage, Window } from '../../kit' +import './glass.css' + +// The glass theme: frosted, translucent surfaces over a soft color field +// drawn from the accent. Window, popovers and anything with class ss-glass +// blur what's behind them; text and icons stay opaque. + +export default function Glass() { + return ( + + {(_, __, layout) => ( + <> + +
+
+ This month + $9,412 + +12.4% +
+ +
+ {[120, 96].map((w, i) => ( +
+ + + +
+ ))} +
+
+
+
+ + Payout sent +
+ + + + + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/gmail/gmail.css b/skills/imported/benjitaylor/stageshow/examples/gmail/gmail.css new file mode 100644 index 0000000..b4488d1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/gmail/gmail.css @@ -0,0 +1,491 @@ +/* Gmail's look, on one cool blue-gray family: the #f6f8fc canvas, a white + rounded list panel, the pale blue Compose button and Inbox pill. */ + +.gm .ss-stage, +.gm.ss-stage { + --gutter: 32px; + --gm-canvas: #f6f8fc; + --gm-ink: #1f1f1f; + --gm-icon: #444746; + --gm-read: #f2f6fc; + --ss-canvas: var(--gm-canvas); + --ss-icon: var(--gm-icon); + overflow: hidden; + border-radius: 16px; + background: #e3e9f2; + font-family: 'Google Sans', Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +/* ---- Window: pinned with a gutter on three sides, bleeding off the bottom ---- */ + +.gm-window { + position: absolute; + top: var(--gutter); + left: 40px; + width: 680px; + height: 480px; + box-shadow: var(--ss-shadow-hero); +} + +.gm-window .ss-window__bar { + background: #fff; +} + +.gm-window .ss-window__body { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + background: var(--gm-canvas); +} + +.gm-sym { + display: block; + flex-shrink: 0; + color: var(--gm-icon); +} + +/* ---- Header ---- */ + +.gm-head { + display: flex; + align-items: center; + height: 56px; + padding: 0 14px 0 8px; + flex-shrink: 0; +} + +.gm-head__brand { + display: flex; + align-items: center; + gap: 6px; + width: 168px; + flex-shrink: 0; +} + +.gm-round { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; +} + +.gm-mark { + display: block; + margin-left: 4px; +} + +.gm-wordmark { + font-size: 19px; + line-height: 1; + color: var(--gm-icon); + letter-spacing: -0.2px; +} + +.gm-search { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + max-width: 340px; + height: 36px; + padding: 0 8px 0 14px; + border-radius: 18px; + background: #e9eef6; +} + +.gm-search__hint { + flex: 1; + font-size: 13px; + color: var(--gm-icon); +} + +.gm-search__tune { + display: grid; + place-items: center; + width: 28px; + height: 28px; +} + +.gm-head__end { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.gm-avatar { + background: #7b5ea7; + color: #fff; +} + +/* ---- Main: nav and the list panel ---- */ + +.gm-main { + display: grid; + grid-template-columns: 176px minmax(0, 1fr); + flex: 1; + min-height: 0; + padding-right: 14px; +} + +.gm-nav { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 2px; +} + +.gm-compose { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 12px; + height: 46px; + margin: 0 0 12px 8px; + padding: 0 20px 0 16px; + border-radius: 14px; + background: #c2e7ff; +} + +.gm-compose .gm-sym { + color: #001d35; +} + +.gm-compose__label { + font-size: 13px; + font-weight: 500; + color: #001d35; +} + +.gm-item { + display: flex; + align-items: center; + gap: 16px; + height: 30px; + margin-right: 12px; + padding: 0 14px 0 22px; + border-radius: 0 15px 15px 0; +} + +.gm-item[data-active] { + background: #d3e3fd; +} + +.gm-item[data-active] .gm-sym { + color: #041e49; +} + +.gm-item__label { + flex: 1; + font-size: 13px; + font-weight: 600; + color: #041e49; +} + +.gm-item__count { + font-size: 12px; + font-weight: 600; + color: #041e49; + font-variant-numeric: tabular-nums; +} + +.gm-panel { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 16px 16px 0 0; + background: #fff; + overflow: hidden; +} + +.gm-tools { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 0 14px 0 20px; + flex-shrink: 0; +} + +.gm-tools__group { + display: flex; + align-items: center; + gap: 18px; +} + +.gm-tools__drop { + margin-left: -16px; +} + +.gm-tools__pager { + display: flex; + align-items: center; + gap: 14px; +} + +.gm-tools__pager .ss-bar { + margin-right: 4px; +} + +/* ---- Rows ---- */ + +.gm-list { + display: flex; + flex-direction: column; +} + +/* Room for the lifted row's shadow inside the fold's clip */ +.gm-fold .ss-collapse__inner { + overflow-clip-margin: 6px; +} + +.gm-row { + position: relative; + display: grid; + grid-template-columns: 52px 116px minmax(0, 1fr) 56px; + align-items: center; + height: 40px; + padding: 0 16px 0 20px; + background: var(--gm-read); + box-shadow: inset 0 -1px 0 #eceff3; + transition: box-shadow 0.12s ease; +} + +.gm-row[data-unread] { + background: #fff; +} + +/* Gmail's hover: the row lifts on a soft shadow, edge to edge */ +.gm-row[data-hover] { + z-index: 2; + box-shadow: inset 1px 0 0 #dadce0, inset -1px 0 0 #dadce0, 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); +} + +.gm-row__icons { + display: flex; + align-items: center; + gap: 10px; +} + +.gm-row__icons .gm-sym { + color: #c4c7c5; +} + +.gm-row__from { + display: flex; + align-items: center; + min-width: 0; + font-size: 13px; + color: var(--gm-ink); +} + +.gm-row[data-unread] .ss-text.gm-row__from { + font-weight: 600; +} + +.gm-row__text { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + font-size: 13px; +} + +.gm-row__text .ss-bar { + flex-shrink: 0; +} + +.gm-row__subject { + font-weight: 600; + color: var(--gm-ink); +} + +.gm-row__dash { + color: #5e5e5e; + margin-left: -2px; +} + +.gm-row__date { + display: flex; + justify-content: flex-end; + font-size: 12px; + font-weight: 600; + color: var(--gm-ink); + transition: opacity 0.12s ease; +} + +.gm-row[data-hover] .gm-row__date { + opacity: 0; +} + +/* Hover actions cover the date and the end of the snippet */ +.gm-actions { + position: absolute; + top: 0; + right: 10px; + bottom: 0; + display: flex; + align-items: center; + gap: 2px; + padding-left: 18px; + background: linear-gradient(to right, transparent, #fff 18px); + opacity: 0; + transition: opacity 0.12s ease; +} + +.gm-row:not([data-unread]) .gm-actions { + background: linear-gradient(to right, transparent, var(--gm-read) 18px); +} + +.gm-row[data-hover] .gm-actions { + opacity: 1; +} + +.gm-act { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + transition: background 0.12s ease; +} + +.gm-act[data-hover] { + background: #e8eaed; +} + +.gm-act[data-pressed] { + background: #dadce0; +} + +/* ---- Tooltip and toast ---- */ + +/* Gmail's tooltip: the kit's, in Gmail's gray and corner */ +.gm .ss-tooltip.gm-tip { + border-radius: 4px; + background: #3c4043; +} + +/* Gmail's snackbar sits at the bottom left of the viewport: here, one gutter + above the stage's bottom edge, where the browser runs out of shot */ +.gm .ss-toast.gm-toast { + position: absolute; + left: 56px; + bottom: var(--gutter); + z-index: 25; + gap: 18px; + padding: 0 8px 0 16px; + height: 44px; + border-radius: 8px; + background: #303030; + font-size: 13px; + --ss-toast-rise: 28px; + transition: opacity 0.5s cubic-bezier(0.25, 0.8, 0.25, 1), margin 0.9s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.gm .gm-toast .ss-toast__action { + margin-left: 0; + color: #a8c7fa; +} + +.gm-toast__actions { + display: flex; + align-items: center; + gap: 6px; +} + +.gm-toast__undo { + padding: 6px 8px; + border-radius: 4px; + font-weight: 500; +} + +.gm-toast__x { + display: grid; + place-items: center; + width: 28px; + height: 28px; +} + +.gm-toast__x .gm-sym { + color: #c7c7c7; +} + +/* ---- Compact: a collapsed nav rail and a tighter row ---- */ + +.gm .ss-stage[data-layout='compact'], +.gm.ss-stage[data-layout='compact'] { + --gutter: 20px; +} + +.gm-window[data-layout='compact'] { + left: 20px; + width: 400px; + height: 380px; +} + +.gm-window[data-layout='compact'] .gm-head__brand { + width: auto; + margin-right: 12px; +} + +.gm-window[data-layout='compact'] .gm-search { + display: none; +} + +.gm-window[data-layout='compact'] .gm-main { + grid-template-columns: 64px minmax(0, 1fr); + padding-right: 10px; +} + +.gm-window[data-layout='compact'] .gm-compose { + gap: 0; + width: 46px; + padding: 0; + justify-content: center; +} + +.gm-window[data-layout='compact'] .gm-compose__label, +.gm-window[data-layout='compact'] .gm-item .ss-bar, +.gm-window[data-layout='compact'] .gm-item__label, +.gm-window[data-layout='compact'] .gm-item__count { + display: none; +} + +.gm-window[data-layout='compact'] .gm-item { + justify-content: center; + width: 46px; + margin: 0 0 2px 8px; + padding: 0; + border-radius: 15px; +} + +.gm-window[data-layout='compact'] .gm-row { + grid-template-columns: 28px 82px minmax(0, 1fr) 52px; + padding: 0 10px 0 14px; +} + +.gm-window[data-layout='compact'] .gm-row__icons .gm-sym:last-child { + display: none; +} + +.gm-window[data-layout='compact'] .gm-actions { + right: 4px; +} + +.gm-window[data-layout='compact'] .gm-tools { + padding: 0 10px 0 14px; +} + +.gm .ss-stage[data-layout='compact'] .gm-toast, +.gm.ss-stage[data-layout='compact'] .gm-toast { + left: 32px; +} + +.gm-window[data-layout='compact'] .gm-row__snippet, +.gm-window[data-layout='compact'] .gm-row__dash { + display: none; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/gmail/index.tsx b/skills/imported/benjitaylor/stageshow/examples/gmail/index.tsx new file mode 100644 index 0000000..c749dae --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/gmail/index.tsx @@ -0,0 +1,285 @@ +import { Avatar, Bar, BrandMark, Collapse, Roll, Stage, Text, Toast, Tooltip, Window, type Act } from '../../kit' +import './gmail.css' + +// Gmail: the cursor hovers an email, the row's hover actions appear, it +// clicks Archive, the row folds away and "Conversation archived" slides up. +// The loop resets with a cause: Undo brings the row back. + +type S = { tip: boolean; archived: boolean; toast: boolean } + +const initial: S = { tip: false, archived: false, toast: false } + +// Material Symbols paths (Gmail's own icon family), 24px grid +const M = { + menu: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z', + search: + 'M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z', + tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z', + apps: 'M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z', + edit: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z', + inbox: + 'M19 3H4.99C3.88 3 3.01 3.89 3.01 5L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z', + star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z', + clock: + 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z', + send: 'M4.01 6.03l7.51 3.22-7.52-1 .01-2.22m7.5 8.72L4 17.97v-2.22l7.51-1M2.01 3L2 10l15 2-15 2 .01 7L23 12 2.01 3z', + draft: + 'M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z', + box: 'M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z', + drop: 'M7 10l5 5 5-5z', + refresh: + 'M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z', + more: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z', + left: 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z', + right: 'M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z', + archive: + 'M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM6.24 5h11.52l.81.97H5.44l.8-.97zM5 19V8h14v11H5zm8.45-9h-2.9v3H8l4 4 4-4h-2.55z', + trash: 'M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z', + unread: + 'M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z', + close: 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', +} as const + +function Sym({ d, size = 20 }: { d: keyof typeof M; size?: number }) { + return ( + + + + ) +} + +// Rows around the story row: sender, subject and snippet widths vary +const ROWS: { unread?: boolean; s: number; t: number; n: number; d: number }[] = [ + { unread: true, s: 62, t: 74, n: 120, d: 34 }, + { unread: true, s: 0, t: 0, n: 0, d: 0 }, // the story row + { unread: true, s: 54, t: 92, n: 86, d: 30 }, + { s: 70, t: 60, n: 132, d: 36 }, + { s: 48, t: 84, n: 104, d: 32 }, + { s: 66, t: 70, n: 118, d: 30 }, + { s: 58, t: 96, n: 92, d: 36 }, + { s: 72, t: 64, n: 110, d: 32 }, +] + +function Actions({ story }: { story?: boolean }) { + return ( + + + + + + + + + + + + + + + ) +} + +function Row({ i, r }: { i: number; r: (typeof ROWS)[number] }) { + const story = i === 1 + return ( +
+ + + + + {story ? ( + <> + Maya Chen + + Offsite plan, v2 + - + + + 10:42 AM + + ) : ( + <> + + + + + + + + + + + + )} + +
+ ) +} + +function Nav({ count }: { count: string }) { + const rest: [keyof typeof M, number][] = [ + ['star', 44], + ['clock', 50], + ['send', 30], + ['draft', 40], + ] + return ( + + ) +} + +async function script(a: Act) { + await a.wait(700) + + // 1. Hover the email: the row lifts and its actions replace the date + a.mark('hover') + await a.move({ at: 'row', x: 0.5, y: 0.55 }) + await a.wait(900) + + // 2. Over to Archive; its tooltip names it + await a.move({ at: 'archive', x: 0.6, y: 0.62 }) + await a.wait(350) + await a.set({ tip: true }) + await a.wait(700) + + // 3. Click: the row folds away, the count drops, the toast rises in + a.mark('archive') + const at = a.center('archive') + await a.click() + await a.set({ tip: false, archived: true }) + await a.wait(320) + await a.set({ toast: true }) + // The next email slides up under the still pointer and picks up the hover + await a.wait(280) + const next = a.rect('r2') + await a.move({ at: 'r2', x: (at.x + 1 - next.x) / next.width, y: 0.62 }, { duration: 160 }) + await a.wait(1800) + + // 4. Undo: the toast leaves and the email folds back in + a.mark('undo') + await a.click({ at: 'undo', x: 0.92, y: 0.86 }) + await a.set({ toast: false, archived: false }) + await a.wait(1400) + await a.move(REST) + await a.wait(400) +} + +const REST = { at: 'nav', x: 0.55, y: 0.6 } + +export default function GmailArchive() { + return ( + + {(s, rect, layout) => { + const tip = rect('archive') + return ( + <> + +
+ + + + + + Gmail + + + + Search mail + + + + + + + + + + +
+ +
+
+
+ + + Archive + + + + + Undo + + + + + + } + > + Conversation archived. + + + ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/google-calendar/gcal.css b/skills/imported/benjitaylor/stageshow/examples/google-calendar/gcal.css new file mode 100644 index 0000000..3d35a6f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/google-calendar/gcal.css @@ -0,0 +1,753 @@ +/* Google Calendar, week view (Material 3). One cool neutral family: the + app's #f8fafd ground, white grid panel, #dde3ea rules. The accent (Google + blue) is spent on today and on the new event. */ + +.gc-stage .ss-stage { + --gc-ground: #f8fafd; + --gc-line: #dde3ea; + --gc-text: #1f1f1f; + --gc-text-2: #444746; + --gc-text-3: #70757a; + --gc-blue: #0b57d0; + --gc-event: #039be5; + --gc-tint: #cbe8f8; + --gc-tint-bar: #93cdee; + --gc-past: #e4f2fb; + --gc-past-bar: #bfe0f3; + --ss-icon: #444746; + overflow: hidden; + background: #e9edf2; + color: var(--gc-text); + font-family: "Google Sans", "Roboto", system-ui, -apple-system, sans-serif; +} + +.gc-icon { + display: block; + flex-shrink: 0; + color: var(--ss-icon); +} + +/* ---- Window ---- */ + +.gc-window { + position: absolute; + left: 36px; + top: 36px; + width: 860px; + height: 560px; + background: var(--gc-ground); + box-shadow: var(--ss-shadow-hero); +} + +.gc-window[data-layout="compact"] { + left: 16px; + top: 16px; + width: 440px; + height: 420px; +} + +.gc-window .ss-window__url { + font-size: 11px; + color: var(--gc-text-3); +} + +.gc-app { + display: flex; + flex-direction: column; + height: 100%; + background: var(--gc-ground); +} + +/* ---- Header ---- */ + +.gc-header { + display: flex; + flex-shrink: 0; + align-items: center; + height: 56px; + padding: 0 12px 0 8px; +} + +.gc-window[data-layout="compact"] .gc-header { + height: 50px; +} + +.gc-brand { + display: flex; + align-items: center; + gap: 6px; + width: 196px; +} + +.gc-window[data-layout="compact"] .gc-brand { + width: auto; + margin-right: 12px; +} + +.gc-brand-name { + font-size: 18px; + color: var(--gc-text-2); + letter-spacing: -0.1px; +} + +.gc-iconbtn { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; +} + +.gc-iconbtn.sm { + width: 28px; + height: 28px; +} + +.gc-mark { + display: block; + flex-shrink: 0; + filter: drop-shadow(0 0.5px 0.5px rgba(0, 0, 0, 0.2)); +} + +.gc-today { + display: grid; + place-items: center; + height: 34px; + padding: 0 16px; + border: 1px solid #747775; + border-radius: 17px; + font-size: 13px; + font-weight: 500; + color: var(--gc-text); +} + +.gc-arrows { + display: flex; + gap: 2px; + margin: 0 10px 0 12px; +} + +.gc-month { + font-size: 21px; + color: var(--gc-text); + letter-spacing: -0.2px; +} + +.gc-window[data-layout="compact"] .gc-month { + font-size: 16px; +} + +.gc-tools { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.gc-view { + display: flex; + align-items: center; + gap: 4px; + height: 34px; + margin: 0 6px; + padding: 0 8px 0 16px; + border: 1px solid #747775; + border-radius: 17px; + font-size: 13px; + font-weight: 500; +} + +.gc-avatar { + width: 30px; + height: 30px; + margin-left: 4px; + border-radius: 50%; + background: #c7d7e8; +} + +/* ---- Body: sidebar + grid panel ---- */ + +.gc-main { + display: flex; + flex: 1; + min-height: 0; +} + +.gc-side { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 204px; + padding: 4px 12px 0 12px; +} + +.gc-create { + display: flex; + align-items: center; + gap: 10px; + align-self: flex-start; + height: 52px; + padding: 0 14px 0 14px; + border-radius: 16px; + background: #fff; + box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); + font-size: 14px; + font-weight: 500; + color: var(--gc-text); +} + +.gc-create .gc-icon { + color: var(--gc-text-2); +} + +.gc-create-plus { + color: var(--gc-text) !important; +} + +.gc-mini { + margin-top: 18px; + padding: 0 2px; +} + +.gc-mini-head { + display: flex; + align-items: center; + justify-content: space-between; + height: 28px; + padding-left: 6px; + font-size: 13px; + font-weight: 500; + color: var(--gc-text); +} + +.gc-mini-nav { + display: flex; + gap: 8px; +} + +.gc-mini-grid { + display: grid; + grid-template-columns: repeat(7, 24px); + justify-content: space-between; + row-gap: 1px; + margin-top: 4px; +} + +.gc-mini-dow, +.gc-mini-day { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + font-size: 10px; + color: var(--gc-text-2); +} + +.gc-mini-dow { + font-weight: 500; + color: var(--gc-text-3); +} + +.gc-mini-day[data-out] { + color: #a0a4a8; +} + +.gc-mini-day[data-today] { + background: var(--gc-blue); + color: #fff; + font-weight: 500; +} + +.gc-cals { + display: grid; + gap: 2px; + margin-top: 20px; + padding-left: 8px; +} + +.gc-cals-head { + display: flex; + align-items: center; + height: 30px; +} + +.gc-cal { + display: flex; + align-items: center; + gap: 14px; + height: 28px; +} + +.gc-check { + width: 14px; + height: 14px; + border-radius: 3px; +} + +/* The grid sits on a white panel with a rounded top-left, as in M3 */ +.gc-panel { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + margin-right: 12px; + border-radius: 16px 16px 0 0; + background: #fff; + overflow: hidden; +} + +.gc-window[data-layout="compact"] .gc-panel { + margin-left: 8px; +} + +.gc-dayhead, +.gc-body { + display: grid; + grid-template-columns: 52px repeat(var(--days), minmax(0, 1fr)); +} + +.gc-window[data-layout="compact"] .gc-dayhead, +.gc-window[data-layout="compact"] .gc-body { + grid-template-columns: 44px repeat(var(--days), minmax(0, 1fr)); +} + +.gc-dayhead { + flex-shrink: 0; + height: 70px; + padding-top: 10px; + border-bottom: 1px solid var(--gc-line); +} + +.gc-window[data-layout="compact"] .gc-dayhead { + height: 64px; + padding-top: 8px; +} + +.gc-tz { + align-self: end; + padding: 0 0 6px 6px; + font-size: 9px; + color: var(--gc-text-3); +} + +.gc-dayname { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.gc-dow { + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.6px; + color: var(--gc-text-2); +} + +.gc-date { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 50%; + font-size: 22px; + color: var(--gc-text-2); +} + +.gc-dayname[data-today] .gc-dow { + color: var(--gc-blue); +} + +.gc-dayname[data-today] .gc-date { + background: var(--gc-blue); + color: #fff; +} + +.gc-body { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.gc-gutter { + position: relative; +} + +.gc-hour { + position: absolute; + right: 8px; + font-size: 10px; + color: var(--gc-text-3); + translate: 0 -50%; + white-space: nowrap; +} + +.gc-col { + position: relative; + border-left: 1px solid var(--gc-line); + background-image: linear-gradient(var(--gc-line) 1px, transparent 1px); + background-size: 100% var(--hour); + background-position: 0 calc(var(--hour) - 1px); +} + +/* ---- Events ---- */ + +.gc-event { + position: absolute; + left: 2px; + right: 10px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 7px; + border-radius: 5px; + background: var(--gc-tint); +} + +.gc-event-bar { + background: var(--gc-tint-bar) !important; +} + +.gc-event-bar[data-sub] { + opacity: 0.75; +} + +.gc-event[data-past] { + background: var(--gc-past); +} + +.gc-event[data-past] .gc-event-bar { + background: var(--gc-past-bar) !important; +} + +.gc-now { + position: absolute; + left: -1px; + right: 0; + z-index: 2; + height: 2px; + margin-top: -1px; + background: #ea4335; +} + +.gc-now::before { + content: ""; + position: absolute; + left: -6px; + top: -5px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #ea4335; +} + +.gc-rest { + position: absolute; + left: 50%; + width: 1px; + height: 1px; +} + +/* The new event: full color, lifted while it's being made, settling flat + into the grid once saved. */ +.gc-draft { + position: absolute; + left: 2px; + right: 10px; + z-index: 3; + display: flex; + flex-direction: column; + gap: 1px; + padding: 4px 7px; + overflow: hidden; + border-radius: 5px; + background: var(--gc-event); + color: #fff; + box-shadow: 0 6px 14px -4px rgba(32, 33, 36, 0.35), 0 2px 5px rgba(32, 33, 36, 0.18); + opacity: 0; + transition: + opacity 0.2s ease, + box-shadow 0.7s cubic-bezier(0.45, 0, 0.1, 1); +} + +.gc-draft[data-show] { + opacity: 1; +} + +.gc-draft[data-saved] { + box-shadow: 0 0 0 0 rgba(32, 33, 36, 0), 0 0 0 rgba(32, 33, 36, 0); +} + +.gc-draft[data-short] { + justify-content: center; + padding: 3px 6px; +} + +.gc-draft[data-short] .gc-draft-title { + white-space: nowrap; + font-size: 11px; + line-height: 12px; +} + +.gc-draft-title { + font-size: 12px; + font-weight: 500; + line-height: 15px; + overflow-wrap: break-word; +} + +.gc-draft-time { + font-size: 11px; + line-height: 14px; + color: #e1f3fc; + transition: opacity 0.15s ease; +} + +/* Too short for the time: it fades out of the flow instead of popping */ +.gc-draft[data-short] .gc-draft-time { + position: absolute; + opacity: 0; +} + +/* ---- Quick-create popup ---- */ + +.gc-pop.ss-popover { + z-index: 30; + padding: 8px 12px 14px 8px; + border-radius: 24px; + --ss-popover-bg: #f0f4f9; + --ss-popover-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 14px 32px -8px rgba(32, 40, 70, 0.3), 0 4px 10px -4px rgba(0, 0, 0, 0.12); + transition: opacity 0.2s ease, transform 0.24s cubic-bezier(0.4, 0, 1, 1); +} + +.gc-pop.ss-popover::before { + transition: transform 0.25s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.gc-pop-top { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 26px; + padding-right: 0; +} + +.gc-pop-top > .gc-icon { + margin-right: auto; + margin-left: calc(50% - 4px); + translate: -50% 0; + color: #747775; +} + +.gc-pop-indent { + padding-left: 48px; +} + +.gc-pop[data-layout="compact"] .gc-pop-indent { + padding-left: 36px; +} + +/* The title field: an underline, not a box */ +.gc-title .ss-field__box { + height: 40px; + padding: 0 2px; + border-radius: 0; + background: transparent; + box-shadow: inset 0 -1px 0 #747775; + font-size: 21px; + color: var(--gc-text); +} + +.gc-title[data-focused] .ss-field__box { + box-shadow: inset 0 -2px 0 var(--gc-blue); +} + +.gc-title .ss-field__caret { + height: 22px; + background: var(--gc-blue); +} + +.gc-title .ss-field__placeholder { + color: #5e6266; +} + +.gc-pop[data-layout="compact"] .gc-title .ss-field__box { + height: 34px; + font-size: 17px; +} + +.gc-pop[data-layout="compact"] .gc-title .ss-field__caret { + height: 19px; +} + +.gc-kinds { + display: flex; + gap: 6px; + margin: 10px 0 6px; +} + +.gc-kind { + display: grid; + place-items: center; + height: 28px; + padding: 0 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + color: var(--gc-text-2); +} + +.gc-kind[data-on] { + background: #c2e7ff; + color: #001d35; +} + +.gc-pop-row { + display: flex; + align-items: center; + gap: 0; + min-height: 34px; +} + +.gc-pop-row > .gc-icon { + width: 48px; + padding: 0 14px; +} + +.gc-pop[data-layout="compact"] .gc-pop-row > .gc-icon { + width: 36px; + padding: 0 9px; +} + +.gc-pop-time { + display: grid; + gap: 7px; + padding: 4px 0; + font-size: 13px; + color: var(--gc-text); +} + +.gc-pop[data-layout="compact"] .gc-pop-time { + font-size: 12px; +} + +.gc-dot { + margin: 0 6px; + color: var(--gc-text-3); +} + +.gc-caldot { + width: 10px; + height: 10px; + margin-left: 8px; + border-radius: 50%; + background: var(--gc-event); +} + +.gc-pop-foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 6px; +} + +.gc-more { + padding: 0 12px; + font-size: 13px; + font-weight: 500; + color: var(--gc-blue); +} + +.gc-save { + display: grid; + place-items: center; + height: 36px; + padding: 0 24px; + border-radius: 18px; + background: var(--gc-blue); + font-size: 13px; + font-weight: 500; + color: #fff; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.gc-save[data-hover] { + background: #0a4fbd; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.gc-save[data-pressed] { + background: #0846a8; +} + +/* ---- Snackbar ---- */ + +/* The kit Toast in Calendar's snackbar look: it rises 16px, a touch slower */ +.ss-toast.gc-snack { + --ss-toast-rise: 16px; + position: absolute; + left: 52px; + bottom: 36px; + z-index: 30; + gap: 28px; + height: 46px; + padding: 0 8px 0 18px; + border-radius: 6px; + background: #303030; + box-shadow: 0 6px 16px -4px rgba(0, 0, 0, 0.35); + font-size: 13px; + color: #f2f2f2; + transition: + opacity 0.35s ease, + margin 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.ss-toast.gc-snack--compact { + left: auto; + right: 16px; + bottom: 16px; +} + +.gc-snack .ss-toast__action { + margin-left: 0; + color: #a8c7fa; +} + +.gc-undo { + display: grid; + place-items: center; + height: 32px; + padding: 0 12px; + border-radius: 4px; + font-weight: 500; + color: #a8c7fa; + transition: background 0.15s ease; +} + +.gc-undo[data-hover] { + background: rgba(168, 199, 250, 0.12); +} + +/* The Calendar / Tasks switch after the view picker */ +.gc-switch { + display: flex; + height: 34px; + margin-left: 4px; + border-radius: 17px; + background: #e1e5ea; +} + +.gc-switch > span { + display: grid; + place-items: center; + width: 44px; + border-radius: 17px; +} + +.gc-switch > span:first-child { + background: #c2e7ff; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/google-calendar/index.tsx b/skills/imported/benjitaylor/stageshow/examples/google-calendar/index.tsx new file mode 100644 index 0000000..c8b2695 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/google-calendar/index.tsx @@ -0,0 +1,443 @@ +import { Bar, Field, Popover, Stage, Window, type Act, type Layout, BrandMark, Toast } from '../../kit' +import './gcal.css' + +// Google Calendar, week view. Context: a quiet week with today marked. +// Action: drag across an empty Thursday afternoon, the quick-create popup +// opens, a title is typed and saved. Result: the event settles into the grid +// and the "Event saved" snackbar offers Undo, which is the loop's reset. + +type S = { + draft: { show: boolean; start: number; end: number } + saved: boolean + pop: { show: boolean; x: number; y: number } + focused: boolean + title: string + snack: boolean +} + +const initial: S = { + draft: { show: false, start: 780, end: 810 }, + saved: false, + pop: { show: false, x: -1000, y: 0 }, + focused: false, + title: '', + snack: false, +} + +// Reduced motion shows the payoff: the event saved into the grid +const poster: S = { ...initial, draft: { show: true, start: 780, end: 870 }, saved: true, title: 'Design review' } + +// Geometry per layout. Minutes are from midnight. +const GEO = { + wide: { stage: { w: 800, h: 460 }, gutter: 36, hour: 44, from: 10 * 60, rows: 10, today: 4, popW: 352 }, + compact: { stage: { w: 400, h: 380 }, gutter: 16, hour: 40, from: 10 * 60, rows: 9, today: 0, popW: 220 }, +} as const + +const WEEK = [ + { d: 'SUN', n: 18 }, + { d: 'MON', n: 19 }, + { d: 'TUE', n: 20 }, + { d: 'WED', n: 21 }, + { d: 'THU', n: 22 }, + { d: 'FRI', n: 23 }, + { d: 'SAT', n: 24 }, +] +// 4-day view starts on today, as Google Calendar's does +const FOUR = WEEK.slice(4).concat([{ d: 'SUN', n: 25 }]) + +// Existing events, by date: [date, start, end, bar widths] +const EVENTS: [number, number, number, number[]][] = [ + [19, 600, 660, [44, 28]], + [20, 690, 750, [52, 30]], + [20, 930, 990, [38, 26]], + [21, 630, 690, [48, 24]], + [22, 600, 645, [40]], + [22, 960, 1050, [46, 28]], + [23, 720, 780, [50, 30]], + [24, 660, 720, [36, 24]], + [25, 750, 810, [44, 26]], +] + +const NOW = 11 * 60 + 20 +const DAY = 22 + +const hourLabel = (m: number) => { + const h = Math.floor(m / 60) + return `${h % 12 || 12} ${h < 12 ? 'AM' : 'PM'}` +} +const clock = (m: number, suffix = true) => { + const h = Math.floor(m / 60) + const min = m % 60 + const t = `${h % 12 || 12}${min ? `:${String(min).padStart(2, '0')}` : ''}` + return suffix ? `${t}${h < 12 ? 'am' : 'pm'}` : t +} +const range = (a: number, b: number) => { + const samehalf = a < 720 === b < 720 + return `${samehalf ? clock(a, false) : clock(a)} – ${clock(b)}` +} +const clockFull = (m: number) => { + const h = Math.floor(m / 60) + return `${h % 12 || 12}:${String(m % 60).padStart(2, '0')}${h < 12 ? 'am' : 'pm'}` +} + + +async function script(a: Act) { + const g = GEO[a.layout] + const col = a.rect('today-col') + const y = (m: number) => col.y + ((m - g.from) / 60) * g.hour + const snap = (py: number) => g.from + Math.round((((py - col.y) / g.hour) * 60) / 15) * 15 + + await a.wait(700) + + // 1. Drag down Thursday afternoon, 1:00 to 2:30 + a.mark('drag') + const from = { x: col.x + col.width * 0.8, y: y(780) + 3 } + const to = { x: col.x + col.width * 0.8, y: y(870) - 2 } + // Google Calendar keeps the arrow through the press and drag-creation. + await a.move(from) + await a.wait(250) + await a.drag( + to, + (at) => { + const end = Math.max(810, Math.min(870, snap(at.y + 6))) + a.put({ draft: { show: true, start: 780, end } }) + }, + { duration: 900, cursor: 'preserve' }, + ) + await a.set({ draft: { show: true, start: 780, end: 870 } }) + await a.wait(200) + + // 2. The quick-create popup opens beside the new block + a.mark('popup') + const block = a.rect('draft') + const pop = a.rect('popup', { layout: true }) + const px = a.layout === 'wide' ? block.x - 32 - pop.width : block.x + block.width + 8 + const py = Math.max(g.gutter, Math.min(block.y - 64, g.stage.h - g.gutter - (a.layout === 'wide' ? 8 : 0) - pop.height)) + await a.snap({ pop: { show: false, x: px, y: py } }, 'popup') + await a.set({ pop: { show: true, x: px, y: py }, focused: true }) + await a.wait(500) + + // 3. Type a title + await a.type('title', 'Design review', { interval: 70 }) + await a.wait(500) + + // 4. Save: the popup closes and the block settles into the grid + a.mark('save') + await a.move({ at: 'save', x: 0.82, y: 0.62 }) + await a.wait(250) + await a.click() + await a.set((s) => ({ pop: { ...s.pop, show: false }, focused: false, saved: true })) + await a.wait(450) + await a.set({ snack: true }) + await a.wait(2400) + + // 5. Undo, the caused reset + a.mark('undo') + await a.move({ at: 'undo', x: 0.9, y: 0.88 }) + await a.wait(300) + await a.click() + await a.set((s) => ({ draft: { ...s.draft, show: false }, snack: false })) + await a.move('rest') + await a.wait(600) + await a.snap({ ...initial }) + await a.wait(300) +} + +/* ---- Material icons (the product's set), 24 grid ---- */ + +const MI = { + menu: 'M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z', + left: 'M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z', + right: 'M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z', + search: + 'M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z', + help: + 'M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z', + apps: 'M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z', + drop: 'M7 10l5 5 5-5z', + add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', + close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', + handle: 'M20 9H4v2h16V9zM4 15h16v-2H4v2z', + schedule: + 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z', + people: + 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z', + check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z', + event: + 'M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z', +} as const + +function MIcon({ name, size = 20, className }: { name: keyof typeof MI; size?: number; className?: string }) { + return ( + + + + ) +} + +function MiniMonth() { + const days: { n: number; out?: boolean }[] = [27, 28, 29, 30].map((n) => ({ n, out: true })) + for (let n = 1; n <= 31; n++) days.push({ n }) + return ( +
+
+ October 2026 + + + + +
+
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => ( + + {d} + + ))} + {days.map((d, i) => ( + + {d.n} + + ))} +
+
+ ) +} + +function Header({ layout }: { layout: Layout }) { + const wide = layout === 'wide' + return ( +
+
+ + + + + {wide && Calendar} +
+ Today + + + + + + + + + October 2026 + {wide && ( + + + + + + + + + Week + + + + + + + + + + + + + + + + )} +
+ ) +} + +function Sidebar() { + return ( + + ) +} + +function Grid({ s, layout }: { s: S; layout: Layout }) { + const g = GEO[layout] + const days = layout === 'wide' ? WEEK : FOUR + const top = (m: number) => ((m - g.from) / 60) * g.hour + const hours = Array.from({ length: g.rows }, (_, i) => g.from + i * 60) + const dur = s.draft.end - s.draft.start + const label = s.title || '(No title)' + return ( +
+
+ GMT-07 + {days.map((d) => ( + + {d.d} + {d.n} + + ))} +
+
+
+ {hours.map((m, i) => ( + + {i > 0 && hourLabel(m)} + + ))} +
+ {days.map((d) => ( +
+ {EVENTS.filter((e) => e[0] === d.n).map(([, a, b, bars], i) => ( +
+ {bars.map((w, j) => ( + 0 || undefined} /> + ))} +
+ ))} + {d.n === DAY + 1 && } + {d.n === DAY && ( + <> + +
+ {label} + {range(s.draft.start, s.draft.end)} +
+ + )} +
+ ))} +
+
+ ) +} + +function QuickCreate({ s, layout }: { s: S; layout: Layout }) { + const wide = layout === 'wide' + return ( + +
+ + + + +
+
+ +
+ + Event + + Task +
+
+
+ +
+ + {wide ? 'Thursday, October 22' : 'Thu, Oct 22'} + · + {wide ? `${clockFull(s.draft.start)} – ${clockFull(s.draft.end)}` : range(s.draft.start, s.draft.end)} + + +
+
+ {wide && ( +
+ + +
+ )} +
+ + + +
+
+ {wide && More options} + + Save + +
+
+ ) +} + +export default function GoogleCalendar() { + return ( + + {(s, _rect, layout) => ( + <> + +
+
+
+ {layout === 'wide' && } + +
+
+
+ + + Undo + + } + > + Event saved + + + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/google-docs/docs.css b/skills/imported/benjitaylor/stageshow/examples/google-docs/docs.css new file mode 100644 index 0000000..d6566a0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/google-docs/docs.css @@ -0,0 +1,564 @@ +/* Google Docs, in its Material 3 colors. One cool gray family throughout. */ + +.gd-stage .ss-stage { + overflow: hidden; + background: #e9edf2; + border-radius: 14px; + --gd-ink: #1f1f1f; + --gd-icon: #444746; + --gd-muted: #5e5e5e; + --gd-blue: #0b57d0; + --gd-surface: #f9fbfd; + --gd-pill: #edf2fa; + --gd-line: #e1e3e1; + --ss-font: "Google Sans", Roboto, system-ui, -apple-system, sans-serif; +} + +.gd-window { + position: absolute; + left: 40px; + top: 40px; + width: 860px; + height: 700px; + box-shadow: var(--ss-shadow-hero); + background: var(--gd-surface); +} + +.gd-window[data-layout="compact"] { + left: 20px; + top: 20px; + width: 420px; + height: 560px; +} + +.gd-window .ss-window__body { + display: flex; + flex-direction: column; +} + +.gd-sym { + display: block; + flex-shrink: 0; + color: var(--gd-icon); +} + +/* ---- Header ---- */ + +.gd-header { + display: flex; + align-items: center; + gap: 10px; + height: 64px; + padding: 0 16px 0 14px; + background: var(--gd-surface); +} + +.gd-mark { + flex-shrink: 0; + /* The glyph's page is narrower than its square box: trim the box's side + air so the gap to the title stays as it was */ + margin: 0 1px 0 -1px; +} + +.gd-titleblock { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.gd-titlerow { + display: flex; + align-items: center; + gap: 10px; +} + +.gd-title { + font-size: 17px; + line-height: 24px; + color: var(--gd-ink); +} + +.gd-title-icons { + display: flex; + gap: 10px; +} + +.gd-menus { + display: flex; + gap: 2px; + margin-left: -6px; + font-size: 13px; + line-height: 22px; + color: var(--gd-ink); +} + +.gd-menus span { + padding: 0 6px; +} + +.gd-header__right { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; +} + +.gd-share { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 18px; + border-radius: 18px; + background: #c2e7ff; + color: #001d35; + font-size: 14px; + font-weight: 500; +} + +.gd-share .gd-sym { + color: #001d35; +} + +[data-layout="compact"] .gd-share { + padding: 0 10px; +} + +.gd-av { + font-weight: 500; +} + +.gd-av--me { + background: #7b5ba6; + color: #fff; +} + +.gd-av--them { + background: #12866b; + color: #fff; +} + +.gd-presence { + box-shadow: 0 0 0 2px var(--gd-surface), 0 0 0 4px #12866b; +} + +/* ---- Toolbar ---- */ + +.gd-toolbar-wrap { + padding: 0 8px 8px; +} + +.gd-toolbar { + display: flex; + align-items: center; + gap: 14px; + height: 40px; + padding: 0 14px; + border-radius: 20px; + background: var(--gd-pill); +} + +.gd-sep { + width: 1px; + height: 20px; + background: #c7c7c7; +} + +.gd-drop { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.gd-toolbar__end { + margin-left: auto; +} + +/* ---- Canvas, page ---- */ + +.gd-canvas { + position: relative; + flex: 1; + background: var(--gd-surface); + border-top: 1px solid var(--gd-line); +} + +.gd-outline { + position: absolute; + left: 22px; + top: 22px; +} + +.gd-page { + position: absolute; + left: 72px; + top: 16px; + width: 500px; + bottom: 0; + padding: 40px 60px 0; + background: #fff; + box-shadow: 0 0 0 1px #dadce0; + font-family: Arial, Helvetica, sans-serif; + color: #000; +} + +[data-layout="compact"] .gd-page { + left: 12px; + top: 12px; + width: 396px; + padding: 28px 54px 0 24px; +} + +.gd-h1 { + margin: 0 0 18px; + font-size: 26px; + line-height: 32px; + font-weight: 400; +} + +[data-layout="compact"] .gd-h1 { + font-size: 19px; + line-height: 24px; +} + +.gd-subtitle { + display: block; + margin: 12px 0 22px; +} + +.gd-para { + margin: 0 0 26px; + font-size: 14px; + line-height: 23px; +} + +[data-layout="compact"] .gd-para { + font-size: 12.5px; + line-height: 20px; +} + +.gd-anchor { + display: inline-block; + width: 0; + height: 1em; + vertical-align: baseline; +} + +.gd-sel { + border-radius: 1px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + background: transparent; + transition: background-color 0.35s ease; +} + +.gd-sel[data-hl="select"] { + background: #c3d7f7; + transition: none; +} + +.gd-sel[data-hl="comment"] { + background: #fcdd84; +} + +.gd-heading { + display: block; + margin-bottom: 16px; +} + +.gd-lines { + display: flex; + flex-direction: column; + gap: 11px; + margin-bottom: 24px; +} + +/* ---- Margin buttons ---- */ + +.gd-margin { + position: absolute; + left: 528px; + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; + border-radius: 22px; + background: #fff; + box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 2px 6px 2px rgba(60, 64, 67, 0.15); + opacity: 0; + transform: scale(0.9); + transform-origin: 50% 50%; + transition: opacity 0.18s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1); + pointer-events: none; +} + +[data-layout="compact"] .gd-margin { + left: 360px; +} + +.gd-margin[data-show] { + opacity: 1; + transform: none; +} + +.gd-margin__btn { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + transition: background-color 0.15s ease; +} + +.gd-margin__btn[data-hover] { + background: #e9eef6; +} + +.gd-margin__btn[data-pressed] { + background: #dfe5ee; +} + +/* ---- Comment card ---- */ + +.gd-card { + position: absolute; + left: 596px; + width: 240px; + padding: 16px 16px 14px; + border-radius: 10px; + background: #fff; + box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); + opacity: 0; + /* Travels by margin pairs, not transform, so its text lands without a hop */ + margin-inline: -10px 10px; + transition: opacity 0.5s ease, margin 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); + font-size: 13px; + line-height: 19px; + color: var(--gd-ink); +} + +[data-layout="compact"] .gd-card { + left: 124px; + width: 256px; + padding: 14px 14px 12px; + font-size: 12.5px; + line-height: 18px; + margin: -8px 0 8px; +} + +.gd-card[data-show] { + opacity: 1; + margin: 0; +} + +.gd-card[data-state="resolved"] { + margin: 0 -14px 0 14px; + transition: opacity 0.4s ease, margin 0.6s cubic-bezier(0.45, 0, 0.1, 1); +} + +.gd-head { + display: flex; + align-items: center; + gap: 10px; +} + +.gd-who { + flex: 1; + min-width: 0; +} + +.gd-name { + font-weight: 500; + font-size: 13px; + line-height: 18px; +} + +.gd-time { + font-size: 12px; + line-height: 17px; + color: var(--gd-muted); +} + +[data-layout="compact"] .gd-name { + font-size: 12px; +} + +[data-layout="compact"] .gd-time { + font-size: 11px; +} + +.gd-acts { + display: flex; + gap: 2px; + margin-right: -6px; + opacity: 0; + transition: opacity 0.3s ease 0.15s; +} + +.gd-acts[data-on] { + opacity: 1; +} + +.gd-icon-btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + transition: background-color 0.15s ease; +} + +.gd-icon-btn[data-hover] { + background: #e9eef6; +} + +.gd-icon-btn[data-pressed] { + background: #dfe5ee; +} + + + +/* The text keeps its place from draft to posted: only the field's ring and + padding-free outline come and go around it. */ +.gd-body { + padding: 0; +} + +.gd-field { + position: relative; + min-height: 19px; + margin: 10px 0 2px; +} + +.gd-field[data-on] { + margin: 10px 0 10px; +} + +.gd-field__ring { + position: absolute; + inset: -7px -8px; + border-radius: 6px; + box-shadow: inset 0 0 0 2px var(--gd-blue); + opacity: 0; + transition: opacity 0.25s ease; +} + +.gd-field[data-on] .gd-field__ring { + opacity: 1; +} + +.gd-field__ph { + position: absolute; + inset: 0 0 auto 3px; + color: var(--gd-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: opacity 0.12s ease; +} + +.gd-field:not([data-empty]) .gd-field__ph, +.gd-field:not([data-on]) .gd-field__ph { + opacity: 0; +} + +.gd-field__text { + position: relative; +} + +.gd-caret { + display: inline-block; + width: 1.5px; + height: 15px; + margin-left: 1px; + vertical-align: -3px; + background: var(--gd-ink); + opacity: 0; +} + +.gd-caret[data-on] { + opacity: 1; +} + +.gd-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + padding-top: 6px; +} + +.gd-btn { + display: inline-flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 16px; + font-size: 13px; + font-weight: 500; + transition: background-color 0.2s ease, color 0.2s ease; +} + +[data-layout="compact"] .gd-btn { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + +.gd-btn--text { + color: var(--gd-blue); +} + +.gd-btn--fill { + background: #e3e3e3; + color: #8f8f8f; +} + +.gd-btn--fill[data-ready] { + background: var(--gd-blue); + color: #fff; +} + +.gd-btn--fill[data-ready][data-hover] { + background: #0a4fbd; +} + +.gd-btn--fill[data-pressed] { + background: #08439f; +} + +.gd-reply { + padding-top: 12px; + margin-top: 2px; + border-top: 1px solid var(--gd-line); +} + +.gd-reply__text { + padding-top: 8px; +} + +.gd-replybox { + margin-top: 12px; + padding: 0 12px; + height: 32px; + display: flex; + align-items: center; + border: 1px solid #c4c7c5; + border-radius: 17px; + color: var(--gd-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +[data-layout="compact"] .gd-replybox { + height: 30px; + font-size: 11px; +} + +.gd-rest { + position: absolute; + width: 1px; + height: 1px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/google-docs/index.tsx b/skills/imported/benjitaylor/stageshow/examples/google-docs/index.tsx new file mode 100644 index 0000000..42aa962 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/google-docs/index.tsx @@ -0,0 +1,358 @@ +import type { CSSProperties } from 'react' +import { BrandMark, Avatar, Bar, Collapse, Stage, Window, type Act, type Layout } from '../../kit' +import './docs.css' + +// Google Docs: comment on a sentence, get a reply. +// Context: a planning doc is open, a collaborator is in it too. +// 1. The writer drag-selects one sentence; the margin buttons appear beside it. +// 2. Add comment: the sentence turns yellow and a comment box opens in the margin. +// 3. They type the comment and post it; the box settles into a comment card. +// 4. Priya's reply lands in the thread. +// 5. They resolve the thread: the card and the highlight leave, the doc is as it began. +// +// Real strings: the doc title, the one paragraph, the comment, the reply, the +// two names, the menu bar and the comment buttons. The rest is placeholder. + +const LEAD = 'Beta opens to the waitlist in October. ' +const SENTENCE = 'The public launch is planned for November 12, alongside the new pricing page.' +const PARA = LEAD + SENTENCE +const START = LEAD.length +const END = PARA.length + +const COMMENT = 'Can we move this to the 19th? Pricing needs one more week.' +const REPLY = 'Works for me. I’ll update the timeline.' + +type Card = 'hidden' | 'compose' | 'posted' | 'resolved' +type S = { + sel: number + hl: 'none' | 'select' | 'comment' + margin: boolean + card: Card + draft: string + reply: boolean +} + +const initial: S = { sel: START, hl: 'none', margin: false, card: 'hidden', draft: '', reply: false } +const poster: S = { sel: END, hl: 'comment', margin: false, card: 'posted', draft: COMMENT, reply: true } + +const REST = { x: 560, y: 520 } + +async function script(a: Act) { + a.mark('start') + await a.wait(900) + + // 1. Select the sentence + const s0 = a.rect('sent-start') + const e0 = a.rect('sent-end') + await a.move({ x: s0.x + 1, y: s0.y + s0.height / 2 }) + await a.wait(250) + a.mark('select') + a.put({ hl: 'select' }) + await a.drag({ x: e0.x + 1, y: e0.y + e0.height / 2 }, (at) => a.put({ sel: Math.max(START, a.caret('para', at)) }), { duration: 1300 }) + a.put({ sel: END }) + await a.wait(150) + await a.set({ margin: true }) + await a.wait(450) + + // 2. Add a comment from the margin + await a.move('add-comment') + await a.wait(250) + a.mark('compose') + await a.click() + await a.set({ margin: false, hl: 'comment', card: 'compose' }) + await a.wait(350) + // Step out of the way of the box while typing + await a.move('rest-type') + await a.wait(150) + await a.type('draft', COMMENT, { interval: 42 }) + await a.wait(400) + + // 3. Post it + a.mark('post') + await a.click({ at: 'post', x: 0.9, y: 0.82 }) + await a.set({ card: 'posted' }) + await a.wait(500) + await a.move('rest-read') + await a.wait(1200) + + // 4. The collaborator replies + a.mark('reply') + await a.set({ reply: true }) + await a.wait(2600) + + // 5. Resolve the thread: a caused reset back to the opening frame + a.mark('resolve') + await a.click({ at: 'resolve', x: 0.5, y: 0.5 }) + await a.set({ card: 'resolved', hl: 'none' }) + await a.wait(900) + await a.snap({ ...initial }) + await a.move(REST) + await a.wait(600) +} + +export default function GoogleDocs() { + return ( + + {(s, rect, layout) => } + + ) +} + +function Docs({ s, rect, layout }: { s: S; rect: (n: string) => { x: number; y: number; width: number; height: number }; layout: Layout }) { + const compact = layout === 'compact' + const canvas = rect('canvas') + const anchor = rect('sent-start') + const end = rect('sent-end') + // Wide: the card sits in the margin beside the sentence. Compact: there is no + // margin, so it floats over the page just under the sentence, as Docs does when narrow. + const cardTop = compact ? end.y - canvas.y + end.height + 14 : anchor.y - canvas.y - 10 + const marginTop = end.y - canvas.y + end.height / 2 - 52 + const posted = s.card === 'posted' || s.card === 'resolved' + const open = s.card === 'compose' || s.card === 'posted' + return ( + +
+ +
+ {!compact && ( + + + + )} +
+
Q4 launch plan
+

+ {PARA.slice(0, START)} + {/* Keep a wrapped selection moving forward: its compact anchor starts a line. */} + {compact &&
} + + + {PARA.slice(START, s.sel)} + + {PARA.slice(s.sel)} + +

+ +
+ {(compact ? ['100%', '92%', '97%', '64%'] : ['100%', '96%', '99%', '58%']).map((w, i) => ( + + ))} +
+
+ {['98%', '100%', '71%'].map((w, i) => ( + + ))} +
+ +
+ {['100%', '94%', '97%', '62%'].map((w, i) => ( + + ))} +
+
+ + {/* The margin buttons beside a selection: comment, react, suggest */} +
+ + + + + + + + + +
+ +
+
+ +
+
Alex Rivera
+ +
10:42 AM Today
+
+
+
+ + + + + + +
+
+
+
+ + Comment or add others with @ + + {s.draft} + + +
+
+ +
+ Cancel + 0 || undefined} data-target="post" data-cursor="hand"> + Comment + +
+
+ +
+
+ +
+
Priya Shah
+
Just now
+
+
+
{REPLY}
+
+
+ +
Reply or add others with @
+
+
+ + {/* Where the cursor waits while the keys do the work */} + + +
+ + ) +} + +function Header({ compact }: { compact: boolean }) { + const menus = compact ? ['File', 'Edit', 'View', 'Insert'] : ['File', 'Edit', 'View', 'Insert', 'Format', 'Tools', 'Extensions', 'Help'] + return ( +
+ +
+
+ Q4 launch plan + {!compact && ( + + + + + + )} +
+
+ {menus.map((m) => ( + {m} + ))} +
+
+
+ {!compact && } + {!compact && } + + + + {!compact && 'Share'} + + {!compact && } +
+
+ ) +} + +function Toolbar({ compact }: { compact: boolean }) { + return ( +
+
+ {!compact && } + + + {!compact && } + {!compact && } + {!compact && } + + + {!compact && } + {!compact && } + + + + + + + + {!compact && } + {!compact && } + {!compact && } + {!compact && } + + + +
+
+ ) +} + +function Drop({ w }: { w: number }) { + return ( + + + + + ) +} + +function Sym({ n, size = 20, style }: { n: keyof typeof P; size?: number; style?: CSSProperties }) { + return ( + + + + ) +} + +// Material Symbols (Outlined, weight 400), Google's own icon set for Docs. +// Path data from @material-symbols/svg-400, Apache 2.0. viewBox 0 -960 960 960. +const P = { + 'add_comment': 'M450-400h60v-130h130v-60H510v-130h-60v130H320v60h130v130ZM80-80v-740q0-24 18-42t42-18h680q24 0 42 18t18 42v520q0 24-18 42t-42 18H240L80-80Zm134-220h606v-520H140v600l74-80Zm-74 0v-520 520Z', + 'add_photo_alternate': 'M480-480ZM180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h365v60H180v600h600v-365h60v365q0 24-18 42t-42 18H180Zm60-162h480L576-474 449-307l-94-124-115 149Zm453-323v-87h-88v-60h88v-88h60v88h87v60h-87v87h-60Z', + 'add_reaction': 'M480-480Zm.27 400q-82.74 0-155.5-31.5Q252-143 197.5-197.5t-86-127.27Q80-397.53 80-480.27 80-563 111.6-636q31.6-73 85.77-127 54.17-54 127.4-85.5T480-880q45.8 0 88.4 10 42.6 10 81.6 28v67q-38-22-80.5-33.5T480.22-820q-141.4 0-240.81 99T140-480.49q0 141.52 99.49 241 99.48 99.49 241 99.49Q622-140 721-239.34q99-99.35 99-240.66 0-34.78-6.5-67.39T794-610h64q11 31.42 16.5 63.37Q880-514.67 880-480q0 82-31.5 155.23-31.5 73.23-85.5 127.4T636-111.6Q563-80 480.27-80ZM810-680v-90h-90v-60h90v-90h60v90h90v60h-90v90h-60ZM626-533q22.5 0 38.25-15.75T680-587q0-22.5-15.75-38.25T626-641q-22.5 0-38.25 15.75T572-587q0 22.5 15.75 38.25T626-533Zm-292 0q22.5 0 38.25-15.75T388-587q0-22.5-15.75-38.25T334-641q-22.5 0-38.25 15.75T280-587q0 22.5 15.75 38.25T334-533Zm146 272q66 0 121.5-35.5T682-393H278q26 61 81 96.5T480-261Z', + 'chat': 'M240-399h313v-60H240v60Zm0-130h480v-60H240v60Zm0-130h480v-60H240v60ZM80-80v-740q0-24 18-42t42-18h680q24 0 42 18t18 42v520q0 24-18 42t-42 18H240L80-80Zm134-220h606v-520H140v600l74-80Zm-74 0v-520 520Z', + 'check': 'M378-246 154-470l43-43 181 181 384-384 43 43-427 427Z', + 'cloud_done': 'm412-279 230-230-40-40-189 189-100-100-41 41 140 140ZM251-160q-88 0-149.5-61.5T40-371q0-78 50-137t127-71q20-97 94-158.5T482-799q112 0 189 81.5T748-522v24q72-2 122 46.5T920-329q0 69-50 119t-119 50H251Zm0-60h500q45 0 77-32t32-77q0-45-32-77t-77-32h-63v-84q0-91-61-154t-149-63q-88 0-149.5 63T267-522h-19q-62 0-105 43.5T100-371q0 63 44 107t107 44Zm229-260Z', + 'drive_file_move': 'm526-410-75 75 42 42 147-147-147-147-42 42 75 75H320v60h206ZM140-160q-24 0-42-18.5T80-220v-520q0-23 18-41.5t42-18.5h281l60 60h339q23 0 41.5 18.5T880-680v460q0 23-18.5 41.5T820-160H140Zm0-60h680v-460H456l-60-60H140v520Zm0 0v-520 520Z', + 'edit': 'M180-180h44l472-471-44-44-472 471v44Zm-60 60v-128l575-574q8-8 19-12.5t23-4.5q11 0 22 4.5t20 12.5l44 44q9 9 13 20t4 22q0 11-4.5 22.5T823-694L248-120H120Zm659-617-41-41 41 41Zm-105 64-22-22 44 44-22-22Z', + 'format_align_left': 'M120-120v-60h720v60H120Zm0-165v-60h480v60H120Zm0-165v-60h720v60H120Zm0-165v-60h480v60H120Zm0-165v-60h720v60H120Z', + 'format_bold': 'M275-200v-560h228q66 0 114.5 42T666-612q0 38-21 70t-56 49v6q43 14 69.5 50t26.5 81q0 68-52.5 112T510-200H275Zm86-76h144q38 0 66-25t28-63q0-37-28-62t-66-25H361v175Zm0-247h136q35 0 60.5-23t25.5-58q0-35-25.5-58.5T497-686H361v163Z', + 'format_color_text': 'M80 0v-160h800V0H80Zm140-280 220-560h80l220 560h-75l-57-150H352l-57 150h-75Zm156-214h208L482-765h-4L376-494Z', + 'format_italic': 'M224-199v-80h134l139-409H338v-80h380v80H584L445-279h159v80H224Z', + 'format_list_bulleted': 'M377-198v-60h463v60H377Zm0-252v-60h463v60H377Zm0-253v-60h463v60H377ZM189-161q-28.05 0-48.02-19Q121-199 121-227.5t19.5-48q19.5-19.5 48-19.5t47.5 19.98q19 19.97 19 48.02 0 27.23-19.39 46.61Q216.23-161 189-161Zm0-252q-28.05 0-48.02-19.68Q121-452.36 121-480t19.98-47.32Q160.95-547 189-547q27.23 0 46.61 19.68Q255-507.64 255-480t-19.39 47.32Q216.23-413 189-413Zm-1-253q-27.64 0-47.32-19.68T121-733q0-27.64 19.68-47.32T188-800q27.64 0 47.32 19.68T255-733q0 27.64-19.68 47.32T188-666Z', + 'format_paint': 'M437-80q-24 0-42-17.63-18-17.62-18-42.37v-200H220q-24 0-42-18t-18-42v-303q0-55 39.66-96 39.65-41 95.34-41h505v440q0 24-18 42t-42 18H583v200q0 24.75-18 42.37Q547-80 523-80h-86ZM220-554h520v-226h-56v171h-60v-171h-71v85h-60v-85H295q-32 0-53.5 23T220-703v149Zm0 154h520v-94H220v94Zm0 0v-94 94Z', + 'format_underlined': 'M200-140v-60h560v60H200Zm280-140q-100 0-156.5-58.5T267-497v-343h83v343q0 63 34 101t96 38q62 0 96-38t34-101v-343h83v343q0 100-56.5 158.5T480-280Z', + 'history': 'M477-120q-149 0-253-105.5T120-481h60q0 125 86 213t211 88q127 0 215-89t88-216q0-124-89-209.5T477-780q-68 0-127.5 31T246-667h105v60H142v-208h60v106q52-61 123.5-96T477-840q75 0 141 28t115.5 76.5Q783-687 811.5-622T840-482q0 75-28.5 141t-78 115Q684-177 618-148.5T477-120Zm128-197L451-469v-214h60v189l137 134-43 43Z', + 'image': 'M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Z', + 'keyboard_arrow_down': 'M480-344 240-584l43-43 197 197 197-197 43 43-240 240Z', + 'link': 'M450-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h170v60H280q-58.33 0-99.17 40.76-40.83 40.77-40.83 99Q140-422 180.83-381q40.84 41 99.17 41h170v60ZM325-450v-60h310v60H325Zm185 170v-60h170q58.33 0 99.17-40.76 40.83-40.77 40.83-99Q820-538 779.17-579q-40.84-41-99.17-41H510v-60h170q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H510Z', + 'lock_f': 'M220-80q-24.75 0-42.37-17.63Q160-115.25 160-140v-434q0-24.75 17.63-42.38Q195.25-634 220-634h70v-96q0-78.85 55.61-134.42Q401.21-920 480.11-920q78.89 0 134.39 55.58Q670-808.85 670-730v96h70q24.75 0 42.38 17.62Q800-598.75 800-574v434q0 24.75-17.62 42.37Q764.75-80 740-80H220Zm260.17-200q31.83 0 54.33-22.03T557-355q0-30-22.67-54.5t-54.5-24.5q-31.83 0-54.33 24.5t-22.5 55q0 30.5 22.67 52.5t54.5 22ZM350-634h260v-96q0-54.17-37.88-92.08-37.88-37.92-92-37.92T388-822.08q-38 37.91-38 92.08v96Z', + 'lock': 'M220-80q-24.75 0-42.37-17.63Q160-115.25 160-140v-434q0-24.75 17.63-42.38Q195.25-634 220-634h70v-96q0-78.85 55.61-134.42Q401.21-920 480.11-920q78.89 0 134.39 55.58Q670-808.85 670-730v96h70q24.75 0 42.38 17.62Q800-598.75 800-574v434q0 24.75-17.62 42.37Q764.75-80 740-80H220Zm0-60h520v-434H220v434Zm260.17-140q31.83 0 54.33-22.03T557-355q0-30-22.67-54.5t-54.5-24.5q-31.83 0-54.33 24.5t-22.5 55q0 30.5 22.67 52.5t54.5 22ZM350-634h260v-96q0-54.17-37.88-92.08-37.88-37.92-92-37.92T388-822.08q-38 37.91-38 92.08v96ZM220-140v-434 434Z', + 'menu': 'M120-240v-60h720v60H120Zm0-210v-60h720v60H120Zm0-210v-60h720v60H120Z', + 'more_vert': 'M479.86-160Q460-160 446-174.14t-14-34Q432-228 446.14-242t34-14Q500-256 514-241.86t14 34Q528-188 513.86-174t-34 14Zm0-272Q460-432 446-446.14t-14-34Q432-500 446.14-514t34-14Q500-528 514-513.86t14 34Q528-460 513.86-446t-34 14Zm0-272Q460-704 446-718.14t-14-34Q432-772 446.14-786t34-14Q500-800 514-785.86t14 34Q528-732 513.86-718t-34 14Z', + 'print': 'M658-648v-132H302v132h-60v-192h476v192h-60Zm-518 60h680-680Zm599 95q12 0 21-9t9-21q0-12-9-21t-21-9q-12 0-21 9t-9 21q0 12 9 21t21 9Zm-81 313v-192H302v192h356Zm60 60H242v-176H80v-246q0-45.05 30.5-75.53Q141-648 186-648h588q45.05 0 75.53 30.47Q880-587.05 880-542v246H718v176Zm102-236v-186.21Q820-562 806.78-575q-13.23-13-32.78-13H186q-19.55 0-32.77 13.22Q140-561.55 140-542v186h102v-76h476v76h102Z', + 'rate_review': 'M240-400h123l199.08-200q8.96-9 13.44-20.5Q580-632 580-643t-4-22q-4-11-13-20l-36.8-37.19Q517-731 506-735.5t-23-4.5q-11 0-22 4t-20.04 13L240-523v123Zm280-243-37-37 37 37ZM300-460v-38l102-101 19 18 18 19-101 102h-38Zm121-121 18 19-37-37 19 18Zm14 181h285v-60H495l-60 60ZM80-80v-740q0-24 18-42t42-18h680q24 0 42 18t18 42v520q0 24-18 42t-42 18H240L80-80Zm134-220h606v-520H140v600l74-80Zm-74 0v-520 520Z', + 'redo': 'M392-200q-95 0-163.5-64T160-422q0-94 68.5-158T392-644h294L572-758l42-42 186 186-186 186-42-42 114-114H391q-70 0-120.5 46.5T220-422q0 69 50.5 115.5T391-260h310v60H392Z', + 'search': 'M796-121 533-384q-30 26-69.96 40.5Q423.08-329 378-329q-108.16 0-183.08-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.13-57.5Q572-504 572-585t-56.87-138.5Q458.25-781 377-781q-82.08 0-139.54 57.5Q180-666 180-585t57.46 138.5Q294.92-389 377-389Z', + 'spellcheck': 'M563-80 404-239l42-42 117 117 240-240 42 42L563-80ZM120-312l200-527h66l200 527h-67l-54-142H238l-54 142h-64Zm137-200h189l-92-254h-5l-92 254Z', + 'star_f': 'm233-120 65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Z', + 'star': 'm323-245 157-94 157 95-42-178 138-120-182-16-71-168-71 167-182 16 138 120-42 178Zm-90 125 65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-355Z', + 'undo': 'M259-200v-60h310q70 0 120.5-46.5T740-422q0-69-50.5-115.5T569-584H274l114 114-42 42-186-186 186-186 42 42-114 114h294q95 0 163.5 64T800-422q0 94-68.5 158T568-200H259Z', + 'videocam': 'M140-160q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h520q24 0 42 18t18 42v215l160-160v410L720-435v215q0 24-18 42t-42 18H140Zm0-60h520v-520H140v520Zm0 0v-520 520Z', +} as const diff --git a/skills/imported/benjitaylor/stageshow/examples/hero-grid/grid.css b/skills/imported/benjitaylor/stageshow/examples/hero-grid/grid.css new file mode 100644 index 0000000..8f3b5bb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/hero-grid/grid.css @@ -0,0 +1,112 @@ +.hero-grid { + position: relative; + width: 100%; + max-width: 680px; + min-width: 0; + margin: 0 auto; + aspect-ratio: 680 / 758; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --hg-paper: #f4f4f3; + --hg-night: #111113; + + & .hg-tile { position: absolute; margin: 0; overflow: hidden; border-radius: 12px; background: var(--hg-paper); } + & .ss-stage { overflow: hidden; } + & .hg-draft .ss-stage { background: transparent; } + & .hg-draft-sheet { + position: absolute; inset: 0; overflow: hidden; font-size: 17px; font-weight: 500; color: #232326; + background-image: linear-gradient(#0000000b 1px, transparent 1px), linear-gradient(90deg, #0000000b 1px, transparent 1px); + background-size: 16px 16px; + } + & .hg-draft-sheet > * { position: absolute; box-sizing: border-box; } + & .hg-draft-sheet[data-compact] { font-size: 14px; } + & :is(.hg-segments, .hg-stepper, .hg-search, .hg-task-list, .hg-frequency, .hg-wheel-button) { + background: #fff; box-shadow: 0 0 0 .5px #00000019, 0 2px 6px -2px #00000014; + } + /* The kit's Segmented in this sheet's look: a soft accent thumb */ + & .hg-segments { grid-template-rows: 1fr; padding: 4px; border-radius: 16px; } + & .hg-segments .ss-seg__thumb { top: 4px; bottom: 4px; left: 4px; width: calc((100% - 8px) / 3); border-radius: 11px; background: var(--ss-accent-soft); box-shadow: none; transition: transform 350ms cubic-bezier(.16,1,.3,1); } + & .hg-segments .ss-seg__option { height: auto; padding: 0; font: inherit; color: #77777a; transition: color 200ms; } + & .hg-segments .ss-seg__option[data-on] { color: var(--ss-accent); } + & .hg-switch { display: flex; scale: 1.6; transform-origin: 0 0; } + & [data-compact] .hg-switch { scale: 1.35; } + & .hg-stepper { display: flex; align-items: center; gap: 4px; padding: 0 8px 0 16px; border-radius: 16px; } + & .hg-secondary { color: #77777a; margin-right: 4px; } + & .hg-stepper-key { display: grid; place-items: center; flex: none; width: 28px; height: 32px; } + & .hg-gap { min-width: 26px; text-align: center; font-variant-numeric: tabular-nums; } + & .hg-search { display: flex; align-items: center; gap: 10px; padding: 0 14px; border-radius: 14px; color: #8b8b8e; } + & .hg-search svg { flex: none; } + & .hg-task-list { display: flex; flex-direction: column; gap: 4px; padding: 8px 16px; border-radius: 16px; } + & .hg-task { display: flex; flex: none; align-items: center; gap: 12px; height: 36px; } + & [data-compact] .hg-task { gap: 8px; height: 30px; } + & .hg-task .ss-check { flex: none; } + & .hg-publish { display: grid; place-items: center; grid-template-areas: 'stack'; border-radius: 14px; background: var(--ss-accent); color: #fff; box-shadow: 0 6px 14px -8px #00000040; } + & .hg-layer { grid-area: stack; display: inline-flex; align-items: center; gap: 6px; opacity: 0; filter: blur(2px); transition: opacity 250ms ease, filter 250ms ease; text-box: trim-both cap alphabetic; } + & .hg-layer[data-on] { opacity: 1; filter: none; transition-delay: 80ms; } + & .hg-publish .ss-check__ring { stroke: rgb(255 255 255 / .45); } + & .hg-publish .ss-check__arc { stroke: #fff; } + & .hg-publish .ss-check__fill { fill: #fff; } + & .hg-publish .ss-check__tick { stroke: var(--ss-accent); } + & .hg-design-frame { border: 1.5px dashed #00000038; transition: border-color 200ms; } + & .hg-design-frame[data-selected] { border: 1.5px solid var(--ss-accent); } + & .hg-frame-name { position: absolute; top: -22px; left: -1px; color: #79797d; font: 12px/16px ui-monospace, monospace; } + & .hg-frame-detail { opacity: 0; transition: opacity 240ms; } + & .hg-frame-detail[data-on] { opacity: 1; } + & .hg-frame-handle { position: absolute; width: 8px; height: 8px; margin: -4.75px; border: 1.5px solid var(--ss-accent); border-radius: 2px; background: #fff; } + & .hg-frame-handle[data-corner='0'] { top: 0; left: 0; } + & .hg-frame-handle[data-corner='1'] { top: 0; right: 0; } + & .hg-frame-handle[data-corner='2'] { bottom: 0; left: 0; } + & .hg-frame-handle[data-corner='3'] { bottom: 0; right: 0; } + & .hg-frame-size { position: absolute; bottom: -25px; left: 50%; translate: -50% 0; padding: 3px 7px; border-radius: 6px; background: var(--ss-accent); color: #fff; white-space: nowrap; font: 11px/15px ui-monospace, monospace; } + & .hg-slider { border-radius: 3px; background: #00000018; } + & .hg-slider > span { position: absolute; inset: 0 auto 0 0; width: 36%; border-radius: inherit; background: var(--ss-accent); } + & .hg-slider > i { position: absolute; top: -9px; left: 30%; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #00000026, 0 2px 6px #00000026; } + & .hg-frequency { display: flex; align-items: center; justify-content: space-between; padding: 0 14px 0 16px; border-radius: 14px; } + & .hg-wheel-button { display: grid; place-items: center; border-radius: 50%; } + & .hg-wheel-button > span { width: 24px; height: 24px; border-radius: 50%; background: conic-gradient(#f97316,#eab308,#22c55e,#06b6d4,#3b82f6,#a855f7,#ec4899,#f97316); mask: radial-gradient(circle, transparent 6.5px, #000 7px); } + & .hg-people { display: flex; } + & .hg-people > span { display: grid; place-items: center; flex: none; width: 54px; height: 54px; margin-right: -10px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 .5px #00000019, 0 0 0 4px var(--hg-paper); font-size: 18px; } + + /* Hard-edged bands make the glass legible; the pane softens them locally. */ + & .hg-revenue .ss-stage { background: radial-gradient(circle at 104% 108%, #ff5b2e 0 22%, #ff8a3d 22% 36%, #ffb547 36% 50%, #ffd9a0 50% 63%, #c8bdff 63% 77%, #8b7bff 77% 91%, #5b5bf6 91%); } + & .hg-revenue-window { position: absolute; } + & .hg-revenue-body { display: grid; gap: 14px; padding: 16px 18px; } + & .hg-revenue-head { display: flex; align-items: center; gap: 8px; } + & .hg-revenue-head strong { font-size: 22px; font-weight: 600; letter-spacing: -.015em; color: var(--ss-text-strong); } + & .hg-chart { display: block; overflow: visible; } + & .hg-chart path { fill: none; stroke: #5b5bf6; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } + & .hg-chart circle { fill: #5b5bf6; stroke: #fff; stroke-width: 2; vector-effect: non-scaling-stroke; } + + & .hg-keys .ss-stage { background: linear-gradient(180deg, #ff7438, #ff5a1f); } + & .hg-keyboard { position: absolute; left: 14px; top: 14px; display: grid; gap: var(--hg-key-gap); } + & .hg-key-row { display: flex; gap: var(--hg-key-gap); } + & .hg-key { position: relative; flex: none; width: var(--hg-key); height: var(--hg-key); border-radius: calc(var(--hg-key) * .16); background: linear-gradient(#fff,#f4f3f1); box-shadow: inset 0 1px 0 #ffffffe6, 0 0 0 1px #0000000a, 0 calc(var(--hg-key) * .06) 0 #00000029, 0 calc(var(--hg-key) * .16) calc(var(--hg-key) * .24) calc(var(--hg-key) * -.08) #0000004d; } + & .hg-key small { position: absolute; top: 3px; left: 5px; font-size: 10px; line-height: 12px; color: #827c77; } + & .hg-key > span { position: absolute; inset: 0; display: grid; place-items: center; padding-top: 4px; font-family: 'Hiragino Sans', 'Noto Sans JP', sans-serif; font-size: 17px; font-weight: 700; color: #2a2826; } + + & .hg-icons .ss-stage { background: var(--hg-paper); } + & .hg-icon-grid { position: absolute; left: 20px; top: 20px; display: grid; grid-template-columns: repeat(3,48px); gap: 14px; } + & .hg-app-icon { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 24%; box-shadow: inset 0 0 0 .5px #0000000f, inset 0 1px 0 #ffffff40, 0 6px 14px -8px #00000059; } + & .hg-calendar { display: grid; justify-items: center; gap: 1px; font-size: 23px; line-height: 24px; font-weight: 300; color: #1c1c1e; } + & .hg-calendar small { font-size: 10px; line-height: 11px; color: #e44242; font-weight: 600; } + + & .hg-player .ss-stage { background: var(--hg-night); } + & .hg-player-content { position: absolute; inset: 20px; display: flex; gap: 18px; color: #f5f5f7; } + & .hg-player-side { display: flex; flex: 1; flex-direction: column; justify-content: space-between; min-width: 0; } + & .hg-player-content[data-stack] { display: block; } + & .hg-player-content[data-stack] .hg-cover { position: absolute; top: 0; left: 0; } + & .hg-player-content[data-stack] .hg-player-side { height: 100%; } + & .hg-player-content[data-stack] .hg-player-names { min-height: var(--hg-cover); align-content: center; padding-left: calc(var(--hg-cover) + 14px); } + & .hg-cover { position: relative; display: block; flex: none; width: var(--hg-cover); height: var(--hg-cover); overflow: hidden; border-radius: 10px; background: linear-gradient(#ffcf9c,#ff9a62 38%,#f0645a 62%); box-shadow: inset 0 0 0 1px #ffffff14, 0 10px 24px -12px #000000b3; } + & .hg-cover-sun { position: absolute; left: 50%; top: 62%; width: 34%; aspect-ratio: 1; translate: -50% -50%; border-radius: 50%; background: radial-gradient(circle at 50% 40%,#fff6e0,#ffd9a0 70%); box-shadow: 0 0 0 6px #ffe6be2e, 0 0 24px 6px #ffdcaa73; } + & .hg-cover-sea { position: absolute; inset: 62% 0 0; background: repeating-linear-gradient(#ffd6aa8c 0 1.5px,transparent 1.5px 7px) 50% 4px / 30% 60% no-repeat,linear-gradient(#3d3aa8,#232067); } + & .hg-player-names { display: grid; gap: 2px; } + & .hg-player-names strong { font-size: 16px; font-weight: 600; line-height: 21px; letter-spacing: -.01em; } + & .hg-player-names > span { font-size: 14px; line-height: 18px; color: #a5a5ac; } + & .hg-player-progress { display: grid; gap: 7px; } + & .hg-player-rail { position: relative; height: 4px; border-radius: 2px; background: #ffffff2e; } + & .hg-player-rail > i { position: absolute; inset: 0 6% 0 0; border-radius: inherit; background: #f5f5f7; } + & .hg-player-times { display: flex; justify-content: space-between; font-size: 11px; line-height: 14px; font-variant-numeric: tabular-nums; color: #a5a5ac; } + & .hg-player-controls { display: flex; align-items: center; justify-content: space-between; padding: 0 6px; } + & .hg-player-controls svg { fill: currentColor; } + & .hg-player-play { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; color: var(--hg-night); background: #f5f5f7; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/hero-grid/index.tsx b/skills/imported/benjitaylor/stageshow/examples/hero-grid/index.tsx new file mode 100644 index 0000000..41d5196 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/hero-grid/index.tsx @@ -0,0 +1,211 @@ +import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react' +import { Badge, Check, Icon, Roll, Segmented, Stage, Toggle, Window, type Act } from '../../kit' +import './grid.css' + +// An image-led hero: one drafting surface, with four quieter pictures. +// Orange keycaps and violet glass balance the neutral sheet and dark player. +// Every tile is its own Stage. The grid never scales as one large picture. +// Only the drafting tile moves: once in view, then it holds the result. + +type Draft = { tab: number; sent: 'idle' | 'busy' | 'done'; selected: boolean; gap: number } +const INITIAL: Draft = { tab: 0, sent: 'idle', selected: false, gap: 24 } +const POSTER: Draft = { tab: 1, sent: 'done', selected: true, gap: 25 } +const at = (left: number, top: number, width?: number, height?: number): CSSProperties => ({ left, top, width, height }) + +const draft = async (a: Act) => { + await a.wait(650) + await a.click({ at: 'week', x: 0.82, y: 0.75 }) + await a.set({ tab: 1 }) + await a.wait(450) + await a.click({ at: 'publish', x: 0.85, y: 0.7 }) + await a.set({ sent: 'busy' }) + await a.wait(800) + await a.set({ sent: 'done' }) + await a.wait(650) + await a.click({ at: 'frame', x: 0.7, y: 0.7 }) + await a.set({ selected: true }) + await a.wait(650) + await a.click('plus') + await a.set({ gap: 25 }) + await a.wait(650) + await a.move({ x: -30, y: 180 }, { duration: 700 }) +} + +function Drafting({ w, h }: { w: number; h: number }) { + const compact = w < 520 + const width = compact ? w : 480 + const height = compact ? h : 270 + const frameWidth = compact ? w - 180 : 104 + return ( + width={width} height={height} fill initial={INITIAL} poster={POSTER} play="once" script={draft} + cursor={{ at: { x: -30, y: 180 }, style: 'large', idle: false }} className="hg-draft"> + {(s) => ( +
+ + +
+ Gap− + {String(s.gap)} + +
+
+ Search +
+
+ {['Design', 'Build', 'Ship'].map((label, i) => {label})} +
+ + {/* Each state is a centered layer in one cell, crossfading: + Publish, then the pending check alone, then the done check + with Live. Nothing resizes or slides across the button */} + Publish + + Live + +
+ Frame + + {[0, 1, 2, 3].map(i => )} + {frameWidth} × {compact ? 62 : 84} + +
+ {!compact && <> +
+
Weekly
+ + A + } +
+ )} + + ) +} + +function Revenue({ w, h }: { w: number; h: number }) { + const chartW = Math.min(300, w - 76) + const chartH = Math.max(48, h - 136) + return ( + + {() => ( + +
+
$12,480+18%
+ + + + +
+
+ )} +
+ ) +} + +const KEY_ROWS = [ + [['Q', 'た'], ['W', 'て'], ['E', 'い'], ['R', 'す'], ['T', 'か']], + [['A', 'ち'], ['S', 'と'], ['D', 'し'], ['F', 'は'], ['G', 'き']], + [['Z', 'つ'], ['X', 'さ'], ['C', 'そ'], ['V', 'ひ'], ['B', 'こ']], +] + +// Choose a substantial crop through a blank cap edge, never a narrow sliver. +function keySize(w: number, h: number) { + for (let size = 36; size <= 48; size++) { + const gap = Math.round(size * 0.17) + const part = (length: number) => (length - Math.floor((length + gap) / (size + gap)) * (size + gap)) / size + if (part(w - 14) >= 0.4 && part(w - 14) <= 0.75 && part(h - 14) >= 0.38 && part(h - 14) <= 0.75) return { size, gap } + } + return { size: 40, gap: 7 } +} + +function Keys({ w, h }: { w: number; h: number }) { + const { size, gap } = keySize(w, h) + return {() => ( +
+ {KEY_ROWS.map((row, y) =>
{row.map(([latin, kana], x) => { + const whole = 14 + x * (size + gap) + size <= w && 14 + y * (size + gap) + size <= h + return {whole && <>{latin}{kana}} + })}
)} +
+ )}
+} + +const MARKS = [ + ['#0a7cff', 'M12 4.5c4.7 0 8.5 3 8.5 6.8S16.7 18 12 18c-.9 0-1.8-.1-2.6-.3L5.2 19.6l1.2-3.4C4.6 14.9 3.5 13.2 3.5 11.3 3.5 7.5 7.3 4.5 12 4.5Z'], + ['#fff', 'calendar'], + ['#ff9500', 'M13.2 2.8 5.6 13.1a.8.8 0 0 0 .64 1.27H11l-1.2 6.8 7.6-10.3a.8.8 0 0 0-.64-1.27H12l1.2-6.8Z'], + ['#1c1c1e', 'M7.4 5.2v12.8a.9.9 0 0 0 1.37.77l10.2-6.4a.9.9 0 0 0 0-1.54L8.77 4.43A.9.9 0 0 0 7.4 5.2Z'], + ['#2fc455', 'M12 2.8a7 7 0 0 1 7 7c0 5-7 11.6-7 11.6S5 14.8 5 9.8a7 7 0 0 1 7-7Zm0 4.2a2.8 2.8 0 1 0 0 5.6 2.8 2.8 0 0 0 0-5.6Z'], + ['#7a5cff', 'M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 17.5Zm3.5 2.3v1.6h7V8.8Zm0 3.6v1.6h7v-1.6Zm0 3.6v1.6h4.4V16Z'], + ['#ff3b45', 'M12 20s-7.5-4.6-7.5-10.1A4.4 4.4 0 0 1 12 7.1a4.4 4.4 0 0 1 7.5 2.8C19.5 15.4 12 20 12 20Z'], + ['#2bb3f0', 'M4 8.2A2.2 2.2 0 0 1 6.2 6h1.6l1.3-1.6h5.8L16.2 6h1.6A2.2 2.2 0 0 1 20 8.2v8.6a2.2 2.2 0 0 1-2.2 2.2H6.2A2.2 2.2 0 0 1 4 16.8Zm8 1.3a3.4 3.4 0 1 0 0 6.8 3.4 3.4 0 0 0 0-6.8Z'], + ['#ffcc00', 'M6 5h12v2.4H6Zm0 4.8h12v2.4H6Zm0 4.8h8v2.4H6Z'], + ['#1c1c1e', 'M12 3.5a8.5 8.5 0 1 1 0 17 8.5 8.5 0 0 1 0-17Zm-.9 4v5.2l4.3 2.6.9-1.5-3.4-2V7.5Z'], + ['#1fb38a', 'M5 11.2 10 16l9-9.2-1.7-1.6L10 12.7 6.7 9.5Z'], + ['#ff6a3d', 'M7 4h10a1 1 0 0 1 1 1v15.2l-6-3.6-6 3.6V5a1 1 0 0 1 1-1Z'], +] + +function Icons({ w, h }: { w: number; h: number }) { + return {() => ( +
{MARKS.map(([background, glyph], i) => + {glyph === 'calendar' ? FRI24 : } + )}
+ )}
+} + +function Player({ w, h }: { w: number; h: number }) { + const side = w - 40 - (h - 40) - 18 < 136 + return {() => ( +
+ +
+ Low TideMarlo + 2:382:48 + +
+
+ )}
+} + +type Tile = { name: string; x: number; y: number; w: number; h: number; component: typeof Keys } +function composition(w: number): Tile[] { + const gap = 8 + const heroH = w >= 520 ? Math.round(w * 9 / 16) : 332 + if (w >= 520) { + const narrow = 172, wide = w - narrow - gap, row = 180 + return [ + { name: 'Drafting controls', x: 0, y: 0, w, h: heroH, component: Drafting }, + { name: 'Revenue on glass', x: 0, y: heroH + gap, w: wide, h: row, component: Revenue }, + { name: 'Keyboard detail', x: wide + gap, y: heroH + gap, w: narrow, h: 126, component: Keys }, + { name: 'Icon collection', x: wide + gap, y: heroH + 142, w: narrow, h: row * 2 - 126, component: Icons }, + { name: 'Music player', x: 0, y: heroH + row + gap * 2, w: wide, h: row, component: Player }, + ] + } + const narrow = Math.floor((w - gap) / 2) + return [ + { name: 'Drafting controls', x: 0, y: 0, w, h: heroH, component: Drafting }, + { name: 'Keyboard detail', x: 0, y: heroH + gap, w: narrow, h: 140, component: Keys }, + { name: 'Icon collection', x: narrow + gap, y: heroH + gap, w: w - narrow - gap, h: 140, component: Icons }, + { name: 'Revenue on glass', x: 0, y: heroH + 156, w, h: 230, component: Revenue }, + { name: 'Music player', x: 0, y: heroH + 394, w, h: 196, component: Player }, + ] +} + +export default function HeroGrid() { + const ref = useRef(null) + const [width, setWidth] = useState(0) + useLayoutEffect(() => { + const element = ref.current + if (!element) return + const measure = () => setWidth(Math.floor(element.getBoundingClientRect().width)) + measure() + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => observer.disconnect() + }, []) + const tiles = width ? composition(width) : [] + const height = tiles.length ? Math.max(...tiles.map(t => t.y + t.h)) : 0 + return
+ {tiles.map(({ name, x, y, w, h, component: Picture }) =>
)} +
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/imessage/imessage.css b/skills/imported/benjitaylor/stageshow/examples/imessage/imessage.css new file mode 100644 index 0000000..add58c7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/imessage/imessage.css @@ -0,0 +1,253 @@ +/* Stage: a quiet cool well with a faint blue wash; the phone is pinned one + gutter from the top, centered, and runs off the bottom through the keyboard */ +.im .ss-stage { + --im-blue: #0a84ff; + --im-grey: #8e8e93; + /* UIKit-quick: off the tap fast, long soft settle */ + --im-ui: cubic-bezier(0.3, 0.75, 0.1, 1); + overflow: hidden; + border-radius: 14px; + background: #f1f3f6; +} + +.im-wash { + position: absolute; + inset: 0; + background: + radial-gradient(70% 45% at 50% 0%, rgba(10, 132, 255, 0.1), transparent 70%), + radial-gradient(60% 40% at 50% 100%, rgba(10, 132, 255, 0.06), transparent 70%); +} + +.im-phone { + position: absolute; + top: 40px; + left: 50%; + translate: -50% 0; +} + +.im-phone .ss-phone__screen { + background: #fff; +} + +/* ---- App column: header, thread, compose bar, keyboard (in iOS points) ---- */ + +.im-app { + position: absolute; + inset: 54px 0 0; + z-index: 1; + display: flex; + flex-direction: column; +} + +.im-head { + position: relative; + display: flex; + flex: none; + justify-content: center; + z-index: 3; + /* Clear of the island above, room under the name below: the bar sizes to + its content, never a fixed height the name can overrun */ + padding: 16px 0 12px; + background: #f7f7f8; + border-bottom: 0.5px solid #d8d8dc; +} + +/* The bar runs up behind the status bar, as iOS's navigation bar does */ +.im-head::before { + content: ''; + position: absolute; + inset: -54px 0 100%; + background: inherit; +} + +.im-back, +.im-video { + position: absolute; + top: 24px; + color: var(--im-blue); + display: grid; + place-items: center; +} + +.im-back { + left: 8px; +} + +.im-video { + right: 18px; + top: 26px; +} + +.im-contact { + display: grid; + justify-items: center; + gap: 4px; +} + +/* The kit's iOS contact avatar, at Messages' lighter weight */ +.im-avatar { + font-size: 20px; + font-weight: 500; +} + +.im-name { + display: flex; + align-items: center; + gap: 2px; + color: #000; + font-size: 15px; + font-weight: 500; + line-height: 18px; +} + +.im-name svg { + color: #b4b4b9; +} + +.im-thread { + flex: 1; + /* Older messages pass under the translucent bar instead of being cut */ + overflow: visible; + padding: 0 18px 10px 16px; +} + +.im-thread .ss-stamp { + margin: 14px 0 8px; + font-weight: 400; +} + +.im-thread .ss-stamp b { + font-weight: 600; +} + +.im-receipt { + padding-top: 2px; +} + +/* ---- Compose bar ---- */ + +.im-compose { + display: flex; + flex: none; + align-items: center; + gap: 9px; + padding: 6px 12px 8px 10px; +} + +.im-plus { + display: grid; + flex: none; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: #eeeef0; + color: #85858b; +} + +.im-field { + position: relative; + display: flex; + flex: 1; + align-items: center; + height: 36px; + padding: 0 4px 0 14px; + border: 1px solid #d4d4d8; + border-radius: 18px; +} + +.im-field__text { + position: relative; + display: flex; + flex: 1; + align-items: center; + font-size: 17px; + letter-spacing: -0.01em; + white-space: pre; +} + +.im-field__ph { + color: #c2c2c6; +} + +.im-caret { + display: block; + width: 2px; + height: 21px; + margin: 0 1px; + border-radius: 1px; + background: var(--im-blue); + animation: im-blink 1.05s steps(1) infinite; +} + +.im-field__ph { + margin-left: -4px; +} + +.im-caret[data-solid] { + animation: none; +} + +@keyframes im-blink { + 50% { + opacity: 0; + } +} + +.im-mic, +.im-send { + position: absolute; + top: 3px; + right: 3px; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + transition: + transform 0.28s var(--im-ui), + opacity 0.18s ease; +} + +.im-mic { + color: #9a9aa0; +} + +.im-mic[data-off] { + transform: scale(0.6); + opacity: 0; +} + +.im-send { + background: var(--im-blue); + color: #fff; + transform: scale(0.5); + opacity: 0; +} + +.im-send[data-on] { + transform: none; + opacity: 1; +} + +/* The press pop: the letter enlarged in a balloon above the key */ +@keyframes im-pop { + 0%, + 65% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .im-caret { + animation: none; + } +} + +/* The placeholder returns once the sent words have lifted out of the field */ +.im-field__ph { + animation: ss-fade-in 0.2s ease 0.3s both; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/imessage/index.tsx b/skills/imported/benjitaylor/stageshow/examples/imessage/index.tsx new file mode 100644 index 0000000..4338cd1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/imessage/index.tsx @@ -0,0 +1,180 @@ +import { ArrowUp, ChevronLeft, Mic, Plus, Video } from 'lucide-react' +import { Stage, Phone, Thread, Bubble, Stamp, Receipt, Keyboard, type Act, Avatar } from '../../kit' +import './imessage.css' + +// iMessage on an iPhone, for a landing page. Four beats: +// 1. Context: Maya's two messages, keyboard up, the field focused. +// 2. Reply: the person types (keys pop as iOS keys do), taps send; the +// bubble rises in, the receipt reads Delivered, then rolls to Read. +// 3. Maya types (the dots), and the dots grow into her reply. Her reply +// makes the receipt redundant, so it folds away, as iOS does. +// Hold the completed conversation. Replay is the explicit reset; sent +// messages do not disappear from the live thread on their own. +// +// Real strings: Maya, the stamp, her two messages, the typed reply, the +// receipt, her reply. The keyboard keeps its letters: it is the domain's +// signature, not information. + +type S = { + draft: string + keys: boolean + sent: boolean + delivered: boolean + read: boolean + typing: boolean + reply: boolean +} + +const initial: S = { + draft: '', + keys: false, + sent: false, + delivered: false, + read: false, + typing: false, + reply: false, +} + +const MINE = "No way, I'm so in" +const REPLY = "Leaving at 5, I'll drive" + +// The predictive bar follows the word being typed, as iOS's does +const SUGGEST: Record = { + N: ['No', 'Not'], + No: ['Now', 'Not'], + w: ['was', 'we'], + wa: ['way', 'was'], + way: ['way', 'was'], + 'way,': ['I', 'I’m'], + I: ['I’m', 'I'], + "I'": ['I’m', 'I’ll'], + "I'm": ['I’m', 'I’ve'], + s: ['so', 'see'], + so: ['so', 'soon'], + i: ['in', 'is'], + in: ['in', 'it'], +} + +function suggestions(draft: string): [string, string, string] { + const word = draft.split(' ').pop() ?? '' + if (!word) return draft ? ['I', 'the', 'and'] : ['I', 'The', 'I’m'] + const [a, b] = SUGGEST[word] ?? [word, word] + return [`“${word}”`, a, b] +} + +async function script(a: Act) { + await a.wait(900) + a.mark('type') + await a.set({ keys: true }) + await a.type('draft', MINE, { interval: 95 }) + await a.wait(150) + await a.set({ keys: false }) + await a.wait(250) + a.mark('send') + await a.click({ at: 'send', x: 0.5, y: 0.5 }) + await a.set({ sent: true, draft: '' }) + await a.wait(700) + await a.set({ delivered: true }) + await a.wait(1200) + await a.set({ read: true }) + await a.wait(900) + a.mark('typing') + await a.set({ typing: true }) + await a.wait(1900) + await a.set({ typing: false, reply: true }) + await a.wait(3700) + a.mark('done') +} + +export default function IMessage() { + return ( + + width={420} + height={620} + initial={initial} + poster={{ ...initial, sent: true, delivered: true, read: true, reply: true }} + script={script} + play="once" + cursor={{ at: { x: 330, y: 420 }, style: 'touch' }} + className="im" + > + {(s) => { + return ( + <> +
+ +
+
+ + + + + + + Maya + + + + + + + +
+ + + Did you see the lake house? + Any luck finding a place? + + Today 9:38 + + Booked the cabin for Friday! + Hot tub and a lake view + {s.sent && ( + + {MINE} + + )} + {s.sent && ( + + {s.read ? 'Read 9:41' : 'Delivered'} + + )} + {(s.typing || s.reply) && ( + + {REPLY} + + )} + + +
+ + + + + + {s.draft} + + {!s.draft && iMessage} + + + + + + + + +
+ + +
+
+ + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/linear/index.tsx b/skills/imported/benjitaylor/stageshow/examples/linear/index.tsx new file mode 100644 index 0000000..e00773b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/linear/index.tsx @@ -0,0 +1,366 @@ +import { Box, ChevronDown, CircleDashed, Folder, Inbox, IterationCcw, Layers, ListFilter, Search, SignalHigh, SlidersHorizontal, SquarePen, Tag, UserRound, UserRoundCheck } from 'lucide-react' +import type { ReactNode } from 'react' +import { Avatar, Bar, Camera, Collapse, Field, Popover, Stage, Text, Window, type Act, type Layout, type Rect } from '../../kit' +import './linear.css' + +// Linear, light, in its desktop window (860 wide), as it looks after the +// 2026 refresh: grayscale chrome, a dimmed sidebar, compact rounded tabs. +// Color stays only on Linear's own content marks (status circles, label +// dots, avatars) and the accent on the one avatar the story lands. The issue list is the +// context; Cmd+K opens the command menu over it, "assign" filters it, the +// user picks "Assign to me", and ENG-142's row takes their avatar while its +// status turns from Todo to In Progress. Cmd+Z undoes it, so the loop closes +// with a caused reset. +// +// Real strings: the team, the view, ENG-142 and its title, the command menu +// (its rows are what the typing filters, so they must be words), and what is +// typed. Every other row is placeholder lines around Linear's own signature +// marks: priority bars, status circles, avatars. +// +// Icons: Linear ships no public icon pack, so the nav and command glyphs are +// lucide-react (installed here), chosen for its thin, even line, which is +// close to Linear's own. The status circles and priority bars are Linear's +// own geometry, drawn from circles and rects, because they are its content. + +type S = { + focus: boolean + open: boolean + query: string + hoverMe: boolean + assigned: boolean + zoom: Rect | null +} + +const initial: S = { focus: false, open: false, query: '', hoverMe: false, assigned: false, zoom: null } + +type Status = 'backlog' | 'todo' | 'progress' | 'done' +type Row = { status: Status; pri: 0 | 1 | 2 | 3 | 4; id: number; title: number; label?: number; who?: string; date: number } + +const ROWS: Row[] = [ + { status: 'progress', pri: 4, id: 44, title: 210, label: 34, who: 'Mara', date: 30 }, + { status: 'progress', pri: 2, id: 40, title: 168, who: 'Theo', date: 26 }, + { status: 'todo', pri: 3, id: 0, title: 0, label: 30, date: 30 }, // ENG-142 + { status: 'todo', pri: 2, id: 42, title: 236, who: 'Ines', date: 28 }, + { status: 'todo', pri: 1, id: 38, title: 150, label: 40, date: 30 }, + { status: 'backlog', pri: 0, id: 44, title: 192, who: 'Mara', date: 24 }, + { status: 'backlog', pri: 2, id: 40, title: 128, date: 30 }, + { status: 'backlog', pri: 3, id: 42, title: 178, who: 'Theo', date: 28 }, + { status: 'done', pri: 1, id: 38, title: 204, date: 26 }, + { status: 'done', pri: 2, id: 44, title: 140, who: 'Ines', date: 30 }, + { status: 'done', pri: 0, id: 40, title: 186, who: 'Mara', date: 28 }, + { status: 'done', pri: 3, id: 42, title: 122, date: 24 }, +] + +const COMMANDS: { label: string; key: string; icon: ReactNode; target?: string }[] = [ + { label: 'Change status…', key: 'S', icon: }, + { label: 'Assign to…', key: 'A', icon: }, + { label: 'Assign to me', key: 'I', icon: , target: 'me' }, + { label: 'Change priority…', key: 'P', icon: }, + { label: 'Add labels…', key: 'L', icon: }, +] + +const REST = { at: 'rest', x: 0.64, y: 0.5 } + +async function script(a: Act) { + const compact = a.layout === 'compact' + await a.wait(900) + + // 1. Context: the pointer comes to rest on ENG-142, which takes focus + a.mark('focus') + await a.move({ at: 'row', x: compact ? 0.86 : 0.7, y: 0.6 }) + await a.set({ focus: true }) + await a.wait(700) + + // 2. Cmd+K opens the command menu on that issue + a.mark('open') + await a.key('cmd+k') + await a.set({ open: true }) + await a.wait(800) + + // 3. Type "assign": the menu narrows to the two assign commands + a.mark('type') + await a.type('query', 'assign', { interval: 95 }) + await a.wait(700) + + // 4. Pick yourself + a.mark('pick') + await a.move({ at: 'me', x: 0.62, y: 0.55 }) + await a.set({ hoverMe: true }) + await a.wait(350) + await a.click() + await a.wait(120) + await a.set({ open: false }) + // Step the pointer off the row so it doesn't sit over the change + await a.move({ at: 'rest', x: compact ? 0.5 : 0.42, y: 0.5 }) + + // 5. The camera comes in on the row (the change is two 16px marks in a + // 920px scene), then the row updates: avatar in, Todo -> In Progress + a.mark('result') + if (!compact) { + const who = a.rect('who142', { layout: true }) + const row = a.rect('row', { layout: true }) + await a.set({ zoom: { x: row.x, y: row.y, width: who.x + who.width + 12 - row.x, height: row.height } }) + await a.wait(900) + } + await a.set({ assigned: true }) + await a.wait(2200) + if (!compact) { + await a.set({ zoom: null }) + await a.wait(1100) + } + + // Undo, the way Linear does it, then let go of the row + a.mark('undo') + await a.key('cmd+z') + await a.set({ assigned: false }) + await a.wait(900) + await a.snap({ query: '', hoverMe: false }) + await a.move(REST) + await a.set({ focus: false }) + await a.wait(900) +} + +function Priority({ level }: { level: Row['pri'] }) { + if (level === 0) { + return ( + + {[2, 6, 10].map((x) => )} + + ) + } + if (level === 4) { + return ( + + + + + + ) + } + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) +} + +// Linear's status circles. Todo and In Progress are one object: the ring +// recolors and the pie inside sweeps to half. +function StatusIcon({ status }: { status: Status }) { + if (status === 'backlog') { + return ( + + + + ) + } + if (status === 'done') { + return ( + + + + + ) + } + return ( + + + + + ) +} + +function Unassigned() { + return ( + + + + + + ) +} + +function IssueRow({ r, i, s, compact }: { r: Row; i: number; s: S; compact: boolean }) { + const target = i === 2 + const status: Status = target && s.assigned ? 'progress' : r.status + const icon = + return ( +
+ + {target ? ENG-142 : } + {target ? {icon} : icon} + {target ? Fix login redirect loop : } + {!compact && ( + + {r.label && ( + + + + + )} + + )} + + {target ? ( + + + + + ) : r.who ? ( + + ) : ( + + )} + + {!compact && ( + + + + )} +
+ ) +} + +function Sidebar() { + const nav = (icon: ReactNode, w: number, active?: boolean) => ( +
+ {icon} + +
+ ) + return ( +
+
+ A + + + + + + + +
+
+ {nav(, 34)} + {nav(, 52)} +
+
+
+
+ E + Engineering + +
+
+
+ + Issues +
+ {nav(, 38)} + {nav(, 46)} +
+
+
+
+ {nav(, 56)} + {nav(, 42)} +
+
+ ) +} + +function Scene({ s, layout }: { s: S; layout: Layout }) { + const compact = layout === 'compact' + const q = s.query.toLowerCase() + const lead = ( +
+ {compact && E} + {compact ? 'Issues' : 'Engineering'} + {!compact && ( +
+ + All issues + + + +
+ )} +
+ ) + const toolbar = ( +
+ + + + {!compact && } + +
+ ) + const list = ( +
+ {ROWS.map((r, i) => ( + + ))} +
+ ) + const props = { lead, toolbar, className: 'ln-window', body: { className: 'ln-body', 'data-crop': true } } + return ( + + {compact ? ( + {list} + ) : ( + } sidebarWidth={212}> + {list} + + )} + +
+ +
+ + ENG-142 + Fix login redirect loop + +
+ +
+ {/* Compact keeps the first three, so the menu clears the keycaps */} + {(compact ? COMMANDS.slice(0, 3) : COMMANDS).map((c, i) => { + const match = !q || c.label.toLowerCase().includes(q) + const firstMatch = COMMANDS.findIndex((x) => !q || x.label.toLowerCase().includes(q)) + const active = s.hoverMe ? c.target === 'me' : i === firstMatch + return ( + +
+ {c.icon} + {c.label} + {c.key} +
+
+ ) + })} +
+
+ + ) +} + +export default function LinearDemo() { + return ( + + {(s, _rect, layout) => } + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/linear/linear.css b/skills/imported/benjitaylor/stageshow/examples/linear/linear.css new file mode 100644 index 0000000..b8e0d43 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/linear/linear.css @@ -0,0 +1,676 @@ +/* Linear as it looks after its 2026 refresh: light, near-grayscale chrome in + one neutral family (the cool blue cast is gone), a dimmed sidebar that is + also the window frame, and the content in an inset white panel with a + hairline edge. Color is left only where Linear itself keeps it: the status + circles, label dots, avatars, and the indigo accent on the person the story + is about. + + Every color is a token here, so the dark theme is one block below + (`theme="dark"`, or film.mjs --dark). */ +.ln-stage .ss-stage { + --gutter: 30px; + --ss-well: #ececeb; + --ss-canvas: #f5f5f4; + --ss-chrome: #f5f5f4; + --ss-surface: #fdfdfc; + --ss-raised: #ffffff; + --ss-line: #e6e6e4; + --ss-text: #6c6c69; + --ss-text-strong: #1d1d1c; + --ss-text-faint: #a2a29e; + --ss-icon: #7a7a76; + --ss-icon-faint: #b9b9b5; + --ss-accent-soft: rgba(94, 106, 210, 0.16); + + --ln-nav-text: #5c5c59; + --ln-nav-active: #e9e9e7; + --ln-row-hover: #f3f3f2; + --ln-tile: #1d1d1c; + --ln-tile-text: #fdfdfc; + --ln-team-tile: #e4e4e2; + --ln-team-text: #4a4a47; + --ln-tab-active: #efefee; + --ln-chip-line: #e3e3e1; + --ln-pri-off: #cfcfcc; + --ln-pri-on: #6c6c69; + --ln-ring-todo: #a8a8a4; + --ln-ring-backlog: #b4b4b0; + --ln-progress: #e5b400; + --ln-done: #5e6ad2; + --ln-done-mark: #fdfdfc; + --ln-unassigned: #b0b0ac; + --ln-scrim: rgba(28, 28, 26, 0.06); + --ln-cmd-line: #ececea; + --ln-cmd-active: #f1f1f0; + --ln-cmd-text: #3a3a38; + --ln-cmd-chip: #f1f1f0; + --ln-cmd-chip-title: #3a3a38; + --ln-key-line: #e3e3e1; + --ln-key-active: #e6e6e4; + --ln-caret: #1d1d1c; + --ln-cmd-shadow: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 24px 56px -16px rgba(0, 0, 0, 0.16), 0 8px 18px -8px rgba(0, 0, 0, 0.08); + --ln-panel-shadow: 0 0 0 1px var(--ss-line), 0 1px 2px rgba(0, 0, 0, 0.03); + + overflow: hidden; + border-radius: 16px; + background: var(--ss-well); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); + font-family: Inter, system-ui, -apple-system, sans-serif; +} + +/* The same scene in Linear's refreshed dark theme: warm-neutral grays, no + blue cast, depth from lighter surfaces. */ +.ln-stage .ss-stage[data-theme="dark"] { + --ss-well: #0b0b0b; + --ss-canvas: #0e0e0e; + --ss-chrome: #0e0e0e; + --ss-surface: #121212; + --ss-raised: #1c1c1c; + --ss-line: #222221; + --ss-text: #8d8d8a; + --ss-text-strong: #ededec; + --ss-text-faint: #636361; + --ss-icon: #8d8d8a; + --ss-icon-faint: #4d4d4b; + --ss-accent-soft: rgba(94, 106, 210, 0.24); + + --ln-nav-text: #a3a3a0; + --ln-nav-active: #1c1c1c; + --ln-row-hover: #191919; + --ln-tile: #ededec; + --ln-tile-text: #0e0e0e; + --ln-team-tile: #262626; + --ln-team-text: #c4c4c1; + --ln-tab-active: #1f1f1f; + --ln-chip-line: #2a2a29; + --ln-pri-off: #444442; + --ln-pri-on: #a3a3a0; + --ln-ring-todo: #a8a8a5; + --ln-ring-backlog: #696967; + --ln-progress: #f0bf00; + --ln-done: #5e6ad2; + --ln-done-mark: #121212; + --ln-unassigned: #696967; + --ln-scrim: rgba(0, 0, 0, 0.28); + --ln-cmd-line: #2a2a29; + --ln-cmd-active: #2a2a29; + --ln-cmd-text: #d2d2cf; + --ln-cmd-chip: #282827; + --ln-cmd-chip-title: #c8c8c5; + --ln-key-line: #333332; + --ln-key-active: #3a3a39; + --ln-caret: #ededec; + --ln-cmd-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 24px 60px -12px rgba(0, 0, 0, 0.7), 0 8px 20px -8px rgba(0, 0, 0, 0.5); + --ln-panel-shadow: 0 0 0 1px var(--ss-line); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.ln-icon { + flex-shrink: 0; + color: var(--ss-icon); +} + +/* ---- Window: pinned a gutter in, runs off the bottom ---- */ + +.ln-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + width: 860px; + height: 500px; + background: var(--ss-canvas); + box-shadow: var(--ss-shadow-hero); +} + +[data-layout="compact"] .ln-window { + top: 16px; + left: 16px; + width: 388px; + height: 360px; +} + +/* Without the sidebar there is no frame around the panel: the whole window + is the content surface */ +.ln-window:not([data-kind="sidebar"]), +.ln-window:not([data-kind="sidebar"]) .ss-window__bar { + background: var(--ss-surface); +} + +.ln-window .ss-window__side { + border-right: 0; +} + +/* Linear's content sits in an inset panel inside the frame */ +.ln-window[data-kind="sidebar"] .ss-window__main { + margin: 8px 8px 0 0; + border-radius: 10px 10px 0 0; + /* The edge is a shadow, so the panel's own box meets the sidebar exactly */ + box-shadow: var(--ln-panel-shadow); + overflow: hidden; +} + +.ln-window .ss-window__bar { + gap: 10px; + background: transparent; +} + +/* ---- Sidebar: dimmed, so the list leads ---- */ + +.ln-side { + display: flex; + flex-direction: column; + gap: 20px; + padding: 2px 10px 0; +} + +.ln-workspace { + display: flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 6px; +} + +.ln-workspace__tile, +.ln-team__tile { + display: grid; + flex-shrink: 0; + place-items: center; + width: 18px; + height: 18px; + border-radius: 5px; + background: var(--ln-tile); + color: var(--ln-tile-text); + font-size: 10.5px; + font-weight: 600; + line-height: 1; +} + +/* Team icons lost their colored backgrounds in the refresh */ +.ln-team__tile { + width: 16px; + height: 16px; + border-radius: 4px; + background: var(--ln-team-tile); + color: var(--ln-team-text); + font-size: 9.5px; +} + +.ln-compose { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: 0 0 0 1px var(--ss-line); +} + +.ln-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ln-section { + display: flex; + align-items: center; + height: 24px; + padding: 0 8px; +} + +.ln-nav { + display: flex; + align-items: center; + gap: 10px; + height: 28px; + padding: 0 8px; + border-radius: 6px; +} + +.ln-nav[data-active] { + background: var(--ln-nav-active); +} + +.ln-nav__label, +.ln-team__name { + font-size: 13px; + font-weight: 500; + color: var(--ss-text-strong); +} + +.ln-team__name { + color: var(--ln-nav-text); +} + +.ln-team { + gap: 9px; +} + +.ln-team .ln-icon { + margin-left: -3px; +} + +.ln-sub { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: 14px; +} + +.ln-icon--on { + color: var(--ss-text-strong); +} + +/* ---- Header ---- */ + +.ln-crumbs { + display: flex; + align-items: center; + gap: 8px; + margin-left: 2px; +} + +[data-layout="compact"] .ln-crumbs { + margin-left: 12px; +} + +.ln-crumbs__team { + font-size: 13px; + font-weight: 500; + color: var(--ss-text-strong); +} + +/* Compact, rounded tabs that don't span the bar */ +.ln-tabs { + display: flex; + align-items: center; + gap: 4px; + margin-left: 10px; +} + +.ln-tab { + display: flex; + align-items: center; + height: 24px; + padding: 0 10px; + border-radius: 12px; + box-shadow: inset 0 0 0 1px var(--ss-line); + font-size: 12px; + font-weight: 500; + color: var(--ss-text-strong); +} + +.ln-tab[data-active] { + background: var(--ln-tab-active); + box-shadow: none; +} + +.ln-tools { + display: flex; + align-items: center; + gap: 6px; +} + +.ln-tool { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-width: 26px; + height: 26px; + border-radius: 13px; +} + +.ln-tool--display { + padding: 0 10px; + box-shadow: inset 0 0 0 1px var(--ss-line); +} + +[data-layout="compact"] .ln-tool--display { + padding: 0; +} + +/* ---- List ---- */ + +.ln-list { + display: flex; + flex-direction: column; + padding: 6px; +} + +/* One column template, so ids, circles, avatars and dates line up */ +.ln-row { + display: grid; + grid-template-columns: 14px 54px 14px minmax(0, 1fr) 96px 18px 44px; + align-items: center; + column-gap: 12px; + height: 38px; + padding: 0 12px; + border-radius: 6px; + transition: background-color 0.18s ease; +} + +[data-layout="compact"] .ln-row { + grid-template-columns: 14px 54px 14px minmax(0, 1fr) 18px; +} + +.ln-row[data-focus] { + background: var(--ln-row-hover); + transition-duration: 0.06s; +} + +.ln-id { + display: flex; + align-items: center; + font-size: 12.5px; + font-variant-numeric: tabular-nums; + color: var(--ss-text); +} + +.ln-title { + display: flex; + align-items: center; + min-width: 0; +} + +.ln-title__text { + font-size: 13px; + font-weight: 500; + color: var(--ss-text-strong); +} + +.ln-labels { + display: flex; + justify-content: flex-end; +} + +.ln-chip { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 9px 0 8px; + border-radius: 11px; + box-shadow: inset 0 0 0 1px var(--ln-chip-line); +} + +.ln-chip__dot { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.ln-chip__dot[data-tone="0"] { background: #eb5757; } +.ln-chip__dot[data-tone="1"] { background: #bb87fc; } +.ln-chip__dot[data-tone="2"] { background: #4ea7fc; } + +.ln-date { + display: flex; + justify-content: flex-end; +} + +/* Priority bars */ +.ln-pri rect { + fill: var(--ln-pri-off); +} + +.ln-pri rect[data-on] { + fill: var(--ln-pri-on); +} + +.ln-pri[data-urgent] > rect:first-child { + fill: var(--ln-pri-on); +} + +.ln-pri[data-urgent] .ln-pri__mark { + fill: var(--ss-surface); +} + +/* Status circles */ +.ln-status { + overflow: visible; +} + +.ln-status[data-status="backlog"] circle { + stroke: var(--ln-ring-backlog); +} + +.ln-status[data-status="done"] circle { + fill: var(--ln-done); +} + +.ln-status[data-status="done"] path { + stroke: var(--ln-done-mark); +} + +.ln-status__ring { + stroke: var(--ln-ring-todo); + transition: stroke 0.25s ease; +} + +.ln-status__pie { + stroke: var(--ln-progress); + stroke-dasharray: 0 100; + transition: stroke-dasharray 0.7s cubic-bezier(0.45, 0, 0.1, 1); +} + +.ln-status[data-status="progress"] .ln-status__ring { + stroke: var(--ln-progress); +} + +.ln-status[data-status="progress"] .ln-status__pie { + stroke-dasharray: 50 100; +} + +/* Assignee */ +.ln-who { + display: grid; + place-items: center; + width: 18px; + height: 18px; +} + +.ln-unassigned { + overflow: visible; +} + +.ln-unassigned circle:first-child { + stroke: var(--ln-unassigned); +} + +.ln-unassigned circle:not(:first-child), +.ln-unassigned path { + fill: var(--ln-unassigned); +} + +.ln-avatar[data-face="0"] { background: #c08a6a; color: #fff; } +.ln-avatar[data-face="1"] { background: #6f8fae; color: #fff; } +.ln-avatar[data-face="2"] { background: #8d7fae; color: #fff; } + +/* The unassigned mark and the avatar share one slot and crossfade; the + avatar's disc grows a touch as it arrives while its initial only fades + (a letter that ends a scale hops as it lands) */ +.ln-who__swap { + display: grid; + width: 18px; + height: 18px; +} + +.ln-who__swap > * { + grid-area: 1 / 1; + transition: opacity 0.35s ease, scale 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.ln-who__me { + position: relative; + isolation: isolate; + opacity: 0; + background: none; +} + +.ln-who__me::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: var(--ss-accent); + scale: 0.6; + transition: scale 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.ln-who__swap[data-assigned] .ln-who__me { + opacity: 1; +} + +.ln-who__swap[data-assigned] .ln-who__me::before { + scale: 1; +} + +.ln-who__swap[data-assigned] .ln-unassigned { + opacity: 0; + scale: 0.8; +} + +/* ---- Command menu ---- */ + +.ln-scrim { + position: absolute; + inset: 0; + z-index: 20; + background: var(--ln-scrim); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.ln-scrim[data-show] { + opacity: 1; +} + +.ln-cmd { + top: 128px; + left: 190px; + width: 560px; + padding: 0; + border-radius: 12px; + --ss-popover-bg: var(--ss-raised); + --ss-popover-shadow: var(--ln-cmd-shadow); +} + +[data-layout="compact"] .ln-cmd { + top: 66px; + left: 30px; + width: 360px; +} + +.ln-cmd__context { + display: flex; + padding: 14px 16px 0; +} + +.ln-cmd__chip { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + background: var(--ln-cmd-chip); + font-size: 11.5px; + font-weight: 500; + color: var(--ss-text); +} + +.ln-cmd__chip-title { + color: var(--ln-cmd-chip-title); +} + +/* The menu's input is the menu's own line, not a field: no box, no ring */ +.ln-cmd__field .ss-field__box, +.ln-cmd__field[data-focused] .ss-field__box { + height: 50px; + padding: 0 18px; + border-radius: 0; + background: transparent; + box-shadow: none; + border-bottom: 1px solid var(--ln-cmd-line); + font-size: 15px; +} + +.ln-cmd__field .ss-field__caret { + height: 18px; + background: var(--ln-caret); +} + +.ln-cmd__list { + padding: 6px 6px 8px; +} + +/* Rows ride up as the ones above collapse: a kept layer, so their text + moves with the box instead of snapping to whole pixels inside it */ +.ln-cmd__item { + display: flex; + align-items: center; + gap: 12px; + height: 40px; + padding: 0 12px; + border-radius: 7px; + font-size: 13.5px; + color: var(--ln-cmd-text); + transition: background-color 0.12s ease; + will-change: transform; +} + +.ln-cmd__item[data-active] { + background: var(--ln-cmd-active); + color: var(--ss-text-strong); +} + +.ln-cmd__icon { + display: grid; + place-items: center; + width: 16px; + color: var(--ss-icon); +} + +.ln-cmd__item[data-active] .ln-cmd__icon { + color: var(--ss-text-strong); +} + +.ln-cmd__label { + flex: 1; +} + +.ln-cmd__key { + display: grid; + place-items: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border-radius: 5px; + box-shadow: inset 0 0 0 1px var(--ln-key-line); + font-size: 11px; + font-weight: 500; + color: var(--ss-text); +} + +.ln-cmd__item[data-active] .ln-cmd__key { + box-shadow: inset 0 0 0 1px var(--ln-key-active); +} + +.ln-st { + display: grid; + width: 14px; + height: 14px; +} + +/* Compact: the menu has to fit the shorter stage, a gutter from its bottom */ +[data-layout="compact"] .ln-cmd__item { + height: 36px; +} + +[data-layout="compact"] .ln-cmd__field .ss-field__box { + height: 46px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/loading/index.tsx b/skills/imported/benjitaylor/stageshow/examples/loading/index.tsx new file mode 100644 index 0000000..765c5bd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/loading/index.tsx @@ -0,0 +1,107 @@ +import { RotateCw } from 'lucide-react' +import { Avatar, Badge, Bar, Stage, Window, type Act } from '../../kit' +import './loading.css' + +// Loading to content: a list of orders arrives into its own skeleton. Beats: +// the skeleton holds a moment, the rows resolve top to bottom, the list +// holds; then Refresh is pressed, the rows dim back to the skeleton and +// resolve again. Loops; the reset is the refresh. +// +// What makes a skeleton resolve without a jolt: +// - The skeleton is the content's own geometry. Each bar sits in the same +// grid cell as the words it stands for, at the words' x-height and about +// their width, so nothing moves when they arrive: the bar fades as its +// words fade in, in place. +// - Only the data loads. The window, the title and the column heads are +// real from the first frame; a skeleton over the chrome reads as a page +// that hasn't loaded at all. +// - Rows resolve top to bottom, 70ms apart, each whole (avatar, name, +// amount and status together), so the eye reads down the list once. +// - The shimmer is faint and slow, and it stops the moment a row resolves. +// +// Four rows, the window running off just under the last: the stage may only +// cut the window's empty foot, never a row's words. +// +// Real strings: the title, the column heads, every resolved row. + +type S = { ready: boolean; spin: number } + +const ROWS = [ + { who: 'Maya Chen', what: 'Pro, annual', amount: '$588.00', status: 'Paid' }, + { who: 'Jon Park', what: 'Team, monthly', amount: '$99.00', status: 'Paid' }, + { who: 'Ana Ruiz', what: 'Pro, monthly', amount: '$49.00', status: 'Refunded' }, + { who: 'Leo Sato', what: 'Team, annual', amount: '$1,188.00', status: 'Paid' }, +] +// Each bar about its words' width +const W = [[74, 70], [60, 82], [64, 74], [58, 76]] +const AW = [52, 44, 40, 60] +const BW = [42, 42, 62, 42] + +async function script(a: Act) { + await a.wait(1100) + a.mark('resolve') + a.put({ ready: true }) + await a.wait(2600) + a.mark('refresh') + await a.move('refresh') + await a.wait(150) + await a.click('refresh') + a.put({ ready: false, spin: 1 }) + await a.wait(1000) + a.put({ spin: 0 }) + await a.move({ at: 'refresh', x: 3, y: 6 }, { duration: 700 }) +} + +export default function Loading() { + return ( + width={600} height={400} compact={{ below: 600, width: 358, height: 400 }} initial={{ ready: false, spin: 0 }} poster={{ ready: true, spin: 0 }} script={script} cursor={{ at: { x: 520, y: 370 } }} className="ld"> + {(s) => ( + +
+
+
Recent orders
+ + + +
+
+ Customer + Amount + Status +
+ {ROWS.map((r, i) => ( +
+ + + + + + + + + {r.who} + + + + {r.what} + + + + + + {r.amount} + + + + + {r.status} + + +
+ ))} +
+
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/loading/loading.css b/skills/imported/benjitaylor/stageshow/examples/loading/loading.css new file mode 100644 index 0000000..ac2e27a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/loading/loading.css @@ -0,0 +1,164 @@ +.ld .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* One window, inset at the top, off the bottom */ +.ld-win { + position: absolute; + top: 40px; + left: 50%; + width: 500px; + translate: -50% 0; +} + +.ld .ss-stage[data-layout='compact'] .ld-win { + width: 326px; +} + +.ld-body { + min-height: 420px; + padding: 20px 24px 0; + background: var(--ss-surface); +} + +.ld .ss-stage[data-layout='compact'] .ld-body { + padding: 18px 16px 0; +} + +.ld-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.ld-title { + margin: 0; + color: var(--ss-text-strong); + font-size: 15px; + font-weight: 600; +} + +.ld-refresh { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: var(--ss-radius-sm); + color: var(--ss-icon); + transition: background-color 0.15s ease; +} + +.ld-refresh[data-hover] { + background: var(--ss-ink-1); +} + +.ld-refresh svg { + transition: rotate 0s; +} + +.ld-refresh[data-spin] svg { + rotate: 360deg; + transition: rotate 0.9s cubic-bezier(0.4, 0, 0.2, 1); +} + +.ld-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 84px 76px; + align-items: center; + gap: 16px; + height: 54px; + border-top: 1px solid var(--ss-line-faint); + color: var(--ss-text-strong); + font-size: 13px; + white-space: nowrap; +} + +.ld .ss-stage[data-layout='compact'] .ld-row { + grid-template-columns: minmax(0, 1fr) 72px 70px; + gap: 10px; +} + +.ld-row--cols { + height: 32px; + border-top: 0; + color: var(--ss-text); + font-size: 12px; + font-weight: 500; +} + +.ld-num { + justify-items: end; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ld-who { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.ld-lines { + display: grid; + gap: 3px; + min-width: 0; +} + +.ld-what { + color: var(--ss-text); + font-size: 12px; +} + +/* A bar and its words share one cell: the bar fades as the words arrive, + in place, so nothing moves */ +.ld-slot { + display: grid; + align-items: center; +} + +.ld-slot > * { + grid-area: 1 / 1; +} + +.ld-sk { + overflow: hidden; + position: relative; + transition: opacity 0.3s ease calc(var(--i) * 70ms); +} + +/* Faint, slow shimmer while waiting */ +.ld-sk::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.55), transparent); + translate: -100% 0; + animation: ld-shimmer 1.6s ease-in-out infinite; +} + +@keyframes ld-shimmer { + to { translate: 100% 0; } +} + +.ld-real { + opacity: 0; + transition: opacity 0.2s ease; +} + +.ld-row[data-ready] .ld-sk { + opacity: 0; +} + +.ld-row[data-ready] .ld-real { + opacity: 1; + transition: opacity 0.4s ease calc(var(--i) * 70ms + 80ms); +} + +.ld-name, +.ld-what { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/index.tsx b/skills/imported/benjitaylor/stageshow/examples/looks/index.tsx new file mode 100644 index 0000000..dd7450d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/index.tsx @@ -0,0 +1,54 @@ +import { Badge, Field, Segmented, Stage, Text, Window } from '../../kit' +import { CanvasLook, ConsoleLook, FinanceLook, MessagesLook, ResearchLook } from './studies' +import { DimensionalLook } from './skeuomorphic' +import './radix.css' +import './looks.css' + +// The same invitation in three product identities. Copy a look's tokens, +// then preserve the target product's typography and density, not this UI. +function Scene() { + return ( + +
+
+
Invite your team3 seats
+ Choose who can access this workspace. +
+ + +
Send invitation
+
+
+ ) +} + +const looks = [ + ['look-default', 'Quiet workspace', 'System type · compact controls'], + ['look-radix', 'Technical product', 'Indigo · precise borders'], + ['look-consumer', 'Consumer app', 'Roomier controls · soft corners'], +] as const + +const studies = [ + { look: 'look-console', title: 'Developer console', detail: 'Dark surfaces · restrained status', scene: ConsoleLook }, + { look: 'look-finance', title: 'Financial overview', detail: 'Tabular figures · measured hierarchy', scene: FinanceLook }, + { look: 'look-canvas', title: 'Creative workspace', detail: 'Open canvas · precise selection', scene: CanvasLook }, + { look: 'look-messages', title: 'Team conversation', detail: 'Soft surfaces · comfortable rhythm', scene: MessagesLook }, + { look: 'look-research', title: 'Research notebook', detail: 'Sans-serif reading · quiet structure', scene: ResearchLook }, + { look: 'look-dimensional', title: 'Dimensional surfaces', detail: 'Layered paper · precise lighting', scene: DimensionalLook }, +] as const + +export default function Looks() { + return
{looks.map(([look, title, detail]) => ( +
+ {() => } +
{title}{detail}
+
+ ))}{studies.map(({ look, title, detail, scene: Study }) => ( +
+ + {() => } + +
{title}{detail}
+
+ ))}
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/looks.css b/skills/imported/benjitaylor/stageshow/examples/looks/looks.css new file mode 100644 index 0000000..a3dcbe8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/looks.css @@ -0,0 +1,44 @@ +.lk-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap: 24px; } +.lk-pair figure { margin: 0; min-width: 0; width: 358px; max-width: 100%; justify-self: center; } +.lk-pair figcaption { display: grid; gap: 4px; padding: 14px 2px 0; font: 12px/1.5 system-ui, sans-serif; color: #73737c; } +.lk-pair figcaption strong { color: #303036; font-size: 13px; font-weight: 550; } +.lk-pair .ss-stage { overflow: hidden; background: var(--ss-well); border-radius: 16px; } +.lk-pair .lk-window { position: absolute; inset: 24px 20px auto; width: 318px; box-shadow: var(--ss-shadow-ground); } +/* Let the window follow its content. Every look keeps the same 20px inset + below the action as above and beside the form, regardless of its density. */ +.lk-pair .lk-body { position: relative; display: flex; flex-direction: column; gap: 16px; padding: 20px; } +.lk-pair .lk-intro { display: grid; gap: 6px; } +.lk-pair .lk-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.lk-pair .lk-title { font-size: 16px; line-height: 1.4; font-weight: 600; color: var(--ss-text-strong); } +.lk-pair .lk-description { font-size: 12px; line-height: 1.5; } +.lk-pair .lk-body .ss-seg, .lk-pair .lk-body .ss-field { width: 100%; } +.lk-pair .lk-invite { display: flex; justify-content: space-between; align-items: center; min-height: 38px; padding: 9px 12px; border-radius: var(--ss-radius-md); background: var(--ss-accent); color: #fff; font-size: 13px; line-height: 20px; font-weight: 550; } +.lk-pair .lk-invite svg { flex: none; } +.lk-pair .look-consumer .ss-stage { + --ss-canvas: #fff; + --ss-surface: #f7f8fa; + --ss-chrome: #fafbfc; + --ss-well: #eef0f3; + --ss-line: #e1e5e9; + --ss-line-faint: #edf0f3; + --ss-text: #737b86; + --ss-text-strong: #242a32; + --ss-accent: #272d35; + --ss-accent-soft: #e9edf3; + --ss-accent-text: #526174; + --ss-radius-sm: 8px; + --ss-radius-md: 10px; + --ss-radius-lg: 14px; + --ss-radius-window: 18px; + --ss-control: 40px; + --ss-row: 30px; +} +.lk-pair .look-consumer .lk-title { font-size: 17px; font-weight: 600; } +.lk-pair .look-consumer .ss-field__box { box-shadow: inset 0 0 0 1px var(--ss-line); } +.lk-pair .look-consumer .ss-seg { background: #f0f2f5; } +.lk-pair .look-consumer .lk-invite { min-height: 42px; padding-inline: 14px; } +/* Reflow inside a 320px host's 288px slot; preserve the type's native size. */ +.lk-pair .ss-stage[data-layout="compact"] .lk-window { inset: 24px 16px auto; width: 256px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-body { padding: 16px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-head { align-items: flex-start; } +.lk-pair .ss-stage[data-layout="compact"] .lk-head .ss-badge { flex: none; } diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/radix.css b/skills/imported/benjitaylor/stageshow/examples/looks/radix.css new file mode 100644 index 0000000..4c04831 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/radix.css @@ -0,0 +1,39 @@ +/* A look from a public design system: Radix Themes' default light tokens + (MIT, radix-ui.com/themes), slate grays and the indigo accent, medium + radius. Copy this file to start a look for a product: set the tokens the + design system defines and leave the rest to the kit. */ + +.look-radix .ss-stage { + /* Color: the slate scale for surfaces, lines and text, indigo 9 for the accent */ + --ss-canvas: #f9f9fb; /* slate 2 */ + --ss-surface: #fff; + --ss-chrome: #fff; + --ss-well: #f0f0f3; /* slate 3 */ + --ss-line: #e8e8ec; /* slate 4 */ + --ss-line-faint: #f0f0f3; /* slate 3 */ + --ss-text: #60646c; /* slate 11 */ + --ss-text-strong: #1c2024; /* slate 12 */ + --ss-text-faint: #8b8d98; /* slate 9 */ + --ss-icon: #60646c; + --ss-icon-faint: #b9bbc6; /* slate 8 */ + --ss-accent: #3e63dd; /* indigo 9 */ + --ss-accent-2: #3e63dd; + --ss-accent-soft: #e1e9ff; /* indigo 3 */ + --ss-success: #30a46c; /* green 9 */ + --ss-danger: #e5484d; /* red 9 */ + + /* Type: the system stack Radix Themes ships */ + --ss-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", system-ui, sans-serif; + + /* Shape: radius 2 (4px) for small controls, 3 (6px) for fields, 4 (8px) + for panels, 5 (12px) for the window, full for badges */ + --ss-radius-sm: 4px; + --ss-radius-md: 6px; + --ss-radius-lg: 8px; + --ss-radius-window: 12px; + --ss-radius-pill: 9999px; + + /* Density: size 2 controls (32px), size 1 rows (24px) */ + --ss-control: 32px; + --ss-row: 24px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.css b/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.css new file mode 100644 index 0000000..5018b74 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.css @@ -0,0 +1,36 @@ +/* One light direction and small material separation. Type remains flat, + crisp and uncovered by reflections. */ +.lk-pair .look-dimensional .ss-stage { + display: grid; place-items: center; + background: radial-gradient(ellipse at 25% 10%, #fbfdff 0, transparent 65%), linear-gradient(150deg, #e9eff5, #dde5ee); +} +.lk-transfer-frame { + width: 306px; padding: 6px 6px 0; box-sizing: border-box; + border: 1px solid #c3ced9; border-radius: 13px; + background: linear-gradient(145deg, #ffffffa6, #e7edf4b3); + box-shadow: inset 0 1px 0 #fff, inset 1px 0 0 #ffffffa6, 0 1px 2px #263d5320, 0 18px 28px -18px #263d5359; + color: #263645; font: 11px/17px var(--ss-font); +} +.lk-transfer-paper { + position: relative; padding: 18px 18px 16px; border: 1px solid #d4dce4; border-radius: 8px; + background: linear-gradient(#fff, #fdfefe); box-shadow: 0 1px 2px #2a3f5512, 0 4px 6px -5px #2a3f552b; +} +.lk-transfer-head { display: flex; align-items: center; gap: 7px; font-weight: 550; white-space: nowrap; } +.lk-transfer-symbol { display: grid; place-items: center; width: 24px; height: 24px; flex: none; border: 1px solid #d8e2ec; border-radius: 6px; background: linear-gradient(#fff, #f0f4f8); box-shadow: inset 0 1px 0 #fff, 0 1px 1px #2233440a; } +.lk-transfer-symbol svg { width: 17px; height: 17px; fill: none; stroke: #60788e; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; } +.lk-transfer-id { margin-left: auto; color: #8794a1; font: 10px/17px var(--ss-mono); font-weight: 400; } +.lk-transfer-amount { display: grid; gap: 4px; margin: 24px 0 22px; color: #7c8995; } +.lk-transfer-amount strong { font-size: 35px; line-height: 42px; font-weight: 550; letter-spacing: -1.4px; color: #233647; font-variant-numeric: tabular-nums; } +.lk-transfer-amount strong span { color: #91a0ae; } +.lk-transfer-details { display: grid; gap: 9px; margin: 0; padding-top: 15px; border-top: 1px solid #e9edf0; } +.lk-transfer-details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.lk-transfer-details dt { color: #7c8995; } +.lk-transfer-details dd { margin: 0; font-weight: 500; } +.lk-transfer-details dd span { margin-left: 5px; color: #8794a1; font-weight: 400; } +.lk-transfer-status { display: flex; align-items: center; gap: 6px; padding: 12px 11px 13px; color: #557461; text-shadow: 0 1px 0 #ffffffb3; } +.lk-transfer-check { display: grid; place-items: center; width: 16px; height: 16px; border: 1px solid #b7cdbc; border-radius: 50%; background: linear-gradient(#f0f8f1, #dceadd); box-shadow: inset 0 1px 0 #fff, 0 1px 1px #48664f0f; } +.lk-transfer-check svg { width: 13px; height: 13px; fill: none; stroke: #62846a; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.lk-transfer-date { margin-left: auto; color: #8794a1; } +.lk-pair .ss-stage[data-layout="compact"] .lk-transfer-frame { width: 256px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-transfer-paper { padding-inline: 14px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-transfer-id { display: none; } diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.tsx b/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.tsx new file mode 100644 index 0000000..6809fee --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/skeuomorphic.tsx @@ -0,0 +1,21 @@ +import './skeuomorphic.css' + +/** Dimensional software UI: a paper receipt inside a lightly raised frame. + * Material supports the hierarchy; the information leads. */ +export function DimensionalLook() { + return
+
+
+ + Bank transferTR–1048 +
+
Amount sent$2,400.00USD · No transfer fee
+
+
From
Operating •• 1842
+
To
Avery Studio
+
Reference
October services
+
+
+
Transfer completeOct 24
+
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/studies.css b/skills/imported/benjitaylor/stageshow/examples/looks/studies.css new file mode 100644 index 0000000..27706e0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/studies.css @@ -0,0 +1,107 @@ +/* Each product owns its hierarchy as well as its tokens. All five studies + use their native 358px canvas on a 390px host and reflow at 288px. */ +.lk-pair :is(.look-console, .look-finance, .look-canvas, .look-messages, .look-research) .ss-stage { display: grid; place-items: center; } +.lk-product { position: relative; width: 318px; overflow: hidden; border: 1px solid var(--ss-line); border-radius: var(--ss-radius-window); background: var(--ss-surface); color: var(--ss-text-strong); box-shadow: var(--ss-shadow-ground); font: 12px/1.5 var(--ss-font); } +.lk-product strong { font-weight: 550; } +.lk-product-head { display: flex; align-items: center; gap: 9px; min-height: 45px; padding: 12px 16px; background: var(--ss-chrome); border-bottom: 1px solid var(--ss-line-faint); } +.lk-product-head > :last-child { margin-left: auto; } +.lk-product-head .ss-icon { flex: none; } + +.look-console .ss-stage { --ss-well: #e7e8eb; --ss-canvas: #15171b; --ss-surface: #191b20; --ss-chrome: #1d2026; --ss-line: #32363e; --ss-line-faint: #2b2f36; --ss-text: #a6aab4; --ss-text-strong: #eceef2; --ss-text-faint: #858b97; --ss-radius-window: 10px; --ss-accent: #a6c6b4; --ss-control: 28px; } +.lk-console-command { color: #a6aab4; font: 13px/18px var(--ss-mono); } +.lk-console-env { color: #a6aab4; font-size: 10px; padding: 2px 6px; border: 1px solid var(--ss-line); border-radius: 4px; } +.lk-console-body { padding: 18px 16px 0; } +.lk-console-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.lk-console-title > strong { font-size: 15px; } +.lk-console-title > span { display: flex; align-items: center; gap: 5px; color: #a6c6b4; font-size: 10px; } +.lk-console-title i { width: 4px; height: 4px; border-radius: 50%; background: currentColor; } +.lk-console-commit { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: var(--ss-text); font-size: 10px; } +.lk-console-commit code { font: 10px/16px var(--ss-mono); color: #c6cad3; } +.lk-console-log { display: grid; gap: 12px; margin-top: 24px; font-size: 11px; line-height: 17px; color: #c6cad3; } +.lk-console-log > div { display: flex; gap: 8px; align-items: center; } +.lk-console-log .ss-icon { color: #a6c6b4; } +.lk-console-log > div > span:last-child { margin-left: auto; color: var(--ss-text-faint); font-variant-numeric: tabular-nums; } +.lk-console-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 20px; padding: 12px 0; border-top: 1px solid var(--ss-line-faint); font-size: 10px; color: var(--ss-text); } +.lk-console-footer > span:last-child { display: flex; align-items: center; gap: 5px; color: #dce0e8; } + +.look-finance .ss-stage { --ss-well: #eaf0f2; --ss-surface: #fff; --ss-chrome: #fff; --ss-line: #e2e9ec; --ss-line-faint: #edf1f3; --ss-text: #6a7780; --ss-text-strong: #25343c; --ss-accent: #317c72; --ss-radius-window: 8px; --ss-radius-md: 5px; --ss-control: 32px; } +.lk-finance-period { display: flex; align-items: center; gap: 6px; color: var(--ss-text); font-size: 10px; } +.lk-finance-body { padding: 18px 20px 14px; } +.lk-finance-label { color: var(--ss-text); font-size: 11px; } +.lk-finance-balance { margin-top: 3px; font-size: 29px; line-height: 38px; font-weight: 550; letter-spacing: -1px; font-variant-numeric: tabular-nums; } +.lk-finance-balance > span { color: #8c989e; font-size: 22px; } +.lk-finance-trend { display: flex; align-items: center; gap: 5px; margin-top: 3px; font-size: 10px; color: #317c72; } +.lk-finance-trend > span { color: var(--ss-text); } +.lk-finance-chart { display: block; width: 100%; height: 60px; margin: 13px 0 9px; overflow: visible; } +.lk-finance-grid { fill: none; stroke: #edf1f3; stroke-width: 1; } +.lk-finance-area { fill: #317c72; opacity: .045; } +.lk-finance-line { fill: none; stroke: #317c72; stroke-width: 1.7; stroke-linecap: round; } +.lk-finance-account { display: flex; align-items: center; gap: 8px; min-height: 43px; border-top: 1px solid var(--ss-line-faint); font-size: 11px; } +.lk-finance-account-icon { display: grid; place-items: center; width: 25px; height: 25px; background: #f1f5f6; border-radius: 5px; color: #6b7d84; } +.lk-finance-account > span:nth-child(2) { display: flex; align-items: center; gap: 6px; } +.lk-finance-account > span:nth-child(2) > span { color: var(--ss-text); font-size: 10px; } +.lk-finance-account > strong { margin-left: auto; font-variant-numeric: tabular-nums; } + +.look-canvas .ss-stage { --ss-well: #eaecf1; --ss-surface: #f3f4f7; --ss-chrome: #fff; --ss-line: #dfe2e8; --ss-line-faint: #e9ebf0; --ss-text: #707887; --ss-text-strong: #303849; --ss-accent: #5579e3; --ss-radius-window: 11px; --ss-radius-md: 6px; --ss-control: 30px; } +.lk-canvas .lk-product-head > span { display: flex; align-items: center; gap: 5px; font-size: 10px; color: var(--ss-text); } +.lk-canvas-workspace { position: relative; height: 270px; background-image: radial-gradient(#d5d9e2 .7px, transparent .7px); background-size: 12px 12px; } +.lk-canvas-tools { position: absolute; top: 17px; left: 12px; display: grid; gap: 3px; padding: 4px; border: 1px solid #e1e4eb; border-radius: 7px; background: #fff; box-shadow: 0 2px 4px #27344f06; } +.lk-canvas-tools > span { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 4px; color: #737d8d; } +.lk-canvas-tools > .lk-canvas-tool-selected { background: #edf2ff; color: #4b6fda; } +.lk-canvas-tools svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linejoin: round; } +.lk-canvas-tools .lk-canvas-tool-selected svg { fill: currentColor; stroke: none; } +.lk-canvas-type { font-size: 15px; font-weight: 500; } +.lk-canvas-art { position: absolute; left: 89px; top: 22px; width: 152px; height: 184px; background: #2e55cc; outline: 1px solid #5579e3; outline-offset: 3px; color: #fff; box-shadow: 0 3px 12px #24439a18; } +.lk-canvas-art-title { position: absolute; top: 16px; left: 15px; font-size: 16px; line-height: 1.25; font-weight: 550; letter-spacing: -.35px; } +.lk-canvas-shape { position: absolute; inset: auto 0 22px; width: 152px; height: 100px; fill: #9ab8ff; } +.lk-canvas-shape > path:last-child { fill: #d5e1ff; } +.lk-canvas-art-footer { position: absolute; left: 15px; bottom: 12px; font-size: 10px; line-height: 12px; letter-spacing: .6px; color: #c3d3ff; } +.lk-canvas-art > i { position: absolute; width: 5px; height: 5px; background: #fff; border: 1px solid #5579e3; } +.lk-canvas-art > i:nth-of-type(1) { left: -6px; top: -6px; } +.lk-canvas-art > i:nth-of-type(2) { right: -6px; top: -6px; } +.lk-canvas-art > i:nth-of-type(3) { left: -6px; bottom: -6px; } +.lk-canvas-art > i:nth-of-type(4) { right: -6px; bottom: -6px; } +.lk-canvas-size { position: absolute; top: 214px; left: 89px; width: 152px; text-align: center; font-size: 10px; color: #76849f; } +.lk-canvas-properties { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 15px; background: #fff; border-top: 1px solid var(--ss-line-faint); color: var(--ss-text); font-size: 10px; } +.lk-canvas-properties > span:first-child { display: flex; align-items: center; gap: 6px; } +.lk-canvas-properties i { width: 11px; height: 11px; background: #2e55cc; border-radius: 2px; } +.lk-canvas-properties > strong { margin-left: auto; color: var(--ss-text-strong); font-size: 10px; font-weight: 450; } + +.look-messages .ss-stage { --ss-well: #eef0f6; --ss-surface: #fff; --ss-chrome: #fff; --ss-line: #e5e7ee; --ss-line-faint: #f0f1f5; --ss-text: #7b7f8f; --ss-text-strong: #353847; --ss-accent: #6263b8; --ss-radius-window: 16px; --ss-radius-md: 12px; --ss-control: 38px; } +.lk-messages .lk-product-head { min-height: 56px; } +.lk-message-avatar { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 10px; background: #e9e8f3; color: #74719f; font-size: 10px; font-weight: 550; } +.lk-message-person { display: grid; gap: 1px; } +.lk-message-person > span { color: var(--ss-text); font-size: 10px; } +.lk-messages .lk-product-head > .ss-icon { color: var(--ss-text); } +.lk-message-thread { display: flex; flex-direction: column; align-items: flex-start; padding: 10px 16px 0; } +.lk-message-time { align-self: center; margin-bottom: 8px; color: #9397a5; font-size: 10px; } +.lk-message-in { padding: 9px 12px; background: #f2f3f7; border-radius: 12px 12px 12px 4px; font-size: 11px; line-height: 17px; } +.lk-message-file { display: flex; align-items: center; gap: 8px; margin-top: 5px; padding: 7px 11px; border: 1px solid #eceef3; border-radius: 4px 12px 12px 12px; } +.lk-message-file > span:first-child { display: grid; place-items: center; width: 25px; height: 29px; border-radius: 5px; background: #f2f3f7; color: #9295a7; } +.lk-message-file > span:last-child { display: grid; gap: 1px; } +.lk-message-file strong { font-size: 10px; } +.lk-message-file small { font-size: 10px; color: var(--ss-text); } +.lk-message-out { align-self: flex-end; margin-top: 10px; padding: 9px 12px; border-radius: 12px 12px 4px 12px; background: #6263b8; color: #fff; font-size: 11px; line-height: 17px; } +.lk-message-read { display: flex; align-items: center; gap: 3px; align-self: flex-end; margin-top: 4px; font-size: 10px; color: #9195a3; } +.lk-message-composer { display: flex; align-items: center; gap: 9px; margin: 10px 14px 12px; min-height: 36px; padding: 7px 9px; border: 1px solid #e8eaf0; border-radius: 10px; color: #9297a5; font-size: 11px; } +.lk-message-send { display: grid; place-items: center; width: 21px; height: 21px; margin-left: auto; border-radius: 6px; background: #f0f1f6; color: #9095a4; } + +.look-research .ss-stage { --ss-well: #eef0f0; --ss-surface: #fff; --ss-chrome: #fafbfb; --ss-line: #e1e6e6; --ss-line-faint: #edf0f0; --ss-text: #798282; --ss-text-strong: #303b3b; --ss-accent: #526b71; --ss-radius-window: 5px; --ss-radius-md: 3px; --ss-control: 28px; } +.lk-research .lk-product-head { min-height: 39px; padding: 10px 14px; gap: 6px; font-size: 10px; color: var(--ss-text); } +.lk-research-body { padding: 23px 23px 0; } +.lk-research-kicker { display: flex; align-items: center; justify-content: space-between; color: #899595; font-size: 10px; letter-spacing: .75px; } +.lk-research-kicker > span { font: 10px/12px var(--ss-mono); letter-spacing: 0; } +.lk-research-title { margin: 12px 0 9px; font-size: 25px; line-height: 1.15; font-weight: 550; letter-spacing: -.65px; } +.lk-research-body p { margin: 0; color: var(--ss-text); font-size: 11px; line-height: 18px; } +.lk-research-note { display: flex; gap: 10px; margin-top: 19px; padding-top: 15px; border-top: 1px solid var(--ss-line-faint); } +.lk-research-note > span { margin-top: 1px; font: 10px/16px var(--ss-mono); color: #9aa3a3; } +.lk-research-note strong { font-size: 11px; } +.lk-research-note p { font-size: 10px; margin-top: 3px; } +.lk-research-source { display: flex; align-items: center; justify-content: space-between; margin-top: 19px; padding: 11px 0 13px; border-top: 1px solid var(--ss-line-faint); color: #8b9595; font-size: 10px; } +.lk-research-source > span:last-child { display: flex; align-items: center; gap: 5px; } + +.lk-pair .ss-stage[data-layout="compact"] .lk-product { width: 256px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-finance-body { padding-inline: 16px; } +.lk-pair .ss-stage[data-layout="compact"] .lk-finance-account-icon { flex: none; } +.lk-pair .ss-stage[data-layout="compact"] .lk-canvas-art, +.lk-pair .ss-stage[data-layout="compact"] .lk-canvas-size { left: 70px; } diff --git a/skills/imported/benjitaylor/stageshow/examples/looks/studies.tsx b/skills/imported/benjitaylor/stageshow/examples/looks/studies.tsx new file mode 100644 index 0000000..a5ffb59 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/looks/studies.tsx @@ -0,0 +1,82 @@ +import { Icon } from '../../kit' +import './studies.css' + +// These are quiet, static product studies. Their anatomy changes with the +// product; tokens alone cannot turn an invitation form into a finance tool. +export function ConsoleLook() { + return
+
>_Deploymentsproduction
+
+
account-webReady
+
8f2c19aUpdate account settings
+
+
Build application5s
+
Run checks2s
+
Publish2s
+
+
Built in 9 secondsView deployment
+
+
+} + +export function FinanceLook() { + return
+
OverviewThis month
+
+
Total balance
+
$48,250.00
+
+4.8% from last month
+ + + + + +
Operating•• 4829$36,250.00
+
Reserve•• 9012$12,000.00
+
+
+} + +export function CanvasLook() { + return
+
Untitled canvas100%
+
+ +
+
A new
perspective.
+ + FORM / 01 + +
+ 152 × 184 +
Fill#2E55CC100%
+
+
+} + +export function MessagesLook() { + return
+
ALAlex LeeDesign team
+
+
Today, 10:42
+
The new cover is ready.
What do you think?
+
Cover exploration.pdf2 pages · 1.4 MB
+
This is the one. Let’s share it.
+
Seen
+
+
Write a message…
+
+} + +export function ResearchLook() { + return
+
Research / Field notes
+
+
PRODUCT RESEARCH 06
+
Make room
for the first idea.
+

The best tools get out of the way.
Start with space, then add structure.

+
01
A quieter starting point

Less setup. More time to explore.

+
Interview notes4 sources
+
+
+} diff --git a/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx b/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx new file mode 100644 index 0000000..89a2f0e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx @@ -0,0 +1,64 @@ +import { Badge, Bar, Stage, Text } from '../../kit' +import './many.css' + +// Many of one: a grid of the same small thing, one of them in focus. Here, +// preview deployments, one per branch. A still. +// +// What makes repetition read as an image, not a wall: +// - Every tile is the same anatomy at the same size: a thumbnail, a line, a +// status. The sameness is the point; vary only the bar lengths. +// - The grid runs off every edge, so it reads as going on. Every edge cuts a +// tile by a third or more, never to a sliver. The focus tile sits off +// center, a column right of the middle: centered, the outer columns would be +// slivers. +// - The focus tile is the only one with words and color. It stays in its +// cell, the same size, and comes forward by depth: the hero shadow and a +// ring in the accent. The others are quiet, not faded or blurred. +// +// Real strings: the focus tile's branch, its commit and its status. + +const COLS = 5 +const ROWS = 4 +const FOCUS = { col: 2, row: 1 } + +const W = [[96, 64], [80, 52], [104, 70], [72, 58], [90, 46]] + +export default function ManyOfOne() { + return ( + + {() => ( +
+ {Array.from({ length: ROWS * COLS }, (_, i) => { + const col = i % COLS + const row = Math.floor(i / COLS) + const focus = col === FOCUS.col && row === FOCUS.row + const [a, b] = W[(i * 3) % W.length]! + return focus ? ( +
+
+ + + +
+
+ pricing-copy + Ready +
+ New annual plan wording +
+ ) : ( +
+
+
+ + +
+ +
+ ) + })} +
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css b/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css new file mode 100644 index 0000000..dc4b2c6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css @@ -0,0 +1,88 @@ +.mo .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Five columns of 184 with 16 between, offset up and left so every edge + cuts a tile by a third or more */ +.mo-grid { + position: absolute; + top: -78px; + left: -116px; + display: grid; + grid-template-columns: repeat(5, 184px); + gap: 16px; +} + +.mo-tile { + display: grid; + gap: 8px; + padding: 8px 8px 12px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-card); +} + +.mo-tile[data-focus] { + position: relative; + z-index: 1; + box-shadow: var(--ss-shadow-hero), 0 0 0 1.5px var(--ss-accent); +} + +.mo-thumb { + height: 92px; + border-radius: 6px; + background: var(--ss-ink-1); +} + +/* The focus tile's thumbnail is a tiny page: a heading block and two lines */ +.mo-thumb--focus { + display: grid; + align-content: start; + gap: 6px; + padding: 14px 12px; + background: linear-gradient(180deg, var(--ss-accent-soft), var(--ss-surface)); +} + +.mo-thumb__head { + width: 70%; + height: 10px; + border-radius: 5px; + background: var(--ss-accent); +} + +.mo-thumb__line { + width: 86%; + height: 6px; + border-radius: 3px; + background: var(--ss-ink-3); +} + +.mo-thumb__line--short { + width: 54%; +} + +.mo-meta { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 20px; + padding: 0 2px; +} + +.mo-branch { + color: var(--ss-text-strong); + font-size: 12px; + font-weight: 600; +} + +.mo-commit { + padding: 0 2px; + color: var(--ss-text); + font-size: 11px; +} + +/* Narrow: the same grid, shifted so the focus tile sits in the middle */ +.mo .ss-stage[data-layout='compact'] .mo-grid { + left: -321px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx b/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx new file mode 100644 index 0000000..70a9b82 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx @@ -0,0 +1,90 @@ +import { Stage, Phone, Thread, Bubble, Stamp, Receipt, type Act, Avatar } from '../../kit' +import './messages.css' + +// A conversation at true iOS size: your reply is typed into the composer and +// sent, the bubble forming out of the field's words; the receipt turns to +// Read, they start typing, and the dots grow into their reply. The new +// conversation holds once complete; the host can explicitly replay it. + +type S = { draft: string; sent: boolean; delivered: boolean; read: boolean; typing: boolean; reply: boolean } +const initial: S = { draft: '', sent: false, delivered: false, read: false, typing: false, reply: false } +// Reduced motion shows the whole exchange +const poster: S = { ...initial, sent: true, delivered: true, read: true, reply: true } +const MINE = 'Yes! See you at 7' + +async function script(a: Act) { + await a.wait(700) + await a.click('compose') + await a.type('draft', MINE, { interval: 60 }) + await a.wait(450) + await a.click('send') + // Clear the field in the same update that adds the bubble: the words lift out of it + await a.set({ sent: true, draft: '' }) + await a.wait(600) + await a.set({ delivered: true }) + await a.wait(900) + await a.set({ read: true }) + await a.wait(700) + await a.set({ typing: true }) + await a.wait(1600) + await a.set({ typing: false, reply: true }) + await a.wait(2600) +} + +function ArrowUp() { + return ( + + + + ) +} + +export default function Messages() { + return ( + + {(s) => ( + +
+ + Maya +
+ + Today 9:38 + Are we still on for tonight? + I booked the place on 5th + {s.sent && ( + + {MINE} + + )} + {s.read ? 'Read 9:41' : 'Delivered'} + {(s.typing || s.reply) && ( + + Perfect, I'll grab us a table by the window + + )} + +
+ + + + + + + + {s.draft} + {s.draft && } + + + iMessage + + + + + +
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/messages/messages.css b/skills/imported/benjitaylor/stageshow/examples/messages/messages.css new file mode 100644 index 0000000..b460ccd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/messages/messages.css @@ -0,0 +1,128 @@ +/* All rules belong to this example, including its responsive variants. */ +.ms { + & .ss-stage { + overflow: hidden; + background: var(--ss-well); + } + + /* A close crop of the conversation: the top of the phone runs off-stage, + while the composer and bottom edge remain in view. */ + .ms-phone { + position: absolute; + bottom: 16px; + left: 47px; + } + & .ss-stage[data-layout='compact'] .ms-phone { left: 16px; } + + .ms-phone .ss-phone__content { + display: flex; + flex-direction: column; + } + + .ms-head { + display: grid; + justify-items: center; + gap: 4px; + padding: 4px 0 10px; + border-bottom: 1px solid var(--ss-line); + } + + /* The kit's iOS contact avatar */ + .ms-avatar { + font-size: 22px; + } + + .ms-name { + font-size: 12px; + color: var(--ss-text-strong); + } + + .ms-thread { + flex: 1; + padding: 0 16px 10px; + } + + .ms-send[data-pressed] { scale: .9; } + + /* The composer: a round plus, then the field. The field's words are the + send's origin, so they sit in a target of their own */ + .ms-compose { + display: flex; + align-items: center; + gap: 10px; + margin: 0 14px 36px; + } + + .ms-plus { + display: grid; + flex: none; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: #e9e9eb; + color: #3c3c43; + } + + .ms-field { + position: relative; + display: flex; + flex: 1; + align-items: center; + min-width: 0; + min-height: 36px; + padding: 0 4px 0 14px; + border-radius: 18px; + background: #fff; + box-shadow: inset 0 0 0 1px #d1d1d6; + font-size: 17px; + letter-spacing: -0.01em; + } + + .ms-field__text { + flex: 1; + min-width: 0; + color: #000; + white-space: nowrap; + } + + .ms-caret { + display: inline-block; + width: 2px; + height: 20px; + margin-left: 1px; + border-radius: 1px; + background: #0a84ff; + vertical-align: -4px; + } + + .ms-field__ph { + position: absolute; + left: 14px; + color: #b0b0b5; + pointer-events: none; + } + + .ms-field__ph[data-hide] { + opacity: 0; + } + + .ms-send { + display: grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #0a84ff; + color: #fff; + opacity: 0; + transform: scale(0.5); + transition: scale .12s ease, opacity 0.18s ease, transform 0.25s cubic-bezier(0.3, 0.75, 0.1, 1); + } + + .ms-send[data-show] { + opacity: 1; + transform: none; + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/monochrome/index.tsx b/skills/imported/benjitaylor/stageshow/examples/monochrome/index.tsx new file mode 100644 index 0000000..2eac469 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/monochrome/index.tsx @@ -0,0 +1,52 @@ +import { Bar, Stage, Text, Window } from '../../kit' +import './mono.css' + +// Monochrome customers: three customers' sites side by side in grayscale, +// fading out at the foot. A still, for a "built on" or showcase section. +// +// What makes a wall of other people's products read as one picture: +// - One treatment for all of them: grayscale and a little lift in contrast. +// Their brand colors would fight each other and the page's own accent. +// - Same height, same top, one gap, every site whole across: each has a real +// headline, and the stage may only cut filler. It is the row of windows +// layout, quiet: no focus, no hero shadow. +// - Fade at the foot with a mask (framing.md's fade crop), over filler, so +// the row sinks into the page instead of stopping at a line. +// - Each site has one real line, its own headline, so they read as +// different products; everything else is placeholder at the right sizes. +// +// Real strings: the three URLs and headlines. + +const SITES = [ + { url: 'lumen.studio', head: 'Light for small rooms', hue: '#d97706', tone: 'warm' }, + { url: 'halvorsen.co', head: 'Accounting, without the paperwork', hue: '#2563eb', tone: 'cool' }, + { url: 'tidepool.app', head: 'Swim lessons, booked in a tap', hue: '#059669', tone: 'fresh' }, +] + +export default function Monochrome() { + return ( + + {() => ( +
+
+ {SITES.map((site) => ( + +
+
+ + + +
+ {site.head} + + +
+
+ + ))} +
+
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/monochrome/mono.css b/skills/imported/benjitaylor/stageshow/examples/monochrome/mono.css new file mode 100644 index 0000000..1b4151b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/monochrome/mono.css @@ -0,0 +1,98 @@ +.mn .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The fade: a mask on a wrapper, so the windows keep their shadows and the + row dissolves into whatever ground it sits on */ +.mn-fade { + position: absolute; + inset: 0; + -webkit-mask-image: linear-gradient(#000 55%, transparent 96%); + mask-image: linear-gradient(#000 55%, transparent 96%); +} + +/* Three at one height and top, whole across, centered */ +.mn-row { + position: absolute; + top: 36px; + left: 50%; + display: grid; + grid-template-columns: repeat(3, 212px); + gap: 20px; + translate: -50% 0; +} + +.mn-win { + height: 320px; + /* One treatment for every customer */ + filter: grayscale(1) contrast(1.05); +} + +.mn-site { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 10px; + height: 100%; + padding: 14px 18px; + background: var(--ss-surface); +} + +.mn-nav { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.mn-head { + white-space: normal; + text-wrap: balance; + color: var(--ss-text-strong); + font-size: 16px; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.01em; +} + +.mn-cta { + width: 84px; + height: 26px; + margin-top: 4px; + border-radius: 7px; + background: var(--mn-hue); +} + +.mn-hero { + height: 90px; + margin-top: 8px; + border-radius: 8px; + background: linear-gradient(135deg, var(--mn-hue), color-mix(in srgb, var(--mn-hue) 40%, #fff)); +} + +/* Narrow: two sites, both whole, the third left out */ +.mn .ss-stage[data-layout='compact'] .mn-row { + grid-template-columns: repeat(2, 155px); + gap: 16px; +} + +.mn .ss-stage[data-layout='compact'] .mn-win:nth-child(3) { + display: none; +} + +.mn .ss-stage[data-layout='compact'] .mn-site { + padding: 12px 14px; +} + +.mn .ss-stage[data-layout='compact'] .mn-head { + font-size: 14px; +} + +.mn .ss-stage[data-layout='compact'] .mn-head { + font-size: 13px; +} + +.mn-site > .ss-bar { + max-width: 100%; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/notion/index.tsx b/skills/imported/benjitaylor/stageshow/examples/notion/index.tsx new file mode 100644 index 0000000..895fbc9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/notion/index.tsx @@ -0,0 +1,321 @@ +import { + Check as Tick, + ChevronDown, + ChevronsRight, + Ellipsis, + FileText, + GripVertical, + Heading1, + Heading2, + Heading3, + House, + Inbox, + List, + ListOrdered, + Plus, + Search, + SquareCheck, + Star, + Type, +} from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { + Bar, + Menu, + Popover, + Stage, + Text, + Window, + type Act, + type Layout, +} from "../../kit"; +import "./notion.css"; + +// Notion's slash command. Beats: click into an empty line, press "/", the +// block menu opens, pick "To-do list", type a task, tick it off. The finished +// task stays checked until the host's Replay starts on the empty line. +// Real words: the page title, the placeholder, the menu, the task. + +type S = { + focus: boolean; + slash: boolean; + menu: boolean; + active: number | undefined; + todo: boolean; + task: string; + typing: boolean; + done: boolean; +}; + +const initial: S = { + focus: false, + slash: false, + menu: false, + active: 0, + todo: false, + task: "", + typing: false, + done: false, +}; + +const TITLE = "Launch plan"; +const TASK = "Send launch email"; + +type Item = { label: string; hint: string; icon: LucideIcon }; +const ITEMS: Item[] = [ + { label: "Text", hint: "", icon: Type }, + { label: "Heading 1", hint: "#", icon: Heading1 }, + { label: "Heading 2", hint: "##", icon: Heading2 }, + { label: "Heading 3", hint: "###", icon: Heading3 }, + { label: "Bulleted list", hint: "-", icon: List }, + { label: "Numbered list", hint: "1.", icon: ListOrdered }, + { label: "To-do list", hint: "[]", icon: SquareCheck }, +]; +const TODO = ITEMS.findIndex((i) => i.label === "To-do list"); + +async function script(a: Act) { + await a.wait(500); + + // Click into the empty line under the paragraph + await a.click({ at: "line", x: 0.72, y: 0.5 }); + await a.set({ focus: true }); + await a.wait(600); + + // "/" opens the block menu, its first row selected + a.mark("slash"); + await a.key("/"); + await a.set({ slash: true }); + await a.wait(90); + await a.set({ menu: true }); + await a.wait(800); + + // The pointer takes over the selection, then runs down to To-do list + await a.move({ at: "item-0", x: 0.62, y: 0.55 }); + await a.set({ active: undefined }); + await a.wait(200); + await a.move({ at: `item-${TODO}`, x: 0.62, y: 0.6 }); + await a.wait(350); + a.mark("pick"); + await a.click(); + await a.set({ menu: false, todo: true }); + await a.wait(650); + + // Type the task + await a.set({ typing: true }); + await a.type("task", TASK, { interval: 65 }); + await a.set({ typing: false }); + await a.wait(500); + + // Tick it off + await a.click({ at: "check", x: 0.72, y: 0.78 }); + a.mark("tick"); + await a.set({ done: true, focus: false }); + await a.wait(2200); + + // Step away and keep the completed task visible. Replay is the reset. + await a.move({ at: "rest", x: 0.5, y: 0.5 }); + a.mark("done"); +} + +function Sidebar() { + return ( +
+
+ B + + +
+
+ {[ + { Icon: Search, w: 40 }, + { Icon: House, w: 34 }, + { Icon: Inbox, w: 38 }, + ].map(({ Icon, w }, i) => ( +
+ + +
+ ))} +
+
+ +
+
+ {[62, 0, 48, 70, 54].map((w, i) => ( +
+ + {w === 0 ? ( + {TITLE} + ) : ( + + )} +
+ ))} +
+
+ ); +} + +function Crumb({ compact }: { compact: boolean }) { + return ( + + {compact && ( + + )} + + {TITLE} + + ); +} + +function Tools() { + return ( + + Share + + + + ); +} + +function Page({ s }: { s: S }) { + return ( +
+
{TITLE}
+
+ +
+
+ +
+ + {/* The line the story happens on: a text line that becomes a to-do */} +
+ + + + + +
+ {s.slash && /} + + + Press ‘/’ for commands + +
+ +
+ + + + + {s.task} + + + To-do + + +
+ + +
Basic blocks
+ `item-${i}`)} + items={ITEMS.map(({ label, hint, icon: Icon }) => ( + <> + + + + {label} + {hint} + + ))} + /> + +
+ +
+ +
+
+ +
+
+ +
+
+ ); +} + +function Scene({ s, layout }: { s: S; layout: Layout }) { + const compact = layout === "compact"; + const page = ; + return ( + <> + {compact ? ( + }> + {page} + + ) : ( + } + sidebarWidth={224} + lead={} + toolbar={} + > + {page} + + )} + {/* Where the pointer rests between loops, over the page's empty right side */} + + + ); +} + +export default function NotionSlash() { + return ( + + {(s, _rect, layout) => } + + ); +} diff --git a/skills/imported/benjitaylor/stageshow/examples/notion/notion.css b/skills/imported/benjitaylor/stageshow/examples/notion/notion.css new file mode 100644 index 0000000..5e84398 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/notion/notion.css @@ -0,0 +1,417 @@ +/* Notion, light. One warm neutral family (Notion's rgb(55,53,47) ink), its + blue for the thing acted on. Gutter: 40px wide, 20px compact. */ + +.nt .ss-stage { + --gutter: 40px; + --nt-ink: #37352f; + --nt-ink-2: #787774; + --nt-ink-3: #a5a4a0; + --nt-side: #f8f8f7; + + --ss-ink-1: rgba(55, 53, 47, 0.04); + --ss-ink-2: rgba(55, 53, 47, 0.06); + --ss-ink-3: rgba(55, 53, 47, 0.09); + --ss-ink-4: rgba(55, 53, 47, 0.13); + --ss-ink-5: rgba(55, 53, 47, 0.2); + --ss-line: rgba(55, 53, 47, 0.09); + --ss-line-faint: rgba(55, 53, 47, 0.05); + --ss-text: var(--nt-ink-2); + --ss-text-strong: var(--nt-ink); + --ss-text-faint: var(--nt-ink-3); + --ss-icon: #91918e; + --ss-icon-faint: #c3c2bf; + --ss-canvas: var(--nt-side); + --ss-well: #ecebe8; + --ss-font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif; + + overflow: hidden; + background: var(--ss-well); +} + +.nt .ss-stage[data-layout="compact"] { + --gutter: 20px; +} + +/* Pinned top-left, runs off the right (the page's empty margin) and the bottom */ +.nt-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + width: 820px; + height: 600px; + border-radius: 10px; + box-shadow: var(--ss-shadow-hero); +} + +.ss-stage[data-layout="compact"] .nt-window { + width: 460px; + height: 520px; + background: #fff; +} + +.ss-stage[data-layout="compact"] .nt-window > .ss-window__bar { + background: #fff; +} + +.nt-window .ss-window__main > .ss-window__bar, +.ss-stage[data-layout="compact"] .nt-window > .ss-window__bar { + border-bottom-color: transparent; +} + +.nt-ico { + flex-shrink: 0; + color: var(--ss-icon); +} + +.nt-ico-faint { + flex-shrink: 0; + color: var(--ss-icon-faint); +} + +/* ---- Sidebar ---- */ + +.nt-side { + display: flex; + flex-direction: column; + padding: 2px 8px; +} + +.nt-ws { + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 8px; + margin-bottom: 4px; +} + +.nt-ws__mark { + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 4px; + background: #e3e2df; + color: var(--nt-ink-2); + font-size: 11px; + font-weight: 600; +} + +.nt-nav { + display: flex; + flex-direction: column; + gap: 1px; +} + +.nt-nav__row { + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 8px; + border-radius: 6px; +} + +.nt-nav__row[data-active] { + background: rgba(55, 53, 47, 0.06); +} + +.nt-nav__label { + font-size: 14px; + font-weight: 500; + color: var(--nt-ink); +} + +.nt-section { + display: flex; + align-items: center; + height: 30px; + padding: 0 8px; + margin-top: 14px; +} + +/* ---- Top bar ---- */ + +.nt-crumb { + display: flex; + align-items: center; + gap: 6px; +} + +.ss-stage[data-layout="compact"] .nt-crumb { + margin-left: 12px; +} + +.nt-crumb__toggle { + margin-right: 6px; +} + +.nt-crumb__label { + font-size: 13px; + color: var(--nt-ink); +} + +.nt-tools { + display: flex; + align-items: center; + gap: 14px; +} + +.nt-share { + font-size: 13px; + color: var(--nt-ink); +} + +/* ---- Page ---- */ + +.nt-page { + padding: 34px 96px 0 96px; + color: var(--nt-ink); +} + +.ss-stage[data-layout="compact"] .nt-page { + padding: 26px 40px 0 52px; +} + +.nt-title { + margin: 0 0 12px; + font-size: 32px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.ss-stage[data-layout="compact"] .nt-title { + font-size: 28px; +} + +.nt-block { + display: flex; + align-items: center; + height: 30px; +} + +.nt-h2 { + height: 40px; + margin-top: 10px; +} + +.nt-line { + position: relative; + height: 30px; + font-size: 16px; + line-height: 24px; +} + +/* Notion's block handle: + and the drag dots, shown while the block is hovered */ +.nt-handle { + position: absolute; + top: 3px; + left: -46px; + display: flex; + gap: 2px; + color: var(--ss-icon-faint); + opacity: 0; + transition: opacity 0.2s ease; +} + +.nt-line[data-hover] .nt-handle { + opacity: 1; +} + +.nt-layer { + position: absolute; + inset: 0; + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.18s ease; +} + +.nt-layer[data-show] { + opacity: 1; +} + +.nt-slash { + white-space: pre; +} + +.nt-placeholder { + color: var(--nt-ink-3); + white-space: nowrap; + opacity: 0; + transition: opacity 0.12s ease; +} + +.nt-placeholder[data-show] { + opacity: 1; +} + +/* A text caret: solid while typing, blinking at rest */ +.nt-caret { + display: inline-block; + flex-shrink: 0; + width: 1.5px; + height: 20px; + margin: 0 1px 0 0; + background: var(--nt-ink); + opacity: 0; + vertical-align: middle; +} + +.nt-caret[data-show] { + animation: nt-blink 1.06s steps(1, end) infinite; +} + +.nt-caret[data-show][data-typing] { + opacity: 1; + animation: none; +} + +@keyframes nt-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ---- The to-do block ---- */ + +.nt-todo { + gap: 8px; + transform: translateY(0); + transition: opacity 0.22s ease, transform 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.nt-todo:not([data-show]) { + transition: opacity 0.35s ease, transform 0.5s cubic-bezier(0.45, 0, 0.1, 1); +} + +.nt-box { + position: relative; + display: grid; + flex-shrink: 0; + place-items: center; + width: 16px; + height: 16px; + margin: 0 2px 0 2px; + border-radius: 3px; + box-shadow: inset 0 0 0 1.5px var(--nt-ink); + color: #fff; + transform: scale(0.6); + transition: background-color 0.14s ease, box-shadow 0.14s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.nt-todo[data-show] .nt-box { + transform: none; +} + +.nt-box[data-pressed] { + transform: scale(0.92) !important; +} + +.nt-box svg { + opacity: 0; + transform: scale(0.5); + transition: opacity 0.12s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.nt-todo[data-done] .nt-box { + background: var(--ss-accent); + box-shadow: inset 0 0 0 1.5px var(--ss-accent); +} + +.nt-todo[data-done] .nt-box svg { + opacity: 1; + transform: none; +} + +.nt-todo__text { + display: flex; + align-items: center; + white-space: pre; + text-decoration: line-through; + text-decoration-color: transparent; + text-decoration-thickness: 1px; + transition: color 0.2s ease, text-decoration-color 0.2s ease; +} + +.nt-todo[data-done] .nt-todo__text { + color: var(--nt-ink-3); + text-decoration-color: #c3c2bf; +} + +/* ---- Slash menu ---- */ + +.nt-menu { + top: calc(100% + 4px); + left: -4px; + width: 300px; + padding: 6px 4px; + border-radius: 10px; + --ss-popover-bg: #fff; + --ss-popover-shadow: 0 0 0 1px rgba(55, 53, 47, 0.09), 0 3px 6px rgba(15, 15, 15, 0.06), 0 9px 24px rgba(15, 15, 15, 0.12); +} + +.ss-stage[data-layout="compact"] .nt-menu { + width: 264px; +} + +.nt-menu__head { + display: flex; + align-items: center; + height: 26px; + padding: 0 8px; + font-size: 12px; + font-weight: 500; + color: var(--nt-ink-2); +} + +.nt-menu .ss-menu__item { + display: flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 8px; + border-radius: 6px; + font-size: 14px; + color: var(--nt-ink); + transition: background-color 0.12s ease; +} + +.nt-menu .ss-menu__item[data-active], +.nt-menu .ss-menu__item[data-hover] { + background: rgba(55, 53, 47, 0.06); + transition-duration: 0s; +} + +.nt-menu__icon { + display: grid; + flex-shrink: 0; + place-items: center; + width: 20px; + height: 20px; + color: var(--nt-ink-2); +} + +.nt-menu__label { + flex: 1; +} + +.nt-menu__hint { + font-size: 12px; + color: var(--nt-ink-3); +} + +/* ---- Cursor rest ---- */ + +.nt-rest { + position: absolute; + top: 330px; + left: 560px; + width: 1px; + height: 1px; +} + +.ss-stage[data-layout="compact"] .nt-rest { + top: 300px; + left: 330px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/opposite-corners/corners.css b/skills/imported/benjitaylor/stageshow/examples/opposite-corners/corners.css new file mode 100644 index 0000000..95e151b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/opposite-corners/corners.css @@ -0,0 +1,47 @@ +.oc .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The art: a panel hung from the top-right corner, off the top and right + edges; the thread inside keeps clear of the cut */ +.oc-art { + position: absolute; + top: -32px; + right: -32px; + width: 316px; + height: 260px; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 0 52px 20px 20px; + border-radius: 16px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-card); +} + +/* The copy: bottom-left at the tile's full inset */ +.oc-copy { + position: absolute; + left: 24px; + bottom: 24px; + display: grid; + gap: 4px; + max-width: 220px; +} + +.oc-title { + color: var(--ss-text-strong); + font-size: 16px; + font-weight: 600; +} + +.oc-line { + color: var(--ss-text); + font-size: 13px; + line-height: 1.45; +} + +.oc .ss-stage[data-layout='compact'] .oc-art { + width: 296px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/opposite-corners/index.tsx b/skills/imported/benjitaylor/stageshow/examples/opposite-corners/index.tsx new file mode 100644 index 0000000..3dab839 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/opposite-corners/index.tsx @@ -0,0 +1,61 @@ +import { Bubble, Stage, Text, Thread, type Act } from '../../kit' +import './corners.css' + +// Copy and art in opposite corners: a bento tile whose words sit bottom-left +// and whose picture hangs from the top-right corner, off both those edges. +// Beats: the replies arrive one by one. Plays once; the still is the payoff. +// +// What makes the corners balance: +// - The copy takes one corner, the art the opposite one, and they never +// overlap: a clear diagonal of ground runs between them. +// - The art runs off the two edges of its corner (top and right), so it +// reads as part of something bigger; the copy keeps the tile's full inset +// (24px) from its two edges. The crop takes the panel the thread sits in, +// never a bubble's words: the bubbles keep a margin inside the visible +// part of the panel. +// - The art is the product's own surface at true size, not a shrunken +// screenshot: a thread of real bubbles, the newest nearest the copy. +// - One idea per tile: a title and a single line, no button. +// +// Real strings: the copy and the three messages. + +type S = { n: number } + +const MESSAGES = [ + { from: 'them', text: 'Is the Lisbon offsite still on?' }, + { from: 'me', text: 'Yes, flights are booked' }, + { from: 'them', text: 'Perfect, sending the agenda now' }, +] + +async function script(a: Act) { + await a.wait(500) + for (let i = 1; i <= MESSAGES.length; i++) { + a.put({ n: i }) + await a.wait(700) + } + await a.wait(2000) +} + +export default function OppositeCorners() { + return ( + + {(s) => ( + <> +
+ + {MESSAGES.slice(0, s.n).map((m) => ( + + {m.text} + + ))} + +
+
+ Replies in one place + Every channel lands in one inbox, threaded. +
+ + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx b/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx new file mode 100644 index 0000000..8a7981f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx @@ -0,0 +1,78 @@ +import { Badge, Bar, Row, Stage, Text, Window } from '../../kit' +import './overlap.css' + +// Overlapping windows: a checkout card in front of the dashboard it reports +// to. A still. The story is two views of one payment: the buyer's side in +// front, the merchant's behind. +// +// What makes an overlap read as placed, not dropped: +// - The front card crosses the back window's left edge and stops inside its +// sidebar, a clear strip of sidebar showing before the table: it covers +// filler, never the table. An edge that lands exactly on the sidebar's +// line reads as an accident (near-tangent); either clear it like this or +// overlap far enough to be plainly deliberate. +// - The front card is smaller and a level above: a stronger shadow, no +// chrome. Only one surface has chrome; two sets of traffic lights fight. +// - The back window is pinned top and runs off the bottom, ending before +// the stage's right edge so the payment's badge is whole; the card runs +// off nothing. The eye reads front to back: pay, then paid. +// - The payment in the card is the top row of the table behind it, the one +// real row in the merchant's list, so the two surfaces tell one event. +// +// Real strings: the card's product, price and button; the table's title, +// its top row and the badge. Everything else is placeholder. + +const SIDEBAR = 150 + +export default function Overlap() { + return ( + + {(_s, _rect, layout) => { + const side = layout === 'compact' ? 120 : SIDEBAR + return ( + <> + +
+ +
+ Payments + $49.00} + subtitle={{layout === 'compact' ? 'Pro plan' : 'Pro plan, maya@lumen.studio'}} + > + {/* Narrow, the shorter word keeps the amount whole */} + {layout === 'compact' ? 'Paid' : 'Succeeded'} + + {[132, 108, 146, 96, 120].map((w, i) => ( + } subtitle={}> + + + ))} +
+
+
+ +
+ Northwind Pro + + $49.00 per month + +
+ + +
+ Subscribe +
+ + ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css b/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css new file mode 100644 index 0000000..33c52d6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css @@ -0,0 +1,134 @@ +.ov .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The back window: pinned top, off the bottom, a gutter on the right */ +.ov-win { + position: absolute; + top: 32px; + left: 120px; + width: 480px; + height: 420px; +} + +.ov-app { + display: grid; + grid-template-columns: var(--ov-side) 1fr; + height: 100%; + background: var(--ss-surface); +} + +.ov-side { + display: grid; + align-content: start; + gap: 12px; + padding: 18px 16px; + border-right: 1px solid var(--ss-line); + background: var(--ss-well); +} + +.ov-main { + display: grid; + align-content: start; + padding: 18px 24px; +} + +.ov-title { + margin-bottom: 10px; + color: var(--ss-text-strong); + font-size: 15px; + font-weight: 600; +} + +.ov-row { + height: 52px; + border-radius: 0; + border-bottom: 1px solid var(--ss-line-faint); +} + +.ov-amount { + color: var(--ss-text-strong); + font-size: 12px; + font-weight: 600; +} + +.ov-who { + color: var(--ss-text); + font-size: 11px; +} + +/* The front card: a level above the window, on the stage's 32px gutter, its + right edge 24px short of the sidebar's line (120 + 150 = 270) so a strip + of sidebar shows */ +.ov-card { + position: absolute; + top: 112px; + left: 32px; + width: 214px; + display: grid; + gap: 4px; + padding: 18px 18px 20px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-satellite); +} + +.ov-product { + color: var(--ss-text); + font-size: 12px; + font-weight: 500; +} + +.ov-price { + margin-bottom: 12px; + color: var(--ss-text-strong); + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.ov-per { + color: var(--ss-text); + font-size: 12px; + font-weight: 400; + letter-spacing: 0; +} + +.ov-fields { + display: grid; + gap: 8px; +} + +.ov-pay { + display: grid; + place-items: center; + height: 36px; + margin-top: 12px; + border-radius: 8px; + background: var(--ss-accent); + color: #fff; + font-size: 13px; + font-weight: 600; +} + +/* Narrow: the window moves in, a narrower sidebar, and the card's right edge + stops 24px short of its line (90 + 120 = 210) */ +.ov .ss-stage[data-layout='compact'] .ov-win { + left: 90px; + width: 280px; +} + +.ov .ss-stage[data-layout='compact'] .ov-main { + padding: 18px 28px 18px 16px; +} + +.ov .ss-stage[data-layout='compact'] .ov-card { + left: 16px; + width: 170px; + padding: 16px 14px 16px; +} + +.ov .ss-stage[data-layout='compact'] .ov-price { + font-size: 19px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx new file mode 100644 index 0000000..5e761fb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx @@ -0,0 +1,110 @@ +import { Bar, Connector, Roll, Stage, Text, Window, type Act } from '../../kit' +import './panel.css' + +// A settings panel over the product it changes, with one annotation tying a +// setting to what it does. Beats: the cursor picks a new accent in the +// Branding panel; the checkout's Pay button takes the color; a leader draws +// from the accent row to the button. Plays once; the still is the payoff. +// +// What makes a panel-over-product picture read: +// - Two jobs, two weights. The checkout is the product, large, behind; the +// panel is the tool, smaller, in front, straddling the checkout's right +// edge over its empty margin, never over the form. +// - The panel and the thing it changes share one color change: the swatch, +// the hex and the button move together, so the cause is plain before the +// line arrives. +// - One leader, after the change: out of the panel's bottom edge under the +// chosen swatch, down through empty ground, then into the button's right +// edge, ending short of it. It never runs over the panel's or the form's +// content. It explains which setting did that; a line per row would be a +// diagram, not a picture. +// - Narrow, there is no clear path for a leader: the panel sits above the +// checkout, over its chrome only, and the shared color carries the story. +// +// Real strings: the merchant, the amount, Pay, the panel's title and its two +// color rows. + +type S = { accent: number; linked: boolean } + +const ACCENTS = [ + { hex: '#2563EB', name: 'blue' }, + { hex: '#7C3AED', name: 'violet' }, + { hex: '#059669', name: 'green' }, + { hex: '#EA580C', name: 'orange' }, +] + +const initial: S = { accent: 0, linked: false } +const poster: S = { accent: 1, linked: true } + +async function script(a: Act) { + await a.wait(1000) + a.mark('pick') + await a.click({ at: 'swatch-1', x: 0.5, y: 0.5 }) + a.put({ accent: 1 }) + await a.wait(500) + a.mark('link') + a.put({ linked: true }) + await a.move({ at: 'panel', x: 0.6, y: 1.15 }) + await a.wait(2600) +} + +export default function PanelAnnotation() { + return ( + + {(s, rect, layout) => { + const accent = ACCENTS[s.accent]!.hex + const panel = rect('panel') + const swatch = rect(`swatch-${s.accent}`) + const pay = rect('pay') + return ( + <> + +
+ Northwind + $60.00 +
+ + +
+ + +
+
+ Pay $60.00 +
+
+ +
+ Branding + How your brand shows on checkout +
+ Brand color + #111827 +
+
+ Accent color + + + {accent} + +
+
+ {ACCENTS.map((c, i) => ( + + ))} +
+
+ + +
+
+ + {layout !== 'compact' && panel.width > 0 && pay.width > 0 && ( + + )} + + ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css new file mode 100644 index 0000000..971bd42 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css @@ -0,0 +1,152 @@ +.pa .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The product: pinned top-left, off the bottom */ +.pa-win { + position: absolute; + top: 32px; + left: 32px; + width: 340px; + height: 460px; +} + +.pa-checkout { + display: grid; + gap: 6px; + padding: 24px 28px; + background: var(--ss-surface); + height: 100%; +} + +.pa-merchant { + color: var(--ss-text); + font-size: 12px; + font-weight: 500; +} + +.pa-amount { + margin-bottom: 12px; + color: var(--ss-text-strong); + font-size: 26px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.pa-fields { + display: grid; + gap: 10px; +} + +.pa-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.pa-pay { + display: grid; + place-items: center; + height: 38px; + margin-top: 14px; + border-radius: 8px; + background: var(--pa-accent); + color: #fff; + font-size: 13px; + font-weight: 600; + transition: background-color 0.45s ease; +} + +/* The tool: smaller, in front, straddling the checkout's right edge over its + margin; a level above, so a stronger shadow */ +.pa-panel { + position: absolute; + top: 84px; + left: 352px; + width: 248px; + display: grid; + gap: 2px; + padding: 16px 18px 14px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-satellite); +} + +.pa-title { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 600; +} + +.pa-hint { + margin-bottom: 8px; + color: var(--ss-text); + font-size: 11px; +} + +.pa-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 36px; + border-top: 1px solid var(--ss-line); +} + +.pa-label { + color: var(--ss-text-strong); + font-size: 12px; +} + +.pa-value { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 3px 8px 3px 4px; + border-radius: 6px; + box-shadow: 0 0 0 1px var(--ss-line); + color: var(--ss-text); + font: 500 11px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.pa-chip { + width: 16px; + height: 16px; + border-radius: 4px; + transition: background-color 0.45s ease; +} + +.pa-swatches { + display: flex; + gap: 8px; + padding: 2px 0 12px; +} + +.pa-swatch { + width: 22px; + height: 22px; + border-radius: 50%; + box-shadow: 0 0 0 2px var(--ss-surface), 0 0 0 2px transparent; + transition: box-shadow 0.25s ease; +} + +.pa-swatch[data-on] { + box-shadow: 0 0 0 2px var(--ss-surface), 0 0 0 3.5px var(--ss-text-strong); +} + +/* Narrow: the panel on top, the checkout under it full width with only its + chrome covered, so the form and Pay stay whole */ +.pa .ss-stage[data-layout='compact'] .pa-win { + top: 196px; + left: 16px; + width: 326px; +} + +.pa .ss-stage[data-layout='compact'] .pa-panel { + top: 16px; + left: 94px; +} + +/* The leader is annotation, not product: neutral ink, so it never reads as + the accent the panel just replaced */ +.pa .ss-connector { --ss-accent: var(--ss-text-strong); } diff --git a/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx b/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx new file mode 100644 index 0000000..fe61114 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx @@ -0,0 +1,136 @@ +import { Stage, Bar, Check, Count, Roll, Connector, Icon, Swap, type Act } from '../../kit' +import './pace.css' + +// A cursorless payout story played once as it scrolls into view. +// Things reveal, then move to make room for what comes next; groups stagger; +// a line ties the new card to the row it came from; the payoff is annotated. +// This scene uses long arrivals and overlapping beats. Other stories can +// need different timing; see references/choreography.md. + +type S = { step: number; sent: boolean } +const START: S = { step: 0, sent: false } + +const ROWS = [ + { amount: '$12,400.00', w: 54 }, + { amount: '$8,915.20', w: 46 }, + { amount: '$21,060.75', w: 60 }, + { amount: '$5,330.00', w: 40 }, +] + +const script = async (a: Act) => { + await a.wait(300) + a.mark('reveal') + a.put({ step: 1 }) // the balance rises in, centered, and counts up + await a.wait(700) + a.put({ step: 2 }) // payout rows stagger in under it + await a.wait(1500) + a.mark('move') + a.put({ step: 3 }) // it slides aside as the payout card arrives + await a.wait(1300) + a.put({ step: 4 }) // the line draws from the row to the card + await a.wait(1100) + a.mark('send') + a.put({ step: 5 }) // sending + await a.wait(1300) + a.put({ sent: true }) + await a.wait(500) + a.put({ step: 6 }) // the annotation lands +} + +export default function PayoutFlow() { + return ( + width={640} height={400} compact={{ below: 600, width: 358, height: 610 }} initial={START} play="once" poster={{ step: 6, sent: true }} script={script} className="sp"> + {(s, rect, layout) => { + const compact = layout === 'compact' + const row = rect('row-0') + const card = rect('instant') + return ( + <> +
+
= 1 || undefined} data-aside={s.step >= 3 || undefined}> + Available balance + = 1 ? 84210.5 : 0} duration={1400} format={money} /> + +
+ Payouts + +
+ {ROWS.map((r, i) => ( +
= 2 || undefined} + style={{ transitionDelay: s.step >= 2 ? `${i * 90}ms` : '0ms' }} + > + + + {r.amount} + {/* The payout's own row settles when it sends, closing the loop with the line */} + + {i === 0 ? {s.sent ? 'Paid' : 'Pending'} : 'Paid'} + +
+ ))} +
+ + {/* A full column beside the balance, top-aligned: no dead corners. + Its contents build in a stagger once it has landed. */} +
= 3 || undefined}> +
+ + + + Instant payout +
+
$12,400.00
+
+
+ To + + + Chase ···· 4821 + +
+
+ Fee + $124.00 +
+
+ {/* Each state is its own centered group, crossfading in place: nothing + ever re-centers or slides sideways between states */} +
+ + Pay out now + + {/* Sending and Sent are one group: one Check turns pending, then + fills and draws its tick. The label is a Swap, so its box + eases to the new word and the group re-centers with it: a + box held at "Sending" would leave "Sent" off center */} + = 5 || undefined}> + + {s.sent ? 'Sent' : 'Sending'} + +
+
+ + {row.width > 0 && card.width > 0 && ( + = 4} + from={{ x: compact ? row.x - 6 : row.x + row.width + 6, y: row.y + row.height / 2 }} + to={{ x: card.x, y: compact ? card.y + 24 : row.y + row.height / 2 }} + route={compact ? 'v' : 'straight'} + /> + )} + +
= 6 || undefined} style={{ left: card.x, top: card.y + card.height + 16 }}> + Arrives in about 30 minutes +
+ + ) + }} + + ) +} + +const money = (n: number) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` diff --git a/skills/imported/benjitaylor/stageshow/examples/payout-flow/pace.css b/skills/imported/benjitaylor/stageshow/examples/payout-flow/pace.css new file mode 100644 index 0000000..d6474c1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/payout-flow/pace.css @@ -0,0 +1,249 @@ +/* All rules belong to this example, including its responsive variants. */ +.sp { + & .ss-stage { + --ss-accent: #635bff; + --ss-accent-2: #e15bff; + --ss-accent-soft: rgba(99, 91, 255, 0.1); + /* A travel curve chosen for this payout story, not a universal preset. */ + --sp-ease: cubic-bezier(0.45, 0, 0.1, 1); + overflow: hidden; + border-radius: 14px; + background: #f6f8fb; + font: 12px/1.4 var(--ss-font); + color: var(--ss-text-strong); + } + + .sp-wash { + position: absolute; + inset: 0; + background: radial-gradient(50% 60% at 100% 0%, rgba(99, 91, 255, 0.16), transparent 70%); + } + + .sp-strong { font-weight: 600; } + .sp-muted { color: var(--ss-text); } + + /* Reveals centered, then slides aside to make room */ + .sp-balance { + position: absolute; + top: 40px; + left: 170px; + display: grid; + gap: 10px; + width: 300px; + height: 400px; + padding: 22px 22px 0; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-hero); + opacity: 0; + --sp-x: 0px; + --sp-y: 24px; + transform: translate(var(--sp-x), var(--sp-y)); + align-content: start; + /* A retained layer carries the type through both the rise and travel. */ + will-change: transform; + transition: opacity 0.9s ease, transform 1.2s var(--sp-ease); + } + + .sp-balance[data-in] { opacity: 1; --sp-y: 0px; } + .sp-balance[data-aside] { --sp-x: -130px; } + + .sp-total { + margin: 2px 0 2px; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + } + + .sp-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 14px; + padding-bottom: 6px; + border-bottom: 1px solid var(--ss-line); + } + + .sp-row { + display: grid; + grid-template-columns: 22px 1fr auto auto; + align-items: center; + gap: 10px; + height: 38px; + border-bottom: 1px solid var(--ss-line-faint); + position: relative; + transform: translateY(10px); + opacity: 0; + will-change: transform; + transition: opacity 0.7s ease, transform 0.9s var(--sp-ease); + } + + .sp-row[data-in] { opacity: 1; transform: translateY(0); transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1); } + + .sp-bank { + width: 22px; + height: 22px; + border-radius: 6px; + background: var(--ss-ink-1); + box-shadow: inset 0 0 0 1px var(--ss-line); + } + + .sp-amount { font-variant-numeric: tabular-nums; } + + .sp-paid { + padding: 2px 7px; + transition: background 0.4s ease, color 0.4s ease; + border-radius: 6px; + background: var(--ss-success-soft); + color: var(--ss-success-text); + font-size: 10.5px; + font-weight: 500; + } + + /* Arrives from the side it ends up on, a beat behind the balance moving. + One gutter from the balance, the same gutter to the stage edge. */ + .sp-instant { + position: absolute; + top: 40px; + left: 364px; + display: grid; + gap: 16px; + width: 236px; + padding: 20px; + border-radius: 12px; + background: var(--ss-raised); + box-shadow: var(--ss-shadow-satellite); + opacity: 0; + transform: translateX(32px); + will-change: transform; + transition: opacity 0.8s ease 0.2s, transform 1.1s var(--sp-ease) 0.2s; + } + + .sp-instant[data-in] { opacity: 1; transform: translateX(0); transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1); } + + /* Its contents follow it in, 70ms apart */ + .sp-instant > * { + position: relative; + transform: translateY(8px); + opacity: 0; + will-change: transform; + transition: opacity 0.6s ease, transform 0.9s var(--sp-ease); + transition-delay: calc(0.45s + var(--i) * 70ms); + } + + .sp-instant[data-in] > * { opacity: 1; transform: translateY(0); transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1); } + + .sp-instant__head { display: flex; align-items: center; gap: 10px; } + + .sp-instant__mark { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--ss-accent-soft); + color: var(--ss-accent); + } + + .sp-instant__amount { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; } + + .sp-fields { display: grid; } + + .sp-field { + display: flex; + align-items: center; + justify-content: space-between; + height: 36px; + border-top: 1px solid var(--ss-line-faint); + } + + .sp-dest { display: flex; align-items: center; gap: 8px; } + .sp-bank--sm { width: 18px; height: 18px; border-radius: 5px; } + + .sp-button { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 36px; + margin-top: 2px; + border-radius: 9px; + background: var(--ss-accent); + color: #fff; + font-weight: 500; + position: relative; + isolation: isolate; + } + + /* Any blend of purple and green passes through teal, so the change is quick + enough (about 200ms) that the in-between never registers */ + .sp-button::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: var(--ss-success, #22c55e); + opacity: 0; + transition: opacity 0.2s ease; + } + + .sp-button[data-sent]::before { opacity: 1; } + + .sp-button { display: grid; } + + .sp-state { + display: flex; + grid-area: 1 / 1; + align-items: center; + justify-self: center; + gap: 7px; + opacity: 0; + filter: blur(2px); + position: relative; + transform: translateY(4px); + will-change: transform; + transition: opacity 0.18s ease, filter 0.18s ease, transform 0.18s ease; + } + + /* The outgoing state is gone before the next arrives */ + .sp-state[data-on] { + opacity: 1; + filter: none; + transform: translateY(0); + transition: opacity 0.35s ease 0.12s, filter 0.35s ease 0.12s, transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) 0.12s; + } + + .sp-note { + position: absolute; + padding: 5px 9px; + border-radius: 7px; + background: var(--ss-text-strong); + color: var(--ss-surface); + font-size: 11px; + opacity: 0; + transform: translateY(8px); + will-change: transform; + transition: opacity 0.6s ease, transform 0.9s var(--sp-ease); + } + + .sp-note[data-in] { opacity: 1; transform: translateY(0); transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1); } + + /* The payout sits below its source on the phone; type remains at design size. */ + & .ss-stage[data-layout='compact'] .sp-balance { left: 16px; top: 24px; width: 326px; height: 640px; } + & .ss-stage[data-layout='compact'] .sp-balance[data-aside] { --sp-x: 0px; } + & .ss-stage[data-layout='compact'] .sp-instant { left: 40px; top: 302px; width: 302px; } + & .ss-stage[data-layout='compact'] .sp-row:nth-last-child(-n + 2) { display: none; } +} + +/* The Check on a filled button: a white ring and arc while sending, then a + white disc with the tick in the button's green */ +.sp-button .ss-check__ring { stroke: rgb(255 255 255 / 0.45); } +.sp-button .ss-check__arc { stroke: #fff; } +.sp-button .ss-check__fill { fill: #fff; } +.sp-button .ss-check__tick { stroke: var(--ss-success); } + +/* The label trimmed to cap height, so the Check centers on the letters, not + on a line box that sits system-ui capitals low */ +.sp-button .ss-swap { text-box: trim-both cap alphabetic; } diff --git a/skills/imported/benjitaylor/stageshow/examples/phone-touch/fitness.css b/skills/imported/benjitaylor/stageshow/examples/phone-touch/fitness.css new file mode 100644 index 0000000..86cbc9c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/phone-touch/fitness.css @@ -0,0 +1,434 @@ +/* All rules belong to this example, including its responsive variants. */ +.fx { + & .ss-stage { + --ss-accent: #ff5a1f; + --ss-accent-2: #ff8a3d; + --ss-accent-soft: rgba(255, 90, 31, 0.12); + /* The card opening: quick off the tap, long soft settle, as iOS does */ + --fx-open: cubic-bezier(0.3, 0.75, 0.1, 1); + overflow: hidden; + border-radius: 14px; + background: #f4f3f1; + } + + .fx-wash { + position: absolute; + inset: 0; + background: + radial-gradient(60% 50% at 50% 0%, rgba(255, 120, 60, 0.16), transparent 70%), + radial-gradient(70% 60% at 50% 100%, rgba(255, 150, 90, 0.1), transparent 70%); + } + + /* Pinned, bottom bleed: inset one gutter from the top, centered, runs off the bottom */ + .fx-phone { + position: absolute; + top: 48px; + left: 50px; + } + + .fx-phone .ss-phone__status { + transition: color 0.35s ease; + } + + .fx-phone[data-open] .ss-phone__status { + color: #fff; + } + + /* ---- List ---- */ + + .fx-list { + position: absolute; + inset: 0; + background: #f2f2f7; + /* Recedes by dimming only: a scale would land its text with a hop */ + transition: opacity 0.6s ease; + } + + .fx-list[data-back] { + opacity: 0.55; + } + + .fx-head { + position: absolute; + top: 66px; + right: 20px; + left: 20px; + display: grid; + gap: 10px; + } + + .fx-head__row { + display: flex; + align-items: center; + justify-content: space-between; + } + + .fx-title { + font-size: 34px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 41px; + color: #000; + } + + .fx-card, + .fx-slot { + position: absolute; + left: 16px; + width: calc(100% - 32px); + height: 132px; + border-radius: 22px; + } + + .fx-card { + overflow: hidden; + } + + .fx-card__text, + .fx-hero__text { + position: absolute; + bottom: 18px; + left: 20px; + display: grid; + gap: 7px; + justify-items: start; + } + + .fx-line { + display: block; + border-radius: 4px; + background: #fff; + } + + /* Workout art: soft, photographic light in one warm family */ + .fx-art--tempo { + background: + radial-gradient(70% 90% at 85% 15%, #ffb071 0%, transparent 60%), + radial-gradient(80% 80% at 10% 100%, #c2361a 0%, transparent 70%), + linear-gradient(160deg, #ff7a3d 0%, #e9461f 55%, #a8301a 100%); + } + + .fx-art--sage { + background: + radial-gradient(70% 90% at 80% 10%, #c9d3b8 0%, transparent 60%), + linear-gradient(160deg, #9aa88a 0%, #6e7d61 60%, #56634b 100%); + } + + .fx-art--slate { + background: + radial-gradient(70% 90% at 85% 10%, #b9bfcb 0%, transparent 60%), + linear-gradient(160deg, #8d93a1 0%, #646a78 60%, #4c515d 100%); + } + + .fx-art--sand { + background: + radial-gradient(70% 90% at 85% 10%, #e6d6be 0%, transparent 60%), + linear-gradient(160deg, #c8b293 0%, #a48d6d 60%, #85704f 100%); + } + + .fx-tabs { + position: absolute; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: space-around; + height: 83px; + padding: 10px 24px 0; + border-top: 0.5px solid rgba(0, 0, 0, 0.1); + background: rgba(249, 249, 249, 0.94); + } + + .fx-tab { + display: grid; + gap: 6px; + justify-items: center; + } + + .fx-tab__icon { + width: 26px; + height: 22px; + border-radius: 7px; + background: #c4c4c7; + } + + .fx-tab__label { + width: 36px; + height: 6px; + border-radius: 3px; + background: #d6d6d9; + } + + .fx-tab[data-on] .fx-tab__icon { + background: var(--ss-accent); + } + + /* ---- The card that becomes the detail ---- */ + + /* Press and expansion have separate owners: Expand's inline clip transition + must not replace the card's transform feedback. */ + .fx-press { + position: absolute; + inset: 0; + transition: transform 0.2s ease; + will-change: transform; + } + + .fx-detail { + position: absolute; + inset: 0; + overflow: hidden; + } + + /* iOS cards give a little under the finger */ + .fx-phone:has(.fx-slot[data-pressed]) .fx-press { + transform: scale(0.97); + } + + .fx-detail__inner { + /* Expand carries the card; this kept layer reveals the header from its foot. */ + position: relative; + background: #fff; + min-height: 100%; + will-change: transform; + transition: transform 0.62s var(--fx-open), background-color 0.18s ease-out; + } + + /* Closing, the clip and the screen shrink from different heights, so the + body would show in the card for half the move: it and its ground fade out + first, and the card lands as only its art. Opening reveals it as before */ + .fx-detail:not([data-open]) .fx-detail__inner { + background-color: transparent; + /* The body has gone before the list shows through, so no ghost of it */ + transition: transform 0.62s var(--fx-open), background-color 0.16s ease-out 0.08s; + } + + .fx-detail:not([data-open]) .fx-body { + opacity: 0; + transition: opacity 0.08s ease-out; + } + + .fx-hero { + position: relative; + /* Its own rounded foot, moving with the card's clip, so the art lands as + the card's shape rather than a flat bottom inside the rounded one */ + transition: clip-path 0.62s var(--fx-open); + height: 300px; + } + + .fx-hero__text { + bottom: 20px; + gap: 6px; + will-change: transform; + transition: transform 0.62s var(--fx-open); + } + + .fx-hero__eyebrow { + width: 46px; + height: 7px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.55); + } + + .fx-hero__title { + font-size: 26px; + font-weight: 700; + line-height: 31px; + letter-spacing: -0.02em; + color: #fff; + } + + .fx-hero__sub { + /* One item with the title: 4 to 6px of ink between the lines */ + margin-top: -4px; + font-size: 15px; + font-weight: 500; + line-height: 20px; + color: rgba(255, 255, 255, 0.85); + } + + .fx-close { + position: absolute; + top: 62px; + right: 20px; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(40, 20, 10, 0.32); + color: #fff; + opacity: 0; + transition: opacity 0.3s ease, transform 0.15s ease; + } + + .fx-detail[data-open] .fx-close { + opacity: 1; + transition-delay: 0.25s, 0s; + } + + .fx-close[data-pressed] { + transform: scale(0.9); + } + + .fx-body { + display: grid; + gap: 22px; + padding: 24px 20px 0; + } + + /* The body needs no fade: the opening card reveals it, full, as it grows */ + + /* The about block and the running timer share one fixed lane */ + .fx-panel { + display: grid; + height: 150px; + } + + .fx-about, + .fx-timer { + grid-area: 1 / 1; + will-change: transform; + transition: opacity 0.35s ease, transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); + } + + .fx-about { + display: grid; + align-content: start; + gap: 26px; + } + + .fx-about[data-hide] { + opacity: 0; + transform: translateY(-8px); + } + + .fx-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + padding-bottom: 20px; + border-bottom: 0.5px solid rgba(0, 0, 0, 0.1); + } + + .fx-stat { + display: grid; + gap: 9px; + justify-items: start; + } + + .fx-desc { + display: grid; + gap: 11px; + } + + .fx-timer { + display: grid; + align-content: start; + gap: 4px; + opacity: 0; + transform: translateY(10px); + } + + .fx-timer[data-show] { + opacity: 1; + transform: translateY(0); + transition-delay: 0.12s; + } + + .fx-timer__label { + font-size: 15px; + font-weight: 600; + line-height: 20px; + color: var(--ss-accent); + } + + .fx-timer__time { + font-size: 72px; + font-weight: 600; + line-height: 86px; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; + color: #000; + transition: color 0.3s ease; + } + + .fx-timer__time[data-paused] { + color: #8e8e93; + } + + .fx-meter { + height: 8px; + margin-top: 10px; + border-radius: 4px; + background: #ececf0; + } + + .fx-start { + display: grid; + place-items: center; + height: 54px; + position: relative; + isolation: isolate; + border-radius: 16px; + color: #fff; + font-size: 17px; + font-weight: 600; + transition: color 0.2s ease; + } + + /* Its fill gives under the finger; the label holds still (a label that ends + a scale hops as it lands) */ + .fx-start::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background-color: var(--fx-start-fill, var(--ss-accent)); + transition: background-color 0.2s ease, transform 0.15s ease; + } + + .fx-start[data-phase='running'] { + --fx-start-fill: #f2f2f7; + color: #000; + } + + .fx-start[data-pressed]::before { + transform: scale(0.97); + } + + .fx-intervals { + display: grid; + gap: 18px; + padding-top: 4px; + } + + .fx-interval { + display: grid; + grid-template-columns: 10px 1fr auto; + align-items: center; + gap: 12px; + } + + .fx-interval__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #d1d1d6; + } + + .fx-interval__dot[data-i='0'] { + background: var(--ss-accent); + } + + & .ss-stage[data-layout='compact'] .fx-phone { top: 16px; left: 16px; } + + .fx-card__title { + display: block; + font-size: 13px; + font-weight: 700; + line-height: 16px; + letter-spacing: -0.01em; + color: #fff; + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/phone-touch/index.tsx b/skills/imported/benjitaylor/stageshow/examples/phone-touch/index.tsx new file mode 100644 index 0000000..70efd11 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/phone-touch/index.tsx @@ -0,0 +1,229 @@ +import { Stage, Phone, Expand, Bar, Avatar, Icon, Meter, Roll, States, type Act } from '../../kit' +import './fitness.css' + +// Two of the cards in view say what they are; the rest are art with bars +const CARD_NAMES: Record = { 0: 'Morning Ride', 2: 'Hill Repeats' } + +// A fitness app's landing-page demo, phone only. Story in three beats: +// the Workouts list (context), a tap on "Tempo Run" expands its card into the +// workout detail the way iOS cards open (action), then Start sets the timer +// running (result). It loops by being put back: pause, close, and the card +// folds back into its place in the list, so no frame is ever empty. +// +// The expanded detail is the same element as the card in the list: closed, it +// is clipped to the card's rect and its header art is slid down so only the +// bottom of the art (title and duration) shows; open, the clip and the slide +// release together. Nothing swaps, so there is no seam at the hand-over. +// +// Real strings: Workouts, Tempo Run, 32 min, Start/Pause/Resume, Warm up, +// the timer. Everything else is a line or a shape. + +type Phase = 'idle' | 'running' | 'paused' +type S = { open: boolean; phase: Phase; t: number } + +const START: S = { open: false, phase: 'idle', t: 0 } + +// The card has equal screen-point gutters; Expand measures the current Phone. +const CARD = { x: 16, h: 132 } +const TOPS = [150, 296, 442, 588] +const PICK = 1 +const REST = { x: 200, y: 300 } +const HEADER = 300 + +const ART = ['fx-art--sage', 'fx-art--tempo', 'fx-art--slate', 'fx-art--sand'] +const LIST_TEXT = [ + { eyebrow: 38, title: 128, sub: 44 }, + null, + { eyebrow: 46, title: 104, sub: 52 }, + { eyebrow: 34, title: 140, sub: 40 }, +] + +const time = (t: number) => `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}` + +const script = async (a: Act) => { + await a.wait(1100) + a.mark('tap') + await a.click('card') + a.put({ open: true }) + await a.wait(1400) + a.mark('start') + await a.click('start') + a.put({ phase: 'running' }) + for (let i = 0; i < 6; i++) { + await a.wait(1000) + a.put((s) => ({ t: s.t + 1 })) + } + await a.wait(400) + a.mark('pause') + await a.click('start') + a.put({ phase: 'paused' }) + await a.wait(1100) + a.mark('close') + await a.click('close') + a.put({ open: false }) + await a.wait(900) + // Hidden behind the card's clip now: reset the workout unseen + await a.snap({ phase: 'idle', t: 0 }) + // The finger lifts away; the touch style shows nothing between taps + await a.move(REST) + await a.wait(300) +} + +function Play() { + return ( + + + + ) +} + +function Pause() { + return ( + + + + + ) +} + +export default function MobileApp() { + return ( + + width={400} + height={520} + compact={{ below: 400, width: 358, height: 520 }} + initial={START} + poster={{ open: true, phase: 'running', t: 7 }} + script={script} + cursor={{ at: REST, style: 'touch' }} + className="fx" + > + {(s, rect, layout) => { + const top = TOPS[PICK] + return ( + <> +
+ +
+
+ +
+ Workouts + +
+
+ {TOPS.map((y, i) => + i === PICK ? ( + + ) : ( +
+ + + {CARD_NAMES[i] ? {CARD_NAMES[i]} : } + + +
+ ), + )} + +
+ + {/* The picked card and its detail are one element */} +
+ +
+
+ + + + + + Tempo Run + 32 min + +
+
+
+
+
+ {[ + [34, 46], + [40, 30], + [28, 38], + ].map(([l, v], i) => ( + + + + + ))} +
+
+ + + +
+
+
+ Warm up + + {time(s.t)} + + +
+
+ + + Start + + ), + running: ( + <> + Pause + + ), + paused: ( + <> + Resume + + ), + }} + /> + +
+ + {[86, 64, 92].map((w, i) => ( + + + + + + ))} +
+
+
+
+
+
+ + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/powerpoint/index.tsx b/skills/imported/benjitaylor/stageshow/examples/powerpoint/index.tsx new file mode 100644 index 0000000..1e7a483 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/powerpoint/index.tsx @@ -0,0 +1,381 @@ +import { useId, type CSSProperties } from 'react' +import { + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Bold, + ClipboardPaste, + Italic, + LayoutDashboard, + Layers, + List, + ListOrdered, + Shapes, + SquarePlus, + Strikethrough, + Underline, + X, +} from 'lucide-react' +import { Bar, Stage, Text, Window, type Act, type Layout } from '../../kit' +import './pp.css' + +// PowerPoint for Mac: pick slide 2 in the rail, it opens in the editor, open +// Designer and pick a suggested layout, and the slide restyles in place (the +// rail thumbnail follows). Cmd+Z undoes it, the pane closes, slide 1 is +// picked again, so the loop ends where it began. + +type Design = 'plain' | 'split' | 'band' | 'overlay' +type S = { slide: 1 | 2; pane: boolean; design: Design; picked: number; ideas: boolean } + +const initial: S = { slide: 1, pane: false, design: 'plain', picked: 0, ideas: false } +const poster: S = { slide: 2, pane: true, design: 'split', picked: 2, ideas: true } + +// Geometry per layout, in design px. The slide is drawn at 640 × 360 and scaled. +const G = { + wide: { w: 920, h: 540, win: { x: 30, y: 30, w: 860, h: 570 }, rail: 140, pane: 220, canvas: 350, thumb: 100, step: 70, idea: 188, pad: 24 }, + // Narrow, the window keeps its size and runs off the left: the slide rail + // goes out of frame so the slide and Designer draw at true scale, not shrunk + compact: { w: 358, h: 400, win: { x: -78, y: 20, w: 420, h: 432 }, rail: 72, pane: 150, canvas: 220, thumb: 48, step: 44, idea: 126, pad: 14 }, +} + +function fit(l: Layout, pane: boolean) { + const g = G[l] + const editor = g.win.w - g.rail - (pane ? g.pane : 0) + const k = Math.min((editor - g.pad * 2) / 640, (g.canvas - g.pad * 2) / 360, l === 'wide' ? 0.8 : 0.5) + // Compact with the pane open: top-align with the second rail thumbnail rather than land a few px off it. + const y = l === 'compact' && pane ? 12 + g.step : (g.canvas - 360 * k) / 2 + return { k, x: (editor - 640 * k) / 2, y } +} + +/* ---- The slide itself ---- */ + +type Box = [number, number, number, number] +const LOOK: Record = { + plain: { photo: [360, 112, 232, 174], title: [48, 40], bullets: [56, 128], light: false, panel: 0, shade: 0, rule: false }, + split: { photo: [0, 0, 300, 360], title: [336, 128], bullets: [340, 200], light: true, panel: 1, shade: 0, rule: true }, + band: { photo: [0, 0, 640, 188], title: [48, 216], bullets: [52, 280], light: false, panel: 0, shade: 0, rule: false }, + overlay: { photo: [0, 0, 640, 360], title: [48, 222], bullets: [52, 284], light: true, panel: 0, shade: 1, rule: false }, +} + +function Photo() { + const id = useId().replace(/:/g, '') + const sky = `${id}-sky` + const sea = `${id}-sea` + // A harbor at dusk: sky, sun, water, a far shore and a hill of rooftops. + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function SlideArt({ n, design }: { n: number; design: Design }) { + if (n === 1) { + return ( +
+ Team Offsite 2026 + +
+ ) + } + if (n >= 3) { + const rows = [ + [300, 240, 270], + [260, 300, 200], + [280, 220, 250], + [240, 290, 230], + ][(n - 3) % 4] + return ( +
+ + {rows.map((w, i) => ( + + ))} +
+ ) + } + const L = LOOK[design] + const [px, py, pw, ph] = L.photo + return ( +
+
0 || undefined} /> +
+ +
+
+ + + Lisbon, in May + +
+ {[210, 168, 188].map((w, i) => ( + + + + + ))} +
+
+ ) +} + +/** A slide scaled into a box of the given width. */ +function Mini({ n, design, width, style }: { n: number; design: Design; width: number; style?: CSSProperties }) { + const k = width / 640 + return ( +
+
+ +
+
+ ) +} + +/* ---- Chrome ---- */ + +const ICON = { size: 15, strokeWidth: 1.6 } + +function Tabs({ layout }: { layout: Layout }) { + const tabs = layout === 'wide' ? ['Home', 'Insert', 'Draw', 'Design', 'Transitions', 'Animations', 'Slide Show', 'Review', 'View'] : ['Home', 'Insert', 'Draw', 'Design'] + return ( +
+ {tabs.map((t, i) => ( + + {t} + + ))} + + {layout === 'wide' && } + + + +
+ ) +} + +function Big({ icon, label }: { icon: React.ReactNode; label: number }) { + return ( + + {icon} + + + ) +} + +function Ribbon({ layout, open }: { layout: Layout; open: boolean }) { + const wide = layout === 'wide' + return ( +
+ } label={24} /> + } label={34} /> + + {wide ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + + + + + + + + )} + {wide && ( + <> + + + + + + + + + + + + + + + } label={30} /> + } label={32} /> + + )} + + + + + Designer + +
+ ) +} + +const IDEAS: Design[] = ['band', 'split', 'overlay'] + +/* ---- Script ---- */ + +async function script(a: Act) { + // Narrow, the rail is out of frame: the story starts on slide 2 + const t = a.rect('thumb-2') + const rail = t.x + t.width > 0 + if (!rail) a.snap({ slide: 2 }) + await a.wait(700) + a.mark('open') + if (rail) { + await a.click('thumb-2') + await a.set({ slide: 2 }) + await a.wait(1000) + } + + a.mark('designer') + await a.click({ at: 'designer', x: 0.5, y: 0.3 }) + await a.set({ pane: true, ideas: true }) + await a.wait(1300) + + await a.move({ at: 'idea-2', x: 0.82, y: 0.78 }) + await a.wait(400) + a.mark('pick') + await a.click() + await a.set({ design: 'split', picked: 2 }) + await a.wait(2800) + + a.mark('undo') + await a.key('cmd+z') + await a.set({ design: 'plain', picked: 0 }) + await a.wait(1200) + await a.click('close') + await a.set({ pane: false, ideas: false }) + await a.wait(900) + if (rail) { + await a.click('thumb-1') + await a.set({ slide: 1 }) + } + await a.wait(1200) +} + +export default function PowerPointDemo() { + return ( + + {(s, _rect, layout) => { + const g = G[layout] + const f = fit(layout, s.pane) + return ( + + + +
+ +
+
+
+ {[1, 2].map((n) => ( +
+ +
+ ))} +
+
+
+ +
+
+ + + +
+ +
+
+
+ ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css b/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css new file mode 100644 index 0000000..93881b6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css @@ -0,0 +1,514 @@ +/* PowerPoint for Mac. The stage clips the window: pinned top-left and right + on one 30px gutter, running off the bottom. */ + +.pp-stage .ss-stage { + overflow: hidden; + background: var(--ss-well); + border-radius: 14px; +} + +.pp-window { + position: absolute; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-hero); +} + +.pp-window .ss-window__body { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.pp-spacer { flex: 1; } + +/* ---- Tabs ---- */ + +.pp-tabs { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 18px; + height: 30px; + padding: 0 16px; + background: var(--ss-chrome); +} + +.pp-window[data-layout="compact"] .pp-tabs { gap: 14px; padding: 0 12px; } + +.pp-tab { + position: relative; + font-size: 11.5px; + line-height: 30px; + color: var(--ss-text); + white-space: nowrap; +} + +.pp-tab[data-active] { + color: var(--ss-accent); + font-weight: 600; +} + +.pp-tab[data-active]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 3px; + height: 2px; + border-radius: 1px; + background: var(--ss-accent); +} + +.pp-share { + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 20px; + border-radius: 5px; + background: var(--ss-accent); +} + +.pp-share-bar { background: rgba(255, 255, 255, 0.6) !important; } + +/* ---- Ribbon ---- */ + +.pp-ribbon { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 14px; + height: 62px; + padding: 0 16px; + background: var(--ss-chrome); + border-bottom: 1px solid var(--ss-line); + color: var(--ss-icon); +} + +.pp-window[data-layout="compact"] .pp-ribbon { gap: 10px; padding: 0 12px; } + +.pp-big { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + width: 40px; +} + +.pp-sep { + width: 1px; + height: 40px; + background: var(--ss-line); +} + +.pp-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pp-row { + display: flex; + align-items: center; + gap: 6px; + height: 20px; +} + +.pp-icons { gap: 11px; } + +.pp-field { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border-radius: 5px; + box-shadow: inset 0 0 0 1px var(--ss-line); +} + +.pp-designer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + width: 64px; + height: 52px; + border-radius: 7px; + transition: background 0.15s ease; +} + +.pp-designer[data-hover], +.pp-designer[data-open] { + background: var(--ss-ink-2); +} + +.pp-designer-icon { color: var(--ss-accent); } + +.pp-designer-label { + font-size: 10.5px; + line-height: 1.3; + color: var(--ss-text-strong); +} + +/* ---- Rail, canvas, notes ---- */ + +.pp-main { + position: relative; + display: flex; + flex: 1; + min-height: 0; +} + +.pp-rail { + position: relative; + flex-shrink: 0; + background: var(--ss-canvas); + border-right: 1px solid var(--ss-line); +} + +.pp-thumb { + position: absolute; + left: 0; + right: 0; + display: flex; + align-items: flex-start; + gap: 8px; + padding-left: 10px; +} + +.pp-window[data-layout="compact"] .pp-thumb { gap: 5px; padding-left: 6px; } + +.pp-num { + width: 10px; + font-size: 10px; + line-height: 1.3; + color: var(--ss-text-faint); + text-align: right; +} + +.pp-window[data-layout="compact"] .pp-num { width: 7px; font-size: 9px; } + +.pp-thumb-box { + border-radius: 3px; + box-shadow: 0 0 0 1px var(--ss-line), 0 1px 2px rgba(0, 0, 0, 0.05); + transition: box-shadow 0.18s ease; +} + +.pp-thumb-box[data-selected] { + box-shadow: 0 0 0 2px var(--ss-accent), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.pp-mini { + overflow: hidden; + border-radius: 3px; + background: #fff; +} + +.pp-work { + position: relative; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.pp-canvas { + position: relative; + flex-shrink: 0; + background: var(--ss-well); +} + +.pp-fit { + position: absolute; + left: 0; + top: 0; + width: 640px; + height: 360px; + transform-origin: 0 0; + transition: transform 0.6s cubic-bezier(0.3, 0.75, 0.1, 1); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 14px rgba(0, 0, 0, 0.08); +} + +/* A slide swap: the old one fades out, the new one fades in with a small rise. */ +.pp-page { + position: absolute; + inset: 0; + opacity: 0; + /* The outgoing slide stays solid under the incoming one, then drops away */ + transition: opacity 0s linear 0.4s; +} + +.pp-page[data-on] { + z-index: 1; + opacity: 1; + transition: opacity 0.35s ease; + animation: pp-rise 0.45s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +@keyframes pp-rise { + from { transform: translateY(8px); } +} + +.pp-notes { + display: flex; + align-items: center; + flex-shrink: 0; + align-items: flex-start; + height: 64px; + padding: 14px 24px 0; + background: var(--ss-surface); + border-top: 1px solid var(--ss-line); +} + +.pp-window[data-layout="compact"] .pp-notes { height: 56px; padding: 12px 14px 0; } + +.pp-status { + display: flex; + align-items: center; + flex-shrink: 0; + height: 26px; + padding: 0 16px; + background: var(--ss-canvas); + border-top: 1px solid var(--ss-line); +} + +/* ---- Designer pane ---- */ + +.pp-pane { + position: absolute; + top: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 0 16px; + background: var(--ss-surface); + border-left: 1px solid var(--ss-line); + transform: translateX(100%); + transition: transform 0.6s cubic-bezier(0.3, 0.75, 0.1, 1); +} + +.pp-pane[data-open] { transform: none; } + +.pp-window[data-layout="compact"] .pp-pane { gap: 10px; padding: 0 12px; } + +.pp-pane-head { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + flex-shrink: 0; +} + +.pp-window[data-layout="compact"] .pp-pane-head { height: 34px; } + +.pp-pane-title { + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.pp-window[data-layout="compact"] .pp-pane-title { font-size: 12px; } + +.pp-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-right: -4px; + border-radius: 5px; + color: var(--ss-icon); + transition: background 0.15s ease; +} + +.pp-close[data-hover] { background: var(--ss-ink-2); } + +.pp-idea { + flex-shrink: 0; + border-radius: 4px; + box-shadow: 0 0 0 1px var(--ss-line); + opacity: 0; + transform: translateY(10px); + transition: + opacity 0.4s ease var(--d, 0ms), + transform 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) var(--d, 0ms), + box-shadow 0.15s ease; +} + +.pp-idea .pp-mini { border-radius: 4px; } + +.pp-idea[data-show] { opacity: 1; transform: none; } + +.pp-idea[data-hover] { box-shadow: 0 0 0 2px var(--ss-ink-5); } + +.pp-idea[data-picked] { box-shadow: 0 0 0 2px var(--ss-accent); } + +/* ---- The slide (640 × 360) ---- */ + +.pp-slide { + position: relative; + width: 640px; + height: 360px; + overflow: hidden; + background: #fff; + font-family: var(--ss-font); + --move: 0.9s cubic-bezier(0.45, 0, 0.1, 1); +} + +.pp-panel { + position: absolute; + left: 300px; + top: 0; + width: 340px; + height: 360px; + background: #1f2b3a; + transform: translateX(100%); + transition: transform var(--move); +} + +.pp-panel[data-show] { transform: none; } + +.pp-photo { + position: absolute; + overflow: hidden; + transition: left var(--move), top var(--move), width var(--move), height var(--move); +} + +.pp-photo-svg { + display: block; + width: 100%; + height: 100%; +} + +.pp-shade { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(15, 22, 32, 0) 35%, rgba(15, 22, 32, 0.72) 100%); + transition: opacity 0.5s ease; +} + +.pp-rule { + position: absolute; + left: 336px; + top: 108px; + width: 44px; + height: 4px; + border-radius: 2px; + background: #e8834f; + transform: scaleX(0); + transform-origin: left; + transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.pp-rule[data-show] { + transform: none; + transition-delay: 0.6s; +} + +.pp-title { + position: absolute; + left: 0; + top: 0; + font-size: 32px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.01em; + white-space: nowrap; + color: #1f1f1f; + transition: transform var(--move), color 0.25s ease 0.15s; +} + +.pp-slide[data-light] .pp-title { + color: #fff; + transition: transform var(--move), color 0.25s ease 0.55s; +} + +.pp-bullets { + position: absolute; + left: 0; + top: 0; + display: flex; + flex-direction: column; + gap: 22px; + transition: transform var(--move) 0.05s; +} + +.pp-bullet { + display: flex; + align-items: center; + gap: 14px; +} + +.pp-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #3a3a3a; + transition: background 0.25s ease 0.15s; +} + +.pp-line { + height: 10px; + border-radius: 5px; + background: #dcdcdc; + transition: background 0.25s ease 0.15s; +} + +.pp-slide[data-light] .pp-dot { background: #e8834f; transition-delay: 0.6s; } +.pp-slide[data-light] .pp-line { background: rgba(255, 255, 255, 0.32); transition-delay: 0.6s; } + +/* Slide 1: the title slide */ +.pp-slide[data-n="1"] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 22px; +} + +.pp-s1-title { + font-size: 40px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.2; + color: #1f1f1f; +} + +.pp-s1-sub { + width: 220px; + height: 12px; + border-radius: 6px; + background: #dcdcdc; +} + +/* Later slides: a title and a few lines */ +.pp-slide[data-n="other"] { + display: flex; + flex-direction: column; + gap: 26px; + padding: 52px 56px; +} + +.pp-o-title { + height: 24px; + margin-bottom: 16px; + border-radius: 6px; + background: #cfcfcf; +} + +.pp-o-row { + height: 12px; + border-radius: 6px; + background: #e2e2e2; +} + +/* Narrow, the window runs off the left by the rail's width (78px): the tab + row and ribbon start that far in, so they begin in frame with Home, never + a word sliced at the stage edge. Paste goes to make room. */ +.pp-window[data-layout="compact"] .pp-tabs, +.pp-window[data-layout="compact"] .pp-ribbon { padding-left: 94px; } +.pp-window[data-layout="compact"] .pp-ribbon > :first-child { display: none; } diff --git a/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx b/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx new file mode 100644 index 0000000..cb354e0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx @@ -0,0 +1,130 @@ +import type { CSSProperties } from 'react' +import { Bar, Facepile, Stage, Window, type Act, type Rect } from '../../kit' +import './presence.css' + +// Presence: two teammates working in the same doc. Beats: Maya's cursor +// comes to a sentence and selects it, her color sweeping across it under +// her name; Jon's cursor comes to the next line and types two words with a +// caret in his color; both rest. Loops; the reset fades their edits out +// while the cursors are off at the margins. +// +// What makes presence read as people, not decoration: +// - Each person is one color everywhere: their face in the pile, their +// cursor, their name flag, their selection, their caret. Two people, two +// colors, neither of them the doc's accent. +// - The name rides the cursor, just below and right of its tip, so the eye +// reads who as well as where. A bare colored arrow is just a second +// pointer. +// - They move like hands: an ease in and out over most of a second, never +// linear, never in step with each other. +// - Their work is in the document's own marks: a selection is a text +// highlight, typing is words arriving at a caret. No bubbles, no badges. +// - There is no own-cursor: the viewer is watching the others. +// +// Real strings: the doc's title, the two sentences in play, the typed words, +// the two names. The rest is placeholder lines at a paragraph's measure. + +type Spot = 'rest' | 'start' | 'end' | 'ins' | 'away' +type S = { maya: Spot; jon: Spot; sel: number; jonText: string; caret: boolean } + +const initial: S = { maya: 'rest', jon: 'rest', sel: 0, jonText: '', caret: false } +const PEOPLE = { maya: '#d1478a', jon: '#2f9e8f' } + +async function script(a: Act) { + await a.wait(700) + a.mark('maya') + a.put({ maya: 'start' }) + await a.wait(1000) + a.put({ maya: 'end', sel: 1 }) + await a.wait(500) + a.mark('jon') + a.put({ jon: 'ins' }) + // His caret appears when he gets there, as his click places it + await a.wait(1100) + a.put({ caret: true }) + await a.wait(250) + await a.type('jonText', ' and EU', { interval: 110 }) + await a.wait(2400) + a.mark('reset') + a.put({ maya: 'away', jon: 'away', caret: false }) + await a.wait(700) + a.put({ sel: 0, jonText: '' }) + await a.wait(700) + a.put({ maya: 'rest', jon: 'rest' }) + await a.wait(900) +} + +function spot(who: 'maya' | 'jon', at: Spot, sel: Rect, ins: Rect, doc: Rect): { x: number; y: number } { + // A name flag never leaves the doc: about 60px from its right edge at most + const right = doc.x + doc.width - 64 + if (!sel.width) return { x: -100, y: -100 } + if (who === 'maya') { + if (at === 'start') return { x: sel.x, y: sel.y + sel.height * 0.6 } + if (at === 'end') return { x: sel.x + sel.width, y: sel.y + sel.height * 0.6 } + if (at === 'away') return { x: sel.x - 60, y: sel.y - 70 } + return { x: sel.x + 40, y: sel.y - 50 } + } + if (at === 'ins') return { x: Math.min(right, ins.x + 2), y: ins.y + ins.height * 0.75 } + if (at === 'away') return { x: Math.min(right, ins.x + 120), y: ins.y + 100 } + return { x: Math.min(right, ins.x + 70), y: ins.y + 74 } +} + +export default function Presence() { + return ( + width={660} height={420} compact={{ below: 660, width: 358, height: 420 }} initial={initial} poster={{ maya: 'end', jon: 'ins', sel: 1, jonText: ' and EU', caret: true }} script={script} className="pr"> + {(s, rect) => { + const sel = rect('sel') + const ins = rect('ins') + const doc = rect('doc') + const m = spot('maya', s.maya, sel, ins, doc) + const j = spot('jon', s.jon, sel, ins, doc) + return ( + <> + }> +
+
Launch plan
+
+ + + +
+

+ + Pricing goes live on Thursday. + {' '} + Checkout opens in the US + {/* Where Jon clicked: his words arrive to the right of it */} + + + {s.jonText} + + + . +

+
+ + + + +
+
+
+ + + + ) + }} + + ) +} + +function Peer({ name, color, at, slow }: { name: string; color: string; at: { x: number; y: number }; slow?: boolean }) { + return ( + + + + + {name} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/presence/presence.css b/skills/imported/benjitaylor/stageshow/examples/presence/presence.css new file mode 100644 index 0000000..ca401ff --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/presence/presence.css @@ -0,0 +1,135 @@ +.pr .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* One window, inset at the top, off the bottom */ +.pr-win { + position: absolute; + top: 40px; + left: 50%; + width: 580px; + translate: -50% 0; +} + +.pr .ss-stage[data-layout='compact'] .pr-win { + top: 16px; + width: 326px; +} + +.pr-doc { + min-height: 440px; + padding: 34px 44px 0; + background: var(--ss-surface); +} + +.pr .ss-stage[data-layout='compact'] .pr-doc { + padding: 24px 22px 0; +} + +.pr-title { + margin: 0 0 18px; + color: var(--ss-text-strong); + font-size: 24px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.pr-lines { + display: grid; + gap: 11px; + margin: 6px 0; +} + +.pr-p { + margin: 16px 0 18px; + color: var(--ss-text-strong); + font-size: 15px; + line-height: 1.65; +} + +/* Maya's selection: her color sweeps across the sentence under her cursor. + The same duration and curve as her cursor's move (.pr-peer), so the + highlight's edge is always at the tip; a quicker sweep runs ahead of her */ +.pr-sel { + border-radius: 2px; + background-image: linear-gradient(color-mix(in srgb, var(--who) 22%, transparent), color-mix(in srgb, var(--who) 22%, transparent)); + background-repeat: no-repeat; + background-size: 0% 100%; + /* A deselect is instant, as in the app; only selecting sweeps */ + transition: none; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +.pr-sel[data-on] { + background-size: 100% 100%; + transition: background-size 0.95s cubic-bezier(0.45, 0, 0.25, 1); +} + +.pr-anchor { + display: inline-block; + width: 0; + height: 1em; + vertical-align: -0.15em; +} + +.pr-typed { + color: var(--ss-text-strong); +} + +.pr-caret { + display: inline-block; + width: 2px; + height: 1.15em; + margin: 0 -2px 0 0; + vertical-align: -0.2em; + background: var(--who); + opacity: 0; +} + +.pr-caret[data-on] { + opacity: 1; +} + +/* A teammate's cursor: their color, their name riding below the tip. It + eases like a hand; the tip is the point */ +.pr-peer { + position: absolute; + top: 0; + left: 0; + z-index: 10; + pointer-events: none; + will-change: transform; + transition: transform 0.95s cubic-bezier(0.45, 0, 0.25, 1); +} + +.pr-peer[data-slow] { + transition-duration: 1.1s; +} + +.pr-peer svg { + display: block; + margin: -1.5px 0 0 -1.5px; + filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.2)); +} + +.pr-flag { + position: absolute; + top: 16px; + left: 11px; + padding: 3px 7px; + border-radius: 6px; + background: var(--who); + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +/* One color per person: the pile's faces match the cursors. Ana is in the + doc but not in shot, so her face stays quiet */ +.pr-pile .ss-facepile__face:nth-child(1) .ss-avatar { background: #d1478a !important; } +.pr-pile .ss-facepile__face:nth-child(2) .ss-avatar { background: #2f9e8f !important; } +.pr-pile .ss-facepile__face:nth-child(3) .ss-avatar { background: var(--ss-ink-3) !important; color: var(--ss-text) !important; } diff --git a/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx b/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx new file mode 100644 index 0000000..51ddab0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx @@ -0,0 +1,249 @@ +import { AppWindow, ArrowLeft, BookOpen, Globe, Search, Calculator, Calendar, ChevronDown, ClipboardList, Folder, Image, Link, Music, Settings, Text as TextIcon, type LucideIcon } from 'lucide-react' +import { Bar, Collapse, Stage, Text, type Act, type Layout } from '../../kit' +import './raycast.css' + +// Raycast over a blurred desktop. Type "clip", Clipboard History is the first +// result, Enter pushes the command, the list opens with its preview pane, +// Down moves to a copied color, Esc pops back to root search (the loop). + +type S = { query: string; view: 'root' | 'clip'; sel: number } + +const initial: S = { query: '', view: 'root', sel: 0 } + +type Row = { id: string; icon: LucideIcon; tile: string; title: number; sub: number; acc: number } + +// Root rows. Clipboard History is its own row; the rest are placeholders. +const ROWS: Row[] = [ + { id: 'r1', icon: Folder, tile: '#3d8bfd', title: 52, sub: 0, acc: 56 }, + { id: 'r2', icon: Calendar, tile: '#ff5f57', title: 64, sub: 40, acc: 50 }, + { id: 'r3', icon: Music, tile: '#fc3c64', title: 44, sub: 0, acc: 56 }, + { id: 'r4', icon: Settings, tile: '#8e8e93', title: 78, sub: 46, acc: 50 }, + { id: 'r5', icon: AppWindow, tile: '#5e5ce6', title: 58, sub: 0, acc: 56 }, + { id: 'r6', icon: Calculator, tile: '#ff9f0a', title: 70, sub: 0, acc: 50 }, + { id: 'r7', icon: Image, tile: '#30b0c7', title: 86, sub: 52, acc: 50 }, + { id: 'r8', icon: Folder, tile: '#3d8bfd', title: 66, sub: 0, acc: 56 }, +] + +// Which rows survive each query, in the order Raycast ranks them. +function shown(q: string): string[] { + if (q === '') return ['r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8'] + if (q === 'c') return ['ch', 'r2', 'r6', 'r3'] + if (q === 'cl') return ['ch', 'r6', 'r7'] + return ['ch', 'r7'] +} + +const FALLBACK = [ + { icon: Globe, tile: '#0a84ff', title: 92, acc: 50 }, + { icon: Search, tile: '#636366', title: 74, acc: 56 }, + { icon: BookOpen, tile: '#34c759', title: 60, acc: 50 }, +] + +type Entry = { kind: 'text' | 'color' | 'link' | 'image'; label?: string; w: number } +const ENTRIES: Entry[] = [ + { kind: 'text', label: 'Ship the new onboarding on Thursday', w: 0 }, + { kind: 'color', label: '#FF6363', w: 0 }, + { kind: 'link', w: 96 }, + { kind: 'text', w: 120 }, + { kind: 'image', w: 82 }, + { kind: 'text', w: 104 }, + { kind: 'link', w: 74 }, +] + +async function script(a: Act) { + await a.wait(1000) + a.mark('type') + await a.type('query', 'clip', { interval: 150 }) + await a.wait(900) + a.mark('open') + await a.key('enter') + await a.set({ view: 'clip', sel: 0 }) + await a.wait(2200) + a.mark('down') + await a.key('down') + await a.set({ sel: 1 }) + await a.wait(2400) + a.mark('back') + await a.key('esc') + await a.set({ view: 'root', query: '', sel: 0 }) + await a.wait(1400) +} + +function EntryIcon({ e }: { e: Entry }) { + if (e.kind === 'color') return + const I = e.kind === 'link' ? Link : e.kind === 'image' ? Image : TextIcon + return +} + +function Root({ s }: { s: S }) { + const vis = shown(s.query) + const first = vis[0] + return ( +
+
+ + {s.query && {s.query}} + + + Search for apps and commands... + + +
+
+
+ + Suggestions + Results + +
+ +
+ + + + Clipboard History + Raycast + + Command +
+
+ {ROWS.map((r) => { + const I = r.icon + return ( + +
+ + + + + {r.sub > 0 && } + + +
+
+ ) + })} + +
+ + {`Use "${s.query || 'clip'}" with...`} + +
+ {FALLBACK.map((f, i) => { + const I = f.icon + return ( +
+ + + + + + +
+ ) + })} +
+
+
+ ) +} + +function Clip({ s, layout }: { s: S; layout: Layout }) { + const compact = layout === 'compact' + return ( +
+
+ + + + + + Type to filter entries... + + + All Types + + +
+
+
+
+ Today +
+ {ENTRIES.map((e, i) => ( +
+ + {e.label ? {e.label} : } +
+ ))} +
+
+
+
+ + Ship the new onboarding on Thursday + +
+
+ + #FF6363 +
+
+
+ Information + {[ + [58, 46, 52], + [70, 34, 28], + [44, 60, 66], + [62, 52, 40], + ].map(([l, v0, v1], i) => ( +
+ + + + + +
+ ))} +
+
+
+
+ ) +} + +export default function RaycastDemo() { + return ( + + {(s, _rect, layout) => ( +
+
+
+ + + + + + + + + + + + +
+
+ + +
+ + + + + +
+
+
+ )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/raycast/raycast.css b/skills/imported/benjitaylor/stageshow/examples/raycast/raycast.css new file mode 100644 index 0000000..d1824e0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/raycast/raycast.css @@ -0,0 +1,430 @@ +/* Raycast, dark appearance, floating over a blurred desktop. */ + +.rc-stage .ss-stage { + overflow: hidden; + background: #191b2e; + --rc-text: #ececee; + --rc-text-2: #8e8e93; + --rc-icon: #a1a1a6; + --rc-line: rgba(255, 255, 255, 0.08); + --rc-sel: rgba(255, 255, 255, 0.09); +} + +.rc-desktop { + position: absolute; + inset: 0; + --rc-x: 56px; + --rc-y: 72px; + --rc-w: 648px; + --rc-h: 460px; + --rc-search: 18px; + --rc-list-w: 40%; +} + +.rc-desktop[data-layout="compact"] { + --rc-x: 16px; + --rc-y: 40px; + --rc-w: 368px; + --rc-h: 420px; + --rc-search: 16px; + --rc-list-w: 46%; +} + +/* A macOS wallpaper, out of focus */ +.rc-wallpaper { + position: absolute; + inset: -80px; + background: + radial-gradient(40% 50% at 22% 30%, #7b5cff 0%, transparent 70%), + radial-gradient(35% 45% at 80% 22%, #ff7a59 0%, transparent 70%), + radial-gradient(45% 55% at 70% 85%, #2f6bff 0%, transparent 70%), + radial-gradient(40% 40% at 15% 90%, #c2417e 0%, transparent 70%), + linear-gradient(160deg, #2a1f63 0%, #16224d 55%, #0f1530 100%); + filter: blur(36px) saturate(1.1); +} + +.rc-wallpaper::after { + content: ""; + position: absolute; + inset: 0; + background: rgba(8, 10, 24, 0.18); +} + +.rc-menubar { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + background: rgba(10, 12, 30, 0.22); + backdrop-filter: blur(20px); +} + +.rc-menubar-left, +.rc-menubar-right { + display: flex; + align-items: center; + gap: 16px; +} + +.rc-menubar-right { + gap: 12px; +} + +/* The Raycast window: no title bar, vibrancy over the desktop */ +.rc-window { + position: absolute; + left: var(--rc-x); + top: var(--rc-y); + width: var(--rc-w); + height: var(--rc-h); + border-radius: 12px; + overflow: hidden; + background: rgba(30, 30, 33, 0.8); + backdrop-filter: blur(40px) saturate(1.6); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.1), + 0 0 0 0.5px rgba(0, 0, 0, 0.6), + 0 30px 70px -10px rgba(0, 0, 0, 0.55), + 0 12px 28px -12px rgba(0, 0, 0, 0.5); + color: var(--rc-text); + font-family: var(--ss-font); +} + +.rc-page { + position: absolute; + inset: 0 0 40px 0; + display: flex; + flex-direction: column; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.rc-page[data-show] { + opacity: 1; + transform: none; + transition: opacity 0.24s ease 0.06s, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) 0.06s; +} + +/* Search bar */ +.rc-search { + flex-shrink: 0; + height: 56px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 20px; + border-bottom: 1px solid var(--rc-line); +} + +.rc-input { + position: relative; + flex: 1; + min-width: 0; + display: flex; + align-items: center; + font-size: var(--rc-search); + line-height: 1.3; + white-space: nowrap; +} + +.rc-query { + color: var(--rc-text); +} + +.rc-caret { + flex-shrink: 0; + width: 2px; + height: calc(var(--rc-search) + 3px); + margin-left: 1px; + border-radius: 1px; + background: #ff6363; + animation: ss-caret 1.06s steps(1) 0.5s infinite; +} + +.rc-placeholder { + position: absolute; + left: 4px; + color: #6e6e73; + transition: opacity 0.12s ease; +} + +.rc-placeholder[data-hidden] { + opacity: 0; + transition: none; +} + +.rc-back { + flex-shrink: 0; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.1); + color: #c7c7cc; +} + +.rc-dropdown { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px 0 10px; + border-radius: 7px; + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + font-size: 13px; + color: #c7c7cc; +} + +.rc-dropdown svg { + color: var(--rc-icon); +} + +/* Lists */ +.rc-list { + flex: 1; + min-height: 0; + padding: 4px 8px; +} + +.rc-section { + padding: 10px 10px 6px; + font-size: 12px; + line-height: 1.3; + font-weight: 600; + color: var(--rc-text-2); +} + +.rc-filter.ss-collapse { + transition: grid-template-rows 0.22s cubic-bezier(0.3, 0.75, 0.1, 1) 0.04s, opacity 0.12s ease; +} + +.rc-filter.ss-collapse[data-open] { + transition: grid-template-rows 0.24s cubic-bezier(0.3, 0.75, 0.1, 1), opacity 0.2s ease 0.04s; +} + +.rc-row { + display: flex; + align-items: center; + gap: 10px; + height: 40px; + padding: 0 10px; + border-radius: 8px; + font-size: 14px; + line-height: 1.3; + white-space: nowrap; + transition: background-color 0.12s ease; +} + +.rc-row[data-selected] { + background: var(--rc-sel); +} + +.rc-tile { + flex-shrink: 0; + display: grid; + place-items: center; + width: 20px; + height: 20px; + border-radius: 5px; + color: #fff; + box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.18); +} + +.rc-title { + color: var(--rc-text); +} + +.rc-sub, +.rc-acc { + color: var(--rc-text-2); +} + +.rc-acc { + font-size: 13px; +} + +.rc-spacer { + flex: 1; +} + +/* Clipboard History */ +.rc-split { + flex: 1; + min-height: 0; + display: flex; +} + +.rc-entries { + flex-shrink: 0; + width: var(--rc-list-w); + padding: 4px 8px; + border-right: 1px solid var(--rc-line); +} + +.rc-entry-icon { + flex-shrink: 0; + color: var(--rc-icon); +} + +.rc-swatch { + flex-shrink: 0; + width: 14px; + height: 14px; + margin: 0 0.5px; + border-radius: 50%; + background: #ff6363; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); +} + +.rc-clamp { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.rc-preview { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.rc-preview-top { + position: relative; + height: 148px; + flex-shrink: 0; +} + +.rc-layer { + position: absolute; + inset: 0; + padding: 18px 20px; + opacity: 0; + transition: opacity 0.14s ease; +} + +.rc-layer[data-show] { + opacity: 1; + transition: opacity 0.2s ease 0.05s; +} + +.rc-layer-color { + display: flex; + flex-direction: column; + gap: 12px; +} + +.rc-preview-text { + font-size: 15px; + line-height: 1.5; + color: var(--rc-text); +} + +.rc-big-swatch { + height: 72px; + border-radius: 8px; + background: #ff6363; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.rc-info { + padding: 0 20px; + border-top: 1px solid var(--rc-line); +} + +.rc-info-head { + display: block; + padding: 14px 0 6px; + font-size: 12px; + line-height: 1.3; + font-weight: 600; + color: var(--rc-text-2); +} + +.rc-info-row { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.rc-info-val { + position: relative; + display: flex; + justify-content: flex-end; + width: 70px; + height: 6px; +} + +.rc-v { + position: absolute; + right: 0; + opacity: 0; + transition: opacity 0.14s ease; +} + +.rc-v[data-show] { + opacity: 1; + transition: opacity 0.2s ease 0.05s; +} + +/* Action bar, below the crop */ +.rc-actions { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 40px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + border-top: 1px solid var(--rc-line); + background: rgba(255, 255, 255, 0.02); +} + +.rc-mark { + width: 16px; + height: 16px; + border-radius: 4px; + background: #ff6363; +} + +.rc-actions-sep { + width: 1px; + height: 14px; + background: var(--rc-line); +} + +/* Keycaps read light over the dark window */ +.rc-stage .ss-key { + background: rgba(250, 250, 252, 0.94); + color: #1c1c1e; + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.12), 0 6px 16px rgba(0, 0, 0, 0.35); +} + +.rc-heads { + display: grid; +} + +.rc-head { + grid-area: 1 / 1; + opacity: 0; + transition: opacity 0.12s ease; +} + +.rc-head[data-show] { + opacity: 1; + transition: opacity 0.2s ease 0.06s; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/reorder/index.tsx b/skills/imported/benjitaylor/stageshow/examples/reorder/index.tsx new file mode 100644 index 0000000..dc2eb5f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/reorder/index.tsx @@ -0,0 +1,94 @@ +import { Bar, Check, Icon, Row, Stage, Text, Window, type Act } from '../../kit' +import './reorder.css' + +// Drag to reorder: a launch checklist. Beats: the cursor takes the fourth +// item by its grip, lifts it, carries it up; the rows it passes make room; +// it drops into second place. Plays once; the still is the payoff. +// +// What makes a reorder read as one gesture: +// - Every row sits at its slot by a transform, never by the list's flow, so +// rows move by gliding, and the lifted row can be anywhere without the +// others reflowing under it. +// - The lifted row follows the cursor 1:1 (no transition while dragging), +// scaled up a touch with a lift shadow; the rows it passes glide a slot to +// make room as its middle crosses theirs. +// - On release it glides from where the hand left it into its slot; it never +// jumps to the slot first. +// - Only the moved item and the item it passes say anything; the rest are +// placeholder rows with the same anatomy. +// +// Real strings: the title and the two items that trade places. + +const H = 48 +const ITEMS = [ + { id: 'a', label: null, w: 150 }, + { id: 'b', label: 'Write the changelog', w: 0 }, + { id: 'c', label: null, w: 120 }, + { id: 'd', label: 'Record the demo video', w: 0 }, + { id: 'e', label: null, w: 136 }, +] + +type S = { order: string[]; lifted: string | null; dy: number } + +const initial: S = { order: ['a', 'b', 'c', 'd', 'e'], lifted: null, dy: 0 } +const poster: S = { order: ['a', 'd', 'b', 'c', 'e'], lifted: null, dy: 0 } + +async function script(a: Act) { + await a.wait(800) + a.mark('grab') + const grip = a.rect('grip-d') + const from = { x: grip.x + grip.width / 2, y: grip.y + grip.height / 2 } + await a.move(from) + await a.wait(200) + let order = initial.order + await a.drag( + { x: from.x + 6, y: from.y - 2 * H }, + (at) => { + const dy = at.y - from.y + // The lifted row's middle decides its slot + const slot = Math.max(0, Math.min(4, Math.round((3 * H + dy) / H))) + const rest = initial.order.filter((id) => id !== 'd') + order = [...rest.slice(0, slot), 'd', ...rest.slice(slot)] + a.put({ lifted: 'd', dy, order }) + }, + { duration: 900 }, + ) + a.mark('drop') + a.put({ lifted: null, dy: 0, order }) + await a.wait(400) + await a.move({ x: from.x + 70, y: from.y + 40 }) + await a.wait(2200) +} + +export default function Reorder() { + return ( + + {(s) => ( + +
+ {ITEMS.map((item) => { + const slot = s.order.indexOf(item.id) + const lifted = s.lifted === item.id + // The lifted row rides the cursor from where it started + const y = lifted ? initial.order.indexOf(item.id) * H + s.dy : slot * H + return ( +
+ } + title={item.label ? {item.label} : } + > + + + + +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/reorder/reorder.css b/skills/imported/benjitaylor/stageshow/examples/reorder/reorder.css new file mode 100644 index 0000000..5688c4d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/reorder/reorder.css @@ -0,0 +1,76 @@ +.ro .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* Pinned top and sides, off the bottom just past the last row */ +.ro-win { + position: absolute; + top: 28px; + left: 60px; + width: 400px; + height: 360px; +} + +.ro-list { + position: relative; + margin: 12px 14px; +} + +/* Each row sits at its slot by a transform and glides between slots */ +.ro-slot { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 48px; + will-change: transform; + transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1); +} + +/* Lifted: on the cursor, no lag; above the rest, a touch larger, a lift + shadow. The scale and shadow ease in; the position never does */ +.ro-slot[data-lifted] { + z-index: 2; + transition: none; +} + +.ro-row { + height: 44px; + /* The 16px ring sits 14px from the top, the bottom and the left; the + grip's dots mirror it on the right */ + padding: 0 14px; + border-radius: 10px; + background: var(--ss-surface); + transition: box-shadow 0.25s ease, scale 0.25s cubic-bezier(0.22, 1, 0.36, 1); +} + +.ro-slot[data-lifted] .ro-row { + scale: 1.02; + box-shadow: var(--ss-shadow-lift); +} + +.ro-label { + color: var(--ss-text-strong); + font-size: 13px; +} + +.ro-grip { + display: grid; + place-items: center; + /* The dots' ink sits as far from the row's right edge as the ring's + ink does from its left */ + width: 16px; + margin-right: -4px; + /* No taller than the row's content box, or the grid track grows and + everything in the row sits low */ + height: 24px; + color: var(--ss-text); + opacity: 0.6; +} + +/* Narrow: a gutter each side */ +.ro .ss-stage[data-layout='compact'] .ro-win { + left: 16px; + width: 326px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ruled-bento/grid.css b/skills/imported/benjitaylor/stageshow/examples/ruled-bento/grid.css new file mode 100644 index 0000000..43ad89e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ruled-bento/grid.css @@ -0,0 +1,541 @@ +/* All rules belong to this example, including its responsive variants. */ +.ruled-bento { + /* Halite's features section: a ruled grid on dark. Everything nests under + &. One rounded container; the rules are the container's own + color showing through 1px gaps between the cells. Each picture is its own + Stage at 1:1, pinned to its cell's bottom and cropped by the cell. */ + --pad: 28px; + --ground: #08090a; + --cell: #0c0d0f; + --rule: #1d1e22; + --panel: #131417; + --panel-edge: #232429; + --ink: #f2f3f5; + --ink-2: #8b8f98; + --ink-3: #5c5f67; + --accent: #f5a524; + container-type: inline-size; + /* No padding of its own: on a page, the grid spans the content column */ + box-sizing: border-box; + background: transparent; + font-family: var(--rb-font, inherit); + -webkit-font-smoothing: antialiased; + + & .rb-grid { + display: grid; + gap: 1px; + max-width: 984px; + margin: 0 auto; + overflow: hidden; + box-sizing: border-box; + border: 1px solid var(--rule); + border-radius: 18px; + background: var(--rule); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: 420px 250px 320px 350px 270px; + grid-template-areas: 'hero' 'stat' 'map' 'restore' 'orm'; + } + + /* A docs column or a tablet: the hero across, a pair, then two bands */ + @container (min-width: 600px) { + & .rb-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: 390px 320px 300px 250px; + grid-template-areas: 'hero hero' 'stat map' 'restore restore' 'orm orm'; + } + } + + /* A homepage: 2 + 1, 1 + 2, then the full-width band */ + @container (min-width: 900px) { + & .rb-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: 390px 320px 220px; + grid-template-areas: 'hero hero stat' 'map restore restore' 'orm orm orm'; + } + } + + @container (max-width: 599px) { + & .rb-grid { + --pad: 24px; + } + } + + /* ---- The cell: one frame for all five ---- */ + + & .rb-cell { + position: relative; + overflow: hidden; + background: var(--cell); + } + + & .rb-cell--hero { grid-area: hero; } + & .rb-cell--stat { grid-area: stat; } + & .rb-cell--map { grid-area: map; } + & .rb-cell--restore { grid-area: restore; } + & .rb-cell--orm { grid-area: orm; } + + & .rb-copy { + position: relative; + z-index: 1; + max-width: 400px; + margin: 0; + padding: 26px var(--pad) 0; + font-size: 17px; + line-height: 24px; + letter-spacing: -0.012em; + color: var(--ink-2); + text-wrap: pretty; + } + + & .rb-copy b { + font-weight: 600; + color: var(--ink); + } + + & .rb-cell--hero .rb-copy, + & .rb-cell--restore .rb-copy { + max-width: 470px; + } + + & .rb-cell--orm .rb-copy { + max-width: 760px; + } + + /* ---- Pictures: pinned to the cell's bottom ---- */ + + & .rb-picture { + position: absolute; + bottom: 0; + } + + & .rb-picture--hero, + & .rb-picture--restore, + & .rb-picture--map { + left: 0; + width: 100%; + } + + & .rb-picture--orm { + /* Half the cell: the band spans the grid, which is the container less nothing on a page */ + --half: min(50cqw, 492px); + left: 50%; + /* The rows drift, so they soften into the cell at both sides */ + -webkit-mask-image: linear-gradient(90deg, transparent calc(50% - var(--half)), #000 calc(50% - var(--half) + 72px), #000 calc(50% + var(--half) - 72px), transparent calc(50% + var(--half))); + mask-image: linear-gradient(90deg, transparent calc(50% - var(--half)), #000 calc(50% - var(--half) + 72px), #000 calc(50% + var(--half) - 72px), transparent calc(50% + var(--half))); + width: 1200px; + margin-left: -600px; + } + + & .rb-stage .ss-stage { + overflow: hidden; + background: transparent; + } + + /* ---- Create (hero): the one window ---- */ + + & .rb-term { + position: absolute; + top: 18px; + left: var(--pad); + width: calc(100% + 40px); + height: 330px; + --ss-term-bg: #141518; + --ss-term-path: var(--ink-3); + --ss-term-sigil: var(--accent); + --ss-term-dim: #6a6d75; + --ss-term-ok: #5fd49a; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 24px 48px -16px rgba(0, 0, 0, 0.7); + } + + & .rb-term__body { + gap: 6px; + padding: 8px 20px 20px; + font-size: 13px; + line-height: 20px; + color: #c9cbd1; + } + + + /* Output arrives line by line, once */ + & .rb-out { + opacity: 0; + transform: translateY(4px); + will-change: transform; + transition: opacity 0.35s ease, transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-delay: calc(var(--i) * 0.42s + 0.1s); + } + + & .rb-term[data-on] .rb-out { + opacity: 1; + transform: none; + } + + /* The ready line waits for the bar */ + & .rb-ready { transition-delay: 1.9s; } + & .rb-url { transition-delay: 2.15s; } + & .rb-term .rb-out.ss-term__prompt { transition-delay: 2.4s; } + + & .rb-migrate { + display: flex; + align-items: center; + gap: 10px; + width: calc(var(--rb-picture-width) - var(--pad) - 60px); + white-space: nowrap; + } + + & .rb-meter { + position: relative; + width: 220px; + flex: 0 1 220px; + min-width: 30px; + height: 6px; + overflow: hidden; + border-radius: 3px; + background: #24262b; + } + + & .rb-term[data-layout='compact'] .rb-meter { + flex-basis: 120px; + } + + /* The kit Meter in the terminal's accent, filling in step with the count */ + & .rb-meter .ss-meter__fill { + background: var(--accent); + transition-duration: 1s; + } + + & .rb-count { + flex: none; + font-variant-numeric: tabular-nums; + } + + & .rb-count .ss-count { + display: inline-block; + min-width: 2ch; + text-align: right; + } + + /* ---- Reads (stat) ---- */ + + & .rb-stat { + display: flex; + flex-direction: column; + box-sizing: border-box; + height: 100%; + padding: 26px var(--pad) 30px; + } + + & .rb-stat-lead { + display: flex; + align-items: center; + gap: 8px; + font-size: 17px; + line-height: 24px; + letter-spacing: -0.012em; + font-weight: 600; + color: var(--ink); + } + + & .rb-bolt { + color: var(--accent); + } + + & .rb-stat-number { + display: block; + margin-top: auto; + font-size: 84px; + line-height: 1; + font-weight: 600; + letter-spacing: -0.045em; + color: var(--ink); + font-variant-numeric: tabular-nums; + } + + & .rb-stat-unit { + margin-left: 4px; + font-size: 40px; + letter-spacing: -0.02em; + color: var(--ink-2); + } + + & .rb-stat-label { + max-width: 230px; + margin-top: 14px; + font-size: 15px; + line-height: 21px; + color: var(--ink-2); + text-wrap: pretty; + } + + /* ---- Regions (diagram) ---- */ + + & .rb-map { + position: absolute; + inset: 0; + /* A dot field for ground, fading out toward the stage's edges */ + background-image: radial-gradient(circle at center, #23252a 1px, transparent 1.4px); + background-size: 12px 12px; + background-position: 4px 2px; + -webkit-mask-image: radial-gradient(70% 80% at 50% 55%, #000 45%, transparent 100%); + mask-image: radial-gradient(70% 80% at 50% 55%, #000 45%, transparent 100%); + } + + & .rb-map__lines { + position: absolute; + inset: 0; + overflow: visible; + } + + & .rb-arc { + fill: none; + stroke: #2b2d33; + stroke-width: 1; + } + + & .rb-arc--lit { + stroke: var(--accent); + stroke-width: 1.25; + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 0.9s cubic-bezier(0.45, 0, 0.2, 1); + transition-delay: calc(var(--i) * 0.09s); + } + + & .rb-map[data-on] .rb-arc--lit { + stroke-dashoffset: 0; + } + + & .rb-node { + position: absolute; + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 9px 0 8px; + border-radius: 12px; + background: var(--panel); + box-shadow: inset 0 0 0 1px var(--panel-edge); + font: 500 11px/1 var(--ss-mono); + color: #b4b7be; + /* Centered on its point by the dot, not the pill */ + transform: translate(-14px, -50%); + } + + & .rb-node__dot { + width: 6px; + height: 6px; + border-radius: 3px; + background: #3a3c43; + transition: background-color 0.25s ease; + transition-delay: calc(var(--i) * 0.09s + 0.75s); + } + + & .rb-map[data-on] .rb-node__dot { + background: var(--accent); + } + + & .rb-node[data-primary] { + color: var(--ink); + box-shadow: inset 0 0 0 1px #3a3328; + } + + & .rb-node[data-primary] .rb-node__dot { + background: var(--accent); + box-shadow: 0 0 0 3px rgba(245, 165, 36, 0.22); + } + + /* ---- Restore (component) ---- */ + + & .rb-restore { + position: absolute; + top: 6px; + left: var(--pad); + box-sizing: border-box; + width: calc(100% + 40px); + height: 250px; + padding: 20px 22px; + border-radius: 14px; + background: var(--panel); + box-shadow: inset 0 0 0 1px var(--panel-edge); + } + + & .rb-restore[data-layout='compact'] { + padding: 18px; + } + + & .rb-restore__head { + display: grid; + grid-template-columns: auto auto; + align-items: center; + justify-content: start; + gap: 4px 14px; + } + + & .rb-restore[data-layout='compact'] .rb-restore__head { + grid-template-columns: minmax(0, 1fr); + } + + & .rb-restore__label { + font-size: 13px; + color: var(--ink-2); + } + + & .rb-restore__time { + font: 500 20px/26px var(--ss-mono); + letter-spacing: -0.02em; + color: var(--ink); + font-variant-numeric: tabular-nums; + } + + /* The time turns as the handle lands, not before */ + & .rb-restore[data-on] .ss-states__item[data-on] { + transition-delay: 0.95s, 0.95s, 0.95s; + } + + & .rb-restore[data-on] .ss-states__item:not([data-on]) { + transition-delay: 0.85s, 0.85s, 1s; + } + + & .rb-track { + --gutter: 3px; + position: relative; + display: grid; + grid-template-columns: repeat(var(--n), minmax(0, 1fr)); + align-items: end; + gap: var(--gutter); + height: 84px; + margin-top: 52px; + /* The handle travels along this, in cells */ + --cell-w: calc((100% + var(--gutter)) / var(--n)); + } + + & .rb-restore .rb-track { + /* Stop the track short of the crop so its end reads as now */ + margin-right: calc(var(--pad) + 40px); + } + + & .rb-track__bar { + border-radius: 2px; + background: #34363d; + transition: background-color 0.4s ease 0.6s; + } + + /* Everything after the restore point dims once the handle passes it */ + & .rb-restore[data-on] .rb-track__bar[data-after] { + background: #1e1f23; + } + + & .rb-event { + position: absolute; + top: -22px; + bottom: 0; + left: calc(var(--cell-w) * (var(--drop) + 1.5) - var(--gutter) / 2); + width: 2px; + margin-left: -1px; + border-radius: 1px; + background: #f0605a; + } + + & .rb-event__tag { + position: absolute; + top: -26px; + left: 8px; + height: 20px; + padding: 0 7px; + border-radius: 6px; + background: #3a1a1a; + font: 500 11px/20px var(--ss-mono); + color: #f6918c; + white-space: nowrap; + } + + & .rb-restore[data-layout="wide"] .rb-event__tag { left: auto; right: 8px; } + + /* The handle: starts at now, slides back to just before the drop */ + & .rb-handle { + position: absolute; + top: -14px; + bottom: -6px; + left: 100%; + width: 2px; + margin-left: -1px; + border-radius: 1px; + background: var(--accent); + will-change: transform; + transition: left 1s cubic-bezier(0.45, 0, 0.15, 1) 0.1s; + } + + & .rb-handle::before { + content: ''; + position: absolute; + top: -5px; + left: 50%; + width: 12px; + height: 12px; + margin-left: -6px; + border-radius: 6px; + background: var(--accent); + box-shadow: 0 0 0 3px var(--panel); + } + + & .rb-restore[data-on] .rb-handle { + left: calc(var(--cell-w) * var(--drop) - var(--gutter) / 2); + } + + & .rb-axis { + position: relative; + height: 14px; + margin-top: 10px; + font: 11px/14px var(--ss-mono); + color: var(--ink-3); + } + + & .rb-restore .rb-axis { + margin-right: calc(var(--pad) + 40px); + } + + & .rb-axis span { + position: absolute; + left: calc((100% + 3px) / var(--n) * var(--at)); + } + + /* ---- Drivers (collection) ---- */ + + & .rb-rows { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 14px; + } + + & .rb-row { + display: flex; + justify-content: center; + gap: 12px; + transform: translateX(var(--off)); + will-change: transform; + transition: transform 2.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + + & .rb-rows[data-on] .rb-row { + transform: translateX(calc(var(--off) + var(--dir) * 56px)); + } + + & .rb-chip { + display: inline-flex; + align-items: center; + flex: none; + height: 40px; + padding: 0 16px; + border-radius: 10px; + background: var(--panel); + box-shadow: inset 0 0 0 1px var(--panel-edge); + font: 500 13px/1 var(--ss-mono); + color: #b4b7be; + white-space: nowrap; + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/ruled-bento/index.tsx b/skills/imported/benjitaylor/stageshow/examples/ruled-bento/index.tsx new file mode 100644 index 0000000..58224dd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/ruled-bento/index.tsx @@ -0,0 +1,347 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { Count, Icon, Meter, Prompt, Caret, Stage, States, Window, useReducedMotion } from '../../kit' +import type { Act, Layout } from '../../kit' +import './grid.css' + +// A ruled bento on dark: the features section of Halite, an invented edge +// database. One rounded container, 1px rules between the cells, no gaps, +// mixed spans (2 + 1, then 1 + 2, then a full-width band), the way Linear, +// Framer and Raycast's developer page set theirs. +// +// Tile Span Kind The one thing Moves +// Create 2 × 1 Hero UI `halite create`, the only window the migration bar fills, Ready prints +// Reads 1 × 1 Stat "4ms" at the median counts down from 120 +// Regions 1 × 1 Diagram a primary and six replicas the lines draw out, replicas light +// Restore 2 × 1 Component the restore scrubber the handle slides back past the bad drop +// Drivers 3 × 1 Collection ORM names, bleeding off both sides the rows drift apart +// +// Two of five tiles are UI, one window. Across and down, no kind sits next +// to itself. Each picture is its own Stage at 1:1, pinned to its cell's +// bottom; the copy is page text. Each cell plays one beat, once, 110ms +// after the one before it in reading order. + +const STEP = 110 +const LEAD = 450 + +type Beat = { on: boolean; fill?: boolean } +const before: Beat = { on: false } +const after: Beat = { on: true, fill: true } +const beat = (i: number) => async (a: Act) => { + await a.wait(LEAD + i * STEP) + await a.set({ on: true }) +} + +function Tile({ area, title, children, picture }: { area: string; title: ReactNode; children?: ReactNode; picture?: ReactNode }) { + return ( +
+ {title ? ( +

+ {title} {children} +

+ ) : ( + children + )} + {picture} +
+ ) +} + +// Measure the tile, not the page: a wide page can still contain a narrow tile. +function usePictureWidth() { + const ref = useRef(null) + const [width, setWidth] = useState(340) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => setWidth(node.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] as const +} + +/* ---------------- Create (hero) ---------------- */ + +// The hero's one beat: the output prints, and the bar fills once its line is in +const create = async (a: Act) => { + await a.wait(LEAD) + await a.set({ on: true }) + await a.wait(800) + await a.set({ on: true, fill: true }) +} + +function Terminal({ on, fill, layout }: { on: boolean; fill?: boolean; layout: Layout }) { + const wide = layout === 'wide' + return ( + +
+ halite create shop +
+ Created shop in iad1 +
+
+ Migrating + + + + /14 + +
+
+ ✓ Ready in 1.9s +
+
+ {wide ? 'DATABASE_URL ' : ''} + {wide ? 'halite://shop.demo.example' : 'halite://shop.demo…'} +
+ + + +
+
+ ) +} + +function Create() { + const [ref, width] = usePictureWidth() + return ( + A database in one command.} + picture={ +
+ width={width} height={300} theme="dark" initial={before} poster={after} play="once" script={create} className="rb-stage"> + {(s) => } + +
+ } + > + Halite provisions it, runs your migrations and hands back a URL. +
+ ) +} + +/* ---------------- Reads (stat) ---------------- */ + +function useInView(delay: number) { + const ref = useRef(null) + const [seen, setSeen] = useState(false) + useEffect(() => { + const el = ref.current + if (!el) return + let t = 0 + const io = new IntersectionObserver(([e]) => { + if (!e.isIntersecting) return + io.disconnect() + t = window.setTimeout(() => setSeen(true), delay) + }, { threshold: 0.4 }) + io.observe(el) + return () => { + io.disconnect() + clearTimeout(t) + } + }, [delay]) + return [ref, seen] as const +} + +function Reads() { + const reduced = useReducedMotion() + const [ref, seen] = useInView(LEAD + STEP) + return ( + +
+ + + Reads in + + + + ms + + at the median, from the replica nearest each user. +
+
+ ) +} + +/* ---------------- Regions (diagram) ---------------- */ + +// Rough world positions on a 380 × 236 stage, the primary in Virginia +// Every pill stays inside x 46 to 334, the part the narrowest cell shows +const PRIMARY = { id: 'iad1', x: 124, y: 96 } +const REPLICAS = [ + { id: 'sfo1', x: 62, y: 58 }, + { id: 'gru1', x: 148, y: 196 }, + { id: 'lhr1', x: 196, y: 40 }, + { id: 'fra1', x: 232, y: 86 }, + { id: 'sin1', x: 270, y: 146 }, + { id: 'syd1', x: 286, y: 204 }, +] + +function arc(a: { x: number; y: number }, b: { x: number; y: number }) { + // A gentle bow above the straight line, more for longer runs + const mx = (a.x + b.x) / 2 + const my = (a.y + b.y) / 2 + const d = Math.hypot(b.x - a.x, b.y - a.y) + return `M${a.x} ${a.y} Q${mx} ${my - d * 0.22} ${b.x} ${b.y}` +} + +function Regions() { + const [ref, width] = usePictureWidth() + // Keep the region names native-size while moving their points inward. + const point = (p: typeof PRIMARY) => ({ ...p, x: width < 360 ? 38 + (p.x - 62) / 224 * (width - 106) : p.x + (width - 380) / 2 }) + const primary = point(PRIMARY) + const replicas = REPLICAS.map(point) + return ( + Replicas near every user.} + picture={ +
+ width={width} height={236} theme="dark" initial={before} poster={after} play="once" script={beat(2)} className="rb-stage"> + {(s) => ( +
+ + {replicas.map((r, i) => ( + + + + + ))} + + + + {PRIMARY.id} + + {replicas.map((r, i) => ( + + + {r.id} + + ))} +
+ )} + +
+ } + > + Reads never cross an ocean. +
+ ) +} + +/* ---------------- Restore (component) ---------------- */ + +// Write activity per minute, 13:00 to 14:40: varied, with a quiet lunch dip +const WRITES = Array.from({ length: 50 }, (_, i) => { + const base = 0.38 + 0.3 * Math.sin(i * 0.37) * Math.cos(i * 0.11) + 0.18 * Math.sin(i * 1.7) + const dip = i > 14 && i < 22 ? 0.45 : 1 + return Math.max(0.12, Math.min(1, base * dip + 0.12)) +}) +// Two minutes a bar from 13:00: the handle stops at 14:02, the table went at 14:04 +const DROP = 31 + +function Restore() { + const [ref, width] = usePictureWidth() + const layout = width < 600 ? 'compact' : 'wide' + return ( + Undo the bad migration.} + picture={ +
+ width={width} height={layout === 'compact' ? 236 : 216} theme="dark" initial={before} poster={after} play="once" script={beat(3)} className="rb-stage"> + {(s) => { + const wide = layout === 'wide' + const bars = wide ? WRITES : WRITES.slice(22, 46) + const drop = wide ? DROP : DROP - 22 + return ( +
+
+ Restore shop to + +
+
+ {bars.map((h, i) => ( + = drop || undefined} style={{ height: `${Math.round(h * 100)}%` }} /> + ))} + + DROP TABLE carts + + +
+
+ {(wide ? [['13:00', 0], ['13:30', 15], ['14:00', 30]] : [['13:50', 3], ['14:10', 13]]).map(([t, at]) => ( + {t} + ))} +
+
+ ) + }} + +
+ } + > + Rewind to any second in the last 30 days. +
+ ) +} + +/* ---------------- Drivers (collection) ---------------- */ + +const ROWS = [ + ['ecto', 'prisma', 'drizzle', 'kysely', 'sqlalchemy', 'gorm', 'diesel', 'sequelize', 'typeorm', 'exposed'], + ['knex', 'active record', 'sqlx', 'django', 'jooq', 'ent', 'mikro-orm', 'hibernate', 'pgx', 'slonik'], + ['dapper', 'peewee', 'objection', 'bun', 'sqlc', 'psycopg', 'tortoise', 'doctrine', 'ebean', 'room'], +] + +function Drivers() { + return ( + Bring your ORM.} + picture={ +
+ width={1200} height={150} theme="dark" initial={before} poster={after} play="once" script={beat(4)} className="rb-stage"> + {(s) => ( +
+ {ROWS.map((row, r) => ( +
+ {row.map((name) => ( + + {name} + + ))} +
+ ))} +
+ )} + +
+ } + > + Halite speaks the Postgres wire protocol, so your driver already works. +
+ ) +} + +export default function RuledBento() { + return ( +
+
+ + + + + +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/share/index.tsx b/skills/imported/benjitaylor/stageshow/examples/share/index.tsx new file mode 100644 index 0000000..e9f2955 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/share/index.tsx @@ -0,0 +1,221 @@ +import { Check as Tick, ChevronDown, FileText, Lock } from 'lucide-react' +import { Avatar, Bar, Collapse, Facepile, Field, Menu, Popover, Roll, Row, Stage, Text, Window, type Act, type Layout } from '../../kit' +import './share.css' + +// A document editor's Share popover. Beats: Share opens the popover over the +// document, an email is typed and committed as a chip with Enter, the role +// menu sets Can edit, Invite drops the new person in at the top of the access +// list. Share closes the popover; the list is reset while it is out of sight. +// +// What makes it hold up frame by frame: +// - The popover is measured from the Share button: its right edge is the +// button's right edge, its top 8px below it. Nothing is hard-coded. +// - The role picker sits inside the field, as wide as its longest label, so +// Can view to Can edit moves nothing; only the word changes. The field is +// wide enough for the whole email as text and as a chip. +// - The role menu opens under its trigger and runs to the popover's inner +// edge, so it covers the role column whole: no word peeks out beside it. +// - The document behind is placeholder bars, so the popover's edge only ever +// crosses filler, never a word. +// - Every person is the same two-line Row, the new one included. +// - The Enter keycap shows over the document, clear of the popover. +// +// Real strings: the title, the popover's labels, the email, the roles and the +// people. The document behind is placeholder. + +type S = { + open: boolean + email: string + typing: boolean + chip: boolean + menu: boolean + role: string + hover: number | undefined + added: boolean +} + +const initial: S = { open: false, email: '', typing: false, chip: false, menu: false, role: 'Can view', hover: undefined, added: false } + +// The still is the payoff: the popover open with the new person added +const poster: S = { ...initial, open: true, added: true } + +const DOC = 'Launch brief' +const EMAIL = 'priya@lumen.co' +const ROLES = ['Can view', 'Can comment', 'Can edit'] +const EDIT = ROLES.indexOf('Can edit') + +async function script(a: Act) { + await a.wait(600) + + // 1. Share opens the popover; the field takes focus + a.mark('open') + await a.click({ at: 'share', x: 0.5, y: 0.55 }) + await a.set({ open: true }) + await a.wait(700) + + // 2. Type the email, then Enter commits it as a chip in place + a.mark('type') + await a.click({ at: 'field', x: 0.3, y: 0.5 }) + await a.set({ typing: true }) + await a.type('email', EMAIL, { interval: 55 }) + await a.set({ typing: false }) + await a.wait(250) + await a.key('enter') + await a.set({ chip: true }) + await a.wait(600) + + // 3. The role menu: open it, run down to Can edit, pick it + a.mark('role') + await a.click({ at: 'role', x: 0.45, y: 0.5 }) + await a.set({ menu: true }) + await a.wait(450) + await a.move({ at: `role-${EDIT}`, x: 0.4, y: 0.55 }) + await a.wait(300) + await a.click() + await a.set({ menu: false, role: 'Can edit' }) + await a.wait(500) + + // 4. Invite: the person drops in at the top of the list, then the field + // clears. Two beats: one cause, one effect at a time, so the eye follows + // the chip into the list before the field empties behind it + a.mark('invite') + await a.click({ at: 'invite', x: 0.5, y: 0.55 }) + await a.set({ added: true }) + await a.wait(350) + await a.set({ chip: false, email: '', role: 'Can view' }) + await a.wait(1850) + + // 5. Share again closes it; the list resets out of sight + a.mark('close') + await a.click({ at: 'share', x: 0.5, y: 0.55 }) + await a.set({ open: false }) + await a.wait(450) + await a.snap({ added: false }) + await a.wait(500) +} + +type Geo = { win: { x: number; y: number; w: number; h: number }; pop: number } + +// Wide: one 24px gutter above and either side of the window, which runs off +// the bottom. Compact: the same at the column's width, a narrower popover. +const GEO: Record = { + wide: { win: { x: 24, y: 24, w: 432, h: 360 }, pop: 344 }, + compact: { win: { x: 16, y: 20, w: 326, h: 400 }, pop: 310 }, +} + +export default function ShareDemo() { + return ( + + width={480} + height={340} + compact={{ below: 440, width: 358, height: 380 }} + initial={initial} + poster={poster} + script={script} + keys={{ wide: 'bottom-left', compact: 'bottom-left' }} + cursor={{ at: { at: 'doc', x: 0.3, y: 0.6 } }} + className="sh" + > + {(s, rect, layout) => { + const g = GEO[layout] + const share = rect('share') + const pop = { left: share.x + share.width - g.pop, top: share.y + share.height + 8, width: g.pop } + return ( + <> + + + {DOC} + + } + toolbar={ + + + + + Share + + + } + body={{ className: 'sh-body', 'data-crop': true }} + > +
+ + + + + + + +
+
+ + +
+ {`Share “${DOC}”`} +
+
+ + + {s.role} + + + + } + /> + + Invite + +
+
+
People with access
+
+ + + + + +
+
+ + ( + + {r === s.role && } + {r} + + ))} + targets={ROLES.map((_, i) => `role-${i}`)} + /> + + + + ) + }} + + ) +} + +/** The role menu sits 6px under the field, from its trigger's left edge to + * 6px past the popover's inner edge (over the role column), in the popover's own coordinates. */ +function roleMenu(rect: (name: string) => { x: number; y: number; width: number; height: number }, pop: { left: number; top: number; width: number }) { + const r = rect('role') + const f = rect('field') + const left = r.x - pop.left - 4 + return { left, top: f.y - pop.top + f.height + 6, width: pop.width - 10 - left } +} + +function Person({ name, email, role }: { name: string; email: string; role: string }) { + return } title={name} subtitle={email} meta={{role}} /> +} diff --git a/skills/imported/benjitaylor/stageshow/examples/share/share.css b/skills/imported/benjitaylor/stageshow/examples/share/share.css new file mode 100644 index 0000000..58918db --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/share/share.css @@ -0,0 +1,209 @@ +/* A document editor, light. The window sits one gutter in from the top and + both sides and runs off the bottom; the Share popover is the hero. */ + +.sh .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +.sh-window { + position: absolute; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-hero); +} + +/* ---- Toolbar ---- */ + +/* A 52px unified toolbar, as recent macOS document windows have: room after + the lights, and the Share button inset the same 10.5px from the top and the + right edge */ +.sh-window > .ss-window__bar { + height: 52px; + padding-right: 10.5px; +} + +.sh-lead { + display: flex; + margin-left: 8px; + align-items: center; + gap: 7px; + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.sh-lead svg { + color: var(--ss-icon); +} + +.sh-tools { + display: flex; + align-items: center; + gap: 12px; +} + +.sh-share { + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px 0 10px; + border-radius: 8px; + background: var(--ss-accent); + color: #fff; + font-size: 12px; + font-weight: 600; + transition: filter 0.15s ease; +} + +.sh-share[data-hover], +.sh-share[data-on] { + filter: brightness(0.92); +} + +/* ---- The document behind ---- */ + +.sh-body { + background: var(--ss-surface); +} + +.sh-doc { + display: flex; + flex-direction: column; + gap: 12px; + padding: 26px 28px; +} + +.sh-doc__title { + margin-bottom: 8px; +} + +.sh-doc__gap { + height: 6px; +} + +/* ---- The popover ---- */ + +.sh-pop { + padding: 0; + font-size: 12.5px; + --ss-popover-shadow: var(--ss-shadow-float); +} + +.sh-pop__head { + padding: 14px 16px 10px; +} + +.sh-pop__title { + font-size: 13.5px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.sh-invite { + display: flex; + align-items: center; + gap: 6px; + padding: 0 16px; +} + +.sh-field { + flex: 1; + min-width: 0; +} + +/* Inside the field, pinned right. The slot is as wide as the longest label + it shows, so the field never moves; the hover surface is the picker + itself, label and chevron with an even inset, and grows to the left with + the label as the Roll measures it */ +.sh-role { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + width: 96px; + margin-right: -6px; +} + +.sh-role__hit { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + padding: 0 6px 0 7px; + border-radius: 6px; + color: var(--ss-text); + white-space: nowrap; + transition: background-color 0.15s ease; +} + +.sh-role__hit svg { + flex-shrink: 0; + color: var(--ss-icon); +} + +.sh-role__hit[data-hover], +.sh-role__hit[data-on] { + background: var(--ss-ink-2); +} + +.sh-invite__btn { + display: inline-flex; + flex-shrink: 0; + align-items: center; + height: var(--ss-control); + padding: 0 13px; + border-radius: var(--ss-radius-md); + background: var(--ss-ink-2); + color: var(--ss-text); + font-weight: 600; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.sh-invite__btn[data-ready] { + background: var(--ss-accent); + color: #fff; +} + +.sh-access { + padding: 12px 16px 6px; +} + +.sh-access__label { + margin-bottom: 2px; + font-size: 11.5px; + font-weight: 500; + color: var(--ss-text); +} + +/* Rows bleed into the popover's inset so their avatars sit on its 16px edge */ +.sh-people { + margin: 0 -6px; +} + +.sh-person__role { + color: var(--ss-text); +} + +/* ---- The role menu ---- */ + +.sh-menu__item { + display: flex; + align-items: center; + gap: 6px; +} + +.sh-menu__tick { + display: grid; + place-items: center; + width: 12px; + color: var(--ss-accent); +} + +/* Compact: a narrower role picker leaves the chip its whole email */ +.sh .ss-stage[data-layout="compact"] .sh-role { + width: 86px; +} + +.sh .ss-stage[data-layout="compact"] .sh-invite__btn { + padding: 0 11px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx b/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx new file mode 100644 index 0000000..ac719ed --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx @@ -0,0 +1,462 @@ +import { + ArrowLeft, + ArrowRight, + AtSign, + Bell, + Bookmark, + ChevronDown, + CircleHelp, + Clock, + Ellipsis, + EllipsisVertical, + Forward, + Hash, + Headphones, + House, + MessageSquareText, + MessagesSquare, + Mic, + Plus, + Search, + SendHorizontal, + Smile, + SmilePlus, + SquarePen, + Type, + X, + type LucideIcon, +} from 'lucide-react' +import { Bar, Collapse, Stage, Text, Window, type Act } from '../../kit' +import './slack.css' + +// Slack, in #design. Maya's message arrives, the user hovers it and taps 👀 +// from the quick reactions, opens the thread and replies. Closing the thread +// leaves the reaction and reply summary in place until the host's Replay. +// +// Icons: Slack's own set isn't available, so Lucide is used on purpose: its +// rounded 1.75 stroke is the closest match to Slack's outline glyphs. + +type S = { + arrived: boolean + reacted: boolean + thread: boolean + focus: boolean + typing: boolean + draft: string + replied: boolean +} + +const initial: S = { arrived: true, reacted: false, thread: false, focus: false, typing: false, draft: '', replied: false } + +// Under reduced motion: the finished moment, reacted and replied in the open thread +const poster: S = { ...initial, arrived: true, reacted: true, thread: true, replied: true } + +const MESSAGE = 'The new onboarding flow is ready for review. Can you take a look before standup?' +const REPLY = "On it, I'll leave notes in the file" +const REST = { x: 560, y: 250 } + +async function script(a: Act) { + // Opens on the channel with Maya's message already in. + await a.wait(1200) + + // Hover the message: the row lights up and its toolbar shows + a.mark('hover') + await a.move({ at: 'msg', x: 0.42, y: 0.55 }) + await a.wait(500) + + // 👀 from the quick reactions + await a.move({ at: 'react-eyes', x: 0.82, y: 0.86 }) + await a.wait(220) + a.mark('react') + await a.click() + await a.set({ reacted: true }) + await a.wait(1000) + + // Reply in thread + await a.move({ at: 'reply-btn', x: 0.78, y: 0.82 }) + await a.wait(260) + a.mark('thread') + await a.click() + await a.set({ thread: true }) + await a.wait(800) + + // Write the reply + await a.move({ at: 'tbox', x: 0.62, y: 0.3 }) + await a.click() + await a.set({ focus: true }) + await a.wait(350) + await a.set({ typing: true }) + await a.type('draft', REPLY, { interval: 52 }) + await a.set({ typing: false }) + await a.wait(450) + a.mark('send') + await a.key('enter') + await a.set({ draft: '', replied: true }) + await a.wait(500) + // Step off the reply as it lands, so the pointer isn't parked on its words + const box = a.rect('tbox') + await a.move({ x: box.x + box.width * 0.7, y: box.y + box.height + 60 }) + await a.wait(1800) + + // Close the thread and hold the result. Replay is the explicit reset. + a.mark('exit') + await a.move({ at: 'tclose', x: 0.5, y: 0.5 }) + await a.wait(200) + await a.click() + await a.set({ thread: false, focus: false }) + await a.wait(500) + await a.move(REST) + await a.wait(1500) + a.mark('done') +} + +function I({ icon: Glyph, size = 16 }: { icon: LucideIcon; size?: number }) { + return +} + +/* ---- Pieces ---- */ + +function Face({ who, size = 36 }: { who: 'maya' | 'alex' | 'p1' | 'p2' | 'p3'; size?: number }) { + const letter = { maya: 'M', alex: 'A', p1: 'J', p2: 'R', p3: 'T' }[who] + return ( + + {letter} + + ) +} + +function SideBar({ w, light }: { w: number; light?: boolean }) { + // The sidebar is aubergine, so its placeholders are a light purple from the same family + return +} + +function Sidebar() { + return ( + + ) +} + +function Rail() { + const items: { icon: LucideIcon; label: string; active?: boolean }[] = [ + { icon: House, label: 'Home', active: true }, + { icon: MessagesSquare, label: 'DMs' }, + { icon: Bell, label: 'Activity' }, + { icon: Ellipsis, label: 'More' }, + ] + return ( + + ) +} + +function Older({ who, name, lines }: { who: 'p1' | 'p2'; name: number; lines: number[] }) { + return ( +
+ +
+
+ + +
+
+ {lines.map((w, i) => ( + + ))} +
+
+
+ ) +} + +function Reaction({ mine }: { mine?: boolean }) { + return ( + + 👀 + 1 + + ) +} + +function HoverBar() { + const quick = ['✅', '👀', '🙌'] + return ( +
+ {quick.map((e) => ( + + {e} + + ))} + + + + + + + + + + + + + + + + +
+ ) +} + +function Composer({ placeholder, draft = '', focus, typing, target }: { placeholder: string; draft?: string; focus?: boolean; typing?: boolean; target?: string }) { + return ( +
+
+ 0 || undefined}> + {placeholder} + + {draft} + +
+
+ + + + + + + + + + 0 || undefined}> + + +
+
+ ) +} + +/* ---- Scene ---- */ + +export default function SlackDemo() { + return ( + + {(s) => ( + + + + + + + + + Search Northwind + + + } + toolbar={ + + + + } + > +
+ +
+ + +
+
+ + + design + + + + + + + + + + 12 + + + + +
+
+ + + Messages + + + + + + + + +
+ +
+
+ + + +
+ + + +
+
+ +
+
+ Maya Chen + 10:42 AM +
+

{MESSAGE}

+ +
+ +
+
+ +
+ + 1 reply + Last reply just now +
+
+
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+
+
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/slack/slack.css b/skills/imported/benjitaylor/stageshow/examples/slack/slack.css new file mode 100644 index 0000000..60fafa4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/slack/slack.css @@ -0,0 +1,847 @@ +/* Slack, Aubergine theme. One purple family for the chrome, Slack's own + neutral grays for the content, Slack blue only on what the user did. */ + +.sl { + --sl-aub: #3f0e40; + --sl-side: #522653; + --sl-aub-raise: #5f3360; + --sl-on-aub: #e7dbe8; + --sl-on-aub-soft: #c9b5ca; + --sl-ink: #1d1c1d; + --sl-ink-2: #616061; + --sl-ink-3: #868686; + --sl-line: #e2e2e2; + --sl-line-2: #d0d0d0; + --sl-hover: #f6f6f6; + --sl-blue: #1264a3; + --sl-blue-line: #1d9bd1; + --sl-blue-soft: #e8f5fa; + --sl-green: #007a5a; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +} + +.sl .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +.sl-grow { + flex: 1; +} + +.sl-i { + display: block; + flex-shrink: 0; +} + +/* ---- Window and top bar ---- */ + +.sl-win { + position: absolute; + left: 40px; + top: 40px; + width: 860px; + height: 560px; + background: var(--sl-aub); + box-shadow: var(--ss-shadow-hero); +} + +.sl-win .ss-window__bar { + position: relative; + background: var(--sl-aub); + border-bottom: none; + color: var(--sl-on-aub-soft); +} + +.sl-win .ss-window__spacer { + flex: 1; +} + +.sl-top__nav { + display: flex; + gap: 12px; + margin-left: 26px; +} + +.sl-top__search { + position: absolute; + top: 7px; + left: 50%; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + width: 380px; + height: 24px; + margin-left: -190px; + border-radius: 6px; + background: var(--sl-aub-raise); + color: var(--sl-on-aub); + font-size: 12.5px; +} + +.sl-top__help { + display: flex; + color: var(--sl-on-aub-soft); +} + +.sl-body { + display: flex; + height: 100%; + background: var(--sl-aub); +} + +/* ---- Rail ---- */ + +.sl-rail { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + gap: 12px; + width: 68px; + padding: 4px 0 14px; +} + +.sl-rail__ws { + display: grid; + place-items: center; + width: 36px; + height: 36px; + margin-bottom: 4px; + border-radius: 9px; + background: #f3eaf3; + color: var(--sl-aub); + font-size: 17px; + font-weight: 800; +} + +.sl-rail__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + color: var(--sl-on-aub); +} + +.sl-rail__btn { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 9px; +} + +.sl-rail__item[data-active] .sl-rail__btn { + background: var(--sl-aub-raise); + color: #fff; +} + +.sl-rail__label { + font-size: 11px; + font-weight: 600; + line-height: 1.3; +} + +.sl-rail__plus { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--sl-aub-raise); + color: var(--sl-on-aub); +} + +.sl-rail__me { + position: relative; + display: flex; +} + +.sl-rail__status { + position: absolute; + right: -3px; + bottom: -3px; + width: 12px; + height: 12px; + border: 2px solid var(--sl-aub); + border-radius: 50%; + background: #2bac76; +} + +/* ---- Faces: Slack's rounded-square avatars ---- */ + +.sl-face { + display: inline-grid; + flex-shrink: 0; + place-items: center; + color: #fff; + font-weight: 700; + line-height: 1; +} + +.sl-face[data-who='maya'] { background: #c9715c; } +.sl-face[data-who='alex'] { background: #4d8b77; } +.sl-face[data-who='p1'] { background: #7a8ca5; } +.sl-face[data-who='p2'] { background: #9686b0; } +.sl-face[data-who='p3'] { background: #ad9471; } + +/* ---- The workspace panel ---- */ + +.sl-panel { + display: flex; + flex: 1; + min-width: 0; + margin: 0 6px 6px 0; + overflow: hidden; + border-radius: 8px; + background: #fff; +} + +.sl-side { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 180px; + padding: 0 8px; + background: var(--sl-side); + color: var(--sl-on-aub-soft); +} + +.sl-side__head { + display: flex; + align-items: center; + gap: 4px; + height: 49px; + padding: 0 4px 0 10px; + color: #fff; +} + +.sl-side__ws { + font-size: 16px; + font-weight: 800; +} + +.sl-side__compose { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: #f3eaf3; + color: var(--sl-aub); +} + +.sl-side__list { + display: grid; + gap: 2px; +} + +.sl-side__row, +.sl-side__section { + display: flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 10px; + border-radius: 6px; +} + +.sl-side__section { + margin-top: 10px; + gap: 9px; +} + +.sl-side__row[data-active] { + background: #f5edf5; + color: var(--sl-aub); +} + +.sl-side__name { + font-size: 14px; + font-weight: 700; +} + +.sl-side__glyph { + width: 14px; + height: 14px; + border-radius: 4px; + background: #8c6a8e; +} + +.sl-side__row .sl-face { + font-size: 9px !important; +} + +/* ---- Channel ---- */ + +.sl-channel { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.sl-channel__head { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + height: 49px; + padding: 0 12px 0 18px; +} + +.sl-channel__title { + display: flex; + align-items: center; + gap: 4px; + font-size: 17px; + font-weight: 800; + color: var(--sl-ink); +} + +.sl-channel__title .sl-i:last-child { + margin-left: 2px; + color: var(--sl-ink-2); +} + +.sl-members { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px 0 4px; + border: 1px solid var(--sl-line-2); + border-radius: 6px; +} + +.sl-members__faces { + display: flex; +} + +.sl-members__faces .sl-face { + font-size: 9px !important; + box-shadow: 0 0 0 2px #fff; +} + +.sl-members__faces .sl-face + .sl-face { + margin-left: -5px; +} + +.sl-members__count { + font-size: 13px; + font-weight: 600; + color: var(--sl-ink-2); +} + +.sl-headbtn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--sl-line-2); + border-radius: 6px; + color: var(--sl-ink-2); + transition: background 0.12s ease; +} + +.sl-headbtn[data-hover] { + background: var(--sl-hover); +} + +.sl-tabs { + display: flex; + gap: 20px; + flex-shrink: 0; + height: 36px; + padding: 0 20px; + border-bottom: 1px solid var(--sl-line); +} + +.sl-tab { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--sl-ink-2); +} + +.sl-tab[data-active] { + color: var(--sl-ink); + box-shadow: inset 0 -2px 0 var(--sl-aub); +} + +.sl-tab__glyph { + width: 14px; + height: 14px; + border-radius: 4px; + background: var(--ss-ink-2); +} + +.sl-feed { + display: flex; + flex-direction: column; + justify-content: flex-end; + flex: 1; + min-height: 0; + overflow: hidden; + padding-bottom: 6px; +} + +.sl-divider { + position: relative; + display: flex; + justify-content: center; + margin: 4px 0 6px; +} + +.sl-divider::before { + content: ''; + position: absolute; + left: 20px; + right: 20px; + top: 50%; + border-top: 1px solid var(--sl-line); +} + +.sl-divider__pill { + position: relative; + display: grid; + place-items: center; + height: 26px; + padding: 0 14px; + border: 1px solid var(--sl-line-2); + border-radius: 13px; + background: #fff; +} + +/* ---- Messages ---- */ + +.sl-msg { + position: relative; + display: grid; + grid-template-columns: 36px minmax(0, 1fr); + column-gap: 10px; + margin: 0 8px; + padding: 10px 12px; + border-radius: 8px; + transition: background 0.14s ease; +} + +.sl-row { + position: relative; +} + +.sl-row[data-hover] .sl-msg--live { + background: var(--sl-hover); +} + +.sl-msg__body { + min-width: 0; +} + +.sl-msg__meta { + display: flex; + align-items: baseline; + gap: 7px; + height: 20px; +} + +.sl-msg__meta--bars { + align-items: center; + gap: 8px; +} + +.sl-msg__lines { + display: grid; + gap: 10px; + padding: 8px 0 6px; +} + +.sl-msg__name { + font-size: 15px; + font-weight: 800; + color: var(--sl-ink); +} + +.sl-msg__time { + font-size: 12px; + color: var(--sl-ink-2); +} + +.sl-msg__text { + /* Three short lines that keep their breaks whether the thread is open or not */ + max-width: 224px; + margin: 2px 0 0; + font-size: 15px; + line-height: 22px; + color: var(--sl-ink); +} + +.sl-msg--thread .sl-msg__text { + max-width: none; +} + +.sl-msg__reacts { + display: flex; + gap: 4px; + padding-top: 6px; +} + +.sl-react { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 9px 0 7px; + border: 1px solid var(--sl-line-2); + border-radius: 12px; + background: #f5f5f5; + transform: scale(0.82); + transition: transform 0.36s cubic-bezier(0.3, 0.75, 0.1, 1); +} + +.ss-collapse[data-open] .sl-react, +.sl-msg--thread .sl-react { + transform: none; +} + +.sl-react[data-mine] { + border-color: var(--sl-blue-line); + background: var(--sl-blue-soft); +} + +.sl-react__emoji { + font-size: 14px; + line-height: 1; +} + +.sl-react__count { + font-size: 12px; + font-weight: 700; + color: var(--sl-ink-2); +} + +.sl-react[data-mine] .sl-react__count { + color: var(--sl-blue); +} + +.sl-replies { + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; +} + +.sl-replies__link { + font-size: 13px; + font-weight: 700; + color: var(--sl-blue); +} + +.sl-replies__when { + font-size: 13px; + color: var(--sl-ink-3); +} + +/* The hover toolbar: quick reactions, then the actions */ + +.sl-hover { + position: absolute; + top: -15px; + right: 18px; + z-index: 2; + display: flex; + align-items: center; + padding: 2px; + border: 1px solid var(--sl-line-2); + border-radius: 8px; + background: #fff; + box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.12); + opacity: 0; + transform: translateY(3px); + transition: opacity 0.14s ease, transform 0.18s ease; + pointer-events: none; +} + +.sl-row[data-hover] .sl-hover { + opacity: 1; + transform: none; +} + +/* Once the thread opens, the toolbar's job is done: it leaves rather than ride over the name as the column narrows */ +.sl-channel[data-thread] .sl-hover { + opacity: 0; +} + +.sl-hover__btn { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 6px; + color: var(--sl-ink-2); + transition: background 0.1s ease; +} + +.sl-hover__btn[data-hover] { + background: #ededed; +} + +.sl-hover__emoji { + font-size: 16px; + line-height: 1; +} + +.sl-hover__div { + width: 1px; + height: 18px; + margin: 0 3px; + background: var(--sl-line-2); +} + +/* ---- Composer ---- */ + +.sl-channel__compose { + flex-shrink: 0; + padding: 0 20px 20px; +} + +.sl-compose { + border: 1px solid var(--sl-line-2); + border-radius: 8px; + background: #fff; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.sl-compose[data-focus] { + border-color: #8a8a8a; + box-shadow: 0 1px 4px -1px rgba(0, 0, 0, 0.1); +} + +.sl-compose__text { + position: relative; + min-height: 42px; + padding: 11px 12px; + font-size: 14px; + line-height: 20px; + color: var(--sl-ink); +} + +.sl-compose__placeholder { + position: absolute; + left: 12px; + top: 11px; + color: var(--sl-ink-3); + transition: opacity 0.1s ease; +} + +.sl-compose__placeholder[data-hide] { + opacity: 0; +} + +.sl-caret { + display: inline-block; + width: 1.5px; + height: 18px; + margin-left: 1px; + vertical-align: -4px; + background: var(--sl-ink); + opacity: 0; +} + +.sl-caret[data-show] { + animation: sl-blink 1.05s steps(1) infinite; +} + +.sl-caret[data-typing] { + animation: none; + opacity: 1; +} + +@keyframes sl-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.sl-compose__tools { + display: flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 6px 0 8px; + color: var(--sl-ink-2); +} + +.sl-compose__round { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: #efefef; +} + +.sl-compose__sep { + width: 1px; + height: 16px; + background: var(--sl-line-2); +} + +.sl-compose__send { + display: grid; + place-items: center; + width: 30px; + height: 24px; + border-radius: 5px; + color: #a5a5a5; + transition: background 0.2s ease, color 0.2s ease; +} + +.sl-compose__send[data-ready] { + background: var(--sl-green); + color: #fff; +} + +/* ---- Thread panel ---- */ + +.sl-thread-wrap { + position: relative; + flex-shrink: 0; + width: 0; + overflow: hidden; + transition: width 0.6s cubic-bezier(0.3, 0.75, 0.1, 1); +} + +.sl-thread-wrap[data-open] { + width: 280px; +} + +/* Pinned to the column's left, so as the column opens the panel slides out + from under the window's right edge, whole, rather than being wiped on */ +.sl-thread { + position: absolute; + top: 0; + left: 0; + bottom: 0; + display: flex; + flex-direction: column; + width: 280px; + border-left: 1px solid var(--sl-line); + background: #fff; +} + +.sl-thread__head { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + height: 49px; + padding: 0 12px 0 16px; + border-bottom: 1px solid var(--sl-line); +} + +.sl-thread__title { + font-size: 16px; + font-weight: 800; + color: var(--sl-ink); +} + +.sl-thread__where { + display: flex; + align-items: center; + gap: 1px; + font-size: 12.5px; + color: var(--sl-ink-2); +} + +.sl-thread__body { + padding: 10px 0; +} + +.sl-thread__count { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 20px 4px; + font-size: 12.5px; + color: var(--sl-ink-2); +} + +.sl-thread__rule { + flex: 1; + border-top: 1px solid var(--sl-line); +} + +.sl-thread__compose { + padding: 8px 16px 0; +} + +/* The live message's toolbar floats half above the row, as Slack's does, so + its Collapse clips wider than the kit's 3px */ +.sl-live > .ss-collapse__inner { + overflow-clip-margin: 24px; +} + +/* ---- Compact: the channel alone, for an article column ---- + The rail and sidebar go, the window runs off the bottom, and the thread + slides over the channel from the right instead of squeezing it. Open, it + covers the channel whole: a strip of channel beside it would show its + words sliced at the thread's edge. */ + +.ss-stage[data-layout='compact'] .sl-win { + left: 20px; + top: 20px; + width: 400px; + height: 460px; +} + +.ss-stage[data-layout='compact'] .sl-rail, +.ss-stage[data-layout='compact'] .sl-side, +.ss-stage[data-layout='compact'] .sl-top__nav { + display: none; +} + +.ss-stage[data-layout='compact'] .sl-panel { + position: relative; + margin-left: 6px; +} + +.ss-stage[data-layout='compact'] .sl-thread-wrap { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 2; + box-shadow: -12px 0 24px -16px rgba(0, 0, 0, 0.25); +} + +.ss-stage[data-layout='compact'] .sl-thread-wrap { + /* A real slide: the whole panel moves in from the right edge, so no word is + ever wiped or cut by a growing edge */ + width: 388px; + translate: 100% 0; + transition: translate 0.6s cubic-bezier(0.3, 0.75, 0.1, 1); +} + +.ss-stage[data-layout='compact'] .sl-thread-wrap[data-open] { + width: 388px; + translate: 0 0; +} + +.ss-stage[data-layout='compact'] .sl-thread { + width: 388px; +} + +.ss-stage[data-layout='compact'] .sl-top__search { + width: 280px; + margin-left: -140px; +} + +/* The compact feed keeps one older message, so Maya's arrival, its reaction + and its reply line fit without pushing anything under the tabs */ +.ss-stage[data-layout='compact'] .sl-feed > .sl-divider + .sl-msg { + display: none; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/spotify/index.tsx b/skills/imported/benjitaylor/stageshow/examples/spotify/index.tsx new file mode 100644 index 0000000..8e74cdc --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/spotify/index.tsx @@ -0,0 +1,320 @@ +import { ChevronLeft, ChevronRight, CirclePlus, Clock3, House, Library, ListMusic, MicVocal, MonitorSpeaker, Pause, Play, Plus, Repeat, Search, Shuffle, SkipBack, SkipForward, Volume2 } from 'lucide-react' +import { Bar, Stage, Swap, Text, Window, type Act } from '../../kit' +import './spotify.css' + +// Spotify desktop, dark, about 860 wide. A hero loop. +// +// Story: a playlist is open and "Low Tide" is playing from it (green title, +// equaliser in place of its number). The cursor double-clicks "Neon Harbor" +// two rows down: the green and the equaliser move to that row, the +// now-playing bar swaps art, title and artist, the total time changes and +// the progress bar starts again from 0:00 and keeps moving. After a hold the +// cursor double-clicks "Low Tide" again, which restarts it from 0:00: the +// exact frame the loop opens on, so the reset is caused, not a seam. +// +// Real strings: the playlist title, the two songs and their artists, the two +// times. Everything else is a line or album art. +// Icons: Spotify's own set isn't available, so lucide, chosen for its plain +// geometric strokes, which are close to Spotify's; play and pause are filled. + +type Id = 'a' | 'b' +type S = { playing: Id; sel: Id; elapsed: number } + +const initial: S = { playing: 'a', sel: 'a', elapsed: 0 } + +const SONGS: Record = { + a: { title: 'Low Tide', artist: 'Marlo', art: 2, dur: 196 }, + b: { title: 'Neon Harbor', artist: 'The Vales', art: 4, dur: 168 }, +} + +// Rows of the playlist. Real text only on the two songs the story plays. +const ROWS: { id?: Id; art: number; t: number; ar: number; al: number }[] = [ + { id: 'a', art: 2, t: 0, ar: 0, al: 58 }, + { art: 3, t: 52, ar: 46, al: 82 }, + { id: 'b', art: 4, t: 0, ar: 0, al: 64 }, + { art: 1, t: 64, ar: 40, al: 70 }, + { art: 5, t: 70, ar: 34, al: 50 }, + { art: 6, t: 58, ar: 44, al: 74 }, +] + +const fmt = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` + +async function script(a: Act) { + // The song keeps playing under everything the cursor does + let live = true + ;(async () => { + try { + while (live) { + await a.wait(1000) + if (live) a.put((s) => ({ elapsed: s.elapsed + 1 })) + } + } catch { + /* stopped */ + } + })() + + await a.wait(1400) + + // Play Neon Harbor + a.mark('play') + await a.move({ at: 'row-b', x: 0.72, y: 0.5 }) + await a.wait(350) + await a.click(undefined, { double: true }) + await a.set({ playing: 'b', sel: 'b', elapsed: 0 }) + await a.wait(1100) + + // Down to the player, where the new song is running: hovering the + // progress bar lights it green and shows its handle, as Spotify does + a.mark('progress') + await a.move({ at: 'progress', x: 0.62, y: 0.5 }) + await a.wait(4400) + + // Back to Low Tide, which starts again from the top: the opening frame + a.mark('back') + await a.move({ at: 'row-a', x: 0.72, y: 0.5 }) + await a.wait(350) + await a.click(undefined, { double: true }) + live = false + await a.set({ playing: 'a', sel: 'a', elapsed: 0 }) +} + +function Art({ n, className = '' }: { n: number; className?: string }) { + return +} + +function Eq() { + return ( + + + + + + + ) +} + +export default function SpotifyDemo() { + return ( + + {(s, _rect, layout) => { + const song = SONGS[s.playing] + const compact = layout === 'compact' + return ( + + + + + + + + + + + + + + + + } + toolbar={} + > +
+ {/* Your Library */} + + + {/* The playlist */} +
+
+ +
+ + Late Night Drive + + + + +
+
+ +
+ + + + + + + + + + +
+ +
+
+ # + + {!compact && } + +
+ {ROWS.map((r, i) => { + const real = r.id ? SONGS[r.id] : null + const on = r.id === s.playing + return ( +
+ + {i + 1} + + + + + {real ? ( + + {real.title} + {real.artist} + + ) : ( + + + + + )} + + {!compact && } + + + +
+ ) + })} +
+
+
+ + {/* Now playing */} +
+
+ {/* Both songs sit in the same place and crossfade: the art keeps its + edges, and the column is as wide as the longer title, so the + + after it stays put */} + + {(['a', 'b'] as Id[]).map((id) => ( + + ))} + + + {(['a', 'b'] as Id[]).map((id) => ( + + {SONGS[id].title} + {SONGS[id].artist} + + ))} + + {!compact && } +
+ +
+
+ + + + + + + +
+
+ {fmt(s.elapsed)} + + + + + + + + + {fmt(song.dur)} + + +
+
+ + +
+
+ ) + }} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/spotify/spotify.css b/skills/imported/benjitaylor/stageshow/examples/spotify/spotify.css new file mode 100644 index 0000000..e6d7b04 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/spotify/spotify.css @@ -0,0 +1,709 @@ +/* Spotify desktop, dark. One neutral family (Spotify's pure grays on black), + one accent (Spotify green) on the thing being played. */ + +.spt-stage .ss-stage { + --gutter: 32px; + --spt-black: #000; + --spt-panel: #121212; + --spt-hover: #1f1f1f; + --spt-selected: #2a2a2a; + --spt-white: #fff; + --spt-grey: #b3b3b3; + --spt-dim: #7a7a7a; + --spt-green: #1ed760; + --ss-icon: #b3b3b3; + --ss-icon-faint: #7a7a7a; + overflow: hidden; + border-radius: 16px; + background: + radial-gradient(90% 70% at 30% 0%, rgba(255, 255, 255, 0.07), transparent 70%), + #18181a; +} + +.spt-stage .ss-stage[data-layout='compact'] { + --gutter: 20px; +} + +/* Pinned top-left a gutter in, a real window edge a gutter from the bottom + (the player bar is the payoff, so the bottom stays in view), and about a + seventh of it runs off the right, where only filler lives. */ +.spt-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + width: 920px; + height: calc(600px - var(--gutter) * 2); + background: var(--spt-black); + box-shadow: var(--ss-shadow-hero); +} + +.spt-window[data-layout='compact'] { + width: 520px; + height: calc(520px - var(--gutter) * 2); +} + +/* ---- Title bar: Spotify's own, black, no divider ---- */ + +.spt-window > .ss-window__bar { + position: relative; + background: var(--spt-black); + border-bottom: 0; + color: var(--spt-grey); +} + +.spt-nav { + display: flex; + align-items: center; + gap: 6px; + margin-left: 14px; + color: var(--spt-grey); +} + +.spt-faint { + color: #555; +} + +.spt-center { + position: absolute; + top: 50%; + left: 50%; + display: flex; + align-items: center; + gap: 8px; + transform: translate(-50%, -50%); +} + +.spt-window[data-layout='compact'] .spt-center { + left: 205px; + transform: translate(0, -50%); +} + +.spt-home { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 50%; + background: #1f1f1f; + color: var(--spt-white); +} + +.spt-search { + display: flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 14px 0 12px; + border-radius: 14px; + background: #1f1f1f; + color: var(--spt-grey); +} + + +.spt-me { + width: 22px; + height: 22px; + margin-right: 4px; + border-radius: 50%; + background: linear-gradient(150deg, #8d8d93, #4a4a4f); + box-shadow: 0 0 0 3px #1f1f1f; +} + +/* ---- Body: two panels over the player bar ---- */ + +.spt-window > .ss-window__body { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + background: var(--spt-black); +} + +.spt-panels { + display: flex; + flex: 1; + gap: 8px; + min-height: 0; + padding: 0 8px; +} + +.spt-library, +.spt-main { + overflow: hidden; + border-radius: 8px; + background: var(--spt-panel); +} + +.spt-library { + display: flex; + flex-direction: column; + flex-shrink: 0; + width: 248px; + padding: 16px 8px 0; +} + +.spt-window[data-layout='compact'] .spt-library { + width: 64px; + align-items: center; +} + +.spt-lib-head { + display: flex; + align-items: center; + gap: 10px; + height: 24px; + padding: 0 8px; + color: var(--spt-grey); +} + +.spt-lib-head--rail { + padding: 0; +} + +.spt-lib-title { + font-size: 14px; + font-weight: 700; + line-height: 1.3; + color: var(--spt-white); +} + +.spt-spacer { + flex: 1; +} + +.spt-chips { + display: flex; + gap: 8px; + padding: 16px 8px 12px; +} + +.spt-lib-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.spt-window[data-layout='compact'] .spt-lib-list { + margin-top: 14px; +} + +.spt-lib-row { + display: flex; + align-items: center; + gap: 12px; + height: 56px; + padding: 0 8px; + border-radius: 6px; +} + +.spt-window[data-layout='compact'] .spt-lib-row { + padding: 0 4px; +} + +.spt-lib-row[data-on] { + background: var(--spt-selected); +} + +.spt-lines { + display: flex; + flex-direction: column; + gap: 7px; +} + +/* ---- Album art: muted sleeves, each with a little light in it ---- */ + +.spt-art { + display: block; + flex-shrink: 0; + border-radius: 4px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} + +.spt-art--1 { background: radial-gradient(circle at 30% 30%, #8a7a6c, #3a322c 70%); } +.spt-art--2 { background: linear-gradient(170deg, #5f7f91 0%, #2b3d48 60%, #1b252c 100%); } +.spt-art--3 { background: radial-gradient(circle at 70% 75%, #b9a37a, #5a4c36 45%, #2a241c 80%); } +.spt-art--4 { background: radial-gradient(circle at 50% 115%, #e3a0b8 0%, #8e5a7c 30%, #33243f 70%); } +.spt-art--5 { background: linear-gradient(200deg, #6f8a6f, #2c382d 65%); } +.spt-art--6 { background: radial-gradient(circle at 25% 80%, #7b7b9c, #2d2d3d 70%); } +.spt-art--7 { background: radial-gradient(circle at 60% 120%, #7b88c9 0%, #3c4380 35%, #191c38 75%); } +.spt-art--8 { background: linear-gradient(150deg, #9a6f5c, #3d2a23 70%); } + +.spt-art--lib { width: 40px; height: 40px; } +.spt-art--row { width: 32px; height: 32px; } +.spt-art--now { width: 48px; height: 48px; } + +.spt-art--cover { + width: 88px; + height: 88px; + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.spt-window[data-layout='compact'] .spt-art--cover { + width: 72px; + height: 72px; +} + +/* ---- The playlist ---- */ + +.spt-main { + flex: 1; + min-width: 0; + /* Spotify tints the top of a playlist with its cover's color */ + background: + linear-gradient(180deg, #2d3366 0, #1d2140 150px, var(--spt-panel) 260px), + var(--spt-panel); +} + +.spt-header { + display: flex; + align-items: flex-end; + gap: 20px; + padding: 20px 24px 16px; +} + +.spt-window[data-layout='compact'] .spt-header { + gap: 14px; + padding: 18px 16px 16px; +} + +.spt-heading { + display: flex; + flex-direction: column; + gap: 10px; + padding-bottom: 4px; +} + +.spt-title { + font-size: 34px; + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.1; + white-space: nowrap; + color: var(--spt-white); +} + +.spt-window[data-layout='compact'] .spt-title { + font-size: 22px; +} + +.spt-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.spt-meta-dot { + width: 16px; + height: 16px; + border-radius: 50%; + background: linear-gradient(150deg, #8d8d93, #4a4a4f); +} + +.spt-actions { + display: flex; + align-items: center; + gap: 24px; + padding: 0 24px 12px; + color: var(--spt-dim); +} + +.spt-window[data-layout='compact'] .spt-actions { + gap: 18px; + padding: 0 16px 10px; +} + +.spt-bigplay { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--spt-green); + color: #000; +} + +.spt-window[data-layout='compact'] .spt-bigplay { + width: 38px; + height: 38px; +} + +.spt-faint-icon { + color: var(--ss-icon-faint); +} + +.spt-more { + display: flex; + gap: 3px; +} + +.spt-more i { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ss-icon-faint); +} + +/* One column template for every row, so numbers, art and bars line up */ +.spt-tracks { + display: flex; + flex-direction: column; + padding: 0 16px; +} + +.spt-window[data-layout='compact'] .spt-tracks { + padding: 0 8px; +} + +.spt-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) 190px 56px; + align-items: center; + gap: 16px; + height: 46px; + padding: 0 16px 0 12px; + border-radius: 6px; + transition: background-color 150ms ease; +} + +.spt-window[data-layout='compact'] .spt-row { + grid-template-columns: 20px minmax(0, 1fr) 40px; + gap: 12px; + height: 46px; + padding: 0 10px; +} + +.spt-row--head { + height: 30px; + margin-bottom: 6px; + border-radius: 0; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.spt-row[data-hover]:not(.spt-row--head) { + background: var(--spt-hover); +} + +.spt-row[data-selected] { + background: var(--spt-selected); +} + +.spt-row[data-selected][data-hover] { + background: #303030; +} + +.spt-num { + position: relative; + display: grid; + place-items: center end; + height: 16px; + font-size: 14px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: var(--spt-grey); +} + +.spt-num__n { + transition: opacity 200ms ease; +} + +.spt-row[data-playing] .spt-num__n { + opacity: 0; +} + +.spt-song { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.spt-song-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.spt-song-title { + font-size: 14px; + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + color: var(--spt-white); + /* Distant hues switch quickly */ + transition: color 200ms ease; +} + +.spt-row[data-playing] .spt-song-title { + color: var(--spt-green); +} + +.spt-song-artist { + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + color: var(--spt-grey); +} + +.spt-dur { + justify-self: end; +} + +.spt-time { + font-size: 13px; + font-variant-numeric: tabular-nums; + color: var(--spt-grey); +} + +/* The playing indicator: four green bars in the number's place */ +.spt-eq { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: 2px; + opacity: 0; + transition: opacity 200ms ease; +} + +.spt-row[data-playing] .spt-eq { + opacity: 1; +} + +.spt-eq i { + width: 2.5px; + height: 14px; + border-radius: 1px; + background: var(--spt-green); + transform-origin: bottom; + animation: spt-eq 900ms ease-in-out infinite alternate; +} + +.spt-eq i:nth-child(1) { animation-delay: -200ms; } +.spt-eq i:nth-child(2) { animation-delay: -650ms; animation-duration: 760ms; } +.spt-eq i:nth-child(3) { animation-delay: -420ms; animation-duration: 1040ms; } +.spt-eq i:nth-child(4) { animation-delay: -80ms; animation-duration: 820ms; } + +@keyframes spt-eq { + 0% { transform: scaleY(0.25); } + 50% { transform: scaleY(0.9); } + 100% { transform: scaleY(0.45); } +} + +/* ---- The player bar ---- */ + +.spt-player { + display: grid; + flex-shrink: 0; + grid-template-columns: 30% 40% 30%; + align-items: center; + height: 80px; + padding: 0 8px; +} + +.spt-now { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-left: 8px; +} + +.spt-now-art { + display: block; + width: 48px; + height: 48px; +} + +.spt-now-art { + display: grid; +} + +.spt-now-art > *, +.spt-now-song { + grid-area: 1 / 1; + opacity: 0; + transition: opacity 0.35s ease; +} + +.spt-now-art > .is-on, +.spt-now-song[data-on] { + opacity: 1; +} + +.spt-now-text { + display: grid; + min-width: 0; +} + +.spt-now-song { + display: flex; + flex-direction: column; + gap: 2px; +} + +.spt-now-title { + font-size: 14px; + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + color: var(--spt-white); +} + +.spt-now-artist { + font-size: 12px; + line-height: 1.3; + white-space: nowrap; + color: var(--spt-grey); +} + +.spt-transport { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.spt-controls { + display: flex; + align-items: center; + gap: 20px; + color: var(--spt-grey); +} + +.spt-window[data-layout='compact'] .spt-controls { + gap: 14px; +} + +.spt-playbtn { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--spt-white); + color: #000; +} + +.spt-progress { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 36px; + align-items: center; + gap: 8px; + width: 100%; + padding: 0 4px; +} + +.spt-t { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 1.3; + color: var(--spt-grey); +} + +.spt-t--start { + text-align: right; +} + +.spt-track { + position: relative; + height: 4px; + border-radius: 2px; + background: #4d4d4d; +} + +.spt-fill { + position: absolute; + inset: 0; + border-radius: 2px; + background: var(--spt-white); + /* Each tick is a second: a linear second keeps it moving without steps */ + transition: clip-path 1000ms linear, background-color 150ms ease; +} + +.spt-track[data-hover] .spt-fill { + background: var(--spt-green); +} + +/* The handle rides the fill's end; it shows only while hovered */ +.spt-knob { + position: absolute; + inset: 0; + transition: transform 1000ms linear; + pointer-events: none; +} + +.spt-knob i { + position: absolute; + top: 50%; + left: -6px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--spt-white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + opacity: 0; + transform: translateY(-50%) scale(0.6); + transition: opacity 150ms ease, transform 150ms ease; +} + +.spt-track[data-hover] .spt-knob i { + opacity: 1; + transform: translateY(-50%) scale(1); +} + +.spt-extras[hidden] { + display: none; +} + +.spt-extras { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 16px; + padding-right: 16px; +} + +.spt-volume { + width: 90px; + height: 4px; + border-radius: 2px; + background: #4d4d4d; +} + +.spt-volume span { + display: block; + width: 70%; + height: 100%; + border-radius: 2px; + background: var(--spt-white); +} + +/* A true crossfade for the player's swaps: the incoming song starts showing + at once while the old one fades, so the bar never sits empty between them */ +.spt-player .ss-swap__in { + animation-name: spt-swap-in; +} + +@keyframes spt-swap-in { + 0% { opacity: 0; filter: blur(2px); } + 40% { opacity: 0.85; filter: blur(0.5px); } + 100% { opacity: 1; filter: none; } +} + +/* The box eases to the new title's width; let the text show past it while + it does, rather than being sliced mid-word */ +.spt-player .ss-swap { + overflow: visible; +} + +/* The poster keeps the equaliser's shape rather than four even bars */ +@media (prefers-reduced-motion: reduce) { + .spt-eq i { animation: none !important; } + .spt-eq i:nth-child(1) { transform: scaleY(0.45); } + .spt-eq i:nth-child(2) { transform: scaleY(0.85); } + .spt-eq i:nth-child(3) { transform: scaleY(0.6); } + .spt-eq i:nth-child(4) { transform: scaleY(0.3); } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/stacked-cards/index.tsx b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/index.tsx new file mode 100644 index 0000000..0840540 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/index.tsx @@ -0,0 +1,56 @@ +import { Chart, Icon, Meter, Stage, Text, type Act } from '../../kit' +import './stacked.css' + +// Stacked cards: a plan card over the usage chart it bills from, two +// components of one product at one width. Beats: the usage bar fills, then +// the chart's bars grow under it. Plays once; the still is the payoff. +// +// What makes a stack read as one object: +// - One width, one left edge, a small gap (12px). Different widths or a +// stagger turns it into two things that happen to be near each other. +// - The top card is whole; the bottom one runs off the stage's bottom edge +// (the chart's axis is filler). Nothing else is cut. +// - Centered on the stage with even gutters; the ground does nothing. +// - The two cards share one number: the meter's 4.2M of 5M is the chart's +// total, so they read as cause and detail. +// +// Real strings: the plan, its price, the usage line, the chart's title. + +type S = { on: boolean } + +const BARS = [8, 12, 9, 15, 13, 18, 11, 17, 22, 16, 20, 26, 21, 29, 24, 31, 27, 34] + +async function script(a: Act) { + await a.wait(500) + a.put({ on: true }) + await a.wait(3000) +} + +export default function StackedCards() { + return ( + + {(s, _rect, layout) => ( +
+
+
+ +
+ Pro plan + $49 per month +
+
+
+ API calls + 4.2M of 5M +
+ +
+
+ Calls, last 18 days + +
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css new file mode 100644 index 0000000..556ba5c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css @@ -0,0 +1,89 @@ +.sk .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* One column, centered, the second card off the bottom */ +.sk-stack { + position: absolute; + top: 56px; + left: 50%; + display: grid; + gap: 12px; + width: 340px; + translate: -50% 0; +} + +.sk-card { + display: grid; + gap: 10px; + padding: 18px 20px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-card); +} + +.sk-plan { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.sk-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: var(--ss-accent-soft); + color: var(--ss-accent); +} + +.sk-name { + color: var(--ss-text-strong); + font-size: 13px; + font-weight: 600; +} + +.sk-sub, +.sk-label { + color: var(--ss-text); + font-size: 12px; +} + +.sk-usage { + display: flex; + justify-content: space-between; +} + +.sk-value { + color: var(--ss-text-strong); + font-size: 12px; + font-weight: 500; +} + +.sk-who { + display: grid; + gap: 2px; +} + +.sk-chart { + display: block; + margin-top: 4px; +} + +/* The second card runs on past the stage's bottom edge, under the axis */ +.sk-card:last-child { + padding-bottom: 80px; +} + +/* A flat accent: the usage is the point, not the fill */ +.sk .ss-meter__fill { + background: var(--ss-accent); +} + +/* Narrow: the column fills the stage less a 16px gutter each side */ +.sk .ss-stage[data-layout='compact'] .sk-stack { + width: 326px; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css new file mode 100644 index 0000000..b92423d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css @@ -0,0 +1,534 @@ +/* All rules belong to this example, including its responsive variants. */ +.staggered-bento { + /* Wayline's features section: a staggered pair on white. Everything nests + under &. Two columns of rounded tiles on a gap, tall and + short alternating so the rows never line up across the columns. Each + picture is its own Stage at 1:1, pinned to its tile's bottom. */ + --pad: 30px; + --gap: 18px; + --tile: #f5f5f7; + --ink: #1d1d1f; + --ink-2: #6e6e73; + --accent: #f2643b; + --short: 310px; + --tall: 518px; + container-type: inline-size; + /* No padding of its own: on a page, the tiles span the content column */ + box-sizing: border-box; + background: #fff; + font-family: var(--sg-font, inherit); + -webkit-font-smoothing: antialiased; + + & .sg-grid { + display: grid; + gap: var(--gap); + max-width: 984px; + margin: 0 auto; + grid-template-columns: minmax(0, 1fr); + } + + /* Narrow: one column, the tiles in reading order, hero first */ + & .sg-col { + display: contents; + } + + & .sg-tile--coast { order: 1; height: 460px; } + & .sg-tile--private { order: 2; height: 230px; } + & .sg-tile--stat { order: 3; height: 250px; } + & .sg-tile--voice { order: 4; height: 310px; } + & .sg-tile--book { order: 5; height: 300px; } + & .sg-tile--day { order: 6; height: 500px; } + + /* Two columns: tall, short, short on the left; short, short, tall on the right */ + @container (min-width: 600px) { + & .sg-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + & .sg-col { + display: flex; + flex-direction: column; + gap: var(--gap); + } + + & .sg-tile { height: var(--short); } + & .sg-tile--coast, + & .sg-tile--day { height: var(--tall); } + } + + @container (min-width: 900px) { + & .sg-grid { + --pad: 36px; + --gap: 20px; + --short: 320px; + --tall: 560px; + } + } + + @container (max-width: 599px) { + & .sg-grid { + --pad: 26px; + } + } + + /* ---- The tile: one frame for all six ---- */ + + & .sg-tile { + position: relative; + overflow: hidden; + border-radius: 28px; + background: var(--tile); + } + + & .sg-copy { + position: relative; + z-index: 1; + max-width: 380px; + margin: 0; + padding: 30px var(--pad) 0; + font-size: 19px; + line-height: 26px; + letter-spacing: -0.016em; + font-weight: 500; + color: var(--ink-2); + text-wrap: pretty; + } + + & .sg-copy b { + font-weight: 600; + color: var(--ink); + } + + /* Over the photo's sky */ + & .sg-tile[data-photo] .sg-copy { + color: rgba(255, 255, 255, 0.72); + } + + & .sg-tile[data-photo] .sg-copy b { + color: #fff; + } + + /* ---- Pictures: pinned to the tile's bottom ---- */ + + & .sg-picture { + position: absolute; + bottom: 0; + } + + & .sg-picture--coast { + left: 50%; + width: 600px; + margin-left: -300px; + } + + & .sg-picture--small, + & .sg-picture--phone { + left: 0; + width: 100%; + } + + & .sg-stage .ss-stage { + overflow: hidden; + background: transparent; + } + + /* ---- Coast (the still hero picture) ---- */ + + & .sg-art { + position: absolute; + inset: 0; + } + + & .sg-art__svg { + position: absolute; + inset: 0; + display: block; + } + + /* One column: the copy wraps to more lines, so the drawn takes sit lower + (their bottoms are open water and more tiles, which can go) */ + @container (max-width: 599px) { + & .sg-picture--coast { + bottom: -40px; + } + } + + /* The tile's ground matches the picture's top, so nothing shows round it */ + & .sg-tile--coast:has([data-art='azulejo']) { background: #1f3f8f; } + + /* ---- Private (statement) ---- */ + + & .sg-statement { + display: flex; + flex-direction: column; + justify-content: space-between; + box-sizing: border-box; + height: 100%; + padding: 30px var(--pad) 32px; + } + + & .sg-lock { + color: #0a7cff; + } + + & .sg-statement__text { + max-width: 340px; + margin: 0; + font-size: 30px; + line-height: 34px; + letter-spacing: -0.024em; + font-weight: 600; + color: #a1a1a6; + text-wrap: balance; + } + + & .sg-statement__text b { + font-weight: 600; + color: var(--ink); + } + + /* ---- Distance (stat) ---- */ + + & .sg-stat { + display: flex; + flex-direction: column; + box-sizing: border-box; + height: 100%; + padding: 30px var(--pad) 32px; + } + + & .sg-stat-lead { + font-size: 19px; + line-height: 26px; + letter-spacing: -0.016em; + font-weight: 600; + color: var(--ink); + } + + & .sg-stat-number { + display: block; + margin-top: auto; + font-size: 60px; + line-height: 1; + font-weight: 600; + letter-spacing: -0.04em; + color: var(--ink); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + /* The exact final text sets the slot, so adding digits never kicks the unit + sideways. Its settled size and alignment remain the same as plain text. */ + & .sg-stat-value { + display: inline-grid; + text-align: right; + } + + & .sg-stat-value > * { grid-area: 1 / 1; } + & .sg-stat-sizer { visibility: hidden; } + + & .sg-stat-unit { + margin-left: 6px; + font-size: 32px; + letter-spacing: -0.02em; + } + + & .sg-stat-label { + max-width: 260px; + margin-top: 12px; + font-size: 15px; + line-height: 21px; + color: var(--ink-2); + text-wrap: pretty; + } + + /* ---- Voice (component) ---- */ + + & .sg-note { + position: absolute; + top: 16px; + left: var(--pad); + box-sizing: border-box; + width: calc(100% - var(--pad) * 2); + height: 200px; + padding: 14px 14px 0; + /* A fragment with no chrome of its own: it dissolves where the tile crops it */ + -webkit-mask-image: linear-gradient(180deg, #000 150px, transparent 200px); + mask-image: linear-gradient(180deg, #000 150px, transparent 200px); + border-radius: 26px; + background: #fff; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 14px 34px -14px rgba(40, 30, 20, 0.24), 0 2px 6px -2px rgba(0, 0, 0, 0.05); + } + + & .sg-recorder { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: 14px; + padding-right: 8px; + } + + & .sg-transcript { + margin: 16px 6px 0; + font-size: 15px; + line-height: 21px; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--ink); + } + + & .sg-transcript-rest { + display: flex; + flex-direction: column; + gap: 9px; + margin: 12px 6px 0; + } + + & .sg-transcript-rest span { + height: 7px; + border-radius: 4px; + background: #ececf0; + } + + & .sg-rec { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 24px; + background: #ff3b30; + } + + & .sg-rec__stop { + width: 16px; + height: 16px; + border-radius: 4px; + background: #fff; + } + + & .sg-wave { + display: flex; + align-items: center; + gap: 2px; + height: 30px; + } + + & .sg-wave__bar { + flex: 1; + height: calc(var(--h) * 100%); + min-height: 3px; + border-radius: 2px; + background: #1d1d1f; + transform: scaleY(0.12); + will-change: transform; + transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-delay: calc(var(--i) * 40ms); + } + + & .sg-note[data-on] .sg-wave__bar { + transform: none; + } + + & .sg-rec__time { + font-size: 15px; + font-weight: 600; + color: var(--ink); + font-variant-numeric: tabular-nums; + } + + /* ---- Book (object) ---- */ + + & .sg-books { + position: absolute; + inset: 0; + } + + & .sg-book { + position: absolute; + box-sizing: border-box; + border-radius: 4px 8px 8px 4px; + will-change: transform; + transition: transform 1.1s cubic-bezier(0.25, 0.8, 0.25, 1); + } + + & .sg-book--back { + top: 34px; + left: calc(var(--pad) + 86px); + width: 150px; + height: 196px; + background: linear-gradient(90deg, #c9b590 0, #dccaa6 12px, #e4d4b2 100%); + box-shadow: 0 0 0 1px rgba(80, 60, 30, 0.12), 0 14px 26px -12px rgba(60, 40, 20, 0.35); + transform: translateY(70px); + transition-delay: 0.08s; + } + + & .sg-book--front { + top: 22px; + left: var(--pad); + display: flex; + flex-direction: column; + align-items: center; + width: 160px; + height: 210px; + /* Centered on the cover, not the spine */ + padding: 52px 0 0 14px; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 3px), + repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.05) 0 1px, transparent 1px 3px), + linear-gradient(160deg, #3b6b5c 0%, #2c5548 60%, #234539 100%); + box-shadow: 0 0 0 1px rgba(20, 40, 30, 0.3), 0 22px 36px -14px rgba(20, 40, 30, 0.55), 0 4px 10px -4px rgba(0, 0, 0, 0.2); + color: #e8d3a0; + transform: translateY(60px); + } + + & .sg-books[data-on] .sg-book { + transform: none; + } + + & .sg-book__spine { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 14px; + border-radius: 4px 0 0 4px; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.06) 70%, rgba(255, 255, 255, 0.08)); + } + + & .sg-book__title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.24em; + text-transform: uppercase; + /* Tracking adds space after the last letter too; take it back so the + word centers on its ink */ + margin-right: -0.24em; + } + + & .sg-book__by { + margin-top: 8px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + margin-right: -0.08em; + color: #c9b98e; + } + + /* ---- Day (the one phone) ---- */ + + & .sg-phone { + position: absolute; + top: 26px; + left: var(--pad); + will-change: transform; + } + + /* Keep the clock legible inside the smallest, guttered phone. */ + @container (max-width: 340px) { + & .sg-phone .ss-phone__time { font-size: 19px; } + } + + & .sg-page { + position: absolute; + inset: 0; + padding: 64px 24px 0; + background: #fff; + } + + & .sg-page__head { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 0 16px; + } + + & .sg-page__eyebrow { + font-size: 19px; + font-weight: 600; + color: var(--accent); + } + + & .sg-page__title { + font-size: 34px; + line-height: 40px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--ink); + } + + & .sg-mapcard { + overflow: hidden; + height: 190px; + border-radius: 20px; + } + + & .sg-mapcard svg { + display: block; + } + + & .sg-route { + /* A gap longer than the path, so no zero-length dash leaves a round cap behind */ + stroke-dasharray: 1 2; + stroke-dashoffset: 1.5; + transition: stroke-dashoffset 1.4s cubic-bezier(0.45, 0, 0.2, 1) 0.1s; + } + + & .sg-phone[data-on] .sg-route { + stroke-dashoffset: 0; + } + + & .sg-route__end { + opacity: 0; + transition: opacity 0.3s ease 1.4s; + } + + & .sg-phone[data-on] .sg-route__end { + opacity: 1; + } + + & .sg-thumbs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 12px; + } + + & .sg-thumb { + height: 108px; + border-radius: 14px; + } + + /* Three small photos: a green hill, a yellow palace wall, a mossy wood */ + & .sg-thumb--a { + background: + radial-gradient(120% 70% at 30% 100%, #3d6b45 40%, transparent 41%), + radial-gradient(90% 60% at 80% 100%, #567f4c 45%, transparent 46%), + linear-gradient(180deg, #a9c8de 0%, #dfe9e6 70%); + } + + & .sg-thumb--b { + background: + linear-gradient(180deg, transparent 0 34%, #e8b64c 34% 100%), + repeating-linear-gradient(90deg, transparent 0 14px, rgba(120, 70, 10, 0.25) 14px 20px), + linear-gradient(180deg, #8fb6d6 0%, #cfe0ea 100%); + } + + & .sg-thumb--c { + background: + radial-gradient(60% 40% at 50% 20%, #c7d79a 0%, transparent 70%), + linear-gradient(180deg, #4b6b3a 0%, #2e4a2a 60%, #1d3020 100%); + } + + & .sg-lines { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; + } + + & .sg-lines span { + height: 8px; + border-radius: 4px; + background: #e8e8ed; + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx new file mode 100644 index 0000000..32a151c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx @@ -0,0 +1,370 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { Count, Icon, Phone, Stage, useReducedMotion } from '../../kit' +import type { Act } from '../../kit' +import './grid.css' + +// A staggered pair, light: the features section of Wayline, an invented +// travel journal for iPhone. Two columns of rounded tiles, tall and short +// alternating so no row lines up across the columns (Apple's MacBook grid). +// +// Tile Column Size Kind The one thing Moves +// Coast left tall Picture a blue-and-white tile pattern (still) +// Private right short Statement one sentence, a blue lock (still) +// Distance left short Stat "38,604 km" this year counts up +// Voice right short Component the voice-note recorder the waveform rises +// Book left short Object the printed, clothbound trip rises into the crop +// Day right tall Hero UI one phone, one day's page the route draws +// +// One phone and no window: two of six tiles are UI. The coast is the one +// tile with its own ground. Across and down, no kind sits next to itself. +// Each picture is its own Stage at 1:1, pinned to its tile's bottom. The +// coast holds still; each tile that moves plays one beat, once, 110ms after +// the one before it in reading order. + +const STEP = 110 +const LEAD = 450 + +type Beat = { on: boolean } +const before: Beat = { on: false } +const after: Beat = { on: true } +const beat = (i: number) => async (a: Act) => { + await a.wait(LEAD + i * STEP) + await a.set({ on: true }) +} + +function Tile({ area, title, children, picture, photo }: { area: string; title: ReactNode; children?: ReactNode; picture?: ReactNode; photo?: boolean }) { + return ( +
+ {picture} + {title ? ( +

+ {title} {children} +

+ ) : ( + children + )} +
+ ) +} + +function useInView(delay: number) { + const ref = useRef(null) + const [seen, setSeen] = useState(false) + useEffect(() => { + const el = ref.current + if (!el) return + let t = 0 + const io = new IntersectionObserver(([e]) => { + if (!e.isIntersecting) return + io.disconnect() + t = window.setTimeout(() => setSeen(true), delay) + }, { threshold: 0.4 }) + io.observe(el) + return () => { + io.disconnect() + clearTimeout(t) + } + }, [delay]) + return [ref, seen] as const +} + +function usePictureWidth() { + const ref = useRef(null) + const [size, setSize] = useState({ width: 340, gutter: 26 }) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const measure = () => setSize({ width: node.clientWidth, gutter: parseFloat(getComputedStyle(node).getPropertyValue('--pad')) }) + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, size] as const +} + +/* ---------------- Coast (the hero picture) ---------------- */ + +// One still, a blue-and-white tile pattern: a different visual role from +// every neighboring tile, with no external image or asset dependency. +function Coast() { + return ( + Every trip, written up for you.} + picture={ + <> +
+ + {() => ( +
+ +
+ )} +
+
+ + } + > + Wayline turns your photos into a journal, a page a day. +
+ ) +} + +/* Azulejo: one tile drawn once and repeated by an SVG pattern. The motif is + the classic lattice of lenses: arcs of circles centered on the tile corners, + meeting in a cross at each tile's center, so the joins across the grout + make one continuous net. A cobalt field above, a frieze, then the field + of tiles running off the bottom and sides. */ +const AZ = { blue: '#1f3f8f', wash: '#8ea5d4', white: '#f6f3ec', grout: '#d3d9e5' } +const T = 80 +// A lens from (0,-L) to (0,L), half-width w: both arcs share radius r +const lens = (L: number, w: number) => { + const r = (L * L + w * w) / (2 * w) + return `M0 ${-L} A${r} ${r} 0 0 1 0 ${L} A${r} ${r} 0 0 1 0 ${-L} Z` +} +const CORNERS = [[0, 0], [T, 0], [0, T], [T, T]] + +function Azulejo() { + const tileId = useId() + const friezeId = useId() + const P = 300 // the field's top edge, on a row of tile corners + const F = 40 // the frieze: a row of half tiles + return ( + + + + + + {/* the net: a lens on each edge, center to center across the joint */} + {[[T / 2, 0, 0], [T, T / 2, 90], [T / 2, T, 0], [0, T / 2, 90]].map(([x, y, r], i) => ( + + + + + ))} + {/* the boss where the lenses cross */} + + + + {/* a four-petal flower on every corner, completed by four tiles */} + {CORNERS.map(([x, y], i) => ( + + {[45, 135, 225, 315].map((d) => ( + + ))} + + + ))} + + + + + + + + + + + {[0, T].map((x) => ( + + + + + ))} + + + + + + + ) +} + +/* ---------------- Private (statement) ---------------- */ + +function Private() { + return ( + +
+ +

+ Private by design. Your journal stays on your iPhone. +

+
+
+ ) +} + +/* ---------------- Distance (stat) ---------------- */ + +function Distance() { + const reduced = useReducedMotion() + const [ref, seen] = useInView(LEAD + 2 * STEP) + return ( + +
+ This year you went + + + 38,604 + + + km + + measured from where your photos were taken. +
+
+ ) +} + +/* ---------------- Voice (component) ---------------- */ + +const WAVE = Array.from({ length: 30 }, (_, i) => { + const v = 0.3 + 0.45 * Math.abs(Math.sin(i * 0.55)) * (0.6 + 0.4 * Math.cos(i * 0.23)) + 0.2 * Math.abs(Math.sin(i * 1.9)) + return Math.min(1, v) +}) + +function Voice() { + const [ref, { width }] = usePictureWidth() + return ( + Say it instead.} + picture={ +
+ width={width} height={206} initial={before} poster={after} play="once" script={beat(3)} className="sg-stage"> + {(s) => ( +
+
+ + + + + {WAVE.map((h, i) => ( + + ))} + + + 0: String(Math.round(n)).padStart(2, '0')} /> + +
+

“Fog all morning, then the whole coast at once.”

+
+ + +
+
+ )} + +
+ } + > + Voice notes become the day’s words. +
+ ) +} + +/* ---------------- Book (object) ---------------- */ + +function Book() { + const [ref, { width }] = usePictureWidth() + return ( + Print the trip.} + picture={ +
+ width={width} height={190} initial={before} poster={after} play="once" script={beat(4)} className="sg-stage"> + {(s) => ( +
+ + + + Portugal + Maya Chen · 2026 + +
+ )} + +
+ } + > + Clothbound, at your door in a week. +
+ ) +} + +/* ---------------- Day (hero UI: the one phone) ---------------- */ + +function Day() { + const [ref, { width, gutter }] = usePictureWidth() + return ( + A page for every day.} + picture={ +
+ width={width} height={400} initial={before} poster={after} play="once" script={beat(5)} className="sg-stage"> + {(s) => ( + // Centered in its tile: the one device in the grid stands in + // the middle of its card, running off the bottom + +
+
+ Day 2 · Tuesday + Sintra +
+
+ {/* Fills the card at the page width, so its edge lines up with the photos under it */} + + + + + + + + + +
+
+ + + +
+
+ + + +
+
+
+ )} + +
+ } + > + Route, photos and notes, laid out for you. +
+ ) +} + +export default function StaggeredBento() { + return ( +
+
+
+ + + +
+
+ + + +
+
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/static-card/card.css b/skills/imported/benjitaylor/stageshow/examples/static-card/card.css new file mode 100644 index 0000000..f2709c8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/static-card/card.css @@ -0,0 +1,192 @@ +/* All rules belong to this example, including its responsive variants. */ +.sc-card { + /* The card is the stage: it clips the window, so the bleed is a clean crop. + One gutter for every edge something sits near: the window's top, and the + detail card's left and bottom. */ + & .ss-stage { + --gutter: 24px; + overflow: hidden; + border-radius: 16px; + background: var(--ss-well); + } + + /* Pinned top-left with a gutter on the left for the detail card; runs about + a third past the right edge and off the bottom. */ + .sc-window { + position: absolute; + top: var(--gutter); + left: 104px; + width: 460px; + height: 320px; + background: var(--ss-surface); + border-radius: 10px; + box-shadow: var(--ss-shadow-hero); + } + + /* Keep the selected customer whole in the narrow crop; only filler bleeds. */ + & [data-layout="compact"] .sc-window { left: 68px; } + + .sc-window .ss-window__body { + display: flex; + } + + .sc-sidebar { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 4px; + width: 120px; + padding: 16px 10px; + border-right: 1px solid var(--ss-line); + background: var(--ss-canvas); + } + + .sc-nav { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + padding: 0 6px; + border-radius: 6px; + } + + .sc-main { + flex: 1; + min-width: 0; + padding: 16px 18px; + } + + .sc-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .sc-title { + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); + } + + .sc-list { + display: flex; + flex-direction: column; + } + + /* Kit Rows at this list's pitch: a hairline between, the selection inset */ + .sc-row { + align-content: center; + height: 36px; + margin: 0 -8px; + padding: 0 8px; + border-radius: 8px; + border-bottom: 1px solid var(--ss-line-faint); + transition: background 0.15s ease; + } + + .sc-row[data-selected] { + background: var(--ss-accent-soft); + border-bottom-color: transparent; + } + + .sc-name { + font-size: 12px; + font-weight: 500; + color: var(--ss-text-strong); + } + + .sc-row .ss-avatar, + .sc-avatar-large { + font-weight: 600; + } + + /* The satellite: straddles the window's lower-left edge, over filler rows + and clear of the selection, with a stronger shadow than the window so it + sits a level above it. */ + .sc-detail { + position: absolute; + bottom: var(--gutter); + left: var(--gutter); + /* Ends a full gutter short of the sidebar divider (104 + 120 = 224) rather + than nearly touching it */ + width: 176px; + padding: 14px 16px 10px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-satellite); + } + + .sc-detail-head { + display: flex; + align-items: center; + /* 26 + 8 + the name (106) leaves the 144px row a few pixels of air */ + gap: 8px; + padding-bottom: 12px; + margin-bottom: 2px; + border-bottom: 1px solid var(--ss-line); + } + + .sc-detail-who { + display: flex; + flex-direction: column; + gap: 6px; + } + + .sc-detail-name { + font-size: 12px; + font-weight: 600; + color: var(--ss-text-strong); + } + + .sc-detail-row { + display: flex; + align-items: center; + justify-content: space-between; + height: 28px; + } + + .sc-detail-row.total { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--ss-line); + height: 32px; + } + + .sc-detail-label { + font-size: 11px; + color: var(--ss-text); + } + + .sc-detail-total { + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); + font-variant-numeric: tabular-nums; + } + + .sc-nav__label { + font-size: 10.5px; + line-height: 14px; + color: var(--ss-text); + } + + .sc-nav[data-on] .sc-nav__label { + color: var(--ss-text-strong); + font-weight: 600; + } + + .sc-rowname { + font-size: 11px; + font-weight: 400; + line-height: 14px; + color: var(--ss-text); + } + + .sc-rowamount { + margin-left: auto; + font-size: 11px; + line-height: 14px; + color: var(--ss-text-faint); + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx b/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx new file mode 100644 index 0000000..037014a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx @@ -0,0 +1,82 @@ +import { Bar, Stage, Text, Window, Row, Avatar } from '../../kit' +import './card.css' + +// A feature card (400 × 300) with the product masked inside it: the window is +// pinned top-left, runs off the right and bottom, and a detail card straddles +// its left edge over the sidebar, about half outside. Real words only on the +// selected customer, its neighbors and the total; bars everywhere else. + +// Three real rows (the selected one and its neighbors), bars only on the +// rows the crop takes +const ROWS: { selected?: boolean; name?: number | string; amount?: number | string }[] = [ + { name: 'Halvorsen & Co', amount: '$1,240' }, + { selected: true }, + { name: 'Lumen Studio', amount: '$860' }, + { name: 84, amount: 38 }, + { name: 64, amount: 48 }, + { name: 78, amount: 40 }, +] +const NAV = ['Overview', 'Customers', 'Invoices', 'Reports'] + +export default function StaticCard() { + return ( + + {() => ( + <> + + {/* The sidebar is what the detail card covers, so the rows stay readable */} + +
+
+ Customers + +
+
+ {ROWS.map((row, i) => + row.selected ? ( + } title={Jackson Hot Yoga}> + + + ) : ( + } title={typeof row.name === 'string' ? {row.name} : }> + {typeof row.amount === 'string' ? {row.amount} : } + + ), + )} +
+
+
+ +
+
+ +
+ Jackson Hot Yoga + +
+
+ {[48, 36].map((w, i) => ( +
+ + +
+ ))} +
+ Total + $72.00 +
+
+ + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/story/index.tsx b/skills/imported/benjitaylor/stageshow/examples/story/index.tsx new file mode 100644 index 0000000..285adba --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/story/index.tsx @@ -0,0 +1,150 @@ +import { Stage, Window, Bar, Icon, Connector, Count, Roll, Meter, Segmented, Chart, type Act } from '../../kit' +import './story.css' + +// A payment story: one element reveals, then another arrives as the +// first is still settling, a line ties them together, and the numbers keep +// moving. Beats overlap instead of waiting on each other; groups stagger. +// It opens on the invoice and loops through it: the satellites leave in +// reverse while the invoice holds; its changing values reset only while hidden. + +type Tier = 'volume' | 'graduated' +type S = { show: number; tokens: number; tier: Tier; usage: number; resetting: boolean } + +const START = { resetting: false, show: 1, tokens: 1_968_086_209, tier: 'volume' as Tier, usage: 0.52 } +const PRICE: Record = { volume: 0.01, graduated: 0.0098 } +const BARS = [6, 10, 8, 14, 12, 18, 9, 16, 22, 15, 19, 26, 21, 30, 17, 24, 33, 28, 20, 36, 31, 27, 40, 34, 29, 44] + +// One deterministic rhythm for the live ticks, so every loop matches +const TICKS = [18_420, 9_310, 22_870, 12_040, 15_660, 26_190, 11_480, 19_930] + +const script = async (a: Act) => { + a.mark('reveal') + // The invoice is the opening frame; the plan arrives into it + await a.wait(500) + a.put({ show: 2 }) + await a.wait(900) + // The line ties the line item to the plan it bills against + a.put({ show: 3 }) + await a.wait(900) + // Usage starts flowing: bars grow in a stagger, then the numbers tick + a.mark('live') + a.put({ show: 4 }) + for (const n of TICKS.slice(0, 4)) { + await a.wait(650) + a.put((s) => ({ tokens: s.tokens + n, usage: Math.min(0.8, s.usage + 0.03) })) + } + // Switch pricing; the price rolls and the total recalculates as it ticks + a.mark('tier') + await a.click({ at: 'graduated', x: 0.84, y: 0.72 }) + a.put({ tier: 'graduated' }) + await a.wait(300) + for (const n of TICKS.slice(4)) { + await a.wait(650) + a.put((s) => ({ tokens: s.tokens + n, usage: Math.min(0.8, s.usage + 0.03) })) + } + await a.move('rest') + await a.wait(2000) + // The invoice remains the anchor. Hide only the changing figures before + // preparing the next cycle; consumed tokens never count backwards in view. + a.mark('exit') + await a.set({ show: 1 }) + await a.wait(900) + await a.set({ resetting: true }) + await a.wait(220) + await a.snap({ ...START, resetting: true }) + await a.set({ resetting: false }) + await a.wait(300) +} + +const money = (n: number) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +export default function Story() { + return ( + specimen width={640} height={452} compact={{ below: 600, width: 358, height: 610 }} initial={START} poster={{ ...START, show: 4, tier: 'graduated' }} script={script} cursor={{ at: 'rest' }} className="st"> + {(s, rect, layout) => { + const amount = (s.tokens / 1000) * PRICE[s.tier] + const ref = rect('inv-ref') + const plan = rect('plan') + return ( + <> +
+ = 1 || undefined}> +
+
+ Invoice + +
+
+ {[[46, 70], [38, 58], [52, 64]].map(([a, b], i) => ( +
+ + +
+ ))} +
+
+ due Oct 1 +
+
+ Description + Qty +
+
+ Cloud tokens + +
+
+ + +
+
+
+ +
= 2 || undefined}> +
+ + + + + Pro plan +
+ Billed monthly +
+
+
+ Tokens + + {`$${PRICE[s.tier]} per 1,000`} + +
+ + = 4 ? s.usage : 0} /> +
+ +
= 2 || undefined}> + Tokens used, last 30 days + {/* The kit's Chart: bars grow in a stagger when the usage arrives */} + = 4} /> +
+ Sep 21 + Oct 21 +
+
+ + {/* From the invoice header, across and down onto the plan's top + edge: it never crosses the card it points at */} + {ref.width > 0 && plan.width > 0 && ( + = 3} + from={{ x: ref.x + ref.width + 8, y: ref.y + ref.height / 2 }} + to={{ x: plan.x + plan.width * 0.7, y: plan.y }} + route="h" + /> + )} + + + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/story/story.css b/skills/imported/benjitaylor/stageshow/examples/story/story.css new file mode 100644 index 0000000..0b7359b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/story/story.css @@ -0,0 +1,168 @@ +/* All rules belong to this example, including its responsive variants. */ +.st { + & .ss-stage { + --ss-accent: #635bff; + --ss-accent-2: #e15bff; + --ss-accent-soft: rgba(99, 91, 255, 0.1); + --gutter: 28px; + overflow: hidden; + border-radius: 14px; + background: #f6f8fb; + font: 12px/1.4 var(--ss-font); + color: var(--ss-text-strong); + } + + /* The color lives in the background, low and to one side */ + .st-wash { + position: absolute; + inset: 0; + background: + radial-gradient(60% 70% at 15% 105%, rgba(99, 91, 255, 0.45), transparent 70%), + radial-gradient(40% 50% at 95% 110%, rgba(255, 186, 90, 0.4), transparent 70%); + } + + .st-strong { font-weight: 600; } + .st-muted { color: var(--ss-text); } + + /* Entrances: rise and fade. Everything is in the DOM from the start. */ + .st-invoice, + .st-plan, + .st-chart { + position: absolute; + opacity: 0; + /* The text and its card travel on one retained layer. */ + transform: translateY(14px); + /* Long entrances settle softly while related arrivals overlap. */ + will-change: transform; + transition: opacity 0.8s ease, transform 1.1s cubic-bezier(0.45, 0, 0.1, 1); + } + + .st-invoice[data-in], + .st-plan[data-in], + .st-chart[data-in] { + opacity: 1; + transform: translate(0, 0); + transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1); + } + + /* The plan arrives from the side it straddles */ + .st-plan { transform: translateX(18px); } + .st-chart { transition-delay: 0.35s; transform: translateX(18px); } + + /* Exits run in reverse: satellites leave first, the invoice last */ + .st-invoice:not([data-in]) { transition-delay: 0.5s; } + .st-plan:not([data-in]) { transition-delay: 0.22s; } + .st-chart:not([data-in]) { transition-delay: 0s; } + + .st-invoice { + top: var(--gutter); + left: var(--gutter); + width: 340px; + height: 480px; + box-shadow: var(--ss-shadow-hero); + } + + .st-invoice .ss-window__bar { display: none; } + .st-invoice .ss-window__body { background: var(--ss-surface); } + + .st-inv { padding: 22px 24px; } + .st-inv__head { display: flex; align-items: center; gap: 16px; margin-bottom: 18px; } + .st-inv__title { font-size: 15px; font-weight: 600; } + .st-inv__meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; } + .st-inv__meta > div { display: grid; gap: 7px; } + + .st-inv__due { + margin-bottom: 18px; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + } + + .st-inv__due span { font-weight: 600; } + + .st-inv__row { + /* Qty sits in a fixed column that ends before the plan card's edge, so + the satellite only covers empty row */ + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + height: 34px; + border-bottom: 1px solid var(--ss-line); + } + + .st-inv__row--head { height: 26px; color: var(--ss-text); font-size: 11px; } + .st-qty { color: var(--ss-text); } + + /* Satellites straddle the invoice's right edge */ + .st-plan, + .st-chart { + left: 300px; + width: 236px; + padding: 16px; + border-radius: 10px; + background: var(--ss-raised); + box-shadow: var(--ss-shadow-satellite); + } + + .st-plan { top: 92px; } + + .st-plan__head { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; line-height: 1.35; } + + .st-plan__mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 8px; + box-shadow: inset 0 0 0 1px var(--ss-line); + color: var(--ss-accent); + } + + .st-plan__price { display: flex; justify-content: space-between; margin-bottom: 12px; } + + /* The kit's Segmented at the plan card's size: its thumb slides between + the tiers */ + .st-seg { + margin-bottom: 14px; + border-radius: 8px; + background: var(--ss-ink-1); + } + + .st-seg .ss-seg__option { + height: 24px; + font-size: 11px; + } + + .st-seg .ss-seg__thumb { border-radius: 6px; } + + /* One gutter under the plan. The card bleeds off the bottom just past its + axis labels, so nothing important is cropped and no empty card is left over. */ + .st-chart { top: 274px; height: 220px; } + + .st-bars { + display: block; + margin-top: 14px; + } + + .st-axis { + display: flex; + justify-content: space-between; + margin-top: 8px; + color: var(--ss-text); + font-size: 10px; + } + + + .st-inv__due, .st-qty { transition: opacity 180ms ease; } + .st-inv[data-reset] .st-inv__due, .st-inv[data-reset] .st-qty { opacity: 0; } + + /* On a phone, the story reads down the invoice instead of beside it. */ + & .ss-stage[data-layout='compact'] { --gutter: 16px; } + & .ss-stage[data-layout='compact'] .st-invoice { width: 310px; height: 640px; } + & .ss-stage[data-layout='compact'] .st-inv { padding: 20px; } + & .ss-stage[data-layout='compact'] .st-inv__meta { gap: 8px; } + & .ss-stage[data-layout='compact'] .st-plan, + & .ss-stage[data-layout='compact'] .st-chart { left: 40px; width: 302px; } + & .ss-stage[data-layout='compact'] .st-plan { top: 250px; } + & .ss-stage[data-layout='compact'] .st-chart { top: 432px; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx b/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx new file mode 100644 index 0000000..3df83f4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx @@ -0,0 +1,252 @@ +import { Avatar, Bar, Chart, Collapse, Count, Fly, Roll, Spinner, Stage, States, Window, type Act, type Rect } from '../../kit' +import './subs.css' + +// A system story, no cursor. A customer subscribes on a checkout, the price +// rides into the company's billing dashboard as the new row's amount, and MRR +// rolls up. It happens twice, then the scene changes to a second company and +// happens once more: a different business on the same product, which is also +// how the loop resets without rewinding. The checkout and the window are the +// two places; the chip is the only thing that travels between them. + +type Pulse = { name: string; initial: string; plan: string; price: number } +type Scene = { org: string; tile: string; mrr: number; history: number[]; pulses: Pulse[] } + +const SCENES: Scene[] = [ + { + org: 'Northwind', + tile: 'N', + mrr: 48210, + history: [31, 33, 32, 36, 38, 37, 41, 44, 43, 46, 48.2], + pulses: [ + { name: 'Maya Lin', initial: 'M', plan: 'Pro', price: 49 }, + { name: 'Studio Kite', initial: 'S', plan: 'Team', price: 99 }, + ], + }, + { + org: 'Brightside', + tile: 'B', + mrr: 12480, + history: [6, 6.4, 7.1, 7, 8.2, 8.8, 9.1, 10.4, 10.9, 11.6, 12.48], + pulses: [{ name: 'Leo Park', initial: 'L', plan: 'Plus', price: 29 }], + }, +] + +type Pay = 'idle' | 'busy' | 'paid' | 'done' +type S = { + scene: number + // per scene: rows opened, rows landed, which pulse the checkout shows, its button + open: [number, number] + landed: [number, number] + k: [number, number] + pay: [Pay, Pay] + fly: null | { scene: number; i: number; from: Rect; to: Rect; go: boolean } +} + +const START: S = { scene: 0, open: [0, 0], landed: [0, 0], k: [0, 0], pay: ['idle', 'idle'], fly: null } +const POSTER: S = { ...START, open: [2, 0], landed: [2, 0], k: [1, 0], pay: ['done', 'idle'] } + +const money = (n: number) => '$' + Math.round(n).toLocaleString('en-US') +const set2 = (pair: [T, T], i: number, v: T): [T, T] => (i === 0 ? [v, pair[1]] : [pair[0], v]) + +async function pulse(a: Act, sc: number, i: number) { + await a.set((s) => ({ pay: set2(s.pay, sc, 'busy') })) + await a.wait(700) + // Paid: the chip lifts off the button at once and the new row's space opens + // under it while it travels. Where it lands is the top of the list as it is + // now (the new row opens there), at the right edge where amounts sit. + const list = a.rect(`list-${sc}`, { layout: true }) + const row = a.rect(`row-${sc}-${i}`, { layout: true }) + const pill = a.rect(`pill-${sc}-${i}`, { layout: true }) + const b = a.rect(`pay-${sc}`, { layout: true }) + const size = { width: pill.width, height: pill.height } + // The row keeps its full layout height inside Collapse. Measure the pill's + // actual inset, including the row's border, so the handoff has no half-pixel hop. + const to = { ...pill, y: list.y + pill.y - row.y } + const from = { x: b.x + b.width / 2 - pill.width / 2, y: b.y + b.height / 2 - pill.height / 2, ...size } + await a.set((s) => ({ pay: set2(s.pay, sc, 'paid'), fly: { scene: sc, i, from, to, go: false } })) + // The chip buds off the button before it sets off + await a.wait(200) + await a.set((s) => ({ open: set2(s.open, sc, i + 1), fly: { ...s.fly!, go: true } })) + await a.wait(900) + // Landed: the real pill takes over in the same frame the chip goes + await a.set((s) => ({ fly: null, landed: set2(s.landed, sc, i + 1), pay: set2(s.pay, sc, 'done') })) +} + +async function script(a: Act) { + await a.wait(900) + a.mark('first') + await pulse(a, 0, 0) + await a.wait(700) + // The checkout moves on to the next customer: only the plan and price change + await a.set((s) => ({ pay: set2(s.pay, 0, 'idle'), k: set2(s.k, 0, 1) })) + await a.wait(1000) + a.mark('second') + await pulse(a, 0, 1) + await a.wait(2000) + + a.mark('scene') + await a.set({ scene: 1 }) + await a.wait(700) + // Northwind resets while it's out of sight + await a.snap((s) => ({ open: set2(s.open, 0, 0), landed: set2(s.landed, 0, 0), k: set2(s.k, 0, 0), pay: set2(s.pay, 0, 'idle') })) + await a.wait(500) + await pulse(a, 1, 0) + await a.wait(2000) + + await a.set({ scene: 0 }) + await a.wait(700) + await a.snap((s) => ({ open: set2(s.open, 1, 0), landed: set2(s.landed, 1, 0), pay: set2(s.pay, 1, 'idle') })) + await a.wait(300) +} + +function Checkout({ sc, s }: { sc: number; s: S }) { + const scene = SCENES[sc] + const p = scene.pulses[s.k[sc]] + return ( +
+
+ + {scene.tile} + + + {scene.org} {p.plan} + +
+
+ {`$${p.price}`} + per month +
+
+
+ +
+
+ + +
+
+
+ , + // The amount is the feedback while it leaves the button. Showing + // the success label underneath would put two messages in one place. + paid: null, + done: ( + + + + + Subscribed + + ), + }} + /> +
+
+ ) +} + +// Filler rows that were there before the story: they keep their place below +const OLD = [ + { name: 62, amount: 34 }, + { name: 78, amount: 40 }, + { name: 54, amount: 34 }, + { name: 70, amount: 40 }, +] + +function Dashboard({ sc, s, compact }: { sc: number; s: S; compact: boolean }) { + const scene = SCENES[sc] + const added = scene.pulses.slice(0, s.landed[sc]).reduce((t, p) => t + p.price, 0) + const mrr = scene.mrr + added + const data = [...scene.history.slice(0, -1), mrr / 1000] + return ( +
+ +
+
+ + {/* Compact drops the sidebar, so the company moves up beside the title */} + + {scene.tile} + + Subscriptions + + +
+
+
+ MRR + +
+ +
+
+ {/* Newest first: each new subscriber opens at the top and pushes the rest down */} + {scene.pulses + .map((p, i) => ({ p, i })) + .reverse() + .map(({ p, i }) => ( + i} reveal="clip"> +
i || undefined}> + + {p.name} + {p.plan} + + +${p.price} + +
+
+ ))} + {OLD.map((r, i) => ( +
+ + + +
+ ))} +
+
+
+ ) +} + +export default function Subscriptions() { + return ( + width={720} height={440} compact={{ below: 600, width: 358, height: 460 }} initial={START} poster={POSTER} script={script} className="sb"> + {(s, _rect, layout) => ( + <> +
+ +
+ + +
+
+ + + {s.fly && ( + + +${SCENES[s.fly.scene].pulses[s.fly.i].price} + + )} + + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/subscriptions/subs.css b/skills/imported/benjitaylor/stageshow/examples/subscriptions/subs.css new file mode 100644 index 0000000..e42ca14 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/subscriptions/subs.css @@ -0,0 +1,352 @@ +/* All rules belong to this example, including its responsive variants. */ +.sb { + & .ss-stage { + --ss-accent: #5b5bd6; + --ss-accent-2: #5b5bd6; + --ss-accent-soft: rgba(91, 91, 214, 0.1); + --gutter: 36px; + /* Arrivals are already moving on their first frame; travel eases out of rest */ + --sb-arrive: cubic-bezier(0.25, 0.8, 0.25, 1); + --sb-travel: cubic-bezier(0.45, 0, 0.1, 1); + overflow: hidden; + border-radius: 16px; + background: #f5f6fa; + font: 12px/1.4 var(--ss-font); + color: var(--ss-text-strong); + } + + .sb-wash { + position: absolute; + inset: 0; + background: + radial-gradient(55% 70% at 0% 100%, rgba(91, 91, 214, 0.13), transparent 70%), + radial-gradient(40% 50% at 100% 0%, rgba(91, 91, 214, 0.07), transparent 70%); + } + + .sb-strong { font-weight: 600; } + .sb-muted { color: var(--ss-text); } + + /* ---- The dashboard: pinned top and left of center, off the right and bottom ---- */ + + .sb-window { + position: absolute; + top: var(--gutter); + left: 196px; + width: 560px; + height: 440px; + background: var(--ss-surface); + border-radius: 10px; + box-shadow: var(--ss-shadow-hero); + } + + .sb-scenes { + display: grid; + height: 100%; + } + + /* Two companies on the same product, stacked: the scene change is a crossfade + of everything below the browser chrome, which stays put */ + .sb-scene { + display: flex; + grid-area: 1 / 1; + min-height: 0; + opacity: 0; + transition: opacity 0.45s ease 0.1s; + } + + /* The incoming scene leads, so the window is never half empty mid-change */ + .sb-scene[data-on] { + opacity: 1; + transition: opacity 0.45s ease; + } + + .sb-side { + display: flex; + flex-direction: column; + flex-shrink: 0; + gap: 4px; + width: 150px; + padding: 16px 10px; + border-right: 1px solid var(--ss-line); + background: var(--ss-canvas); + } + + .sb-org { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + margin-bottom: 10px; + padding: 0 6px; + font-size: 12.5px; + } + + .sb-tile { + display: grid; + flex-shrink: 0; + place-items: center; + width: 20px; + height: 20px; + border-radius: 6px; + background: #23304d; + color: #fff; + font-size: 11px; + font-weight: 600; + } + + .sb-tile[data-scene='1'] { background: #e8833a; } + + .sb-nav { + display: flex; + align-items: center; + gap: 8px; + height: 26px; + padding: 0 6px; + border-radius: 6px; + } + + .sb-nav[data-on] { background: var(--ss-ink-2); } + + .sb-main { + flex: 1; + min-width: 0; + /* The window runs 36px past the stage's right edge: the content stops a + gutter short of what is visible, not of the window */ + padding: 18px calc(22px + 36px) 18px 22px; + } + + .sb-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + } + + .sb-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + } + + .sb-mrr { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding: 14px 16px; + border: 1px solid var(--ss-line); + border-radius: 10px; + } + + .sb-mrr__text { + display: grid; + gap: 2px; + } + + .sb-mrr__value { + font-size: 26px; + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + } + + /* ---- Subscribers: newest on top ---- */ + + .sb-row { + display: grid; + flex-shrink: 0; + grid-template-columns: 22px minmax(0, 1fr) 44px 52px; + align-items: center; + gap: 10px; + height: 42px; + padding: 0 4px; + border-bottom: 1px solid var(--ss-line); + /* Pushed down as each new row opens: a layer keeps the text with its box */ + will-change: transform; + } + + .sb-row--old { + grid-template-columns: 22px minmax(0, 1fr) 52px; + } + + /* The kit's Avatar in the product's soft tints */ + .sb-av { + background: #e7e4fb; + color: #4b45b8; + font-size: 10.5px; + font-weight: 600; + } + + .sb-av[data-i='1'] { background: #dff1ea; color: #23744f; } + .sb-av[data-scene='1'] { background: #fde9da; color: #b05a1c; } + + .sb-name { font-weight: 500; } + .sb-plan { color: var(--ss-text); } + + /* The row's words arrive with the chip; the pill is the chip itself, so it + shows the instant the flying one is gone, with no fade between them */ + .sb-row > :not(.sb-pill) { + opacity: 0; + transition: opacity 0.2s ease; + } + + .sb-row[data-landed] > :not(.sb-pill) { + opacity: 1; + transition: opacity 0.45s ease 0.05s; + } + + .sb-row--old > * { opacity: 1 !important; } + + .sb-pill { + justify-self: end; + padding: 3px 8px; + border-radius: 999px; + background: var(--ss-accent-soft); + color: var(--ss-accent); + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + visibility: hidden; + } + + .sb-row[data-landed] > .sb-pill { visibility: visible; } + + /* In flight it lifts off the page: a white chip with the same words. Over + the last of its flight it lowers into the soft pill (fill eases to the + tint, the lift and ring fade), so it lands already looking like the row's + pill and the handover is invisible */ + .sb-pill--chip { + display: inline-block; + visibility: visible; + background: #fff; + box-shadow: 0 0 0 1px rgba(91, 91, 214, 0.18), 0 6px 16px -4px rgba(50, 50, 93, 0.3); + } + + .ss-fly[data-go] .sb-pill--chip { + /* The tint over the row's white, as one opaque color */ + background: #efeffb; + box-shadow: 0 0 0 1px rgba(91, 91, 214, 0), 0 0 0 -4px rgba(50, 50, 93, 0); + transition: background-color 0.35s ease 0.5s, box-shadow 0.35s ease 0.5s; + } + + /* ---- The checkout: straddles the dashboard's sidebar ---- */ + + .sb-checkout { + position: absolute; + top: 148px; + left: var(--gutter); + display: grid; + gap: 14px; + width: 236px; + padding: 18px; + border-radius: 12px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-satellite); + opacity: 0; + transform: translateX(-14px); + will-change: transform; + transition: opacity 0.22s ease, transform 0.4s var(--sb-travel); + } + + .sb-checkout[data-in] { + opacity: 1; + transform: none; + transition: opacity 0.6s ease 0.28s, transform 0.9s var(--sb-arrive) 0.28s; + } + + .sb-checkout__head { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + } + + .sb-price { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + } + + .sb-price .sb-muted { + font-size: 12px; + font-weight: 400; + letter-spacing: 0; + } + + .sb-fields { + display: grid; + border: 1px solid var(--ss-line-strong, rgba(0, 0, 0, 0.1)); + border-radius: 8px; + } + + .sb-field { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 10px; + } + + .sb-field + .sb-field { border-top: 1px solid var(--ss-line); } + + .sb-pay { + display: grid; + place-items: center; + height: 36px; + border-radius: 8px; + background: var(--ss-accent); + color: #fff; + font-weight: 600; + transition: background-color 0.2s ease; + } + + .sb-pay[data-state='paid'], + .sb-pay[data-state='done'] { background: #1f9d55; } + + .sb-done { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sb-head-tile { display: none; } + + /* ---- Compact (below 600): the dashboard on top, the checkout over its + filler rows at the bottom, so the chip flies up into the list ---- */ + + & .ss-stage[data-layout='compact'] { + --gutter: 24px; + } + + [data-layout='compact'] .sb-window { + left: 48px; + width: 334px; + height: 460px; + } + + [data-layout='compact'] .sb-side { display: none; } + [data-layout='compact'] .sb-head-tile { display: grid; } + + [data-layout='compact'] .sb-main { + padding: 16px calc(18px + 24px) 16px 18px; + } + + [data-layout='compact'] .sb-checkout { + /* One gutter above the stage's bottom edge, like the left */ + top: auto; + bottom: var(--gutter); + gap: 12px; + width: 196px; + padding: 16px; + } + + [data-layout='compact'] .sb-fields { display: none; } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/swipe/index.tsx b/skills/imported/benjitaylor/stageshow/examples/swipe/index.tsx new file mode 100644 index 0000000..bbe8d81 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/swipe/index.tsx @@ -0,0 +1,195 @@ +import { Archive, ChevronLeft, ChevronRight, Ellipsis, Megaphone, MessageSquare, ShoppingCart, User } from 'lucide-react' +import { Avatar, Bar, Collapse, Phone, Row, Stage, type Act } from '../../kit' +import './swipe.css' + +// A mail app's swipe to archive, phone only. Beats: the finger presses a +// message, swipes it left with the archive action opening behind, lets go, +// and the row carries on off the screen at the speed the finger left it; +// then the gap closes and the next message moves up. Plays once; Replay is +// the reset. +// +// What makes the swipe read as one gesture, rather than harsh, paused half +// way or jolted into place: +// - The row is under the finger the whole way, 1:1, no lag, no rubber band. +// - The throw starts at the finger's own speed. The script measures the last +// few samples of the drag and builds a curve whose first slope is that +// speed, so there is no step at the hand-over and no pause. +// - The Archive mark rides the row's trailing edge, so it ends at the left +// edge when the row is off, as Mail's does; the gap then closes in one ease +// with the color solid, never fading through gray. +// - The kit's touch cursor presses first, then swipes, leaving its trail. +// +// Only the swiped message is unread: a blue dot on a placeholder row would +// be the heaviest mark on filler. +// +// Real strings: Inbox, Edit, the swiped message, the one that moves up into +// its place (so the last frame has words where the eye lands), Archive. The other rows are +// placeholder lines at a real message's line heights. + +type S = { dx: number; throw: { ms: number; ease: string } | null; gone: boolean } + +const initial: S = { dx: 0, throw: null, gone: false } + +const PICK = 0 + +const MAIL = [ + { from: 'Northwind Air', time: '9:12', subject: 'Your trip to Lisbon', preview: 'Check-in opens 24 hours before departure. Your seat is 14A.', unread: true }, + { from: 'Jon Park', time: '8:30', subject: 'Re: pricing page', preview: 'Looks good to me. One note on the annual toggle copy.', unread: false }, + { from: 'Maya Chen', time: '9:41', subject: 'Launch checklist', preview: 'Two things left before Thursday: the pricing page and the press kit.', unread: false }, + { from: 'Ana Ruiz', time: 'Yesterday', subject: 'Q3 board deck', preview: 'Draft is in the shared folder, comments welcome by Friday.', unread: false }, + { from: 'Leo Sato', time: 'Yesterday', subject: 'Offsite dates', preview: 'Can everyone do the week of the 14th? Venue is holding it.', unread: false }, +] + +async function script(a: Act) { + await a.wait(900) + + // 1. Press the message on its right side, then swipe it left + a.mark('swipe') + const row = a.rect('row') + const y = row.y + row.height * 0.5 + await a.move({ x: row.x + row.width * 0.8, y }) + await a.wait(250) + const from = row.x + row.width * 0.8 + const samples: { x: number; t: number }[] = [] + await a.drag( + { x: row.x + row.width * 0.22, y }, + (at) => { + samples.push({ x: at.x, t: performance.now() }) + a.put({ dx: at.x - from }) + }, + // A swipe is flat: no carrying bow, the finger runs along the row + { duration: 300, path: (t, a0, b0) => ({ x: a0.x + (b0.x - a0.x) * t, y: a0.y }) }, + ) + + // 2. The finger lifts still moving; the row carries on at that speed + a.mark('throw') + const last = samples.slice(-4) + const v = last.length > 1 ? Math.abs(last[last.length - 1]!.x - last[0]!.x) / Math.max(1, last[last.length - 1]!.t - last[0]!.t) : 1 + const dx = samples.length ? samples[samples.length - 1]!.x - from : 0 + const left = row.width + dx + const ms = 300 + // A curve whose first slope is the finger's speed, easing out at the end + const x1 = 0.25 + const y1 = Math.min(0.95, (x1 * v * ms) / left) + a.put({ throw: { ms, ease: `cubic-bezier(${x1}, ${y1.toFixed(3)}, 0.35, 1)` }, dx: -row.width }) + await a.wait(ms) + + // 3. The gap closes and the next message moves up + a.mark('close') + a.put({ gone: true }) + await a.wait(1800) + a.mark('done') +} + +export default function SwipeDemo() { + return ( + + width={400} + height={480} + compact={{ below: 400, width: 358, height: 480 }} + initial={initial} + poster={{ dx: 0, throw: null, gone: true }} + script={script} + play="once" + cursor={{ at: { x: 330, y: 470 }, style: 'touch' }} + className="sw" + > + {(s, rect, layout) => { + // The row moves in screen points; the phone is drawn scaled + const box = rect('row') + const k = box.width ? box.width / 402 : 1 + const shift = s.dx / k + const open = Math.max(0, -shift) + return ( + +
+
+
+ + +
+
Inbox
+
+ + + Primary + + + + +
+
+
+ {MAIL.map((m, i) => { + const picked = i === PICK + const item = ( +
+ {/* Only while the row is off its rest: at rest its antialiased edges would show a purple hairline */} + {picked && (s.dx !== 0 || s.throw) &&
} + : } + title={picked || i === PICK + 1 ? m.from : } + meta={ + picked || i === PICK + 1 ? ( + + {m.time} + + + ) : ( + + ) + } + subtitle={picked || i === PICK + 1 ? {m.subject} : } + preview={ + picked || i === PICK + 1 + ? m.preview + : FILL[i]!.preview.map((w, j) => ( + + + + )) + } + > + {picked && ( + + + Archive + + )} + +
+ ) + return picked ? ( + + {item} + + ) : ( +
{item}
+ ) + })} +
+
+ + ) + }} + + ) +} + +// Placeholder rows: bars at a real message's line heights, so every row is +// the height a real one would be. Only the swiped message has words. +const FILL = [ + null, + null, + { from: 104, subject: 148, preview: [250, 170] }, + { from: 92, subject: 136, preview: [256, 180] }, + { from: 84, subject: 112, preview: [230, 140] }, +] diff --git a/skills/imported/benjitaylor/stageshow/examples/swipe/swipe.css b/skills/imported/benjitaylor/stageshow/examples/swipe/swipe.css new file mode 100644 index 0000000..531a074 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/swipe/swipe.css @@ -0,0 +1,169 @@ +/* A mail app on iOS, light. The phone is centered and runs off the bottom; + everything inside is in screen points. */ + +.sw .ss-stage { + --sw-ink: #111; + --sw-ink-2: #8a8a8e; + --sw-line: #e3e3e6; + --sw-archive: #5856d6; + --sw-blue: #0a84ff; + overflow: hidden; + background: #efeef0; +} + +.sw-phone { + position: absolute; + top: 40px; + left: 50%; + translate: -50% 0; +} + +.sw-app { + color: var(--sw-ink); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; +} + +.sw-head { + padding: 2px 20px 10px; +} + +/* Back to Mailboxes on the left, more on the right, as Mail's bar has */ +.sw-bar-top { + display: flex; + justify-content: space-between; + height: 36px; +} + +/* The round glass button iOS puts in the top bar */ +.sw-round { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: #f0f0f2; + color: var(--sw-ink); +} + +.sw-title { + margin: 4px 0 0; + font-size: 34px; + font-weight: 700; + line-height: 41px; + letter-spacing: 0.01em; +} + +/* Categories: the chosen one is a filled pill with its name, the rest icons */ +.sw-cats { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.sw-cat { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 52px; + height: 34px; + padding: 0 14px; + border-radius: 17px; + background: #f0f0f2; + color: #3c3c43; + font-size: 15px; + font-weight: 600; +} + +.sw-cat[data-on] { + background: var(--sw-blue); + color: #fff; +} + +/* ---- Rows ---- */ + +.sw-cell { + position: relative; + overflow: hidden; +} + +/* The archive color sits under the row; its mark rides on the row's + trailing edge */ +.sw-action { + position: absolute; + inset: 0; + background: var(--sw-archive); +} + +.sw-action__mark { + position: absolute; + top: 0; + bottom: 0; + left: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + width: 74px; + color: #fff; + font-size: 13px; + font-weight: 500; +} + +/* The kit Row at Mail's geometry: square, white, 12pt between avatar and text */ +.sw-row { + column-gap: 12px; + border-radius: 0; + /* Mail reserves a dot column on every row: the list sits 12pt in from + the title, read or not, so nothing moves when a message is read */ + padding: 12px 20px 12px 32px; + background: #fff; + will-change: transform; +} + +/* A hairline under each row, from the text's edge, as Mail draws it */ +.sw-row::after { + position: absolute; + right: 0; + bottom: 0; + left: 84px; + height: 0.5px; + background: var(--sw-line); + content: ''; +} + +.sw-time { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + color: var(--sw-ink-2); + font-size: 15px; + line-height: 20px; +} + +.sw-subject { + color: var(--ss-text-strong); +} + +.sw-row .ss-row__preview[data-text] { + color: var(--sw-ink-2); +} + +.sw-time svg { + color: #c4c4c7; +} + +/* Mail centers the photo on the sender and subject, not lower with the + preview the way the kit's default does */ +.sw .ss-row.sw-row:has(.ss-row__preview) { + --ss-row-lead-h: calc(var(--ss-row-line) + var(--ss-row-line-2) + var(--ss-row-gap)); +} + +/* Placeholder rows at the real ones' height: the gold's bars are 11pt for + the sender and 9pt for the lines, centered in their line boxes */ +.sw-row .ss-row__title > .ss-bar { margin: 5.5px 0; } +.sw-row .ss-row__subtitle > .ss-bar { display: block; margin: 5.5px 0; } +.sw-fill { display: flex; align-items: center; height: 20px; } +.sw-row .ss-row__meta > .ss-bar { display: block; margin: 6.5px 0; } diff --git a/skills/imported/benjitaylor/stageshow/examples/table-edit/index.tsx b/skills/imported/benjitaylor/stageshow/examples/table-edit/index.tsx new file mode 100644 index 0000000..8d8f12a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/table-edit/index.tsx @@ -0,0 +1,114 @@ +import { Badge, Roll, Stage, Window, type Act } from '../../kit' +import './table.css' + +// Editing a cell in place: an amount in an invoices table is corrected and +// the total follows. Beats: the pointer double-clicks one amount, the cell +// becomes a field with its value selected, the new amount is typed over it, +// Enter commits, the cell washes once and the total rolls to match. Plays +// once; Replay is the reset. +// +// What makes an in-place edit read as the same cell: +// - The value doesn't move when the cell becomes a field. The field's ring +// is drawn inside the cell and the text keeps its exact position, right +// aligned, so only the ring and the caret arrive. +// - Select-all is shown (the value highlighted) before the first character +// replaces it, so the typing reads as an overwrite, not an append. +// - The total changes after the commit, a beat later, not with each key: +// the cause lands, then its effect (Roll's delay). +// - One wash on the edited cell, then gone. No toast: the table is the +// confirmation. +// +// Real strings: every row. A table is the one place dense real data reads +// right; it is short (five rows) so none of it is noise. + +type S = { edit: boolean; sel: boolean; val: string; amount: number; wash: boolean } + +const ROWS = [ + { who: 'Halvorsen & Co', amount: 2400, status: 'Paid', due: 'Oct 2' }, + { who: 'Lumen Studio', amount: 1200, status: 'Open', due: 'Oct 30' }, + { who: 'Tidepool', amount: 860, status: 'Open', due: 'Nov 4' }, + { who: 'Northwind Air', amount: 3150, status: 'Overdue', due: 'Oct 12' }, + { who: 'Kite Labs', amount: 540, status: 'Paid', due: 'Oct 9' }, +] +const EDIT = 1 +const money = (n: number) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2 })}` +const TONES = { Paid: 'success', Open: 'neutral', Overdue: 'danger' } as const + +const initial: S = { edit: false, sel: false, val: '', amount: 1200, wash: false } + +async function script(a: Act) { + await a.wait(900) + a.mark('open') + await a.move({ at: 'cell', x: 0.7, y: 0.5 }) + await a.wait(200) + await a.click({ at: 'cell', x: 0.7, y: 0.5 }, { double: true }) + a.put({ edit: true, sel: true, val: '1,200.00' }) + // Off the digits while typing, as a hand does once it goes to the keys + await a.move({ at: 'cell', x: -0.5, y: 1.5 }, { duration: 450 }) + await a.wait(200) + a.mark('type') + a.put({ sel: false, val: '' }) + await a.type('val', '1,450.00', { interval: 70 }) + await a.wait(400) + // Enter commits. No keycap: it would sit over the total, the payoff + a.mark('commit') + a.put({ edit: false, amount: 1450, wash: true }) + await a.wait(900) + a.put({ wash: false }) + await a.wait(1800) +} + +export default function TableEdit() { + return ( + width={660} height={400} compact={{ below: 660, width: 358, height: 400 }} initial={initial} poster={{ ...initial, amount: 1450 }} script={script} play="once" cursor={{ at: { x: 600, y: 350 } }} className="te"> + {(s) => { + const total = ROWS.reduce((n, r, i) => n + (i === EDIT ? s.amount : r.amount), 0) + return ( + +
+
+ Customer + Amount + Status + Due +
+ {ROWS.map((r, i) => ( +
+ {r.who} + {i === EDIT ? ( + + + + {s.edit ? ( + <> + $ + {s.val} + + + ) : ( + money(s.amount) + )} + + + ) : ( + {money(r.amount)} + )} + + {r.status} + + {r.due} +
+ ))} +
+ Total + + {money(total)} + +
+
+
+ ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/table-edit/table.css b/skills/imported/benjitaylor/stageshow/examples/table-edit/table.css new file mode 100644 index 0000000..7d5d642 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/table-edit/table.css @@ -0,0 +1,138 @@ +.te .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* One window, inset at the top, off the bottom just under the total */ +.te-win { + position: absolute; + top: 40px; + left: 50%; + width: 580px; + translate: -50% 0; +} + +.te .ss-stage[data-layout='compact'] .te-win { + top: 16px; + width: 326px; +} + +.te-body { + min-height: 400px; + padding: 10px 24px 0; + background: var(--ss-surface); +} + +.te-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 112px 92px 64px; + align-items: center; + gap: 16px; + height: 44px; + border-bottom: 1px solid var(--ss-line-faint); + color: var(--ss-text-strong); + font-size: 13px; + white-space: nowrap; +} + +.te .ss-stage[data-layout='compact'] .te-row { + grid-template-columns: minmax(0, 1fr) 96px 72px; + gap: 10px; +} + +.te .ss-stage[data-layout='compact'] .te-due { + display: none; +} + +.te .ss-stage[data-layout='compact'] .te-body { + padding: 10px 14px 0; +} + +.te-row--head { + height: 36px; + color: var(--ss-text); + font-size: 12px; + font-weight: 500; +} + +.te-row--foot { + border-bottom: 0; + font-weight: 600; +} + +.te-who { + overflow: hidden; + text-overflow: ellipsis; +} + +.te-num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.te-due { + color: var(--ss-text); +} + +/* The edited cell: the ring is drawn inside it, so the value never moves */ +.te-cell { + position: relative; + display: flex; + justify-content: flex-end; + align-items: center; + height: 32px; +} + +.te-ring { + position: absolute; + inset: 0 -8px; + border-radius: var(--ss-radius-sm); + box-shadow: inset 0 0 0 1.5px var(--ss-accent), 0 0 0 3px var(--ss-accent-soft); + background: var(--ss-surface); + opacity: 0; + transition: opacity 0.15s ease; +} + +.te-cell[data-edit] .te-ring { + opacity: 1; +} + +/* One wash on commit, then gone */ +.te-cell::before { + content: ''; + position: absolute; + inset: -6px -8px; + border-radius: var(--ss-radius-sm); + background: var(--ss-accent-soft); + opacity: 0; + transition: opacity 0.9s ease; +} + +.te-cell[data-wash]::before { + opacity: 1; + transition-duration: 0.1s; +} + +.te-text { + position: relative; + display: inline-flex; + align-items: center; +} + +.te-sel[data-on] { + border-radius: 2px; + background: color-mix(in srgb, var(--ss-accent) 24%, transparent); +} + +.te-caret { + width: 1.5px; + height: 16px; + margin-left: 1px; + margin-right: -2.5px; + background: var(--ss-accent); + animation: ss-caret 1.06s steps(1) 0.5s infinite; +} + +.te-caret[data-hide] { + visibility: hidden; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/telegram/index.tsx b/skills/imported/benjitaylor/stageshow/examples/telegram/index.tsx new file mode 100644 index 0000000..9236304 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/telegram/index.tsx @@ -0,0 +1,227 @@ +import { Avatar, Stage, Phone, Thread, Bubble, Stamp, type Act } from '../../kit' +import './telegram.css' + +// Telegram on an iPhone, the current iOS app: floating glass header (back, +// a pill with the name and status, the avatar), the default green wallpaper +// with its doodle pattern, white and pale green bubbles with the time and +// ticks inside them, and the composer (attach, field, mic or send). +// +// The phone is cropped by the stage's top: the composer and the newest +// messages are the story, so they stay in view and the header runs off. +// +// Four beats: +// 1. Context: Nika asks about a sunrise hike, with a photo of the destination. +// 2. The reply is typed and sent; it forms out of the field, a clock in its +// corner while it sends. +// 3. The clock becomes one tick (sent), then a second tick draws in (read). +// 4. Nika answers with a big animated emoji, the way Telegram shows a lone +// emoji: no bubble, just the emoji. +// Hold the completed conversation. Replay is the explicit reset; sent +// messages do not disappear from the live thread on their own. +// +// Real strings: the date pills, the messages, the typed reply, the times. + +type Tick = 'clock' | 'sent' | 'read' +type S = { draft: string; sent: boolean; tick: Tick; reply: boolean } + +const initial: S = { draft: '', sent: false, tick: 'clock', reply: false } +const poster: S = { ...initial, sent: true, tick: 'read', reply: true } + +const MINE = "I'm in, I'll bring coffee" +// Where the touch rests between taps, in stage pixels (below the composer) +const REST = { x: 300, y: 470 } + +async function script(a: Act) { + await a.wait(900) + a.mark('type') + await a.type('draft', MINE, { interval: 70 }) + await a.wait(250) + a.mark('send') + await a.click({ at: 'send', x: 0.5, y: 0.5 }) + await a.set({ sent: true, draft: '' }) + await a.wait(650) + await a.set({ tick: 'sent' }) + await a.wait(1000) + a.mark('read') + await a.set({ tick: 'read' }) + await a.wait(1500) + a.mark('reply') + await a.set({ reply: true }) + await a.wait(3000) + a.mark('done') +} + +/** Telegram's ticks: a clock while sending, one tick sent, two read. */ +function Ticks({ state }: { state: Tick }) { + return ( + + + + + + + + + ) +} + +function Meta({ time, tick }: { time: string; tick?: Tick }) { + return ( + + {time} + {tick && } + + ) +} + +/** Local CC0 photograph shared with the Wikipedia study. */ +function Trail() { + return Mount Fuji above Lake Kawaguchi +} + +function Back() { + return ( + + + + ) +} + +function Clip() { + return ( + + + + ) +} + +function Mic() { + return ( + + + + + ) +} + +function Sticker() { + return ( + + + + + + ) +} + +function Send() { + return ( + + + + ) +} + +export default function Telegram() { + return ( + width={400} height={520} initial={initial} poster={poster} script={script} play="once" cursor={{ at: REST, style: 'touch' }} className="tg"> + {(s) => { + return ( + +
+
+ + + + + Nika + online + + +
+ + + Yesterday + + Did the new boots come? + + + + Yes! Breaking them in + + + Today + + Free this weekend? + + + + Should be, why? + + + + + 9:38 + + + Sunrise hike on Saturday? + + + {s.sent && ( + + {MINE} + + + )} + {s.reply && ( + + 🙌 + 9:42 + + )} + + +
+ + + + + + {s.draft} + {s.draft && } + + + Message + + + + + + + + + + + + + +
+ + ) + }} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/telegram/telegram.css b/skills/imported/benjitaylor/stageshow/examples/telegram/telegram.css new file mode 100644 index 0000000..9e1be27 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/telegram/telegram.css @@ -0,0 +1,422 @@ +/* Telegram for iOS (the current glass design), default Day theme. Everything + is nested under .tg so it can share a page with other demos. Inside the + phone, 1px is 1pt. */ +.tg { + --tg-blue: #3e88f7; + --tg-out: #e7fdd5; + --tg-out-meta: #4fa84a; + --tg-in-meta: #a2acb4; + --tg-ink: #000; + /* UIKit-quick: off the tap fast, long soft settle */ + --tg-ui: cubic-bezier(0.3, 0.75, 0.1, 1); + + .ss-stage { + overflow: hidden; + background: #eef0ec; + } + + /* Runs off the top: the composer and the newest messages are the story, + the header is filler. 340 wide is 715 tall; 30 from the sides and the + bottom, so 225 runs past the top */ + .tg-phone { + position: absolute; + top: -225px; + left: 30px; + } + + .ss-phone__content { + display: flex; + flex-direction: column; + } + + /* ---- Wallpaper: the default green gradient with a doodle pattern. It + runs under the status bar: the content box starts at the screen's top, + its 62pt padding is the status bar ---- */ + .ss-phone__screen { + background: + radial-gradient(60% 45% at 12% 8%, #e4e2b8 0%, transparent 70%), + radial-gradient(70% 50% at 92% 30%, #a7c98f 0%, transparent 70%), + radial-gradient(70% 50% at 10% 70%, #8fbf8e 0%, transparent 70%), + radial-gradient(70% 45% at 90% 96%, #d9dc97 0%, transparent 70%), + #b9cf9c; + } + + .tg-paper { + position: absolute; + inset: 0; + background: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240" fill="none" stroke="%2324452f" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpath transform="translate(22 24) rotate(-20) scale(.8)" d="M-15 6 15-6 7 22 1 12Z M1 12 15-6"/%3E%3Cpath transform="translate(78 16) rotate(10) scale(.8)" d="M-9-2c0-5 7-7 9-2 2-5 9-3 9 2 0 6-9 11-9 11s-9-5-9-11Z"/%3E%3Cpath transform="translate(134 30) rotate(0) scale(.8)" d="M0-11l3.5 7 7.5 1-5.5 5 1.5 7.5-7-3.5-7 3.5 1.5-7.5-5.5-5 7.5-1Z"/%3E%3Cpath transform="translate(196 18) rotate(-12) scale(.8)" d="M4 9V-7l10-3V6 M4 9a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z M14 6a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z"/%3E%3Cpath transform="translate(228 62) rotate(25) scale(.8)" d="M-21 0c4-6 10-6 14 0s10 6 14 0 10-6 14 0"/%3E%3Cpath transform="translate(40 72) rotate(0) scale(.8)" d="M-11 7h22a7 7 0 0 0 0-14 9 9 0 0 0-17-2 6 6 0 0 0-5 16Z"/%3E%3Cpath transform="translate(104 66) rotate(-15) scale(.8)" d="M-9 0a9 9 0 1 0 18 0 9 9 0 1 0-18 0Z M-2.5 0a2.5 2.5 0 1 0 5 0 2.5 2.5 0 1 0-5 0Z"/%3E%3Cpath transform="translate(168 76) rotate(30) scale(.8)" d="M0-8l8 14h-16Z"/%3E%3Cpath transform="translate(210 118) rotate(0) scale(.8)" d="M-3-10c6 0 10 4 10 10s-4 10-10 10c4-3 5-6 5-10s-1-7-5-10Z"/%3E%3Cpath transform="translate(16 128) rotate(40) scale(.8)" d="M-9-5h18v14h-18Z M-5-5v-4h10v4"/%3E%3Cpath transform="translate(66 120) rotate(-8) scale(.8)" d="M-8 0l6 6 12-14"/%3E%3Cpath transform="translate(124 118) rotate(12) scale(.8)" d="M-11 5c0-6 5-10 11-10s11 4 11 10 M-5 5v-4 M5 5v-4"/%3E%3Cpath transform="translate(178 150) rotate(-25) scale(.8)" d="M-8 6c3-3 3-7 0-10m8 13c6-6 6-14 0-20m8 26c9-9 9-23 0-32"/%3E%3Cpath transform="translate(36 180) rotate(10) scale(.8)" d="M-12 0h24M0-12v24"/%3E%3Cpath transform="translate(92 170) rotate(0) scale(.8)" d="M-6-10h12l-2 20h-8Z M6-6h3a3 3 0 0 1 0 6h-3"/%3E%3Cpath transform="translate(146 196) rotate(-10) scale(.8)" d="M-15 6 15-6 7 22 1 12Z M1 12 15-6"/%3E%3Cpath transform="translate(206 206) rotate(20) scale(.8)" d="M-9-2c0-5 7-7 9-2 2-5 9-3 9 2 0 6-9 11-9 11s-9-5-9-11Z"/%3E%3Cpath transform="translate(62 222) rotate(-30) scale(.8)" d="M0-11l3.5 7 7.5 1-5.5 5 1.5 7.5-7-3.5-7 3.5 1.5-7.5-5.5-5 7.5-1Z"/%3E%3Cpath transform="translate(118 234) rotate(0) scale(.8)" d="M4 9V-7l10-3V6 M4 9a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z M14 6a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z"/%3E%3Cpath transform="translate(232 170) rotate(0) scale(.8)" d="M-21 0c4-6 10-6 14 0s10 6 14 0 10-6 14 0"/%3E%3Cpath transform="translate(78 256) rotate(10) scale(.8)" d="M-9-2c0-5 7-7 9-2 2-5 9-3 9 2 0 6-9 11-9 11s-9-5-9-11Z"/%3E%3Cpath transform="translate(196 258) rotate(-12) scale(.8)" d="M4 9V-7l10-3V6 M4 9a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z M14 6a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z"/%3E%3Cpath transform="translate(-12 62) rotate(25) scale(.8)" d="M-21 0c4-6 10-6 14 0s10 6 14 0 10-6 14 0"/%3E%3Cpath transform="translate(256 128) rotate(40) scale(.8)" d="M-9-5h18v14h-18Z M-5-5v-4h10v4"/%3E%3Cpath transform="translate(62 -18) rotate(-30) scale(.8)" d="M0-11l3.5 7 7.5 1-5.5 5 1.5 7.5-7-3.5-7 3.5 1.5-7.5-5.5-5 7.5-1Z"/%3E%3Cpath transform="translate(118 -6) rotate(0) scale(.8)" d="M4 9V-7l10-3V6 M4 9a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z M14 6a3.5 3 0 1 1-7 0 3.5 3 0 1 1 7 0Z"/%3E%3Cpath transform="translate(-8 170) rotate(0) scale(.8)" d="M-21 0c4-6 10-6 14 0s10 6 14 0 10-6 14 0"/%3E%3C/svg%3E') 0 0 / 240px 240px; + /* One opaque ink, faded as a whole layer, so joins never darken */ + opacity: 0.13; + pointer-events: none; + } + + /* ---- Header: floating glass, back, the name pill, the avatar ---- */ + .tg-glass { + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(14px) saturate(1.6); + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.06), + 0 4px 14px -4px rgba(30, 50, 30, 0.18); + } + + .tg-head { + position: absolute; + top: 66px; + right: 16px; + left: 16px; + z-index: 3; + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + } + + .tg-back { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 50%; + color: #1c1c1e; + } + + .tg-title { + position: absolute; + left: 50%; + display: grid; + justify-items: center; + gap: 1px; + min-width: 128px; + padding: 6px 22px 7px; + border-radius: 24px; + transform: translateX(-50%); + } + + .tg-name { + color: var(--tg-ink); + font-size: 17px; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 20px; + } + + .tg-status { + color: var(--tg-blue); + font-size: 13px; + line-height: 16px; + } + + /* Telegram's peach gradient on the kit's Avatar */ + .tg-avatar { + font-weight: 600; + } + + /* ---- The thread: Telegram's bubbles, restyled from the kit's ---- */ + .tg-thread { + --ss-me: var(--tg-out); + --ss-me-text: var(--tg-ink); + --ss-them: #fff; + --ss-them-text: var(--tg-ink); + /* Above the composer, so a sent message's words lift out over the field */ + position: relative; + z-index: 2; + flex: 1; + gap: 4px; + padding: 64px 10px 10px; + } + + .ss-bubble { + max-width: 82%; + padding: 6px 10px 6px 12px; + border-radius: 18px; + line-height: 22px; + letter-spacing: -0.02em; + } + + /* Tails are the kit's, drawn a little tighter, as Telegram's corner is */ + .ss-bubble__tail { + width: 15px; + height: 14px; + } + + [data-from='me'] > .ss-bubble > .ss-bubble__tail { + right: -5px; + } + + [data-from='them'] > .ss-bubble > .ss-bubble__tail { + left: -5px; + } + + .ss-bubble-row[data-from='me'] + .ss-bubble-row[data-from='them'], + .ss-bubble-row[data-from='them'] + .ss-bubble-row[data-from='me'] { + margin-top: 6px; + } + + /* Time and ticks sit inside the bubble, low on the last line */ + .tg-meta { + position: relative; + top: 5px; + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 10px; + color: var(--tg-in-meta); + font-size: 12px; + letter-spacing: 0; + line-height: 1; + white-space: nowrap; + } + + [data-from='me'] .tg-meta { + color: var(--tg-out-meta); + } + + .tg-ticks { + flex: none; + margin-right: -2px; + fill: none; + stroke: currentColor; + stroke-width: 1.35; + stroke-linecap: round; + stroke-linejoin: round; + overflow: visible; + } + + .tg-ticks__clock { + transform-origin: 6px 5.5px; + transition: opacity 0.2s ease, transform 0.3s var(--tg-ui); + } + + .tg-ticks__one, + .tg-ticks__two { + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 0.3s cubic-bezier(0.25, 1, 0.5, 1); + } + + .tg-ticks:not([data-state='clock']) .tg-ticks__clock { + opacity: 0; + transform: scale(0.6); + } + + .tg-ticks:not([data-state='clock']) .tg-ticks__one, + .tg-ticks[data-state='read'] .tg-ticks__two { + stroke-dashoffset: 0; + } + + /* A photo fills its bubble; the time rides on it in a dark pill */ + .tg-photo > .ss-bubble { + padding: 2px; + --b: #fff; + } + + .tg-photo .ss-bubble__text { + position: relative; + } + + .tg-photo__img { + display: block; + width: 252px; + height: 168px; + object-fit: cover; + object-position: center 40%; + border-radius: 16px; + } + + .tg-photo__time, + .tg-big__time { + position: absolute; + right: 8px; + bottom: 8px; + padding: 2px 7px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.32); + color: #fff; + font-size: 12px; + line-height: 16px; + letter-spacing: 0; + } + + /* A lone emoji is the message: big, no bubble, its time in a pill */ + .tg-big > .ss-bubble { + --b: transparent; + margin-left: 6px; + padding: 0 0 4px; + } + + .tg-big .ss-bubble__tail { + display: none; + } + + .tg-big .ss-bubble__text { + position: relative; + width: 112px; + } + + .tg-emoji { + display: block; + font-size: 84px; + line-height: 100px; + text-align: center; + will-change: transform; + transform-origin: 50% 80%; + animation: tg-emoji 1.6s cubic-bezier(0.25, 0.8, 0.25, 1) both; + } + + .tg-big__time { + right: -46px; + bottom: 2px; + background: rgba(40, 70, 40, 0.3); + } + + /* The date: a small tinted pill in the middle of the wallpaper */ + .tg-date { + align-self: center; + margin: 8px 0; + padding: 3px 10px; + border-radius: 12px; + background: rgba(40, 70, 40, 0.28); + color: #fff; + font-size: 13px; + font-weight: 600; + line-height: 16px; + } + + /* ---- The composer: attach, the field, mic or send ---- */ + .tg-compose { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + margin: 0 12px 40px; + } + + .tg-round { + display: grid; + flex: none; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + color: #6d7279; + } + + .tg-field { + position: relative; + display: flex; + flex: 1; + align-items: center; + min-width: 0; + height: 44px; + padding: 0 10px 0 16px; + border-radius: 22px; + font-size: 17px; + letter-spacing: -0.02em; + } + + .tg-field__text { + flex: 1; + min-width: 0; + color: var(--tg-ink); + white-space: nowrap; + } + + .tg-caret { + display: inline-block; + width: 2px; + height: 21px; + margin-left: 1px; + border-radius: 1px; + background: var(--tg-blue); + vertical-align: -4px; + } + + .tg-field__ph { + position: absolute; + left: 16px; + color: #a2a6ab; + pointer-events: none; + /* Comes back once the sent words have lifted clear of the field */ + transition: opacity 0.2s ease 0.3s; + } + + .tg-field__ph[data-hide] { + opacity: 0; + transition: opacity 0.12s ease; + } + + .tg-field__icon { + display: grid; + flex: none; + place-items: center; + color: #8a8f95; + } + + /* Mic and send share one place: the mic shrinks away as send grows in */ + .tg-action { + position: relative; + display: grid; + flex: none; + width: 44px; + height: 44px; + + > * { + grid-area: 1 / 1; + } + } + + .tg-mic { + transition: opacity 0.18s ease, transform 0.3s var(--tg-ui); + } + + .tg-mic[data-off] { + opacity: 0; + transform: scale(0.5); + } + + .tg-send { + display: grid; + place-items: center; + border-radius: 50%; + background: var(--tg-blue); + color: #fff; + opacity: 0; + transform: scale(0.5); + transition: opacity 0.18s ease, transform 0.3s var(--tg-ui); + } + + .tg-send[data-on] { + opacity: 1; + transform: none; + } +} + +/* A lone emoji arrives as Telegram's animated ones do: it grows in, then + waves twice and settles */ +@keyframes tg-emoji { + 0% { + transform: scale(0.55); + } + 30% { + transform: scale(1); + } + 45% { + transform: rotate(-9deg); + } + 60% { + transform: rotate(7deg); + } + 75% { + transform: rotate(-4deg); + } + 100% { + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .tg .tg-emoji { + animation: none; + } +} diff --git a/skills/imported/benjitaylor/stageshow/examples/tilted-stack/index.tsx b/skills/imported/benjitaylor/stageshow/examples/tilted-stack/index.tsx new file mode 100644 index 0000000..8d06974 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/tilted-stack/index.tsx @@ -0,0 +1,49 @@ +import { Bar, Stage, Text } from '../../kit' +import './tilted.css' + +// A loose tilted stack: three receipts dropped on a desk, the newest on top. +// A still. Tilt is the exception in faux UI, not a default: it earns its +// place here because the story is a pile of paper (things gathered, not a +// screen), and a straight stack would read as a list. +// +// What keeps a tilted stack from looking accidental: +// - Small angles, alternating: about -6, 4 and -2 degrees. Past 8 the type +// on the top card stops reading as level. +// - The top card is the straightest and the only one with words; the cards +// under it show a third or less, and that part is filler. +// - Each card turns about its own center and is offset a little (12 to 20px), +// so the corners fan out unevenly, the way paper lands. +// - The stack sits on the stage's center line with room on every side; the +// stage cuts nothing. +// +// Real strings: the top receipt's merchant, items and total. + +export default function TiltedStack() { + return ( + + {() => ( +
+
+ + + +
+
+ + + +
+
+ Lumen Coffee + Oct 28, 9:14 AM +
+
Flat white$4.50
+
Almond croissant$3.75
+
+
Total$8.25
+
+
+ )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/tilted-stack/tilted.css b/skills/imported/benjitaylor/stageshow/examples/tilted-stack/tilted.css new file mode 100644 index 0000000..f318a63 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/tilted-stack/tilted.css @@ -0,0 +1,63 @@ +.ts .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +/* The pile: centered, every card the same size, stacked in one cell */ +.ts-pile { + position: absolute; + top: 50%; + left: 50%; + display: grid; + translate: -50% -50%; +} + +.ts-card { + grid-area: 1 / 1; + display: grid; + align-content: start; + gap: 10px; + width: 220px; + height: 196px; + padding: 20px; + border-radius: 10px; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-card); +} + +/* Small, alternating angles, each about its own center, offset unevenly */ +.ts-card--back { translate: -20px 10px; rotate: -6deg; } +.ts-card--mid { translate: 18px -6px; rotate: 4deg; } +.ts-card--top { translate: 0 4px; rotate: -2deg; box-shadow: var(--ss-shadow-satellite); } + +.ts-merchant { + color: var(--ss-text-strong); + font-size: 15px; + font-weight: 600; +} + +.ts-date { + margin-top: -6px; + color: var(--ss-text); + font-size: 11px; +} + +.ts-items { + display: grid; + gap: 8px; + margin-top: 8px; + padding: 12px 0; + border-block: 1px dashed var(--ss-line); +} + +.ts-item, +.ts-total { + display: flex; + justify-content: space-between; + color: var(--ss-text-strong); + font-size: 12px; +} + +.ts-total { + font-weight: 600; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/upload/index.tsx b/skills/imported/benjitaylor/stageshow/examples/upload/index.tsx new file mode 100644 index 0000000..a73d898 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/upload/index.tsx @@ -0,0 +1,257 @@ +import { useLayoutEffect, useRef, type ReactNode } from 'react' +import { ChevronLeft, ChevronRight, File, Folder, LayoutGrid, List, Search, Share } from 'lucide-react' +import { Bar, Collapse, Count, Stage, States, Window, type Act, type Layout, type Point, Meter } from '../../kit' +import './upload.css' + +// A file storage app, list view. A PDF sits on the desktop beside the +// Projects window. The user drags it in: the drag image rides with the arrow, +// the list lights up under it, and on the drop an upload row opens at the top +// (newest first). Its bar fills while the size counts up, then the row settles +// into an ordinary file row: Just now, 4.2 MB. Cmd+Z takes the upload back, +// which is exactly where the loop began. +// +// The upload is the story, so it gets the room: no sidebar, rows at reading +// size, and a list short enough that the new row is a tenth of the window, not +// a sliver at the top of a long one. Everything else is placeholder bars. The +// desktop strip holds the file at the start and the keycaps at the end. + +type Ghost = { on: boolean; dropped: boolean; off: Point; at: Point } +type S = { up: boolean; mb: number; done: boolean; ghost: Ghost } + +const initial: S = { up: false, mb: 0, done: false, ghost: { on: false, dropped: false, off: { x: 0, y: 0 }, at: { x: 0, y: 0 } } } + +const FILE = 'Launch deck.pdf' +const MB = 4.2 + +type Geo = { + win: { x: number; y: number; w: number; h: number } + rows: number + cols: string + desk: { x: number; y: number } +} + +// Wide: one gutter (40) above the window and left of the file; the window +// runs off the right and bottom, its Size column still whole in the crop. +// Compact: the file sits above the window, which runs off the bottom only +// with equal side gutters, so all three columns stay whole. +const GEO: Record = { + wide: { win: { x: 184, y: 40, w: 620, h: 420 }, rows: 9, cols: 'minmax(0, 1fr) 132px 118px', desk: { x: 40, y: 124 } }, + compact: { win: { x: 24, y: 120, w: 352, h: 300 }, rows: 6, cols: 'minmax(0, 1fr) 92px 76px', desk: { x: 20, y: 18 } }, +} + +type Kind = 'folder' | 'doc' +const ROWS: { kind: Kind; name: number; date: number; size: number }[] = [ + { kind: 'folder', name: 96, date: 84, size: 16 }, + { kind: 'doc', name: 132, date: 76, size: 40 }, + { kind: 'doc', name: 104, date: 88, size: 34 }, + { kind: 'folder', name: 80, date: 80, size: 16 }, + { kind: 'doc', name: 120, date: 72, size: 44 }, + { kind: 'doc', name: 92, date: 86, size: 36 }, + { kind: 'folder', name: 112, date: 78, size: 16 }, + { kind: 'doc', name: 100, date: 74, size: 38 }, + { kind: 'doc', name: 126, date: 82, size: 42 }, +] + +async function script(a: Act) { + await a.wait(600) + + // 1. Pick the PDF up off the desktop by its icon + a.mark('grab') + const icon = a.rect('desk-icon') + const file = a.rect('desk-file') + const grab = { x: icon.x + icon.width / 2, y: icon.y + icon.height * 0.55 } + await a.move(grab) + await a.wait(240) + const off = { x: grab.x - file.x, y: grab.y - file.y } + + // 2. Carry it into the window; the list lights up under it + a.mark('drag') + const list = a.rect('drop') + await a.drag({ x: list.x + list.width * 0.36, y: list.y + list.height * 0.32 }, (at) => { + a.put({ ghost: { on: true, dropped: false, off, at } }) + }, { duration: 1300, cursor: 'preserve' }) + + // 3. Drop: the upload row opens at the top, its bar fills and the size + // counts up with it + a.mark('drop') + await a.set((s) => ({ up: true, ghost: { ...s.ghost, dropped: true } })) + await a.wait(500) + for (const [mb, ms] of [[1.1, 650], [2.3, 650], [3.4, 650], [MB, 600]] as const) { + await a.set({ mb }) + await a.wait(ms) + } + + // 4. Uploaded: the bar gives way to Just now and the tint fades + a.mark('done') + await a.set({ done: true }) + await a.wait(2400) + + // 5. Cmd+Z takes the upload back; the row closes: back to the start + a.mark('undo') + await a.key('cmd+z') + await a.set({ up: false, ghost: initial.ghost }) + await a.wait(600) + await a.snap({ mb: 0, done: false }) + await a.wait(300) +} + +export default function UploadDemo() { + return ( + + width={760} + height={420} + compact={{ below: 560, width: 400, height: 340 }} + initial={initial} + script={script} + accent="#0a6de6" + keys={{ wide: 'bottom-left', compact: 'top' }} + cursor={{ at: { at: 'drop', x: 0.36, y: 0.32 } }} + className="up" + > + {(s, _rect, layout) => { + const g = GEO[layout] + const lifted = s.ghost.on && !s.ghost.dropped + return ( + <> +
+ + + + {FILE} +
+ + + + + + + Projects + + } + toolbar={ + + + + + + + + + + + + + } + body={{ className: 'up-body', 'data-crop': true }} + > +
+
+ Name + {layout === 'wide' ? 'Date Modified' : 'Modified'} + Size +
+
+ + + + {ROWS.slice(0, g.rows).map((r, i) => ( + + ))} +
+
+
+ + + + {FILE} + + + ) + }} + + ) +} + +function UploadRow({ mb, done }: { mb: number; done: boolean }) { + return ( +
+ + + {FILE} + + + ), + done: Just now, + }} + /> + + n.toFixed(1)} /> + {' MB'} + +
+ ) +} + +function Row({ r, k }: { r: (typeof ROWS)[number]; k: number }) { + return ( +
+ + {r.kind === 'folder' ? : } + + + + {r.kind === 'folder' ? : } +
+ ) +} + +/** A page with a folded corner and a red PDF band: the file being uploaded. */ +function PdfIcon({ small }: { small?: boolean }) { + return ( + + {small ? null : 'PDF'} + + ) +} + +/** The drag image follows the cursor on the drag's own frame callback, and + * lights whatever [data-drop] it is over. */ +function DragImage({ ghost, children }: { ghost: Ghost; children: ReactNode }) { + const ref = useRef(null) + const over = useRef(null) + const following = ghost.on && !ghost.dropped + const { x: ox, y: oy } = ghost.off + const { x, y } = ghost.at + useLayoutEffect(() => { + const root = ref.current?.closest('.ss-stage') + let next: HTMLElement | null = null + if (following && root) { + const box = root.getBoundingClientRect() + const scale = box.width / root.offsetWidth || 1 + const hit = document.elementFromPoint(box.left + x * scale, box.top + y * scale)?.closest('[data-drop]') ?? null + if (hit && root.contains(hit)) next = hit + } + if (next !== over.current) { + over.current?.removeAttribute('data-over') + next?.setAttribute('data-over', '') + over.current = next + } + }, [following, x, y]) + useLayoutEffect(() => () => over.current?.removeAttribute('data-over'), []) + return ( +
+
+ {children} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/upload/upload.css b/skills/imported/benjitaylor/stageshow/examples/upload/upload.css new file mode 100644 index 0000000..49f59f7 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/upload/upload.css @@ -0,0 +1,310 @@ +/* A file storage app, list view. Corner bleed: the window sits one gutter in from the + top and runs off the right and bottom; the desktop shows to its left. */ + +.up .ss-stage { + overflow: hidden; + border-radius: 14px; + background: var(--ss-well); +} + +.up-window { + position: absolute; + background: var(--ss-surface); + box-shadow: var(--ss-shadow-hero); +} + +/* ---- Toolbar ---- */ + +.up-lead { + display: flex; + align-items: center; + gap: 12px; +} + +.up-nav { + display: flex; + gap: 2px; + color: var(--ss-icon); +} + +.up-nav-off { + color: var(--ss-icon-faint); +} + +.up-heading { + font-size: 14px; + font-weight: 600; + color: var(--ss-text-strong); +} + +.up-tools { + display: flex; + align-items: center; + gap: 16px; + color: var(--ss-icon); +} + +.up-views { + display: flex; + gap: 2px; +} + +.up-view { + display: grid; + place-items: center; + width: 28px; + height: 24px; + border-radius: 6px; +} + +.up-view[data-on] { + background: var(--ss-ink-2); + color: var(--ss-text-strong); +} + +/* ---- List ---- */ + +.up-body { + overflow: hidden; +} + +.up-list { + padding: 4px 10px 0; + font-size: 14px; +} + +.up-head, +.up-row { + display: grid; + grid-template-columns: var(--cols); + align-items: center; +} + +.up-head { + height: 30px; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + color: var(--ss-text); +} + +.up-head > span, +.up-row > * { + padding-left: 12px; +} + +.up-head > span + span { + border-left: 1px solid var(--ss-line); + line-height: 15px; +} + +.up-row > .ss-bar { + margin-left: 12px; + padding: 0; +} + +.up-rows { + position: relative; + min-height: 600px; + border-radius: 8px; + transition: box-shadow 0.15s ease, background-color 0.15s ease; +} + +/* The list lights up while a file is carried over it */ +.up-rows[data-over] { + background: color-mix(in srgb, var(--ss-accent) 5%, transparent); + box-shadow: inset 0 0 0 2px var(--ss-accent); +} + +.up-row { + position: relative; + height: 38px; + border-radius: 7px; + color: var(--ss-text-strong); +} + +.up-name { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.up-name__text { + white-space: nowrap; + font-weight: 500; +} + +.up-folder { + flex-shrink: 0; + color: #4fa6ea; + fill: #8ccaf6; +} + +.up-doc { + flex-shrink: 0; + color: #a9a9ae; + fill: #fff; +} + +/* ---- The upload row: tinted while it uploads, plain once it lands ---- */ + +.up-new { + background: color-mix(in srgb, var(--ss-accent) 7%, #fff); + transition: background-color 0.6s ease; +} + +.up-new[data-done] { + background: transparent; +} + +.up-new > .ss-states { + padding-left: 12px; +} + +.up-track { + width: 110px; + background: color-mix(in srgb, var(--ss-accent) 18%, #fff); +} + +.up-list[data-compact] .up-track { + width: 64px; +} + +/* The kit Meter, flat accent, eased to keep pace with the size counting up */ +.up-track .ss-meter__fill { + background: var(--ss-accent); + transition-duration: 0.6s; + transition-timing-function: cubic-bezier(0.3, 0.6, 0.3, 1); +} + +.up-size { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +/* ---- The file on the desktop: icon over its name, as macOS draws it ---- */ + +.up-desk { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + width: 104px; + font-size: 12px; + transition: opacity 0.2s ease; +} + +.up-desk[data-lifted] { + opacity: 0.45; +} + +.up-desk__name { + padding: 1px 5px; + line-height: 1.35; + font-weight: 500; + color: var(--ss-text-strong); + white-space: nowrap; +} + +.up-pdf { + position: relative; + display: block; + width: 44px; + height: 56px; + border-radius: 3px; + background: #fff; + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.16), 0 2px 6px -2px rgba(0, 0, 0, 0.22); + clip-path: polygon(0 0, 70% 0, 100% 23%, 100% 100%, 0 100%); +} + +.up-pdf::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 30%; + height: 23%; + background: linear-gradient(225deg, transparent 50%, #e4e4e7 50%); +} + +.up-pdf__band { + position: absolute; + left: 0; + right: 0; + bottom: 10px; + padding: 2px 0; + background: #e5484d; + color: #fff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 13px; + text-align: center; +} + +.up-pdf[data-small] { + flex-shrink: 0; + width: 15px; + height: 19px; + border-radius: 2px; + box-shadow: inset 0 0 0 1px #c9c9ce; + clip-path: none; +} + +.up-pdf[data-small]::before { + display: none; +} + +.up-pdf[data-small] .up-pdf__band { + bottom: 4px; + height: 5px; + padding: 0; +} + +/* ---- The drag image: the icon and its name on a selection pill ---- */ + +.up-drag { + position: absolute; + top: 0; + left: 0; + z-index: 30; + pointer-events: none; + opacity: 0; + will-change: transform; +} + +.up-drag[data-show] { + opacity: 0.85; +} + +.up-drag__inner { + /* Laid out exactly as the desktop icon, so the pickup never doubles it */ + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + width: 104px; + font-size: 12px; + will-change: transform; +} + +.up-drag[data-dropped] { + opacity: 0; + transition: opacity 0.22s ease-in; +} + +.up-drag[data-dropped] .up-drag__inner { + transform: scale(0.5); + transition: transform 0.24s cubic-bezier(0.4, 0, 0.2, 1); +} + +.up-drag__name { + padding: 1px 5px; + border-radius: 4px; + background: var(--ss-accent); + color: #fff; + white-space: nowrap; + line-height: 1.35; +} diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/PHOTOS.md b/skills/imported/benjitaylor/stageshow/examples/wikipedia/PHOTOS.md new file mode 100644 index 0000000..63cbb2a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/wikipedia/PHOTOS.md @@ -0,0 +1,17 @@ +# Photograph sources + +Both photographs are bundled locally. Their Wikimedia Commons file pages +identify them as dedicated under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/). +They are not covered by the Stageshow source-code MIT license. + +| Local file | Original and author | Local changes | +| --- | --- | --- | +| `fuji.jpg` | [Fuji Kawaguchi 465.JPG](https://commons.wikimedia.org/wiki/File:Fuji_Kawaguchi_465.JPG), Marion & Christoph Aistleitner | Cropped to a 2:1 view of the mountain and lake, resized to 640 × 320, and JPEG-compressed. | +| `kawaguchiko.jpg` | [Kawaguchiko, Part III - Kawaguchiko6782.jpg](https://commons.wikimedia.org/wiki/File:Kawaguchiko,_Part_III_-_Kawaguchiko6782.jpg), lumoplank | Resized to 440 × 309 and JPEG-compressed. | + +The example also displays these files through CSS crops and filters for +illustrative search-result thumbnails; those thumbnails do not depict every +place named in the mocked search results. + +Source and CC0 status verified against the Commons file pages on 2026-10-25. +The local images matched the recorded crop/resize operations byte for byte. diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/fuji.jpg b/skills/imported/benjitaylor/stageshow/examples/wikipedia/fuji.jpg new file mode 100644 index 0000000..f20ced3 Binary files /dev/null and b/skills/imported/benjitaylor/stageshow/examples/wikipedia/fuji.jpg differ diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/index.tsx b/skills/imported/benjitaylor/stageshow/examples/wikipedia/index.tsx new file mode 100644 index 0000000..205e02f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/wikipedia/index.tsx @@ -0,0 +1,428 @@ +import { useCallback, type ReactNode } from 'react' +import { Popover, Stage, Swap, Window, type Act, type Layout } from '../../kit' +import './wiki.css' + +// Wikipedia (Vector 2022) in a browser, for a docs page (800 wide). Story: +// search as you go. The reader is on Mount Fuji, clicks into the search +// field and types "lake"; Wikipedia's typeahead drops under the field with +// thumbnails and short descriptions. They keep typing, "kaw", and the list +// refines in place until Lake Kawaguchi sits on top, with its photo. One +// click and the page is the lake. The browser's back button returns to Mount +// Fuji, which is the loop's caused reset. +// +// Realistic fidelity, as the prompt asks for "exactly like Wikipedia": the +// suggestions are the ones Wikipedia really returns for "lake kaw", the +// rows use Codex's geometry (60px rows, 40px thumbnails, a placeholder +// thumbnail for an article without an image, the "Search for pages +// containing" footer), and the icons are Codex's own paths. + +type Page = 'fuji' | 'lake' +type S = { + focus: boolean + q: string + menu: boolean + // Which result set each row shows, so the refine can ripple down the list + rows: number[] + // The field after navigating: the query fades and the placeholder returns + cleared: boolean + page: Page +} +const initial: S = { focus: false, q: '', menu: false, rows: [0, 0, 0, 0], cleared: false, page: 'fuji' } + +const REST = { wide: { x: 300, y: 470 }, compact: { x: 200, y: 470 } } + +type Thumb = 'photo' | 'baikal' | 'tahoe' | 'victoria' | 'superior' | 'kaweah' | 'kawaupaka' | 'none' +type Result = { title: string; desc: string; thumb: Thumb } + +// What Wikipedia suggests for "lake", then for "lake kaw" (October 2026). +const RESULTS: Result[][] = [ + [ + { title: 'Lake Baikal', desc: 'Freshwater rift lake in Russia', thumb: 'baikal' }, + { title: 'Lake Tahoe', desc: 'Lake in California and Nevada, US', thumb: 'tahoe' }, + { title: 'Lake Victoria', desc: 'African Great Lake', thumb: 'victoria' }, + { title: 'Lake Superior', desc: 'Largest of the Great Lakes of North America', thumb: 'superior' }, + ], + [ + { title: 'Lake Kawaguchi', desc: 'Lake in Yamanashi prefecture, Japan', thumb: 'photo' }, + { title: 'Lake Kaweah', desc: 'Reservoir in Tulare County, California', thumb: 'kaweah' }, + { title: 'Lake Kawaupaka', desc: 'Lake in New Zealand', thumb: 'kawaupaka' }, + { title: 'The Lake (Kawabata novel)', desc: '1954 novel by Yasunari Kawabata', thumb: 'none' }, + ], +] + +// A person's typing: uneven, but the same every loop +const RHYTHM = [1.1, 0.8, 1.3, 0.9, 1.6, 1, 0.75, 1.2] + +async function typeOn(a: Act, from: string, to: string) { + for (let i = from.length + 1; i <= to.length; i++) { + await a.set({ q: to.slice(0, i) }) + await a.wait(105 * RHYTHM[i % RHYTHM.length]) + } +} + +async function script(a: Act) { + const rest = REST[a.layout] + await a.wait(900) + + // 1. Into the search field + a.mark('search') + await a.click({ at: 'field', x: 0.72, y: 0.55 }) + await a.set({ focus: true }) + await a.wait(350) + + // 2. "lake": suggestions drop under the field + await typeOn(a, '', 'lake') + await a.wait(140) + await a.set({ menu: true }) + await a.wait(900) + + // 3. "kaw": the list refines in place, top row first + a.mark('refine') + await typeOn(a, 'lake', 'lake kaw') + await a.wait(120) + await a.stagger([0, 1, 2, 3], (i) => a.set((s) => ({ rows: s.rows.map((r, j) => (j === i ? 1 : r)) })), 50) + await a.wait(900) + + // 4. Pick Lake Kawaguchi: the menu closes and the page becomes the lake + a.mark('pick') + await a.move({ at: 'result', x: 0.74, y: 0.3 }) + await a.wait(250) + await a.click() + await a.set({ menu: false }) + await a.wait(140) + await a.set({ page: 'lake', focus: false, cleared: true }) + a.mark('lake') + await a.move({ x: rest.x + 40, y: rest.y - 20 }) + await a.wait(2600) + + // 5. Back to Mount Fuji, the way a reader would + a.mark('back') + await a.click('back') + await a.set({ page: 'fuji' }) + await a.snap({ q: '', cleared: false, rows: [0, 0, 0, 0] }, 'field') + await a.move(rest) + await a.wait(700) +} + +export default function WikipediaSearch() { + return ( + + {(s, _rect, layout) => } + + ) +} + +function Scene({ s, layout }: { s: S; layout: Layout }) { + const compact = layout === 'compact' + // The kit's browser chrome draws its back arrow without a target; name it + // here so the script can aim at it (see the report: a kit gap). + const nameBack = useCallback((el: HTMLSpanElement | null) => { + el?.closest('.ss-stage')?.querySelector('.ss-window__nav .ss-window__chev')?.setAttribute('data-target', 'back') + }, []) + return ( + + + ) +} + +/* ---- Header and search ---- */ + +function Header({ s, compact }: { s: S; compact: boolean }) { + const typed = s.q.length > 0 && !s.cleared + return ( +
+ + +
+
+ + + + Search Wikipedia + + + {s.q} + 0 && s.q.length < 8) || undefined} /> + + +
+ {!compact &&
Search
} + + +
+ {s.rows.map((set, i) => { + const r = RESULTS[set][i] + return ( +
+ + + + + {r.title} + {r.desc} + + + +
+ ) + })} +
+
+ + + + + Search for pages containing {s.q} + +
+
+
+ + {!compact && Log in} + +
+ ) +} + +/* ---- The two articles ---- */ + +function Titlebar({ title, langs, compact }: { title: string; langs: number; compact: boolean }) { + return ( + <> +
+ +
{title}
+ + + + {!compact && {langs} languages} + + +
+ + + ) +} + +function Fuji({ compact }: { compact: boolean }) { + return ( + <> + +
+ {!compact && ( + 35°21′N 138°43′E], + ['Native name', '富士山'], + ['Country', 'Japan'], + ]} + /> + )} +
From Wikipedia, the free encyclopedia
+ {compact &&
} +

+ Mount Fuji is an active stratovolcano located on the Japanese island of{' '} + Honshu, with a summit elevation of 3,776.24 m (12,389 ft). It is the highest + mountain in Japan, and the second-highest volcano on any Asian island. +

+

+ It is located about 100 km southwest of Tokyo, from which it is visible on clear + days. It has an exceptionally symmetrical cone, which is covered in snow for about five months of the year. +

+
Etymology
+

+ The current kanji for Mount Fuji, 富 and 士, mean wealth or abundant and a man of a + certain status respectively. +

+
+ + ) +} + +function Lake({ compact }: { compact: boolean }) { + return ( + <> + +
+ {!compact && ( + Yamanashi Prefecture], + ['Surface area', '6.13 km² (2.37 sq mi)'], + ['Max. depth', '15.2 m (50 ft)'], + ['Basin countries', 'Japan'], + ['Surface elevation', '833 m (2,733 ft)'], + ]} + /> + )} +
From Wikipedia, the free encyclopedia
+ {compact &&
} +

+ Lake Kawaguchi (河口湖, Kawaguchi-ko) is located in the town of{' '} + Fujikawaguchiko in southern Yamanashi Prefecture near{' '} + Mount Fuji, Japan. It is the second largest of the{' '} + Fuji Five Lakes in terms of surface area. +

+

+ The lake is within the borders of the Fuji-Hakone-Izu National Park, and has the + longest shoreline of any of the Fuji Five Lakes. +

+
History
+

+ The lake has no natural outlet, and flooding of settlements on its shores was a problem until a canal was + completed in 1914 to connect it to a tributary of the Sagami River. +

+
+ + ) +} + +function Infobox({ name, tone, img, caption, rows }: { name: string; tone: 'mountain' | 'water'; img: 'fuji' | 'lake'; caption: string; rows: [string, ReactNode][] }) { + return ( + + ) +} + +/* ---- Thumbnails ---- */ + +// Every suggestion has a photo, as on wikipedia.org. Lake Kawaguchi's is +// the real one its article opens with; the others are tight crops of the +// demo's two photos (a hillside, open water, a shoreline at dusk), which at +// 40px read as their own photos. Kawaupaka's real thumbnail is black and +// white, so its crop is too. The novel has Codex's placeholder, as an +// article without an image does. +const CROPS: Record, { img: 'lake' | 'fuji'; size: number; at: string; filter?: string }> = { + baikal: { img: 'lake', size: 320, at: '0% 46%', filter: 'saturate(0.75)' }, + tahoe: { img: 'lake', size: 260, at: '100% 88%' }, + victoria: { img: 'fuji', size: 360, at: '0% 30%', filter: 'brightness(1.1)' }, + superior: { img: 'fuji', size: 460, at: '100% 100%' }, + kaweah: { img: 'lake', size: 380, at: '92% 52%' }, + kawaupaka: { img: 'fuji', size: 320, at: '100% 72%', filter: 'grayscale(1) contrast(1.1)' }, +} + +function ThumbImage({ kind }: { kind: Thumb }) { + if (kind === 'photo') return + if (kind === 'none') + return ( + + + + ) + const c = CROPS[kind] + return +} + +/* ---- Logo and icons ---- */ + +/* Wikipedia's mark, drawn small and plain: a globe with a few puzzle seams, + next to the wordmark set in a serif. */ +function Logo({ compact }: { compact: boolean }) { + return ( +
+ + + + + + + {!compact && ( +
+ + WIKIPEDIA + + The Free Encyclopedia +
+ )} +
+ ) +} + +// Codex, Wikimedia's icon set (MIT), at its native 20px grid +const CDX: Record = { + menu: , + search: , + ellipsis: , + listBullet: , + expand: , + language: ( + + ), + image: ( + <> + + + + + ), + articleSearch: , +} + +function CdxIcon({ name, size = 20, className }: { name: string; size?: number; className?: string }) { + return ( + + {CDX[name]} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg b/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg new file mode 100644 index 0000000..7ef94ca Binary files /dev/null and b/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg differ diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css b/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css new file mode 100644 index 0000000..121f374 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css @@ -0,0 +1,577 @@ +/* Wikipedia (Vector 2022) in a browser on a docs plate. Pinned with a 32px + gutter on the left, right and top; the window runs off the bottom. Colors + and the search menu's geometry are Codex's own values (measured on + en.wikipedia.org, October 2026), all one cool neutral family. */ + +.wk-stage .ss-stage { + --gutter: 32px; + --wk-text: #202122; + --wk-subtle: #54595d; + --wk-placeholder: #72777d; + --wk-link: #36c; + --wk-border: #a2a9b1; + --wk-border-soft: #c8ccd1; + --wk-soft: #f8f9fa; + --wk-hover: #eaecf0; + --wk-active: #dadde3; + --wk-serif: 'Linux Libertine', 'Georgia', 'Times', serif; + --wk-sans: -apple-system, 'Helvetica Neue', Arial, sans-serif; + overflow: hidden; + background: var(--ss-well); +} + +.wk-window { + position: absolute; + top: var(--gutter); + left: var(--gutter); + right: var(--gutter); + height: 640px; + background: #fff; + border-radius: 10px; + box-shadow: var(--ss-shadow-hero); +} + +.wk-page { + display: flex; + flex-direction: column; + height: 100%; + padding: 0 24px; + background: #fff; + color: var(--wk-text); + font-family: var(--wk-sans); + font-size: 14px; + line-height: 1.6; +} + +.wk-spacer { + flex: 1; +} + +.wk-icon { + display: block; + flex-shrink: 0; + color: var(--wk-text); +} + +/* ---- Header ---- */ + +.wk-header { + position: relative; + z-index: 2; + display: flex; + flex-shrink: 0; + align-items: center; + gap: 16px; + height: 58px; +} + +.wk-logo { + display: flex; + align-items: center; + gap: 8px; +} + +.wk-logo svg { + display: block; +} + +.wk-wordmark { + display: flex; + flex-direction: column; + align-items: center; + font-family: var(--wk-serif); + color: #000; + line-height: 1; +} + +.wk-wordmark__name { + font-size: 17px; + letter-spacing: 0.06em; +} + +.wk-wordmark__name small { + font-size: 13.5px; +} + +.wk-wordmark__tag { + margin-top: 3px; + font-size: 9.5px; + white-space: nowrap; + letter-spacing: 0.01em; +} + +.wk-search { + position: relative; + display: flex; + margin-left: 8px; +} + +.wk-field { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 316px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--wk-border); + border-radius: 2px 0 0 2px; + transition: border-color 0.1s ease, box-shadow 0.1s ease; +} + +/* Codex's focus: a progressive-blue border doubled by an inset ring */ +.wk-field[data-focus] { + border-color: var(--wk-link); + box-shadow: inset 0 0 0 1px var(--wk-link); +} + +.wk-field[data-menu] { + border-bottom-left-radius: 0; +} + +.wk-field__icon { + color: var(--wk-placeholder); +} + +.wk-field__text { + position: relative; + flex: 1; + min-width: 0; + height: 22px; +} + +.wk-ph, +.wk-q { + position: absolute; + inset: 0 auto 0 0; + display: flex; + align-items: center; + white-space: nowrap; +} + +/* The placeholder leaves on the first key, as a real field's does; it + comes back with a fade when the page changes and the field is reset */ +.wk-ph { + color: var(--wk-placeholder); + /* Comes back once the query has gone, so the two never overlap */ + transition: opacity 0.25s ease 0.14s; +} + +.wk-ph[data-hide] { + opacity: 0; + transition: none; +} + +.wk-q { + opacity: 0; + transition: opacity 0.14s ease; +} + +.wk-q[data-show] { + opacity: 1; + transition: none; +} + +.wk-caret { + width: 1px; + height: 17px; + margin-left: 1px; + background: var(--wk-text); + opacity: 0; +} + +.wk-caret[data-on] { + animation: wk-blink 1.06s steps(1) infinite; +} + +.wk-caret[data-on][data-typing] { + animation: none; + opacity: 1; +} + +@keyframes wk-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.wk-button { + display: flex; + align-items: center; + height: 32px; + padding: 0 12px; + border: 1px solid var(--wk-placeholder); + border-left: 0; + border-radius: 0 2px 2px 0; + background: var(--wk-soft); + font-weight: 700; +} + +.wk-userlink { + color: var(--wk-link); + white-space: nowrap; +} + +/* ---- The typeahead menu ---- + Hangs from the field's bottom edge at the field's width, square at the + top, with Codex's hairline and soft shadow. */ + +.wk-menu.ss-popover { + --ss-popover-bg: #fff; + /* The hairline sits inside the box, so the menu's edges line up with the + field's, as on wikipedia.org; no top line, the field's border is it */ + --ss-popover-shadow: inset 1px 0 0 var(--wk-border), inset -1px 0 0 var(--wk-border), inset 0 -1px 0 var(--wk-border), 0 4px 4px rgba(0, 0, 0, 0.06), 0 0 8px rgba(0, 0, 0, 0.06); + top: 32px; + left: 0; + width: 316px; + padding: 0; + border-radius: 0 0 2px 2px; +} + +.wk-menu__list { + /* Rows run the menu's full width, as Codex's do (measured on + wikipedia.org: no menu padding, 8px 12px inside each row) */ + padding: 0; + /* One lasting layer for the rows, so their text never hops in its box */ + will-change: transform; +} + +/* Rows are links; Codex's hover is a square fill the full width of the menu */ +.wk-result { + height: 60px; + transition: background-color 0.1s ease; +} + +.wk-result[data-hover] { + background: var(--wk-hover); +} + +.wk-result[data-pressed] { + background: var(--wk-active); +} + +.wk-swap.ss-swap { + display: block; + height: 60px; +} + +.wk-swap .ss-swap__in, +.wk-swap .ss-swap__out { + width: 100%; +} + +.wk-result__inner { + display: flex; + align-items: center; + gap: 8px; + height: 60px; + padding: 0 12px; +} + +.wk-thumb { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + overflow: hidden; + border: 1px solid var(--wk-border-soft); + border-radius: 2px; + background: var(--wk-soft) center / cover; +} + +.wk-thumb svg { + display: block; +} + +.wk-thumb[data-empty] .wk-icon { + color: var(--wk-placeholder); +} + +.wk-result__text { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 22px; +} + +.wk-result__title { + overflow: hidden; + font-weight: 700; + white-space: nowrap; + text-overflow: ellipsis; +} + +.wk-result__desc { + overflow: hidden; + color: var(--wk-subtle); + white-space: nowrap; + text-overflow: ellipsis; +} + +.wk-menu__footer { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 8px 12px; + line-height: 22px; +} + +.wk-menu__footer-icon { + display: flex; + justify-content: center; + width: 40px; +} + +.wk-menu__footer-icon .wk-icon { + color: var(--wk-subtle); +} + +.wk-menu__footer-text { + min-width: 0; +} + +/* ---- Pages: a navigation crossfades the article under the header ---- */ + +.wk-pages { + position: relative; + flex: 1; +} + +.wk-pg { + position: absolute; + inset: 0; + opacity: 0; + transform: translateY(8px); + /* Kept on its layer, so the article's text doesn't hop as the rise lands */ + will-change: transform; + transition: opacity 0.2s ease, transform 0s linear 0.2s; +} + +.wk-pg[data-show] { + opacity: 1; + transform: none; + transition: opacity 0.45s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s, transform 0.7s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s; +} + +/* ---- Title, tabs ---- */ + +.wk-titlebar { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + padding-bottom: 2px; + border-bottom: 1px solid var(--wk-border); +} + +.wk-title { + margin: 0; + font-family: var(--wk-serif); + font-size: 28.8px; + font-weight: 400; + line-height: 1.375; + white-space: nowrap; +} + +.wk-lang { + display: flex; + align-items: center; + gap: 6px; + color: var(--wk-link); + font-weight: 700; + white-space: nowrap; +} + +.wk-lang .wk-icon { + color: var(--wk-link); +} + +.wk-tabs { + display: flex; + align-items: stretch; + gap: 16px; + height: 40px; +} + +.wk-tab { + display: flex; + align-items: center; + white-space: nowrap; + border-bottom: 2px solid transparent; +} + +.wk-tab[data-selected] { + border-bottom-color: var(--wk-text); +} + +.wk-blue { + color: var(--wk-link); +} + +/* ---- Article ---- */ + +.wk-tagline { + margin: 6px 0 8px; + font-size: 12.3px; + color: var(--wk-subtle); +} + +.wk-content p { + margin: 0 0 10px; +} + +.wk-link { + color: var(--wk-link); +} + +.wk-infobox { + float: right; + width: 232px; + margin: 10px 0 12px 20px; + padding: 3px; + border: 1px solid var(--wk-border); + background: var(--wk-soft); + font-size: 12.3px; + line-height: 1.5; +} + +/* Wikipedia tints an infobox's title by subject */ +.wk-infobox__name { + margin-bottom: 4px; + padding: 2px 4px; + text-align: center; + font-size: 15.5px; + font-weight: 700; + line-height: 1.4; +} + +.wk-infobox__name[data-tone="mountain"] { + background: #e7dcc3; +} + +.wk-infobox__name[data-tone="water"] { + background: #cedeff; +} + +/* Real photos, both CC0 on Wikimedia Commons: "Fuji Kawaguchi 465.JPG" and + "Kawaguchiko, Part III - Kawaguchiko6782.jpg" */ +[data-img="fuji"] { + background: #9fb6cc url('./fuji.jpg') center 30% / cover; +} + +[data-img="lake"] { + background: #c9d4dc url('./kawaguchiko.jpg') center / cover; +} + +.wk-infobox__img { + display: block; + width: 220px; + height: 146px; + margin: 0 auto; +} + +.wk-infobox__caption { + padding: 4px 4px 6px; + text-align: center; + font-size: 11.5px; +} + +.wk-infobox table { + width: 100%; + border-collapse: collapse; +} + +.wk-infobox th, +.wk-infobox td { + padding: 3px 4px; + vertical-align: top; + text-align: left; +} + +.wk-infobox th { + width: 42%; + font-weight: 700; +} + +.wk-figure { + height: 150px; + margin: 4px 0 12px; + border: 1px solid var(--wk-border-soft); +} + +/* ---- Compact (narrow containers) ---- */ + +.ss-stage[data-layout="compact"] .wk-window { + --gutter: 16px; +} +.ss-stage[data-layout="compact"] .wk-page { + padding: 0 16px; +} +.ss-stage[data-layout="compact"] .wk-header { + gap: 12px; +} +.ss-stage[data-layout="compact"] .wk-search { + flex: 1; + min-width: 0; + margin-left: 0; +} +.ss-stage[data-layout="compact"] .wk-field { + width: 100%; + border-radius: 2px; +} +.ss-stage[data-layout="compact"] .wk-field[data-menu] { + border-radius: 2px 2px 0 0; +} +.ss-stage[data-layout="compact"] .wk-menu.ss-popover { + width: 100%; +} +.ss-stage[data-layout="compact"] .wk-title { + font-size: 24px; +} +.wk-window .ss-window__url { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The refine crossfades each row in place: the new row starts to show as the + old one leaves, so no row is ever empty (the kit's Swap holds the new text + back for its first 30%, which leaves a blank row mid-swap) */ +.wk-swap .ss-swap__in { + animation-name: wk-row-in; +} + +.wk-swap .ss-swap__out { + animation-name: wk-row-out; +} + +@keyframes wk-row-in { from { opacity: 0; } to { opacity: 1; } } + +@keyframes wk-row-out { to { opacity: 0; } } + +.wk-h2 { + margin: 18px 0 8px; + padding-bottom: 2px; + border-bottom: 1px solid var(--wk-border); + font-family: var(--wk-serif); + font-size: 21px; + font-weight: 400; + line-height: 1.375; +} + +/* Vector's collapsed page tools are a vertical ellipsis */ +.wk-kebab { + transform: rotate(90deg); +} +.ss-stage[data-layout="compact"] .wk-header > .wk-spacer { + display: none; +} + +/* Picking a result navigates, so the menu goes quickly rather than hanging + half-faded over the article underneath (the kit's 0.3s close) */ +.wk-menu.ss-popover:not([data-show]) { + transition: opacity 0.15s ease, transform 0.15s cubic-bezier(0.4, 0, 1, 1); +} diff --git a/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx b/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx new file mode 100644 index 0000000..fc2fceb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx @@ -0,0 +1,87 @@ +import { Badge, Bar, Check, Row, Stage, States, Text, Window, type Act } from '../../kit' +import './row.css' + +// A row of windows: three agent sessions side by side at one height. The +// middle one is the story and the only one with words; the outer two run off +// both sides of the stage, still working, as placeholder. Beats: the middle +// session ticks through its steps, then lands on Ready for review. Plays +// once; the still is the payoff. +// +// What makes a row read as one picture: +// - Same height and top for every window, one gap between them. The outer +// ones are cut by the stage by about a third, so the row reads as going on. +// - Focus by depth, not by dimming the neighbors: the middle window has the +// hero shadow and sits in front; the outer ones keep the quieter window +// shadow. Faded or blurred neighbors read as a mistake. +// - The neighbors are busy but quiet: a spinner and bars, no title, no words +// to read. The stage cuts them, and it may only cut filler. +// - The windows run off the bottom just under the result, so no window has a +// dead strip below its content. +// +// Real strings: the middle session's title, its steps and the result. + +type S = { done: number; ready: boolean } + +const initial: S = { done: 0, ready: false } +const poster: S = { done: 4, ready: true } + +const STEPS = ['Read the auth middleware', 'Found the redirect loop', 'Patched the session check', 'Tests passing, 48 of 48'] + +async function script(a: Act) { + await a.wait(900) + for (let i = 1; i <= STEPS.length; i++) { + a.mark(`step-${i}`) + a.put({ done: i }) + await a.wait(i === STEPS.length ? 700 : 950) + } + a.mark('ready') + a.put({ ready: true }) + await a.wait(2400) +} + +// The neighbors: the same rows as the hero, bars where the words are +function Busy({ rows }: { rows: number[] }) { + return ( +
+ {rows.map((w, i) => ( + } title={} /> + ))} +
+ ) +} + +export default function WindowRow() { + return ( + + {(s) => ( + <> + + + + +
+ {/* One Check per step, mounted throughout: ring, then pending, then + done, so each hand-over is the kit's own animation */} + {STEPS.map((step, i) => ( + } title={{step}} /> + ))} +
+ Working, + ready: Ready for review, + }} + /> +
+
+
+ + + + + )} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/examples/window-row/row.css b/skills/imported/benjitaylor/stageshow/examples/window-row/row.css new file mode 100644 index 0000000..2462d43 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/examples/window-row/row.css @@ -0,0 +1,63 @@ +/* Three windows at one height and top, one 20px gap. The middle one is + centered; the outer two run off both sides by about a third. */ +.wr .ss-stage { + overflow: hidden; + background: var(--ss-well); +} + +.wr-win { + position: absolute; + top: 32px; + width: 300px; + height: 340px; +} + +.wr-win--hero { + left: 210px; + z-index: 1; + box-shadow: var(--ss-shadow-hero); +} + +.wr-win--left { + left: -110px; +} + +.wr-win--right { + left: 530px; +} + +.wr-body { + display: grid; + gap: 12px; + padding: 18px 20px 0; +} + + +.wr-label { + color: var(--ss-text); + font-size: 13px; + transition: color 0.3s ease; +} + +.wr-step[data-on] .wr-label, +.wr-step[data-next] .wr-label { + color: var(--ss-text-strong); +} + +.wr-foot { + margin-top: 6px; + padding-top: 16px; + border-top: 1px solid var(--ss-line); +} + +/* Narrow: no room for a row. The neighbors would be slivers, so the hero + stands alone, a gutter in from each side, and runs off the bottom */ +.wr .ss-stage[data-layout='compact'] .wr-win--left, +.wr .ss-stage[data-layout='compact'] .wr-win--right { + display: none; +} + +.wr .ss-stage[data-layout='compact'] .wr-win--hero { + left: 16px; + width: 326px; +} diff --git a/skills/imported/benjitaylor/stageshow/kit/act.ts b/skills/imported/benjitaylor/stageshow/kit/act.ts new file mode 100644 index 0000000..10bb568 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/act.ts @@ -0,0 +1,669 @@ +import { flushSync } from 'react-dom' +import type { Dispatch, SetStateAction } from 'react' +import type { CursorKind } from './cursor' +import { capsOf, type Keys } from './keys' +import type { Layout } from './looks' +import { EMPTY, drawnScale, nameOf, layoutRectOf, rectOf, thumb, type Point, type Rect, type Target } from './measure' +import { bezier } from './ease' +import { clamp, easeInOutCubic, hash, warn } from './util' + +// The touch dot lingers a beat after a lift, then goes +const fingerGone = (el: HTMLElement | null | undefined) => { if (!el) return; window.setTimeout(() => el.removeAttribute('data-touching'), 240) } + +// The script API: the act a demo's script is handed each run. It drives the +// scene state and the cursor, measures targets, and stops the moment its +// signal aborts (the stage left the screen, or the layout changed). + +export interface Act { + /** Merge a patch into scene state. Resolves once it has painted, so rects are fresh. */ + set(patch: Partial | ((s: S) => Partial)): Promise + /** Like set, without waiting. For per-frame updates inside drag. */ + put(patch: Partial | ((s: S) => Partial)): void + wait(ms: number): Promise + /** + * Where a target is now, in stage pixels. With `layout`, where it sits in + * the layout, ignoring transforms: use that to aim at something that is + * still scaling or sliding in. + */ + rect(name: string, opts?: { layout?: boolean }): Rect + /** + * The character offset a text selection reaches with the pointer at `at`, + * the way the OS works it out: above the text is its start, below is its + * end, beside a line clamps to that line, between lines takes the nearer. + * Call it in a drag's onMove and the highlight stays under the I-beam from + * the first pixel: `a.drag(end, (at) => a.put({ sel: a.caret('para', at) }))`. + */ + caret(name: string, at: Point): number + center(name: string): Point + /** Move the cursor in a hand-like arc. Hovering a target marks it data-hover. */ + /** + * `path` sets the route in stage coordinates (eased 0 to 1 progress, the + * same contract as drag's) instead of the shallow arc: a hand running + * along a chart line. `onMove(at, from, t)` gets each point on the way, as drag's does, for anything + * that follows the pointer (a scrubbed day, a hover readout). + */ + move(to: Target, opts?: { duration?: number; path?: (progress: number, from: Point, to: Point) => Point; onMove?: (at: Point, from: Point, t: number) => void }): Promise + /** + * Move (if given a target) and press. `double` presses twice, as a double + * click does (open a file, select a word). `right` is a secondary click: it + * looks like any press, so what opens at the pointer (a context menu) is + * what tells it; pressed elements get data-pressed="right" to tell them apart. + */ + click(to?: Target, opts?: { double?: boolean; right?: boolean }): Promise + /** + * Scroll a target that clips its content (give it the class ss-scroll), the + * way a trackpad does: the cursor stays put and the content moves. `to` is + * a scroll offset in px, or the name of a target inside it to bring to the + * top (less `offset`). Once scrolled, its edges fade over what's hidden. + * A visible cursor moves into the area first if it isn't over it already, + * and holds still while the content scrolls under it. + */ + scroll(name: string, to: number | string, opts?: { duration?: number; offset?: number }): Promise + cursor(kind: CursorKind): Promise + /** + * Apply a patch with transitions off, for resets nothing on screen should + * explain. Pass a target name to switch transitions off only inside it. + */ + snap(patch: Partial | ((s: S) => Partial), scope?: string): Promise + /** + * Show a keystroke as keycaps: 'cmd+k', 'enter', 'shift+cmd+p'. Resolves + * as the key comes back up, the moment an app would react; the caps fade + * on their own. + */ + key(combo: string): Promise + /** + * Start fn for each item, `gap` ms apart (60 by default), and wait for them all. Staggered + * starts read as organic where a single block reads as mechanical. + */ + stagger(items: T[], fn: (item: T, i: number) => Promise | void, gap?: number): Promise + /** Name the current beat. Sets data-mark on the stage so a film can start there (film.mjs --at). */ + mark(name: string): void + /** Type text into a string key of state, one character at a time, `interval` ms apart on average (35 by default). */ + type(key: keyof S, text: string, opts?: { interval?: number }): Promise + /** Press, hold and move in the same shallow, hand-like arc as move(), reporting each frame. The cursor + * dips as it presses and closes into the grab hand at the bottom of the dip, and opens on release by itself: + * don't switch to 'grab' before a drag, a hovering cursor is never a fist. + * I-beam and crosshair selections keep their kind. Use cursor: 'preserve' + * when the product keeps its current cursor during a drag, such as an arrow + * creating a calendar event. This changes the cursor only, not the path. + * For constrained mechanics, path receives eased progress (0..1) and returns + * stage coordinates shared by the hand and onMove. Its endpoints must match + * from and to. Use a circular path for a dial, or a straight path for a rail. */ + drag(to: Target, onMove?: (at: Point, from: Point, t: number) => void, opts?: { duration?: number; cursor?: 'preserve'; path?: (progress: number, from: Point, to: Point) => Point }): Promise + /** Put the cursor there at once, with no travel. Only behind a cut, where nothing on screen has to explain it. */ + place(to: Target): void + /** + * Bring the cursor in from outside the frame, already moving, and settle + * it on `to`: it starts past the frame edge nearest the target and never + * fades in mid-frame. The frame is the stage. + */ + enter(to: Target, opts?: { duration?: number }): Promise + readonly signal: AbortSignal + /** 'wide' or 'compact': which design is showing, so a script can aim or pace per layout. */ + readonly layout: Layout +} + +export type Script = (act: Act) => Promise + +export class Stopped extends Error {} + +type Ref = { current: T } + +/** What an act works with: the stage's elements, refs and setters, read when the act is made. */ +export interface ActEnv { + rootRef: { readonly current: HTMLDivElement | null } + cursorRef: { readonly current: HTMLDivElement | null } + pos: Ref + hovered: Ref + kindRef: Ref + keysRef: Ref + dragging: Ref + autoUsed: Ref + autoRef: Ref<((p: Point) => void) | null> + scaleRef: Ref + speed: number + layout: Layout + cursor?: { ripple?: boolean; style?: string } + resolve: (t: Target) => Point + place: (p: Point) => void + remeasure: () => void + ripple: (at: Point) => void + setState: Dispatch> + setKind: Dispatch> + setKeys: Dispatch> + setLastKeys: Dispatch> +} + +// Each run gets its own act, bound to its own signal, so a canceled run can +// never pick up the next run's signal and keep going. +export function createAct(signal: AbortSignal, env: ActEnv): Act { + const { rootRef, cursorRef, pos, hovered, kindRef, keysRef, dragging, autoUsed, autoRef, scaleRef, speed, layout, cursor, resolve, place, remeasure, ripple, setState, setKind, setKeys, setLastKeys } = env + const entering = bezier('enter') + const check = () => { + if (signal.aborted) throw new Stopped() + } + // Both clocks belong to this run. A settled wait releases its listener; + // abort cancels the pending work before another run can take over. + const pause = (schedule: (done: () => void) => T, cancel: (id: T) => void) => + new Promise((res, rej) => { + check() + const abort = () => { + cancel(id) + signal.removeEventListener('abort', abort) + rej(new Stopped()) + } + const id = schedule(() => { + signal.removeEventListener('abort', abort) + if (signal.aborted) rej(new Stopped()) + else res() + }) + signal.addEventListener('abort', abort, { once: true }) + }) + const frame = () => pause((done) => requestAnimationFrame(done), cancelAnimationFrame) + const wait = (ms: number) => pause((done) => setTimeout(done, ms / speed), clearTimeout) + // Release transient DOM/ref state synchronously on abort. The guarded + // finally call cannot then clear a new run's press or drag ownership. + const own = (release: () => void) => { + let live = true + const done = () => { + if (!live) return + live = false + signal.removeEventListener('abort', done) + release() + } + signal.addEventListener('abort', done, { once: true }) + return done + } + const tween = async (ms: number, step: (t: number) => void) => { + const start = performance.now() + const dur = ms / speed + for (;;) { + check() + const t = Math.min(1, (performance.now() - start) / dur) + step(t) + if (t === 1) return + await frame() + } + } + // Hover is the target and every data-target around it, as in a browser: + // a row stays hovered while the cursor is on a button inside it. + const chain = (el: Element | null) => { + const out: Element[] = [] + for (let n = el; n && n !== rootRef.current; n = n.parentElement) if (n.hasAttribute('data-target')) out.push(n) + return out + } + // Kind from what's under the cursor, as a real one works: any element + // marked data-cursor="hand" | "ibeam" | ... sets the kind while the cursor + // is over it, switching the moment it crosses the edge. Stages that mark + // nothing keep the kind the script sets. + const auto = (p: Point) => { + const root = rootRef.current + if (!root) return + const marked = root.querySelectorAll('[data-cursor]') + // A stage that never marks anything keeps the script's kind. One that + // did falls back to the pointer once its marked elements are gone, so + // a hand doesn't outlive the thing it was over. + if (!marked.length && !autoUsed.current) return + if (marked.length) autoUsed.current = true + const box = root.getBoundingClientRect() + const s = box.width / root.offsetWidth || 1 + // What it shows over anything unmarked: the pointer, or the stage's own + // base kind (a launch film keeps the hand throughout) + const base = (root.dataset.cursorBase as CursorKind | undefined) ?? 'pointer' + let next: CursorKind = base + // What is actually on top under the point decides, as a real pointer + // works: a button in a popover over a text area is not text. Walk up + // from the topmost element to the nearest marked one. + // Every layer, with pointer-events forced on for the test: demos turn + // them off on overlays (a menu, margin buttons), and the cursor must + // still read what is visibly on top, not the text beneath it. Layers + // that are see-through (faded out) are skipped. + root.setAttribute('data-hit', '') + const stack = document.elementsFromPoint(box.left + p.x * s, box.top + p.y * s) + root.removeAttribute('data-hit') + const top = stack.find((el) => { + if (!root.contains(el) || el.closest('.ss-cursor, .ss-keys, .ss-ripple')) return false + // A layer that turned pointer-events off counts only where it is a + // control or a target; a bare overlay (connectors, a flight layer) + // must not cover what is under it + if (getComputedStyle(el).pointerEvents === 'none' && !el.closest('[data-cursor], [data-target], button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]')) return false + let o = 1 + for (let n: Element | null = el; n && n !== root.parentElement; n = n.parentElement) { + const cs = getComputedStyle(n) + if (cs.visibility === 'hidden') return false + o *= Number(cs.opacity) + } + return o > 0.15 + }) + if (top) { + // A control inside a marked area (a button over text) is the pointer + // again unless it's marked itself, as on a real screen + const n = top.closest('[data-cursor], button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]') + next = n && root.contains(n) && n.dataset.cursor ? (n.dataset.cursor as CursorKind) : base + if (next !== kindRef.current) setKind(next) + return + } + // Off screen (nothing to hit-test): judge by boxes alone + marked.forEach((n) => { + const r = n.getBoundingClientRect() + const x = (r.left - box.left) / s + const y = (r.top - box.top) / s + // Later (inner) matches win, so a link inside a text block is a hand + if (p.x >= x && p.x <= x + r.width / s && p.y >= y && p.y <= y + r.height / s) next = n.dataset.cursor as CursorKind + }) + if (next !== kindRef.current) setKind(next) + } + autoRef.current = auto + // A run starts in the kind of what the cursor rests on, so a loop's first + // frame matches its last + if (!dragging.current) auto(pos.current) + const hover = (el: Element | null) => { + if (hovered.current === el) return + const next = chain(el) + chain(hovered.current).forEach((n) => next.includes(n) || n.removeAttribute('data-hover')) + next.forEach((n) => n.setAttribute('data-hover', '')) + hovered.current = el + } + // Stage keeps the last key row through resets; a new Act must not reuse its identity. + let keyId = keysRef.current.id + let carets = 0 + const targetEl = (t: Target) => { + const name = nameOf(t) + return name === null ? null : rootRef.current?.querySelector(`[data-target="${name}"]`) ?? null + } + + const a: Act = { + get signal() { + return signal + }, + put(patch) { + if (!signal.aborted) setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) })) + }, + async set(patch) { + check() + a.put(patch) + await frame() + await frame() + // The scene changed under a still pointer: a button that left, a menu + // that opened. Re-read what is under it now, as a real cursor would + autoRef.current?.(pos.current) + check() + }, + wait: async (ms) => { + check() + await wait(ms) + }, + rect: (name, opts) => { + const root = rootRef.current + const el = root?.querySelector(`[data-target="${name}"]`) + if (!root || !el) { + warn(`no element with data-target="${name}"`) + return EMPTY + } + return opts?.layout ? layoutRectOf(root, el) : rectOf(root, el, scaleRef.current) + }, + center: (name) => resolve(name), + caret(name, at) { + carets++ + const el = targetEl(name) as HTMLElement | null + const root = rootRef.current + if (!el || !root) return 0 + const box = root.getBoundingClientRect() + const k = box.width / root.offsetWidth || 1 + const x = box.left + at.x * k + const y = box.top + at.y * k + // Measure the element's own characters rather than hit-testing, so + // overlays (a rewrite, a highlight layer, the cursor) never get in the way + const chars: { left: number; right: number; mid: number; top: number; bottom: number; at: number }[] = [] + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) + const r = document.createRange() + let offset = 0 + // Text that can't be seen isn't selectable: a rewrite waiting at opacity 0 + // over the original would otherwise pull the caret onto its own lines + const hidden = (node: Node) => { + for (let p = node.parentElement; p; p = p === el ? null : p.parentElement) { + const cs = getComputedStyle(p) + if (cs.opacity === '0' || cs.visibility === 'hidden' || cs.display === 'none') return true + } + return false + } + for (let n = walk.nextNode(); n; n = walk.nextNode()) { + const text = n.textContent ?? '' + if (hidden(n)) continue + for (let c = 0; c < text.length; c++) { + r.setStart(n, c) + r.setEnd(n, c + 1) + const b = r.getBoundingClientRect() + if (b.width || b.height) chars.push({ left: b.left, right: b.right, mid: (b.left + b.right) / 2, top: b.top, bottom: b.bottom, at: offset + c }) + } + offset += text.length + } + if (!chars.length) return 0 + if (y < chars[0].top) return 0 + if (y > chars[chars.length - 1].bottom) return offset + // The visual line nearest the pointer, then the boundary nearest it on that line + let line = chars[0] + for (const ch of chars) if (Math.abs((ch.top + ch.bottom) / 2 - y) < Math.abs((line.top + line.bottom) / 2 - y)) line = ch + const row = chars.filter((ch) => Math.abs(ch.top - line.top) < (line.bottom - line.top) / 2) + const next = row.find((ch) => ch.mid > x) + return next ? next.at : row[row.length - 1].at + 1 + }, + async move(to, opts) { + check() + const from = { ...pos.current } + const end = resolve(to) + const dist = Math.hypot(end.x - from.x, end.y - from.y) + if (dist < 0.5) return hover(targetEl(to)) + // Leave hover only on what the cursor actually leaves: moving from a + // row onto a button inside it keeps the row hovered all the way + const dest = chain(targetEl(to)) + hover(chain(hovered.current).find((n) => dest.includes(n)) ?? null) + // A hand travels in a shallow arc, quicker over short hops + const ms = opts?.duration ?? clamp(260 + dist * 0.9, 300, 650) + const curve = easeInOutCubic + const bow = Math.min(dist * 0.08, 24) + const nx = -(end.y - from.y) / dist + const ny = (end.x - from.x) / dist + await tween(ms, (t) => { + const e = curve(t) + const lift = Math.sin(Math.PI * e) * bow + const p = opts?.path ? opts.path(e, from, end) : { x: from.x + (end.x - from.x) * e + nx * lift, y: from.y + (end.y - from.y) * e + ny * lift } + place(p) + auto(p) + if (opts?.onMove) flushSync(() => opts.onMove!(p, from, t)) + }) + check() + hover(targetEl(to)) + }, + async click(to, opts) { + check() + if (to !== undefined) { + await a.move(to) + await wait(120) + } + check() + const el = to !== undefined ? targetEl(to) : hovered.current + const cursorEl = cursorRef.current + const finger = cursor?.style === 'touch' + const release = own(() => { + cursorEl?.removeAttribute('data-pressed') + el?.removeAttribute('data-pressed') + if (finger) fingerGone(cursorEl) + }) + try { + // A finger is only there when it touches: it shows just before, and goes after it lifts + if (finger) { cursorEl?.setAttribute('data-touching', ''); await wait(140); check() } + for (let i = 0; i < (opts?.double ? 2 : 1); i++) { + if (i) await wait(70) + check() + cursorEl?.setAttribute('data-pressed', 'true') + if (cursor?.ripple) ripple(pos.current) + el?.setAttribute('data-pressed', opts?.right ? 'right' : '') + await wait(opts?.double ? 80 : 100) + check() + cursorEl?.removeAttribute('data-pressed') + el?.removeAttribute('data-pressed') + } + } finally { + release() + } + }, + async scroll(name, to, opts) { + check() + const el = targetEl(name) as HTMLElement | null + if (!el) return + const from = el.scrollTop + let end = typeof to === 'number' ? to : from + if (typeof to === 'string') { + const t = targetEl(to) as HTMLElement | null + if (t) end = from + (t.getBoundingClientRect().top - el.getBoundingClientRect().top) / drawnScale(rootRef.current, scaleRef.current) - (opts?.offset ?? 0) + } + end = clamp(end, 0, el.scrollHeight - el.clientHeight) + // You can only scroll what the pointer is over: a visible cursor + // outside the area moves into it first, then holds still while the + // content moves under it + if (cursor) { + const box = rectOf(rootRef.current!, el, scaleRef.current || 1) + const p = pos.current + const inside = p.x >= box.x + 8 && p.x <= box.x + box.width - 8 && p.y >= box.y + 8 && p.y <= box.y + box.height - 8 + if (!inside) { + await this.move({ x: box.x + box.width * 0.6, y: box.y + box.height * 0.55 }) + await this.wait(120) + } + } + // A Scroll's overlay bar shows while it moves and fades once it rests + const wrap = el.parentElement?.classList.contains('ss-scroller') ? el.parentElement : null + wrap?.setAttribute('data-scrolling', '') + const release = own(() => wrap?.removeAttribute('data-scrolling')) + try { + await tween(opts?.duration ?? clamp(420 + Math.abs(end - from) * 1.2, 500, 1400), (t) => { + el.scrollTop = from + (end - from) * easeInOutCubic(t) + el.toggleAttribute('data-scrolled', el.scrollTop > 0.5) + thumb(el) + }) + check() + } finally { + release() + } + remeasure() + autoRef.current?.(pos.current) + }, + async snap(patch, scope) { + check() + const el = scope ? rootRef.current?.querySelector(`[data-target="${scope}"]`) : rootRef.current + el?.setAttribute('data-instant', '') + const release = own(() => el?.removeAttribute('data-instant')) + try { + a.put(patch) + await frame() + await frame() + check() + } finally { + release() + } + }, + async key(combo) { + check() + const caps = capsOf(combo) + const id = ++keyId + const k = keysRef.current + setLastKeys(k.caps.length ? { ...k, show: false, down: false } : null) + setKeys({ id, caps, show: false, down: false }) + await frame() + await frame() + check() + setKeys({ id, caps, show: true, down: false }) + await wait(180) + check() + setKeys({ id, caps, show: true, down: true }) + await wait(140) + check() + setKeys({ id, caps, show: true, down: false }) + // The caps linger and fade without holding up the script. + void wait(600).then(() => { + if (keyId === id && !signal.aborted) setKeys({ id, caps, show: false, down: false }) + }, (error) => { + if (!(error instanceof Stopped)) console.error('[stageshow] script error', error) + }) + }, + async stagger(items, fn, gap = 60) { + await Promise.all( + items.map(async (item, i) => { + if (i) await wait(gap * i) + check() + await fn(item, i) + }), + ) + }, + mark(name) { + if (!signal.aborted) rootRef.current?.setAttribute('data-mark', name) + }, + async cursor(k) { + check() + setKind(k) + await frame() + }, + async type(key, text, opts) { + const interval = opts?.interval ?? 35 + for (let i = 0; i <= text.length; i++) { + check() + setState((s) => ({ ...s, [key]: text.slice(0, i) })) + // Uneven but repeatable rhythm, so every loop types the same way + await wait(interval * (0.7 + 0.6 * hash(i))) + } + }, + place(to) { + if (signal.aborted) return + const p = resolve(to) + place(p) + autoRef.current?.(p) + }, + async enter(to, opts) { + check() + const end = resolve(to) + // The frame: the stage, in stage px + const root = rootRef.current! + const f = { x: 0, y: 0, w: root.offsetWidth, h: root.offsetHeight } + // Out past the edge nearest the target, far enough that the hand is hidden + const out = 40 + const gaps = [end.x - f.x, f.x + f.w - end.x, end.y - f.y, f.y + f.h - end.y] + const side = gaps.indexOf(Math.min(...gaps)) + const from = side === 0 ? { x: f.x - out, y: end.y + f.h * 0.12 } : side === 1 ? { x: f.x + f.w + out * 0.4, y: end.y + f.h * 0.12 } : side === 2 ? { x: end.x - f.w * 0.06, y: f.y - out } : { x: end.x + f.w * 0.06, y: f.y + f.h + out * 0.4 } + place(from) + hover(null) + await tween(opts?.duration ?? 700, (t) => { + const e = entering(t) + const p = { x: from.x + (end.x - from.x) * e, y: from.y + (end.y - from.y) * e } + place(p) + auto(p) + }) + check() + hover(targetEl(to)) + }, + async drag(to, onMove, opts) { + check() + const from = { ...pos.current } + const end = resolve(to) + const dist = Math.hypot(end.x - from.x, end.y - from.y) + hover(null) + // The hand closes only while the button is held, as a real cursor's + // does: on press here, and back to what it was on release. Over text + // (I-beam) or a canvas (crosshair) a drag selects, so it keeps its kind. + const before = kindRef.current + const selects = before === 'ibeam' || before === 'crosshair' + const closesHand = !selects && opts?.cursor !== 'preserve' + // A finger swipes in one motion: it lands and is already moving, fast + // first and slowing as it goes. A mouse presses, then carries. + const finger = cursor?.style === 'touch' + // Gathers speed over the first quarter, then one speed to the lift, + // still moving when it leaves so the throw carries straight on + const fingerEase = (t: number) => { const a = 0.25, k = 1 / (1 - a / 2); return t < a ? (t * t) / (2 * a) * k : (t - a / 2) * k } + if (finger) { cursorRef.current?.setAttribute('data-touching', ''); await wait(140); check() } + // The trail: one streak from where the finger was a few frames ago to + // where it is now, turned along the motion and fading toward its tail + const streak = finger ? cursorRef.current?.querySelector('.ss-cursor__streak') ?? null : null + const trail: Point[] = [] + const drawTrail = (at: Point) => { + trail.unshift({ ...at }) + if (trail.length > 9) trail.pop() + const h = trail[trail.length - 1]! + const dx = at.x - h.x, dy = at.y - h.y + const len = Math.min(Math.hypot(dx, dy), 100) + if (!streak) return + streak.style.width = `${len}px` + streak.style.left = `${-len}px` + streak.style.transform = `rotate(${Math.atan2(dy, dx)}rad)` + } + dragging.current = true + const cursorEl = cursorRef.current + const release = own(() => { + dragging.current = false + cursorEl?.removeAttribute('data-pressed') + cursorEl?.removeAttribute('data-swiping') + if (finger) fingerGone(cursorEl) + }) + try { + // The press dips first and the hand closes at the bottom of it, so + // the change of shape sits inside the press + cursorEl?.setAttribute('data-pressed', 'true') + // A finger presses first, seen as a press, and then swipes + await wait(finger ? 170 : 60) + if (finger) { if (streak) streak.style.width = '0px'; cursorEl?.setAttribute('data-swiping', '') } + check() + if (closesHand && !finger) setKind('grab') + if (!finger) await wait(70) + check() + cursorEl?.removeAttribute('data-pressed') + // Carried by a hand: the same shallow arc and ease as a move, a little + // slower for the weight, never a ruler-straight slide + // A selection is precise: straight, so the caret never leaves the text + const bow = selects ? 0 : Math.min(dist * 0.06, 18) + const nx = dist ? -(end.y - from.y) / dist : 0 + const ny = dist ? (end.x - from.x) / dist : 0 + // A selection across lines drops into its last line first, then + // sweeps along it, as a hand does. A straight diagonal runs back past + // the anchor on the first line (selecting nothing, or backwards) and + // then fills everything at once as it crosses into the next. + const lines = selects && Math.abs(end.y - from.y) > 6 + // Ending left of where it started, no path reads right: the beam runs + // back along the first line or past the end on the last + if (lines && end.y > from.y && end.x < from.x - 2) warn('a text selection that ends left of where it starts overshoots and shrinks back; select text that starts a line, or fits on one') + const caretsBefore = carets + // A flick is brief, about a quarter second; the throw does the rest + await tween(finger ? (opts?.duration ?? clamp(180 + dist * 0.5, 240, 300)) : (opts?.duration ?? clamp(360 + dist * 1.1, 450, 900)), (t) => { + // A finger gathers speed for a few frames and then runs at one + // speed until it lifts, still moving: what it threw carries on at + // that speed and eases off. A finger that slows before it lifts + // makes the throw jolt; one that eases out early reads as a pause + const e = finger ? fingerEase(t) : easeInOutCubic(t) + const lift = Math.sin(Math.PI * e) * bow + // The drop: y arrives early, x mostly late (a curve through (from.x, end.y)) + const ey = lines ? 1 - (1 - e) * (1 - e) : e + const ex = lines ? e * e : e + const at = opts?.path?.(e, from, end) ?? { x: from.x + (end.x - from.x) * ex + nx * lift, y: from.y + (end.y - from.y) * ey + ny * lift } + // The cursor and whatever follows it (a card, a selection) paint in + // the same frame: render the scene now, not a frame behind + flushSync(() => onMove?.(at, from, t)) + place(at) + if (finger) drawTrail(at) + }) + // A selection grown by time or a word count drifts off the I-beam, + // ahead of it on one line and behind on the next + if (selects && onMove && carets === caretsBefore) warn('selection drift: a text selection drag never read a.caret(), so the highlight is not under the I-beam; set it from a.caret(name, at) in onMove') + check() + if (closesHand && !finger) setKind(before === 'grab' ? 'pointer' : before) + } finally { + release() + } + // What's under the cursor now decides (the thing it was over may have moved or gone) + autoRef.current?.(end) + }, + layout, + } + // A beat started without await (a move alongside other beats) rejects + // with Stopped when the stage stops; that is expected, so it must not + // surface as an unhandled rejection. Any other error is reported here, + // since nobody may be awaiting it. Awaited calls still see the rejection. + for (const k of Object.keys(a) as (keyof Act)[]) { + const fn = a[k] + if (typeof fn !== 'function') continue + ;(a as unknown as Record)[k] = (...args: unknown[]) => { + const out = (fn as (...x: unknown[]) => unknown).apply(a, args) + if (out instanceof Promise) + out.catch((e) => { + if (!(e instanceof Stopped)) console.error('[stageshow] script error', e) + }) + return out + } + } + return a +} + +// A loop that doesn't end where it began jumps on every repeat. +export function checkSeam(end: object, initial: object, at: Point, rest: Point | null) { + const changed = Object.keys(initial).filter( + (k) => JSON.stringify((end as Record)[k]) !== JSON.stringify((initial as Record)[k]), + ) + if (changed.length) warn(`loop seam: state still differs from initial at the end of the script (${changed.join(', ')})`) + if (rest && Math.hypot(at.x - rest.x, at.y - rest.y) > 1) warn('loop seam: cursor does not end where it starts') +} diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg new file mode 100644 index 0000000..b88d47c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg b/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg new file mode 100644 index 0000000..49b1596 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt new file mode 100644 index 0000000..70d4a7b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt @@ -0,0 +1,30 @@ +# CC0 1.0 Universal + +## Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following: + 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + 2. moral rights retained by the original author(s) and/or performer(s); + 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work; + 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below; + 5. rights protecting the extraction, dissemination, use and reuse of data in a Work; + 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose. + +4. Limitations and Disclaimers. + 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. + +For more information, please see . diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt new file mode 100644 index 0000000..5a24d8e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Pablo Hdez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md b/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md new file mode 100644 index 0000000..6f223db --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md @@ -0,0 +1,57 @@ +# Brand artwork sources + +These SVGs are local copies of the original artwork supplied by the following sources. Their colors and proportions are unchanged. Source revisions and hashes make each asset traceable. + +- [SVGL](https://github.com/pheralb/svgl), MIT. See `LICENSE-SVGL.txt`. +- [Linear brand assets](https://linear.app/brand): official light and dark logomarks, downloaded 25 October 2026. Subject to Linear’s published brand usage terms. +- [American Express](https://www.americanexpress.com/en-us/): the official blue-box asset from its versioned static asset package, downloaded 25 October 2026. Subject to American Express’s brand usage terms. +- [Circle brand kit](https://www.circle.com/pressroom): official USDC token logo, downloaded 25 October 2026. Subject to Circle’s brand usage policy. +- [Cryptocurrency Icons](https://github.com/spothq/cryptocurrency-icons), CC0 1.0. Flat Bitcoin and Ethereum artwork; see `LICENSE-CRYPTO-FLAT.txt`. +- [Payment icons](https://github.com/aaronfagan/svg-credit-card-payment-icons), Apache 2.0. See `LICENSE-PAYMENT-ICONS.txt`. +- [Simple Icons](https://github.com/simple-icons/simple-icons), CC0 1.0, for the bank marks (Chase, Wells Fargo, Bank of America, Barclays, HSBC, Monzo, Revolut), downloaded 27 October 2026, and Google Docs, downloaded 28 October 2026, all at commit `90646e4bf7f3`; each monochrome glyph is given the brand's own hex from the Simple Icons data as its fill. See `LICENSE-SIMPLE-ICONS.txt`. + +The asset repositories’ software licenses do not confer ownership of the brands. These marks identify the respective products; follow their brand guidelines for the intended use. + +| Asset | Source | SHA-256 | +|---|---|---| +| `visa.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/visa.svg) | `8d837bb887be5ac1a959c8bfc8874dc35d1571d5953efa560b1f317ed6f56bb4` | +| `mastercard.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/mastercard.svg) | `1c8f56fb410ec06e8467423cec0fb9fe54d6b18eba703591548d3159e744496b` | +| `discover.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/discover.svg) | `8ef8d5002bc7364c2468f24156a6cb5bd9321a4461f4fda5de5aa21cfc9a13ca` | +| `paypal.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/paypal.svg) | `be46ba3ede93ac6d513f00947d6aba825e450497b1a47a8bd408e90d6ee829c7` | +| `stripe.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/stripe.svg) | `d1dd829f330c3c17a02c7356e2e41f77f6ecad3f72a9b2bf11b649703c66e30a` | +| `notion.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/notion.svg) | `0d94471649fb9badd434394846079c279876fdd224cf49ed46b6c09fbd3a041f` | +| `gmail.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/gmail.svg) | `6e0a1db3ce3893a809c3293bf0780902cad270ed443bb118cfcbc40647e272ab` | +| `google-calendar.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-calendar.svg) | `643e0b6b34b278cf062bedcf798f894b7e8fa7b665793736664c5cf57f9f7873` | +| `1password.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/1password-light.svg) | `36ef31dc8e1ea5a2a7f4a1a53dc874c61170056bdacacd24ea4fa4c8cd050af8` | +| `1password-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/1password-dark.svg) | `ad593cc8ca4fb8e3ae066fdd4dd3d86cb9084cad8b9092275e3ecd27fb35cc65` | +| `figma.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/figma.svg) | `5bcdea3417293412845b8298fe357fce3b192e8ff112d9b3fef315bc5cd127f6` | +| `slack.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/slack.svg) | `29734796b3a85f9d0e03150d53142fab0b7f994ae80c7e3e1efd0bef52c12f5d` | +| `github.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/github_light.svg) | `64568845c83d5e840e6a1fb7af1939fed14709636427ce9be2fbcdc14ac5e021` | +| `github-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/github_dark.svg) | `83adf1cacb564250654ccf60caadd8aaf852d9de47936befb7cb61b674ace7e8` | +| `dropbox-glyph.svg` | The glyph from `dropbox.svg` below in Dropbox blue, with the square field removed | derived | +| `dropbox.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/dropbox.svg) | `ef6eeed72f74fcd89674afd62dcf451eaf48c360da516679b08c1e99a8ea0819` | +| `google-drive.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/drive.svg) | `2a0272583e3ea138ba5b809d9fa7d099b7b31e18221d5550c4e6f0d8866d4f84` | +| `google-sheets.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-sheets.svg) | `1e610a408605d9cfc1e90683ef25f360e7eaa6eb89c4c54dcab40a19b5c72562` | +| `google-slides.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-slides.svg) | `328414886ebfaeea95e460cdda3fd015c143c910399799654b0f73e1e9b1c558` | +| `google-meet.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-meet.svg) | `78740599528aa18810c9ab7e854fd1607679bccf3ee7a00a622f9dce2b5d8473` | +| `zoom.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/zoom.svg) | `5d1bf4d38bc5fc437578292dafc3cb4a678682e3124f1e2910d817aee9658615` | +| `vercel.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/vercel.svg) | `565f40e56abbd9c9f31e113c45e90ce456cc112d4811f2f49d1d5041fe501b7c` | +| `vercel-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/vercel_dark.svg) | `a9ae5ad4ea10c39122cc6b07927132f9c684dd2f1a7bef5337b0eb7364e139ae` | +| `shopify.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/shopify.svg) | `86e19afda708cead229b714d25de147ef0b920cfea807c5b2933b30d17a234db` | +| `linear.svg` | [Official source](https://static.linear.app/design-assets/Linear-Brand-Assets.zip?v=3) | `e683e95a5eb9b360ae06360c0350fd1b5fcf3d2222ef9de8e71fa0bc413dccc3` | +| `linear-dark.svg` | [Official source](https://static.linear.app/design-assets/Linear-Brand-Assets.zip?v=3) | `32f9883452188b39787f8242c7bfce723ded255f35f13254e727871d3dae2fd1` | +| `amex.svg` | [Official source](https://www.aexp-static.com/cdaas/one/statics/axp-static-assets/1.8.0/package/dist/img/logos/dls-logo-bluebox-solid.svg) | `028f643755987211bf2f3add6c62ae1870a888cf2f4fe3040a4fac7dce2543ab` | +| `solana.svg` | [Source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/sol.svg) | `d5ae3179116a3e643c131489e305cd71a6eb2f0d58e20f3c4189f741eed9ac91` | +| `usdc.svg` | [Source](https://6778953.fs1.hubspotusercontent-na1.net/hubfs/6778953/Pressroom/brandkit/logo-downloads/usdc.zip) | `fe4f9d5f34ef4ebeb5d80e1f5ff63dcaf5a0d5495f4adf206e4c3011d1f5c57d` | +| `tether.svg` | [Source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/tether.svg) | `14c4ce4de02e430893b51b6fd6c814d1c32a2001d5a0f8962304652f30773a17` | +| `amex-tile.svg` | [Official source](https://www.aexp-static.com/cdaas/one/statics/axp-static-assets/1.8.0/package/dist/img/logos/dls-logo-stack-white.svg) | `56b8e90244c34621e294d3357edfef9a1467e501773ed21b25dc6367ab3d7803` | +| `bitcoin.svg` | [Pinned source](https://raw.githubusercontent.com/spothq/cryptocurrency-icons/1a63530be6e374711a8554f31b17e4cb92c25fa5/svg/color/btc.svg) | `5a8131ecdf855b12cb56080aeeeefea266976529c45b2d58c284a13b7519f4ca` | +| `ethereum.svg` | [Pinned source](https://raw.githubusercontent.com/spothq/cryptocurrency-icons/1a63530be6e374711a8554f31b17e4cb92c25fa5/svg/color/eth.svg) | `1f94df8533f61806f7b17eaf9cd28678cdba66e1d82a9ca8f9fb38d35a907e9c` | +| `google-docs.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/googledocs.svg), brand hex #4285F4 applied as the fill | `880b39680b64812e5203f366c08779e86c824e3a6264a8eefca2a56afbf79fa6` | +| `chase.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/chase.svg), brand hex #117ACA applied as the fill | `1323e4a9d6bd891ca4d519fae8c74385240572c9968e750b2a753fe481a2a4a9` | +| `wellsfargo.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/wellsfargo.svg), brand hex #D71E28 applied as the fill | `20624a3e07aafd1e5b0805d36a74f077a3e3eb2c296fcc3ec5ae1b66e7adf94b` | +| `bankofamerica.svg` and `bankofamerica-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/bankofamerica.svg), brand hex #012169 applied as the fill (white on dark) | `4af97000b06a925cf8ce6359855de2fbc502c59edcb10d26302e231d86fb8a02` | +| `barclays.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/barclays.svg), brand hex #00AEEF applied as the fill | `25330b9967dcfbe89e77cee37bfd2c76ce7073173a303a045df32ab2012f1b42` | +| `hsbc.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/hsbc.svg), brand hex #DB0011 applied as the fill | `d0c04fb9e0cba69e3ad9ac896241200aa8beb8a16f3271715860c27c558006bf` | +| `monzo.svg` and `monzo-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/monzo.svg), brand hex #14233C applied as the fill (white on dark) | `91a20ce29789ac2e4bd6debd251c9f965eea0945a78f3e94e704e4cb0dd7cce0` | +| `revolut.svg` and `revolut-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/revolut.svg), brand hex #191C1F applied as the fill (white on dark) | `cfb3427243ad5a0315fa2b20ef3302d290a3cb55529c994485dd78ae7d4cb646` | diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg new file mode 100644 index 0000000..d4c4416 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg @@ -0,0 +1 @@ +Bank of America \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg new file mode 100644 index 0000000..123c151 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg @@ -0,0 +1 @@ +Bank of America \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg b/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg new file mode 100644 index 0000000..d59cdba --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg @@ -0,0 +1 @@ +Barclays \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg new file mode 100644 index 0000000..5dc8a39 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg b/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg new file mode 100644 index 0000000..36159d4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg @@ -0,0 +1 @@ +Chase \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg b/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg new file mode 100644 index 0000000..8b2bdcb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg @@ -0,0 +1,2 @@ + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg new file mode 100644 index 0000000..bb858da --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg @@ -0,0 +1 @@ + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg new file mode 100644 index 0000000..c4161d9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg b/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg new file mode 100644 index 0000000..c761cef --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg b/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg new file mode 100644 index 0000000..78a05a9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg new file mode 100644 index 0000000..c454d05 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/github.svg b/skills/imported/benjitaylor/stageshow/kit/brands/github.svg new file mode 100644 index 0000000..a808c6c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg b/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg new file mode 100644 index 0000000..fc19b56 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg new file mode 100644 index 0000000..1170be3 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg new file mode 100644 index 0000000..1207781 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg @@ -0,0 +1 @@ +Google Docs \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg new file mode 100644 index 0000000..a30564e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg new file mode 100644 index 0000000..2b9eff5 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg new file mode 100644 index 0000000..e2a924c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg new file mode 100644 index 0000000..0208da1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg b/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg new file mode 100644 index 0000000..b3456dd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg @@ -0,0 +1 @@ +HSBC \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/index.ts b/skills/imported/benjitaylor/stageshow/kit/brands/index.ts new file mode 100644 index 0000000..699537a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/index.ts @@ -0,0 +1,210 @@ +/** Local product artwork. No network lookup or runtime icon service. */ +export type BrandAsset = { + name: string + category: 'Payments' | 'Banks' | 'Apps' | 'Crypto' + light: string + dark: string + tileArtwork?: string + tileBackground?: string + proportion: number + scale: number +} + +export const brandMarks = { + 'visa': { + name: "Visa", category: 'Payments', + light: new URL('./visa.svg', import.meta.url).href, + dark: new URL('./visa.svg', import.meta.url).href, + proportion: 1.56, scale: 0.9, + }, + 'mastercard': { + name: "Mastercard", category: 'Payments', + light: new URL('./mastercard.svg', import.meta.url).href, + dark: new URL('./mastercard.svg', import.meta.url).href, + proportion: 1.56, scale: 0.9, + }, + 'discover': { + name: "Discover", category: 'Payments', + light: new URL('./discover.svg', import.meta.url).href, + dark: new URL('./discover.svg', import.meta.url).href, + proportion: 1.56, scale: 0.9, + }, + 'paypal': { + name: "PayPal", category: 'Payments', + light: new URL('./paypal.svg', import.meta.url).href, + dark: new URL('./paypal.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'stripe': { + name: "Stripe", category: 'Payments', + light: new URL('./stripe.svg', import.meta.url).href, + dark: new URL('./stripe.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'notion': { + name: "Notion", category: 'Apps', + light: new URL('./notion.svg', import.meta.url).href, + dark: new URL('./notion.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'gmail': { + name: "Gmail", category: 'Apps', + light: new URL('./gmail.svg', import.meta.url).href, + dark: new URL('./gmail.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'google-calendar': { + name: "Google Calendar", category: 'Apps', + light: new URL('./google-calendar.svg', import.meta.url).href, + dark: new URL('./google-calendar.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'google-docs': { + name: "Google Docs", category: 'Apps', + light: new URL('./google-docs.svg', import.meta.url).href, + dark: new URL('./google-docs.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + '1password': { + name: "1Password", category: 'Apps', + light: new URL('./1password.svg', import.meta.url).href, + dark: new URL('./1password-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'figma': { + name: "Figma", category: 'Apps', + light: new URL('./figma.svg', import.meta.url).href, + dark: new URL('./figma.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'slack': { + name: "Slack", category: 'Apps', + light: new URL('./slack.svg', import.meta.url).href, + dark: new URL('./slack.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'github': { + name: "GitHub", category: 'Apps', + light: new URL('./github.svg', import.meta.url).href, + dark: new URL('./github-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'dropbox': { + name: "Dropbox", category: 'Apps', + // The source artwork is the glyph on a square blue field; shown as the + // blue glyph alone, so it sits on a tile like the other app marks + light: new URL('./dropbox-glyph.svg', import.meta.url).href, + dark: new URL('./dropbox-glyph.svg', import.meta.url).href, + proportion: 1, scale: 0.72, + }, + 'google-drive': { + name: "Google Drive", category: 'Apps', + light: new URL('./google-drive.svg', import.meta.url).href, + dark: new URL('./google-drive.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'google-sheets': { + name: "Google Sheets", category: 'Apps', + light: new URL('./google-sheets.svg', import.meta.url).href, + dark: new URL('./google-sheets.svg', import.meta.url).href, + proportion: 1, scale: 0.74, + }, + 'google-slides': { + name: "Google Slides", category: 'Apps', + light: new URL('./google-slides.svg', import.meta.url).href, + dark: new URL('./google-slides.svg', import.meta.url).href, + proportion: 1, scale: 0.74, + }, + 'google-meet': { + name: "Google Meet", category: 'Apps', + light: new URL('./google-meet.svg', import.meta.url).href, + dark: new URL('./google-meet.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'zoom': { + name: "Zoom", category: 'Apps', + light: new URL('./zoom.svg', import.meta.url).href, + dark: new URL('./zoom.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'vercel': { + name: "Vercel", category: 'Apps', + light: new URL('./vercel.svg', import.meta.url).href, + dark: new URL('./vercel-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.64, + }, + 'shopify': { + name: "Shopify", category: 'Apps', + light: new URL('./shopify.svg', import.meta.url).href, + dark: new URL('./shopify.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'bitcoin': { + name: 'Bitcoin', category: 'Crypto', + light: new URL('./bitcoin.svg', import.meta.url).href, + dark: new URL('./bitcoin.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'ethereum': { + name: 'Ethereum', category: 'Crypto', + light: new URL('./ethereum.svg', import.meta.url).href, + dark: new URL('./ethereum.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'solana': { + name: 'Solana', category: 'Crypto', + light: new URL('./solana.svg', import.meta.url).href, + dark: new URL('./solana.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'tether': { + name: 'Tether', category: 'Crypto', + light: new URL('./tether.svg', import.meta.url).href, + dark: new URL('./tether.svg', import.meta.url).href, + proportion: 1, scale: 0.68, + }, + 'chase': { + name: "Chase", category: 'Banks', + light: new URL('./chase.svg', import.meta.url).href, + dark: new URL('./chase.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'wellsfargo': { + name: "Wells Fargo", category: 'Banks', + light: new URL('./wellsfargo.svg', import.meta.url).href, + dark: new URL('./wellsfargo.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'bankofamerica': { + name: "Bank of America", category: 'Banks', + light: new URL('./bankofamerica.svg', import.meta.url).href, + dark: new URL('./bankofamerica-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'barclays': { + name: "Barclays", category: 'Banks', + light: new URL('./barclays.svg', import.meta.url).href, + dark: new URL('./barclays.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'hsbc': { + name: "HSBC", category: 'Banks', + light: new URL('./hsbc.svg', import.meta.url).href, + dark: new URL('./hsbc.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'monzo': { + name: "Monzo", category: 'Banks', + light: new URL('./monzo.svg', import.meta.url).href, + dark: new URL('./monzo-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, + 'revolut': { + name: "Revolut", category: 'Banks', + light: new URL('./revolut.svg', import.meta.url).href, + dark: new URL('./revolut-dark.svg', import.meta.url).href, + proportion: 1, scale: 0.8, + }, +} as const satisfies Record + +export type BrandName = keyof typeof brandMarks diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg b/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg new file mode 100644 index 0000000..c93bd16 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg new file mode 100644 index 0000000..d4be9b6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg @@ -0,0 +1 @@ +Monzo \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg b/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg new file mode 100644 index 0000000..3967f94 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg @@ -0,0 +1 @@ +Monzo \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg b/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg new file mode 100644 index 0000000..fd68d14 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg b/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg new file mode 100644 index 0000000..cf14b63 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg new file mode 100644 index 0000000..273217b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg @@ -0,0 +1 @@ +Revolut \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg b/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg new file mode 100644 index 0000000..18e1acb --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg @@ -0,0 +1 @@ +Revolut \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg b/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg new file mode 100644 index 0000000..ea2677e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg b/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg new file mode 100644 index 0000000..6d14c72 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg b/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg new file mode 100644 index 0000000..7f2031d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg b/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg new file mode 100644 index 0000000..468b5bf --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg b/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg new file mode 100644 index 0000000..3ddfd2a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg new file mode 100644 index 0000000..7596891 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg b/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg new file mode 100644 index 0000000..611c021 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg b/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg new file mode 100644 index 0000000..7d65ee0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg @@ -0,0 +1,3 @@ + + + diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg b/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg new file mode 100644 index 0000000..8048dbc --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg @@ -0,0 +1 @@ +Wells Fargo \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg b/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg new file mode 100644 index 0000000..dd15a4a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/skills/imported/benjitaylor/stageshow/kit/chart.css b/skills/imported/benjitaylor/stageshow/kit/chart.css new file mode 100644 index 0000000..86c8048 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/chart.css @@ -0,0 +1,59 @@ +/* Chart (chart.tsx). */ + +@layer stageshow { + .ss-chart { + display: block; + overflow: visible; + } + + /* Draws in: a wipe over the line and its fill, led by the dot riding the + curve at the wipe's edge (Chart drives both on one timeline, through an SVG + clip rect in the chart's own units). Hiding fades the whole chart at once. */ + .ss-chart__draw { + opacity: 0; + transition: opacity 0.25s ease; + } + + .ss-chart[data-show] .ss-chart__draw { + opacity: 1; + transition: opacity 0.15s ease; + } + + .ss-chart__line { + fill: none; + stroke: var(--ss-accent); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + transition: d 0.8s cubic-bezier(0.45, 0, 0.2, 1); + } + + .ss-chart__area { + transition: d 0.8s cubic-bezier(0.45, 0, 0.2, 1); + } + + .ss-chart__dot { + fill: var(--ss-accent); + stroke: var(--ss-surface); + stroke-width: 2; + opacity: 0; + transform: scale(0.4); + transform-box: fill-box; + transform-origin: center; + transition: opacity 0.2s ease, transform 0.2s ease, cx 0.8s cubic-bezier(0.45, 0, 0.2, 1), cy 0.8s cubic-bezier(0.45, 0, 0.2, 1); + } + + .ss-chart[data-show] .ss-chart__dot { + opacity: 1; + transform: none; + } + + .ss-chart__bar { + fill: var(--ss-accent); + transition: y 0.7s cubic-bezier(0.25, 0.8, 0.25, 1), height 0.7s cubic-bezier(0.25, 0.8, 0.25, 1); + } + + .ss-chart[data-still] * { + transition: none !important; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/chart.tsx b/skills/imported/benjitaylor/stageshow/kit/chart.tsx new file mode 100644 index 0000000..a444ba6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/chart.tsx @@ -0,0 +1,221 @@ +import { useId, useLayoutEffect, useRef, type CSSProperties } from 'react' +import { useReducedMotion } from './reduced-motion' +import './chart.css' + +// Charts drawn the way product dashboards draw them: a smooth line with a +// soft area and an end dot, or rounded bars. They draw in when shown and +// ease between values when the data changes. + +export interface ChartProps { + data: number[] + kind?: 'line' | 'bars' + width: number + height: number + /** Draws in when it turns true. */ + show?: boolean + /** The soft fill under a line. */ + area?: boolean + /** A dot on the last point. */ + dot?: boolean + /** The value at the top of the chart. Defaults to the data's maximum. */ + max?: number + className?: string + style?: CSSProperties +} + +/** + * Where a Chart with these props draws each point, in its own pixels: a + * line's points (the end dot sits on the last), or the top center of each + * bar. For a tooltip, a marker or a hairline on a point, so nothing repeats + * the chart's maths. Add the chart's offset in its container. + */ +export function chartPoints({ data, kind = 'line', width, height, dot = true, max }: Pick) { + const top = max ?? Math.max(...data, 1) + const pad = kind === 'line' && dot ? 4 : 1 + const h = height - pad * 2 + if (kind === 'bars') { + const gap = Math.max(2, Math.round(width / data.length / 4)) + const bw = (width - gap * (data.length - 1)) / data.length + return data.map((v, i) => ({ x: i * (bw + gap) + bw / 2, y: height - Math.max(2, (v / top) * h) })) + } + return data.map((v, i) => ({ x: pad + (i / Math.max(1, data.length - 1)) * (width - pad * 2), y: pad + h - (v / top) * h })) +} + +/** A smooth path through the points (Catmull-Rom as cubic Béziers), never overshooting the ends. */ +function smooth(p: { x: number; y: number }[]) { + if (!p.length) return '' + let d = `M${p[0].x} ${p[0].y}` + for (let i = 0; i < p.length - 1; i++) { + const a = p[i - 1] ?? p[i] + const b = p[i] + const c = p[i + 1] + const e = p[i + 2] ?? c + d += `C${b.x + (c.x - a.x) / 6} ${b.y + (c.y - a.y) / 6} ${c.x - (e.x - b.x) / 6} ${c.y - (e.y - b.y) / 6} ${c.x} ${c.y}` + } + return d +} + +/** + * The same curve as smooth(), sampled at a fixed number of points. A path + * only eases into another with the same commands, so a line drawn from 8 + * points and one from 9 would swap in a frame while the dot traveled. + * Sampling keeps every line the same shape of path, whatever the data's length. + */ +const SAMPLES = 48 + +/** The draw-in's timing: the same curve the rest of the kit's travel uses. */ +const DRAW_MS = 1200 +function ease(t: number) { + // cubic-bezier(0.45, 0, 0.2, 1), solved for x by bisection + const b = (a: number, c: number, u: number) => 3 * a * u * (1 - u) ** 2 + 3 * c * u * u * (1 - u) + u ** 3 + let lo = 0 + let hi = 1 + for (let i = 0; i < 20; i++) { + const m = (lo + hi) / 2 + if (b(0.45, 0.2, m) < t) lo = m + else hi = m + } + return b(0, 1, (lo + hi) / 2) +} + +/** The curve's height at x, from its sampled points. */ +function yAt(p: { x: number; y: number }[], x: number) { + let i = 1 + while (i < p.length - 1 && p[i].x < x) i++ + const a = p[i - 1] + const c = p[i] + const t = c.x === a.x ? 0 : Math.max(0, Math.min(1, (x - a.x) / (c.x - a.x))) + return a.y + (c.y - a.y) * t +} + +function resample(p: { x: number; y: number }[]) { + if (p.length < 2) return p + const out: { x: number; y: number }[] = [] + for (let s = 0; s <= SAMPLES; s++) { + const u = (s / SAMPLES) * (p.length - 1) + const i = Math.min(Math.floor(u), p.length - 2) + const t = u - i + const a = p[i - 1] ?? p[i] + const b = p[i] + const c = p[i + 1] + const e = p[i + 2] ?? c + // The Bézier segment smooth() draws between b and c + const c1 = { x: b.x + (c.x - a.x) / 6, y: b.y + (c.y - a.y) / 6 } + const c2 = { x: c.x - (e.x - b.x) / 6, y: c.y - (e.y - b.y) / 6 } + const m = 1 - t + const k = (p0: number, p1: number, p2: number, p3: number) => m * m * m * p0 + 3 * m * m * t * p1 + 3 * m * t * t * p2 + t * t * t * p3 + out.push({ x: k(b.x, c1.x, c2.x, c.x), y: k(b.y, c1.y, c2.y, c.y) }) + } + return out +} + +/** + * A line or bar chart in a fixed box. A sparkline is a small line with no + * area or dot. Values change by easing the shape (the line morphs, bars grow), + * never by redrawing. + */ +export function Chart({ data, kind = 'line', width, height, show = true, area = true, dot = true, max, className, style }: ChartProps) { + const reduced = useReducedMotion() + const id = useId().replace(/:/g, '') + const drawRef = useRef(null) + const wipeRef = useRef(null) + const dotRef = useRef(null) + const samples = useRef<{ x: number; y: number }[]>([]) + const shown = useRef(false) + // Draw-in: the dot leads and the line follows it. One timeline moves the + // wipe's edge and puts the dot on the curve at that edge, every frame. + useLayoutEffect(() => { + const was = shown.current + const g = drawRef.current + shown.current = show && data.length > 1 && !!g + if (!show || was || !g) return + const c = dotRef.current + const p = samples.current + if (reduced || p.length < 2) return + const x0 = p[0].x + const x1 = p[p.length - 1].x + const wipe = wipeRef.current + if (!wipe) return + // The clip is a rect in the chart's own units, so its edge is exactly the dot's x + const at = (e: number) => { + const x = x0 + (x1 - x0) * e + wipe.setAttribute('width', `${x + 6}`) + if (c) { + c.style.setProperty('cx', `${x}px`) + c.style.setProperty('cy', `${yAt(p, x)}px`) + } + } + g.style.transition = 'opacity 0.15s ease' + if (c) { + c.style.transition = 'opacity 0.2s ease, transform 0.3s cubic-bezier(0.34, 1.4, 0.64, 1)' + c.style.opacity = '1' + c.style.transform = 'none' + } + at(0) + const t0 = performance.now() + let drawing = true + let raf = requestAnimationFrame(function step(now) { + const t = Math.min(1, (now - t0) / DRAW_MS) + at(ease(t)) + if (t < 1) raf = requestAnimationFrame(step) + else done() + }) + // Back to the stylesheet: the end state matches it, so nothing moves + const done = () => { + drawing = false + wipe.setAttribute('width', `${width + 12}`) + g.style.removeProperty('transition') + if (c) for (const k of ['cx', 'cy', 'transition', 'opacity', 'transform']) c.style.removeProperty(k) + } + return () => { + cancelAnimationFrame(raf) + // An interrupted setup (including StrictMode's rehearsal) can draw + // again. A completed draw does not replay when motion is restored. + if (drawing) shown.current = false + done() + } + }, [show, reduced, kind, width, data.length > 1]) // eslint-disable-line react-hooks/exhaustive-deps + const top = max ?? Math.max(...data, 1) + const pad = kind === 'line' && dot ? 4 : 1 + const h = height - pad * 2 + if (!data.length) return + if (kind === 'bars') { + const gap = Math.max(2, Math.round(width / data.length / 4)) + const bw = (width - gap * (data.length - 1)) / data.length + return ( + + {data.map((v, i) => { + const bh = Math.max(2, (v / top) * h) + return + })} + + ) + } + const pts = chartPoints({ data, kind, width, height, dot, max }) + const curve = resample(pts) + samples.current = curve + const line = smooth(curve) + const fill = `${line}L${pts[pts.length - 1].x} ${height}L${pts[0].x} ${height}Z` + const end = pts[pts.length - 1] + return ( + + + + + + + + + + + {/* One wipe reveals the line and its fill together, so the fill never + runs ahead of the line. d is also set as a style so a data change + eases the shape */} + + {area && pts.length > 1 && } + + + {dot && } + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/clock.ts b/skills/imported/benjitaylor/stageshow/kit/clock.ts new file mode 100644 index 0000000..e7cbb97 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/clock.ts @@ -0,0 +1,265 @@ +// The stage clock. Everything on a stage moves on the browser's own clocks: +// performance.now, requestAnimationFrame, setTimeout/setInterval, and CSS +// transitions and animations on the compositor. With nothing attached, that +// is all this file is: `clock` reads those same globals, so a stage plays in +// real time exactly as it always has. +// +// A recorder or the playground runs installVirtualClock() in the page +// before any of its code loads. From then on time only moves when the +// recorder steps it, by exactly 1/fps: timers fire in time order, one +// animation frame runs, and every CSS transition and animation is set to the +// new time. Live playback instead calls advance() with the native RAF's +// elapsed time, so its motion follows the display refresh rate. Each fixed +// frame of a video is then the same on every render, a +// timed beat lands on a known frame, and nothing drops or doubles. +// +// Chrome's own virtual time (Emulation.setVirtualTimePolicy) doesn't do this: +// it moves timers but runs no animation frames and leaves CSS animations +// where they are. + +/** What a recorder drives, at window.__stageshow.clock once installed. */ +export interface ClockControl { + readonly fps: number + /** Render updates since install; fixed frames when driven by step(). */ + readonly frame: number + /** Virtual ms, what performance.now() returns. */ + now(): number + /** + * Advance `frames` frames (1 by default). Per frame: timers due by the new + * time fire in order, each followed by the work it queued (React commits, + * promise chains); then one round of animation frames; then every CSS + * transition and animation is moved to the new time. + */ + step(frames?: number): Promise + /** Advance live playback by elapsed milliseconds, rendering one update. */ + advance(milliseconds: number): Promise + /** Let queued tasks run without moving time, and hold any CSS animation that started meanwhile. */ + settle(): Promise + /** Stage measurement callbacks, separate from motion frames. */ + requestLayoutFrame(callback: FrameRequestCallback): number + cancelLayoutFrame(id: number): void + readonly layoutPending: boolean + /** Run one round of layout callbacks without advancing time, timers or motion. */ + flushLayout(): Promise +} + +type Host = { __stageshow?: { clock?: ClockControl } } + +/** + * The clock the kit and its scenes read. Each call goes to the page's global + * at call time, so under a recorder it is the virtual clock and otherwise + * the real one. + */ +export const clock = { + now: (): number => performance.now(), + raf: (fn: FrameRequestCallback): number => requestAnimationFrame(fn), + cancelRaf: (id: number): void => cancelAnimationFrame(id), + // Layout must finish even when a preview is paused. Keep these callbacks + // separate: running arbitrary motion RAFs at a frozen timestamp can still + // advance a spring that integrates once per callback. + layoutRaf: (fn: FrameRequestCallback): number => clock.recording?.requestLayoutFrame(fn) ?? requestAnimationFrame(fn), + cancelLayoutRaf: (id: number): void => { + const recording = clock.recording + if (recording) recording.cancelLayoutFrame(id) + else cancelAnimationFrame(id) + }, + timeout: (fn: () => void, ms: number): number => window.setTimeout(fn, ms), + clearTimeout: (id: number): void => window.clearTimeout(id), + /** The recorder's control while one drives the page, else null. */ + get recording(): ClockControl | null { + return typeof window === 'undefined' ? null : ((window as unknown as Host).__stageshow?.clock ?? null) + }, +} + +/** + * Replace the page's clocks with a virtual one that only moves on step(). + * Self-contained (a recorder can inject its source as an init script), so it + * must not reference anything outside its own body. + * + * `epoch` is the wall-clock time Date reports at frame 0, so a clock drawn + * in a scene reads the same on every render. + */ +export function installVirtualClock(opts: { fps?: number; epoch?: number; onLayout?: () => void } = {}): ClockControl { + const w = window as any + if (w.__stageshow?.clock) return w.__stageshow.clock + const fps = opts.fps ?? 60 + const RealDate = Date + const epoch = opts.epoch ?? RealDate.now() + // Not 0: code that tests a timestamp for truthiness must still see one + const BASE = 1000 + let vt = BASE + let frame = 0 + let fixedAnchor = BASE + let fixedFrame = 0 + + type Timer = { id: number; at: number; seq: number; fn: unknown; args: unknown[]; every: number | null } + const timers = new Map() + let nextId = 1 + let seq = 0 + let frameCbs = new Map() + let layoutCbs = new Map() + + const arm = (fn: unknown, ms: unknown, args: unknown[], every: boolean) => { + const d = Math.max(0, Number(ms) || 0) + const id = nextId++ + timers.set(id, { id, at: vt + d, seq: seq++, fn, args, every: every ? Math.max(1, d) : null }) + return id + } + w.setTimeout = (fn: unknown, ms?: unknown, ...args: unknown[]) => arm(fn, ms, args, false) + w.setInterval = (fn: unknown, ms?: unknown, ...args: unknown[]) => arm(fn, ms, args, true) + w.clearTimeout = w.clearInterval = (id: number) => void timers.delete(id) + w.requestAnimationFrame = (fn: FrameRequestCallback) => { + const id = nextId++ + frameCbs.set(id, fn) + return id + } + w.cancelAnimationFrame = (id: number) => void frameCbs.delete(id) + performance.now = () => vt + const dateNow = () => Math.floor(epoch + (vt - BASE)) + const VDate = function (this: unknown, ...a: unknown[]) { + if (!new.target) return new RealDate(dateNow()).toString() + return a.length ? new (RealDate as any)(...a) : new RealDate(dateNow()) + } as unknown as DateConstructor + Object.assign(VDate, { prototype: RealDate.prototype, now: dateNow, parse: RealDate.parse, UTC: RealDate.UTC }) + w.Date = VDate + + // Queued work (React renders on a MessageChannel, effects after them, + // promise chains) runs between tasks; a few message round trips let it all + // land before time moves on. + const tick = () => + new Promise((r) => { + const c = new MessageChannel() + c.port1.onmessage = () => { + c.port1.close() + c.port2.close() + r() + } + c.port2.postMessage(0) + }) + // Anything that started meanwhile is held from here on (see scan below). + const settle = async () => { + for (let i = 0; i < 4; i++) await tick() + scan() + } + const run = (fn: unknown, args: unknown[]) => { + try { + // String callbacks are ignored: running them would need eval + if (typeof fn === 'function') fn(...args) + } catch (e) { + console.error(e) + } + } + + // CSS transitions and animations: each one is held (playback rate 0) from + // the moment it is first seen, and its own clock is moved by hand with the + // virtual one. One that turned paused in CSS stays put; one that reaches its + // end is finished, so transitionend and animationend still fire. + type Held = { local: number; rate: number; born: number } + const held = new WeakMap() + const live = new Set() + const scan = () => { + for (const a of document.getAnimations()) { + if (held.has(a) || a.playState === 'finished' || a.playState === 'idle') continue + held.set(a, { local: 0, rate: a.playbackRate || 1, born: vt }) + a.playbackRate = 0 + a.currentTime = 0 + live.add(a) + } + } + const moveAnimations = (from: number) => { + scan() + for (const a of Array.from(live)) { + const h = held.get(a)! + if (a.playState === 'idle' || a.playState === 'finished') { + live.delete(a) + continue + } + if (a.playState !== 'paused') h.local += (vt - Math.max(from, h.born)) * h.rate + const end = a.effect?.getComputedTiming().endTime + if (typeof end === 'number' && Number.isFinite(end) && h.local >= end) { + // Cancel a pending play task before restoring the rate. Otherwise a + // delayed transition can restart on the native timeline after finish(). + a.pause() + a.playbackRate = h.rate + a.finish() + live.delete(a) + } else { + a.currentTime = h.local + } + } + } + + const stepTo = async (to: number) => { + const from = vt + await settle() + // Timers due by `to`, in time order, each with the work it queued. A + // hair of slack, so a wait summed from frame-sized steps can't miss its + // frame by a rounding error + for (let guard = 0; guard < 10000; guard++) { + let next: Timer | null = null + for (const t of Array.from(timers.values())) if (t.at <= to + 1e-6 && (!next || t.at < next.at || (t.at === next.at && t.seq < next.seq))) next = t + if (!next) break + vt = next.at + if (next.every === null) timers.delete(next.id) + else { + next.at += next.every + next.seq = seq++ + } + run(next.fn, next.args) + await settle() + } + vt = to + frame++ + const cbs = frameCbs + const layout = layoutCbs + frameCbs = new Map() + layoutCbs = new Map() + for (const fn of Array.from(layout.values())) run(fn, [vt]) + for (const fn of Array.from(cbs.values())) run(fn, [vt]) + await settle() + moveAnimations(from) + } + + const control: ClockControl = { + fps, + get frame() { + return frame + }, + now: () => vt, + async step(n = 1) { + // Keep recorder timestamps exact, without accumulating rounded deltas. + for (let i = 0; i < n; i++) await stepTo(fixedAnchor + (++fixedFrame * 1000) / fps) + }, + async advance(milliseconds) { + if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Clock advance must be a finite, non-negative duration') + if (milliseconds === 0) return + await stepTo(vt + milliseconds) + // A manual fixed step after live playback starts at the current time. + fixedAnchor = vt + fixedFrame = 0 + }, + settle, + requestLayoutFrame(callback) { + const empty = layoutCbs.size === 0 + const id = nextId++ + layoutCbs.set(id, callback) + // A paused React/ResizeObserver commit can enqueue measurement work + // without changing any observed DOM attributes. Wake its host directly. + if (empty) opts.onLayout?.() + return id + }, + cancelLayoutFrame: (id) => void layoutCbs.delete(id), + get layoutPending() { return layoutCbs.size > 0 }, + async flushLayout() { + await settle() + // Snapshot once: a nested layout frame belongs to the next native + // paint, rather than an unbounded drain in this task. + const callbacks = layoutCbs + layoutCbs = new Map() + for (const fn of Array.from(callbacks.values())) run(fn, [vt]) + await settle() + }, + } + w.__stageshow = { ...(w.__stageshow ?? {}), clock: control } + return control +} diff --git a/skills/imported/benjitaylor/stageshow/kit/code.css b/skills/imported/benjitaylor/stageshow/kit/code.css new file mode 100644 index 0000000..cfbf5ba --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/code.css @@ -0,0 +1,87 @@ +/* Code (code.tsx). */ + +@layer stageshow { + .ss-code { + --ss-code-key: #7c3aed; + --ss-code-str: #15803d; + --ss-code-num: #b45309; + --ss-code-fn: #2563eb; + font: 11.5px/19px ui-monospace, 'SF Mono', Menlo, monospace; + color: var(--ss-text-strong); + } + + .ss-stage[data-theme="dark"] .ss-code { + --ss-code-key: #c4b5fd; + --ss-code-str: #86efac; + --ss-code-num: #fdba74; + --ss-code-fn: #93c5fd; + } + + .ss-stage[data-palette="mono"] .ss-code, + .ss-stage[data-theme="blueprint"] .ss-code { + --ss-code-key: var(--ss-text-strong); + --ss-code-str: var(--ss-text); + --ss-code-num: var(--ss-text-strong); + --ss-code-fn: var(--ss-text-strong); + } + + /* Lines not yet shown are laid out but hidden, so streaming never reflows */ + .ss-code__line { + display: flex; + padding-right: 12px; + opacity: 0; + transition: opacity 0.3s ease; + } + + .ss-code__line[data-on] { + opacity: 1; + } + + .ss-code__num { + flex-shrink: 0; + width: 30px; + padding-right: 10px; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--ss-text-faint); + } + + /* Wide enough that the + or − stands clear of the code after it */ + .ss-code__mark { + flex-shrink: 0; + width: 16px; + color: var(--ss-text-faint); + } + + .ss-code__text { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: pre; + } + + /* A line longer than the block fades at its right edge, never cut hard; + the diff tint behind it still runs the full width */ + .ss-code__text[data-over] { + mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent); + } + + .ss-code__line[data-kind="add"] { background: var(--ss-success-soft); } + + .ss-code__line[data-kind="add"] .ss-code__mark { color: var(--ss-success-text); } + + .ss-code__line[data-kind="del"] { background: var(--ss-danger-soft); } + + .ss-code__line[data-kind="del"] .ss-code__mark { color: var(--ss-danger-text); } + + .ss-code__key { color: var(--ss-code-key); } + + .ss-code__str { color: var(--ss-code-str); } + + .ss-code__num + .ss-code__mark + .ss-code__text .ss-code__num, + .ss-code__text .ss-code__num { width: auto; padding: 0; text-align: inherit; color: var(--ss-code-num); } + + .ss-code__fn { color: var(--ss-code-fn); } + + .ss-code__com { color: var(--ss-text-faint); } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/code.tsx b/skills/imported/benjitaylor/stageshow/kit/code.tsx new file mode 100644 index 0000000..37ef44d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/code.tsx @@ -0,0 +1,70 @@ +import { useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react' +import './code.css' + +// Code as the subject: real lines in a monospace block with line numbers, +// quiet syntax color, and diff lines that read at a glance. + +export interface CodeLine { + text: string + /** add: a green line with a +. del: a red line with a −. */ + kind?: 'add' | 'del' + /** false leaves this line's number blank (a wrapped continuation, a fold). */ + number?: boolean +} + +const KEYWORDS = /^(const|let|var|function|return|if|else|await|async|import|from|export|default|new|true|false|null|undefined|for|of|in|while|try|catch|throw|class|extends|type|interface|def|self|None|True|False)$/ + +/** A light tokenizer: comments, strings, numbers, keywords, function calls. */ +function tokens(line: string): ReactNode[] { + const out: ReactNode[] = [] + const re = /(\/\/.*$|#.*$)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b\d[\d._]*\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)/g + let last = 0 + let m: RegExpExecArray | null + let k = 0 + while ((m = re.exec(line))) { + if (m.index > last) out.push(line.slice(last, m.index)) + const [text, com, str, num, call, word] = m + const kind = com ? 'com' : str ? 'str' : num ? 'num' : call ? (KEYWORDS.test(call) ? 'key' : 'fn') : word && KEYWORDS.test(word) ? 'key' : null + out.push(kind ? {text} : text) + last = m.index + text.length + } + if (last < line.length) out.push(line.slice(last)) + return out +} + +/** + * Lines of code. `count` shows only the first n lines, the rest laid out but + * hidden, so a block can stream in (a build log, a generated function) without + * anything reflowing. Diff lines get a tint and a + or − in the gutter. + */ +export function Code({ lines, start = 1, numbers = true, count, highlight = true, className, style }: { lines: (CodeLine | string)[]; start?: number; numbers?: boolean; count?: number; highlight?: boolean; className?: string; style?: CSSProperties }) { + let n = start + // Only lines that really run past the block fade at the edge + const ref = useRef(null) + useLayoutEffect(() => { + const mark = () => + ref.current?.querySelectorAll('.ss-code__text').forEach((t) => { + t.toggleAttribute('data-over', t.scrollWidth > t.clientWidth + 1) + }) + mark() + const ro = new ResizeObserver(mark) + if (ref.current) ro.observe(ref.current) + return () => ro.disconnect() + }) + return ( +
+ {lines.map((raw, i) => { + const line = typeof raw === 'string' ? { text: raw } : raw + // A removed line keeps the old number; the line after it reuses it + const num = line.kind === 'del' || line.number === false ? n : n++ + return ( +
+ {numbers && {line.kind === 'del' || line.number === false ? '' : num}} + {line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ''} + {highlight ? tokens(line.text) : line.text} +
+ ) + })} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css new file mode 100644 index 0000000..5d00dc6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css @@ -0,0 +1,56 @@ +/* Badge */ + +@layer stageshow { + /* Small boxes with text keep a layer of their own. Pushed by a row opening + above them, or carried along by anything that moves by layout, their text + would step whole pixels while the fill glides, and hop about inside it */ + .ss-badge { + will-change: transform; + } + + .ss-badge { + display: inline-flex; + align-items: center; + gap: 5px; + height: 20px; + padding: 0 9px; + border-radius: var(--ss-radius-pill); + background: var(--ss-ink-2); + font-size: 11px; + font-weight: 500; + color: var(--ss-text-strong); + white-space: nowrap; + /* A tone change (Working to Ready) eases on the same pill, no swap */ + transition: background-color 0.35s ease, color 0.35s ease; + } + + /* A round mark before the words reads as further in than text does, so + its side is 2px tighter: the dot and the last letter then sit the same + distance from their ends */ + .ss-badge[data-lead] { + padding-left: 7px; + } + + .ss-badge[data-tone="accent"] { background: var(--ss-accent-soft); color: var(--ss-accent-text); } + + .ss-badge[data-tone="success"] { background: var(--ss-success-soft); color: var(--ss-success-text); } + + .ss-badge[data-tone="danger"] { background: var(--ss-danger-soft); color: var(--ss-danger-text); } + + .ss-badge[data-tone="warning"] { background: var(--ss-warning-soft); color: var(--ss-warning-text); } + + /* The label's line box trimmed to cap height and baseline, so the words + center on their own ink; a plain line box keeps descender room and sits + the text about 2px low in a 20px pill */ + .ss-badge__label { + display: block; + text-box: trim-both cap alphabetic; + } + + .ss-badge__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx new file mode 100644 index 0000000..e7fde34 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx @@ -0,0 +1,17 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import './Badge.css' + +/** + * A small status pill. `dot` adds a status dot before the label; `icon` puts + * a glyph there instead (a tick on Matched), beside the label rather than in + * it, so the label's trimmed box still centers the words on their ink. + */ +export function Badge({ tone = 'neutral', dot, icon, children, className, ...rest }: HTMLAttributes & { tone?: 'neutral' | 'accent' | 'success' | 'danger' | 'warning'; dot?: boolean; icon?: ReactNode }) { + return ( + + {dot && } + {icon} + {children} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Field.css b/skills/imported/benjitaylor/stageshow/kit/controls/Field.css new file mode 100644 index 0000000..9fe2ef1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Field.css @@ -0,0 +1,201 @@ +/* Field */ + +@layer stageshow { + /* The box never grows past the width it was given: a chip opening, or any + content, clips inside the text area instead of pushing the trailing + control and the box edge out into whatever sits next to the field */ + .ss-field { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + } + + .ss-field__label { + font-size: 11.5px; + font-weight: 500; + color: var(--ss-text-strong); + } + + /* The ring is a shadow, so focusing never moves the text */ + .ss-field__box { + display: flex; + align-items: center; + gap: 8px; + height: var(--ss-control); + padding: 0 11px; + border-radius: var(--ss-radius-md); + background: var(--ss-surface); + box-shadow: inset 0 0 0 1px var(--ss-ink-4), 0 0 0 0 transparent; + font-size: 13px; + color: var(--ss-text-strong); + transition: box-shadow 0.2s ease, padding 0.35s cubic-bezier(0.45, 0, 0.1, 1); + } + + /* A chip sits the same distance from every edge of the field: the box gives + up its left padding to the chip's own, at the same pace, so the text + inside never moves while the chip opens around it */ + .ss-field__box:has(.ss-field__value[data-chip]) { + padding-left: calc((var(--ss-control) - 24px) / 2); + } + + .ss-field[data-focused] .ss-field__box { + box-shadow: inset 0 0 0 1px var(--ss-accent), 0 0 0 3px var(--ss-accent-soft); + } + + .ss-field[data-status="error"] .ss-field__box { + box-shadow: inset 0 0 0 1px var(--ss-danger), 0 0 0 3px var(--ss-danger-soft); + } + + .ss-field__text { + position: relative; + display: flex; + flex: 1; + align-items: center; + min-width: 0; + height: 100%; + overflow: hidden; + white-space: pre; + } + + /* Leaves the moment the first character lands; fades back when emptied */ + .ss-field__placeholder { + position: absolute; + top: 50%; + left: 0; + color: var(--ss-text-faint); + translate: 0 -50%; + transition: opacity 0.2s ease; + } + + .ss-field[data-filled] .ss-field__placeholder { + opacity: 0; + transition: none; + } + + .ss-field__value[data-password] { + letter-spacing: 1.5px; + } + + /* Committed as a chip in place: the text never swaps or changes weight; the + surface fades in around it, the padding opens it out, and the x grows in + beside it. Cleared, the whole chip fades holding its text. */ + .ss-field__value { + display: inline-flex; + flex: none; + align-items: center; + height: 24px; + padding: 0; + border-radius: var(--ss-radius-pill); + background-color: transparent; + /* Released once it has faded: out of sight, so nothing needs to move */ + transition: none; + } + + /* Centered on the cap height, not the line box (which reads low) or the + x-height (which reads high once the words have ascenders: an email, a + name). Cap to baseline is what a real text field centers. The + placeholder is centered the same way, so they share a line. */ + .ss-field__chars, + .ss-field__placeholder { + line-height: 1; + text-box: trim-both cap alphabetic; + } + + .ss-field__chars { + display: block; + } + + .ss-field__value[data-chip] { + padding: 0 calc(11px - (var(--ss-control) - 24px) / 2); + background-color: var(--ss-chip-bg, var(--ss-accent-soft)); + color: var(--ss-chip-text, var(--ss-text-strong)); + transition: + padding 0.35s cubic-bezier(0.45, 0, 0.1, 1), + background-color 0.3s ease, + color 0.3s ease; + } + + .ss-field__value[data-chip][data-removable] { + padding-right: 4px; + } + + .ss-field__value[data-leaving] { + opacity: 0; + transition: opacity 0.25s ease; + } + + /* The placeholder waits for a cleared chip to finish fading, never crossing it */ + .ss-field:has(.ss-field__value[data-leaving]) .ss-field__placeholder { + opacity: 0; + transition: none; + } + + .ss-field__x { + display: inline-grid; + flex: none; + place-items: center; + /* Always whole: it slides out from under the text's end by margin, so its + icon is never sliced while the chip opens */ + width: 16px; + height: 16px; + margin-left: -16px; + border-radius: 50%; + color: inherit; + opacity: 0; + transition: background-color 0.15s ease; + } + + .ss-field__value[data-chip] .ss-field__x { + margin-left: 4px; + opacity: 0.75; + transition: + margin 0.35s cubic-bezier(0.45, 0, 0.1, 1), + opacity 0.25s ease 0.12s, + background-color 0.15s ease; + } + + .ss-field__x[data-hover] { + opacity: 1; + background-color: var(--ss-ink-2); + transition-duration: 0s; + } + + .ss-field__value[data-chip] + .ss-field__caret { + margin-left: 6px; + } + + /* Solid while typing (each keystroke remounts it), blinking once it rests */ + .ss-field__caret { + flex-shrink: 0; + transition: margin 0.35s cubic-bezier(0.45, 0, 0.1, 1); + width: 1.5px; + height: 16px; + margin-left: 1px; + border-radius: 1px; + background: var(--ss-accent); + animation: ss-caret 1.06s steps(1) 0.5s infinite; + } + + @keyframes ss-caret { + 50% { opacity: 0; } + } + + .ss-field__ok { + flex-shrink: 0; + } + + .ss-field__ok path { + fill: none; + stroke: var(--ss-success); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 1; + /* Drawn at rest, so a still (animations off) still shows it */ + animation: ss-draw 0.35s cubic-bezier(0.65, 0, 0.35, 1) both; + } + + @keyframes ss-draw { + from { stroke-dashoffset: 1; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx new file mode 100644 index 0000000..b152e60 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx @@ -0,0 +1,94 @@ +import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react' +import { Icon } from '../parts/Icon' +import './Field.css' + +export interface FieldProps extends Omit, 'children'> { + label?: ReactNode + /** The typed value. Drive it with act.type(). */ + value: string + placeholder?: string + /** Focus ring and a blinking caret. */ + focused?: boolean + /** Dots instead of the value. */ + password?: boolean + /** valid: a check at the end and a calm ring. error: the ring turns danger. */ + status?: 'valid' | 'error' + /** The typed value committed as a chip, in place (a filter on Enter): the + * text stays where it was typed while a surface grows around it. Clear the + * value and the chip together; the chip fades out holding its text. */ + chip?: boolean + /** Gives the chip a remove ×, with this data-target for the cursor. */ + remove?: string + leading?: ReactNode + trailing?: ReactNode + /** Props for the input box itself, the thing to click: data-target, data-cursor. */ + box?: HTMLAttributes & Record<`data-${string}`, unknown> +} + +/** + * A text field: label, box, value and caret. The focus ring fades in without + * moving the text, the placeholder leaves the moment the first character + * lands (and fades back when emptied), and the caret sits solid while typing, + * blinking only once it rests. Put data-target on `box`, the part a cursor clicks. + */ +export function Field({ label, value, placeholder, focused, password, status, chip, remove, leading, trailing, box, className, style, ...rest }: FieldProps) { + // A cleared chip keeps its text while it fades, then lets go + // (decided while rendering, so not one frame passes without it) + const held = useRef('') + const text = useRef(null) + const [, release] = useState(0) + if (chip || value) held.current = chip ? value : '' + const let_go = () => { + if (!held.current) return + held.current = '' + release((n) => n + 1) + } + const out = !chip && !value && !!held.current + // Keep the text only while its fade is visible. A stage reset can disable + // the transition before it starts, or cancel it halfway through. + useLayoutEffect(() => { + const el = text.current + if (!out || !el) return + const check = () => { + if (Number(getComputedStyle(el).opacity) === 0) let_go() + } + const settle = (event: TransitionEvent) => { + if (event.target === el && event.propertyName === 'opacity') check() + } + el.addEventListener('transitionend', settle) + el.addEventListener('transitioncancel', settle) + check() + return () => { + el.removeEventListener('transitionend', settle) + el.removeEventListener('transitioncancel', settle) + } + }, [out]) + const shown = out ? held.current : value + return ( +
+ {label && {label}} +
+ {leading} + + {placeholder && {placeholder}} + + {password ? '•'.repeat(shown.length) : shown} + {remove !== undefined && ( + + + + )} + + {/* Keyed on the value, so every keystroke restarts the blink from solid */} + {focused && } + + {status === 'valid' && ( + + + + )} + {trailing} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.css b/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.css new file mode 100644 index 0000000..5035e9c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.css @@ -0,0 +1,40 @@ +/* Segmented */ + +@layer stageshow { + .ss-seg { + position: relative; + display: grid; + grid-template-columns: repeat(var(--n), 1fr); + padding: 2px; + border-radius: var(--ss-radius-md); + background: var(--ss-ink-2); + } + + .ss-seg__thumb { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: calc((100% - 4px) / var(--n)); + border-radius: var(--ss-radius-sm); + background: var(--ss-raised); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.06); + transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-seg__option { + position: relative; + display: grid; + place-items: center; + height: var(--ss-row); + padding: 0 10px; + font-size: 12px; + font-weight: 500; + color: var(--ss-text); + transition: color 0.2s ease; + } + + .ss-seg__option[data-on] { + color: var(--ss-text-strong); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.tsx new file mode 100644 index 0000000..4c10251 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Segmented.tsx @@ -0,0 +1,21 @@ +import type { CSSProperties, ReactNode } from 'react' +import type { CursorKind } from '../cursor' +import './Segmented.css' + +/** + * A segmented control: equal segments, one raised thumb that slides to the + * chosen one. `targets` names each segment for the cursor; `cursor` is the + * pointer it shows over them (hand, pointer). + */ +export function Segmented({ items, value, targets, cursor, className, style, ...rest }: { items: ReactNode[]; value: number; targets?: (string | undefined)[]; cursor?: CursorKind; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + return ( +
+ + {items.map((o, i) => ( + + {o} + + ))} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css new file mode 100644 index 0000000..d2cadf8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css @@ -0,0 +1,39 @@ +/* Slot */ + +@layer stageshow { + .ss-slot { + flex-shrink: 0; + border-radius: var(--ss-radius-md); + transition: height 0.45s cubic-bezier(0.45, 0, 0.2, 1), margin-top 0.45s cubic-bezier(0.45, 0, 0.2, 1); + } + + /* The dashed mark is drawn at the slot's full height and only fades: a + dashed border on a box whose height animates re-lays its dashes every + frame, and they flicker. It fades in as the slot finishes opening (the + clip only guards the last few pixels), and leaves first when it closes. */ + .ss-slot { + position: relative; + } + + .ss-slot__clip { + position: absolute; + inset: 0; + overflow: clip; + border-radius: inherit; + pointer-events: none; + } + + .ss-slot__mark { + position: absolute; + inset: 0 0 auto; + border: 1.5px dashed var(--ss-ink-5); + border-radius: inherit; + opacity: 0; + transition: opacity 0.15s ease-out; + } + + .ss-slot[data-open][data-dashed] .ss-slot__mark { + opacity: 1; + transition: opacity 0.16s ease-out 0.3s; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx new file mode 100644 index 0000000..e77f6fe --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx @@ -0,0 +1,25 @@ +import type { HTMLAttributes } from 'react' +import './Slot.css' + +/** + * A gap in a list that opens and closes, for moving a card between lists: the + * source's slot closes behind the card as it's picked up, the destination's + * opens (dashed) where it will land, and the card drops into it. `gap` is the + * list's own gap, so a closed slot takes no space at all. + */ +export function Slot({ open, height, gap = 0, dashed, className, style, children, ...rest }: HTMLAttributes & { open: boolean; height: number; gap?: number; dashed?: boolean }) { + return ( +
+
+
+
+ {children} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css new file mode 100644 index 0000000..1dbe40c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css @@ -0,0 +1,33 @@ +/* Tabs */ + +@layer stageshow { + .ss-tabs { + position: relative; + display: flex; + gap: 18px; + border-bottom: 1px solid var(--ss-line); + } + + .ss-tabs__tab { + padding-bottom: 9px; + font-size: 12.5px; + font-weight: 500; + color: var(--ss-text); + transition: color 0.2s ease; + } + + .ss-tabs__tab[data-on] { + color: var(--ss-text-strong); + } + + .ss-tabs__bar { + position: absolute; + bottom: -1px; + left: 0; + width: 1px; + height: 2px; + background: var(--ss-text-strong); + transform-origin: 0 0; + transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx new file mode 100644 index 0000000..b79bcd9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx @@ -0,0 +1,26 @@ +import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import './Tabs.css' + +/** + * A row of tabs with an underline that slides to the active one, measured, + * so tabs can be any width. `targets` names each tab for the cursor. + */ +export function Tabs({ items, value, targets, className, style, ...rest }: { items: ReactNode[]; value: number; targets?: (string | undefined)[]; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const row = useRef(null) + const [bar, setBar] = useState({ x: 0, w: 0 }) + useLayoutEffect(() => { + const el = row.current?.children[value + 1] as HTMLElement | undefined + if (el) setBar({ x: el.offsetLeft, w: el.offsetWidth }) + }, [value, items.length]) + return ( +
+ {/* A 1px line scaled to the tab's width: it slides on transform alone */} + + {items.map((t, i) => ( + + {t} + + ))} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css new file mode 100644 index 0000000..daff3e4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css @@ -0,0 +1,46 @@ +/* Toggle */ + +@layer stageshow { + .ss-toggle { + position: relative; + display: inline-block; + flex-shrink: 0; + width: 30px; + height: 18px; + border-radius: 9px; + background: var(--ss-ink-5); + transition: background-color 0.2s ease; + } + + .ss-toggle[data-on] { + background: var(--ss-accent); + } + + .ss-toggle__knob { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + /* Light and tight: a heavy shadow grays the track beside the knob, so + the accent reads as see-through */ + box-shadow: 0 0.5px 1.5px rgba(0, 0, 0, 0.14), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-toggle[data-on] .ss-toggle__knob { + transform: translateX(12px); + } + + .ss-stage[data-palette="mono"][data-theme="dark"] .ss-toggle[data-on] .ss-toggle__knob { + background: #111; + } + + /* On blueprint the accent is white, so the knob takes the ground */ + .ss-stage[data-theme="blueprint"] .ss-toggle[data-on] .ss-toggle__knob { + background: var(--ss-well); + box-shadow: none; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx new file mode 100644 index 0000000..a952ac4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx @@ -0,0 +1,11 @@ +import type { HTMLAttributes } from 'react' +import './Toggle.css' + +/** A switch. The knob slides and the track takes the accent when on. */ +export function Toggle({ on, className, ...rest }: HTMLAttributes & { on: boolean }) { + return ( + + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/index.ts b/skills/imported/benjitaylor/stageshow/kit/controls/index.ts new file mode 100644 index 0000000..ded02d5 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/controls/index.ts @@ -0,0 +1,11 @@ +// Form controls every product demo needs, with the motion done once: a text +// field that types with a caret, a segmented control and tabs whose +// highlight slides, a switch, a badge, and a slot that opens a gap in a list. +// One file per part, each importing its own CSS. +export { Field } from './Field' +export type { FieldProps } from './Field' +export { Segmented } from './Segmented' +export { Toggle } from './Toggle' +export { Tabs } from './Tabs' +export { Badge } from './Badge' +export { Slot } from './Slot' diff --git a/skills/imported/benjitaylor/stageshow/kit/cursor.css b/skills/imported/benjitaylor/stageshow/kit/cursor.css new file mode 100644 index 0000000..896640c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/cursor.css @@ -0,0 +1,256 @@ +/* The cursor (cursor.tsx): glyphs, the five styles, press, idle, ripples. */ + +@layer stageshow { + .ss-cursor { + position: absolute; + top: 0; + left: 0; + z-index: 100; + pointer-events: none; + will-change: transform; + transition: opacity 0.25s ease; + } + + /* Sat still too long while the script plays: it steps out of the shot, and + comes back the moment it moves */ + .ss-cursor[data-idle] { + opacity: 0; + transition-duration: 0.6s; + } + + .ss-cursor__press { + display: grid; + /* The glyphs' hotspots meet at 0,0; pressing keeps that contact fixed. */ + transform-origin: 0 0; + /* Lets go softly, as a finger lifting */ + transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-cursor[data-pressed="true"] .ss-cursor__press { + transform: scale(0.9); + transition: transform 0.08s ease-out; + } + + .ss-cursor__glyph { + grid-area: 1 / 1; + transform: scale(0); + opacity: 0; + transition: transform 0.2s ease, opacity 0.2s ease; + } + + .ss-cursor__glyph[data-active="true"] { + transform: scale(var(--ss-cursor-size, 1)); + opacity: 1; + } + + /* Into and out of a resize cursor, the arrow and the triangles cross over + rather than pop: neither shrinks to nothing, and the ease is softer */ + .ss-cursor__glyph[data-glyph^="resize"] { + transform: scale(calc(var(--ss-cursor-size, 1) * 0.5)); + } + + .ss-cursor[data-kind^="resize"] .ss-cursor__glyph:not([data-active="true"]) { + transform: scale(calc(var(--ss-cursor-size, 1) * 0.6)); + } + + .ss-cursor[data-kind^="resize"] .ss-cursor__glyph, + .ss-cursor__glyph[data-glyph^="resize"] { + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease; + } + + .ss-cursor__glyph[data-glyph^="resize"][data-active="true"] { + transform: scale(var(--ss-cursor-size, 1)); + } + + /* What it was leaves quickly, so no ghost of it trails the new shape */ + .ss-cursor[data-kind^="resize"] .ss-cursor__glyph:not([data-active="true"]), + .ss-cursor:not([data-kind^="resize"]) .ss-cursor__glyph[data-glyph^="resize"] { + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.1s ease; + } + + /* The open and closed hand are one hand: between them it closes and opens in + place, and the press dip shows the grip, never one hand fading out as the + other grows in */ + .ss-cursor:is([data-kind="open"], [data-kind="grab"]) .ss-cursor__glyph:is([data-glyph="open"], [data-glyph="grab"]) { + transform: scale(var(--ss-cursor-size, 1)); + transition: opacity 0.08s linear; + } + + .ss-cursor[data-style="large"] { + --ss-cursor-size: 1.5; + } + + /* The I-beam marks a text line, so it stays near the text's size */ + .ss-cursor[data-style="large"] .ss-cursor__glyph[data-glyph="ibeam"] { + --ss-cursor-size: 1.15; + } + + /* Gestural: a soft dot, no cursor kinds. Shrinks on press, fills while held. */ + .ss-cursor__dot { + display: block; + width: 22px; + height: 22px; + margin: -11px 0 0 -11px; + /* Glass, like the iPadOS pointer: no outline, a translucent tint that + blurs and lifts what's under it, a soft top highlight and a faint shadow. + The blur goes while pressed (below): the dot then sits on the thing + being pressed (a checkbox, a row's words) and a blur smears it */ + border-radius: 50%; + background: rgba(118, 118, 128, 0.26); + backdrop-filter: blur(5px) saturate(1.8) brightness(1.04); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.4), + inset 0 -1px 1px rgba(0, 0, 0, 0.05), + 0 2px 10px rgba(0, 0, 0, 0.1); + transition: background 0.15s ease; + } + + /* Over text the glass circle narrows into an I-beam bar, as the iPad pointer does */ + .ss-cursor__dot { + transition: background 0.15s ease, width 0.2s cubic-bezier(0.22, 1, 0.36, 1), height 0.2s cubic-bezier(0.22, 1, 0.36, 1), + margin 0.2s cubic-bezier(0.22, 1, 0.36, 1), border-radius 0.2s ease; + } + + .ss-cursor[data-style="circle"][data-kind="ibeam"] .ss-cursor__dot { + width: 4px; + height: 20px; + margin: -10px 0 0 -2px; + border-radius: 2px; + background: rgba(60, 60, 67, 0.55); + } + + .ss-stage[data-theme='dark'] .ss-cursor__dot { + background: rgba(255, 255, 255, 0.2); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.3), + 0 2px 10px rgba(0, 0, 0, 0.3); + } + + .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__press { + transform: scale(0.8); + } + + .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot { + backdrop-filter: none; + background: rgba(118, 118, 128, 0.22); + } + + .ss-stage[data-theme='dark'] .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot { + background: rgba(255, 255, 255, 0.18); + } + + .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot, + .ss-cursor[data-style="circle"][data-kind="grab"] .ss-cursor__dot { + background: rgba(118, 118, 128, 0.4); + } + + /* Touch: nothing between taps. A press lands a soft dot that fades as the + finger lifts; a drag keeps it while held (the kind is grab then). */ + .ss-cursor[data-style="touch"] .ss-cursor__dot { + width: 34px; + height: 34px; + margin: -17px 0 0 -17px; + background: rgba(118, 118, 128, 0.14); + /* A finger, not glass: no backdrop blur, which smears the checkbox or + row it presses */ + backdrop-filter: none; + /* Not there until it touches: the script shows it just before a press + or a swipe and lets it go after the lift */ + opacity: 0; + transform: scale(0.7); + transition: opacity 0.4s ease, transform 0.45s cubic-bezier(0.22, 1, 0.36, 1); + } + + /* The trail a finger leaves while it swipes: one soft streak from where + it was a few frames ago to where it is, fading toward its tail. The + script sizes and turns it each frame; it fades once the finger lifts */ + .ss-cursor__streak { + position: absolute; + left: 0; + top: -13px; + width: 0; + height: 26px; + border-radius: 13px; + background: linear-gradient(to right, rgba(118, 118, 128, 0), rgba(118, 118, 128, 0.16)); + transform-origin: 100% 50%; + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease; + } + + .ss-cursor[data-swiping] .ss-cursor__streak { + opacity: 1; + transition: none; + } + + .ss-cursor[data-style="touch"][data-touching] .ss-cursor__dot { + opacity: 1; + transform: scale(1); + transition: opacity 0.14s ease, transform 0.16s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-cursor[data-style="touch"][data-pressed="true"] .ss-cursor__dot, + .ss-cursor[data-style="touch"][data-swiping] .ss-cursor__dot { + background: rgba(118, 118, 128, 0.22); + transform: scale(0.82); + transition: background 0.08s ease, transform 0.1s ease-out; + } + + .ss-cursor__glyph svg { + display: block; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); + } + + /* Arrow cursor: one solid, rounded arrowhead */ + .ss-cursor__arrow path { + fill: var(--ss-cursor-ink, #111); + stroke: var(--ss-cursor-ink, #111); + stroke-width: 2.4; + stroke-linejoin: round; + } + + .ss-cursor__arrow { + display: block; + filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.18)); + } + + .ss-stage[data-theme="dark"] .ss-cursor__arrow, + .ss-stage[data-theme="blueprint"] .ss-cursor__arrow { + --ss-cursor-ink: #fff; + } + + /* Line cursors (I-beam, crosshair) invert on a dark surface, as macOS's do */ + .ss-stage[data-theme="dark"] .ss-cursor, + .ss-stage[data-theme="blueprint"] .ss-cursor { + --ss-cursor-line: #fff; + --ss-cursor-edge: rgba(0, 0, 0, 0.75); + } + + /* Ripple clicks: a ring spreads from the point and fades */ + .ss-ripple { + position: absolute; + z-index: 99; + width: 36px; + height: 36px; + margin: -18px 0 0 -18px; + border-radius: 50%; + border: 1.5px solid var(--ss-accent); + background: color-mix(in srgb, var(--ss-accent) 10%, transparent); + pointer-events: none; + animation: ss-ripple 0.55s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; + } + + @keyframes ss-ripple { + from { transform: scale(0.2); opacity: 1; } + to { transform: scale(1); opacity: 0; } + } + + .ss-stage[data-palette="mono"] .ss-ripple { + border-color: var(--ss-text-strong); + background: transparent; + } + + .ss-stage[data-hit] :is(.ss-cursor, .ss-cursor *) { + pointer-events: none !important; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/cursor.tsx b/skills/imported/benjitaylor/stageshow/kit/cursor.tsx new file mode 100644 index 0000000..ffd3f99 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/cursor.tsx @@ -0,0 +1,103 @@ +import type { ReactNode, Ref } from 'react' +import { MacCrosshair, MacGrab, MacIbeam, MacOpenHand, MacPointer, MacPointingHand, MacResizeEW, MacResizeNESW, MacResizeNS, MacResizeNWSE, MacWait } from './cursors' +import type { Point } from './measure' +import './cursor.css' + +// The cursor a stage draws: the glyph for each kind, where its hotspot sits, +// the five styles, and the ripple a click can send out. + +/** pointer: the arrow. hand: the pointing hand, for links and buttons. open: the + * open hand over something draggable. grab: the closed hand while it's held. + * resize-ns, resize-ew: over an edge that drags. resize-nwse, resize-nesw: + * over a corner (top-left or bottom-right, and top-right or bottom-left). */ +export type CursorKind = 'pointer' | 'ibeam' | 'crosshair' | 'hand' | 'open' | 'grab' | 'wait' | 'resize-ns' | 'resize-ew' | 'resize-nwse' | 'resize-nesw' +/** + * real: the macOS pointer. large: the same, 1.5x, for sites whose demos run + * small. circle: the iPad's glass pointer, always showing. touch: a finger on + * a phone, shown only where it touches (a soft dot that lands on press, + * stays while a drag is held, and fades), invisible between taps. arrow: a + * solid, rounded arrowhead, as illustrated product demos draw the pointer; it + * stays an arrow over links and while dragging, and turns to an I-beam only + * over text. + */ +export type CursorStyle = 'real' | 'large' | 'circle' | 'touch' | 'arrow' + +const GLYPHS: Record ReactNode> = { + pointer: MacPointer, + ibeam: MacIbeam, + crosshair: MacCrosshair, + hand: MacPointingHand, + open: MacOpenHand, + grab: MacGrab, + wait: MacWait, + 'resize-ns': MacResizeNS, + 'resize-ew': MacResizeEW, + 'resize-nwse': MacResizeNWSE, + 'resize-nesw': MacResizeNESW, +} + +// Where each glyph's hotspot sits inside its box, so the point is the click. +const HOTSPOT: Record = { + pointer: { x: 7.5, y: 5.25 }, + ibeam: { x: 5, y: 8 }, + crosshair: { x: 8.5, y: 8.5 }, + hand: { x: 9.75, y: 3.25 }, + open: { x: 11.7, y: 10.4 }, + grab: { x: 12, y: 12 }, + wait: { x: 8, y: 8 }, + 'resize-ns': { x: 10.5, y: 10.5 }, + 'resize-ew': { x: 10.5, y: 10.5 }, + 'resize-nwse': { x: 10.5, y: 10.5 }, + 'resize-nesw': { x: 10.5, y: 10.5 }, +} + +// The arrow style's pointer: one solid, rounded arrowhead, tip at the hotspot +const ARROW_HOTSPOT: Point = { x: 4, y: 3 } +function SolidArrow() { + return ( + + + + ) +} + +/** The cursor element. The stage moves it (a transform on cursorRef) and marks it pressed or idle. */ +export function Cursor({ cursorRef, style = 'real', kind }: { cursorRef: Ref; style?: CursorStyle; kind: CursorKind }) { + return ( +
+ {style === 'touch' && } +
+ {(style === 'circle' || style === 'touch') && } + {style !== 'circle' && style !== 'touch' && (Object.keys(GLYPHS) as CursorKind[]).map((k) => { + const arrow = style === 'arrow' + // The arrow style has two glyphs: the arrow, and the I-beam over text + if (arrow && k !== 'pointer' && k !== 'ibeam') return null + const Glyph = arrow && k === 'pointer' ? SolidArrow : GLYPHS[k] + const hot = arrow && k === 'pointer' ? ARROW_HOTSPOT : HOTSPOT[k] + const shown = arrow && kind !== 'ibeam' ? 'pointer' : kind + return ( +
+ +
+ ) + })} +
+
+ ) +} + +/** A ring that spreads from the click point and fades, drawn under the cursor. */ +export function ripple(root: HTMLElement, at: Point) { + const r = document.createElement('span') + r.className = 'ss-ripple' + r.style.left = `${at.x}px` + r.style.top = `${at.y}px` + root.appendChild(r) + r.addEventListener('animationend', () => r.remove()) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/cursors.tsx b/skills/imported/benjitaylor/stageshow/kit/cursors.tsx new file mode 100644 index 0000000..8dd5b8d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/cursors.tsx @@ -0,0 +1,106 @@ +// macOS system cursors. Hotspots are listed in HOTSPOT in stage.tsx. +// The hands draw a little larger than the arrow, as they do on macOS. + +export function MacPointer() { + return ( + + + + + + + ) +} + +export function MacIbeam() { + return ( + + {/* A thin edge in the opposite tone, as macOS draws it, so it reads on any surface */} + + + + ) +} + +export function MacCrosshair() { + return ( + + + + + ) +} + +// The pointing hand, for links and buttons: a white glove, index finger up, +// the other three fingers folded, thumb along the left. Hotspot at the fingertip. +export function MacPointingHand() { + return ( + + + + + ) +} + +// The open hand: over something that can be dragged, before it's pressed. +export function MacOpenHand() { + return ( + + + + + + + + + + + + ) +} + +export function MacGrab() { + return ( + + + + + + + ) +} + +export function MacWait() { + return ( + + + + + ) +} + +// The resize cursors, measured from macOS's own: two rounded black +// triangles, a 6pt gap between them, each with a 1pt white edge. The pair +// points north-south, and turns for east-west and the two diagonals. Drawn +// at the pointer's scale (a 28pt box at 21px), hotspot at the center. +const RESIZE_TIP = 'M0-8.4 2.55-3.65H-2.55ZM0 8.4 2.55 3.65H-2.55Z' +function MacResize({ turn }: { turn: number }) { + return ( + + + + + + + ) +} +export const MacResizeNS = () => +export const MacResizeEW = () => +export const MacResizeNWSE = () => +export const MacResizeNESW = () => diff --git a/skills/imported/benjitaylor/stageshow/kit/ease.ts b/skills/imported/benjitaylor/stageshow/kit/ease.ts new file mode 100644 index 0000000..38c4e72 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/ease.ts @@ -0,0 +1,65 @@ +// Named curves, as numbers (for tweens on the clock) and as CSS. The video +// ones are measured from product launch films: one house curve for moves +// inside a shot, a steeper one for moves that end in a cut, and an expo-out +// settle for the shot that picks the move up. + +export type Bezier = readonly [number, number, number, number] + +export const EASE = { + /** Moves inside a shot (push-in, pan, a long cursor trip): peak at about 40%, long soft tail. 1.4 to 2.7 s. */ + house: [0.65, 0, 0.1, 1], + /** The outgoing half of a move that ends in a cut: accelerates into it. */ + through: [0.8, 0, 0.1, 1], + /** The incoming half after a cut: arrives at speed, settles. 0.6 to 0.8 s (1.6 s for a big pull-out). */ + settle: [0.05, 0.85, 0.3, 1], + /** A cursor arriving from off-frame, already moving. */ + enter: [0.1, 0.8, 0.3, 0.95], + /** One long cursor trip across the frame. */ + trip: [0.78, 0, 0.17, 1], + /** The kit's camera push-in (Camera). */ + camera: [0.45, 0, 0.1, 1], +} as const satisfies Record + +export type EaseName = keyof typeof EASE + +export const css = (e: EaseName | Bezier) => { + const [a, b, c, d] = typeof e === 'string' ? EASE[e] : e + return `cubic-bezier(${a}, ${b}, ${c}, ${d})` +} + +/** cubic-bezier(x1, y1, x2, y2) as a function of time 0..1, as CSS works it out. */ +export function bezier(e: EaseName | Bezier): (t: number) => number { + const [x1, y1, x2, y2] = typeof e === 'string' ? EASE[e] : e + const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx + const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by + const x = (u: number) => ((ax * u + bx) * u + cx) * u + const dx = (u: number) => (3 * ax * u + 2 * bx) * u + cx + return (t: number) => { + if (t <= 0) return 0 + if (t >= 1) return 1 + // Newton, falling back to bisection where the slope is flat + let u = t + for (let i = 0; i < 8; i++) { + const d = dx(u) + if (Math.abs(d) < 1e-6) break + const next = u - (x(u) - t) / d + if (next < 0 || next > 1) break + u = next + } + if (Math.abs(x(u) - t) > 1e-6) { + let lo = 0, hi = 1 + for (let i = 0; i < 40; i++) { + u = (lo + hi) / 2 + if (x(u) < t) lo = u + else hi = u + } + } + return ((ay * u + by) * u + cy) * u + } +} + +/** The curve's slope as it starts (progress per unit time at t = 0). */ +export const startSlope = (e: EaseName | Bezier) => { + const [x1, y1] = typeof e === 'string' ? EASE[e] : e + return x1 > 0 ? y1 / x1 : 1e3 +} diff --git a/skills/imported/benjitaylor/stageshow/kit/index.ts b/skills/imported/benjitaylor/stageshow/kit/index.ts new file mode 100644 index 0000000..c5bad77 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/index.ts @@ -0,0 +1,45 @@ +// Components import their own CSS, so no separate style import is required. +// Which unused styles are removed depends on the host bundler. +// kit/stageshow.css explicitly loads every kit style up front. + +// The stage: the canvas, the script, the cursor, measuring and playback +export { Stage } from './stage' +export { useLook } from './looks' +export { useReducedMotion } from './reduced-motion' +export { inset, box, between, contains } from './measure' +export type { StageProps, Play, LookTokens } from './stage' +export type { Act, Script } from './act' +export type { Point, Rect, Target } from './measure' +export type { CursorKind, CursorStyle } from './cursor' +export type { Theme, Palette, Fidelity, Layout } from './looks' +export type { KeysAt } from './keys' +export { clock, installVirtualClock } from './clock' +export type { ClockControl } from './clock' + +// Frames: windows, devices and system surfaces, from real geometry +export { Window, Bar, Text, Prompt, Caret } from './window' +export type { WindowProps, BarProps, TextProps, PromptProps } from './window' +export { Phone, LockScreen, Notification, WALLPAPER } from './phone' +export type { PhoneProps, PhoneFrame, LockScreenProps, NotificationProps } from './phone' +export { Keyboard } from './ios' +export type { KeyboardProps } from './ios' +export { Thread, Bubble, Stamp, Receipt } from './messages' +export type { ThreadProps, BubbleProps } from './messages' + +// Motion: behaviors that have to hold up frame by frame (motion/, one file per part) +export { Connector, Count, Roll, Swap, Collapse, Fly, Follow, cellOf, States, Stream, Popover, Camera, Scroll, Expand } from './motion' +export type { ConnectorProps, CountProps, FollowProps, FlyProps, PopoverOrigin } from './motion' +export { EASE, bezier, css as easeCss } from './ease' +export type { EaseName, Bezier } from './ease' + +// Small parts: starting points with quiet defaults; restyle them as the product does (parts/, controls/) +export { Avatar, Check, Icon, Spinner, Toast, Tooltip, Meter, Menu, Facepile, Chip, BrandMark, Pin, Row } from './parts' +export type { AvatarProps, IconName, BrandMarkProps, PinProps, RowProps } from './parts' +export { brandMarks } from './brands' +export type { BrandName, BrandAsset } from './brands' +export { Field, Segmented, Toggle, Tabs, Badge, Slot } from './controls' +export type { FieldProps } from './controls' +export { Code } from './code' +export type { CodeLine } from './code' +export { Chart, chartPoints } from './chart' +export type { ChartProps } from './chart' diff --git a/skills/imported/benjitaylor/stageshow/kit/ios.css b/skills/imported/benjitaylor/stageshow/kit/ios.css new file mode 100644 index 0000000..124915f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/ios.css @@ -0,0 +1,177 @@ +/* The iOS keyboard (ios.tsx). */ + +@layer stageshow { + /* ---- iOS keyboard (ios.tsx) ---- + iOS 27, at true points inside a Phone. Measured from iOS 27 screenshots: + keys 31.8 × 41.6 with 5.8 gaps on a 52.6 row pitch, special keys the + letters' color, a 123 / space / return row of 88 / 182 / 88. */ + .ss-kb { + --ss-kb-sheet: #e3e4e9; + --ss-kb-key: #fff; + --ss-kb-down: #c4c6cd; + --ss-kb-ink: #000; + --ss-kb-faint: #3c3c43; + /* Nearly the screen's full width: 3pt in from each edge */ + position: relative; + flex: none; + margin: 0 3px 4px; + padding: 0 3px 0; + border-radius: 28px 28px 52px 52px; + background: color-mix(in srgb, var(--ss-kb-sheet) 94%, transparent); + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.7), 0 -0.5px 0 rgba(0, 0, 0, 0.06); + color: var(--ss-kb-ink); + font-family: -apple-system, 'SF Pro Text', system-ui, sans-serif; + user-select: none; + } + + .ss-kb[data-dark] { + --ss-kb-sheet: #2a2a2c; + --ss-kb-key: #4a4a4c; + --ss-kb-down: #6b6b6e; + --ss-kb-ink: #fff; + --ss-kb-faint: #ebebf5; + box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.1); + } + + .ss-kb__suggest { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + height: 50px; + font-size: 17px; + letter-spacing: -0.01em; + text-align: center; + } + + .ss-kb__suggest span + span { + border-left: 1px solid color-mix(in srgb, var(--ss-kb-faint) 22%, transparent); + } + + .ss-kb__row { + /* One key: ten keys and nine gaps fill the row, on every row */ + --k: calc((100% - 54px) / 10); + display: flex; + justify-content: center; + gap: 6px; + height: 42.5px; + margin-bottom: 11px; + } + + .ss-kb__key { + position: relative; + display: grid; + flex: none; + place-items: center; + width: var(--k); + border-radius: 8.5px; + background: var(--ss-kb-key); + font-size: 23px; + line-height: 1; + transition: background-color 0.12s ease; + } + + .ss-kb[data-dark] .ss-kb__key { + font-weight: 400; + } + + .ss-kb__key[data-down] { + background: var(--ss-kb-down); + transition: none; + } + + /* Letters show their callout instead of darkening */ + .ss-kb__row:not(.ss-kb__row--bottom) .ss-kb__key:not(.ss-kb__key--wide)[data-down] { + background: var(--ss-kb-key); + } + + .ss-kb__key--wide { + width: calc(var(--k) * 1.36); + } + + .ss-kb__row[data-row="2"] > .ss-kb__key--wide:first-child { + margin-right: 7.2px; + } + + .ss-kb__row[data-row="2"] > .ss-kb__key--wide:last-child { + margin-left: 7.2px; + } + + .ss-kb__key--mode { + width: calc(var(--k) * 2.78); + font-size: 17px; + } + + .ss-kb__key--space { + flex: 1; + width: auto; + } + + /* The magnified letter that rises out of a pressed key */ + .ss-kb__callout { + position: absolute; + bottom: 30px; + left: 50%; + display: grid; + place-items: center; + width: 145%; + height: 54px; + padding-bottom: 6px; + border-radius: 12px; + background: var(--ss-kb-key); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16); + font-size: 34px; + transform: translateX(-50%); + pointer-events: none; + z-index: 2; + } + + /* Joins the balloon to its key, over the balloon's shadow */ + .ss-kb__callout::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + /* The key's own width (the balloon is 145% of it) */ + width: 69%; + height: 14px; + margin-top: -4px; + background: var(--ss-kb-key); + transform: translateX(-50%); + } + + .ss-kb__row:first-of-type .ss-kb__key:first-child .ss-kb__callout, + .ss-kb__row[data-row="0"] .ss-kb__key:first-child .ss-kb__callout { + left: 0; + transform: none; + } + + .ss-kb__row[data-row="0"] .ss-kb__key:first-child .ss-kb__callout::after { + left: 0; + transform: none; + } + + .ss-kb__row[data-row="0"] .ss-kb__key:last-child .ss-kb__callout { + left: auto; + right: 0; + transform: none; + } + + .ss-kb__row[data-row="0"] .ss-kb__key:last-child .ss-kb__callout::after { + left: auto; + right: 0; + transform: none; + } + + .ss-kb__row--bottom { + margin-bottom: 0; + } + + .ss-kb__below { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; + padding: 0 22px 6px; + color: var(--ss-kb-faint); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/ios.tsx b/skills/imported/benjitaylor/stageshow/kit/ios.tsx new file mode 100644 index 0000000..22a4ed8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/ios.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import './ios.css' + +// The iOS 27 keyboard, at true points inside a Phone: the Liquid Glass sheet +// with its predictive bar, softly rounded keys with wide gaps, special keys +// the same color as the letters, a ↵ return key, and the emoji and dictation +// buttons below the keys. Measured from iOS 27 screenshots. + +const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'] + +export interface KeyboardProps { + /** + * The text typed so far. Each new character pops its key, and shift follows + * iOS's auto-capitalisation (on at the start and after a sentence ends). + */ + typed?: string + /** Force shift on or off instead of following the text. */ + shift?: boolean + /** The three words in the predictive bar. */ + suggestions?: [string, string, string] + /** A key held down: a letter, or 'shift', 'delete', 'return', 'space', '123'. */ + pressed?: string | null + dark?: boolean + className?: string + style?: CSSProperties +} + +function Glyph({ name }: { name: 'shift' | 'shift-on' | 'delete' | 'return' | 'emoji' | 'mic' }) { + const common = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const } + switch (name) { + case 'shift': + case 'shift-on': + return ( + + + + ) + case 'delete': + return ( + + + + + ) + case 'return': + return ( + + + + + ) + case 'emoji': + return ( + + + + + + + ) + case 'mic': + return ( + + + + + ) + } +} + +/** + * The iOS keyboard. Put it at the bottom of a Phone's screen, under the + * composer; pass `typed` and it pops each key as the text grows. + */ +export function Keyboard({ typed = '', shift, suggestions = ['I', 'The', 'I’m'], pressed, dark, className, style }: KeyboardProps) { + // The key that just landed, shown in its callout for a moment + const [pop, setPop] = useState<{ ch: string; n: number } | null>(null) + const last = useRef(typed) + useEffect(() => { + const was = last.current + last.current = typed + if (typed.length <= was.length || !typed.startsWith(was)) return + const ch = typed[typed.length - 1].toLowerCase() + setPop((p) => ({ ch, n: (p?.n ?? 0) + 1 })) + const id = setTimeout(() => setPop(null), 140) + return () => clearTimeout(id) + }, [typed]) + const auto = typed === '' || /[.!?]\s$/.test(typed) + const upper = shift ?? auto + const down = (k: string) => pressed === k || (k.length === 1 && pop?.ch === k) + return ( +
+
+ {suggestions.map((w, i) => ( + {w} + ))} +
+ {ROWS.map((row, r) => ( +
+ {r === 2 && ( + + + + )} + {row.split('').map((k) => ( + + {upper ? k.toUpperCase() : k} + {pop?.ch === k && ( + + {upper ? k.toUpperCase() : k} + + )} + + ))} + {r === 2 && ( + + + + )} +
+ ))} +
+ + 123 + + + + + +
+
+ + +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/keys.css b/skills/imported/benjitaylor/stageshow/kit/keys.css new file mode 100644 index 0000000..a6b39e9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/keys.css @@ -0,0 +1,60 @@ +/* Keystrokes (act.key): the keycap rows. */ + +@layer stageshow { + .ss-keys { + position: absolute; + bottom: 16px; + left: 50%; + z-index: 99; + display: flex; + gap: 4px; + opacity: 0; + transform: translate(-50%, 6px) scale(0.96); + transition: opacity 0.16s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + } + + .ss-keys[data-show="true"] { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + + .ss-keys[data-at="top"] { + top: 16px; + bottom: auto; + } + + .ss-keys[data-at="bottom-left"], + .ss-keys[data-at="bottom-right"] { + left: auto; + transform: translateY(6px) scale(0.96); + } + + .ss-keys[data-at="bottom-left"] { left: 16px; } + + .ss-keys[data-at="bottom-right"] { right: 16px; } + + .ss-keys[data-at="bottom-left"][data-show="true"], + .ss-keys[data-at="bottom-right"][data-show="true"] { + transform: none; + } + + .ss-key { + display: grid; + place-items: center; + min-width: 28px; + height: 28px; + padding: 0 8px; + border-radius: 7px; + background: rgba(24, 24, 27, 0.88); + box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.18); + color: #fff; + font: 500 13px/1 var(--ss-font); + transition: transform 0.08s ease-out, box-shadow 0.08s ease-out; + } + + .ss-key[data-down="true"] { + transform: translateY(1px) scale(0.94); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.18); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/keys.tsx b/skills/imported/benjitaylor/stageshow/kit/keys.tsx new file mode 100644 index 0000000..3d1d28a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/keys.tsx @@ -0,0 +1,36 @@ +import './keys.css' + +// Keystrokes shown as keycaps (act.key): the caps for a combo, and the rows +// the stage draws them in. + +/** Where keycaps show. Give `{ wide, compact }` to place them per layout. */ +export type KeysAt = 'bottom' | 'top' | 'bottom-left' | 'bottom-right' + +// Each keystroke is its own row of caps, so one still fading out never +// changes or recenters when the next arrives +export type Keys = { id: number; caps: string[]; show: boolean; down: boolean } + +const KEYCAPS: Record = { cmd: '⌘', shift: '⇧', alt: '⌥', option: '⌥', ctrl: '⌃', enter: '↵', return: '↵', esc: 'esc', tab: '⇥', up: '↑', down: '↓', left: '←', right: '→', backspace: '⌫' } + +/** 'cmd+k' as caps: ['⌘', 'K']. */ +export const capsOf = (combo: string) => combo.split('+').map((k) => KEYCAPS[k.trim().toLowerCase()] ?? k.trim().toUpperCase()) + +/** The last row (fading out) and the current one. */ +export function Keycaps({ rows, at }: { rows: (Keys | null)[]; at: KeysAt }) { + return ( + <> + {rows.map( + (row) => + row && ( +
+ {row.caps.map((c, i) => ( + + {c} + + ))} +
+ ), + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/looks.ts b/skills/imported/benjitaylor/stageshow/kit/looks.ts new file mode 100644 index 0000000..b637c7d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/looks.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react' + +// The look a stage sets for everything inside it: theme, palette, fidelity, +// and which of its designs (wide or compact) is showing. provides it; +// parts read it with useLook(). + +/** glass: frosted, translucent surfaces over a soft color field. */ +export type Theme = 'light' | 'dark' | 'blueprint' | 'glass' +export type Palette = 'accent' | 'mono' +/** minimal: no text, only shapes. standard: real text where it carries meaning. realistic: real text, logos and avatars everywhere. */ +export type Fidelity = 'minimal' | 'standard' | 'realistic' +/** wide: the design size. compact: the smaller design used below `compact.below`. */ +export type Layout = 'wide' | 'compact' + +export type Look = { theme: Theme; palette: Palette; fidelity: Fidelity; layout: Layout } + +export const LookContext = createContext({ + theme: 'light', + palette: 'accent', + fidelity: 'standard', + layout: 'wide', +}) +export const useLook = () => useContext(LookContext) diff --git a/skills/imported/benjitaylor/stageshow/kit/measure.ts b/skills/imported/benjitaylor/stageshow/kit/measure.ts new file mode 100644 index 0000000..27880ce --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/measure.ts @@ -0,0 +1,122 @@ +// Measuring: where things are, in stage pixels. Targets and rects for the +// script API, the drawn scale that turns screen pixels into stage pixels, +// and the rect helpers demos use to place overlays from measured targets. + +export type Point = { x: number; y: number } +export type Rect = { x: number; y: number; width: number; height: number } +/** + * A target is the name in an element's data-target (aimed at its center), a + * named element with x and y as fractions of its box ({ at: 'apply', x: 0.8, y: 0.7 }), + * or a point in stage pixels. + */ +export type Target = string | Point | { at: string; x: number; y: number } | { wide: Target; compact: Target } + +export const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 } + +export const nameOf = (t: Target) => (typeof t === 'string' ? t : 'at' in t ? t.at : null) + +/** Rect helpers for positioning overlays from measured targets. */ +export const inset = (r: Rect, by: number): Rect => ({ x: r.x - by, y: r.y - by, width: r.width + by * 2, height: r.height + by * 2 }) +export const box = (r: Rect) => ({ left: r.x, top: r.y, width: r.width, height: r.height }) +export const between = (a: Point, b: Point): Rect => ({ + x: Math.min(a.x, b.x), + y: Math.min(a.y, b.y), + width: Math.abs(b.x - a.x), + height: Math.abs(b.y - a.y), +}) +export const contains = (outer: Rect, inner: Rect) => + inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height + +// Stage pixels from screen pixels. The scale is measured as the stage is drawn +// now (its rendered width over its layout width), not the fit's own scale, so +// a stage inside anything else that scales it (a camera, a zoomed card) still +// aims true +export const drawnScale = (root: HTMLElement | null, fallback: number) => (root?.offsetWidth && root.getBoundingClientRect().width / root.offsetWidth) || fallback || 1 + +// Reuse one stage read while measuring a collection of targets. A fresh +// snapshot on each pass still includes parent Camera/Phone transforms. +export function stageSpace(root: HTMLElement, fallback: number) { + const bounds = root.getBoundingClientRect() + return { left: bounds.left, top: bounds.top, scale: (root.offsetWidth && bounds.width / root.offsetWidth) || fallback || 1, borderX: root.clientLeft, borderY: root.clientTop } +} + +export function rectOf(root: HTMLElement, el: HTMLElement, fallback: number, space = stageSpace(root, fallback)): Rect { + const b = el.getBoundingClientRect() + const { scale } = space + // From the inside of the stage's border, where its absolute children are placed + return { x: (b.left - space.left) / scale - space.borderX, y: (b.top - space.top) / scale - space.borderY, width: b.width / scale, height: b.height / scale } +} + +// A target's box as rect() hands it to the scene: where it is drawn, but +// without a scale of its own (a button's press, a hover lift). Anything +// placed from it (a popover under a button) would otherwise shift by a +// fraction of a pixel for every press and settle back after +export function targetRectOf(root: HTMLElement, el: HTMLElement, fallback: number, space = stageSpace(root, fallback)): Rect { + const r = rectOf(root, el, fallback, space) + const style = getComputedStyle(el) + const t = style.transform + const m = t && t !== 'none' ? t.match(/^matrix\(([^)]+)\)$/) : null + if (!m) return r + const [a, b, c, d, e, f] = m[1].split(',').map(Number) + if (b || c || e || f || !a || !d) return r + // Unscaled from the drawn box, not offsetWidth, which rounds to whole pixels + const w = r.width / a + const h = r.height / d + const [ox, oy] = style.transformOrigin.split(' ').map(parseFloat) + return { x: r.x - ox * (1 - a), y: r.y - oy * (1 - d), width: w, height: h } +} + +// The untransformed layout box: offsets up to the stage, ignoring any scale +// or slide an element is part way through. When nothing between the element +// and the stage is transformed, the drawn box is the layout box, measured +// exactly; offsets round to whole pixels. Otherwise it walks the offsets. An +// offset is measured from the inside of its parent's border and before its +// scroll, so each parent's border is added and its scroll taken off on the way +// up: without that, an element in a bordered box measured a pixel up and left, +// and whatever landed on it (a flyer) jumped that pixel on the handoff. +const transformed = (n: Element) => { + const c = getComputedStyle(n) + return c.transform !== 'none' || (c.translate && c.translate !== 'none') || (c.scale && c.scale !== 'none') || (c.rotate && c.rotate !== 'none') +} +export function layoutRectOf(root: HTMLElement, el: HTMLElement): Rect { + let still = true + for (let a: Element | null = el; a && a !== root; a = a.parentElement) if (transformed(a)) { still = false; break } + if (still) return rectOf(root, el, 1) + let x = 0 + let y = 0 + let n: HTMLElement | null = el + while (n && n !== root) { + x += n.offsetLeft + y += n.offsetTop + const parent: Element | null = n.offsetParent + if (!parent || !root.contains(parent)) break + n = parent as HTMLElement + if (n !== root) { + x += n.clientLeft - n.scrollLeft + y += n.clientTop - n.scrollTop + } + } + return { x, y, width: el.offsetWidth, height: el.offsetHeight } +} + +export function sameRects(a: Record, b: Record) { + const ka = Object.keys(a) + if (ka.length !== Object.keys(b).length) return false + return ka.every((k) => { + const x = a[k] + const y = b[k] + return y && Math.abs(x.x - y.x) < 0.5 && Math.abs(x.y - y.y) < 0.5 && Math.abs(x.width - y.width) < 0.5 && Math.abs(x.height - y.height) < 0.5 + }) +} + +/** Places a Scroll's bar thumb for the element's current scroll position. */ +export function thumb(el: HTMLElement) { + const wrap = el.parentElement + if (!wrap?.classList.contains('ss-scroller')) return + const h = el.clientHeight + const total = el.scrollHeight + const size = total > h ? Math.max(24, (h / total) * h) : 0 + const y = total > h ? (el.scrollTop / (total - h)) * (h - size) : 0 + wrap.style.setProperty('--ss-thumb-h', `${size}px`) + wrap.style.setProperty('--ss-thumb-y', `${y}px`) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/messages.css b/skills/imported/benjitaylor/stageshow/kit/messages.css new file mode 100644 index 0000000..c540195 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/messages.css @@ -0,0 +1,462 @@ +/* Messages (messages.tsx): Thread, Bubble, Stamp, Receipt and the chat look. */ + +@layer stageshow { + .ss-thread { + --ss-thread-size: 15px; + --ss-me: #0a84ff; + --ss-me-text: #fff; + --ss-them: #e9e9eb; + --ss-them-text: #000; + /* One curve for everything that scrolls, so the thread moves as one */ + --ss-scroll: 0.55s cubic-bezier(0.25, 1, 0.5, 1); + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 3px; + /* Never grows past the room it's given: a full conversation overflows its + top (under the header) instead of pushing the composer and keyboard down */ + min-height: 0; + /* Clips only what scrolls off the top: a bubble forming out of the composer + below, or a tail or badge at the sides, stays visible */ + clip-path: inset(0 -100vw -100vh -100vw); + font-size: var(--ss-thread-size); + line-height: 1.3; + letter-spacing: -0.01em; + } + + /* Inside a phone, messages are at true iOS size */ + .ss-phone__content .ss-thread { + --ss-thread-size: 17px; + } + + /* Inside a phone the stamps and receipts scale with the bubbles: a phone + keeps them readable (iOS draws them at 11pt, which under a 300px phone + falls below 10px on the page) */ + .ss-phone__content .ss-stamp, + .ss-phone__content .ss-receipt { + font-size: 14px; + } + + .ss-thread[data-app="sms"] { + --ss-me: #34c759; + } + + .ss-stage[data-theme="dark"] .ss-thread, + .ss-phone[data-dark] .ss-thread { + --ss-them: #26252a; + --ss-them-text: #fff; + } + + /* Rows glide on top, each on a layer it keeps, so a bubble and its words + move as one picture (alone, the words step whole pixels inside the + gliding bubble). top is set (not auto) because auto can't be animated */ + .ss-bubble-row { + position: relative; + top: 0; + will-change: transform; + display: flex; + flex: none; + transition: top var(--ss-scroll); + } + + .ss-bubble-row[data-from="me"] { + justify-content: flex-end; + } + + /* A gap between runs, a hairline within one */ + .ss-bubble-row[data-from="me"] + .ss-bubble-row[data-from="them"], + .ss-bubble-row[data-from="them"] + .ss-bubble-row[data-from="me"] { + margin-top: 8px; + } + + .ss-bubble { + position: relative; + max-width: 75%; + padding: 0.42em 0.76em; + top: 0; + left: 0; + border-radius: 1.06em; + isolation: isolate; + color: var(--t); + overflow-wrap: break-word; + transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1); + } + + /* The fill is its own layer, so it can grow from the tail corner while the + text only rises and fades: text that ends a scale hops as it lands */ + .ss-bubble::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: var(--b); + transform-origin: inherit; + transition: opacity 0.3s ease 0.06s; + } + + /* A received bubble in a group chat carries the speaker's avatar beside + it, sat on the bubble's bottom edge as Messages draws it */ + .ss-bubble-row[data-avatar] { + align-items: flex-end; + } + + .ss-bubble__avatar { + display: inline-flex; + flex: none; + margin-right: 8px; + transition: margin-bottom 0.45s cubic-bezier(0.22, 1, 0.36, 1); + } + + /* Beside a reacted bubble the avatar stays on the bubble's bottom edge, + not on the room the reaction opened under it */ + .ss-bubble-row:has(.ss-bubble[data-tapback]) .ss-bubble__avatar { + margin-bottom: 21px; + } + + /* A reaction: the emoji in a small white pill on the bubble's bottom + corner, on the tail's side, overlapping the edge the way every chat app + draws one. The bubble makes room below it. It pops in from nothing. */ + .ss-bubble[data-tapback] { + margin-bottom: 21px; + } + + /* The room under the bubble opens on the same clock as the reaction + growing out of its edge, so the thread moves with it, not after it; it + closes only once the reaction has gone */ + .ss-bubble { + transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1), margin-bottom 0.3s cubic-bezier(0.22, 1, 0.36, 1) 0.12s; + } + + .ss-bubble[data-tapback] { + transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1), margin-bottom 0.45s cubic-bezier(0.22, 1, 0.36, 1); + } + + /* A true circle, overlapping only the bubble's bottom padding (6px), never + its last line of words; the emoji is centered in it */ + .ss-tapback { + position: absolute; + bottom: -17px; + left: 8px; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 50%; + background: var(--ss-thread-ground, #fff); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.05); + font-size: 13px; + line-height: 1; + pointer-events: none; + transform-origin: 50% 50%; + transform: scale(0); + transition: transform 0.45s cubic-bezier(0.22, 1.4, 0.36, 1), opacity 0.1s ease; + } + + .ss-bubble-row[data-from="me"] .ss-tapback { + left: auto; + right: 8px; + } + + .ss-stage[data-theme="dark"] .ss-tapback { + background: var(--ss-thread-ground, #2c2c2e); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + } + + .ss-tapback[data-show] { + transform: scale(1); + } + + .ss-tapback:not([data-show]) { + opacity: 0; + transform: scale(0.5); + transition: transform 0.16s ease, opacity 0.14s ease; + } + + .ss-tapback__emoji { + display: block; + width: 1em; + height: 1em; + line-height: 1; + text-align: center; + } + + /* Only arrivals rise in from their tail corner */ + .ss-bubble[data-arrived] { + animation: ss-bubble-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .ss-bubble[data-arrived]::before { + animation: ss-bubble-fill 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + /* A bubble with an origin forms out of the composer instead (in messages.tsx) */ + .ss-bubble[data-arrived][data-source], + .ss-bubble[data-arrived][data-source]::before { + animation: none; + } + + /* In flight from the composer: words only, the fill comes in as it travels */ + .ss-bubble[data-flying]::before { + opacity: 0; + transition: none; + } + + /* Its tail grows in once it has landed */ + .ss-bubble[data-arrived][data-source][data-tail] > .ss-bubble__tail { + animation-delay: 0.32s; + } + + [data-from="me"] > .ss-bubble { + --b: var(--ss-me); + --t: var(--ss-me-text); + transform-origin: 100% 100%; + } + + [data-from="them"] > .ss-bubble { + --b: var(--ss-them); + --t: var(--ss-them-text); + transform-origin: 0 100%; + } + + @keyframes ss-bubble-in { + from { + opacity: 0; + top: 6px; + } + } + + @keyframes ss-bubble-fill { + from { + transform: scale(0.8); + } + } + + /* The morph clips its own text while the box grows, but only on the sides + that grow: the tail, just outside the tail corner, stays where it is */ + [data-from="them"] > .ss-bubble[style*="width"] { + clip-path: inset(0 0 -1em -1em); + } + + [data-from="me"] > .ss-bubble[style*="width"] { + clip-path: inset(0 -1em -1em 0); + } + + .ss-bubble__text { + display: block; + } + + /* An arriving bubble brings its words with it, from the first frame. Words + growing out of the typing dots fade in while the box grows, never after it, + so there is no empty pill */ + .ss-bubble[data-grew] > .ss-bubble__text { + animation: ss-fade-in 0.28s ease both; + } + + .ss-bubble__tail { + position: absolute; + bottom: 0; + width: 1.06em; + height: 0.94em; + fill: var(--b); + pointer-events: none; + /* Retracted into the bubble's corner unless this bubble closes its run */ + opacity: 0; + transform: scale(0.2); + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.18s ease; + } + + .ss-bubble[data-tail] > .ss-bubble__tail { + opacity: 1; + transform: none; + } + + [data-from="me"] > .ss-bubble > .ss-bubble__tail { + right: -0.36em; + transform-origin: 15% 85%; + } + + [data-from="them"] > .ss-bubble > .ss-bubble__tail { + left: -0.36em; + transform-origin: 85% 85%; + } + + /* An arriving bubble's tail grows out once the bubble has nearly landed. + Backwards fill only, so the transition above still runs when it retracts */ + .ss-bubble[data-arrived][data-tail] > .ss-bubble__tail { + animation: ss-tail-in 0.32s cubic-bezier(0.25, 1, 0.5, 1) 0.2s backwards; + } + + @keyframes ss-tail-in { + from { opacity: 0; transform: scale(0.2); } + } + + .ss-bubble__bar { + display: inline-block; + height: 0.4em; + border-radius: 0.2em; + background: currentColor; + opacity: 0.35; + vertical-align: middle; + } + + /* Exactly one line tall (0.84em of padding + 1.3em of line), so a one-line + reply grows only sideways and nothing above it moves */ + .ss-bubble[data-typing] { + padding: 0.84em 0.8em; + } + + .ss-bubble__dots { + display: flex; + gap: 0.24em; + } + + .ss-bubble__dots i { + width: 0.46em; + height: 0.46em; + border-radius: 50%; + background: #8e8e93; + animation: ss-typing 1.2s ease-in-out infinite; + } + + .ss-bubble__dots i:nth-child(2) { + animation-delay: 0.16s; + } + + .ss-bubble__dots i:nth-child(3) { + animation-delay: 0.32s; + } + + @keyframes ss-typing { + 0%, 60%, 100% { + opacity: 0.4; + transform: none; + } + 30% { + opacity: 1; + transform: translateY(-0.12em); + } + } + + /* Stamps glide with the thread like the bubbles do (the receipt's own + transition below includes top too) */ + .ss-stamp { + position: relative; + top: 0; + will-change: transform; + transition: top var(--ss-scroll); + } + + .ss-stamp { + flex: none; + margin: 10px 0 6px; + color: #8e8e93; + font-size: 0.7em; + font-weight: 500; + text-align: center; + } + + .ss-receipt { + display: grid; + flex: none; + grid-template-rows: 0fr; + justify-items: end; + color: #8e8e93; + font-size: 0.66em; + font-weight: 500; + position: relative; + top: 0; + will-change: transform; + transition: top var(--ss-scroll), grid-template-rows var(--ss-scroll), opacity 0.25s ease; + opacity: 0; + } + + /* Delivered to Read: the old word lifts out as the new one rises in, the two + overlapping so there is never an empty moment, pinned to the right edge */ + .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__item[data-out] { + animation: ss-receipt-out 0.3s ease both; + } + + .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__char { + animation: ss-receipt-in 0.4s cubic-bezier(0.25, 1, 0.5, 1) 0.08s both; + } + + .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__mid { + transition-duration: 0.45s; + } + + @keyframes ss-receipt-out { + to { opacity: 0; filter: blur(1.5px); transform: translateY(-0.5em); } + } + + @keyframes ss-receipt-in { + from { opacity: 0; filter: blur(1.5px); transform: translateY(0.5em); } + } + + .ss-receipt > span { + min-height: 0; + overflow: hidden; + padding-right: 0.3em; + } + + .ss-receipt[data-show] { + grid-template-rows: 1fr; + opacity: 1; + } + + /* Chat: the AI-app look. The person's turn is a quiet bubble on the right; the + reply is plain text across the column, with no bubble and no tail */ + .ss-thread[data-app="chat"] { + --ss-me: var(--ss-ink-1); + --ss-me-text: var(--ss-text-strong); + gap: 18px; + line-height: 1.55; + } + + .ss-thread[data-app="chat"] .ss-bubble { + border-radius: 1.2em; + padding: 0.5em 0.9em; + } + + .ss-thread[data-app="chat"] .ss-bubble__tail { + display: none; + } + + /* The fill is the bubble's own layer (::before), so the reply clears that, + not just its background */ + .ss-thread[data-app="chat"] [data-from="them"] > .ss-bubble { + --b: transparent; + /* Plain text has no box to ease: it takes its height as words stream in, + or the thread chases a lagging box and the text spills past it */ + transition: none; + max-width: 100%; + padding: 0; + border-radius: 0; + background: none; + color: var(--ss-text); + } + + .ss-thread[data-app="chat"] .ss-bubble-row + .ss-bubble-row { + margin-top: 0; + } + + @media (prefers-reduced-motion: reduce) { + .ss-bubble, + .ss-bubble__text { + animation-duration: 0.01s; + } + + .ss-bubble__dots i { + animation: none; + opacity: 0.6; + } + + .ss-bubble-row, + .ss-bubble { + transition-duration: 0.01s; + } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/messages.tsx b/skills/imported/benjitaylor/stageshow/kit/messages.tsx new file mode 100644 index 0000000..4441ce8 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/messages.tsx @@ -0,0 +1,259 @@ +import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useRef, type CSSProperties, type ReactElement, type ReactNode } from 'react' +import { useLook } from './looks' +import { Roll } from './motion/Roll' +import './messages.css' + +// Message threads the way messaging apps draw them: runs of bubbles where only +// the last in a run has a tail, a typing indicator that grows into the reply, +// and older messages easing up as new ones arrive. One part for iMessage-style +// threads and for AI chat, where the reply is plain text with no bubble. + +// Whether the thread has finished its first render: bubbles there from the +// start are the opening picture and don't rise in; only arrivals do +const Ready = createContext<{ current: boolean }>({ current: true }) + +// offsetTop rounds to whole pixels. Keep fractional positions through the +// FLIP, including inside a scaled Phone, or a row steps before its glide starts. +function threadTops(root: HTMLElement) { + const style = getComputedStyle(root) + const extra = style.boxSizing === 'border-box' ? 0 : + [style.paddingLeft, style.paddingRight, style.borderLeftWidth, style.borderRightWidth].reduce((sum, value) => sum + (parseFloat(value) || 0), 0) + const width = parseFloat(style.width) + extra + const scale = (width && root.getBoundingClientRect().width / width) || 1 + const parents = new Map() + return new Map(Array.from(root.children, (el) => { + const parent = (el as HTMLElement).offsetParent + if (parent && !parents.has(parent)) parents.set(parent, parent.getBoundingClientRect().top) + return [el, (el.getBoundingClientRect().top - (parent ? parents.get(parent)! : 0)) / scale] + })) +} + +export interface ThreadProps { + /** + * imessage: blue and gray bubbles with tails (sms for green). chat: the + * AI-app look, a quiet gray bubble for the person and plain text for the reply. + */ + app?: 'imessage' | 'sms' | 'chat' + className?: string + style?: CSSProperties + children?: ReactNode +} + +/** + * A column of messages pinned to its bottom, as a conversation is. Messages + * already on screen glide up when one arrives instead of jumping; the thread + * clips what scrolls off its top, so put it under a header or let the stage + * crop it. Tails go on the last bubble of each run automatically. + */ +export function Thread({ app = 'imessage', className, style, children }: ThreadProps) { + const ref = useRef(null) + const ready = useRef(false) + // Where each message is right before this update. Read fresh every time, + // not remembered from the last update: CSS motion in between (a receipt + // opening, a bubble growing) moves messages without a render, and a stale + // position would glide them from somewhere they no longer are + const tops = ref.current ? threadTops(ref.current) : new Map() + useEffect(() => { + ready.current = true + }, []) + // Glide: every message that moved since the last render starts where it + // was and eases to where it is (FLIP), so an arrival pushes the rest up + useLayoutEffect(() => { + const root = ref.current + if (!root) return + const next = threadTops(root) + for (const [node, top] of Array.from(next)) { + const el = node as HTMLElement + const was = tops.get(el) + if (was === undefined || Math.abs(was - top) < 0.001) continue + // Start from where it is on screen: a glide still under way continues + // from its current offset instead of jumping back + // (by top, on the layer each row keeps, so a bubble's words neither hop + // as they land nor step inside the bubble on the way) + const now = parseFloat(getComputedStyle(el).top) || 0 + el.style.transition = 'none' + el.style.top = `${was + now - top}px` + void el.offsetWidth + el.style.transition = '' + el.style.top = '' + } + }) + // A tail closes each run: the last bubble before the sender changes + const list = Children.toArray(children).filter(isValidElement) as ReactElement[] + const fromOf = (c: ReactElement) => (c.type === Bubble ? c.props.from : null) + const out = list.map((c, i) => { + if (c.type !== Bubble || c.props.tail !== undefined) return c + const next = list.slice(i + 1).find((n) => n.type === Bubble) + return cloneElement(c, { tail: !next || fromOf(next) !== c.props.from }) + }) + return ( + +
+ {out} +
+
+ ) +} + +export interface BubbleProps { + /** me sits on the right in the accent bubble; them on the left. */ + from: 'me' | 'them' + /** Three bouncing dots in place of the text. Turning it off grows the bubble into the message. */ + typing?: boolean + /** Set by Thread: true on the last bubble of a run. */ + tail?: boolean + /** + * Where the message comes from, as iMessage sends: a target name (or a + * CSS selector) for the composer's text. The bubble forms out of it, the words + * lifting from the field into place while the bubble fills in around them, + * instead of rising in. Clear the field in the same update that adds the bubble. + */ + source?: string + /** + * A tapback on this bubble: the emoji, and `show` to pop it in and out. It + * sits in a white circle on the bubble's bottom corner, and the bubble makes + * room below so the emoji never covers the words. + */ + tapback?: { emoji: ReactNode; show?: boolean } + /** A small `Avatar` beside a received bubble, as a group chat shows who is speaking. */ + avatar?: ReactNode + className?: string + style?: CSSProperties + children?: ReactNode + /** data-target and other data attributes land on the bubble itself. */ + [data: `data-${string}`]: unknown +} + +/** + * One message. Bubbles in the thread on its first render are the opening + * picture and don't animate; only later arrivals rise in. In minimal + * fidelity a string message becomes a bar. + */ +export function Bubble({ avatar, from, typing = false, tail, source, tapback, className, style, children, ...data }: BubbleProps) { + const { fidelity } = useLook() + const arrived = useRef(useContext(Ready).current).current + const ref = useRef(null) + const size = useRef<{ w: number; h: number } | null>(null) + // Whether this bubble grew out of the typing dots (its words fade in as it grows) + const wasTyping = useRef(typing) + const grew = useRef(false) + if (wasTyping.current && !typing) grew.current = true + wasTyping.current = typing + // Morph: measure the bubble before and after the content changes, then ease + // its box from the old size to the new + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const was = size.current + el.style.width = '' + el.style.height = '' + // Resolved CSS dimensions retain the fractions offsetWidth/Height round + // away, so releasing the measured box doesn't resize it at the endpoint. + const measured = getComputedStyle(el) + const now = { w: parseFloat(measured.width), h: parseFloat(measured.height) } + size.current = now + if (!was || (was.w === now.w && was.h === now.h)) return + // The words keep their final line breaks while the box grows around them + const text = el.querySelector('.ss-bubble__text') + if (text) text.style.width = getComputedStyle(text).width + el.style.transition = 'none' + el.style.width = `${was.w}px` + el.style.height = `${was.h}px` + void el.offsetWidth + el.style.transition = '' + el.style.width = `${now.w}px` + el.style.height = `${now.h}px` + const end = () => { + el.style.width = '' + el.style.height = '' + if (text) text.style.width = '' + } + el.addEventListener('transitionend', end, { once: true }) + return () => el.removeEventListener('transitionend', end) + }, [typing, children]) + // Form out of the composer: start where the field's words were, in the + // field's color with no fill, and travel into place as the fill comes in + useLayoutEffect(() => { + const el = ref.current + if (!el || !arrived || !source) return + const stage = el.closest('.ss-stage') ?? document + const src = stage.querySelector(`[data-target="${source}"]`) ?? stage.querySelector(source) + const text = el.querySelector('.ss-bubble__text') + if (!src || !text) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || el.closest('[data-still]')) return + // Measure where it rests, not where an earlier run already moved it + el.style.transition = 'none' + el.style.left = el.style.top = '0px' + const k = el.getBoundingClientRect().width / el.offsetWidth || 1 + const o = src.getBoundingClientRect() + const t = text.getBoundingClientRect() + const dx = (o.left - t.left) / k + const dy = (o.top + o.height / 2 - (t.top + t.height / 2)) / k + const ease = 'cubic-bezier(0.22, 1, 0.36, 1)' + // Travels by left and top, so the words land without a sub-pixel hop + el.style.transition = 'none' + el.style.left = `${dx}px` + el.style.top = `${dy}px` + el.setAttribute('data-flying', '') + el.style.color = getComputedStyle(src).color + void el.offsetWidth + el.style.transition = `left 0.5s ${ease}, top 0.5s ${ease}, color 0.3s ease 0.06s` + el.style.left = el.style.top = '' + el.removeAttribute('data-flying') + el.style.color = '' + const end = (e: TransitionEvent) => { + if (e.target !== el || e.propertyName !== 'top') return + el.style.transition = '' + el.removeEventListener('transitionend', end) + } + el.addEventListener('transitionend', end) + return () => el.removeEventListener('transitionend', end) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + let body = children + if (fidelity === 'minimal' && typeof children === 'string') body = + return ( +
+ {avatar && from !== 'me' && {avatar}} +
+ {tapback && ( + + {tapback.emoji} + + )} + {typing ? ( + + + + + + ) : ( + {body} + )} + {/* Always drawn, so a tail grows out of the corner when its bubble + becomes the last of a run, and retracts when it stops being */} + + + +
+
+ ) +} + +/** A centered timestamp between runs ("Today 9:41"). */ +export function Stamp({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +/** + * The small status under your last message. `children` rolls when it changes + * (Delivered to Read); `show` false folds it away. + */ +export function Receipt({ show = true, children, className }: { show?: boolean; children: string; className?: string }) { + return ( +
+ + {children} + +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css new file mode 100644 index 0000000..7b66727 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css @@ -0,0 +1,27 @@ +/* Camera */ + +@property --ss-feather { + syntax: ''; + inherits: false; + initial-value: 0px; +} + +@layer stageshow { + /* The camera's frame: clips the zoomed scene and feathers its edges while + zoomed (the feather eases in and out with the move) */ + .ss-camera-view { + position: absolute; + inset: 0; + overflow: clip; + mask-image: + linear-gradient(to right, transparent, #000 var(--ss-feather), #000 calc(100% - var(--ss-feather)), transparent), + linear-gradient(to bottom, transparent, #000 var(--ss-feather), #000 calc(100% - var(--ss-feather)), transparent); + mask-composite: intersect; + } + + .ss-camera { + position: absolute; + inset: 0; + transform-origin: 0 0; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx new file mode 100644 index 0000000..8eeff56 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx @@ -0,0 +1,51 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { useReducedMotion } from '../reduced-motion' +import type { Rect } from '../measure' +import { css, type Bezier, type EaseName } from '../ease' +import './Camera.css' + +/** + * Moves the view in on part of the scene and back out, pushing in on a + * detail. Wrap the scene in it and set `focus` to a rect in + * stage pixels, measured with `a.rect(name, { layout: true })` (the layout + * rect, so the zoom itself never feeds back in); null shows everything. The + * view never leaves the scene's edges. Keep the cursor still while the camera + * moves, or put it where it is going only once it has settled. The view is + * its own frame: zoomed in, its edges feather so the scene passes under a + * soft edge instead of running hard into the stage's (`feather`, in px). + * Everything inside moves with the zoom, and `a.rect()` of a target inside + * reports where it is on screen now; give the cursor's rest point in stage + * pixels, outside the Camera, or it lands somewhere else once zoomed. + * `ease` takes a named curve from kit/ease.ts ('house' for a video-paced move). + */ +export function Camera({ focus, pad = 32, max = 2, duration = 1100, feather = 28, ease: curve = 'camera', children, className }: { focus: Rect | null; pad?: number; max?: number; duration?: number; feather?: number; ease?: EaseName | Bezier; children: ReactNode; className?: string }) { + const ref = useRef(null) + const [size, setSize] = useState({ w: 0, h: 0 }) + const reduced = useReducedMotion() + // Followed, not read once: a Stage switching to its compact design (or any + // resize) changes the frame the zoom is worked out in + useLayoutEffect(() => { + const p = ref.current?.parentElement?.parentElement + if (!p) return + const read = () => setSize((was) => (was.w === p.offsetWidth && was.h === p.offsetHeight ? was : { w: p.offsetWidth, h: p.offsetHeight })) + read() + const ro = new ResizeObserver(read) + ro.observe(p) + return () => ro.disconnect() + }, []) + let transform = 'none' + if (focus && size.w) { + const s = Math.max(1, Math.min(max, size.w / (focus.width + pad * 2), size.h / (focus.height + pad * 2))) + const tx = Math.min(0, Math.max(size.w - size.w * s, size.w / 2 - (focus.x + focus.width / 2) * s)) + const ty = Math.min(0, Math.max(size.h - size.h * s, size.h / 2 - (focus.y + focus.height / 2) * s)) + transform = `translate(${tx}px, ${ty}px) scale(${s})` + } + const ease = `${reduced ? 0 : duration}ms ${css(curve)}` + return ( +
+
+ {children} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css new file mode 100644 index 0000000..41fc77a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css @@ -0,0 +1,97 @@ +/* Collapse */ + +@layer stageshow { + /* The space and the content move together, so there's never an empty band + where a row was or is about to be. Closing, the content dims fast (to a + third in the first 100ms, before the closing edge reaches it) and then + dissolves over the rest of the height's travel, so it is faint while + the edge passes and still there while the space closes: the fade and + the height overlap to the end and no empty box is shown. A fade that + finished before the height had moved left a blank band for about 100ms. + Opening, the content comes in as the edge passes it. No mask, so no + gradient wash. */ + .ss-collapse { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1) 0.05s, opacity 0.42s cubic-bezier(0, 0.8, 0.5, 1); + } + + .ss-collapse[data-open] { + grid-template-rows: 1fr; + opacity: 1; + transition: grid-template-rows 0.46s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.24s ease-out 0.15s; + } + + /* solid: a surface unlike the ground opens and closes without fading + (a dark panel would pass through gray); only the height moves */ + .ss-collapse[data-solid], + .ss-collapse[data-solid][data-open] { + opacity: 1; + } + + .ss-collapse__inner { + min-height: 0; + overflow: clip; + overflow-clip-margin: 3px; + } + + /* A clip reveal unfolds from the content's middle line: the space clips it + equally above and below, so a row's text (in its middle) is whole by about + half the height and can be seen early without ever looking sliced. Closing + folds back into that line. */ + .ss-collapse[data-reveal='clip'] > .ss-collapse__inner { + display: flex; + flex-direction: column; + justify-content: center; + /* Thin rows side by side: a margin would let their windows overlap */ + overflow-clip-margin: 0; + } + + /* Taller than a row (a comment, a card): it reveals from the top, and its + content comes in as the space finishes opening, so no line is seen cut. + Cards and messages read better with reveal="drop". */ + .ss-collapse[data-reveal='clip'][data-tall] > .ss-collapse__inner { + justify-content: flex-start; + } + + .ss-collapse[data-reveal='clip'][data-tall][data-open] { + transition: grid-template-rows 0.46s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.24s ease-out 0.3s; + } + + /* Settled open, nothing clips: a toolbar or popover can hang off a row */ + .ss-collapse[data-open]:not([data-moving]) > .ss-collapse__inner { + overflow: visible; + } + + /* drop: nothing clips; the content arrives whole into the opening space */ + .ss-collapse[data-reveal='drop'] > .ss-collapse__inner { + overflow: visible; + } + + /* The glide is a transform on a layer the content keeps for good. Moved by + layout (top), text steps whole pixels while its box glides, so a count in + a pill hops inside it; and text that ends a transform without a layer of + its own is redrawn snapped and hops as it lands. On a lasting layer it + moves as one picture and rests drawn exactly as it moved. */ + .ss-collapse[data-reveal='drop'] > .ss-collapse__inner > * { + transform: translateY(-10px); + will-change: transform; + transition: transform 0.3s ease; + } + + .ss-collapse[data-reveal='drop'][data-from='below'] > .ss-collapse__inner > * { + transform: translateY(10px); + } + + .ss-collapse[data-reveal='drop'][data-open] > .ss-collapse__inner > * { + transform: none; + transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.2s; + } + + /* Drop content is whole and unclipped, so it waits for most of its space: + shown early, it would hang over whatever sits below the opening gap */ + .ss-collapse[data-reveal='drop'][data-open] { + transition: grid-template-rows 0.5s cubic-bezier(0.45, 0, 0.2, 1), opacity 0.3s ease 0.24s; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx new file mode 100644 index 0000000..18306f3 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx @@ -0,0 +1,69 @@ +import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react' +import './Collapse.css' + +/** + * Opens and closes its content's height, so what comes after moves with it: + * a row leaving a list, a log panel opening under a status. Closing dims the + * content fast and dissolves it over the whole of the height's travel, so a + * row is faint by the time the edge reaches it and the space is never seen + * empty; opening brings the content with its height. Children can have any height or + * padding. The inner box is a deliberate crop (film.mjs skips it) and clips + * 3px wide, so rings and shadows on the content aren't shaved. + */ +export function Collapse({ open, reveal = 'clip', from = 'above', solid = false, children, className, ...rest }: HTMLAttributes & { + open: boolean + /** + * clip: the opening space uncovers the content, for rows and text. The + * content is faint while the edge passes it, so nothing is ever seen sliced. + * drop: the space opens and the content arrives whole, gliding into place. + * For cards and messages: a notification, a toast, a chat message. + */ + reveal?: 'clip' | 'drop' + /** Where drop content comes from: 'above' (a notification), or 'below' (a chat message landing at the bottom of a feed). */ + from?: 'above' | 'below' + /** + * Opaque throughout: the height opens and closes over the content without + * fading it. For a surface unlike the ground (a dark log panel on a light + * window), which would otherwise pass through gray. Its content is there + * from the first frame: open it with what it shows, never empty. + */ + solid?: boolean + children: ReactNode +}) { + // Marked moving from the frame open changes until the height settles + const [moving, setMoving] = useState(false) + // Taller than a row: unfolding from the middle would cut its first line + // while it's already visible, so it reveals from the top and fades in late + const [tall, setTall] = useState(false) + const inner = useRef(null) + const first = useRef(true) + useLayoutEffect(() => { + const h = inner.current?.scrollHeight ?? 0 + if (h) setTall(h > 44) + if (first.current) return void (first.current = false) + setMoving(true) + // No transitionend without a transition (reduced motion): never leave it moving + const t = setTimeout(() => setMoving(false), 900) + return () => clearTimeout(t) + }, [open]) + return ( +
{ + if (e.target === e.currentTarget && e.propertyName === 'grid-template-rows') setMoving(false) + rest.onTransitionEnd?.(e) + }} + > +
+ {children} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css new file mode 100644 index 0000000..a0f78f9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css @@ -0,0 +1,128 @@ +/* Connector */ + +@layer stageshow { + .ss-connector { + position: absolute; + inset: 0; + z-index: 20; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; + } + + .ss-connector__line { + fill: none; + stroke: var(--ss-accent); + stroke-width: 1.5; + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.25s ease; + } + + .ss-connector[data-dashed] .ss-connector__line { + stroke-dasharray: 4 4; + stroke-dashoffset: 0; + } + + .ss-connector__reveal { + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1); + } + + .ss-connector[data-show] .ss-connector__reveal { stroke-dashoffset: 0; } + .ss-connector:not([data-show]) .ss-connector__reveal { + transition: stroke-dashoffset 0s 0.25s; + } + + .ss-connector[data-show] .ss-connector__line { + stroke-dashoffset: 0; + } + + .ss-connector[data-show][data-dashed] .ss-connector__line { + opacity: 1; + } + + .ss-connector:not([data-show]) .ss-connector__line { + opacity: 0; + transition: opacity 0.25s ease, stroke-dashoffset 0s 0.25s; + } + + .ss-connector__dot { + fill: var(--ss-accent); + stroke: #fff; + stroke-width: 2; + filter: drop-shadow(0 1px 1px rgb(20 35 55 / .16)); + opacity: 0; + transform-box: fill-box; + transform-origin: center; + transform: scale(0.4); + transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-connector[data-arrived] .ss-connector__dot { + opacity: 1; + transform: scale(1); + } + + .ss-connector[data-show] .ss-connector__dot--start { + opacity: 1; + transform: scale(1); + } + + .ss-connector__arrow { + fill: none; + stroke: var(--ss-accent); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0; + transition: opacity 0.2s ease; + } + + /* The head arrives as the line reaches it */ + .ss-connector[data-arrived] .ss-connector__arrow { + opacity: 1; + } + + /* The head is always solid: it must not pick up the line's draw-in dash */ + .ss-connector__arrow { + stroke-dasharray: none; + stroke-dashoffset: 0; + transition: opacity 0.2s ease; + } + + .ss-connector__label { + position: absolute; + z-index: 21; + padding: 2px 7px; + border-radius: 999px; + background: var(--ss-raised); + box-shadow: 0 0 0 1px var(--ss-line), var(--ss-shadow-card); + font-size: 10.5px; + font-weight: 500; + line-height: 14px; + color: var(--ss-text-strong); + white-space: nowrap; + opacity: 0; + transform: translate(-50%, -50%); + pointer-events: none; + transition: opacity 0.2s ease; + } + + /* A step number: a small circle on the line */ + .ss-connector__label[data-step] { + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + font-variant-numeric: tabular-nums; + } + + .ss-connector__label[data-arrived] { + opacity: 1; + transition: opacity 0.2s ease; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx new file mode 100644 index 0000000..07643a9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx @@ -0,0 +1,182 @@ +import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import type { Point } from '../measure' +import './Connector.css' + +type Route = 'h' | 'v' | 'straight' | 'step' | 'curve' + +export interface ConnectorProps { + from: Point + to: Point + /** h: across, then down or up. v: down or up, then across. straight: a direct line. + * step: across, down or up halfway, then across again, so it enters `to` + * level. Use step when several lines meet one target: they merge into one + * shared run before it, instead of knotting at its edge. curve: the same + * leave-level, arrive-level path as step, as one soft S, for a looser + * diagram (a flow between nodes, a hand-off between two cards). */ + route?: Route + /** Draws on when it turns true, fades off (intact) when it turns false. */ + show: boolean + /** Corner radius at the elbow. */ + radius?: number + /** End dots. Defaults to 'end', or 'none' with an arrow. */ + dots?: 'end' | 'both' | 'none' + /** An arrowhead at `to`, for direction: a request going out, a response coming back. */ + arrow?: boolean + /** A label on the line: a short word ("POST", "200 OK") in a pill, or a number for a step marker. Arrives after the line draws. */ + label?: ReactNode + /** Where along the line the label sits, 0 (from) to 1 (to). */ + labelAt?: number + dashed?: boolean + className?: string +} + +/** + * A line that ties one thing to another: an invoice to its plan, a row to its + * detail card, a value to its annotation, a request to its endpoint. Points + * are in stage pixels (from rect() or center()). Put it last in the scene so + * it draws over both ends. + */ +export function Connector({ from, to, route = 'h', show, radius = 8, dots, arrow, label, labelAt = 0.5, dashed, className }: ConnectorProps) { + // A curve with an arrow ends a little short and runs straight in, so the + // head sits on a straight run. Over a short gap the curve only levels out in + // its last pixels, and a head laid on the bend kinks against the line + const tip = arrow && route === 'curve' ? { x: to.x - Math.sign(to.x - from.x) * Math.min(8, Math.abs(to.x - from.x) / 3), y: to.y } : to + const d = tip === to ? elbow(from, to, route, radius) : `${elbow(from, tip, route, radius)}L${to.x} ${to.y}` + const ends = dots ?? (arrow ? 'none' : 'end') + // A dashed line ends on a dash, never a gap short of its target: stretch + // the 4/4 pattern a hair so a whole number of dashes fits + const line = useRef(null) + const reveal = useRef(null) + const revealId = `ss-connector-${useId().replace(/:/g, '')}` + const [arrived, setArrived] = useState(false) + useLayoutEffect(() => { + const el = line.current + if (!el) return + if (!dashed) { + el.style.removeProperty('stroke-dasharray') + return + } + const len = el.getTotalLength() + const n = Math.max(1, Math.round((len + 4) / 8)) + const dash = (len / (n * 2 - 1)).toFixed(3) + el.style.strokeDasharray = `${dash} ${dash}` + }, [d, dashed]) + useLayoutEffect(() => { + const el = dashed ? reveal.current : line.current + setArrived(false) + if (!show || !el) return + // The endpoint belongs to the visible line, not a guessed delay. This + // also works when reduced motion removes the transition, or a host + // advances its CSS transition one frame at a time. + const property = 'stroke-dashoffset' + const check = () => { + const style = getComputedStyle(el) + const complete = Math.abs(parseFloat(style.strokeDashoffset)) < .0001 + if (complete) setArrived(true) + } + const settle = (event: TransitionEvent) => { + if (event.target === el && event.propertyName === property) check() + } + el.addEventListener('transitionend', settle) + el.addEventListener('transitioncancel', settle) + check() + return () => { + el.removeEventListener('transitionend', settle) + el.removeEventListener('transitioncancel', settle) + } + }, [d, dashed, show]) + const pts = tip === to ? corners(from, to, route) : [...corners(from, tip, route), to] + const q = pts[pts.length - 1] + // A straight horizontal/vertical elbow can end with a zero-length leg. + // Its arrow follows the last real segment, rather than collapsing to a dot. + const p = pts.slice(0, -1).reverse().find(p => Math.hypot(q.x - p.x, q.y - p.y) > .001) ?? q + const directionLength = Math.hypot(q.x - p.x, q.y - p.y) + const len = directionLength || 1 + const ux = (q.x - p.x) / len + const uy = (q.y - p.y) / len + const head = `M${q.x - ux * 6 - uy * 4} ${q.y - uy * 6 + ux * 4}L${q.x} ${q.y}L${q.x - ux * 6 + uy * 4} ${q.y - uy * 6 - ux * 4}` + const at = along(pts, labelAt) + return ( + <> + + {/* The reveal has its own normalized path. The visible dashed line + keeps real dash spacing while its tip travels toward the target. */} + {dashed && } + + {arrow && directionLength > .001 && } + {ends === 'both' && } + {ends !== 'none' && } + + {label !== undefined && ( + + {label} + + )} + + ) +} + +// The S-curve's controls: level out of `a` and level into `b`, half way across +function bend(a: Point, b: Point): [Point, Point] { + const m = (a.x + b.x) / 2 + return [{ x: m, y: a.y }, { x: m, y: b.y }] +} + +function corners(a: Point, b: Point, route: Route): Point[] { + if (route === 'straight') return [a, b] + if (route === 'curve') { + // Sampled, so a label and the arrow follow the curve itself + const [c1, c2] = bend(a, b) + return Array.from({ length: 25 }, (_, i) => { + const t = i / 24 + const u = 1 - t + return { + x: u * u * u * a.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * b.x, + y: u * u * u * a.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * b.y, + } + }) + } + if (route === 'step') { + const m = (a.x + b.x) / 2 + return [a, { x: m, y: a.y }, { x: m, y: b.y }, b] + } + return route === 'h' ? [a, { x: b.x, y: a.y }, b] : [a, { x: a.x, y: b.y }, b] +} + +function along(pts: Point[], f: number): Point { + const segs = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y)) + let left = segs.reduce((n, s) => n + s, 0) * Math.max(0, Math.min(1, f)) + for (let i = 0; i < segs.length; i++) { + if (left <= segs[i] || i === segs.length - 1) { + const t = segs[i] ? Math.min(1, left / segs[i]) : 0 + return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t } + } + left -= segs[i] + } + return pts[0] +} + +function elbow(a: Point, b: Point, route: Route, r: number) { + if (route === 'straight') return `M${a.x} ${a.y}L${b.x} ${b.y}` + if (route === 'curve') { + const [c1, c2] = bend(a, b) + return `M${a.x} ${a.y}C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${b.x} ${b.y}` + } + const dx = b.x - a.x + const dy = b.y - a.y + if (route === 'step') { + const m = (a.x + b.x) / 2 + const k = Math.min(r, Math.abs(dx) / 2, Math.abs(dy) / 2) + const sx = Math.sign(dx) || 1 + const sy = Math.sign(dy) || 1 + if (!k) return `M${a.x} ${a.y}H${m}V${b.y}H${b.x}` + return `M${a.x} ${a.y}H${m - sx * k}Q${m} ${a.y} ${m} ${a.y + sy * k}V${b.y - sy * k}Q${m} ${b.y} ${m + sx * k} ${b.y}H${b.x}` + } + const k = Math.min(r, Math.abs(dx), Math.abs(dy)) + const sx = Math.sign(dx) || 1 + const sy = Math.sign(dy) || 1 + if (route === 'h') { + return `M${a.x} ${a.y}H${b.x - sx * k}Q${b.x} ${a.y} ${b.x} ${a.y + sy * k}V${b.y}` + } + return `M${a.x} ${a.y}V${b.y - sy * k}Q${a.x} ${b.y} ${a.x + sx * k} ${b.y}H${b.x}` +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Count.css b/skills/imported/benjitaylor/stageshow/kit/motion/Count.css new file mode 100644 index 0000000..57c74da --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Count.css @@ -0,0 +1,68 @@ +/* Count */ + +@layer stageshow { + .ss-count { + display: inline-flex; + align-items: baseline; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .ss-count__char { + display: inline-block; + } + + /* A unit after the figure keeps a box of its own, a hair from the last + digit (a word space when the format writes one), so "21°" and "240ms" + read as one figure without the unit sitting on the digit. Restyle it + here (a smaller, lighter degree sign); widen the hair with the token */ + .ss-count__unit { + display: inline-block; + margin-left: var(--ss-count-unit-gap, 0.08em); + } + + .ss-count__unit[data-space] { + margin-left: 0.28em; + } + + /* One digit: a window one line tall over a strip of 0 to 9. The strip + slides to the figure it shows; a change rolls it through the figures + in between, so 4 to 9 passes 5, 6, 7 and 8 */ + .ss-count__digit { + display: inline-block; + /* Sized by its figures, so the number's letter-spacing counts as it does + in plain text; a fixed 1ch box set tracked figures wider than the text */ + height: 1em; + overflow: hidden; + line-height: 1; + vertical-align: baseline; + /* The window's edges soften a touch, so a figure passing them is never + sliced; the figure at rest sits inside the clear middle */ + mask-image: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent); + } + + .ss-count__strip { + display: flex; + flex-direction: column; + will-change: transform; + transition-property: transform; + transition-timing-function: cubic-bezier(0.3, 0.75, 0.1, 1); + } + + .ss-count__strip > span { + display: block; + height: 1em; + line-height: 1; + text-align: left; /* a digit sits on its cell's left like set type; centered, a leading 1 drifts off the column */ + } + + .ss-count[data-snap] .ss-count__strip { + transition: none; + } + + @media (prefers-reduced-motion: reduce) { + .ss-count__strip { + transition: none; + } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx new file mode 100644 index 0000000..42d3ee6 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx @@ -0,0 +1,141 @@ +import { useLayoutEffect, useEffect, useRef, useState, type HTMLAttributes } from 'react' +import { useReducedMotion } from '../reduced-motion' +import './Count.css' + +export interface CountProps extends HTMLAttributes { + value: number + /** How a number is written, e.g. n => `$${n.toFixed(2)}` */ + format?: (n: number) => string + /** ms for a digit to roll from the old figure to the new one. */ + duration?: number + /** ms between one digit starting and the next, from the right. */ + stagger?: number +} + +// Three decades of figures, so a digit can roll on past 9 into 0 (or back +// past 0 into 9) without ever running off the end of the strip +const STRIP = '012345678901234567890123456789'.split('') +const MIDDLE = 10 + +// A unit after the figure ("21°", "240ms", "40%", "12 min"): the run of +// letters or signs that ends the text, after its last digit. It gets a box of +// its own, a hair from the digits (a word space when the format writes one), +// so the pair reads as one figure without the unit sitting on the last digit +const unitOf = (text: string) => { + const m = /^(.*\d)(\s*)([^\d\s.,]+)$/.exec(text) + return m ? { head: m[1], space: m[2].length > 0, unit: m[3] } : { head: text, space: false, unit: '' } +} + +const digitsFromRight = (text: string) => { + const out: number[] = [] + for (let i = text.length - 1; i >= 0; i--) { + const c = text[i] + if (c >= '0' && c <= '9') out.push(Number(c)) + } + return out +} + +/** + * A number whose digits roll to each new value, the way a counter does: + * each changed digit turns from the old figure to the new one, the last digit + * first, so a total settles from the right. A rise rolls the figures up, a + * fall rolls them down, and a digit passing 9 carries on into 0 rather than + * spinning back. Unchanged digits and the punctuation hold still. Tabular + * figures keep every column the same width. A unit the format writes after + * the figure ("21°", "240ms", "40%") sits in a box of its own + * (`.ss-count__unit`), a hair from the last digit, or a word space when the + * format has one ("12 min"); restyle it there for a smaller or lighter unit, + * and set `--ss-count-unit-gap` to widen the hair. Inside an `act.snap` or an + * offscreen reset it jumps to the value, so unseen resets stay unseen. + */ +export function Count({ value, format = (n) => Math.round(n).toLocaleString('en-US'), duration = 700, stagger = 45, className, style, ...rest }: CountProps) { + const reduced = useReducedMotion() + const el = useRef(null) + const text = format(value) + const { head, space, unit } = unitOf(text) + const chars = head.split('') + const digits = digitsFromRight(text) + // Where each column's strip sits, by column from the right. Starts in the + // middle decade; a change moves it by the shortest turn in the value's + // direction, and it is put back in the middle once the roll has settled + const [idx, setIdx] = useState(() => digits.map((d) => MIDDLE + d)) + const shown = useRef({ text, value, idx }) + shown.current.idx = idx + const homing = useRef(false) + + useLayoutEffect(() => { + const node = el.current + if (!node) return + const prev = shown.current + if (prev.text === text) return + const instant = reduced || duration <= 0 || !!node.closest('[data-instant], [data-offscreen]') + const before = digitsFromRight(prev.text) + const up = value >= prev.value + const next = digits.map((d, c) => { + const was = before[c] + const at = prev.idx[c] + if (was === undefined || at === undefined || instant) return MIDDLE + d + if (was === d) return at + return up ? at + ((d - was + 10) % 10) : at - ((was - d + 10) % 10) + }) + node.toggleAttribute('data-snap', instant) + if (!instant) { + // Settle the old transforms in the browser first, so the new ones transition + for (const strip of Array.from(node.querySelectorAll('.ss-count__strip'))) void getComputedStyle(strip).transform + node.setAttribute('data-rolling', '') + } + shown.current = { text, value, idx: next } + setIdx(next) + }, [text, value, reduced, duration]) // eslint-disable-line react-hooks/exhaustive-deps + + // Once every digit has landed, put the strips back in the middle decade + // without a transition, and drop the rolling mark a still waits on + useEffect(() => { + const node = el.current + if (!node || !node.hasAttribute('data-rolling')) return + const id = window.setTimeout(() => { + node.setAttribute('data-snap', '') + homing.current = true + const home = digits.map((d) => MIDDLE + d) + shown.current.idx = home + setIdx(home) + node.removeAttribute('data-rolling') + }, duration + stagger * digits.length + 60) + return () => window.clearTimeout(id) + }, [idx]) // eslint-disable-line react-hooks/exhaustive-deps + + // The snap comes off only after the home transforms are in the DOM (a + // frame callback could run before React commits them, and the strips + // would then roll a whole decade back to the middle) + useLayoutEffect(() => { + const node = el.current + if (!node || !homing.current) return + homing.current = false + for (const strip of Array.from(node.querySelectorAll('.ss-count__strip'))) void getComputedStyle(strip).transform + node.removeAttribute('data-snap') + }, [idx]) + + // Columns are keyed from the right, so a total that gains a digit keeps the + // digits it already had rolling in place and only the new leading one appears + let fromRight = digits.length + return ( + + {chars.map((c, i) => { + if (c < '0' || c > '9') return {c} + const column = --fromRight + const at = idx[column] ?? MIDDLE + Number(c) + return ( + + + {STRIP.map((n, k) => {n})} + + + ) + })} + {unit && {unit}} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css new file mode 100644 index 0000000..963ee09 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css @@ -0,0 +1,80 @@ +/* Expand */ + +@layer stageshow { + .ss-expand { + position: absolute; + inset: 0; + overflow: hidden; + } + + .ss-expand__content { + position: absolute; + inset: 0; + } + + /* Launching an app, as iOS zooms one out of a notification or an icon: the + whole app, laid out full size, is scaled down onto the closed thing and + grows to fill the screen. It stays on its own layer for good, so its text + never re-snaps when the zoom ends. The window takes the app's surface as it + leaves the face, and gives it back as it lands on it closing */ + .ss-expand[data-launch] { + transform-origin: 0 0; + will-change: transform; + background-color: transparent; + transition: + transform var(--ss-expand-ms) var(--ss-expand-ease), + clip-path var(--ss-expand-ms) var(--ss-expand-ease), + background-color calc(var(--ss-expand-ms) * 0.25) ease calc(var(--ss-expand-ms) * 0.7); + } + + /* Opening, the app's surface rises from the first frame as the face fades, + one crossfade, so the window is never a see-through frame of content */ + .ss-expand[data-launch][data-open] { + background-color: var(--ss-expand-bg, var(--ss-surface)); + transition: + transform var(--ss-expand-ms) var(--ss-expand-ease), + clip-path var(--ss-expand-ms) var(--ss-expand-ease), + background-color calc(var(--ss-expand-ms) * 0.4) ease; + } + + /* The app's content waits for its window: it fades in once the window has + grown a third of the way, never floating over the wallpaper while the + window is still the size of the face. Closing, it holds until the window + is nearly the face again */ + .ss-expand[data-launch] > .ss-expand__content { + opacity: 0; + transition: opacity calc(var(--ss-expand-ms) * 0.25) ease calc(var(--ss-expand-ms) * 0.6); + } + + .ss-expand[data-launch][data-open] > .ss-expand__content { + opacity: 1; + transition: opacity calc(var(--ss-expand-ms) * 0.4) ease calc(var(--ss-expand-ms) * 0.3); + } + + /* The closed thing's own face, drawn at its own size and scaled with the + window: it blows up and fades as the app grows out of it, and comes back as + the window lands on it */ + .ss-expand__face { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + pointer-events: none; + transition: opacity calc(var(--ss-expand-ms) * 0.3) ease calc(var(--ss-expand-ms) * 0.65); + } + + /* It is placed by the closed thing's measured box, so that box is all it + takes: margins it has where it lives (a notification's side margins) would + shift it off the original the moment it takes over */ + .ss-expand__face > * { + box-sizing: border-box; + width: 100%; + height: 100%; + margin: 0; + } + + .ss-expand[data-open] > .ss-expand__face { + opacity: 0; + transition: opacity calc(var(--ss-expand-ms) * 0.4) ease; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx new file mode 100644 index 0000000..97a76b1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx @@ -0,0 +1,109 @@ +import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react' +import { useReducedMotion } from '../reduced-motion' +import type { Rect } from '../measure' +import './Expand.css' + +/** + * A card that opens into a full screen: one element, never a swap. Closed, it + * is clipped to `from` (the card's rect from a.rect, in stage pixels; it is + * converted for you, also inside a Phone, whose screen is scaled again) with + * its corners; open, the clip releases to the whole parent. + * + * Without `face`, as iOS opens an App Store card: lay the children out as the + * open screen, top-aligned so the card's header is the screen's header; the + * content rides with the card and settles at the top. + * + * With `face`, as iOS opens an app out of a notification or an icon: `face` is + * the closed thing's own look (render the notification again). The whole app, + * laid out full size, is scaled down onto it and zooms up to fill the screen, + * its top showing first; the face blows up with it and fades, so the app grows + * out of the banner. Give the app's background as `--ss-expand-bg`. + * `radius` is the card's corner. `duration` defaults to 520ms for a card and + * 640ms for a launch. + */ +export function Expand({ open, from, radius = 16, duration, face, children, className, style, ...rest }: HTMLAttributes & { open: boolean; from: Rect; radius?: number; duration?: number; face?: ReactNode }) { + const ref = useRef(null) + // Where the parent sits on the stage, and how much it is scaled on screen + // (the stage's scale, times a phone screen's own) + const [box, setBox] = useState({ w: 0, h: 0, x: 0, y: 0, k: 1, r: 0 }) + useLayoutEffect(() => { + const p = ref.current?.parentElement + const stage = p?.closest('.ss-stage') + if (!p || !stage) return + const sr = stage.getBoundingClientRect() + const pr = p.getBoundingClientRect() + const s = sr.width / stage.offsetWidth || 1 + const k = pr.width / p.offsetWidth || 1 + // Inside a Phone, open is the display's own corner, not square: the app + // keeps its rounded corners all the way in and lands under the bezel's + const r = parseFloat(getComputedStyle(p).getPropertyValue('--ss-screen-r')) || 0 + setBox({ w: p.clientWidth, h: p.clientHeight, x: (pr.left - sr.left) / s + (p.clientLeft * k) / s, y: (pr.top - sr.top) / s + (p.clientTop * k) / s, k: k / s, r }) + }, [from.x, from.y, from.width, from.height]) + const reduced = useReducedMotion() + // Only opening and closing animate. A new `from` while the state holds (the + // first measure, a re-measure) is applied at once: animating it would sweep + // the clip in from wherever the unmeasured rect put it, the stage's top left. + // A card not measured yet (no size) isn't shown at all + const key = `${open}|${from.x}|${from.y}|${from.width}|${from.height}|${box.w}|${box.h}|${box.x}|${box.y}` + const last = useRef({ open, key }) + // Open, the target doesn't depend on `from`, so a re-measure mid-open (the + // tapped notification easing back from its press) must not cut it short + const moving = last.current.key === key || last.current.open !== open || open + useLayoutEffect(() => { + last.current = { open, key } + }) + const unmeasured = !open && (!from.width || !from.height || !box.w) + // `from` in the parent's own pixels + const x = (from.x - box.x) / box.k + const y = (from.y - box.y) / box.k + const w = from.width / box.k + const h = from.height / box.k + const r = Math.max(0, box.w - x - w) + const b = Math.max(0, box.h - y - h) + // A card: a quick start that settles long, like a UIKit spring with no + // overshoot. A launch is gentler and longer: with the card's curve the app + // is nearly full screen within a tenth of a second, which reads as a jump + const curve = face ? 'cubic-bezier(0.25, 0.6, 0.2, 1)' : 'cubic-bezier(0.3, 0.75, 0.1, 1)' + const ms = reduced ? 0 : (duration ?? (face ? 640 : 520)) + const ease = `${ms}ms ${curve}` + if (face) { + // The app at full size, scaled by the card's width onto the card, its + // height clipped to the card's (in the window's own pixels) + const s = box.w ? w / box.w : 1 + const clipH = Math.max(0, box.h - h / s) + const shut = { transform: `translate(${x}px, ${y}px) scale(${s})`, clipPath: `inset(0px 0px ${clipH}px 0px round ${radius / s}px)` } + const wide = { transform: 'translate(0px, 0px) scale(1)', clipPath: `inset(0px 0px 0px 0px round ${box.r}px)` } + return ( +
+
{children}
+
+ {face} +
+
+ ) + } + const clip = open ? `inset(0px 0px 0px 0px round ${box.r}px)` : `inset(${y}px ${r}px ${b}px ${x}px round ${radius}px)` + // Retain the same composited content layer at both ends, so the text does + // not re-rasterise when the card finishes opening or closing. + const ride = { transform: `translate3d(0, ${open ? 0 : y}px, 0)`, transition: moving ? `transform ${ease}` : 'none' } + return ( +
+
+ {children} +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css new file mode 100644 index 0000000..ef88ccc --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css @@ -0,0 +1,51 @@ +/* Fly */ + +@layer stageshow { + .ss-fly { + position: absolute; + /* Above popovers and menus: what flies usually leaves one */ + z-index: 30; + pointer-events: none; + } + + /* The flyer fills its box exactly: no line box under an inline child (which + sat it a few px low, then the real element snapped up on landing), and + children sized 100% get the full height */ + .ss-fly > div, + .ss-fly__lift { + width: 100%; + height: 100%; + } + + .ss-fly__lift { + display: flex; + } + + .ss-fly__lift > * { + flex: 1 1 auto; + max-width: 100%; + max-height: 100%; + } + + /* held: it buds off what it leaves, fading in where it sits (a value + chip's words lie exactly over the value's), never popping in whole */ + .ss-fly[data-held]:not([data-go]) .ss-fly__lift { + animation: ss-fly-bud 0.2s ease-out both; + } + + @keyframes ss-fly-bud { + from { opacity: 0; } + } + + /* A small lift mid-flight that is gone before it lands */ + .ss-fly[data-go] .ss-fly__lift { + animation-name: ss-fly-lift; + animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1); + animation-fill-mode: both; + } + + @keyframes ss-fly-lift { + 30% { transform: scale(1.12); filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.18)); } + 85%, 100% { transform: none; filter: none; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx new file mode 100644 index 0000000..d8f1075 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, type ReactNode } from 'react' +import { useReducedMotion } from '../reduced-motion' +import type { Rect } from '../measure' +import { warn } from '../util' +import './Fly.css' + +export interface FlyProps { + /** Where it starts and where it lands, in stage pixels (from `rect()`): the source element's own rect and the landing element's own rect, never a slot or wrapper around them. */ + from: Rect + to: Rect + /** False waits at `from`, hidden; true shows it there and flies to `to`. Hide the source in the same set that turns it true. */ + go: boolean + /** Shown while it waits at `from`: the thing being carried (under the cursor, before it flies), or a chip budding off a value, which grows out of it (it scales up from the source as it fades in) rather than popping in whole. Off by default, so it never doubles the element it leaves. */ + held?: boolean + /** ms for the flight. Default 750. */ + duration?: number + children: ReactNode + className?: string +} + +/** + * Carries one element from one place to another along a soft arc: an avatar + * into an assignee slot, a file into a folder. It lifts a little on the way + * and is back to its final size before it lands, decelerating into place with + * no bounce. It is hidden until `go` (unless `held`): hide the source in the + * set that turns `go` on. Render the real element at `to` only once it has + * landed, in the same set that hides this one (unmount it, or snap `go` back + * to false, which hides it), so it reads as one object. + * + * The flyer is scaled from the `from` box to the `to` box, and its child is + * stretched to fill `from`, so draw the child at the start size. A child of a + * different shape, or a `to` taken from a slot rather than the element that + * lands, changes size in flight and snaps on the handoff (film.mjs: `fly size`). + */ +export function Fly({ from: liveFrom, to: liveTo, go, held: shownAtRest = false, duration = 750, children, className }: FlyProps) { + const reduced = useReducedMotion() + // Both ends are fixed at take-off. Measured live, they move under the flight + // (a closing menu scales the start away) and every change restarts the + // transition: a creep near the end, then a jump when the real one appears. + const held = useRef<{ from: Rect; to: Rect } | null>(null) + if (!go) held.current = null + else if (!held.current) held.current = { from: liveFrom, to: liveTo } + const { from: start, to } = held.current ?? { from: liveFrom, to: liveTo } + // An empty start (nothing measured yet) takes the landing size, so the + // flyer never grows from a point + const from = start.width ? start : { ...start, width: to.width || 1, height: to.height || 1 } + const at = go ? to : from + const ms = reduced ? 0 : duration + const sx = at.width / (from.width || 1) + // A child that doesn't fill the start box takes off or lands at the wrong size + const body = useRef(null) + useEffect(() => { + const el = body.current?.firstElementChild as HTMLElement | null + if (!el || !start.width) return + if (Math.abs(el.offsetWidth - from.width) > 1 || Math.abs(el.offsetHeight - from.height) > 1) + warn(`Fly: the child is ${el.offsetWidth}×${el.offsetHeight} but takes off from ${Math.round(from.width)}×${Math.round(from.height)}. Draw it at the start size, and pass the landing element's own rect as \`to\`.`) + }, [from.width, from.height, start.width]) + return ( + // Horizontal and vertical travel on slightly different curves make a gentle + // arc. Far apart curves (a fast-out x) read as a skid sideways, then a drop. +
+
+
+ {children} +
+
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css new file mode 100644 index 0000000..037aaa4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css @@ -0,0 +1,17 @@ +/* Follow */ + +@layer stageshow { + .ss-follow { + position: absolute; + top: 0; + left: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; + will-change: transform; + } + + .ss-follow[data-show] { + opacity: 1; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx new file mode 100644 index 0000000..5465317 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx @@ -0,0 +1,139 @@ +import { useLayoutEffect, useRef, type HTMLAttributes, type ReactNode } from 'react' +import type { Rect } from '../measure' +import { useReducedMotion } from '../reduced-motion' +import './Follow.css' + +export interface FollowProps extends HTMLAttributes { + /** The rect to sit on, in stage pixels. null hides it. */ + to: Rect | null + /** Higher is snappier. 0.25 is a quick, settled follow. */ + stiffness?: number + children?: ReactNode +} + +/** + * An overlay that springs onto a rect and keeps its momentum when the rect + * changes: drop targets, selection rings, hover highlights. A CSS transition + * restarts from rest on every change, so a target updated cell by cell on a + * freeform drag stutters and shakes; this retargets smoothly instead. + */ +export function Follow({ to, stiffness = 0.25, className, style, children, ...rest }: FollowProps) { + const reduced = useReducedMotion() + const el = useRef(null) + const cur = useRef(null) + const vel = useRef({ x: 0, y: 0, width: 0, height: 0 }) + const target = useRef(to) + target.current = to + // The loop sleeps once it has settled; a new target wakes it + const wake = useRef<() => void>(() => {}) + useLayoutEffect(() => { + let id = 0 + let running = false + const k = stiffness + const damp = 2 * Math.sqrt(k) * 0.95 + const frame = 1000 / 60 + const keys = ['x', 'y', 'width', 'height'] as const + let previous = performance.now() + let remainder = 0 + let simulation = cur.current ? { ...cur.current } : null + let velocity = { ...vel.current } + let priorTarget: Rect | null = null + const integrate = (c: Rect, v: typeof velocity, t: Rect) => { + for (const key of keys) { + v[key] += (t[key] - c[key]) * k - v[key] * damp + c[key] += v[key] + } + } + const step = (time = performance.now()) => { + const elapsed = Math.min(100, Math.max(0, time - previous)) + previous = time + const t = target.current + const node = el.current + // Hidden: forget where it was, so it next appears on its target rather than sliding in from the last one + if (!t || reduced) { + cur.current = t ? { ...t } : null + vel.current = { x: 0, y: 0, width: 0, height: 0 } + simulation = cur.current ? { ...cur.current } : null + velocity = { ...vel.current } + remainder = 0 + } + if (node && t) { + if (!cur.current) cur.current = { ...t } + if (!simulation || !priorTarget || keys.some(key => priorTarget![key] !== t[key])) { + // Retarget from the last rendered position and velocity, including + // a partial simulation step, so an interruption cannot rewind. + simulation = { ...cur.current } + velocity = { ...vel.current } + remainder = 0 + } + priorTarget = { ...t } + const c = cur.current + const v = vel.current + // Preserve the existing 60Hz spring trajectory. Interpolate its next + // step at higher refresh rates instead of integrating faster per RAF. + remainder += elapsed + while (remainder >= frame - 1e-6) { + integrate(simulation, velocity, t) + remainder = Math.max(0, remainder - frame) + } + const next = { ...simulation }, nextVelocity = { ...velocity } + integrate(next, nextVelocity, t) + const part = remainder / frame + for (const key of keys) { + c[key] = simulation[key] + (next[key] - simulation[key]) * part + v[key] = velocity[key] + (nextVelocity[key] - velocity[key]) * part + if (Math.abs(t[key] - c[key]) < 0.05 && Math.abs(v[key]) < 0.05) { + c[key] = t[key] + v[key] = 0 + } + } + node.style.transform = `translate(${c.x}px, ${c.y}px)` + node.style.width = `${c.width}px` + node.style.height = `${c.height}px` + } + const c = cur.current + const v = vel.current + const settled = !t || !node || (c !== null && v.x === 0 && v.y === 0 && v.width === 0 && v.height === 0 && c.x === t.x && c.y === t.y && c.width === t.width && c.height === t.height) + running = !settled + if (running) id = requestAnimationFrame(step) + } + wake.current = () => { + if (reduced || !target.current || !cur.current) { + // Initial placement and reappearance are layout, not spring motion. + // A paused host must not need Step just to reveal the first target. + cancelAnimationFrame(id) + running = false + step() + return + } + if (running || keys.every(key => cur.current![key] === target.current![key] && vel.current[key] === 0)) return + running = true + previous = performance.now() + id = requestAnimationFrame(step) + } + wake.current() + return () => { + running = false + cancelAnimationFrame(id) + wake.current = () => {} + } + }, [stiffness, reduced]) + useLayoutEffect(() => wake.current(), [to?.x, to?.y, to?.width, to?.height, !to]) + return ( +
+ {children} +
+ ) +} + +/** + * Which cell a value falls in, sticky at the borders: it only changes once + * the value is `hysteresis` of a cell past the edge, so a drag along a border + * doesn't flicker between two cells. + */ +export function cellOf(value: number, size: number, prev: number | null, hysteresis = 0.2) { + const raw = Math.floor(value / size) + if (prev === null || raw === prev) return raw + const edge = raw > prev ? (prev + 1) * size : prev * size + return Math.abs(value - edge) > size * hysteresis ? raw : prev +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css new file mode 100644 index 0000000..35027f3 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css @@ -0,0 +1,65 @@ +/* Popover */ + +@layer stageshow { + .ss-popover { + position: absolute; + /* Above connectors and flights: an open menu is the topmost surface */ + z-index: 25; + isolation: isolate; + /* The surface and its shadow are drawn on ::before, so clipping the popover + cuts its shadow off. A sliding submenu clips an inner track instead. */ + overflow: visible !important; + padding: 4px; + border-radius: var(--ss-radius-lg); + opacity: 0; + pointer-events: none; + /* Long enough to read as a fade at a demo's pace: at 0.15s it falls + between frames and the menu seems to vanish */ + transition: opacity 0.3s ease; + } + + /* The surface scales a touch from the origin; the text only fades. Text that + ends a scale is redrawn snapped to the pixel grid and wobbles as it lands, + so it never rides the transform. Restyle it with --ss-popover-bg and + --ss-popover-shadow rather than background and box-shadow. */ + .ss-popover::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: var(--ss-popover-bg, var(--ss-raised)); + box-shadow: var(--ss-popover-shadow, var(--ss-shadow-float)); + transform: scale(0.96); + transform-origin: var(--ss-popover-origin, top left); + transition: transform 0.3s ease; + pointer-events: none; + } + + .ss-popover[data-show] { + opacity: 1; + transition: opacity 0.18s ease; + } + + .ss-popover[data-show]::before { + transform: none; + transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1); + } + + /* Closing, it leaves as one object: the whole popover, contents and all, + shrinks a touch toward its origin as it fades. A surface that shrank + alone would slip out from under its contents and leave them floating. + (Text landing at the end of a scale wobbles; on the way out it ends + invisible, so that doesn't apply.) Once it has faded (data-instant), + the surface takes its opening scale again, out of sight */ + .ss-popover:not([data-show]):not([data-instant]) { + transform: scale(0.96); + transform-origin: var(--ss-popover-origin, top left); + transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.4, 0, 1, 1); + } + + .ss-popover:not([data-show]):not([data-instant])::before { + transform: none; + transition: none; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx new file mode 100644 index 0000000..f0ffbb4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react' +import './Popover.css' + +/** CSS transform-origin keywords: a side, or a vertical and horizontal pair. */ +export type PopoverOrigin = 'top' | 'bottom' | 'left' | 'right' | 'center' | `${'top' | 'bottom' | 'center'} ${'left' | 'right' | 'center'}` | `${'left' | 'right'} center` + +/** + * A floating surface anchored to something: a menu, a snooze picker, a prompt + * bar under a selection. It fades and scales a touch from `origin`, the side + * nearest its trigger; the box itself never resizes while it opens. Position + * it from rect() of the trigger. + */ +export function Popover({ show, origin = 'top left', className, style, children, ...rest }: HTMLAttributes & { show: boolean; origin?: PopoverOrigin }) { + // Closing, it keeps the place and contents it had open: a scene that resets + // the anchor or the items as it closes would otherwise make the fading + // popover jump or change under the eye. Once it has faded, it lets go and + // takes the live contents instantly, out of sight, so a change made while + // it was hidden (a list reset for the next loop) doesn't play when it reopens. + const held = useRef<{ style?: CSSProperties; children: ReactNode; origin: PopoverOrigin } | null>(null) + const [released, setReleased] = useState(false) + useEffect(() => { + if (show) return setReleased(false) + const t = setTimeout(() => setReleased(true), 320) + return () => clearTimeout(t) + }, [show]) + if (show) held.current = { style, children, origin } + const shown = show || released || !held.current ? { style, children, origin } : held.current + return ( +
+ {shown.children} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css new file mode 100644 index 0000000..ee8d807 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css @@ -0,0 +1,77 @@ +/* Roll */ + +@layer stageshow { + .ss-roll { + white-space: pre; + } + + /* Only the changed part rolls; it clips its own motion and eases to the new width */ + .ss-roll__mid { + display: inline-grid; + /* Clip the vertical roll only; the width never cuts text, because the box + holds the wider of the two lines while they swap */ + clip-path: inset(0 -100vw); + vertical-align: bottom; + transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-roll__item { + grid-area: 1 / 1; + justify-self: start; + width: max-content; + } + + /* Pinned at its right edge: the words change against that edge, so a narrower + word doesn't ride the box's left edge as it closes */ + .ss-roll[data-align='end'] .ss-roll__mid { + /* The column is the box itself, not the widest word, so the right edge + holds while the box eases and the wider word spills left, unclipped */ + grid-template-columns: 100%; + } + + .ss-roll[data-align='end'] .ss-roll__item { + justify-self: unsafe end; + } + + .ss-roll__char { + display: inline-block; + white-space: pre; + animation: ss-roll-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; + } + + .ss-roll__item[data-out] { + /* The old text is mostly gone before the new rises (it starts 110ms later) */ + animation: ss-roll-out 0.28s cubic-bezier(0.55, 0, 1, 0.45) both; + } + + /* A full line of travel each way, so the old and new text never overlap */ + @keyframes ss-roll-in { + from { opacity: 0; filter: blur(2px); transform: translateY(100%); } + } + + @keyframes ss-roll-out { + to { opacity: 0; filter: blur(2px); transform: translateY(-100%); } + } + + /* Labels on controls crossfade in place instead of rolling a full line */ + .ss-roll[data-variant='fade'] .ss-roll__char { + animation-name: ss-fade-in; + animation-duration: 0.35s; + animation-delay: 0.12s; + } + + .ss-roll[data-variant='fade'] .ss-roll__item[data-out] { + animation-name: ss-fade-out; + animation-duration: 0.16s; + } + + /* The width eases in step with the crossfade, and nothing paints outside it */ + .ss-roll[data-variant='fade'] .ss-roll__mid { + clip-path: inset(0); + transition-duration: 0.4s; + } + + @keyframes ss-fade-out { + to { opacity: 0; filter: blur(2px); transform: translateY(-3px); } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx new file mode 100644 index 0000000..f4b8ff3 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx @@ -0,0 +1,191 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' +import { useReducedMotion } from '../reduced-motion' +import './Roll.css' + +/** + * Text that rolls when it changes. Only the part that differs moves: in + * "$0.01 per 1,000" to "$0.0098 per 1,000", "$0.0" and " per 1,000" hold still + * while the old digits slide up and out and the new ones rise in one by one. + * Words move whole: "Checking calendars" to "Everyone can make it" rolls word + * by word, and a change never splits a word. + * The changing part eases to its new width, so the text after it moves with it. + * For prices, counts in words, statuses and labels. + */ +export function Roll({ delay = 0, children, ...rest }: Parameters[0] & { + /** Hold the old text this many ms before rolling: a total that changes + * after the row that caused it has landed, not on the same frame. */ + delay?: number +}) { + const reduced = useReducedMotion() + const [held, setHeld] = useState(children) + useEffect(() => { + if (!delay || reduced) return setHeld(children) + const id = setTimeout(() => setHeld(children), delay) + return () => clearTimeout(id) + }, [children, delay, reduced]) + return {delay && !reduced ? held : children} +} + +function RollNow({ + children, + stagger = 45, + variant = 'roll', + align, + className, + style, +}: { + children: string + stagger?: number + /** The side the text is pinned to. Found from the layout when left out: a + * right-aligned value (text-align right, or last in a space-between or + * flex-end row) changes against its right edge, so it never drifts. */ + align?: 'start' | 'end' + /** 'roll' slides text up through the line, for numbers and inline text. 'fade' + * crossfades in place with a small lift, for labels on buttons and other + * controls, where a full roll shows two lines at once. */ + variant?: 'roll' | 'fade' + className?: string + style?: CSSProperties +}) { + const reduced = useReducedMotion() + const [view, setView] = useState({ text: children, head: 0, tail: 0, old: '', key: 0, numeric: true }) + const mid = useRef(null) + const root = useRef(null) + const fromWidth = useRef(null) + // Inline text has no computed width. Use the line box's scale to convert + // its painted width back to local CSS pixels, including inside a Phone or + // Camera. offsetWidth rounds away the fractions that keep a suffix still. + const rootWidth = () => { + if (!root.current || !mid.current) return 0 + const height = parseFloat(getComputedStyle(mid.current).height) + const scale = height > 0 ? mid.current.getBoundingClientRect().height / height : 1 + return root.current.getBoundingClientRect().width / (scale || 1) + } + const [side, setSide] = useState<'start' | 'end'>(align ?? 'start') + useLayoutEffect(() => { + if (align) return setSide(align) + const el = root.current + if (!el?.parentElement) return + // Look through wrappers that only hold the roll (a , a value span): + // they size to it, so where the wrapper sits is where the text pins + let box: HTMLElement = el + while (box.parentElement && box.parentElement.childElementCount === 1 && Math.abs(box.parentElement.getBoundingClientRect().width - box.getBoundingClientRect().width) < 1) box = box.parentElement + const parent = box.parentElement + if (!parent) return + const p = getComputedStyle(parent) + const flex = /flex/.test(p.display) && p.flexDirection.startsWith('row') + // Pushed to the right edge by an auto margin (or anything else) reads the + // same from the layout: flush with the row's end, slack before it + const pushed = () => { + const prev = box.previousElementSibling + const r = box.getBoundingClientRect() + const pr = parent.getBoundingClientRect() + const flush = Math.abs(pr.right - parseFloat(p.paddingRight) - parseFloat(p.borderRightWidth) - r.right) < 1 + const slack = prev ? r.left - prev.getBoundingClientRect().right - (parseFloat(p.columnGap) || 0) : 0 + return flush && slack > 1 + } + const end = + /right|end/.test(getComputedStyle(el).textAlign) || + (flex && box === parent.lastElementChild && (/space-between|flex-end|^end/.test(p.justifyContent) || pushed())) + setSide(end ? 'end' : 'start') + }, [align]) + useEffect(() => { + if (reduced) { + setView({ text: children, head: 0, tail: 0, old: '', key: 0, numeric: true }) + return + } + if (view.text === children) return + const a = view.text + const b = children + let head = 0 + while (head < a.length && head < b.length && a[head] === b[head]) head++ + let tail = 0 + while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++ + // Digits can change one at a time; words can't. Snap a wordy change out to + // whole words. + const numeric = /^[\d.,$€£%+\-−\s]*$/.test(a.slice(head, a.length - tail) + b.slice(head, b.length - tail)) + if (!numeric) { + const space = (c: string | undefined) => c === undefined || /\s/.test(c) + while (head > 0 && !space(b[head - 1]) && !space(b[head])) head-- + while (tail > 0 && !space(b[b.length - tail - 1]) && !space(b[b.length - tail])) tail-- + } + const key = view.key + 1 + // Capture the rendered width before repartitioning head/middle/tail. + // During interruption this is between endpoints, not the old target. + fromWidth.current = rootWidth() + setView({ text: b, head, tail, old: a.slice(head, a.length - tail), key, numeric }) + const id = setTimeout(() => setView((v) => (v.key === key ? { ...v, old: '' } : v)), 700) + return () => clearTimeout(id) + }, [children, reduced]) // eslint-disable-line react-hooks/exhaustive-deps + // While the old and new text swap, the box starts at the old text's width, + // opens to the wider of the two so neither runs into what follows, then + // settles to the new width. Set on the element directly so the starting + // width never animates from a stale value. + useLayoutEffect(() => { + const el = mid.current + const live = el?.querySelector('[data-live]') + const out = el?.querySelector('[data-out]') + if (!el || !live) return + const width = (node: HTMLElement) => parseFloat(getComputedStyle(node).width) || 0 + const next = width(live) + if (reduced || view.key === 0) { + el.style.transition = 'none' + el.style.width = `${next}px` + fromWidth.current = null + return + } + // Repartitioning moves unchanged text out of the grid. Subtract its new + // width from the old rendered total so that move itself changes no room. + const affixes = rootWidth() - width(el) + const previous = fromWidth.current + fromWidth.current = null + const start = previous === null ? (out ? width(out) : 0) : Math.max(0, previous - affixes) + el.style.transition = 'none' + el.style.width = `${start}px` + void el.offsetWidth + el.style.transition = '' + // A control's label: the box and the crossfade are one move, straight to + // the new width, and the box clips the old label as it goes + if (variant === 'fade') { + el.style.width = `${next}px` + return + } + const widest = Math.max(next, start, out ? width(out) : 0) + el.style.width = `${widest}px` + if (widest === next) return + const id = setTimeout(() => (el.style.width = `${next}px`), 360) + return () => clearTimeout(id) + }, [view.key, reduced, reduced ? children : undefined]) + // The settled render must not wait for an effect: a Stage poster can + // arrive in the same commit that disables its CSS animations. + const { text, head, tail, old, key, numeric } = reduced + ? { text: children, head: 0, tail: 0, old: '', key: 0, numeric: true } + : view + const now = text.slice(head, text.length - tail) + // Characters for numbers, whole words (with their spaces) for text + const units = numeric ? now.split('') : now.split(/(?<=\s)/) + return ( + + {text.slice(0, head)} + + {old && ( + + {old} + + )} + + {key > 0 && variant === 'fade' + ? {now} + : key > 0 + ? units.map((c, i) => ( + + {c} + + )) + : now} + + + {text.slice(text.length - tail)} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css new file mode 100644 index 0000000..cb163ea --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css @@ -0,0 +1,49 @@ +/* Scroll */ + +@layer stageshow { + .ss-scroll { + overflow: hidden; + } + + /* Only once it has scrolled: both edges soften over the rows passing them */ + .ss-scroll[data-scrolled] { + mask-image: linear-gradient(to bottom, transparent, #000 18px, #000 calc(100% - 18px), transparent); + } + + /* Scroll: the overlay bar shows while scrolling and fades once it rests */ + .ss-scroller { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + } + + .ss-scroller > .ss-scroll { + flex: 1; + min-height: 0; + } + + .ss-scroller__thumb { + position: absolute; + top: 0; + right: 2px; + width: 6px; + height: var(--ss-thumb-h, 0); + border-radius: 3px; + background: var(--ss-ink-5); + transform: translateY(var(--ss-thumb-y, 0)); + opacity: 0; + transition: opacity 0.5s ease 0.6s; + pointer-events: none; + } + + .ss-scroller[data-scrolling] .ss-scroller__thumb, + .ss-scroller[data-bar="always"] .ss-scroller__thumb { + opacity: 1; + transition: opacity 0.15s ease; + } + + .ss-stage[data-theme="dark"] .ss-scroller__thumb { + background: rgba(255, 255, 255, 0.28); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx new file mode 100644 index 0000000..461aa9b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx @@ -0,0 +1,24 @@ +import { useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react' +import { thumb } from '../measure' +import './Scroll.css' + +/** + * A scrolling area with a scrollbar, driven by `a.scroll(name, to)`. The bar + * is macOS's overlay bar: hidden at rest, shown while the content moves, fading + * once it stops. `bar="always"` keeps it visible (Windows, or a still that + * needs to say "this scrolls"). Rows cut at an edge soften once it has scrolled. + */ +export function Scroll({ name, bar = 'overlay', className, style, children, ...rest }: { name: string; bar?: 'overlay' | 'always' | 'none'; className?: string; style?: CSSProperties; children: ReactNode } & Record<`data-${string}`, unknown>) { + const ref = useRef(null) + useLayoutEffect(() => { + if (ref.current) thumb(ref.current) + }) + return ( +
+
+ {children} +
+ {bar !== 'none' && } +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/States.css b/skills/imported/benjitaylor/stageshow/kit/motion/States.css new file mode 100644 index 0000000..dbd709d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/States.css @@ -0,0 +1,49 @@ +/* States */ + +@layer stageshow { + .ss-states { + display: inline-grid; + align-items: center; + justify-items: center; + } + + /* The old state hands off to the new one: it's mostly gone as the new one + starts, so they never read as two labels, but the control is never empty */ + .ss-states__item { + display: inline-flex; + grid-area: 1 / 1; + align-items: center; + gap: 6px; + /* The item keeps the line box the plain label would have had: its text + inherits the line-height of the spot it sits in, so a Roll or any + other part that clips to its line (Roll's clip-path does) has a line + to show in. A zero line box here once sized the item to its glyphs, + which also clipped a Roll inside it to nothing and left a text-only + States no height at all. A small line under a name sets its own + line-height, as Row's subtitle does */ + opacity: 0; + filter: blur(2px); + /* Transform travel leaves the grid's baseline in place. Moving margins + let the hidden first item shift the incoming label and adjacent text. */ + will-change: transform; + transform: translate3d(0, 4px, 0); + transition: opacity 0.15s ease, filter 0.15s ease, transform 0s 0.15s; + } + + .ss-states__item[data-on] { + opacity: 1; + filter: none; + transform: translate3d(0, 0, 0); + transition: opacity 0.25s ease-out 0.07s, filter 0.25s ease-out 0.07s, transform 0.3s cubic-bezier(0.22, 1, 0.36, 1) 0.07s; + } + + .ss-states[data-align="start"] { justify-items: start; } + + .ss-states[data-align="end"] { justify-items: end; } + + .ss-states[data-reduced] > .ss-states__item { + transform: translate3d(0, 0, 0); + filter: none; + transition: opacity 0.12s ease; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx new file mode 100644 index 0000000..1894b3c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx @@ -0,0 +1,29 @@ +import type { CSSProperties, ReactNode } from 'react' +import { useReducedMotion } from '../reduced-motion' +import './States.css' + +/** + * The states of one control stacked in one place and crossfaded: a button's + * "Deploy", spinner and "Deployed"; a status's "Building" and "Ready". The + * outgoing state is gone before the incoming one arrives, so they never + * overlap, and the box holds the largest state, so nothing around it shifts. + * Each state keeps the line box a plain label would have in that spot, so a + * `Roll` or `Count` inside a state shows whole; give a small line under a + * name its own line-height, as a subtitle would. + * + * Not for a label centered with an icon in a box that keeps its width (a + * button's "Sending" then "Sent"): the held box leaves the shorter word off + * center. Use `Swap` there, so the group re-centers as the label changes. + */ +export function States({ value, states, align = 'center', className, style, ...rest }: { value: K; states: Record; /** Where a smaller state sits in the box: start for text in a row, center for a button's label, end for a right-aligned value. */ align?: 'start' | 'center' | 'end'; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const reduced = useReducedMotion() + return ( + + {(Object.keys(states) as K[]).map((k) => ( + + {states[k]} + + ))} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css new file mode 100644 index 0000000..605699d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css @@ -0,0 +1,32 @@ +/* Stream */ + +@layer stageshow { + .ss-stream__w { + opacity: 0; + filter: blur(3px); + border-radius: 3px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + transition: opacity 0.35s ease, filter 0.35s ease, background-color 0.3s ease; + } + + .ss-stream__w[data-on] { + opacity: 1; + filter: none; + } + + /* Changed words, joined into runs */ + .ss-stream__w[data-mark] { + background-color: var(--ss-success-soft); + } + + .ss-stream__w[data-join] { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .ss-stream__w[data-joined] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx new file mode 100644 index 0000000..c036578 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx @@ -0,0 +1,30 @@ +import type { CSSProperties } from 'react' +import './Stream.css' + +/** + * Text written in word by word, as a model streams it. Every word is laid out + * from the start in its final place, hidden, so nothing reflows as it + * arrives. `count` is how many words show; drive it from the script. `mark` + * highlights words (what changed in a rewrite), and neighboring marked words + * join into one run; return false for all to fade the marks away. + */ +export function Stream({ text, count, mark, className, style, ...rest }: { text: string; count: number; mark?: (word: string, i: number) => boolean; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const words = text.split(' ') + const marked = words.map((w, i) => !!mark?.(w, i)) + return ( + + {words.map((w, i) => { + const join = marked[i] && marked[i + 1] + return ( + + + {w} + {join ? ' ' : ''} + + {!join && i < words.length - 1 ? ' ' : ''} + + ) + })} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css new file mode 100644 index 0000000..9492b1f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css @@ -0,0 +1,48 @@ +/* Swap */ + +@layer stageshow { + .ss-swap { + position: relative; + display: inline-block; + vertical-align: top; + /* Sideways only: a word can't spill while the box narrows, but a label + trimmed to cap height keeps its overshoot above and below */ + overflow-x: clip; + overflow-y: visible; + } + + /* While the box resizes, its edge is soft: a word passing it fades out + rather than being sliced */ + .ss-swap[data-swapping] { + mask-image: linear-gradient(to right, #000 calc(100% - 8px), transparent); + } + + .ss-swap__in, + .ss-swap__out { + display: block; + width: max-content; + max-width: 100%; + /* A label trimmed to its cap height (text-box on the Swap) trims both + copies alike: the fading one is its own box and would sit lower */ + text-box: inherit; + } + + .ss-swap__out { + position: absolute; + top: 0; + left: 0; + animation: ss-swap-out ease forwards; + } + + .ss-swap__in { + animation: ss-swap-in cubic-bezier(0.25, 0.8, 0.25, 1) both; + } + + @keyframes ss-swap-out { to { opacity: 0; filter: blur(2px); } } + + @keyframes ss-swap-in { 0%, 30% { opacity: 0; filter: blur(2px); } 100% { opacity: 1; filter: none; } } + + @media (prefers-reduced-motion: reduce) { + .ss-swap__in, .ss-swap__out { animation-duration: 1ms !important; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx new file mode 100644 index 0000000..7f0b32d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx @@ -0,0 +1,103 @@ +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { useReducedMotion } from '../reduced-motion' +import './Swap.css' + +/** + * Text (or anything) replaced in place: the old fades out, the new fades in, + * and the box eases between their sizes so the lines around it move with it, + * never snap. Key it on what changed: `{text}`. + */ +export function Swap({ id, children, duration = 450, className, style, ...rest }: { id: string | number; children: ReactNode; duration?: number; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const reduced = useReducedMotion() + const box = useRef(null) + const inner = useRef(null) + const [prev, setPrev] = useState<{ id: string | number; node: ReactNode; w?: number } | null>(null) + // The id on screen, kept in state so a render React runs twice (Strict + // Mode) still hands the old content to the fading copy + const [shownId, setShownId] = useState(id) + const last = useRef(children) + const [size, setSize] = useState<{ w: number; h: number } | null>(null) + // True while a swap's size transition runs: measuring then would cancel it + const swapping = useRef(false) + swapping.current = !reduced && prev !== null + if (shownId !== id) { + // The old words keep the width they had, so they never rewrap as the box + // narrows under them + setPrev(reduced ? null : { id: shownId, node: last.current, w: size?.w }) + setShownId(id) + } + // The content last committed: written after commit, never during a render + // React may repeat, so the fading copy always holds the old words + useLayoutEffect(() => { + last.current = children + }) + // Measured on every change of id, and kept current after: a font or the + // demo's own CSS arriving late changes the size without a swap + useLayoutEffect(() => { + const el = inner.current + if (!el) return + // Measured with the box at its natural size for one synchronous read: the + // box still holds the old size, and longer text measured inside it would + // be squeezed to the old width. Restored before the browser paints. The + // observer skips while a swap runs: switching the transition off for the + // read would cancel the height animation and snap the box to its end. + const measure = (observed?: boolean) => { + const b = box.current + if (!b || (observed && swapping.current)) return + const { width, height, transition } = b.style + const own = { width: el.style.width, maxWidth: el.style.maxWidth } + b.style.transition = 'none' + b.style.width = '' + b.style.height = '' + el.style.width = '' + el.style.maxWidth = '' + // The exact used size, not offsetWidth: that rounds down, and a label + // a fraction of a pixel wider than its box wraps onto two lines. + // Computed style also ignores the stage's own scale + const cs = getComputedStyle(el) + const w = parseFloat(cs.width) || 0 + const h = parseFloat(cs.height) || 0 + el.style.width = own.width + el.style.maxWidth = own.maxWidth + b.style.width = width + b.style.height = height + void b.offsetWidth + b.style.transition = transition + setSize((s) => (s && s.w === w && s.h === h ? s : { w, h })) + } + measure() + const ro = new ResizeObserver(() => measure(true)) + ro.observe(el) + return () => ro.disconnect() + }, [id, reduced]) + useEffect(() => { + if (!prev) return + if (reduced) { + setPrev(null) + return + } + const t = setTimeout(() => setPrev(null), duration) + return () => clearTimeout(t) + }, [prev, duration, reduced]) + // Narrowing, the box waits while the old words fade, so they are never cut + // by its edge mid-word; widening, it opens at once for the new ones + const shrink = !!(prev && size && prev.w !== undefined && size.w < prev.w) + // The soft edge is for words passing a moving edge: an image swapped for + // one the same size keeps its own edges + const resizing = !!(prev && size && prev.w !== undefined && size.w !== prev.w) + return ( + + {!reduced && prev && ( + + {prev.node} + + )} + {/* While the box eases, the new words are already at their final width: + capped by the old box they would wrap until it caught up. The box + clips them sideways meanwhile */} + + {children} + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/index.ts b/skills/imported/benjitaylor/stageshow/kit/motion/index.ts new file mode 100644 index 0000000..f5a18e4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/motion/index.ts @@ -0,0 +1,22 @@ +// Motion: the behaviors a demo is made of, each built to hold up frame by +// frame. A height that opens and carries what follows, text that changes only +// where it changed, a card that opens into a screen, a camera push-in. They +// bring motion, not a look: style what's inside them as the product does. +// One file per part, each importing its own CSS. +export { Connector } from './Connector' +export type { ConnectorProps } from './Connector' +export { Count } from './Count' +export type { CountProps } from './Count' +export { Roll } from './Roll' +export { Swap } from './Swap' +export { Collapse } from './Collapse' +export { Fly } from './Fly' +export type { FlyProps } from './Fly' +export { Follow, cellOf } from './Follow' +export type { FollowProps } from './Follow' +export { States } from './States' +export { Stream } from './Stream' +export { Popover, type PopoverOrigin } from './Popover' +export { Camera } from './Camera' +export { Scroll } from './Scroll' +export { Expand } from './Expand' diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.css b/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.css new file mode 100644 index 0000000..d69ab2d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.css @@ -0,0 +1,102 @@ +/* Avatar */ + +@layer stageshow { + /* Small boxes with text keep a layer of their own. Pushed by a row opening + above them, or carried along by anything that moves by layout, their text + would step whole pixels while the fill glides, and hop about inside it */ + .ss-avatar { + will-change: transform; + } + + .ss-avatar { + display: inline-grid; + flex-shrink: 0; + place-items: center; + overflow: hidden; + border-radius: 50%; + /* Opaque: avatars overlap in a pile, and a translucent fill onion-skins */ + background: var(--ss-fill, var(--ss-ink-3)); + color: var(--ss-text-strong); + font-weight: 600; + line-height: 1; + transition: background 0.3s ease, color 0.3s ease; + } + + .ss-avatar[data-tone="accent"] { + background: var(--ss-accent); + color: #fff; + } + + /* The iOS contact monogram: Apple's gray-blue, lighter at the top */ + .ss-avatar[data-tone="ios"] { + background: linear-gradient(180deg, #a5abb8, #858a96); + color: #fff; + font-family: ui-rounded, 'SF Pro Rounded', -apple-system, system-ui, sans-serif; + font-weight: 600; + letter-spacing: 0.01em; + } + + .ss-avatar[data-tone="success"] { + background: var(--ss-success); + color: #fff; + } + + /* The line box trimmed to cap height and baseline, so a capital centers on + its own ink in any font (a plain line box drops system-ui caps ~7% low) */ + .ss-avatar__initial { + display: block; + text-box: trim-both cap alphabetic; + } + + .ss-avatar img { + width: 100%; + height: 100%; + border-radius: inherit; + object-fit: cover; + } + + /* A ring with a gap cut from whatever the avatar sits on (the gap is the + surface color, so set --ss-ring-bg on a tinted row) */ + .ss-avatar[data-ring] { + box-shadow: 0 0 0 1.5px var(--ss-ring-bg, var(--ss-surface)), 0 0 0 3px var(--ss-ring, var(--ss-ink-3)); + transition: box-shadow 0.35s ease; + } + + .ss-avatar[data-ring="confirmed"] { --ss-ring: var(--ss-success); } + + .ss-avatar[data-ring="declined"] { --ss-ring: var(--ss-danger); } + + /* Waiting: the quiet ring, with an accent arc traveling round it (a + person being asked, a call ringing). It turns smoothly at a steady + pace; the arc fades out as the ring settles to confirmed or declined */ + .ss-avatar[data-ring] { + position: relative; + overflow: visible; + } + + .ss-avatar[data-ring]::after { + content: ''; + position: absolute; + inset: -3px; + border-radius: 50%; + background: conic-gradient(from 0deg, transparent 0 62%, var(--ss-accent) 100%); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px)); + opacity: 0; + transition: opacity 0.3s ease; + animation: ss-avatar-wait 1.1s linear infinite; + pointer-events: none; + } + + .ss-avatar[data-ring="waiting"]::after { + opacity: 1; + } + + @keyframes ss-avatar-wait { + to { transform: rotate(1turn); } + } + + @media (prefers-reduced-motion: reduce) { + .ss-avatar[data-ring]::after { animation: none; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.tsx new file mode 100644 index 0000000..274c03d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Avatar.tsx @@ -0,0 +1,46 @@ +import type { HTMLAttributes } from 'react' +import { useLook } from '../looks' +import './Avatar.css' + +export interface AvatarProps extends HTMLAttributes { + /** Its initial. Leave it out (or set `plain`) for a plain disc. */ + name?: string + /** A plain disc in any fidelity: filler people (assignees, a facepile behind the one that matters), so they don't compete with the named one. */ + plain?: boolean + size?: number + /** person (the default for a named avatar): a muted color of its own, + * picked from the name, as Mail and Messages draw monograms, so a list or a + * pile reads as different people. accent: the person the story is about. + * ink: a gray disc, for filler. ios: the monogram iOS draws for a contact + * with no photo (Contacts, Mail, Messages): a gray-blue gradient, lighter at + * the top, with up to two white initials in a rounded face. */ + tone?: 'accent' | 'ink' | 'success' | 'person' | 'ios' + src?: string + /** A status ring, cut out from the background: pending (a quiet ring), waiting (the quiet ring with an accent arc traveling round it: asked, not yet answered), confirmed (success), declined (danger). It eases between states. */ + ring?: 'pending' | 'waiting' | 'confirmed' | 'declined' +} + +/** A round avatar with an initial, or an image from src. In minimal fidelity, or with `plain`, it is a plain disc. */ +// Six muted, opaque fills that read on light and dark grounds, one per person +const PEOPLE = ['#c98f6e', '#6f9b7d', '#7b86c7', '#c47a9e', '#8c9ab0', '#c2a35a'] +const hue = (name: string) => { let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 9973; return PEOPLE[h % PEOPLE.length] } + +export function Avatar({ name = '', plain = false, size = 24, tone = name ? 'person' : 'ink', src, ring, className, style, ...rest }: AvatarProps) { + const { fidelity } = useLook() + const disc = plain || fidelity === 'minimal' + const person = tone === 'person' && !disc ? { background: hue(name), color: '#fff' } : undefined + // iOS monograms carry two initials, first and last name + const words = name.trim().split(/\s+/).filter(Boolean) + const initials = tone === 'ios' ? (words[0]?.[0] ?? '') + (words.length > 1 ? words[words.length - 1]![0] : '') : name.trim()[0] ?? '' + return ( + + {src && !disc ? : disc ? null : {initials.toUpperCase()}} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.css b/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.css new file mode 100644 index 0000000..acff6e2 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.css @@ -0,0 +1,45 @@ +/* BrandMark */ + +@layer stageshow { + .ss-brand { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + overflow: hidden; + } + + .ss-brand[data-tile] { + background: var(--ss-surface); + box-shadow: inset 0 0 0 1px var(--ss-line); + } + + .ss-brand[data-tile][data-light-tile] { + background: #fff; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%); + } + + /* Logos are optically smaller than their tile, aspect kept */ + .ss-brand img { + display: block; + flex: none; + min-width: 0; + min-height: 0; + width: 62%; + height: 62%; + object-fit: contain; + } + + .ss-brand__initial { + color: var(--ss-text-strong); + font-weight: 600; + } +} + +@layer stageshow { + /* Bare, the mark is the whole footprint */ + .ss-brand:not([data-tile]) img { + width: 100%; + height: 100%; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.tsx new file mode 100644 index 0000000..30de992 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/BrandMark.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties } from 'react' +import { useLook } from '../looks' +import { brandMarks, type BrandName, type BrandAsset } from '../brands' +import './BrandMark.css' + +type CommonProps = { + src?: string + size?: number + width?: number + tile?: boolean + /** The surface behind the mark. Defaults to the Stage theme. */ + surface?: 'light' | 'dark' + className?: string + style?: CSSProperties +} +export type BrandMarkProps = CommonProps & ( + | { brand: BrandName; name?: string } + | { brand?: undefined; name: string } +) + +/** A bundled product mark, or a caller-supplied logo. Original artwork keeps + * its proportions; wide payment marks receive a wider footprint. On a tile the + * mark is inset; bare (`tile={false}`, an app header's logo) it fills `size`. A custom + * name without artwork retains the neutral initial tile. */ +export function BrandMark({ brand, name, src, size = 28, width, tile = true, surface, className, style }: BrandMarkProps) { + const { fidelity, theme } = useLook() + const asset: BrandAsset | undefined = brand ? brandMarks[brand] : undefined + const dark = (surface ?? (theme === 'dark' || theme === 'blueprint' ? 'dark' : 'light')) === 'dark' + const label = name ?? asset?.name ?? '' + const image = src ?? (tile && asset?.tileArtwork ? asset.tileArtwork : dark ? asset?.dark : asset?.light) + const assetSizing = asset && !src + const frameWidth = width ?? (assetSizing ? Math.round(size * asset.proportion) : size) + // Full-color marks without a supplied dark variant keep their original + // artwork on a light tile. Bare marks leave surface choice to the caller. + const lightTile = tile && dark && assetSizing && asset.dark === asset.light + return ( + + {image && fidelity !== 'minimal' + ? + : {fidelity === 'minimal' ? '' : label.trim()[0]?.toUpperCase()}} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Check.css b/skills/imported/benjitaylor/stageshow/kit/parts/Check.css new file mode 100644 index 0000000..ac1741f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Check.css @@ -0,0 +1,94 @@ +/* Check */ + +@layer stageshow { + .ss-check { + display: inline-grid; + flex-shrink: 0; + place-items: center; + } + + .ss-check svg { + overflow: visible; + } + + .ss-check__ring { + fill: none; + stroke: var(--ss-ink-4); + stroke-width: 1.5; + transition: opacity 0.2s ease; + } + + /* Pending: an arc turning inside the ring */ + .ss-check__arc { + fill: none; + stroke: var(--ss-text-strong); + stroke-width: 1.5; + stroke-linecap: round; + stroke-dasharray: 0.28 1; + opacity: 0; + transform-origin: 8px 8px; + animation: ss-spin 0.8s linear infinite; + transition: opacity 0.18s ease; + } + + .ss-check[data-pending] .ss-check__arc { + opacity: 1; + } + + /* Done: a solid disc grows from the center over the ring, then the tick + draws on it. No frame is a half-faded fill under a fading spinner. */ + .ss-check__fill { + fill: var(--ss-success); + opacity: 0; + transform: scale(0.35); + transform-origin: 8px 8px; + transition: opacity 0.2s ease, transform 0.2s ease; + } + + .ss-check[data-on] .ss-check__fill { + opacity: 1; + transform: scale(1); + transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0s; + } + + /* The arc gets out of the way as the disc starts */ + .ss-check[data-on] .ss-check__arc { + transition-duration: 0.08s; + } + + .ss-check[data-on] .ss-check__ring { + opacity: 0; + } + + .ss-check__tick { + fill: none; + stroke: #fff; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 1; + stroke-dashoffset: 1; + opacity: 0; + transition: opacity 0.2s ease; + } + + /* On blueprint the disc is white, so the tick takes the ground */ + .ss-stage[data-theme="blueprint"] .ss-check__tick { + stroke: var(--ss-well); + } + + /* The tick draws in once the disc is mostly there, and fades out intact */ + .ss-check[data-on] .ss-check__tick { + stroke-dashoffset: 0; + opacity: 1; + transition: stroke-dashoffset 0.32s cubic-bezier(0.65, 0, 0.35, 1) 0.16s, opacity 0.01s 0.16s; + } + + .ss-stage[data-palette="mono"][data-theme="dark"] .ss-check__tick { + stroke: #111; + } + + @media (prefers-reduced-motion: reduce) { + .ss-check__arc { animation: none; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Check.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Check.tsx new file mode 100644 index 0000000..dcc06b0 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Check.tsx @@ -0,0 +1,21 @@ +import type { HTMLAttributes } from 'react' +import './Check.css' + +/** + * A status circle. `pending` turns an arc inside the ring; `on` fills the + * circle from its center and then draws the tick. Pending hands over to done + * in one mark, never a spinner fading out over a fill fading in. The tick + * fades out intact rather than undrawing. + */ +export function Check({ on, pending, size = 16, className, ...rest }: HTMLAttributes & { on: boolean; pending?: boolean; size?: number }) { + return ( + + + + + + + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Chip.css b/skills/imported/benjitaylor/stageshow/kit/parts/Chip.css new file mode 100644 index 0000000..8b5057f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Chip.css @@ -0,0 +1,66 @@ +/* Chip */ + +@layer stageshow { + /* Small boxes with text keep a layer of their own. Pushed by a row opening + above them, or carried along by anything that moves by layout, their text + would step whole pixels while the fill glides, and hop about inside it */ + .ss-chip { + will-change: transform; + } + + .ss-chip-slot { + display: inline-flex; + flex: none; + overflow: hidden; + border-radius: var(--ss-radius-pill); + opacity: 0; + /* Closing, it fades first and the gap closes behind it, so the narrowing + edge never slices a visible chip */ + transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s, margin 0.4s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s, opacity 0.14s ease; + } + + /* Opening, it shows once the slot is nearly its width, so the edge never + slices a visible chip */ + .ss-chip-slot[data-show] { + opacity: 1; + transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), margin 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.22s ease 0.16s; + } + + .ss-chip-slot > .ss-chip { + flex: none; + } + + .ss-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border-radius: var(--ss-radius-pill); + background: var(--ss-accent-soft); + color: var(--ss-text-strong); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + } + + /* The x's round hover sits as far from the end as the label from the start, optically */ + .ss-chip[data-removable] { + padding-right: 4px; + } + + .ss-chip__x { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + border-radius: 50%; + color: var(--ss-text); + transition: background-color 0.15s ease; + } + + .ss-chip__x[data-hover] { + background: var(--ss-ink-2); + transition-duration: 0s; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Chip.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Chip.tsx new file mode 100644 index 0000000..4170bc3 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Chip.tsx @@ -0,0 +1,29 @@ +import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { Icon } from './Icon' +import './Chip.css' + +/** + * A filter chip with a remove x. It arrives by opening its own width (the + * chips after it slide over rather than jump) and leaves the same way. + * Name the x with `remove` so the cursor can click it. + */ +export function Chip({ show = true, remove, gap = 0, children, className, style, ...rest }: { show?: boolean; remove?: string; /** The row's own gap, so a removed chip takes no space at all. */ gap?: number; children: ReactNode; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + // Measured, so the slot opens to exactly the chip's width wherever it sits (a flex row would shrink a CSS-only version) + const chip = useRef(null) + const [w, setW] = useState(null) + useLayoutEffect(() => { + if (chip.current) setW(chip.current.offsetWidth) + }, [children]) + return ( + + + {children} + {remove !== undefined && ( + + + + )} + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.css b/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.css new file mode 100644 index 0000000..65ba91d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.css @@ -0,0 +1,17 @@ +/* Facepile */ + +@layer stageshow { + /* Facepile: each face is cut out where the next overlaps it (a mask, not a ring) */ + .ss-facepile { + display: inline-flex; + align-items: center; + } + + .ss-facepile__face { + display: flex; + } + + .ss-facepile__face + .ss-facepile__face { + margin-left: var(--ss-overlap); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.tsx new file mode 100644 index 0000000..11ca372 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Facepile.tsx @@ -0,0 +1,26 @@ +import type { CSSProperties } from 'react' +import { Avatar, type AvatarProps } from './Avatar' +import './Facepile.css' + +/** + * Overlapping avatars, each one cut out where the next sits on it: a real + * gap, so whatever is behind shows through (a toolbar, a row that turns + * active), never a ring painted in a color that only matches one background. + * Each face takes a muted color of its own (`tone="person"`), so three gray + * discs with sliced letters never read as one blob. + */ +export function Facepile({ names, size = 22, overlap = 6, gap = 2, tones, className, style, ...rest }: { names: string[]; size?: number; overlap?: number; gap?: number; tones?: AvatarProps['tone'][]; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const r = size / 2 + // Center of the next avatar, measured from this one's left edge + const next = size - overlap + r + const mask = `radial-gradient(circle at ${next}px 50%, transparent ${r + gap}px, #000 ${r + gap + 0.5}px)` + return ( + + {names.map((n, i) => ( + + + + ))} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Icon.css b/skills/imported/benjitaylor/stageshow/kit/parts/Icon.css new file mode 100644 index 0000000..598a8ae --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Icon.css @@ -0,0 +1,8 @@ +/* Icon */ + +@layer stageshow { + .ss-icon { + display: block; + flex-shrink: 0; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Icon.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Icon.tsx new file mode 100644 index 0000000..5864fdf --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Icon.tsx @@ -0,0 +1,65 @@ +import type { CSSProperties } from 'react' +import './Icon.css' +import { Archive, ArrowRight, ArrowUpRight, Bell, Calendar, Check, ChevronDown, ChevronRight, Clock, CreditCard, Ellipsis, File, Globe, GripVertical, Heart, House, Lock, Mail, Pause, Play, Plus, ScanFace, Search, Send, Settings, Star, Trash2, User, Wifi, X, Zap } from 'lucide-react' + +// Glyphs come from lucide, one family at one construction, so a row of icons +// shares a weight. Two card-specific drawings (a chip, contactless arcs) stay +// hand-drawn because no set has them. +const LUCIDE = { + check: Check, plus: Plus, close: X, 'chevron-right': ChevronRight, 'chevron-down': ChevronDown, search: Search, globe: Globe, + external: ArrowUpRight, clock: Clock, archive: Archive, trash: Trash2, more: Ellipsis, arrow: ArrowRight, file: File, user: User, + bell: Bell, card: CreditCard, bolt: Zap, lock: Lock, play: Play, pause: Pause, faceid: ScanFace, wifi: Wifi, heart: Heart, + star: Star, calendar: Calendar, mail: Mail, send: Send, home: House, gear: Settings, grip: GripVertical, +} as const +const DRAWN = { + chip: 'M3.5 2.5h9a1.5 1.5 0 0 1 1.5 1.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12V4a1.5 1.5 0 0 1 1.5-1.5zM2 6h4M2 10h4M10 6h4M10 10h4M6 2.5v11M10 2.5v11M6 8h4', + contactless: 'M6.8 6.2a2.5 2.5 0 0 1 0 3.6M8.5 4.5a5 5 0 0 1 0 7M10.3 2.7a7.5 7.5 0 0 1 0 10.6', +} as const +type LucideName = keyof typeof LUCIDE +export type IconName = LucideName | keyof typeof DRAWN + +// Solid versions, for small marks and tinted tiles where a thin outline goes +// wiry. Cut-outs use the even-odd rule. +const FILLED: Partial> = { + bolt: 'M9.6 1.2 3.2 8.9a.6.6 0 0 0 .5 1H7.4l-1 4.9a.4.4 0 0 0 .7.3l6.4-7.7a.6.6 0 0 0-.5-1H9.3l1-4.9a.4.4 0 0 0-.7-.3Z', + card: 'M3 3h10a1.75 1.75 0 0 1 1.75 1.75V5.6H1.25v-.85A1.75 1.75 0 0 1 3 3Zm-1.75 4.4h13.5v3.85A1.75 1.75 0 0 1 13 13H3a1.75 1.75 0 0 1-1.75-1.75Zm2.25 2.6v1h3v-1Z', + bell: 'M8 1.25a4.25 4.25 0 0 0-4.25 4.25v3.1l-1.3 2.1a.6.6 0 0 0 .5.9h10.1a.6.6 0 0 0 .5-.9l-1.3-2.1V5.5A4.25 4.25 0 0 0 8 1.25ZM6.2 13.1a1.9 1.9 0 0 0 3.6 0Z', + user: 'M8 1.5a3.1 3.1 0 1 1 0 6.2 3.1 3.1 0 0 1 0-6.2Zm0 7.4c3.1 0 5.4 1.8 5.9 4.6a.7.7 0 0 1-.7.8H2.8a.7.7 0 0 1-.7-.8C2.6 10.7 4.9 8.9 8 8.9Z', + clock: 'M8 1.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5Zm-.75 3v4.2l2.9 1.75.75-1.25-2.15-1.3V4.25Z', + check: 'M8 1.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5Zm3 4.3-.95-.8-3.3 3.95-1.85-1.8-.85.9 2.8 2.75Z', + // SF Symbols-style solids for Apple-native demos + lock: 'M8 1.25A3.25 3.25 0 0 0 4.75 4.5v2H4.5A1.5 1.5 0 0 0 3 8v5.25a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5V8a1.5 1.5 0 0 0-1.5-1.5h-.25v-2A3.25 3.25 0 0 0 8 1.25Zm-1.75 5.25v-2a1.75 1.75 0 0 1 3.5 0v2Z', + play: 'M4.5 2.6v10.8a.8.8 0 0 0 1.2.7l8.6-5.4a.8.8 0 0 0 0-1.4L5.7 1.9a.8.8 0 0 0-1.2.7Z', + pause: 'M4 2.5h2.6a.6.6 0 0 1 .6.6v9.8a.6.6 0 0 1-.6.6H4a.6.6 0 0 1-.6-.6V3.1a.6.6 0 0 1 .6-.6Zm5.4 0H12a.6.6 0 0 1 .6.6v9.8a.6.6 0 0 1-.6.6H9.4a.6.6 0 0 1-.6-.6V3.1a.6.6 0 0 1 .6-.6Z', + heart: 'M8 14.2c-.2 0-6.6-3.7-6.6-8.4a3.6 3.6 0 0 1 6.6-2 3.6 3.6 0 0 1 6.6 2c0 4.7-6.4 8.4-6.6 8.4Z', + star: 'M8 1.2a.6.6 0 0 1 .55.35l1.75 3.6 3.95.55a.6.6 0 0 1 .33 1.03l-2.86 2.8.68 3.94a.6.6 0 0 1-.87.63L8 12.25 4.47 14.1a.6.6 0 0 1-.87-.63l.68-3.94-2.86-2.8a.6.6 0 0 1 .33-1.03l3.95-.55 1.75-3.6A.6.6 0 0 1 8 1.2Z', + home: 'M7.5 1.6a.8.8 0 0 1 1 0l5.7 4.6a.8.8 0 0 1 .3.6v6.7a1 1 0 0 1-1 1H10V10.3H6v4.2H2.5a1 1 0 0 1-1-1V6.8a.8.8 0 0 1 .3-.6Z', + gear: 'M6.67 2.46L7.03 0.81L8.97 0.81L9.33 2.46L10.98 3.14L12.40 2.24L13.76 3.60L12.86 5.02L13.54 6.67L15.19 7.03L15.19 8.97L13.54 9.33L12.86 10.98L13.76 12.40L12.40 13.76L10.98 12.86L9.33 13.54L8.97 15.19L7.03 15.19L6.67 13.54L5.02 12.86L3.60 13.76L2.24 12.40L3.14 10.98L2.46 9.33L0.81 8.97L0.81 7.03L2.46 6.67L3.14 5.02L2.24 3.60L3.60 2.24L5.02 3.14ZM5.60 8.00a2.40 2.40 0 1 0 4.80 0a2.40 2.40 0 1 0 -4.80 0Z', + calendar: 'M3.5 2h9A2 2 0 0 1 14.5 4v8.5a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm-.5 4.5v6a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-6ZM4.2 7.6h1.6v1.6h-1.6ZM7.2 7.6h1.6v1.6h-1.6ZM10.2 7.6h1.6v1.6h-1.6ZM4.2 10.2h1.6v1.6h-1.6ZM7.2 10.2h1.6v1.6h-1.6ZM10.2 10.2h1.6v1.6h-1.6Z', + send: 'M14.3 1.7a.55.55 0 0 0-.6-.12L1.75 6.4a.55.55 0 0 0 0 1.02l4.6 1.85 4.9-4.13a.2.2 0 0 1 .28.28L7.4 10.3l1.84 4.6a.55.55 0 0 0 1.02 0l4.83-11.95a.55.55 0 0 0-.12-.6Z', + mail: 'M2.5 3h11A1.5 1.5 0 0 1 15 4.4L8 8.9 1 4.4A1.5 1.5 0 0 1 2.5 3ZM1 6.1v5.4A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.1l-6.6 4.2a.8.8 0 0 1-.8 0Z', +} + +/** + * A small icon in currentColor, drawn by lucide at an absolute stroke so every + * glyph in a row shares one weight. `filled` gives a solid glyph, which reads + * better at 14px and under and inside tinted tiles. These cover basics only: + * use the product's own icon pack for the rest. + */ +export function Icon({ name, size = 16, stroke = 1.5, filled, className, style }: { name: IconName; size?: number; stroke?: number; filled?: boolean; className?: string; style?: CSSProperties }) { + const solid = filled ? FILLED[name] : undefined + const cls = `ss-icon${className ? ` ${className}` : ''}` + if (!solid && name in LUCIDE) { + const Glyph = LUCIDE[name as LucideName] + return + } + return ( + + {solid ? ( + + ) : ( + + )} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Menu.css b/skills/imported/benjitaylor/stageshow/kit/parts/Menu.css new file mode 100644 index 0000000..afb8ad9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Menu.css @@ -0,0 +1,34 @@ +/* Menu */ + +@layer stageshow { + .ss-menu { + display: flex; + flex-direction: column; + } + + .ss-menu__item { + display: flex; + align-items: center; + gap: 8px; + height: var(--ss-row); + padding: 0 8px; + border-radius: var(--ss-radius-sm); + font-size: 12px; + color: var(--ss-text-strong); + transition: background-color 0.12s ease; + } + + .ss-menu__item[data-active] { + background-color: var(--ss-ink-2); + } + + /* The item under the cursor highlights, as in a real menu; fast in, eased out */ + .ss-menu__item { + transition: background-color 0.15s ease; + } + + .ss-menu__item[data-hover] { + background-color: var(--ss-ink-2); + transition-duration: 0s; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Menu.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Menu.tsx new file mode 100644 index 0000000..50ed686 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Menu.tsx @@ -0,0 +1,15 @@ +import type { CSSProperties, ReactNode } from 'react' +import './Menu.css' + +/** A menu: rows for a Popover. `value` is the highlighted row (hover or keyboard); give a row `data-target` to click it. `targets` names each item for the cursor; an item highlights while the cursor is over it, as a real menu does. */ +export function Menu({ items, value, targets, className, style, ...rest }: { items: ReactNode[]; value?: number; targets?: (string | undefined)[]; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + return ( +
+ {items.map((item, i) => ( +
+ {item} +
+ ))} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Meter.css b/skills/imported/benjitaylor/stageshow/kit/parts/Meter.css new file mode 100644 index 0000000..f7176c9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Meter.css @@ -0,0 +1,46 @@ +/* Meter */ + +@layer stageshow { + .ss-meter { + display: block; + height: 6px; + overflow: hidden; + border-radius: 3px; + background: var(--ss-ink-1); + } + + .ss-meter__fill { + display: block; + width: 100%; + height: 100%; + background: linear-gradient(90deg, var(--ss-accent), var(--ss-accent-2, var(--ss-accent))); + transition: clip-path 0.8s cubic-bezier(0.45, 0, 0.2, 1); + } + + /* Tones are a layer over the accent gradient that fades in and out: a + gradient can't transition to a flat color, so swapping backgrounds snaps */ + .ss-meter__fill { + position: relative; + } + + .ss-meter__fill::after { + content: ''; + position: absolute; + inset: 0; + background-color: var(--ss-meter-tone, var(--ss-accent)); + opacity: 0; + transition: opacity 0.4s ease, background-color 0.4s ease; + } + + .ss-meter[data-tone="danger"] { --ss-meter-tone: var(--ss-danger); } + + .ss-meter[data-tone="warning"] { --ss-meter-tone: var(--ss-warning); } + + .ss-meter[data-tone="success"] { --ss-meter-tone: var(--ss-success); } + + .ss-meter[data-tone="ink"] { --ss-meter-tone: var(--ss-ink-5); } + + .ss-meter:not([data-tone="accent"]) .ss-meter__fill::after { + opacity: 1; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Meter.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Meter.tsx new file mode 100644 index 0000000..b3603f9 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Meter.tsx @@ -0,0 +1,16 @@ +import type { CSSProperties } from 'react' +import './Meter.css' + +/** + * A usage bar that fills to `value` (0 to 1) with an accent gradient. `tone` + * recolors the fill (a password meter going danger, warning, success); the + * color eases as the fill moves. + */ +export function Meter({ value, tone = 'accent', className, style, ...rest }: { value: number; tone?: 'accent' | 'danger' | 'warning' | 'success' | 'ink'; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + return ( + + {/* Revealed with clip-path rather than width, so nothing re-lays out and the end stays round */} + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Pin.css b/skills/imported/benjitaylor/stageshow/kit/parts/Pin.css new file mode 100644 index 0000000..a38940e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Pin.css @@ -0,0 +1,58 @@ +/* Pin */ + +@layer stageshow { + /* The rim and the face share one shape: a rounded square whose pointed + corner is square. Percentage radii keep the inner shape nested inside + the outer one at any size, so the corner never pokes out */ + .ss-pin { + --ss-pin-r: 50% 50% 50% 0; + position: relative; + display: inline-grid; + flex-shrink: 0; + place-items: center; + padding: 2px; + box-sizing: border-box; + border-radius: var(--ss-pin-r); + background: var(--ss-surface); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 2px 6px -1px rgba(0, 0, 0, 0.25); + will-change: transform; + transition: box-shadow 0.3s ease; + } + + .ss-pin[data-corner="br"] { --ss-pin-r: 50% 50% 0 50%; } + .ss-pin[data-corner="tl"] { --ss-pin-r: 0 50% 50% 50%; } + .ss-pin[data-corner="tr"] { --ss-pin-r: 50% 0 50% 50%; } + + .ss-pin__face { + display: grid; + place-items: center; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: var(--ss-pin-r); + background: var(--ss-ink-3); + color: var(--ss-text-strong); + font-weight: 600; + line-height: 1; + } + + .ss-pin[data-tone="accent"] .ss-pin__face { + background: var(--ss-accent); + color: #fff; + } + + .ss-pin__face img { + width: 100%; + height: 100%; + object-fit: cover; + } + + /* The open pin: a ring in its own color, with the surface showing between */ + .ss-pin[data-selected] { + box-shadow: 0 0 0 1.5px var(--ss-surface), 0 0 0 3px var(--ss-accent), 0 2px 6px -1px rgba(0, 0, 0, 0.25); + } + + .ss-pin[data-selected][data-tone="ink"] { + box-shadow: 0 0 0 1.5px var(--ss-surface), 0 0 0 3px var(--ss-ink-5), 0 2px 6px -1px rgba(0, 0, 0, 0.25); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Pin.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Pin.tsx new file mode 100644 index 0000000..d6d3d98 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Pin.tsx @@ -0,0 +1,39 @@ +import type { HTMLAttributes } from 'react' +import { useLook } from '../looks' +import './Pin.css' + +export interface PinProps extends HTMLAttributes { + /** The commenter's initial. */ + name?: string + src?: string + size?: number + /** Which corner is the point, the one that touches what the comment is about. */ + corner?: 'bl' | 'br' | 'tl' | 'tr' + /** accent: the pin the story is about. ink: the others. */ + tone?: 'accent' | 'ink' + /** The pin whose thread is open: a ring in its own color. */ + selected?: boolean +} + +/** + * A comment pin: one rounded square with a single square corner, the way + * design tools draw them, with the face clipped to the same shape. Never a + * disc with a corner stuck on. Position it yourself; the square corner is the + * point, so place that corner on the thing the comment is about. + */ +export function Pin({ name = '', src, size = 24, corner = 'bl', tone = 'accent', selected, className, style, ...rest }: PinProps) { + const { fidelity } = useLook() + const plain = fidelity === 'minimal' + return ( + + {src && !plain ? : plain ? null : name.trim()[0]?.toUpperCase()} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Row.css b/skills/imported/benjitaylor/stageshow/kit/parts/Row.css new file mode 100644 index 0000000..abcf2e4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Row.css @@ -0,0 +1,211 @@ +/* Row */ + +@layer stageshow { + /* A grid: the lead sits in the first row beside the title and subtitle + and is centered on exactly those two lines, whatever their size; any + further lines (preview bars) go under the text, not under the lead */ + .ss-row { + --ss-row-pad: 12px; + /* The type scale: desktop sizes by default, iOS sizes on a phone screen */ + --ss-row-title: 12px; + --ss-row-subtitle: 11.5px; + --ss-row-meta: 11px; + --ss-row-line: 16px; + --ss-row-line-2: 16px; + --ss-row-gap: 2px; + --ss-row-dot: 6px; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + column-gap: 10px; + padding: var(--ss-row-pad) var(--ss-row-pad); + border-radius: var(--ss-radius-lg); + will-change: transform; + transition: background 0.2s ease; + } + + /* An unread dot hangs in the margin, 7px left of the lead (below): it + never moves the row. The list needs a few px of its own padding for the + dot to sit in. A product that reserves a dot column on every row (Mail, + Messages) pads the whole list; never only the unread rows */ + + .ss-row[data-selected], + .ss-row[data-hover] { + background: var(--ss-ink-1); + } + + /* The lead and the dot sit on the name and its line, not the middle of + the row: a row that runs to three or four lines keeps them up with the + name, the way Mail does */ + .ss-row[data-lead] { + grid-template-columns: auto minmax(0, 1fr); + } + + /* Anything else in the row (a toggle, a chevron, a badge) trails on the + first line's row, at the end, centered on the pair */ + .ss-row > :not(.ss-row__lead):not(.ss-row__body):not(.ss-row__dot):not(.ss-row__preview) { + grid-row: 1; + align-self: center; + justify-self: end; + } + + /* The dot takes the first cell as its box, so its middle is the lead's */ + .ss-row__dot { + position: absolute; + /* Both lines named: an open end would run the box to the row's bottom */ + grid-row: 1 / 2; + grid-column: 1 / 2; + z-index: 1; + /* 7px clear of the lead, whatever size the dot is */ + left: calc(-7px - var(--ss-row-dot)); + top: calc(50% - 3px); + width: var(--ss-row-dot); + height: var(--ss-row-dot); + margin-top: calc(3px - var(--ss-row-dot) / 2); + border-radius: 50%; + background: var(--ss-accent); + } + + /* On a phone the row is an iOS list row: 17pt name, 15pt lines, a 10pt dot */ + .ss-phone__screen .ss-row { + --ss-row-title: 17px; + --ss-row-subtitle: 15px; + --ss-row-meta: 15px; + --ss-row-line: 22px; + --ss-row-line-2: 20px; + --ss-row-gap: 0px; + --ss-row-dot: 10px; + } + + .ss-row__lead { + grid-row: 1 / 2; + grid-column: 1 / 2; + display: inline-flex; + align-items: center; + } + + /* The two lines are one item: 2px between 16px line boxes gives the ink + 4 to 6px of air; the row's padding carries the rest. A title on its own + is one line: the row is the title plus its padding, with no second line + reserved (a file list, a settings list), so one-line rows don't read fat */ + .ss-row__body { + grid-row: 1; + display: flex; + flex-direction: column; + gap: var(--ss-row-gap); + min-width: 0; + justify-content: center; + } + + /* With a subtitle or preview lines the body holds the two-line pitch even + when a line is a bar or shorter type, so rows in one list line up */ + .ss-row:has(.ss-row__subtitle, .ss-row__preview) .ss-row__body { + min-height: calc(var(--ss-row-line) + var(--ss-row-line-2) + var(--ss-row-gap)); + } + + .ss-row__head { + display: flex; + align-items: center; + gap: 8px; + min-height: var(--ss-row-line); + } + + .ss-row__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--ss-row-title); + font-weight: 600; + line-height: var(--ss-row-line); + color: var(--ss-text-strong); + } + + .ss-row__meta { + flex: none; + font-size: var(--ss-row-meta); + line-height: var(--ss-row-line); + color: var(--ss-text-faint); + } + + .ss-row__subtitle { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--ss-row-subtitle); + line-height: var(--ss-row-line-2); + color: var(--ss-text); + } + + /* A bar standing in for a line keeps the line's pitch: 6px bar, 5px each side */ + /* Preview lines: under the subtitle, in the text column, at its pitch */ + .ss-row__preview { + grid-row: 2; + display: grid; + justify-items: start; + row-gap: calc(var(--ss-row-line-2) - 6px); + padding-top: calc((var(--ss-row-line-2) - 6px) / 2); + } + + /* Real preview text: two lines at the subtitle's size, then an ellipsis */ + .ss-row__preview[data-text] { + display: -webkit-box; + overflow: hidden; + padding-top: 0; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: var(--ss-row-subtitle); + line-height: var(--ss-row-line-2); + color: var(--ss-text); + } + + .ss-row[data-lead] .ss-row__preview { + grid-column: 2; + } + + .ss-row__preview > .ss-bar { + display: block; + } + + /* With preview lines the lead and the dot sit a little lower: centered on + the name, the subtitle and the first preview line, where Mail puts a + contact photo, neither hugging the name nor sunk to the row's middle */ + .ss-row:has(.ss-row__preview) .ss-row__lead, + .ss-row:has(.ss-row__preview) .ss-row__dot { + grid-row: 1 / 3; + align-self: start; + } + + /* One height for both, so a product that raises the lead (Mail centers + it on the sender and subject) carries the dot with it by setting + --ss-row-lead-h alone */ + .ss-row:has(.ss-row__preview) { + --ss-row-lead-h: calc(var(--ss-row-line) + var(--ss-row-line-2) * 2 + var(--ss-row-gap)); + } + + .ss-row:has(.ss-row__preview) .ss-row__lead { + height: var(--ss-row-lead-h); + } + + .ss-row:has(.ss-row__preview) .ss-row__dot { + top: calc(var(--ss-row-lead-h) / 2 - 3px); + } + + .ss-row__title > .ss-bar { + display: block; + margin: calc((var(--ss-row-line) - 6px) / 2) 0; + } + + .ss-row__subtitle > .ss-bar { + display: block; + margin: calc((var(--ss-row-line-2) - 6px) / 2) 0; + } + + .ss-row__meta > .ss-bar { + display: block; + margin: calc((var(--ss-row-line) - 6px) / 2) 0; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Row.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Row.tsx new file mode 100644 index 0000000..62d3652 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Row.tsx @@ -0,0 +1,67 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react' +import { Bar } from '../window' +import './Row.css' + +export interface RowProps extends Omit, 'title'> { + /** The disc on the left: an `Avatar`, an icon tile, a thumbnail. */ + lead?: ReactNode + /** The first line: a name, a title. A `Text` or a `Bar`. */ + title: ReactNode + /** The second line: a preview, a description. A `Text` or a `Bar`. */ + subtitle?: ReactNode + /** Trailing detail on the first line: a time, a count, a badge. */ + meta?: ReactNode + /** Preview lines under the subtitle: bar widths in px for placeholder (Mail's two-line preview: `[220, 150]`), or the real text. */ + preview?: number[] | ReactNode + /** An unread dot hanging in the margin left of the lead; the row never moves. */ + unread?: boolean + selected?: boolean + /** Inset of the row's fill, in px. 12 by default; a dense list takes 8. */ + pad?: number +} + +/** + * One row of a list: an inbox conversation, a sidebar item, a table line. The + * anatomy is fixed so a list reads as a product and not a wireframe: the lead + * centered on the two lines, the title 4 to 6px above its subtitle (the air + * comes from the row's padding, not the gap), the meta on the first line, and + * an unread dot hanging in the margin, so the row never moves. A lead and the dot + * sit on the name's two lines, not the middle of a taller row; anything + * passed as children (a toggle, a chevron) trails at the end of that line. + * A title with no subtitle is a one-line row: the title plus the padding, no + * second line reserved, so a file or settings list stays slim. + * More lines under the subtitle (preview bars) go inside `subtitle`. Selected and hovered + * rows take an inset, rounded fill. Build lists out of it instead of by hand. + */ +export function Row({ lead, title, subtitle, meta, preview, unread, selected, pad = 12, className, style, children, ...rest }: RowProps) { + return ( +
+ {unread && } + {lead && {lead}} + + + {title} + {meta && {meta}} + + {subtitle && {subtitle}} + + {preview && ( + Array.isArray(preview) && preview.every((w) => typeof w === 'number') ? ( + + {preview.map((w, i) => )} + + ) : ( + {preview} + ) + )} + {children} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.css b/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.css new file mode 100644 index 0000000..0213359 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.css @@ -0,0 +1,12 @@ +/* Spinner */ + +@layer stageshow { + .ss-spinner { + flex: none; + animation: ss-spin 0.8s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + .ss-spinner { animation: none; } + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.tsx new file mode 100644 index 0000000..4772300 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Spinner.tsx @@ -0,0 +1,12 @@ +import type { CSSProperties } from 'react' +import './Spinner.css' + +/** A pending indicator: a thin arc turning, in the current color. Holds still under reduced motion. Swap it for a `Check` when the thing finishes. */ +export function Spinner({ size = 14, className, style, ...rest }: { size?: number; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + return ( + + + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Toast.css b/skills/imported/benjitaylor/stageshow/kit/parts/Toast.css new file mode 100644 index 0000000..1ddc585 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Toast.css @@ -0,0 +1,35 @@ +/* Toast */ + +@layer stageshow { + .ss-toast { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: var(--ss-radius-lg); + background: #1c1c1f; + box-shadow: var(--ss-shadow-satellite); + color: #fff; + font-size: 12px; + opacity: 0; + /* Rises by margin on a layer it keeps: the equal and opposite margins move + it the same whether it's placed by its top, its bottom or the flow, and + nothing around it moves. The layer moves box and text as one picture; + without it the text steps whole pixels inside the gliding box */ + will-change: transform; + margin-block: var(--ss-toast-rise, 10px) calc(-1 * var(--ss-toast-rise, 10px)); + transition: opacity 0.35s ease, margin 0.6s cubic-bezier(0.22, 1, 0.36, 1); + } + + .ss-toast[data-show] { opacity: 1; margin-block: 0; } + + .ss-toast__icon { display: grid; opacity: 0.8; } + + .ss-toast__action { margin-left: 6px; color: rgba(255, 255, 255, 0.6); font-weight: 500; } + + [data-theme='dark'] .ss-toast, + .ss-stage[data-theme='dark'] .ss-toast { + background: var(--ss-raised); + color: var(--ss-text-strong); + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Toast.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Toast.tsx new file mode 100644 index 0000000..1628017 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Toast.tsx @@ -0,0 +1,22 @@ +import type { CSSProperties, ReactNode } from 'react' +import { useLook } from '../looks' +import './Toast.css' + +/** + * A confirmation that rises in from the bottom of whatever it sits in: "Snoozed + * until 8:00 AM", "Link copied". Dark pill on light themes, raised surface on + * dark. Position its container where the product shows toasts; it stays in + * the DOM and fades, so it never pops. + */ +export function Toast({ show, icon, children, action, className, style, ...rest }: { show: boolean; icon?: ReactNode; children: ReactNode; action?: ReactNode; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const { fidelity } = useLook() + // Minimal: words become a placeholder line, as everywhere else + if (fidelity === 'minimal' && typeof children === 'string') children = + return ( +
+ {icon && {icon}} + {children} + {action && {action}} +
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.css b/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.css new file mode 100644 index 0000000..81bbd46 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.css @@ -0,0 +1,42 @@ +/* Tooltip */ + +@layer stageshow { + .ss-tooltip { + position: absolute; + z-index: 20; + display: flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: var(--ss-radius-sm); + background: #26262a; + color: #fff; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translate(-50%, -2px); + transition: opacity 0.1s ease, transform 0.1s ease; + } + + .ss-tooltip[data-side='above'] { + transform: translate(-50%, calc(-100% + 2px)); + } + + .ss-tooltip[data-show] { + opacity: 1; + transform: translate(-50%, 0); + transition-duration: 0.15s; + } + + .ss-tooltip[data-side='above'][data-show] { + transform: translate(-50%, -100%); + } + + /* Centered on its letters, not its line box */ + .ss-tooltip__label { + display: block; + text-box: trim-both cap alphabetic; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.tsx b/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.tsx new file mode 100644 index 0000000..e3c032e --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/Tooltip.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties, ReactNode } from 'react' +import type { Rect } from '../measure' +import './Tooltip.css' + +/** + * A small dark label naming the control under the cursor: centered on the + * target's rect (from rect()), just below it or above it. It comes in fast + * a few px (`gap`) from the target's side, as interface feedback does, and leaves + * faster. Show it only while the cursor rests on the target; hide it on the + * click. A tooltip names an icon; it never carries the story. + */ +export function Tooltip({ show, at, side = 'below', gap = 6, children, className, style, ...rest }: { show: boolean; at: Rect; side?: 'below' | 'above'; gap?: number; children: ReactNode; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) { + const x = at.x + at.width / 2 + const y = side === 'below' ? at.y + at.height + gap : at.y - gap + return ( + 0) || undefined} data-side={side} style={{ left: x, top: y, ...style }} role="tooltip"> + {children} + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/parts/index.ts b/skills/imported/benjitaylor/stageshow/kit/parts/index.ts new file mode 100644 index 0000000..e480174 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/parts/index.ts @@ -0,0 +1,22 @@ +// Small parts: avatars, status marks, a few icons, a meter, chips. Starting +// points with quiet defaults, there so a demo doesn't hand-draw the same +// circle twice. Restyle or replace them to match the product being shown. +// One file per part, each importing its own CSS. +export { Avatar } from './Avatar' +export type { AvatarProps } from './Avatar' +export { Check } from './Check' +export { Icon } from './Icon' +export type { IconName } from './Icon' +export { Spinner } from './Spinner' +export { Toast } from './Toast' +export { Tooltip } from './Tooltip' +export { Meter } from './Meter' +export { Menu } from './Menu' +export { Facepile } from './Facepile' +export { Chip } from './Chip' +export { BrandMark } from './BrandMark' +export { Pin } from './Pin' +export type { PinProps } from './Pin' +export { Row } from './Row' +export type { RowProps } from './Row' +export type { BrandMarkProps } from './BrandMark' diff --git a/skills/imported/benjitaylor/stageshow/kit/phone.css b/skills/imported/benjitaylor/stageshow/kit/phone.css new file mode 100644 index 0000000..7c86212 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/phone.css @@ -0,0 +1,539 @@ +/* Phones (phone.tsx): frames, the status bar, the lock screen and + notification banners. */ + +@layer stageshow { + /* Zero specificity for the positioning, so a demo's own class (.xx-phone + { position: absolute }) wins whichever stylesheet happens to load last */ + :where(.ss-phone) { + position: relative; + } + + .ss-phone { + flex-shrink: 0; + box-shadow: + 0 calc(var(--ps) * 40) calc(var(--ps) * 80) calc(var(--ps) * -16) rgba(50, 50, 93, 0.25), + 0 calc(var(--ps) * 24) calc(var(--ps) * 48) calc(var(--ps) * -24) rgba(0, 0, 0, 0.3); + } + + .ss-phone__glass { + height: 100%; + } + + .ss-phone__window { + position: relative; + overflow: hidden; + /* Keeps the scaled screen clipped to the rounded corners in Safari */ + isolation: isolate; + } + + /* The iOS frames paint their bands as strokes of the screen's outline + (Phone's BANDS), from these colors; the body and glass paint nothing */ + .ss-phone:is([data-frame="pro"], [data-frame="silver"], [data-frame="bezel"], [data-frame="clay"], [data-frame="outline"], [data-frame="bare"]) { + --ss-phone-glass: #0b0b0c; + --ss-phone-rim: #2c2c2d; + --ss-phone-seam: #e0dfdc; + --ss-phone-line: var(--ss-text-faint, #9a9a9d); + background: none; + } + + .ss-phone__bands { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; + } + + .ss-phone:is([data-frame="pro"], [data-frame="silver"], [data-frame="bezel"], [data-frame="clay"], [data-frame="outline"]) .ss-phone__glass { + position: relative; + } + + /* Pro: brushed titanium band around black glass */ + .ss-phone[data-frame="pro"] { + --ss-phone-band-1: #e3e3e6; + --ss-phone-band-2: #9a9ba0; + --ss-phone-band-3: #cfd0d4; + --ss-phone-band-4: #85868b; + --ss-phone-band-5: #c9cacd; + } + + /* On dark stages, black titanium: a light band would be the brightest thing in the shot */ + .ss-stage[data-theme="dark"] .ss-phone[data-frame="pro"], + .ss-stage[data-theme="glass"] .ss-phone[data-frame="pro"][data-dark] { + --ss-phone-band-1: #5b5c61; + --ss-phone-band-2: #2b2c2f; + --ss-phone-band-3: #48494d; + --ss-phone-band-4: #232427; + --ss-phone-band-5: #404145; + } + + /* Silver: light titanium around black glass */ + .ss-phone[data-frame="silver"] { + --ss-phone-band-1: #f4f4f6; + --ss-phone-band-2: #c9cacf; + --ss-phone-band-3: #eeeef1; + --ss-phone-band-4: #b9babf; + --ss-phone-band-5: #e6e6ea; + } + + /* Bezel: a plain black border */ + .ss-phone[data-frame="bezel"] { + --ss-phone-glass: #111113; + } + + /* Clay: one matte color, soft and thick, lit from above like a rendered + model rather than a black ring. Light by default; set --ss-phone-clay */ + .ss-phone[data-frame="clay"] { + --ss-phone-clay-top: color-mix(in srgb, var(--ss-phone-clay, #ecebe8), #fff 45%); + --ss-phone-clay-bottom: color-mix(in srgb, var(--ss-phone-clay, #ecebe8), #000 6%); + box-shadow: + 0 calc(var(--ps) * 30) calc(var(--ps) * 60) calc(var(--ps) * -12) rgba(50, 50, 93, 0.2), + 0 calc(var(--ps) * 16) calc(var(--ps) * 32) calc(var(--ps) * -16) rgba(0, 0, 0, 0.16); + } + + .ss-stage[data-theme="dark"] .ss-phone[data-frame="clay"] { + --ss-phone-clay-top: color-mix(in srgb, var(--ss-phone-clay, #2a2a2e), #fff 6%); + --ss-phone-clay-bottom: var(--ss-phone-clay, #2a2a2e); + --ss-phone-seam: #333338; + } + + /* Outline: line art. One fine stroke for the body, a hairline for the + screen; no shadow, nothing filled */ + .ss-phone[data-frame="outline"] { + box-shadow: none; + } + + .ss-stage[data-theme="blueprint"] .ss-phone[data-frame="outline"] { + --ss-phone-line: var(--ss-line); + } + + /* Every iPhone frame lays the status bar out exactly as Apple's iOS 27 UI + kit does (Status bar, iPhone, in points): the time centered in the first + 138.5, its 22pt line from y 21.5; signal, wifi and battery from x 285.25, + 8 apart, their tops at 26.33, 25.33 and 25.67 */ + .ss-phone:not([data-frame="android"], [data-frame="soft"]) .ss-phone__status { + height: 62px; + padding: 0; + } + + .ss-phone:not([data-frame="android"], [data-frame="soft"]) .ss-phone__time { + position: absolute; + top: 21.5px; + left: 0; + width: 138.5px; + line-height: 22px; + text-align: center; + } + + .ss-phone:not([data-frame="android"], [data-frame="soft"]) .ss-phone__glyphs { + position: absolute; + top: 25.33px; + left: 285.25px; + align-items: flex-start; + gap: 8px; + } + + .ss-phone:not([data-frame="android"], [data-frame="soft"]) .ss-phone__signal { + margin-top: 1px; + } + + .ss-phone:not([data-frame="android"], [data-frame="soft"]) .ss-phone__battery { + margin-top: 0.33px; + } + + /* Photo: Apple's iPhone 18 Pro product bezel, silver, black on dark stages. + The body paints nothing but keeps the phone's soft shadow (a shadow on + the image would also fall inward, through its cut-out, onto the screen); + the image lies over the screen, its cut-out framing it */ + .ss-phone[data-frame="photo"] { + position: relative; + background: none; + } + + .ss-phone__photo { + position: absolute; + z-index: 4; + background: var(--ss-phone-photo-light, none) center / 100% 100% no-repeat; + pointer-events: none; + } + + .ss-stage[data-theme="dark"] .ss-phone__photo, + .ss-phone[data-dark] .ss-phone__photo { + background-image: var(--ss-phone-photo-dark, var(--ss-phone-photo-light, none)); + } + + /* Android: an even dark frame, a punch-hole camera instead of an island */ + .ss-phone[data-frame="android"] { + background: linear-gradient(150deg, #3a3b3f, #1d1e21 40%, #2f3034 70%, #1a1b1e); + } + + .ss-phone[data-frame="android"] .ss-phone__glass { + background: #050505; + } + + .ss-phone[data-frame="android"] .ss-phone__island { + top: 14px; + width: 24px; + height: 24px; + border-radius: 50%; + } + + .ss-phone[data-frame="android"] .ss-phone__status { + height: 48px; + padding: 0 26px; + } + + .ss-phone[data-frame="android"] .ss-phone__time { + font-family: system-ui, sans-serif; + font-size: 15px; + font-weight: 500; + } + + .ss-phone[data-frame="android"] .ss-phone__content[data-status] { + padding-top: 48px; + } + + .ss-phone[data-frame="android"] .ss-phone__home { + width: 108px; + height: 4px; + } + + /* Soft: a frosted white frame with a hairline edge. */ + /* Soft: the illustrative phone of a product marketing page. One pale, + even band with a hairline ring, a soft shade along its inner bottom edge + (lit from above), a light lifted shadow; no island, no status bar */ + .ss-phone[data-frame="soft"] { + box-shadow: + 0 0 0 calc(var(--ps) * 1.5) rgba(10, 37, 64, 0.05), + 0 calc(var(--ps) * 30) calc(var(--ps) * 60) calc(var(--ps) * -12) rgba(50, 50, 93, 0.18), + 0 calc(var(--ps) * 18) calc(var(--ps) * 36) calc(var(--ps) * -18) rgba(0, 0, 0, 0.12); + } + + .ss-phone[data-frame="soft"] .ss-phone__glass { + background: #f6f9fc; + box-shadow: inset 0 calc(var(--ps) * -2.9) calc(var(--ps) * 7.2) rgba(10, 37, 64, 0.22); + } + + .ss-stage[data-theme="dark"] .ss-phone[data-frame="soft"] .ss-phone__glass { + background: #1c2433; + box-shadow: inset 0 calc(var(--ps) * -2.9) calc(var(--ps) * 7.2) rgba(0, 0, 0, 0.45); + } + + .ss-phone__screen { + --pt: 1px; + position: absolute; + top: 0; + left: 0; + display: flex; + flex-direction: column; + overflow: hidden; + /* Most iOS screens are white; a grouped list (Settings) sets #f2f2f7 itself */ + background: #fff; + color: #000; + font-family: -apple-system, system-ui, sans-serif; + font-size: 15px; + letter-spacing: -0.015em; + transform-origin: 0 0; + -webkit-font-smoothing: antialiased; + } + + .ss-phone[data-dark] .ss-phone__screen { + background: #000; + color: #fff; + } + + .ss-phone__status { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + height: 54px; + padding: 0 34px 0 52px; + /* Light to dark content fades, never cuts, when an app opens over a dark + screen or closes back to it */ + transition: color 0.3s ease; + /* A layer of its own for good: when an app launches under it, it would + otherwise be lifted onto one mid-open and its text would shift */ + will-change: transform; + } + + /* iOS sets the time in SF Pro Text. At this size a browser's system font + switches to the Display cut, whose 9 has a straighter tail; name Text + first, and ask the variable system font for text optics */ + .ss-phone__time { + font-family: "SF Pro Text", -apple-system, system-ui, sans-serif; + font-variation-settings: "opsz" 17; + font-size: 17.67px; + font-weight: 590; + letter-spacing: 0; + } + + .ss-phone__island { + position: absolute; + top: var(--ss-island-top, 14px); + left: 50%; + z-index: 3; + width: var(--ss-island-w, 95px); + height: 37px; + border-radius: 18.5px; + background: #000; + transform: translateX(-50%); + } + + .ss-phone__glyphs { + display: flex; + align-items: center; + gap: 8px; + } + + + .ss-phone__glyph { + display: block; + } + + .ss-phone__content { + position: relative; + flex: 1; + min-height: 0; + } + + .ss-phone__content[data-status] { + padding-top: 62px; + } + + /* Frames without a status bar keep its safe area: content starts below the + corner's curve, never tucked into it */ + .ss-phone__content:not([data-status]) { + padding-top: 44px; + } + + /* With no frame, the screen's edge is the object's edge, and iOS's 20pt + margins read as cramped against it. Bare screens get a wider margin */ + .ss-phone[data-frame="bare"] .ss-phone__content { + padding-right: 8px; + padding-left: 8px; + } + + .ss-phone__home { + position: absolute; + bottom: 8px; + left: 50%; + z-index: 2; + width: 144px; + height: 5px; + border-radius: 2.5px; + background: currentColor; + transform: translateX(-50%); + /* The color fades, not the fill: currentColor is a keyword, so a + background-color transition never sees it change and the bar snaps */ + transition: color 0.3s ease; + /* Its own layer for good, as the status bar: lifted onto one mid-open, + the thin bar would jump half a pixel and thin out */ + will-change: transform; + } + + .ss-lock { + position: absolute; + inset: 0; + color: #fff; + text-align: center; + } + + .ss-lock__date { + margin-top: 66px; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.01em; + opacity: 0.85; + } + + .ss-lock__clock { + margin-top: -4px; + font-size: 112px; + font-weight: 700; + line-height: 1; + letter-spacing: -0.035em; + font-variant-numeric: tabular-nums; + opacity: 0.92; + } + + .ss-lock__stack { + position: absolute; + right: 0; + bottom: 118px; + left: 0; + display: flex; + flex-direction: column; + gap: 8px; + text-align: left; + } + + /* Under the clock instead, for a phone cropped by the stage's bottom edge */ + .ss-lock__stack[data-at='top'] { + top: 290px; + bottom: auto; + } + + .ss-lock__controls { + position: absolute; + right: 46px; + bottom: 48px; + left: 46px; + display: flex; + justify-content: space-between; + } + + .ss-lock__control { + display: grid; + place-items: center; + width: 50px; + height: 50px; + border-radius: 50%; + /* The lock screen's frosted buttons: a dim, blurred disc with a white glyph */ + background: rgba(10, 12, 24, 0.26); + backdrop-filter: blur(24px) saturate(1.3); + color: #fff; + } + + /* iOS notification banner, in points, measured from Apple's own iOS 26 + screenshots: 38pt icon 14pt from the edges, 13pt to the text, 17pt on the + right, 15pt type on 18pt lines. The radius is the icon's 9 plus 14. On a + wallpaper it is frosted glass, so the wallpaper tints it. */ + .ss-notification { + display: flex; + align-items: center; + gap: 13px; + /* Inset well inside the screen's corners, as iOS sets lock screen banners. + Where the stage (or a clipping tile) cuts the screen's sides, the + banner measures the cut and insets from the visible edge instead, so + a phone drawn past the sides at true size keeps its banner whole */ + margin: 0 18px; + padding: 14px 17px 14px 14px; + border-radius: 23px; + background: rgba(252, 252, 253, 0.76); + backdrop-filter: blur(30px) saturate(1.25); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06); + color: #000; + text-align: left; + } + + /* Unlocked: the banner drops in at the top, just under the island, over + the app. Mount and unmount it with the story; it eases down from above */ + .ss-notification[data-banner] { + position: absolute; + top: 56px; + right: 0; + left: 0; + /* Over the status bar (the same layer, later in the page) and under the + island, which is the hardware cutout: a banner slides in beneath it */ + z-index: 2; + animation: ss-banner-in 520ms cubic-bezier(0.2, 0.9, 0.25, 1) both; + } + + @keyframes ss-banner-in { + from { transform: translateY(-110px); } + } + + .ss-notification__icon { + display: grid; + flex-shrink: 0; + place-items: center; + width: 38px; + height: 38px; + overflow: hidden; + border-radius: 9px; + background: var(--ss-accent); + color: #fff; + } + + /* A real app icon: the image is the whole tile, its own corners included */ + .ss-notification__icon:has(> img) { + background: none; + } + + .ss-notification__icon > img { + display: block; + width: 100%; + height: 100%; + } + + .ss-notification__text { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + font-size: 15px; + line-height: 18px; + letter-spacing: -0.016em; + } + + .ss-notification__head { + display: flex; + align-items: baseline; + gap: 8px; + } + + .ss-notification__title { + flex: 1; + min-width: 0; + overflow: hidden; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; + } + + .ss-notification__time { + flex-shrink: 0; + font-size: 12px; + letter-spacing: -0.005em; + color: rgba(60, 60, 67, 0.6); + } + + /* Wraps to three lines, as iOS shows a banner's body */ + .ss-notification__body { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } + + /* Satellite: a phone beside a window. No controls, and notifications stack + under the clock so a cropped phone still shows them. Everything stays at + true iOS proportion: a notification is never scaled up to read. */ + .ss-lock[data-size='satellite'] .ss-lock__stack { + top: 232px; + bottom: auto; + } + + /* No clock: the stack takes its place, just under the status bar, as + Notification Center sits, rather than floating where the clock was */ + .ss-lock[data-size='satellite'][data-clock='off'] .ss-lock__stack { + top: 74px; + } + + /* Small boxes with text keep a layer of their own. Pushed by a row opening + above them, or carried along by anything that moves by layout, their text + would step whole pixels while the fill glides, and hop about inside it */ + .ss-notification { + will-change: transform; + } + + /* A banner on a light screen needs its own edge: frosted white on white vanishes */ + .ss-phone:not([data-dark]) .ss-notification { + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.12); + } + + /* Minimal fidelity: the status bar time is a placeholder like any other word */ + .ss-phone__time-bar { + display: inline-block; + width: 30px; + height: 7px; + border-radius: 4px; + background: currentColor; + opacity: 0.5; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/phone.tsx b/skills/imported/benjitaylor/stageshow/kit/phone.tsx new file mode 100644 index 0000000..6c3919f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/phone.tsx @@ -0,0 +1,366 @@ +import { type CSSProperties, type HTMLAttributes, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import { useLook } from './looks' +import './phone.css' + +// An iPhone drawn from the real geometry, the iPhone 18 Pro in points, as +// Apple's iOS 27 UI kit lays it out: screen 402 × 874, status bar 62 tall, +// Dynamic Island 95 × 37 sitting 14 from the top, home indicator 144 × 5 +// sitting 8 from the bottom. The screen's corner is traced from the cut-out +// in Apple's own product bezel (the kit's screens are square and the bezel +// rounds them): a superellipse 71pt across, exponent 2.31, within 0.7pt of +// Apple's curve all the way round. +// +// The screen is laid out at true points and scaled down as a whole, so type +// inside it renders at the size iOS uses (15pt, not 8px) and keeps the right +// letter shapes and spacing. Inside the screen, 1pt is 1px; `--pt` is kept +// at 1px there so calc(var(--pt) * n) still works. +const IPHONE = { w: 402, h: 874, r: 64 } +const CORNER = { a: 71, n: 2.31 } +// soft: the illustrative phone marketing pages draw, measured from one (a +// 250 × 527 screen, 28 corners): a little squatter, tighter corners +const SOFT = { w: 393, h: 829, r: 42 } +// Pixel 8: 412 × 915dp, corners about 38, a 24dp punch-hole camera 14 from the top. +const PIXEL = { w: 412, h: 915, r: 38 } + +/** + * photo: an optional caller-provided product bezel; falls back to pro without one. + * pro: titanium band and black glass, island, status bar. The default. + * bezel: a plain black border, no band. Flatter, more illustrative. + * soft: a frosted white frame with no island or status bar, for a lighter, illustrative phone. + * bare: the screen alone, rounded, on a shadow. + * silver: light titanium around black glass, for light pages where black reads heavy. + * clay: a soft matte frame in one light color (--ss-phone-clay), lit like a rendered model. + * outline: line art, a stroke around the screen. For blueprint and minimal looks. + * android: a Pixel. Its own screen size, tighter corners, a punch-hole camera. + */ +export type PhoneFrame = 'pro' | 'photo' | 'bezel' | 'soft' | 'bare' | 'silver' | 'clay' | 'outline' | 'android' + +const FRAMES: Record = { + pro: { band: 3, glass: 10 }, + // Apple's photograph of the iPhone 18 Pro (its product bezel): the body runs + // 16pt past the screen on every side; the image itself is drawn over it + photo: { band: 0, glass: 16 }, + bezel: { band: 0, glass: 12 }, + soft: { band: 0, glass: 12.5 }, + bare: { band: 0, glass: 0 }, + silver: { band: 4, glass: 9 }, + clay: { band: 0, glass: 16 }, + outline: { band: 0, glass: 8 }, + android: { band: 2, glass: 9 }, +} + +// The iOS frames draw their bands as strokes of the screen's own outline, +// each centered on the screen's edge, widest first: every band then stays +// exactly parallel to the screen, corners included. Each is [reach from the +// screen's edge in points, paint]; a paint is a color or one of the +// gradients Phone defines (url(#…-band), url(#…-clay)) +const BANDS: Partial> = { + pro: [[13, 'band'], [10, 'var(--ss-phone-rim)'], [9.25, 'var(--ss-phone-glass)']], + silver: [[13, 'band'], [9, 'var(--ss-phone-glass)']], + bezel: [[12, 'var(--ss-phone-glass)']], + clay: [[16, 'clay'], [1, 'var(--ss-phone-seam)']], +} + +/** A rounded rectangle with continuous (superellipse) corners, as an SVG + * path: corners `a` across with exponent `n` (2 is a circle). */ +function superellipsePath(w: number, h: number, a: number, n: number) { + const steps = 32 + const e = 2 / n + const pts: string[] = [] + const corners: [number, number, number, number][] = [ + [w - a, a, 1, -1], + [w - a, h - a, 1, 1], + [a, h - a, -1, 1], + [a, a, -1, -1], + ] + corners.forEach(([cx, cy, sx, sy], c) => { + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * (Math.PI / 2) + // Clockwise: top-right runs top to right, bottom-right right to bottom, and so on + const u = c % 2 === 0 ? Math.sin(t) : Math.cos(t) + const v = c % 2 === 0 ? Math.cos(t) : Math.sin(t) + pts.push(`${(cx + sx * a * Math.pow(u, e)).toFixed(2)} ${(cy + sy * a * Math.pow(v, e)).toFixed(2)}`) + } + }) + return `M${pts.join('L')}Z` +} + +export interface PhoneProps extends HTMLAttributes { + /** Outer width in px. Height follows the real proportions. */ + width: number + frame?: PhoneFrame + /** Status bar time. */ + time?: string + /** The Dynamic Island. On by default for pro, silver, bezel and android. */ + island?: boolean + /** The status bar (time, signal, battery). On by default for pro, silver, bezel and android. With it off, content still starts below a 44pt safe area. */ + status?: boolean + /** Light content (white status bar, home indicator) for dark screens and wallpapers. */ + dark?: boolean + /** Any CSS background for the screen: a color, gradient or image. The screen is white by default, black with `dark`. */ + wallpaper?: string + children?: ReactNode +} + +export function Phone({ width, frame: asked = 'pro', time = '9:41', island, status, dark, wallpaper, className, style, children, ...rest }: PhoneProps) { + // The photo frame uses a caller-provided bezel. Without a configured image, + // or if it fails to load, the phone draws the pro frame + // instead of standing as an empty shell + const photoRef = useRef(null) + const [photoOk, setPhotoOk] = useState(true) + useEffect(() => { + if (asked !== 'photo' || !photoRef.current) return + const bg = getComputedStyle(photoRef.current).backgroundImage + const url = bg.match(/url\(["']?([^"')]+)["']?\)/)?.[1] + if (!url) return void setPhotoOk(false) + let live = true + const img = new Image() + img.onerror = () => live && setPhotoOk(false) + img.src = url + return () => { + live = false + } + }, [asked, dark]) + const frame: PhoneFrame = asked === 'photo' && !photoOk ? 'pro' : asked + const { band, glass } = FRAMES[frame] + const { fidelity } = useLook() + const SCREEN = frame === 'android' ? PIXEL : frame === 'soft' ? SOFT : IPHONE + const edge = band + glass + const s = width / (SCREEN.w + edge * 2) + const pt = (n: number) => n * s + const id = useId().replace(/:/g, '') + const ios = frame !== 'android' && frame !== 'soft' + const outline = ios ? superellipsePath(pt(SCREEN.w), pt(SCREEN.h), pt(CORNER.a), CORNER.n) : null + const bands = BANDS[frame] + const chrome = frame === 'pro' || frame === 'photo' || frame === 'bezel' || frame === 'silver' || frame === 'android' + // The photo has its own island, lens and all + const showIsland = island ?? (chrome && frame !== 'photo') + const showStatus = status ?? chrome + return ( +
+ {(bands || frame === 'outline') && outline && ( + + + + {[0, 0.22, 0.48, 0.74, 1].map((o, i) => ( + + ))} + + + + + + {frame === 'outline' && ( + // Line art: a ring between 7 and 8pt out, cut from a mask so it is one hairline + + + + + )} + + + {frame === 'outline' ? ( + <> + + + + ) : ( + bands!.map(([d, paint]) => ( + + )) + )} + + + )} +
+
+
+ {showStatus && ( +
+ {fidelity === 'minimal' ? : time} + + + + + +
+ )} + {showIsland && } +
+ {children} +
+ {chrome && } +
+
+
+ {frame === 'photo' && ( + // The bezel image is 450 × 920pt with the screen at (24, 23): laid over + // the body so its cut-out frames the screen exactly + + )} +
+ ) +} + +export interface LockScreenProps extends HTMLAttributes { + date?: string + time?: string + /** The flashlight and camera buttons at the bottom. */ + controls?: boolean + /** Where notifications stack. 'bottom' is how iOS does it; 'top' puts them + * under the clock, for a phone cropped by the stage's bottom edge. */ + stack?: 'bottom' | 'top' + /** + * satellite: a phone beside a window. The controls go and notifications + * stack under the clock. Nothing is scaled: notifications stay true iOS + * size, so if one wouldn't read, make the phone bigger (and crop it), never + * the notification. + */ + size?: 'full' | 'satellite' + /** The date and clock. Off for a crop that shows only the notifications. */ + clock?: boolean + /** Notifications. They stack at the bottom of the screen, as iOS does. */ + children?: ReactNode +} + +/** The default lock screen wallpaper: a soft blue to lilac gradient with a + * faint light at the top, after the iPhone's own defaults. Quiet enough to + * sit behind any demo; white type and frosted banners read on it. */ +export const WALLPAPER = + 'radial-gradient(120% 70% at 25% 0%, rgba(255, 255, 255, 0.3), transparent 65%), linear-gradient(172deg, #8ea9d6 0%, #9aa1d4 50%, #aca3cf 100%)' + +/** The iOS lock screen: date, large clock, notifications stacked at the bottom. Put it inside a Phone with `dark` and a `wallpaper`. */ +export function LockScreen({ date = 'Tuesday, October 21', time = '9:41', controls = true, stack = 'bottom', size = 'full', clock = true, className, children, ...rest }: LockScreenProps) { + return ( +
+ {clock &&
{date}
} + {clock &&
{time}
} +
{children}
+ {controls && size === 'full' && ( +
+ {/* Drawn after SF Symbols flashlight.off.fill and camera.fill: solid + glyphs with cut-outs, not outlines */} + + + + + + + + + + +
+ )} +
+ ) +} + +export interface NotificationProps extends Omit, 'title'> { + icon?: ReactNode + title: ReactNode + body?: ReactNode + time?: string + /** Where it shows. Leave it out on a LockScreen, which stacks notifications + * at the bottom as iOS does. banner: the phone is unlocked (in an app or on + * the home screen), so it drops in at the top of the screen, under the + * island, over whatever is there. */ + banner?: boolean +} + +/** + * An iOS notification banner, in points. Place it inside a Phone (or a + * LockScreen). The body wraps to three lines, as iOS shows it. It is native + * width, the screen minus 18pt each side, and nothing narrows it: a phone + * cropped at a side is composed so the banner shows whole. + */ +export function Notification({ icon, title, body, time = 'now', banner, className, ...rest }: NotificationProps) { + const el = useRef(null) + return ( +
+ {icon} + + + {title} + {time} + + {body && {body}} + +
+ ) +} + +// Stack adaptation: independently authored generic signal, Wi-Fi and battery +// geometry replaces glyph paths copied from Apple's Figma UI kit upstream. +// These illustrative symbols do not claim native Apple artwork fidelity. +function Signal() { + return ( + + {[4, 7, 10, 13].map((height, i) => ( + + ))} + + ) +} + +function Wifi() { + return ( + + + + + + + + + ) +} + +function Battery() { + return ( + + + + + + ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/reduced-motion.ts b/skills/imported/benjitaylor/stageshow/kit/reduced-motion.ts new file mode 100644 index 0000000..cdac59b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/reduced-motion.ts @@ -0,0 +1,18 @@ +import { createContext, useContext, useEffect, useState } from 'react' + +// Static stages settle motion parts too, even without an OS preference. +export const StillContext = createContext(false) + +/** Whether the viewer asked for reduced motion. A stage shows its still; parts skip their tweens. */ +export function useReducedMotion() { + const still = useContext(StillContext) + const [reduced, setReduced] = useState(false) + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const update = () => setReduced(mq.matches) + update() + mq.addEventListener('change', update) + return () => mq.removeEventListener('change', update) + }, []) + return still || reduced +} diff --git a/skills/imported/benjitaylor/stageshow/kit/stage.css b/skills/imported/benjitaylor/stageshow/kit/stage.css new file mode 100644 index 0000000..54319bd --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/stage.css @@ -0,0 +1,448 @@ +/* Stageshow kit: the stage. Tokens, the looks (theme, palette, + fidelity), edge fades, the still and placing switches, the keyframes more + than one part shares, and the guards against host CSS. Imported by + stage.tsx; every other component imports its own file. */ + +@layer stageshow { + /* Stageshow kit. Tokens, the stage, the cursor, window chrome and faux atoms. + Copy this file with the rest of kit/ and import it once. */ + + .ss-fit { + position: relative; + width: 100%; + /* Stopped at its design size in a wider slot, a stage sits in the middle + of it, not against its left edge */ + margin-inline: auto; + } + + .ss-stage { + /* Surfaces */ + --ss-canvas: #f6f5f4; + --ss-surface: #fff; + --ss-chrome: #fff; + + /* Placeholder ink, lightest to darkest. Faux UI lives in these five. */ + --ss-ink-1: rgba(0, 0, 0, 0.04); + --ss-ink-2: rgba(0, 0, 0, 0.06); + --ss-ink-3: rgba(0, 0, 0, 0.08); + /* ink-3 flattened onto the surface: an opaque fill for shapes that overlap + (avatars in a pile), so the overlap never paints darker */ + --ss-fill: #ebebeb; + --ss-ink-4: rgba(0, 0, 0, 0.1); + --ss-ink-5: rgba(0, 0, 0, 0.15); + + /* Lines and real text */ + --ss-line: rgba(0, 0, 0, 0.06); + --ss-line-faint: rgba(0, 0, 0, 0.04); + /* Opaque, so icons that inherit them are one solid color (a translucent + color paints darker wherever strokes meet) */ + --ss-text: #7a7a7d; + --ss-text-strong: #262628; + --ss-text-faint: #9a9a9d; + /* Icons: opaque, so strokes that overlap or meet never paint darker. Never + color an icon with a translucent ink or text token. */ + --ss-icon: #707074; + --ss-icon-faint: #c4c4c7; + + /* One accent, spent on the thing being acted on. Status colors only + where status is the subject. */ + --ss-accent: #3b82f6; + --ss-accent-soft: rgba(59, 130, 246, 0.12); + --ss-success: #22c55e; + --ss-danger: #ef4444; + --ss-warning: #f59e0b; + + /* The stage behind a framed shot */ + --ss-well: #efeeec; + + /* Status tints for diffs, badges and pills: soft fill, strong text */ + --ss-success-soft: rgba(34, 197, 94, 0.12); + --ss-success-text: #15803d; + --ss-danger-soft: rgba(239, 68, 68, 0.1); + --ss-danger-text: #b91c1c; + --ss-warning-soft: rgba(245, 158, 11, 0.14); + --ss-warning-text: #b45309; + /* A second accent stop, for gradient meters and lines */ + --ss-accent-2: #8b5cf6; + /* The accent as text (a link, a selected label). Darkened so it reads on + white; a custom accent gets the same treatment automatically */ + --ss-accent-text: color-mix(in srgb, var(--ss-accent) 82%, #000); + + /* Depth */ + --ss-shadow-window: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); + --ss-shadow-float: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.12), 0 12px 32px rgba(0, 0, 0, 0.08); + --ss-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04); + /* Framing depth: the hero window on the stage, satellites over the hero */ + /* The ring gives every edge a line: the drop falls downward, so the top and + a light sidebar on a light ground would otherwise vanish */ + --ss-shadow-hero: 0 0 0 1px rgba(0, 0, 0, 0.06), 0 30px 60px -12px rgba(50, 50, 93, 0.25), 0 18px 36px -18px rgba(0, 0, 0, 0.3); + /* Something picked up and carried (a dragged card) */ + --ss-shadow-lift: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 12px 28px -6px rgba(50, 50, 93, 0.28), 0 6px 12px -6px rgba(0, 0, 0, 0.16); + /* Tight on purpose: a wide blur over a white window reads as a gray smear */ + --ss-raised: #fff; + --ss-shadow-satellite: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 14px 32px -8px rgba(50, 50, 93, 0.24), 0 4px 10px -4px rgba(0, 0, 0, 0.1); + /* A small window resting on the stage's ground, not over another window: + a hairline and a long, faint fall. The satellite's tight, dark contact + shadow reads harsh under a small window on open ground */ + --ss-shadow-ground: 0 0 0 1px rgba(50, 50, 93, 0.06), 0 24px 48px -24px rgba(50, 50, 93, 0.18); + + --ss-font: system-ui, -apple-system, sans-serif; + --ss-mono: "SF Mono", "SFMono-Regular", ui-monospace, Consolas, monospace; + + /* Shape: the radius scale the product's own parts use. A look sets these + to its design system's values; each rounded part reads one */ + --ss-radius-sm: 6px; /* a menu row, a segment's thumb */ + --ss-radius-md: 8px; /* a field, a segmented track, a drop slot */ + --ss-radius-lg: 10px; /* a popover, a toast */ + --ss-radius-window: 12px; /* the product window */ + --ss-radius-pill: 999px; /* badges and chips */ + + /* Density: the heights the parts are built on */ + --ss-control: 34px; /* a text field */ + --ss-row: 26px; /* a menu row, a segment */ + + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + font-family: var(--ss-font); + -webkit-font-smoothing: antialiased; + user-select: none; + /* The baseline every demo is designed against, pinned here so the host + page's type (a blog's 15px body, its letter-spacing) never reaches in */ + font-size: 16px; + font-weight: 400; + font-style: normal; + line-height: normal; + letter-spacing: normal; + word-spacing: normal; + text-align: start; + text-indent: 0; + text-transform: none; + white-space: normal; + color: #000; + } + + /* Orbs: a pale tile lit by a few huge, soft color orbs (Stage ground). + Under everything in the scene (the stage is its own stacking context), + so glass has color to take and white cards lift off it. Each orb is + wider than the tile and mostly off it, so only its soft edge shows */ + .ss-ground { + --ss-orb-1: #c9b8ff; + --ss-orb-2: #ffc59a; + --ss-orb-3: #a9d8ff; + position: absolute; + inset: 0; + z-index: -1; + overflow: hidden; + /* A tile: set --ss-ground-radius to 0 for one that runs to the page's edges */ + border-radius: var(--ss-ground-radius, 20px); + background: + radial-gradient(60% 75% at 8% 4%, color-mix(in srgb, var(--ss-orb-1) 85%, transparent), transparent 70%), + radial-gradient(55% 70% at 96% 92%, color-mix(in srgb, var(--ss-orb-2) 80%, transparent), transparent 70%), + radial-gradient(45% 55% at 78% 8%, color-mix(in srgb, var(--ss-orb-3) 55%, transparent), transparent 70%), + var(--ss-canvas); + pointer-events: none; + } + + .ss-stage[data-theme="dark"] .ss-ground { + background: + radial-gradient(60% 75% at 8% 4%, color-mix(in srgb, var(--ss-orb-1) 30%, transparent), transparent 70%), + radial-gradient(55% 70% at 96% 92%, color-mix(in srgb, var(--ss-orb-2) 24%, transparent), transparent 70%), + radial-gradient(45% 55% at 78% 8%, color-mix(in srgb, var(--ss-orb-3) 18%, transparent), transparent 70%), + var(--ss-canvas); + } + + .ss-stage[data-theme="dark"] { + --ss-canvas: #111113; + --ss-surface: #1a1a1d; + --ss-chrome: #1d1d20; + --ss-ink-1: rgba(255, 255, 255, 0.05); + --ss-ink-2: rgba(255, 255, 255, 0.07); + --ss-ink-3: rgba(255, 255, 255, 0.1); + /* ink-3 flattened onto the surface: an opaque fill for shapes that overlap + (avatars in a pile), so the overlap never paints darker */ + --ss-fill: #313134; + --ss-ink-4: rgba(255, 255, 255, 0.13); + --ss-ink-5: rgba(255, 255, 255, 0.2); + --ss-line: rgba(255, 255, 255, 0.08); + --ss-line-faint: rgba(255, 255, 255, 0.05); + --ss-text: #9d9da1; + --ss-text-strong: #ececee; + --ss-text-faint: #6c6c70; + --ss-icon: #9a9aa0; + --ss-icon-faint: #4a4a50; + --ss-accent-soft: rgba(59, 130, 246, 0.2); + --ss-accent-text: color-mix(in srgb, var(--ss-accent) 70%, #fff); + --ss-well: #0a0a0b; + --ss-success-soft: rgba(34, 197, 94, 0.16); + --ss-success-text: #4ade80; + --ss-danger-soft: rgba(239, 68, 68, 0.16); + --ss-danger-text: #f87171; + --ss-warning-soft: rgba(245, 158, 11, 0.18); + --ss-warning-text: #fbbf24; + --ss-shadow-lift: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 12px 28px -6px rgba(0, 0, 0, 0.6); + --ss-shadow-window: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 4px 16px rgba(0, 0, 0, 0.4); + --ss-shadow-float: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 4px 16px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.3); + --ss-shadow-card: 0 0 0 1px rgba(255, 255, 255, 0.05); + --ss-shadow-hero: 0 0 0 1px rgba(255, 255, 255, 0.08), 0 30px 60px -12px rgba(0, 0, 0, 0.6); + /* On dark, depth comes mostly from a lighter surface: put satellites on + --ss-raised. A wide blur on a dark window barely registers. */ + --ss-shadow-satellite: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 14px 32px -8px rgba(0, 0, 0, 0.7), 0 4px 10px -4px rgba(0, 0, 0, 0.5); + --ss-shadow-ground: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 24px 48px -24px rgba(0, 0, 0, 0.6); + --ss-raised: #26262a; + } + + /* Blueprint: drawn, not painted. Surfaces become outlines on a blue ground. */ + .ss-stage[data-theme="blueprint"] { + --ss-canvas: transparent; + /* Opaque, a step lighter than the ground: the grid shows around UI, not + through it */ + --ss-surface: #1857bd; + --ss-raised: #1b5cc4; + --ss-chrome: transparent; + --ss-ink-1: rgba(214, 232, 255, 0.12); + --ss-ink-2: rgba(214, 232, 255, 0.16); + --ss-ink-3: rgba(214, 232, 255, 0.22); + /* ink-3 flattened onto the surface: an opaque fill for shapes that overlap + (avatars in a pile), so the overlap never paints darker */ + --ss-fill: #4277cc; + --ss-ink-4: rgba(214, 232, 255, 0.3); + --ss-ink-5: rgba(214, 232, 255, 0.45); + --ss-line: rgba(214, 232, 255, 0.35); + --ss-line-faint: rgba(214, 232, 255, 0.2); + --ss-text: #b3c9ec; + --ss-text-strong: #f0f6ff; + --ss-text-faint: #7fa1da; + --ss-icon: #c9dcf7; + --ss-icon-faint: #6f97d6; + --ss-accent: #ffffff; + --ss-accent-soft: rgba(255, 255, 255, 0.15); + --ss-success: #e6f0ff; + --ss-danger: #e6f0ff; + --ss-warning: #e6f0ff; + /* Status reads by mark and word on blueprint, never by green or red */ + --ss-success-soft: rgba(255, 255, 255, 0.14); + --ss-danger-soft: rgba(255, 255, 255, 0.14); + --ss-warning-soft: rgba(255, 255, 255, 0.14); + --ss-success-text: #ffffff; + --ss-danger-text: #ffffff; + --ss-warning-text: #ffffff; + --ss-well: #1450b4; + --ss-shadow-window: 0 0 0 1px rgba(214, 232, 255, 0.55); + --ss-shadow-float: 0 0 0 1px rgba(214, 232, 255, 0.7); + --ss-shadow-card: 0 0 0 1px rgba(214, 232, 255, 0.35); + --ss-shadow-hero: 0 0 0 1px rgba(214, 232, 255, 0.55); + --ss-shadow-satellite: 0 0 0 1px rgba(214, 232, 255, 0.8); + --ss-shadow-ground: 0 0 0 1px rgba(214, 232, 255, 0.7); + background-color: var(--ss-well); + background-image: + linear-gradient(rgba(214, 232, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(214, 232, 255, 0.08) 1px, transparent 1px); + background-size: 16px 16px; + /* Shift the grid so window edges sit on its lines: a 24px gutter wants 8px */ + background-position: var(--ss-grid-x, 0) var(--ss-grid-y, 0); + } + + /* Glass: frosted, translucent panels over a soft color field drawn from the + accent. Panels blur what's behind them; text and icons stay opaque. Give + your own panels the class ss-glass to frost them too. */ + .ss-stage[data-theme="glass"] { + --ss-canvas: rgba(255, 255, 255, 0.5); + --ss-surface: rgba(255, 255, 255, 0.62); + --ss-chrome: rgba(255, 255, 255, 0.38); + --ss-raised: rgba(255, 255, 255, 0.74); + --ss-line: rgba(255, 255, 255, 0.65); + --ss-line-faint: rgba(255, 255, 255, 0.45); + --ss-well: #ebe9f5; + --ss-shadow-window: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 20px 50px -14px rgba(40, 40, 90, 0.28); + --ss-shadow-hero: inset 0 0 0 1px rgba(255, 255, 255, 0.75), 0 30px 60px -16px rgba(40, 40, 90, 0.3); + --ss-shadow-satellite: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 16px 36px -10px rgba(40, 40, 90, 0.32); + --ss-shadow-float: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 12px 32px -8px rgba(40, 40, 90, 0.28); + --ss-shadow-ground: inset 0 0 0 1px rgba(255, 255, 255, 0.85), 0 24px 48px -24px rgba(40, 40, 90, 0.24); + --ss-shadow-card: inset 0 0 0 1px rgba(255, 255, 255, 0.6); + background: + radial-gradient(55% 65% at 12% 18%, color-mix(in srgb, var(--ss-accent) 42%, transparent), transparent 70%), + radial-gradient(50% 60% at 88% 28%, color-mix(in srgb, var(--ss-accent-2) 38%, transparent), transparent 70%), + radial-gradient(60% 55% at 58% 100%, rgba(255, 168, 120, 0.42), transparent 70%), + var(--ss-well); + } + + .ss-stage[data-theme="glass"] .ss-window, + .ss-stage[data-theme="glass"] .ss-popover, + .ss-stage[data-theme="glass"] .ss-glass { + backdrop-filter: blur(24px) saturate(1.6); + -webkit-backdrop-filter: blur(24px) saturate(1.6); + } + + /* Cool grays (Stage greys="cool"): the canvas and well lose their warmth, + for a scene that wants one cool family (macOS, a blue ground). Light + theme only; dark, blueprint and glass have their own grounds */ + .ss-stage[data-greys="cool"]:not([data-theme="dark"], [data-theme="blueprint"], [data-theme="glass"]) { + --ss-canvas: #f4f5f8; + --ss-well: #eceef2; + } + + /* Grayscale: the accent and status colors fall back to the strongest ink */ + .ss-stage[data-palette="mono"] { + --ss-accent: #1a1a1a; + --ss-accent-soft: rgba(0, 0, 0, 0.06); + --ss-success: #1a1a1a; + --ss-danger: #1a1a1a; + --ss-warning: #1a1a1a; + --ss-accent-2: #1a1a1a; + --ss-success-text: #1a1a1a; + --ss-danger-text: #1a1a1a; + --ss-success-soft: rgba(0, 0, 0, 0.06); + --ss-danger-soft: rgba(0, 0, 0, 0.04); + } + + .ss-stage[data-palette="mono"][data-theme="dark"] { + --ss-accent: #f2f2f2; + --ss-accent-soft: rgba(255, 255, 255, 0.1); + --ss-success: #f2f2f2; + --ss-danger: #f2f2f2; + --ss-warning: #f2f2f2; + --ss-accent-2: #f2f2f2; + --ss-success-text: #f2f2f2; + --ss-danger-text: #f2f2f2; + --ss-success-soft: rgba(255, 255, 255, 0.1); + --ss-danger-soft: rgba(255, 255, 255, 0.06); + } + + /* Images, logos and avatars lose their color in grayscale */ + .ss-stage[data-palette="mono"] img, + .ss-stage[data-palette="mono"] .ss-keep-mono { + filter: grayscale(1); + } + + /* Edge fades. A crop must explain itself: content fades into the page + instead of being sliced. Length via --ss-fade. */ + .ss-fade-b { mask-image: linear-gradient(to bottom, #000 calc(100% - var(--ss-fade, 64px)), transparent); } + + .ss-fade-r { mask-image: linear-gradient(to right, #000 calc(100% - var(--ss-fade, 64px)), transparent); } + + .ss-fade-br { + mask-image: + linear-gradient(to bottom, #000 calc(100% - var(--ss-fade, 64px)), transparent), + linear-gradient(to right, #000 calc(100% - var(--ss-fade, 64px)), transparent); + mask-composite: intersect; + } + + /* A layer that steps back when another comes forward */ + .ss-recede { + transition: opacity 0.3s ease; + } + + .ss-recede[data-receded="true"] { + opacity: 0.4; + } + + /* Transitions off for one patch (act.snap), stage-wide or in one target */ + .ss-stage [data-instant], + .ss-stage[data-instant] *, + .ss-stage [data-instant] *, + .ss-stage[data-instant] *::before, + .ss-stage[data-instant] *::after { + transition: none !important; + } + + @keyframes ss-spin { + to { transform: rotate(1turn); } + } + + @keyframes ss-fade-in { + from { opacity: 0; filter: blur(2px); transform: translateY(3px); } + } + + /* A still (reduced motion, or no script) is the poster, settled: nothing in + it transitions or animates, so it never shows a frame caught mid-fade */ + .ss-stage[data-still] *, + .ss-stage[data-still] *::before, + .ss-stage[data-still] *::after { + transition: none !important; + animation: none !important; + } + + /* Offscreen resets settle immediately. Pausing keyframes alone leaves + newly-created CSS transitions running after the story clock stops. + Visible hover exits keep their normal glide; completed once scenes hold. */ + .ss-stage[data-offscreen], + .ss-stage[data-offscreen] *, + .ss-stage[data-offscreen] *::before, + .ss-stage[data-offscreen] *::after { + transition: none !important; + animation-play-state: paused !important; + } + + /* Settling (first paint, a layout switch): things placed from measurements + arrive in place instead of traveling there. Entrance animations still play. */ + .ss-stage[data-placing] *, + .ss-stage[data-placing] *::before, + .ss-stage[data-placing] *::after { + transition: none !important; + } + + .ss-stage *, + .ss-stage *::before, + .ss-stage *::after { + box-sizing: border-box; + } + + /* The auto cursor's hit test (stage.tsx): for that one read, everything + counts as hittable except the cursor itself */ + .ss-stage[data-hit] * { + pointer-events: auto !important; + } +} + +/* Outside the layer, on purpose. Everything above is in @layer stageshow so + a demo's own (unlayered) CSS always wins over the kit. These few rules are + different: they exist to beat the host page's CSS, and an unlayered host + rule (a blog's `p { margin }`, `img { max-width: 100% }`) would beat + anything in a layer. So they stay unlayered. + + Their specificity is pitched between the two: three type selectors' + worth (`:where()` adds nothing, each `:not(html)`-style test adds one + type), so they beat a host's element rules (`p`, `article p`) but lose to + any class, which keeps a demo's own class rules winning whatever order the + files load in. A bundler can load the kit's CSS again after a demo's (one + copy per lazily loaded chunk), so order can't be relied on here. */ + +/* A host page's element rules (a blog's p margins, link colors, list + bullets) roll back to the browser's defaults inside a stage, which is what + every demo is designed against. A host that styles prose through a class + (.article p) out-ranks this: exclude stages there with + :not(:where(.ss-stage *)). */ +:where(.ss-stage) :is(p, h1, h2, h3, h4, h5, h6, ul, ol, li, dl, dt, dd, a, figure, figcaption, blockquote, pre, code, hr, table, th, td, button, input, label):not(html):not(body) { + all: revert; +} + +/* A universal reset (Tailwind v3's preflight: `* { border-width: 0; + box-sizing: border-box }`) is unlayered, so it would beat the kit's own + borders, box model and spacing (a bare `* { padding: 0 }` put window + lights flush against the edge on a blog). revert-layer hands each property back to the kit's + layer. One type selector's worth, so it beats `*` but not a host's element + rules, which the guard above already handles */ +:where(.ss-stage, .ss-stage *, .ss-stage ::before, .ss-stage ::after):not(html) { + border-width: revert-layer; + border-style: revert-layer; + border-color: revert-layer; + box-sizing: revert-layer; + padding: revert-layer; + margin: revert-layer; +} + +/* Hosts often cap images and SVGs at their container (max-width: 100%), + which squeezes an icon or ring drawn larger than its slot off-center. + Tailwind v3 also aligns them to the middle */ +:where(.ss-stage) :is(svg, img):not(html):not(body) { + max-width: none; + vertical-align: revert-layer; +} + +/* What a Fly or an Expand carries stays inside it, so its own max-width has + to beat the guard above, which a layered rule can't: one type more */ +:where(.ss-stage :is(.ss-fly__lift, .ss-expand__face)) > :is(svg, img):not(html):not(body):not(head) { + max-width: 100%; +} diff --git a/skills/imported/benjitaylor/stageshow/kit/stage.tsx b/skills/imported/benjitaylor/stageshow/kit/stage.tsx new file mode 100644 index 0000000..17b960c --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/stage.tsx @@ -0,0 +1,611 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import './stage.css' +import { checkSeam, createAct, Stopped, type Script } from './act' +import { Cursor, ripple as spawnRipple, type CursorKind, type CursorStyle } from './cursor' +import { Keycaps, type Keys, type KeysAt } from './keys' +import { LookContext, type Fidelity, type Layout, type Palette, type Theme } from './looks' +import { EMPTY, nameOf, rectOf, sameRects, stageSpace, targetRectOf, thumb, type Point, type Rect, type Target } from './measure' +import { StillContext, useReducedMotion } from './reduced-motion' +import { clock } from './clock' +import { clamp, easeInOutCubic, isDev, warn } from './util' + +// The stage: fits a design-size scene into its container, runs the script +// while it is on screen, and draws the cursor and keycaps over the scene. +// The script API is act.ts, the cursor cursor.tsx, measuring measure.ts and +// the look context looks.ts. + +/** loop: repeats while on screen. once: plays when first seen, then holds the end. hover: plays while the pointer is over it, settles back when it leaves; on a screen with no hover (a phone) it plays once. */ +export type Play = 'loop' | 'once' | 'hover' + +/** Design tokens for a look: `--ss-accent`, `--ss-radius-md`, `--ss-font` and the rest. */ +export type LookTokens = { [token: `--ss-${string}`]: string } + +export interface StageProps { + /** Design size. The stage scales down to fit its container, and only grows past it with `fill`. */ + width: number + height: number + /** Scene state at the start of each loop. Statics can leave it out. */ + initial?: S + /** Loops while on screen. Leave out for a static illustration. */ + script?: Script + /** The still frame shown under reduced motion or with no script. Defaults to initial. */ + poster?: S + /** + * Where the cursor rests at the start of each loop, or where it sits in a + * still. Leave out to hide it. Animated demos hide it in their reduced-motion + * poster unless `still` is true. `ripple` sends a soft ring out from each + * click, for demos where clicks need to read at a glance (small stages, the + * arrow and circle styles). `idle` is how long (ms) the cursor may rest + * after it has acted before it fades out (4000 by default); it fades back + * in as it next moves, and never fades while it presses or hovers. Before + * its first move each loop it waits out of shot instead, coming back just + * before it moves. false keeps it on screen throughout. `at` can differ by + * layout: { wide: ..., compact: ... }, for a stage with a compact design. + */ + cursor?: { at: Target | { wide: Target; compact: Target }; kind?: CursorKind; style?: CursorStyle; still?: boolean; ripple?: boolean; idle?: number | false } + play?: Play + /** + * A second, smaller design for narrow containers. Below `below` px of + * container width the stage uses this size, the render's `layout` argument + * (and `useLook().layout` inside the stage) and data-layout become 'compact', and the script restarts. Build the + * compact scene as a tighter crop around the story, never the wide scene + * shrunk. + */ + compact?: { below: number; width: number; height: number } + /** Where keycaps from act.key appear. */ + keys?: KeysAt | { wide: KeysAt; compact: KeysAt } + /** Targets shown hovered in a still, since no script is there to hover them. */ + hover?: string[] + theme?: Theme + palette?: Palette + fidelity?: Fidelity + /** The grays' temperature. `warm` (the default) is a warm canvas and well; + * `cool` swaps them for cool ones, for a macOS scene or a blue ground, so + * every gray shares one temperature. Light theme only. */ + greys?: 'warm' | 'cool' + /** A look as values: any `--ss-*` tokens (color, type, shape, density), + * set on the stage over its theme's. See `references/looks.md`. */ + tokens?: LookTokens + /** Grow past the design size to fill the container (up to 2x), for a hero or a docs plate. A host page can set data-ss-fill on any wrapper instead. */ + fill?: boolean + /** The accent color for this stage (any CSS color). Sets --ss-accent, its soft fill and the gradient's second stop together. */ + accent?: string + /** What the scene sits on. `orbs`: a pale tile lit by a few huge, soft + * color orbs behind the UI, so glass has color to take and white cards + * lift off it. Pass two or three colors to choose them (lavender and + * orange by default). Leave it out for the plain ground. */ + ground?: 'orbs' | [string, string] | [string, string, string] + /** Plays the script faster (above 1) or slower (below 1). Waits and tweens divide by it. */ + speed?: number + className?: string + /** A specimen sheet of parts (the kit's chrome, controls, phones sheets), not a product frame: film's composition checks (framing, ladders, voids) stand down. */ + specimen?: boolean + /** No framing, when the developer asks for it: no ground, and the product sits whole with room around it, floating on their page. Film's bleed and floating checks stand down; everything else still applies. */ + unframed?: boolean + /** `layout` is 'wide' or 'compact' (see `compact`); useLook() also has it, but only in components rendered inside the stage. */ + children: (s: S, rect: (name: string) => Rect, layout: Layout) => ReactNode +} + +const NONE = {} + +export function Stage({ + width, + height, + initial = NONE as S, + script, + poster, + cursor, + hover, + play: playProp = 'loop', + compact, + keys: keysAt = 'bottom', + palette = 'accent', + greys, + fidelity = 'standard', + specimen, + unframed, + speed: speedProp = 1, + accent, + tokens, + ground, + fill: fillProp = false, + theme: themeProp = 'light', + className, + children, +}: StageProps) { + // Hover scripts can hold their ending without returning to the start. + // On touch, play once rather than assuming the script has a loop seam. + const play: Play = playProp === 'hover' && typeof window !== 'undefined' && window.matchMedia('(hover: none)').matches ? 'once' : playProp + const [layout, setLayout] = useState('wide') + const layoutRef = useRef(layout) + layoutRef.current = layout + // Where the cursor rests, for the layout showing + const restAt = (): Target => { + const at = cursor!.at + return typeof at === 'object' && 'wide' in at ? at[layoutRef.current] : at + } + const [grows, setGrows] = useState(false) + const designW = layout === 'compact' && compact ? compact.width : width + const designH = layout === 'compact' && compact ? compact.height : height + + const fitRef = useRef(null) + const rootRef = useRef(null) + const cursorRef = useRef(null) + const reduced = useReducedMotion() + const still = !script || reduced + + const [state, setState] = useState(() => (still && poster ? poster : initial)) + const stateRef = useRef(state) + stateRef.current = state + const [scale, setScale] = useState(1) + const scaleRef = useRef(1) + scaleRef.current = scale + // film.mjs can slow every stage down (--slow) and film any stage dark (--dark) + const film = typeof window === 'undefined' ? {} : ((window as unknown as { __stageshow?: { slow?: number; theme?: Theme } }).__stageshow ?? {}) + const speed = speedProp / (film.slow ?? 1) + const theme = film.theme ?? themeProp + const [kind, setKind] = useState(cursor?.kind ?? 'pointer') + // A ring that spreads from the click point and fades, drawn under the cursor + const ripple = (at: Point) => { + const root = rootRef.current + if (!root || reduced) return + spawnRipple(root, at) + } + const kindRef = useRef(kind) + kindRef.current = kind + const [keys, setKeys] = useState({ id: 0, caps: [], show: false, down: false }) + const [lastKeys, setLastKeys] = useState(null) + const keysRef = useRef(keys) + keysRef.current = keys + const pos = useRef({ x: 0, y: 0 }) + // The cursor's kind follows what's under it after every commit too, so a + // hand doesn't linger when the link it was over slides away. Not mid-drag: + // the drag owns the kind until release. + const autoRef = useRef<((p: Point) => void) | null>(null) + const [inset, setInset] = useState(0) + const autoUsed = useRef(false) + const dragging = useRef(false) + const hovered = useRef(null) + + // Fit: pick the layout from the container width, then scale its design + // size into it. + useLayoutEffect(() => { + const fit = fitRef.current + if (!fit) return + const measure = () => { + const w = fit.clientWidth + // A host that sizes the stage by something other than the screen (a + // bento tile set by its row's height) marks a wrapper data-ss-wide to + // keep the wide design, scaled, rather than the phone-width one + const wide = !!fit.parentElement?.closest('[data-ss-wide]') + const next: Layout = compact && !wide && w < compact.below ? 'compact' : 'wide' + setLayout(next) + // fill (or a host wrapper marked data-ss-fill) lets the stage grow past its design size, within reason + const grow = fillProp || !!fit.parentElement?.closest('[data-ss-fill]') + setGrows(grow) + const dw = next === 'compact' ? compact!.width : width + const s = Math.min(grow ? 2 : 1, w / dw) + setScale(s) + // A design narrower than its box (a compact layout, or a stage that has + // stopped at its design size) sits in the middle of it, not at its left + setInset(Math.max(0, (w - dw * s) / 2)) + } + measure() + const ro = new ResizeObserver(measure) + ro.observe(fit) + // Hosts can switch layout/fill policy without changing this box's + // width. Observe only those attributes on its ancestors, not the scene. + const hosts = new MutationObserver(measure) + for (let host = fit.parentElement; host; host = host.parentElement) { + hosts.observe(host, { attributes: true, attributeFilter: ['data-ss-wide', 'data-ss-fill'] }) + } + return () => { + ro.disconnect() + hosts.disconnect() + } + }, [width, compact?.below, compact?.width, fillProp]) // eslint-disable-line react-hooks/exhaustive-deps + + // Rects of every data-target, in stage pixels, refreshed after each commit. + const [rects, setRects] = useState>({}) + const measureAll = useCallback(() => { + const root = rootRef.current + if (!root) return {} + const out: Record = {} + const targets = root.querySelectorAll('[data-target]') + if (!targets.length) return out + const space = stageSpace(root, scaleRef.current) + targets.forEach((el) => { + out[el.dataset.target!] = targetRectOf(root, el, scaleRef.current, space) + }) + return out + }, []) + // Something placed from rect() can itself be a target, and the two can + // chase each other. Allow a few passes per frame, then wait for the next. + const passes = useRef({ n: 0, frame: 0, owed: false }) + const remeasure = useCallback(() => { + const p = passes.current + if (!p.frame) + p.frame = clock.layoutRaf(() => { + p.n = 0 + p.frame = 0 + if (!p.owed) return + p.owed = false + const next = measureAll() + setRects((prev) => (sameRects(prev, next) ? prev : next)) + }) + if (p.n >= 4) { + p.owed = true + return + } + p.n++ + const next = measureAll() + setRects((prev) => (sameRects(prev, next) ? prev : next)) + }, [measureAll]) + useLayoutEffect(remeasure) + useLayoutEffect(() => () => { + clock.cancelLayoutRaf(passes.current.frame) + passes.current = { n: 0, frame: 0, owed: false } + }, []) + // Things that slide or scale into place move after the commit that started + // them, so measure again once they settle. + useEffect(() => { + const root = rootRef.current + if (!root) return + root.addEventListener('transitionend', remeasure) + root.addEventListener('animationend', remeasure) + return () => { + root.removeEventListener('transitionend', remeasure) + root.removeEventListener('animationend', remeasure) + } + }, [remeasure]) + const rectFor = useCallback((name: string) => rects[name] ?? EMPTY, [rects]) + + // Placing: until the first measurements (after fonts load) have painted, + // and again after a wide/compact switch, nothing transitions. Otherwise + // anything placed from rect() would visibly travel from 0,0 into place. + const [settling, setSettling] = useState(true) + useLayoutEffect(() => { + setSettling(true) + let live = true + let frame = 0 + const release = () => { + frame = clock.layoutRaf(() => { + frame = clock.layoutRaf(() => live && setSettling(false)) + }) + } + if (!document.fonts || document.fonts.status === 'loaded') release() + else document.fonts.ready.then(() => live && release()) + return () => { + live = false + clock.cancelLayoutRaf(frame) + } + }, [layout]) + + // When the cursor last did something; while a script plays, a cursor that + // has sat still for longer than `idle` fades out until its next move + const active = useRef(0) + // Each loop: when it began, whether the cursor has moved yet, and (learned + // from the first run) how long it waits before its first move + const loopAt = useRef(0) + const moved = useRef(false) + const firstMove = useRef(null) + const place = useCallback((p: Point) => { + if (loopAt.current && !moved.current && Math.hypot(p.x - pos.current.x, p.y - pos.current.y) > 0.5) { + moved.current = true + firstMove.current ??= performance.now() - loopAt.current + } + pos.current = p + active.current = performance.now() + const el = cursorRef.current + if (!el) return + el.style.transform = `translate(${p.x}px, ${p.y}px)` + el.removeAttribute('data-idle') + }, []) + + const resolve = useCallback((t: Target): Point => { + // One target per layout, as cursor.at takes it + if (typeof t === 'object' && t !== null && 'wide' in t) t = (layoutRef.current === 'compact' ? t.compact : t.wide) + const name = nameOf(t) + if (name === null) return t as Point + const root = rootRef.current + const el = root?.querySelector(`[data-target="${name}"]`) + if (!root || !el) { + warn(`no element with data-target="${name}"`) + return pos.current + } + const r = rectOf(root, el, scaleRef.current) + const f = typeof t === 'string' ? { x: 0.5, y: 0.5 } : (t as { x: number; y: number }) + return { x: r.x + r.width * f.x, y: r.y + r.height * f.y } + }, []) + + // Stills have no script to hover things, so mark them here. + useLayoutEffect(() => { + if (!still || !hover) return + const root = rootRef.current + hover.forEach((name) => root?.querySelector(`[data-target="${name}"]`)?.setAttribute('data-hover', '')) + }) + + // Rest the cursor at its start point, unless a script is running: then the + // cursor is mid-story, and a resize must not send it back to rest + useLayoutEffect(() => { + if (cursor && !loopAt.current) place(resolve(restAt())) + }, [scale, layout]) // eslint-disable-line react-hooks/exhaustive-deps + + // Back to the start. `glide` (a hover stage settling as the pointer leaves) + // lets the scene's own transitions carry it back and walks the cursor home; + // otherwise everything jumps, since nobody is watching. + const glideId = useRef(0) + const resetFrame = useRef(0) + const cancelReset = useCallback(() => { + ++glideId.current + cancelAnimationFrame(resetFrame.current) + resetFrame.current = 0 + rootRef.current?.removeAttribute('data-settling') + }, []) + const reset = useCallback((glide = false) => { + cancelReset() + const id = glideId.current + dragging.current = false + setState(initial) + setKind(cursor?.kind ?? 'pointer') + setKeys((k) => ({ ...k, show: false, down: false })) + setLastKeys(null) + rootRef.current?.querySelectorAll('[data-hover],[data-pressed]').forEach((el) => { + el.removeAttribute('data-hover') + el.removeAttribute('data-pressed') + }) + rootRef.current?.removeAttribute('data-mark') + rootRef.current?.querySelectorAll('[data-scrolled]').forEach((el) => { + el.scrollTop = 0 + el.removeAttribute('data-scrolled') + thumb(el) + }) + rootRef.current?.querySelectorAll('[data-scrolling]').forEach((el) => el.removeAttribute('data-scrolling')) + cursorRef.current?.removeAttribute('data-pressed') + hovered.current = null + if (!cursor) return + if (!glide) { + resetFrame.current = requestAnimationFrame(() => { + resetFrame.current = 0 + place(resolve(restAt())) + autoRef.current?.(pos.current) + }) + return + } + const root = rootRef.current + root?.setAttribute('data-settling', '') + resetFrame.current = requestAnimationFrame(() => { + const from = { ...pos.current } + const end = resolve(restAt()) + const ms = clamp(320 + Math.hypot(end.x - from.x, end.y - from.y) * 0.7, 380, 700) + const t0 = performance.now() + const step = () => { + if (glideId.current !== id) return + const t = Math.min(1, (performance.now() - t0) / ms) + const e = easeInOutCubic(t) + place({ x: from.x + (end.x - from.x) * e, y: from.y + (end.y - from.y) * e }) + if (t < 1) resetFrame.current = requestAnimationFrame(step) + else { + resetFrame.current = 0 + root?.removeAttribute('data-settling') + } + } + step() + }) + }, [layout]) // eslint-disable-line react-hooks/exhaustive-deps + + // Each run gets its own act (act.ts), bound to its own signal, so a + // canceled run can never pick up the next run's signal and keep going. + const makeAct = useMemo( + () => (signal: AbortSignal) => + createAct(signal, { + rootRef, cursorRef, pos, hovered, kindRef, keysRef, dragging, autoUsed, autoRef, scaleRef, + speed, layout, cursor, resolve, place, remeasure, ripple, setState, setKind, setKeys, setLastKeys, + }), + [resolve, place, speed, layout], // eslint-disable-line react-hooks/exhaustive-deps + ) + + // Run the loop only while the stage is on screen and the tab is visible. + useEffect(() => { + const stage = rootRef.current! + if (still || !script) { + reset() + stage.removeAttribute('data-offscreen') + setState(poster ?? initial) + return cancelReset + } + // A changed script, layout or motion preference starts a fresh scene. + // Cleanup only releases resources; it never schedules another reset. + reset() + const root = fitRef.current! + let ctrl: AbortController | null = null + let onScreen = false + let hovering = false + let done = false + + // Idle: a cursor waiting to start reads as a stuck one, so before its + // first move it steps out of the shot. Once it has acted, resting to + // watch the result is part of the story: only a long stillness fades it, + // and never while it holds a press or rests on what it hovers. + const idle = cursor?.idle ?? 4000 + const LEAD = 1200 + let idleTimer = 0 + let revealTimer = 0 + const watchIdle = () => { + window.clearInterval(idleTimer) + if (!cursor || idle === false) return + active.current = performance.now() + idleTimer = window.setInterval(() => { + const el = cursorRef.current + if (!el) return + const now = performance.now() + if (!moved.current) { + // Later loops know when it moves and handle it in beginLoop + if (firstMove.current === null && now - active.current > LEAD) el.setAttribute('data-idle', '') + return + } + if (el.hasAttribute('data-pressed') || rootRef.current?.querySelector('[data-hover]')) active.current = now + else if (now - active.current > idle) el.setAttribute('data-idle', '') + }, 150) + } + const beginLoop = () => { + loopAt.current = performance.now() + moved.current = false + window.clearTimeout(revealTimer) + const el = cursorRef.current + const wait = firstMove.current + if (idle === false || !el || wait === null || wait <= LEAD) return + // A long opening wait: out now, back in just before the first move + el.setAttribute('data-idle', '') + revealTimer = window.setTimeout(() => el.removeAttribute('data-idle'), wait - 400) + } + const unwatchIdle = () => { + window.clearInterval(idleTimer) + window.clearTimeout(revealTimer) + loopAt.current = 0 + cursorRef.current?.removeAttribute('data-idle') + } + const finishIdle = () => { + window.clearInterval(idleTimer) + window.clearTimeout(revealTimer) + // A completed once scene cannot move again. Keep a hovered/pressed + // cursor visible, or finish its existing idle delay with one timeout. + // There is no reason to poll the held ending forever. + const el = cursorRef.current + const held = () => el?.hasAttribute('data-pressed') || stage.querySelector('[data-hover]') + if (!cursor || idle === false || !el || el.hasAttribute('data-idle') || held()) return + const delay = (moved.current ? idle : LEAD) - (performance.now() - active.current) + revealTimer = window.setTimeout(() => { + if (!held()) el.setAttribute('data-idle', '') + }, Math.max(0, delay)) + } + + const start = () => { + if (ctrl || done || !onScreen || document.hidden) return + if (play === 'hover' && !hovering) return + cancelReset() + if (cursor) place(resolve(restAt())) + ctrl = new AbortController() + const signal = ctrl.signal + watchIdle() + const act = makeAct(signal) + ;(async () => { + do { + beginLoop() + try { + await script(act) + } catch (e) { + if (e instanceof Stopped) return + // A real error in the script: report it once and stand the stage + // down, so a later start() can run it again + console.error('[stageshow] script error', e) + ctrl = null + unwatchIdle() + return + } + if (play === 'loop' && isDev()) checkSeam(stateRef.current, initial, pos.current, cursor ? resolve(restAt()) : null) + } while (play === 'loop' && !signal.aborted) + // Once holds its end state for good; hover holds it until the pointer leaves. + if (play === 'once' && !signal.aborted) { + done = true + finishIdle() + } + })() + } + const stop = (glide = false) => { + unwatchIdle() + if (!ctrl) return + ctrl.abort() + ctrl = null + if (!done) reset(glide) + } + + const markOffscreen = () => stage.toggleAttribute('data-offscreen', !onScreen || document.hidden) + markOffscreen() + const io = new IntersectionObserver(([entry]) => { + onScreen = entry.isIntersecting + // Off screen its CSS animations pause too, so nothing runs out of view + markOffscreen() + onScreen ? start() : stop() + }) + io.observe(root) + const onVisibility = () => { + markOffscreen() + document.hidden ? stop() : start() + } + document.addEventListener('visibilitychange', onVisibility) + const enter = () => { + hovering = true + start() + } + const leave = () => { + hovering = false + stop(true) + } + if (play === 'hover') { + root.addEventListener('pointerenter', enter) + root.addEventListener('pointerleave', leave) + } + return () => { + io.disconnect() + document.removeEventListener('visibilitychange', onVisibility) + root.removeEventListener('pointerenter', enter) + root.removeEventListener('pointerleave', leave) + unwatchIdle() + ctrl?.abort() + ctrl = null + autoRef.current = null + cancelReset() + } + }, [still, script, makeAct, play, layout]) // eslint-disable-line react-hooks/exhaustive-deps + + useLayoutEffect(() => { + if (cursor && !still && !dragging.current) autoRef.current?.(pos.current) + }, [state]) // eslint-disable-line react-hooks/exhaustive-deps + + const look = useMemo(() => ({ theme, palette, fidelity, layout }), [theme, palette, fidelity, layout]) + const showCursor = cursor && (!script || !still || cursor.still) + + return ( +
+
+ {ground && ( +
+ )} + + {children(state, rectFor, layout)} + + {script && } + {showCursor && } +
+
+ ) +} diff --git a/skills/imported/benjitaylor/stageshow/kit/stageshow.css b/skills/imported/benjitaylor/stageshow/kit/stageshow.css new file mode 100644 index 0000000..a40ac3f --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/stageshow.css @@ -0,0 +1,43 @@ +/* Stageshow kit: every style in one import, for a host that wants them all + loaded up front. Components import their own files, so a demo that + imports from kit/index.ts needs nothing else. The order is the order + the components load them in. */ + +@import './stage.css'; +@import './keys.css'; +@import './cursor.css'; +@import './window.css'; +@import './phone.css'; +@import './ios.css'; +@import './motion/Roll.css'; +@import './messages.css'; +@import './motion/Connector.css'; +@import './motion/Count.css'; +@import './motion/Swap.css'; +@import './motion/Collapse.css'; +@import './motion/Fly.css'; +@import './motion/Follow.css'; +@import './motion/States.css'; +@import './motion/Stream.css'; +@import './motion/Popover.css'; +@import './motion/Camera.css'; +@import './motion/Scroll.css'; +@import './motion/Expand.css'; +@import './parts/Avatar.css'; +@import './parts/Check.css'; +@import './parts/Icon.css'; +@import './parts/Spinner.css'; +@import './parts/Toast.css'; +@import './parts/Meter.css'; +@import './parts/Menu.css'; +@import './parts/Facepile.css'; +@import './parts/Chip.css'; +@import './parts/BrandMark.css'; +@import './controls/Field.css'; +@import './controls/Segmented.css'; +@import './controls/Toggle.css'; +@import './controls/Tabs.css'; +@import './controls/Badge.css'; +@import './controls/Slot.css'; +@import './code.css'; +@import './chart.css'; diff --git a/skills/imported/benjitaylor/stageshow/kit/util.ts b/skills/imported/benjitaylor/stageshow/kit/util.ts new file mode 100644 index 0000000..a2e2642 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/util.ts @@ -0,0 +1,25 @@ +// Small helpers shared by the stage and its script API. + +// Development only. Bundlers (Vite, Next, esbuild with a define) replace +// process.env.NODE_ENV, so a production build never warns; with no bundler +// to replace it, `process` is missing and this counts as development. +export function isDev() { + try { + return process.env.NODE_ENV !== 'production' + } catch { + return true + } +} + +export function warn(msg: string) { + if (isDev()) console.warn(`[stageshow] ${msg}`) +} + +export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) +export const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2) +export const hash = (i: number) => { + const x = Math.sin(i * 12.9898) * 43758.5453 + return x - Math.floor(x) +} + +declare const process: { env: { NODE_ENV?: string } } diff --git a/skills/imported/benjitaylor/stageshow/kit/window.css b/skills/imported/benjitaylor/stageshow/kit/window.css new file mode 100644 index 0000000..7b09d67 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/window.css @@ -0,0 +1,612 @@ +/* Window chrome (window.tsx): browser, app and sidebar app, plus the Bar and + Text placeholders. */ + +@layer stageshow { + /* Drawn: lights and fields are outlines, not fills */ + .ss-stage[data-theme="blueprint"] .ss-window__dot { + background: transparent; + box-shadow: inset 0 0 0 1px var(--ss-line); + } + + .ss-stage[data-theme="blueprint"] .ss-window__url { + background: transparent; + box-shadow: inset 0 0 0 1px var(--ss-line-faint); + } + + /* Traffic lights go gray too */ + .ss-stage[data-palette="mono"] .ss-window__dot { + background: var(--ss-ink-5); + box-shadow: none; + } + + :where(.ss-window) { + position: relative; + /* Where the lights sit: their center's distance from the window's top + (and from its left edge), their size, and the floating sidebar's + inset. lightsSize, barSize and sidebarStyle set these */ + --ss-lights-y: 19px; + --ss-dot: 9px; + --ss-side-inset: 0px; + } + + .ss-window { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--ss-canvas); + border-radius: var(--ss-radius-window); + box-shadow: var(--ss-shadow-window); + } + + .ss-window__bar { + /* A fixed height, so the traffic lights sit in the same place whether the + bar holds an address, a title or nothing. */ + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + height: 38px; + /* The lights sit as far from the left edge as from the top: (38 - 9) / 2 */ + padding: 0 calc(var(--ss-lights-y) - var(--ss-dot) / 2); + background: var(--ss-chrome); + border-bottom: 1px solid var(--ss-line); + } + + .ss-window__lights { + display: flex; + flex-shrink: 0; + /* macOS proportions: 12pt lights, 8pt apart */ + gap: 6px; + } + + .ss-window__dot { + width: var(--ss-dot); + height: var(--ss-dot); + border-radius: 50%; + box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.12); + } + + .ss-window__dot:nth-child(1) { background: #ff5f57; } + + .ss-window__dot:nth-child(2) { background: #febc2e; } + + .ss-window__dot:nth-child(3) { background: #28c840; } + + .ss-window__url { + flex: 1; + min-width: 0; + margin-left: 8px; + padding: 4px 10px; + overflow: hidden; + background: var(--ss-ink-1); + border-radius: 6px; + /* Chrome type is the browser's, never the page's: small and quiet, whatever + size the page inside sets */ + color: var(--ss-text-faint); + font-size: 10.5px; + line-height: 1.4; + white-space: nowrap; + text-overflow: ellipsis; + } + + /* urlShape="pill": the round address field of current Safari and Chrome */ + .ss-window[data-url-shape="pill"] .ss-window__url { + padding-inline: 12px; + border-radius: 999px; + } + + .ss-window__title { + flex: 1; + /* Optically centered: offsets the three dots on the left */ + margin-right: 36px; + text-align: center; + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); + } + + .ss-window__body { + position: relative; + flex: 1; + min-height: 0; + } + + .ss-window__spacer { + flex: 1; + } + + /* lights="grey": quiet lights for any chrome */ + .ss-window[data-lights="grey"] .ss-window__dot { + background: rgba(186, 200, 218, 0.5); + box-shadow: none; + } + + /* lights="white": translucent white lights remain distinct over a strong + color field, where gray lights can lose contrast. */ + .ss-window[data-lights="white"] .ss-window__dot { + background: rgba(255, 255, 255, 0.5); + box-shadow: none; + } + + /* chrome="glass": a frosted frame that takes + the color behind it, no bar fill or rule, the address centered on the + window, and the page a white card inset in the frame. Nested radii: the + frame's 12 is the card's 6 plus the 6 inset */ + .ss-window[data-chrome="glass"] { + background: rgba(248, 250, 253, 0.45); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-radius: 12px; + box-shadow: + 0 0 0 1px rgba(229, 237, 245, 0.5), + 0 15px 35px rgba(23, 23, 23, 0.08); + } + + .ss-window[data-chrome="glass"] > .ss-window__bar { + display: grid; + grid-template-columns: 1fr minmax(0, 262px) 1fr; + gap: 12px; + height: 35px; + padding: 0 20px; + background: none; + border: 0; + } + + .ss-window[data-chrome="glass"] .ss-window__lights { + gap: 4px; + } + + .ss-window[data-chrome="glass"] .ss-window__dot { + width: 8px; + height: 8px; + } + + .ss-window[data-chrome="glass"] .ss-window__url { + display: flex; + grid-column: 2; + align-items: center; + justify-content: center; + gap: 4px; + height: 20px; + margin: 0; + padding: 0 8px; + background: rgba(255, 255, 255, 0.38); + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(229, 237, 245, 0.5); + color: #50617a; + font-size: 10px; + } + + .ss-window__lock { + flex-shrink: 0; + color: #667691; + } + + .ss-window__url-text { + overflow: hidden; + text-overflow: ellipsis; + } + + .ss-window[data-chrome="glass"] .ss-window__end { + display: flex; + grid-column: 3; + justify-content: flex-end; + gap: 6px; + } + + /* The page is white, not the canvas tone: the step from frosted frame to + clean white page is what draws the card */ + .ss-window[data-chrome="glass"] > .ss-window__body { + margin: 0 6px 6px; + overflow: hidden; + background: #fff; + border-radius: 6px; + box-shadow: 0 3px 6px rgba(23, 23, 23, 0.06); + } + + /* chrome="card": one white card. Pale dots and a wide pale address bar, + no bar fill and no rule, so the page runs straight up into the chrome; + the long, soft shadow lifts it */ + .ss-window[data-chrome="card"] { + background: #fff; + border-radius: 8px; + box-shadow: + 0 50px 100px -20px rgba(50, 50, 93, 0.25), + 0 30px 60px -30px rgba(0, 0, 0, 0.3); + } + + .ss-window[data-chrome="card"] > .ss-window__bar { + gap: 14px; + height: 44px; + padding: 0 16px; + background: none; + border: 0; + } + + .ss-window[data-chrome="card"]:not([data-lights="color"]) .ss-window__dot { + background: #ecf2f7; + box-shadow: none; + } + + .ss-window[data-chrome="card"] .ss-window__url { + flex: 1; + height: 24px; + margin: 0; + padding: 0 10px; + background: #ecf2f7; + border-radius: 4px; + box-shadow: none; + color: #425466; + font-size: 12px; + font-weight: 300; + line-height: 24px; + } + + .ss-window[data-chrome="card"] .ss-window__end:empty { + display: none; + } + + .ss-window[data-chrome="card"] > .ss-window__body { + background: #fff; + } + + /* chrome="hairline": a short bar over a 1px rule, tiny dots, the address + as bare centered text */ + .ss-window[data-chrome="hairline"] { + background: #fff; + border-radius: 8px; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.06), + 0 12px 28px -12px rgba(50, 50, 93, 0.2); + } + + .ss-window[data-chrome="hairline"] > .ss-window__bar { + display: grid; + grid-template-columns: 1fr auto 1fr; + height: 20px; + padding: 0 8px; + background: none; + border: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + } + + .ss-window[data-chrome="hairline"] .ss-window__lights { + gap: 3px; + } + + .ss-window[data-chrome="hairline"] .ss-window__dot { + width: 5px; + height: 5px; + } + + .ss-window[data-chrome="hairline"]:not([data-lights="color"]) .ss-window__dot { + background: #e3e8ee; + box-shadow: none; + } + + .ss-window[data-chrome="hairline"] .ss-window__url { + grid-column: 2; + height: auto; + margin: 0; + padding: 0; + background: none; + box-shadow: none; + color: #8792a2; + font-size: 9px; + } + + .ss-window[data-chrome="hairline"] .ss-window__end { + grid-column: 3; + justify-self: end; + } + + /* chrome="terminal": one dark surface. The bar is the same color as the + body with no rule, the title dim and centered on the window; a hairline + light edge keeps the frame from melting into a dark stage */ + .ss-window[data-chrome="terminal"] { + --ss-term-bg: #17171a; + background: var(--ss-term-bg); + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.07), + var(--ss-shadow-hero, var(--ss-shadow-window)); + } + + .ss-window[data-chrome="terminal"] > .ss-window__bar { + position: relative; + background: none; + border: 0; + } + + .ss-window[data-chrome="terminal"][data-lights="grey"] .ss-window__dot { + background: rgba(255, 255, 255, 0.16); + box-shadow: none; + } + + .ss-term__title { + position: absolute; + left: 50%; + translate: -50% 0; + color: rgba(255, 255, 255, 0.42); + font-size: 11px; + font-weight: 500; + white-space: nowrap; + } + + /* Mono at a size that reads, lines with room: output a step dimmer than + what was typed, so the command is what the eye finds */ + .ss-term { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px 16px 16px; + overflow: hidden; + color: #9d9da6; + font: 11.5px/1.65 var(--ss-mono); + font-variant-ligatures: none; + } + + .ss-term > * + .ss-term__prompt { + margin-top: 8px; + } + + .ss-term__line, + .ss-term > div, + .ss-term > p { + margin: 0; + white-space: pre; + } + + /* Terminal colors are tokens with these defaults: set --ss-term-path, + --ss-term-sigil, --ss-term-ok, --ss-term-err or --ss-term-dim on the + window (or any parent) to match a product's accent */ + .ss-term__path { + color: var(--ss-term-path, #6f8ccf); + } + + .ss-term__sigil { + margin: 0 7px; + color: var(--ss-term-sigil, #6ee7a8); + } + + .ss-term__cmd { + color: #f1f1f3; + } + + /* Status color, sparingly: a pass, a failure, a dim aside */ + .ss-term .ok { color: var(--ss-term-ok, #6ee7a8); } + .ss-term .err { color: var(--ss-term-err, #f87171); } + .ss-term .dim { color: var(--ss-term-dim, #63636b); } + + .ss-term__caret { + display: inline-block; + width: 0.6em; + height: 1.2em; + margin-left: 1px; + vertical-align: -0.22em; + background: #e8e8ec; + animation: ss-term-blink 1.06s steps(1) infinite; + } + + .ss-term__caret[data-still] { + animation: none; + } + + @keyframes ss-term-blink { + 50% { opacity: 0; } + } + + @media (prefers-reduced-motion: reduce) { + .ss-term__caret { animation: none; } + } + + /* With toolbar controls the bar becomes a unified toolbar, as in Notes or + Finder: the title goes left beside the lights and the controls go right. */ + .ss-window__title[data-toolbar] { + margin: 0 0 0 14px; + text-align: left; + } + + .ss-window__nav { + display: flex; + flex-shrink: 0; + gap: 6px; + margin-left: 10px; + color: var(--ss-text-faint); + } + + .ss-window__nav + .ss-window__url { + margin-left: 4px; + } + + /* Browser with tabs: the strip holds the lights at the same 38px height; the + active tab is the toolbar's color and joins it, the strip sits a step back. */ + .ss-window__tabs { + display: flex; + flex-shrink: 0; + align-items: flex-end; + gap: 2px; + height: 38px; + padding: 0 calc(var(--ss-lights-y) - var(--ss-dot) / 2); + background: var(--ss-canvas); + } + + .ss-window__tabs .ss-window__lights { + align-self: center; + margin-right: 14px; + } + + .ss-window__tab { + display: flex; + align-items: center; + gap: 7px; + width: 140px; + min-width: 0; + height: 30px; + padding: 0 12px; + border-radius: 8px 8px 0 0; + font-size: 11px; + color: var(--ss-text-faint); + } + + .ss-window__tab[data-active] { + background: var(--ss-chrome); + color: var(--ss-text-strong); + } + + .ss-window__tab-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .ss-window__favicon { + flex-shrink: 0; + width: 10px; + height: 10px; + border-radius: 3px; + background: var(--ss-ink-4); + } + + .ss-window__tab[data-active] .ss-window__favicon { + background: var(--ss-accent); + } + + [data-kind="tabs"] > .ss-window__bar .ss-window__url { + margin-left: 0; + } + + [data-kind="tabs"] > .ss-window__bar .ss-window__nav { + margin-left: 0; + } + + /* Sidebar app: the sidebar runs the full height, the lights sit in its top + 38px at the same spot as every other chrome, and the content column has + its own bar with a left-aligned title. */ + .ss-window[data-kind="sidebar"] { + flex-direction: row; + } + + .ss-window__side { + display: flex; + flex-direction: column; + flex-shrink: 0; + background: var(--ss-canvas); + border-right: 1px solid var(--ss-line); + } + + .ss-window__side-top { + display: flex; + flex-shrink: 0; + align-items: center; + /* Centers the lights --ss-lights-y down from the window's top, as far in + from its left edge, inside a floating panel too */ + height: calc((var(--ss-lights-y) - var(--ss-side-inset)) * 2); + padding: 0 calc(var(--ss-lights-y) - var(--ss-side-inset) - var(--ss-dot) / 2); + } + + .ss-window__main { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + background: var(--ss-surface); + } + + .ss-window__main > .ss-window__bar { + padding: 0 16px; + } + + .ss-window__heading { + font-size: 13px; + font-weight: 600; + color: var(--ss-text-strong); + } + + .ss-bar { + display: block; + flex-shrink: 0; + } + + /* A line gives way in a row too narrow for it, never past its container + (Bar shrink, on by default for lines). Where it fits, nothing changes */ + .ss-bar[data-shrink] { + flex-shrink: 1; + min-width: 0; + max-width: 100%; + } + + /* lightsSize="large": macOS 26's 12pt lights, 8pt apart */ + .ss-window[data-lights-size="large"] { + --ss-dot: 12px; + } + + .ss-window[data-lights-size="large"] .ss-window__lights { + gap: 8px; + } + + /* barSize="tall": a 64px icon-and-label toolbar with no fill or rule. + Controls are 32px at the top, so they center 26px down, with their + labels under them; the lights and the title sit level with them */ + .ss-window[data-bar="tall"] { + --ss-lights-y: 26px; + } + + .ss-window[data-bar="tall"] > .ss-window__bar, + .ss-window[data-bar="tall"] .ss-window__main > .ss-window__bar { + align-items: flex-start; + height: 64px; + padding-top: 10px; + background: none; + border-bottom: 0; + } + + .ss-window[data-bar="tall"] > .ss-window__bar > .ss-window__lights { + margin-top: calc(16px - var(--ss-dot) / 2); + } + + /* The address field is shorter than a control: center it on their row */ + .ss-window[data-bar="tall"] .ss-window__url { + margin-top: 5px; + } + + .ss-window[data-bar="tall"] .ss-window__title, + .ss-window[data-bar="tall"] .ss-window__heading { + line-height: 32px; + } + + /* sidebarStyle="floating": macOS 26's sidebar, a rounded panel inset on + the top, left and bottom with a faint edge and shadow. Nested radii: the + window's 24 is the panel's 16 plus the 8 inset */ + .ss-window[data-side="floating"] { + --ss-side-inset: 8px; + border-radius: 24px; + background: var(--ss-surface); + } + + .ss-window[data-side="floating"] > .ss-window__side { + position: relative; + z-index: 1; + margin: var(--ss-side-inset) 0 var(--ss-side-inset) var(--ss-side-inset); + border-right: 0; + border-radius: 16px; + box-shadow: + 0 0 0 0.5px rgba(0, 0, 0, 0.07), + inset 0 0.5px 0 rgba(255, 255, 255, 1), + 0 3px 14px rgba(20, 35, 70, 0.07); + } + + .ss-stage[data-theme="dark"] .ss-window[data-side="floating"] > .ss-window__side { + background: var(--ss-raised); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.08), + 0 3px 14px rgba(0, 0, 0, 0.3); + } + + .ss-text { + white-space: nowrap; + } + + .ss-text[data-wrap] { + white-space: normal; + } +} diff --git a/skills/imported/benjitaylor/stageshow/kit/window.tsx b/skills/imported/benjitaylor/stageshow/kit/window.tsx new file mode 100644 index 0000000..2d3985a --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/kit/window.tsx @@ -0,0 +1,276 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { useLook } from './looks' +import './window.css' + +export interface WindowProps extends Omit, 'title'> { + /** The address. A string, or any node: roll just the path with `<>site.app/{path}`. */ + url?: ReactNode + /** Browser: the address field's shape. `pill` for the round field of current Safari and Chrome. */ + urlShape?: 'rounded' | 'pill' + /** Browser: tab titles above the address bar; the first is the active tab. */ + /** Tab titles. A title that changes should animate the part that changed, so pass a node */ + tabs?: ReactNode[] + /** Browser: back and forward buttons before the address. */ + nav?: boolean + /** App: the window title. Centered in a plain title bar; with a `toolbar`, left-aligned 14px after the lights (a unified toolbar) with the toolbar on the right; beside a sidebar, the content column's heading at its 16px inset. */ + title?: string + /** App: a full-height sidebar. The traffic lights sit in its top, the way Mail, Notes, Linear and Slack do it. */ + sidebar?: ReactNode + /** The sidebar's width in px. */ + sidebarWidth?: number + /** The sidebar's shape. `flush` (the default) runs edge to edge with a rule + * on its right. `floating` is macOS 26: a rounded panel inset 8px from the + * window's top, left and bottom, with a faint edge and shadow, on a window + * whose corners grow to 24 (the panel's 16 plus the inset). The lights stay + * in the panel's top, where the bar's controls are level with them. */ + sidebarStyle?: 'flush' | 'floating' + /** Controls on the right of the bar (toolbar buttons, avatars, a share button). */ + toolbar?: ReactNode + /** Controls on the left of the bar, after the title (a breadcrumb, a filter, view tabs). */ + lead?: ReactNode + /** Props for the content column (data-crop, a class), which sits inside the window's own clip. */ + body?: HTMLAttributes & Record<`data-${string}`, unknown> + /** Fades the window to 40%, so a window in front of it reads as the subject. */ + receded?: boolean + /** The chrome's material. `native` is macOS (the default). `glass` is a frosted + * frame that takes the color of whatever is behind it, with gray lights, the + * address centered in a pill with a lock, and the page as a white card inset in + * the frame. Glass is for a browser: give it a `url`. + * `terminal` is a dark terminal: one surface, no rule under the bar, the + * `title` dim and centered (`northwind — zsh`). Fill it with `Prompt` lines, + * plain output and a `Caret`. + * `card` is one white card: pale dots, a wide pale address bar, no rule under + * it, a long soft shadow (the checkout-page browser of a payments site). + * `hairline` is a small, quiet frame: a short bar over a 1px rule, tiny dots, + * the address as bare centered text; for a window that is a detail, not the + * subject. */ + chrome?: 'native' | 'glass' | 'terminal' | 'card' | 'hairline' + /** The traffic lights: macOS colors, gray (the default for glass), or white (glass over a strong color). */ + lights?: 'color' | 'grey' | 'white' + /** The lights' size. `standard` is 9px, 6px apart; `large` is macOS 26's + * 12px, 8px apart. Either way they sit as far from the left edge as their + * center is from the top. Native and terminal chrome only. */ + lightsSize?: 'standard' | 'large' + /** The bar's height. `standard` is the fixed 38px every window shares. + * `tall` is a 64px icon-and-label toolbar (macOS 26 Finder): no fill and no + * rule, controls 32px tall at the top with their labels under them, and + * the lights level with the controls (centered 26px down). Only for a + * product whose real toolbar is that tall. Native chrome only. */ + barSize?: 'standard' | 'tall' + children?: ReactNode +} + +/** The traffic lights. Every chrome puts them in the same spot: centered in the top 38px, 12px in. */ +function Lights() { + return ( + + + + + + ) +} + +function Lock() { + return ( + + + + + ) +} + +function Chevron({ back }: { back?: boolean }) { + return ( + + + + ) +} + +/** + * Window chrome. Three kinds, chosen by props: + * - browser (`url`): address bar, optional `nav` arrows and `tabs` strip + * - app (`title`): a plain title bar with a centered title + * - sidebar app (`sidebar`): the sidebar runs to the top and holds the lights; the content column gets its own toolbar + */ +export function Window({ url, urlShape, tabs, nav, title, sidebar, sidebarWidth = 180, sidebarStyle, toolbar, lead, body, className, receded, chrome, lights, lightsSize, barSize, children, ...rest }: WindowProps) { + const cls = `ss-window ss-recede${className ? ` ${className}` : ''}` + const large = lightsSize === 'large' ? 'large' : undefined + const tall = barSize === 'tall' ? 'tall' : undefined + + if (chrome === 'terminal') { + return ( +
+
+ + {title !== undefined && {title}} +
+
{children}
+
+ ) + } + + if (chrome === 'glass' || chrome === 'card' || chrome === 'hairline') { + return ( +
+
+ + {url !== undefined && ( + + {chrome === 'glass' && } + {url} + + )} + {toolbar} +
+
{children}
+
+ ) + } + + const address = url !== undefined && ( + <> + {nav && ( + + + + + )} + {url} + + ) + + if (sidebar !== undefined) { + return ( +
+ +
+
+ {title !== undefined && {title}} + {lead} + {address} + + {toolbar} +
+
{children}
+
+
+ ) + } + + if (tabs?.length) { + return ( +
+
+ + {tabs.map((t, i) => ( + + + {t} + + ))} +
+
+ {address} + {toolbar} +
+
{children}
+
+ ) + } + + return ( +
+
+ + {address} + {title !== undefined && {title}} + {lead} + {url === undefined && title === undefined && } + {toolbar} +
+
{children}
+
+ ) +} + +const INK = ['var(--ss-ink-1)', 'var(--ss-ink-2)', 'var(--ss-ink-3)', 'var(--ss-ink-4)', 'var(--ss-ink-5)'] + +export interface BarProps extends HTMLAttributes { + w: number | string + h?: number + /** 1 (faintest) to 5 */ + tone?: 1 | 2 | 3 | 4 | 5 + r?: number + /** Let it narrow in a row too tight for it, never past its container. On + * by default for a line (a numeric `w` over three times `h`); an icon slot, + * a dot or a percentage width keeps its size. */ + shrink?: boolean +} + +/** A placeholder: a line of text, a value, an icon slot or a pill. A line + * gives way in a narrow row as text would; where it fits, it is exactly `w`. */ +export function Bar({ w, h = 6, tone = 3, r = 2, shrink, className, style, ...rest }: BarProps) { + const gives = shrink ?? (typeof w === 'number' && w > h * 3) + return +} + +export interface TextProps extends HTMLAttributes { + children: string + /** Filler: the words show only at `realistic` fidelity; at `standard` and `minimal` the line is a bar of about the same width. Write the real copy once and let the stage's fidelity decide. */ + filler?: boolean + /** Bar width in minimal fidelity. Defaults to an estimate from the text. */ + w?: number + h?: number + tone?: BarProps['tone'] + /** Let it wrap. Text is one line by default, so a label never breaks by accident. */ + wrap?: boolean +} + +/** + * Text that knows the stage's fidelity: real words normally, a bar of about + * the same width in minimal. Write real copy once and every look follows. + */ +export function Text({ children, w, h = 6, tone = 3, wrap, filler, className, style, ...rest }: TextProps) { + const { fidelity } = useLook() + if (fidelity === 'minimal' || (filler && fidelity !== 'realistic')) { + const size = typeof style?.fontSize === 'number' ? style.fontSize : 11 + return + } + return ( + + {children} + + ) +} + +export interface PromptProps extends HTMLAttributes { + /** The working directory shown before the prompt. */ + path?: string + /** What was typed. A string, or a node (a typed string, a Caret after it). */ + children?: ReactNode +} + +/** A terminal command line: a dim path, the prompt mark, and the command + * bright. Recolor the path and mark with `--ss-term-path` and + * `--ss-term-sigil` (set on the window, the stage or any parent). */ +export function Prompt({ path = '~', children, className, ...rest }: PromptProps) { + return ( +
+ {path} + ❯ + {children} +
+ ) +} + +/** A terminal's block cursor. Blinks unless `still` (while typing, a real one holds solid). */ +export function Caret({ still }: { still?: boolean }) { + return +} diff --git a/skills/imported/benjitaylor/stageshow/package-lock.json b/skills/imported/benjitaylor/stageshow/package-lock.json new file mode 100644 index 0000000..a6db9aa --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/package-lock.json @@ -0,0 +1,1932 @@ +{ + "name": "stageshow", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stageshow", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "lucide-react": "0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.7.0", + "playwright-core": "~1.48.2", + "postcss": "^8.5.6", + "typescript": "^5.6.3", + "vite": "^6.4.5" + }, + "engines": { + "node": ">=20.19" + }, + "optionalDependencies": { + "portless": "^0.4.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", + "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz", + "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/portless": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/portless/-/portless-0.4.2.tgz", + "integrity": "sha512-/G3jIeD1XokoO9KY/lUGTV9irKz3tgx8yqHkz+hvj/86QeR219GvYFB+QEfpvjRlvvLVJa5vE7BkRfBZBe/lQg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "chalk": "^5.3.0" + }, + "bin": { + "portless": "dist/cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/skills/imported/benjitaylor/stageshow/package.json b/skills/imported/benjitaylor/stageshow/package.json new file mode 100644 index 0000000..7ff7f54 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/package.json @@ -0,0 +1,34 @@ +{ + "name": "stageshow", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "The self-contained Stageshow skill, React kit, examples, and playground.", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "dev": "node scripts/dev.mjs", + "typecheck": "tsc --noEmit -p playground", + "build": "vite build playground --config playground/vite.config.ts", + "check:css": "node scripts/class-clash.mjs --bundle teaching --bundle examples/looks examples teaching" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "lucide-react": "0.468.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.7.0", + "vite": "^6.4.5", + "typescript": "^5.6.3", + "playwright-core": "~1.48.2", + "postcss": "^8.5.6" + }, + "optionalDependencies": { + "portless": "^0.4.1" + } +} diff --git a/skills/imported/benjitaylor/stageshow/playground/catalog.ts b/skills/imported/benjitaylor/stageshow/playground/catalog.ts new file mode 100644 index 0000000..1027d3d --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/catalog.ts @@ -0,0 +1,94 @@ +// Built from SKILL.md's example tables: Gold under Scenes, Product studies +// under Real products, Specimen and reference sheets under Kit. Keep the +// names and notes in step with SKILL.md when either changes. +const curated = [ + { id: 'static-card', name: 'Static card', kind: 'Scene', note: 'Quiet hierarchy, a hard crop, one satellite, real words only on the selected customer, its neighbors and the total.' }, + { id: 'dark-bento', name: 'Dark card', kind: 'Scene', note: 'One signal said once; album art keeps it a music app.' }, + { id: 'hero-demo', name: 'Hero demo', kind: 'Scene', note: 'Five beats, named targets, an annotation, a terminal coming forward.' }, + { id: 'payout-flow', name: 'Payout flow', kind: 'Scene', note: 'Slow overlapping arrivals, reading time, a payoff that holds.' }, + { id: 'choreography', name: 'Changing markets', kind: 'Scene', note: 'Retained tracks, soft masks, dependent timing.' }, + { id: 'ai-chat', name: 'AI answer', kind: 'Scene', note: 'The question sent, Thinking, the answer streaming in with its height, sources last; no sparkles, no purple.' }, + { id: 'ai-cards', name: 'AI with cards', kind: 'Scene', note: 'A multiple-choice card answered and kept as the record, a card of options opening in the thread, booked from its own button.' }, + { id: 'form-error', name: 'Form validation', kind: 'Scene', note: 'An error on the field that caused it, cleared by fixing it, then the button confirms on itself.' }, + { id: 'reorder', name: 'Drag to reorder', kind: 'Scene', note: 'A row lifted by its grip, the others making room, dropped into place.' }, + { id: 'chart-scrub', name: 'Chart scrub', kind: 'Scene', note: 'A hairline and dot that follow the pointer, the headline value rolling to the day under it.' }, + { id: 'table-edit', name: 'Edit in a table', kind: 'Scene', note: 'A cell edited in place, then the total it feeds rolls a beat later.' }, + { id: 'loading', name: 'Loading to content', kind: 'Scene', note: 'A skeleton at the content\'s own geometry, rows resolving top to bottom, real chrome throughout.' }, + { id: 'dark-hero', name: 'Dark hero', kind: 'Scene', note: 'Depth from surface steps on black, one lit color, a status that changes on its own pill.' }, + { id: 'presence', name: 'Live collaborators', kind: 'Scene', note: 'Each person one color everywhere, names riding their cursors, work shown in the doc\'s own marks.' }, + { id: 'subscriptions', name: 'Subscription handoff', kind: 'Scene', note: 'A chip flies into a row, MRR rolls, a second scene is the reset.' }, + { id: 'close-up', name: 'Close-up', kind: 'Scene', note: 'One control at 1.4x with its window\'s chrome, the row whole, a large cursor to match.' }, + { id: 'many-of-one', name: 'Many of one', kind: 'Scene', note: 'A grid of one thing off every edge, one tile in focus by depth, the only one with words.' }, + { id: 'fade-crop', name: 'Fade crop', kind: 'Scene', note: 'A window fading out at its foot, over filler, instead of running off an edge.' }, + { id: 'panel-annotation', name: 'Panel over product', kind: 'Scene', note: 'A settings panel over the product\'s margin, one leader from the setting to what it changed.' }, + { id: 'overlap', name: 'Overlapping windows', kind: 'Scene', note: 'A card in front of a window, its edge on the window\'s sidebar line.' }, + { id: 'stacked-cards', name: 'Stacked cards', kind: 'Scene', note: 'Two components at one width, a small gap, the lower one off the bottom.' }, + { id: 'opposite-corners', name: 'Opposite corners', kind: 'Scene', note: 'A bento tile: copy bottom-left, the product hung from the top-right corner.' }, + { id: 'tilted-stack', name: 'Tilted stack', kind: 'Scene', note: 'A loose pile of paper, small alternating angles, only the top card has words.' }, + { id: 'corner-chip', name: 'Corner chip', kind: 'Scene', note: 'A small chip centered on a window\'s corner, half outside.' }, + { id: 'monochrome', name: 'Monochrome', kind: 'Scene', note: 'Other companies\' products in a row, grayscale, faded at the foot.' }, + { id: 'window-row', name: 'Row of windows', kind: 'Scene', note: 'Sessions side by side at one height: focus by depth, neighbors cut by the stage, only filler cropped.' }, + { id: 'device-pair', name: 'Window and phone', kind: 'Scene', note: 'A dashboard and a lock screen telling one event; the phone straddles over margin, never over the figure.' }, + { id: 'story', name: 'Invoice story', kind: 'Scene', note: 'An invoice, a plan, a connector, rolling totals.' }, + { id: 'calendar', name: 'Calendar drag', kind: 'Scene', note: 'A populated week, one focal event, a measured pickup.' }, + { id: 'freeform-drag', name: 'Drag and drop', kind: 'Scene', note: 'Grab offset, a measured grid, a drop slot that snaps.' }, + { id: 'phone-touch', name: 'Card expansion', kind: 'Scene', note: 'A card opens into its screen as one object, and the loop folds back.' }, + { id: 'dynamic-island', name: 'Dynamic Island', kind: 'Scene', note: 'One black shape, its content a separate layer.' }, + { id: 'messages', name: 'Messages', kind: 'Scene', note: 'Composer, typing, receipts, a bubble\'s lifetime.' }, + { id: 'cursors', name: 'Cursor kinds', kind: 'Specimen', note: 'Cursor kinds and direct feedback across common interactions.' }, + { id: 'cursor-morph', name: 'Cursor morph', kind: 'Morph', note: 'One outline becomes four cursor styles. Position, press feedback, and shape share the same stage.' }, + { id: 'cursor-resize', name: 'Resize cursors', kind: 'Scene', note: 'Resize edges and corners, the cursor changing kind as it crosses them.' }, + { id: 'annotations', name: 'Annotations and lines', kind: 'Scene', note: 'Leaders, dimensions, flows and a moving target.' }, + { id: 'bento', name: 'Feature grid', kind: 'Scene', note: 'One hero, one window, a stat, an object, a diagram.' }, + { id: 'ruled-bento', name: 'Technical grid', kind: 'Scene', note: 'Shared rules and technical imagery.' }, + { id: 'staggered-bento', name: 'Consumer grid', kind: 'Scene', note: 'Offset tiles, graphic art, physical objects.' }, + { id: 'hero-grid', name: 'Hero grid', kind: 'Scene', note: 'Different grounds and materials composed as one picture.' }, + { id: 'upload', name: 'Upload', kind: 'Scene', note: 'A file dragged in from the desktop, an upload row with progress that settles as a file row.' }, + { id: 'share', name: 'Share', kind: 'Scene', note: 'A share popover over a document: an email becomes a chip, a role menu, a person added to the list.' }, + { id: 'swipe', name: 'Swipe', kind: 'Scene', note: 'A phone swipe to archive: press, swipe, a throw at the finger\'s speed, the gap closes.' }, + { id: 'contrasts', name: 'Comparisons', kind: 'Teaching', note: 'Paired studies of motion, framing, hierarchy and restraint. Choose a principle to inspect.' }, +] as const + +const products = [ + ['figma', 'Figma', 'Canvas selection, a color picker, a second cursor.'], + ['linear', 'Linear', 'Command menu and assignment.'], + ['slack', 'Slack', 'Messages, a reaction, a thread reply.'], + ['raycast', 'Raycast', 'Search and clipboard history over a blurred desktop.'], + ['finder', 'Finder', 'Quick Look over a file list; a file moved into a folder; the same window in frosted glass.'], + ['finder-glass', 'Finder · glass', 'Quick Look over a file list; a file moved into a folder; the same window in frosted glass.'], + ['spotify', 'Spotify', 'A dark desktop app with album art.'], + ['notion', 'Notion', 'A slash command; a comment and its reply.'], + ['google-docs', 'Google Docs', 'A slash command; a comment and its reply.'], + ['gmail', 'Gmail', 'Hover actions, archive, an undo toast.'], + ['google-calendar', 'Google Calendar', 'Drag to create an event, the quick-create popup.'], + ['powerpoint', 'PowerPoint', 'Slide editing; AutoSum and a chart.'], + ['excel', 'Excel', 'Slide editing; AutoSum and a chart.'], + ['wikipedia', 'Wikipedia', 'Search suggestions in a browser.'], + ['telegram', 'Telegram', 'Native chat apps on an iPhone.'], + ['imessage', 'iMessage', 'Native chat apps on an iPhone.'], +].map(([id, name, note]) => ({ id, name, note, kind: 'Product', group: 'Real products' })) + +const specimens = [ + ['backgrounds', 'Backgrounds', 'One interface over twenty grounds: colorful gradients, light, linework and material.'], + ['looks', 'Visual styles', 'Type, shape and color working together. Treat the kit defaults as a starting point.'], + ['brand-marks', 'Brand marks', 'Common payment, app, bank and crypto marks, with original artwork on light and dark surfaces.'], + ['chrome', 'Window chrome', 'Twelve window frames, including glass with an inset page, card, hairline and native browser variants.'], + ['phones', 'Phones', 'Every phone frame in the kit.'], + ['controls', 'Controls', 'Field, Segmented, Toggle, Tabs, Code, Chart, Slot, Scroll and a Camera push-in.'], + ['parts', 'Parts', 'States, Check, Meter, Stream, Connector arrows, Popover and Menu, the satellite lock screen.'], + ['kit-extras', 'Kit extras', 'Chip, Swap, Avatar rings, Meter tones, Menu hover, Expand, solid glyphs, BrandMark.'], + ['glass', 'Glass', 'The glass theme on a window, a chip and a popover.'], +].map(([id, name, note]) => ({ id, name, note, kind: 'Reference', group: 'Kit' })) + +// A new example directory shows up at the end of Kit until it is listed above. +// Vite updates this glob when a file is added; no restart needed. +const listed = new Set([...curated, ...products, ...specimens].map(example => example.id)) +const extra = Object.keys(import.meta.glob('../examples/*/index.tsx')) + .map(path => path.split('/').at(-2)!) + .filter(id => !listed.has(id)) + .sort() + .map(id => ({ id, name: id.split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' '), kind: 'Reference', group: 'Kit', note: 'A new example, not yet listed in the catalog.' })) +// Stack's licensed subset omits five upstream example directories. +const available = new Set(['contrasts', ...Object.keys(import.meta.glob('../examples/*/index.tsx')).map(path => path.split('/').at(-2)!)]) +export const examples = [...curated.map(example => ({ ...example, group: example.id === 'contrasts' ? 'Comparisons' : 'Scenes' })), ...products, ...specimens, ...extra].filter(example => available.has(example.id)) +export const groups = ['Scenes', 'Real products', 'Comparisons', 'Kit'] diff --git a/skills/imported/benjitaylor/stageshow/playground/index.html b/skills/imported/benjitaylor/stageshow/playground/index.html new file mode 100644 index 0000000..9d8bdd4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/index.html @@ -0,0 +1,5 @@ + + + Stageshow · Playground +
+ diff --git a/skills/imported/benjitaylor/stageshow/playground/main.tsx b/skills/imported/benjitaylor/stageshow/playground/main.tsx new file mode 100644 index 0000000..807de42 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/main.tsx @@ -0,0 +1,111 @@ +import { installVirtualClock } from '../kit/clock' +import { createFrameDriver, createPlayback } from './playback' + +const params = new URLSearchParams(location.search) +const errorText = (error: unknown) => error instanceof Error ? error.message : String(error) +function showFailure(error: unknown) { + const message = document.createElement('p') + message.setAttribute('role', 'alert') + message.textContent = `This example could not load: ${errorText(error)}. Check the console for details, then replay after fixing it.` + document.getElementById('root')!.replaceChildren(message) +} + +async function boot() { + if (params.has('capture')) { + // Film/jump tools control their own pacing and instrumentation. This + // direct route keeps the browser's native timers, RAF and CSS clocks. + const { mount } = await import('./preview') + await mount(params.get('capture')!, params.get('still') === '1') + return + } + if (!params.has('embed')) { + const { mount } = await import('./viewer') + mount() + return + } + // Only the iframe owns virtual time. Capture the native clocks before + // installing it so the viewer and this driver remain responsive. + const nativeRaf = window.requestAnimationFrame.bind(window) + const nativeCancelRaf = window.cancelAnimationFrame.bind(window) + const realNow = performance.now.bind(performance) + let wakeLayout = () => {} + const clock = installVirtualClock({ onLayout: () => wakeLayout() }) + const startedAt = clock.now() + let failed = false + let paused = false + let reported = 0 + let ready = false + let staticScene = false + let placingScene = false + let syncScene = () => {} + const send = (extra = {}) => parent.postMessage({ type: 'stageshow:status', frame: clock.frame, elapsed: clock.now() - startedAt, staticScene, ...extra }, location.origin) + const fail = (error: unknown) => { + if (failed) return + failed = true + playback.set({ playing: false }) + driver.sync() + send({ ready: false, error: errorText(error) }) + } + const report = () => { + const now = realNow() + if (paused || now - reported > 150) { send(); reported = now } + } + const playback = createPlayback(clock, { now: realNow, hidden: () => document.hidden, onFrame: report, onError: fail }) + const needsPlacement = () => placingScene || ((staticScene || !playback.isPlaying()) && clock.layoutPending) + const driver = createFrameDriver({ + running: () => ready && !failed && (needsPlacement() ? playback.isVisible() : !staticScene && playback.isPlaying()), + request: nativeRaf, + cancel: nativeCancelRaf, + tick: async (timestamp) => { + try { + if (needsPlacement()) { await playback.place(); syncScene() } + else await playback.tick(timestamp) + } + catch (error) { fail(error) } + }, + }) + wakeLayout = driver.sync + const syncVisibility = () => { playback.set({}); driver.sync() } + document.addEventListener('visibilitychange', syncVisibility) + window.addEventListener('error', (event) => fail(event.error ?? event.message)) + window.addEventListener('unhandledrejection', (event) => fail(event.reason)) + window.addEventListener('message', (event) => { + if (event.origin !== location.origin || event.source !== parent || event.data?.type !== 'stageshow:control' || failed) return + const data = event.data + if (typeof data.playing === 'boolean') paused = !data.playing + if (data.step) paused = true + playback.set({ playing: data.playing, visible: data.visible, speed: data.speed }) + if (data.step) void playback.step().then(() => send()) + else if (paused) send() + driver.sync() + }) + try { + const { mount } = await import('./preview') + await mount(params.get('embed')!, params.get('still') === '1', fail) + await clock.settle() + const example = document.getElementById('example') + if (!example) throw new Error('The example did not mount') + new ResizeObserver(() => send({ height: example.offsetHeight })).observe(example) + syncScene = () => { + const stages = [...example.querySelectorAll('.ss-stage')] + // Still/reduced scenes need their initial measurement frames before + // sleeping. Active scenes keep all timers and CSS motion advancing; + // an empty queue is not evidence that a script has finished. + const next = stages.length > 0 && stages.every(stage => stage.hasAttribute('data-still')) + placingScene = stages.some(stage => stage.hasAttribute('data-placing')) + if (staticScene !== next) { staticScene = next; playback.set({}); send() } + driver.sync() + } + new MutationObserver(syncScene).observe(example, { + subtree: true, childList: true, attributes: true, attributeFilter: ['data-still', 'data-placing'], + }) + ready = true + syncScene() + if (!failed) send({ ready: true }) + } catch (error) { + fail(error) + showFailure(error) + } +} + +void boot().catch(showFailure) diff --git a/skills/imported/benjitaylor/stageshow/playground/playback.ts b/skills/imported/benjitaylor/stageshow/playground/playback.ts new file mode 100644 index 0000000..3e2d3f4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/playback.ts @@ -0,0 +1,110 @@ +type Clock = { fps: number; step(frames?: number): Promise; advance(milliseconds: number): Promise; flushLayout(): Promise } +type Controls = { playing?: boolean; visible?: boolean; speed?: number } + +/** One virtual frame at a time, so a control received during a frame takes + * effect before the next. Manual steps share the same queue as playback. */ +export function createPlayback(clock: Clock, { + now, hidden, onFrame, onError, +}: { + now: () => number + hidden: () => boolean + onFrame: () => void + onError: (error: unknown) => void +}) { + let playing = true + let visible = false + let speed = 1 + let previous = now() + let failed = false + let ticking = false + let queue = Promise.resolve() + const canRender = () => visible && !hidden() && !failed + const canPlay = () => playing && canRender() + const resetTime = () => { previous = now() } + const advance = (kind: 'play' | 'step' | 'layout', elapsed = 0) => { + queue = queue.then(async () => { + if (failed || (kind === 'play' && !canPlay()) || (kind === 'layout' && !canRender())) return + try { + if (kind === 'layout') await clock.flushLayout() + else { + if (kind === 'play') await clock.advance(elapsed) + else await clock.step(1) + onFrame() + } + } catch (error) { + failed = true + playing = false + onError(error) + } + }) + return queue + } + return { + isPlaying: canPlay, + isVisible: canRender, + set(controls: Controls) { + if (typeof controls.playing === 'boolean') playing = controls.playing + if (typeof controls.visible === 'boolean') visible = controls.visible + if (controls.speed !== undefined && [0.25, 0.5, 1, 2].includes(controls.speed)) speed = controls.speed + resetTime() + }, + step() { + playing = false + resetTime() + return advance('step') + }, + place() { + // Measurement work shares the motion queue, but changes neither the + // user's play preference nor the scene's virtual clock. + resetTime() + return advance('layout') + }, + async tick(timestamp = now()) { + if (ticking || failed) return + ticking = true + // Native RAF timestamps share one paint clock. Callback-entry times + // include task jitter and can turn steady paints into repeated/skipped + // fixed frames. Live playback advances once by the actual elapsed time. + const elapsed = Math.max(0, timestamp - previous) + previous = timestamp + try { + if (!canPlay() || elapsed === 0) return + // Bound a stalled tab's work; pause never waits behind a catch-up + // batch. Recording and manual Step retain their fixed frame size. + await advance('play', Math.min(100, elapsed * speed)) + } finally { + ticking = false + } + }, + } +} + +/** Keep at most one native frame scheduled. A sleeping driver wakes only + * when controls, visibility or the scene's static state actually change. */ +export function createFrameDriver({ running, tick, request, cancel }: { + running: () => boolean + tick: (timestamp: number) => Promise + request: (callback: FrameRequestCallback) => number + cancel: (id: number) => void +}) { + let frame: number | null = null + let ticking = false + let disposed = false + const sync = () => { + if (disposed || !running()) { + if (frame !== null) cancel(frame) + frame = null + } else if (frame === null && !ticking) { + frame = request(async (timestamp) => { + frame = null + ticking = true + try { await tick(timestamp) } + finally { ticking = false; sync() } + }) + } + } + return { + sync, + dispose() { disposed = true; sync() }, + } +} diff --git a/skills/imported/benjitaylor/stageshow/playground/preview.css b/skills/imported/benjitaylor/stageshow/playground/preview.css new file mode 100644 index 0000000..d091362 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/preview.css @@ -0,0 +1,6 @@ +* { box-sizing: border-box; } +html, body { margin: 0; background: transparent; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +#example { width: 100%; min-height: 1px; } +#example > .ss-fit { margin-inline: auto; } +#example > [role="alert"] { padding: 24px; font-size: 13px; color: #a42a2a; } diff --git a/skills/imported/benjitaylor/stageshow/playground/preview.tsx b/skills/imported/benjitaylor/stageshow/playground/preview.tsx new file mode 100644 index 0000000..3f56568 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/preview.tsx @@ -0,0 +1,18 @@ +import { Component, StrictMode, type ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { StillContext } from '../kit/reduced-motion' +import './preview.css' + +const modules = import.meta.glob<{ default: React.ComponentType }>('../examples/*/index.tsx') +class Boundary extends Component<{ children: ReactNode; onError: (message: string) => void }, { error: string | null }> { + state = { error: null as string | null } + static getDerivedStateFromError(error: Error) { return { error: error.message } } + componentDidCatch(error: Error) { this.props.onError(error.message) } + render() { return this.state.error ?

This example could not render: {this.state.error}. Check the console for details, then replay after fixing it.

: this.props.children } +} +export async function mount(name: string, still: boolean, onError: (message: string) => void = () => {}) { + const load = name === 'contrasts' ? () => import('../teaching/contrasts') : modules[`../examples/${name}/index.tsx`] + if (!load) throw new Error(`Unknown example: ${name}`) + const { default: Demo } = await load() + createRoot(document.getElementById('root')!).render(
) +} diff --git a/skills/imported/benjitaylor/stageshow/playground/tsconfig.json b/skills/imported/benjitaylor/stageshow/playground/tsconfig.json new file mode 100644 index 0000000..2dff6d4 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"] }, + "include": [".", "../kit", "../examples", "../teaching"] +} diff --git a/skills/imported/benjitaylor/stageshow/playground/viewer.css b/skills/imported/benjitaylor/stageshow/playground/viewer.css new file mode 100644 index 0000000..648db8b --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/viewer.css @@ -0,0 +1,58 @@ +:root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #111; background: #fdfdfc; font-synthesis: none; -webkit-font-smoothing: antialiased; } +* { box-sizing: border-box; } +body { margin: 0; } +button, select, input { font: inherit; } +button, select { cursor: pointer; } +button:focus-visible, select:focus-visible, input:focus-visible, a:focus-visible { outline: 1.5px solid #7ca9d1; outline-offset: 2px; } +button:disabled, select:disabled { cursor: default; opacity: .4; } +.pg { max-width: 960px; margin: auto; padding: 0 40px; } +.pg-header { display: flex; align-items: baseline; gap: 10px; padding: 48px 0 32px; } +.pg-brand { color: inherit; text-decoration: none; font-size: 15px; font-weight: 560; letter-spacing: -.13px; } +.pg-eyebrow { color: #999; font-size: 14px; } +.pg-picker { display: flex; align-items: center; gap: 14px; padding: 0 0 28px; font-size: 13px; } +.pg-picker label { color: #999; } +.pg-picker select { appearance: none; border: 1px solid #e9e9e9; border-radius: 6px; background-color: transparent; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%23787870' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 11px center; color: #333; padding: 8px 32px 8px 11px; line-height: 18px; font-weight: 500; min-width: 180px; max-width: 100%; transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease; } +.pg-picker select:focus-visible { outline: 2px solid transparent; border-color: #9eb5c8; box-shadow: 0 0 0 2px #3e9fff18; } +@media (hover: hover) { .pg-picker select:hover { border-color: #d0d0d0; background-color: #fafafa; } } +.pg-preview { min-width: 0; } +.pg-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.pg-segment { display: flex; gap: 4px; } +.pg-segment button { color: #999; font-size: 13px; border: 0; background: transparent; border-radius: 5px; padding: 6px 10px; transition: color 160ms ease, background-color 160ms ease, box-shadow 160ms ease; } +.pg-segment button[aria-pressed=true] { color: #222; background: #f0f0f0; } +.pg-still { display: flex; align-items: center; gap: 7px; font-size: 13px; color: #888; cursor: pointer; } +.pg-still input { margin: 0; accent-color: #333; width: 13px; height: 13px; } +.pg-canvas { display: flex; align-items: center; justify-content: center; min-height: 280px; margin-top: 16px; padding: 24px; background: #f7f7f7; border-radius: 8px; } +.pg-canvas[data-grid] { background: #fff; } +.pg-canvas iframe { width: 100%; max-width: 880px; border: 0; display: block; color-scheme: light; } +.pg-canvas[data-phone] iframe { width: 358px; max-width: 100%; } +.pg-transport { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 16px; padding: 12px 0; border-top: 1px solid #ededed; font-size: 13px; } +.pg-buttons { display: flex; align-items: center; gap: 6px; } +.pg-buttons button { border: 0; background: transparent; color: #888; padding: 7px 10px; border-radius: 5px; line-height: 20px; flex: none; transition: color 140ms ease, background-color 140ms ease, opacity 140ms ease, transform 140ms cubic-bezier(.22,1,.36,1); } +@media (hover: hover) { .pg-buttons button:hover:not(:disabled) { color: #111; background: #f2f2f2; } } +.pg-buttons button:active:not(:disabled) { transform: scale(.96); } +.pg-buttons .pg-play { display: inline-flex; align-items: center; gap: 6px; color: #333; text-align: left; } +.pg-play-icon { width: 14px; height: 14px; flex: none; fill: currentColor; } +.pg-play-icon path { transition: d 180ms cubic-bezier(.22,1,.36,1); } +.pg-play-icon-left { d: path('M5 3L9 5.5L9 10.5L5 13Z'); } +.pg-play-icon-right { d: path('M9 5.5L13 8L13 8L9 10.5Z'); } +.pg-play[data-playing] .pg-play-icon-left { d: path('M4 3L6.5 3L6.5 13L4 13Z'); } +.pg-play[data-playing] .pg-play-icon-right { d: path('M9.5 3L12 3L12 13L9.5 13Z'); } +.pg-play-label { display: grid; height: 20px; overflow: hidden; } +.pg-play-label > span { grid-area: 1 / 1; transition: opacity 180ms ease, filter 180ms ease, transform 240ms cubic-bezier(.22,1,.36,1); } +.pg-pause-word { opacity: 0; filter: blur(1.5px); transform: translateY(5px); } +.pg-play-word { opacity: 1; filter: blur(0); transform: translateY(0); } +.pg-play[data-playing] .pg-play-word { opacity: 0; filter: blur(1.5px); transform: translateY(-5px); } +.pg-play[data-playing] .pg-pause-word { opacity: 1; filter: blur(0); transform: translateY(0); } +.pg-time { display: flex; align-items: center; flex: none; gap: 18px; margin-left: auto; color: #777; } +.pg-time output { font: 12px ui-monospace, SFMono-Regular, monospace; font-variant-numeric: tabular-nums; } +.pg-time output span { color: #aaa; margin-left: 3px; } +.pg-time select { appearance: none; border: 0; background-color: transparent; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='m3 4.5 3 3 3-3' fill='none' stroke='%23787870' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; color: #777; padding: 6px 26px 6px 8px; border-radius: 4px; } +.pg-caption { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; padding: 24px 0 0; } +.pg-caption h2 { font-size: 14px; margin: 0 0 7px; font-weight: 560; } +.pg-caption p { font-size: 13px; line-height: 1.6; color: #888; max-width: 510px; margin: 0; } +.pg-caption code { font: 11px ui-monospace, SFMono-Regular, monospace; color: #aaa; white-space: nowrap; } +.pg-footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 48px; color: #aaa; font-size: 12px; } +@media (max-width: 600px) { .pg { padding: 0 16px; } .pg-header { padding: 32px 0 28px; } .pg-picker { gap: 12px; } .pg-picker select { min-width: 0; max-width: calc(100% - 68px); } .pg-canvas { padding: 16px 0; } .pg-transport { padding: 10px 0; } .pg-caption { display: block; } .pg-caption code { display: block; margin-top: 14px; } .pg-footer { flex-direction: column; gap: 8px; } } +@media (prefers-reduced-motion: reduce) { .pg-buttons button, .pg-play-icon path, .pg-play-label > span, .pg-segment button { transition: none; } } +.pg[data-reduced-motion] :is(.pg-buttons button, .pg-play-icon path, .pg-play-label > span, .pg-segment button) { transition: none; } +@media (forced-colors: active) { .pg-picker select, .pg-time select { appearance: auto; background-image: none; padding-right: 10px; } .pg-picker select:focus-visible { outline-color: Highlight; } } diff --git a/skills/imported/benjitaylor/stageshow/playground/viewer.tsx b/skills/imported/benjitaylor/stageshow/playground/viewer.tsx new file mode 100644 index 0000000..524e2f1 --- /dev/null +++ b/skills/imported/benjitaylor/stageshow/playground/viewer.tsx @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { examples, groups } from './catalog' +import './viewer.css' + +function Viewer() { + const initial = new URLSearchParams(location.search).get('example') + const [name, setName] = useState(examples.some(e => e.id === initial) ? initial! : examples[0].id) + const [phone, setPhone] = useState(false) + const [still, setStill] = useState(() => matchMedia('(prefers-reduced-motion: reduce)').matches) + const motionOverride = useRef(false) + const [playing, setPlaying] = useState(true) + const [speed, setSpeed] = useState(1) + const [revision, replay] = useState(0) + const [elapsed, setElapsed] = useState(0) + const [height, setHeight] = useState(460) + const [ready, setReady] = useState(false) + const [staticScene, setStaticScene] = useState(false) + const [error, setError] = useState(null) + const iframe = useRef(null) + const visible = useRef(false) + const lesson = useRef(new URLSearchParams(location.search).get('lesson') ?? 'label') + const annotation = useRef(new URLSearchParams(location.search).get('annotation') ?? 'collect') + const selected = examples.find(e => e.id === name)! + const transportDisabled = !ready || still || staticScene + const source = useMemo(() => `?embed=${name}&still=${still ? 1 : 0}${name === 'contrasts' ? `&lesson=${encodeURIComponent(lesson.current)}` : name === 'annotations' ? `&annotation=${encodeURIComponent(annotation.current)}` : ''}`, [name, revision, still]) + const send = (data: object) => iframe.current?.contentWindow?.postMessage({ type: 'stageshow:control', ...data }, location.origin) + useEffect(() => { + const preference = matchMedia('(prefers-reduced-motion: reduce)') + const change = () => { if (!motionOverride.current) setStill(preference.matches) } + preference.addEventListener('change', change) + return () => preference.removeEventListener('change', change) + }, []) + useEffect(() => { + const receive = (event: MessageEvent) => { + if (event.origin !== location.origin || event.source !== iframe.current?.contentWindow || event.data?.type !== 'stageshow:status') return + if (typeof event.data.lesson === 'string') { + lesson.current = event.data.lesson + const url = new URL(location.href); url.searchParams.set('lesson', event.data.lesson); history.replaceState(null, '', url) + } + if (Number.isFinite(event.data.elapsed)) setElapsed(event.data.elapsed) + if (typeof event.data.annotation === 'string') { + annotation.current = event.data.annotation + const url = new URL(location.href); url.searchParams.set('annotation', event.data.annotation); history.replaceState(null, '', url) + } + if (typeof event.data.staticScene === 'boolean') setStaticScene(event.data.staticScene) + if (Number.isFinite(event.data.height)) setHeight(Math.max(1, event.data.height)) + if (event.data.error) { setError(String(event.data.error)); setReady(false); setPlaying(false) } + else if (event.data.ready) { setReady(true); send({ playing: playing && !still, speed, visible: visible.current }) } + } + window.addEventListener('message', receive) + return () => window.removeEventListener('message', receive) + }, [playing, speed, still]) + useEffect(() => { send({ playing: playing && !still, speed }) }, [playing, speed, still]) + useEffect(() => { + const observer = new IntersectionObserver(([entry]) => { + visible.current = entry.isIntersecting + send({ visible: visible.current }) + }) + if (iframe.current) observer.observe(iframe.current) + return () => observer.disconnect() + }, [name, revision, still]) + useEffect(() => { + setReady(false); setStaticScene(false); setElapsed(0); setError(null) + const url = new URL(location.href); url.searchParams.set('example', name); history.replaceState(null, '', url) + }, [name, revision, still]) + return
+
StageshowPlayground
+
+
+
+