diff --git a/.gitignore b/.gitignore index ae7ee4424..9ef8075c5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,10 @@ Thumbs.db .impeccable/live/deferred-svelte-component-accepts.json .impeccable/history/ .impeccable/config.local.json +# API keys for optional image-generation cues (document seed mode). +.impeccable/.env +# Generated visual-cue images + cues.json (document seed Step 4). +.impeccable/visual-cues/ **/.impeccable/hook.pending.json .impeccable/provider-smoke/ src/__impeccable_provider_smoke_*.html @@ -119,7 +123,8 @@ talks/ .astro/ # Local-only scratch for exploratory scripts, parked pages, and unused asset candidates. -tmp/ +tmp/* +!tmp/questionaire/ # Isolated ablation staging (impeccable-evals copies the built staged skill here # per rule, removes one rule, points a worker at it). Throwaway; never committed. @@ -129,3 +134,12 @@ tmp/ # PNGs, the old card backup). The canonical generator is `bun run og-image` # (scripts/generate-og-image.js); this dir is throwaway and safe to delete. .og-build/ + +# Working plans for agent-executed features (browser questionnaire etc.). +# Local planning scratch between the planning agent and executing agents. +plan/ + +# Intermediate and generated output for the picker build. Release builds sync +# the generated picker into the tracked provider harness directories. +build-picker/ +skill/scripts/picker/ diff --git a/.impeccable/config.json b/.impeccable/config.json index 95ab71955..53f341689 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -4,7 +4,8 @@ "ignoreFiles": [ "tests/fixtures/**", "tests/framework-fixtures/**", - "tests/detect-antipatterns.test.js" + "tests/detect-antipatterns.test.js", + "picker/styles/design-context.css" ], "ignoreValues": [ { @@ -15,6 +16,32 @@ ], "createdAt": "2026-07-17T00:00:00.000Z", "reason": "Live overlay chrome is injected over arbitrary host pages and builds a self-contained UI with its own small type scale; DESIGN.md's ramp describes the impeccable website, not this widget" + }, + { + "rule": "broken-image", + "value": "*", + "files": [ + "picker/pages/index.astro" + ], + "createdAt": "2026-07-23T14:51:18.241Z" + }, + { + "rule": "design-system-font-size", + "value": "*", + "files": [ + "picker/styles/picker.css" + ], + "createdAt": "2026-07-23T21:00:00.000Z", + "reason": "The picker is a self-contained questionnaire widget with its own kit-token type scale, and screen 04's cards render arbitrary Google Fonts at specimen sizes; DESIGN.md's ramp describes the impeccable website, not this tool (same footing as the live-browser.js entry)" + }, + { + "rule": "design-system-font", + "value": "*", + "files": [ + "picker/scripts/design-context.js" + ], + "createdAt": "2026-07-30T13:11:54.730Z", + "reason": "Runtime font loader: builds a Google Fonts URL from the family the agent/user chose in the questionnaire; no literal face is being introduced" } ] }, diff --git a/AGENTS.md b/AGENTS.md index 39bcc730c..05071112f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,8 @@ TDD order is non-negotiable: 5. Add two adapters that wrap the pure check: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). Wire **both** adapters into **both** element loops in `cli/engine/detect-antipatterns.mjs` (browser loop ~line 1837, jsdom loop in `detectHtml` ~line 2058). Forgetting one is the most common mistake. 6. Verify on a live page at `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and on the homepage. The two adapter paths can disagree. +To scan the browser questionnaire, use `node cli/bin/cli.js detect skill/scripts/picker/` after `bun run build:picker`. Name the directory, never `index.html` on its own: the page links its stylesheet, so a page-only scan reads none of the picker's CSS. The baseline and the reason each accepted finding stands are in CLAUDE.md's **Picker anti-pattern gate**. + Conventions: wrap the identifying heading text in straight double quotes inside snippets so the fixture test can extract it. jsdom-specific helpers `resolveBackground()`, `resolveGradientStops()`, and `parseGradientColors()` exist because `background:` shorthand isn't decomposed and computed colors aren't normalized in jsdom — use them. Reference rules to copy from: `side-tab` (border), `low-contrast` (color+gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level). ## Commit & Pull Request Guidelines diff --git a/CLAUDE.md b/CLAUDE.md index dc3dae89a..5dc28eedc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -230,6 +230,31 @@ bun run build:browser **IMPORTANT**: Always use `node` (not `bun`) to run the detect CLI. Bun's jsdom implementation is extremely slow and will cause scans with HTML files to hang for minutes. +### Picker anti-pattern gate + +After `bun run build:picker`, scan the **built directory**: + +```bash +node cli/bin/cli.js detect skill/scripts/picker/ +``` + +**Name the directory, not `index.html`.** The built page only links its stylesheet and its script, and the rules that read CSS or JS text run per file, so scanning the page alone never reads a line of either. That is how three `design-system-color` findings vanished with nothing fixed: the mode-tile demo palette moved out of an inline `style` attribute and into `picker.css`, and the gate went quiet while the values stayed put. Naming a bundle by path is not a fix either, because Vite content-hashes the filenames and a stale path silently narrows the scan back to the page. + +Baseline: **54 findings**, 29 on `index.html`, 24 on the bundled stylesheet, and 1 on the bundled script. The groups below are accepted with a reason on record, so hold 54 rather than treating it as a backlog: + +- 18 `cramped-padding` on the palette strips, nine for each of the two: the editable one on screen 02 and the reading copy under screen 03's choices. Each strip reports its group, its four items, and its four swatches. A swatch is a color that has to reach its own edges, so `.picker-band` carries no padding and places its hex readout and drag grip by inset instead, which the rule has no way to see. Adding padding here would put a frame around every color the visitor picked. +- 3 `design-system-color` for the mode-tile demo palette (`#195856`, `#5f7c7a`, `#3a958b`). They are examples standing in for the palette the visitor is about to pick, which is the carve-out DESIGN.md's OKLCH-Only Rule already names. The argument is at the declaration in `picker/styles/picker.css`; read it before touching them. +- 8 `design-system-font-size` from `picker/styles/picker.css`, seven of them already waived there in `.impeccable/config.json`. They reappear under the built path only because that waiver is scoped to the source path. The eighth is `1.05rem` on `.picker-finish-lede`, the review copy on the last screen, which sits on the questionnaire's own kit-token ramp for the same reason the other seven do. +- 2 `border-accent-on-rounded` on `.ks-bento` in the vendored `kinpaku-kit.css`, which carries no `border-radius` at all. The rule wants a radius on the same line, and minification puts the whole stylesheet on one line, so every line-scoped regex rule reads this bundle with no line context. Check a snippet against the source before acting on it. +- 13 from the design context document, the review UI the picker hands off to after the last question. Eleven are `picker/styles/design-context.css`: 5 `design-system-font-size` and 1 `design-system-radius` (`22px`) for the ported prototype's own type ramp and shape vocabulary, 2 `design-system-color` (`#7ba98f`, `#c26d5a`) for the tray status dots, 2 `design-system-font` naming the two faces the document sets itself in, and 1 `layout-transition` on the tile expander, which animates `width` and `height` because the tile morphing into a panel is the geometry it is animating. One is `picker/scripts/design-context.js`, whose Google Fonts URL is built from whichever family the run chose, so the rule sees a template where it wants a declared face. The last is `all-caps-body` on the doc masthead's mono note. The user locked that UI; both files carry a waiver in `.impeccable/config.json` saying so, and like the picker.css group those waivers are source-scoped, so the values resurface under the built path. +- 1 `undersized-ui-text` on the `Change` legend in the document's request-scope control, at 10.88px. Same slot and same argument as the micro-labels below. + +The last 9 are the page findings the questionnaire has carried since those screens were built: 3 `cramped-padding` on the option panel and the icon sheet, both edge-to-edge grids whose rows hold their own padding, and 6 mono readouts and micro-labels under the text-size and tracking floors. They are the group to revisit first if the number is ever worth lowering. + +**When the number moves, find out which file moved it before editing this section.** The baseline read 39 until the design context document landed, and all 15 findings that arrived with it are named above: 13 in its own two files, plus the finish-screen lede and the `Change` legend that ship in the picker's files but belong to that feature. The five other workstreams in the same batch added none. Building the tree at the previous commit in a `git worktree` and scanning both builds is how that was established, and it beats reasoning about which change is responsible. + +For line numbers during triage, `node cli/bin/cli.js detect picker/styles/` scans the unminified sources. It is not the gate: the source scan's style-context heuristic skips custom-property declarations, so it misses the palette entirely. A served URL is worse than either, because the browser pass only measures the screen in front of it and the picker hides all but the active one. + ## Versioning **Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself. diff --git a/NOTICE.md b/NOTICE.md index 0468271c9..8b3884fd6 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -9,3 +9,21 @@ The `skill/reference/ios.md` and `skill/reference/android.md` platform reference **Original work:** https://github.com/ehmo/platform-design-skills **Original license:** MIT **Author:** ehmo + +## Icon specimens in the design picker + +`picker/data/icon-packs.json`, and the copy of it built into `skill/scripts/picker/`, holds twenty-four glyphs from each of the icon families the picker offers, redistributed unmodified apart from the removal of attributes the specimen does not draw with. They are shown so a user can compare the families and choose one; the choice, not this file, is what a project then installs. `scripts/vendor-icons.mjs` records the exact version each set was taken from. + +| Set | License | Source | +|---|---|---| +| Lucide | ISC | https://github.com/lucide-icons/lucide | +| Feather | MIT | https://github.com/feathericons/feather | +| Phosphor | MIT | https://github.com/phosphor-icons/core | +| Heroicons | MIT | https://github.com/tailwindlabs/heroicons | +| Tabler | MIT | https://github.com/tabler/tabler-icons | +| Radix Icons | MIT | https://github.com/radix-ui/icons | +| Bootstrap Icons | MIT | https://github.com/twbs/icons | +| Iconoir | MIT | https://github.com/iconoir-icons/iconoir | +| Octicons | MIT | https://github.com/primer/octicons | +| Hugeicons (free core) | MIT | https://github.com/hugeicons/hugeicons-react | +| Remix Icon | Apache-2.0 | https://github.com/Remix-Design/RemixIcon | diff --git a/assets/mode-illustrations/experience-final.png b/assets/mode-illustrations/experience-final.png new file mode 100644 index 000000000..9a6160ff1 Binary files /dev/null and b/assets/mode-illustrations/experience-final.png differ diff --git a/assets/mode-illustrations/operate-final.png b/assets/mode-illustrations/operate-final.png new file mode 100644 index 000000000..ce370fd74 Binary files /dev/null and b/assets/mode-illustrations/operate-final.png differ diff --git a/assets/mode-illustrations/read-final.png b/assets/mode-illustrations/read-final.png new file mode 100644 index 000000000..d0bd2bb4f Binary files /dev/null and b/assets/mode-illustrations/read-final.png differ diff --git a/bun.lock b/bun.lock index ea29bc724..512e36ac6 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "@babel/parser": "^8.0.4", "ai": "^7.0.14", "archiver": "^8.0.0", + "astro": "^7.0.0", "playwright": "^1.59.1", "svelte": "^5", "zod": "^4.3.6", @@ -63,6 +64,36 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.115.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ=="], + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.3.2", "", { "optionalDependencies": { "@astrojs/compiler-binding-darwin-arm64": "0.3.2", "@astrojs/compiler-binding-darwin-x64": "0.3.2", "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.2", "@astrojs/compiler-binding-linux-arm64-musl": "0.3.2", "@astrojs/compiler-binding-linux-x64-gnu": "0.3.2", "@astrojs/compiler-binding-linux-x64-musl": "0.3.2", "@astrojs/compiler-binding-wasm32-wasi": "0.3.2", "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.2", "@astrojs/compiler-binding-win32-x64-msvc": "0.3.2" } }, "sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ=="], + + "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g=="], + + "@astrojs/compiler-binding-darwin-x64": ["@astrojs/compiler-binding-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag=="], + + "@astrojs/compiler-binding-linux-arm64-gnu": ["@astrojs/compiler-binding-linux-arm64-gnu@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ=="], + + "@astrojs/compiler-binding-linux-arm64-musl": ["@astrojs/compiler-binding-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw=="], + + "@astrojs/compiler-binding-linux-x64-gnu": ["@astrojs/compiler-binding-linux-x64-gnu@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q=="], + + "@astrojs/compiler-binding-linux-x64-musl": ["@astrojs/compiler-binding-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ=="], + + "@astrojs/compiler-binding-wasm32-wasi": ["@astrojs/compiler-binding-wasm32-wasi@0.3.2", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.2.0" }, "cpu": "none" }, "sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw=="], + + "@astrojs/compiler-binding-win32-arm64-msvc": ["@astrojs/compiler-binding-win32-arm64-msvc@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q=="], + + "@astrojs/compiler-binding-win32-x64-msvc": ["@astrojs/compiler-binding-win32-x64-msvc@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA=="], + + "@astrojs/compiler-rs": ["@astrojs/compiler-rs@0.3.2", "", { "dependencies": { "@astrojs/compiler-binding": "0.3.2" } }, "sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.2", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-yt7fMgPYqSM4Tmr+taTW6Per+hjJ8Pk6lA1PAcDyqzOt8HzJ6Kje5WzCxA2Sd+9wsUW7uhkLeoTMK0cXPwH9rQ=="], + + "@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.3.5", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.2", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "satteri": "^0.9.1" } }, "sha512-CvWVEFAbay7YO+i9SaqDJubipA5ckiVB89QWoMJ5XC0m5CtFg8JwZ7Kau6X9sYY7FZURH0w2l03ISH2jOS/RDQ=="], + + "@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], @@ -73,8 +104,144 @@ "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], + "@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="], + + "@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="], + + "@bruits/satteri-linux-arm64-gnu": ["@bruits/satteri-linux-arm64-gnu@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA=="], + + "@bruits/satteri-linux-arm64-musl": ["@bruits/satteri-linux-arm64-musl@0.9.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ=="], + + "@bruits/satteri-linux-x64-gnu": ["@bruits/satteri-linux-x64-gnu@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw=="], + + "@bruits/satteri-linux-x64-musl": ["@bruits/satteri-linux-x64-musl@0.9.5", "", { "os": "linux", "cpu": "x64" }, "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q=="], + + "@bruits/satteri-wasm32-wasi": ["@bruits/satteri-wasm32-wasi@0.9.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ=="], + + "@bruits/satteri-win32-arm64-msvc": ["@bruits/satteri-win32-arm64-msvc@0.9.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg=="], + + "@bruits/satteri-win32-x64-msvc": ["@bruits/satteri-win32-x64-msvc@0.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="], + + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.2" }, "os": "darwin", "cpu": "x64" }, "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "os": "freebsd" }, "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.2" }, "os": "linux", "cpu": "arm" }, "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.2" }, "os": "linux", "cpu": "ppc64" }, "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.2" }, "os": "linux", "cpu": "none" }, "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.2" }, "os": "linux", "cpu": "s390x" }, "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" }, "os": "linux", "cpu": "arm64" }, "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -87,18 +254,84 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.1.0", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-RDLpio3fH/qrj5k4DVY6eyiN8tCS0Zovd/6jW//n605oeqkWcUjn+3k+9ZtZBnbwMpsu0F7xDIiKXvVmG5c5Bw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@shikijs/core": ["@shikijs/core@4.4.3", "", { "dependencies": { "@shikijs/primitive": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w=="], + + "@shikijs/langs": ["@shikijs/langs@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ=="], + + "@shikijs/themes": ["@shikijs/themes@4.4.3", "", { "dependencies": { "@shikijs/types": "4.4.3" } }, "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw=="], + + "@shikijs/types": ["@shikijs/types@4.4.3", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], @@ -115,13 +348,21 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="], - "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "astro": ["astro@7.2.2", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.2", "@astrojs/internal-helpers": "0.10.2", "@astrojs/markdown-satteri": "0.3.5", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "find-process": "^2.1.1", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.3.0", "jsonc-parser": "^3.3.1", "magic-string": "^1.0.0", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.2" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-OvLWCpXpb43lVYcWzSBJ0sk44qcEYYRZCjadtalLfAwb2RaC3P/zT+blZykBw/o6suw6sQQkn00ugN36akD8Mw=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -129,6 +370,8 @@ "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], @@ -161,19 +404,43 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="], + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], + "compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -187,20 +454,36 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + "css-select": ["css-select@7.0.0", "", { "dependencies": { "boolbase": "^2.0.0", "css-what": "^8.0.0", "domhandler": "^6.0.1", "domutils": "^4.0.2", "nth-check": "^3.0.1" } }, "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-what": ["css-what@8.0.0", "", {}, "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw=="], + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "devtools-protocol": ["devtools-protocol@0.0.1653615", "", {}, "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], @@ -209,6 +492,8 @@ "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -223,8 +508,12 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -237,6 +526,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], @@ -249,18 +540,36 @@ "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-process": ["find-process@2.1.1", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^14.0.3", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/cjs/bin/find-process.js" } }, "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -277,16 +586,42 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "htmlparser2": ["htmlparser2@12.0.0", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -299,6 +634,12 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], @@ -311,6 +652,8 @@ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], @@ -319,24 +662,68 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "magic-string": ["magic-string@1.2.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw=="], + + "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], "marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], @@ -347,10 +734,22 @@ "modern-tar": ["modern-tar@0.7.6", "", {}, "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neotraverse": ["neotraverse@1.0.1", "", {}, "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], "nth-check": ["nth-check@3.0.1", "", { "dependencies": { "boolbase": "^2.0.0" } }, "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ=="], @@ -359,26 +758,60 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "p-limit": ["p-limit@7.3.1", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q=="], + + "p-queue": ["p-queue@9.3.3", "", { "dependencies": { "eventemitter3": "^5.0.4", "p-timeout": "^7.0.0" } }, "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA=="], + + "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], + + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + "process-ancestry": ["process-ancestry@0.1.0", "", {}, "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "puppeteer": ["puppeteer@25.5.0", "", { "dependencies": { "@puppeteer/browsers": "3.1.0", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1653615", "lilconfig": "^3.1.3", "puppeteer-core": "25.5.0", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" } }, "sha512-qpp73xblxNr+bF0nSXTodM3v+zcK5IPo/GkjLsdUqRf/qpLJp/1KxBUbstoMMnwnPw9xD6OMei8kmYf6CLWfGw=="], @@ -387,6 +820,8 @@ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -395,24 +830,48 @@ "readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "satteri": ["satteri@0.9.5", "", { "dependencies": { "@types/estree-jsx": "^1.0.5", "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "@types/unist": "^3.0.3" }, "optionalDependencies": { "@bruits/satteri-darwin-arm64": "0.9.5", "@bruits/satteri-darwin-x64": "0.9.5", "@bruits/satteri-linux-arm64-gnu": "0.9.5", "@bruits/satteri-linux-arm64-musl": "0.9.5", "@bruits/satteri-linux-x64-gnu": "0.9.5", "@bruits/satteri-linux-x64-musl": "0.9.5", "@bruits/satteri-wasm32-wasi": "0.9.5", "@bruits/satteri-win32-arm64-msvc": "0.9.5", "@bruits/satteri-win32-x64-msvc": "0.9.5" } }, "sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.4.3", "", { "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", "@shikijs/engine-oniguruma": "4.4.3", "@shikijs/langs": "4.4.3", "@shikijs/themes": "4.4.3", "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -421,8 +880,14 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -433,32 +898,86 @@ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="], + "svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="], + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinyclip": ["tinyclip@0.1.15", "", {}, "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unstorage": ["unstorage@1.17.5", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.10", "lru-cache": "^11.2.7", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.3" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -469,12 +988,16 @@ "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], @@ -483,18 +1006,72 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], + "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "magicast/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "magicast/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "svelte/aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "svgo/css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "svgo/css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "unifont/undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "magicast/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "magicast/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "svgo/css-select/boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "svgo/css-select/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "svgo/css-select/domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "svgo/css-select/nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "svgo/css-select/domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "svgo/css-select/domutils/dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "svgo/css-select/domutils/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "svgo/css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], } } diff --git a/docs/design-context-categorization/CONTENTS-TABLE.html b/docs/design-context-categorization/CONTENTS-TABLE.html new file mode 100644 index 000000000..d96d9e422 --- /dev/null +++ b/docs/design-context-categorization/CONTENTS-TABLE.html @@ -0,0 +1,166 @@ + + + + +Contents Table + + + + +

Contents Table

+ +

Source legend

+ +

See PROVENANCE.md for the full mechanism. Where a value can come from either scan mode or the planned seed flow, both are listed.

+ + + + + + + + + + + + +
SourceMeaning
DerivedScanned off existing code. Changed only by re-scanning, not by re-answering a question.
Questionnaire — In-chat (init)PRODUCT.md fields, asked in chat during /impeccable init Step 3. Not persisted or resettable.
Questionnaire — In-chat (document)DESIGN.md fields asked in chat during document.md — covers both today's qualitative round and the new seed flow's chat portion (assets, named references, anti-reference). Not persisted or resettable today; the (planned) rows are where that's changing.
Questionnaire — Browser (document)DESIGN.md fields asked through a visual card UI during document.md — covers both today's shipped Layout-strictness slide and the new seed flow's browser slides (Color, Typography, Motion, Layout, Elevation, Iconography). Same persistence note as above — see PROVENANCE.md.
ComputedGenerated by the agent from other values. Never directly asked.
+ +

Categories

+ + + +

Audience

+ + + + + + + + + + + +
ValueDerived fromSource
Primary/secondary user rolePRODUCT.md · UsersQuestionnaire — In-chat (init)
Confirmed primary/secondary splitPRODUCT.md · Users (PR #315)Questionnaire — In-chat (init)
Context, job-to-be-donePRODUCT.md · UsersQuestionnaire — In-chat (init)
Emotional goal on landingPRODUCT.md · Brand PersonalityQuestionnaire — In-chat (init)
+ +

Product

+ + + + + + + + + + + + + + +
ValueDerived fromSource
NameDESIGN.md frontmatter nameDerived
Register (brand/product)PRODUCT.md · RegisterQuestionnaire — In-chat (init)
What it does, success definitionPRODUCT.md · Product PurposeQuestionnaire — In-chat (init)
Use cases (implicit)PRODUCT.md · Product PurposeDerived
Differentiator / strategic claimPRODUCT.md · Positioning (PR #315)Questionnaire — In-chat (init)
CTAs, memorable line, belief ladderPRODUCT.md · Conversion & proof (PR #315, brand only)Questionnaire — In-chat (init)
Proof assets (testimonials, press, logos)PRODUCT.md · Conversion & proof (PR #315, brand only)Questionnaire — In-chat (init)
+ +

Brand

+ + + + + + + + + + + + +
ValueDerived fromSource
Identity name + narrative (long form)DESIGN.md · OverviewQuestionnaire — In-chat (document)
Description (short form of the same narrative)DESIGN.md frontmatter descriptionQuestionnaire — In-chat (document)
Three words, voice, tone, referencesPRODUCT.md · Brand PersonalityQuestionnaire — In-chat (init)
Anti-referencesPRODUCT.md · Anti-referencesQuestionnaire — In-chat (init)
Strategic principlesPRODUCT.md · Design PrinciplesQuestionnaire — In-chat (init)
+ +

Color

+ + + + + + + + + + + + +
ValueDerived fromSource
Palette (hex)DESIGN.md frontmatter colorsDerived / Questionnaire — Browser (document)
Roles (primary/neutral)DESIGN.md · ColorsQuestionnaire — In-chat (document)
Strategy (named rules)DESIGN.md · Colors → Named RulesQuestionnaire — In-chat (document)
Tints (opacity variants)DESIGN.md frontmatter colorsDerived / Questionnaire — Browser (document)
Tonal ramps (OKLCH)design.json · colorMeta.tonalRampComputed
+ +

Typography

+ + + + + + + + + + + +
ValueDerived fromSource
Font familiesDESIGN.md frontmatter typographyDerived / Questionnaire — Browser (document)
Typescale (base + ratio, e.g. Major Third)Visual document seed (planned)Questionnaire — Browser (document)
Hierarchy (role/size/weight/etc.)DESIGN.md · HierarchyDerived
Readability rulesDESIGN.md · Named RulesComputed
+ +

Iconography

+ + + + + + + + +
ValueDerived fromSource
Icon library/style (Lucide/Tabler Icons/Hugeicons)Visual document seed (planned)Questionnaire — Browser (document)
+ +

Material

+ + + + + + + + + + + + + + + + +
ValueDerived fromSource
Spacing / layout scaleDESIGN.md frontmatter spacingDerived / Questionnaire — Browser (document)
Layout structure / grid strictnessVisual document seed (existing)Questionnaire — Browser (document)
Breakpointsdesign.json · extensions.breakpointsComputed
RadiusDESIGN.md frontmatter roundedDerived / Questionnaire — Browser (document)
BordersDESIGN.md prose (Elevation/Colors)Derived
SurfacesDESIGN.md · Colors → NeutralDerived
Shadows + elevationDESIGN.md · Elevation; design.json · shadowsDerived / Questionnaire — Browser (document)
Motion feel (qualitative)DESIGN.md · Do's and Don'tsQuestionnaire — In-chat (document) / Questionnaire — Browser (document)
Motion tokens (ease/duration)design.json · extensions.motionComputed / Questionnaire — Browser (document)
+ +

Interface

+ + + + + + + + + + + + +
ValueDerived fromSource
Component tokensDESIGN.md frontmatter componentsDerived
Component prose + statesDESIGN.md · ComponentsDerived / Questionnaire — In-chat (document)
Signature/future componentsDESIGN.md · ComponentsQuestionnaire — In-chat (document)
Concrete HTML/CSS snippetsdesign.json · components[]Computed
Accessibility & InclusionPRODUCT.md · Accessibility & InclusionQuestionnaire — In-chat (init)
+ + + diff --git a/docs/design-context-categorization/CONTENTS-TABLE.md b/docs/design-context-categorization/CONTENTS-TABLE.md new file mode 100644 index 000000000..41d6ef2f0 --- /dev/null +++ b/docs/design-context-categorization/CONTENTS-TABLE.md @@ -0,0 +1,104 @@ +# Contents Table + +## Source legend + +See [`PROVENANCE.md`](./PROVENANCE.md) for the full mechanism. Where a value can come from either scan mode or the planned seed flow, both are listed. + +| Source | Meaning | +| --- | --- | +| Derived | Scanned off existing code. Changed only by re-scanning, not by re-answering a question. | +| Questionnaire — In-chat (init) | `PRODUCT.md` fields, asked in chat during `/impeccable init` Step 3. Not persisted or resettable. | +| Questionnaire — In-chat (document) | `DESIGN.md` fields asked in chat during `document.md` — covers both today's qualitative round and the new seed flow's chat portion (assets, named references, anti-reference). Not persisted or resettable today; the `(planned)` rows are where that's changing. | +| Questionnaire — Browser (document) | `DESIGN.md` fields asked through a visual card UI during `document.md` — covers both today's shipped Layout-strictness slide and the new seed flow's browser slides (Color, Typography, Motion, Layout, Elevation, Iconography). Same persistence note as above — see `PROVENANCE.md`. | +| Computed | Generated by the agent from other values. Never directly asked. | + +## Categories + +- Audience +- Product +- Brand +- Color +- Typography +- Iconography +- Material +- Interface + +## Audience + +| Value | Derived from | Source | +| --- | --- | --- | +| Primary/secondary user role | `PRODUCT.md` · Users | Questionnaire — In-chat (init) | +| Confirmed primary/secondary split | `PRODUCT.md` · Users (PR #315) | Questionnaire — In-chat (init) | +| Context, job-to-be-done | `PRODUCT.md` · Users | Questionnaire — In-chat (init) | +| Emotional goal on landing | `PRODUCT.md` · Brand Personality | Questionnaire — In-chat (init) | + +## Product + +| Value | Derived from | Source | +| --- | --- | --- | +| Name | `DESIGN.md` frontmatter `name` | Derived | +| Register (`brand`/`product`) | `PRODUCT.md` · Register | Questionnaire — In-chat (init) | +| What it does, success definition | `PRODUCT.md` · Product Purpose | Questionnaire — In-chat (init) | +| Use cases (implicit) | `PRODUCT.md` · Product Purpose | Derived | +| Differentiator / strategic claim | `PRODUCT.md` · Positioning (PR #315) | Questionnaire — In-chat (init) | +| CTAs, memorable line, belief ladder | `PRODUCT.md` · Conversion & proof (PR #315, brand only) | Questionnaire — In-chat (init) | +| Proof assets (testimonials, press, logos) | `PRODUCT.md` · Conversion & proof (PR #315, brand only) | Questionnaire — In-chat (init) | + +## Brand + +| Value | Derived from | Source | +| --- | --- | --- | +| Identity name + narrative (long form) | `DESIGN.md` · Overview | Questionnaire — In-chat (document) | +| Description (short form of the same narrative) | `DESIGN.md` frontmatter `description` | Questionnaire — In-chat (document) | +| Three words, voice, tone, references | `PRODUCT.md` · Brand Personality | Questionnaire — In-chat (init) | +| Anti-references | `PRODUCT.md` · Anti-references | Questionnaire — In-chat (init) | +| Strategic principles | `PRODUCT.md` · Design Principles | Questionnaire — In-chat (init) | + +## Color + +| Value | Derived from | Source | +| --- | --- | --- | +| Palette (hex) | `DESIGN.md` frontmatter `colors` | Derived / Questionnaire — Browser (document) | +| Roles (primary/neutral) | `DESIGN.md` · Colors | Questionnaire — In-chat (document) | +| Strategy (named rules) | `DESIGN.md` · Colors → Named Rules | Questionnaire — In-chat (document) | +| Tints (opacity variants) | `DESIGN.md` frontmatter `colors` | Derived / Questionnaire — Browser (document) | +| Tonal ramps (OKLCH) | `design.json` · `colorMeta.tonalRamp` | Computed | + +## Typography + +| Value | Derived from | Source | +| --- | --- | --- | +| Font families | `DESIGN.md` frontmatter `typography` | Derived / Questionnaire — Browser (document) | +| Typescale (base + ratio, e.g. Major Third) | Visual document seed (planned) | Questionnaire — Browser (document) | +| Hierarchy (role/size/weight/etc.) | `DESIGN.md` · Hierarchy | Derived | +| Readability rules | `DESIGN.md` · Named Rules | Computed | + +## Iconography + +| Value | Derived from | Source | +| --- | --- | --- | +| Icon library/style (Lucide/Tabler Icons/Hugeicons) | Visual document seed (planned) | Questionnaire — Browser (document) | + +## Material + +| Value | Derived from | Source | +| --- | --- | --- | +| Spacing / layout scale | `DESIGN.md` frontmatter `spacing` | Derived / Questionnaire — Browser (document) | +| Layout structure / grid strictness | Visual document seed (existing) | Questionnaire — Browser (document) | +| Breakpoints | `design.json` · `extensions.breakpoints` | Computed | +| Radius | `DESIGN.md` frontmatter `rounded` | Derived / Questionnaire — Browser (document) | +| Borders | `DESIGN.md` prose (Elevation/Colors) | Derived | +| Surfaces | `DESIGN.md` · Colors → Neutral | Derived | +| Shadows + elevation | `DESIGN.md` · Elevation; `design.json` · `shadows` | Derived / Questionnaire — Browser (document) | +| Motion feel (qualitative) | `DESIGN.md` · Do's and Don'ts | Questionnaire — In-chat (document) / Questionnaire — Browser (document) | +| Motion tokens (ease/duration) | `design.json` · `extensions.motion` | Computed / Questionnaire — Browser (document) | + +## Interface + +| Value | Derived from | Source | +| --- | --- | --- | +| Component tokens | `DESIGN.md` frontmatter `components` | Derived | +| Component prose + states | `DESIGN.md` · Components | Derived / Questionnaire — In-chat (document) | +| Signature/future components | `DESIGN.md` · Components | Questionnaire — In-chat (document) | +| Concrete HTML/CSS snippets | `design.json` · `components[]` | Computed | +| Accessibility & Inclusion | `PRODUCT.md` · Accessibility & Inclusion | Questionnaire — In-chat (init) | diff --git a/docs/design-context-categorization/CONTENTS.md b/docs/design-context-categorization/CONTENTS.md new file mode 100644 index 000000000..9ca76a6c8 --- /dev/null +++ b/docs/design-context-categorization/CONTENTS.md @@ -0,0 +1,200 @@ +# Contents + +What currently produced output (`PRODUCT.md`, `DESIGN.md`, `.impeccable/design.json`) maps to, category by category, against the proposed 8-category design context doc. + +Source examples analyzed (local scratch runs, not tracked in this repo): + +- `tmp/notes/init-tests/runs/noren-ops-20260708-1832/DESIGN.md` + `proof/PRODUCT.md` + `proof/design.json` +- `tmp/notes/init-tests/runs/hanazono-atelier-20260708-1832/DESIGN.md` + `/Users/abdulwahab/hanazono-atelier/PRODUCT.md` + +Every field below also has a provenance — derived from code, or from one of three questionnaire flows (in-chat during init, in-chat during document, browser during document — the last two cover both what's shipped today and the new seed work). See [`PROVENANCE.md`](./PROVENANCE.md) for the mechanism and [`CONTENTS-TABLE.md`](./CONTENTS-TABLE.md) for the per-field breakdown. + +--- + +## Audience + +*Who it is for, emotional state, needs, trust triggers.* + +Found in: +- `PRODUCT.md` → `## Users` + - Primary/secondary role + - Company context + - Weekly rhythm + - Job-to-be-done +- `PRODUCT.md` → `Brand Personality` → "Emotional goal on landing" + - e.g. Hanazono: "quiet authority" + - Filed under Brand Personality today, but it's really audience emotional state + +Gap: +- No dedicated trust-trigger question in the Audience interview block. As of [PR #315](https://github.com/pbakaus/impeccable/pull/315), the *content* exists one category over — see Product → `## Conversion & proof` → "Proof on hand" — but nothing cross-references it back into Audience. +- What exists instead (Rippling/Linear, Nicolai Bergmann/Cereal) are taste anchors, not trust signals + +Tightened by PR #315 (not new content, but new rigor): +- `## Users` now requires a confirmed primary/secondary split rather than an inferred one — no manufacturing a secondary audience that isn't there. +- Explicit question when the surface's audience differs from who actually uses the product (e.g. a marketing site's visitor vs. an app's daily operator). + +--- + +## Product + +*Purpose, differentiator, proof points, use cases, what must be clear first.* + +Found in: +- `PRODUCT.md` → `## Product Purpose` + - What it does + - The success definition +- `PRODUCT.md` → `## Positioning` (added in [PR #315](https://github.com/pbakaus/impeccable/pull/315), both registers) + - "The single strategic claim every screen reinforces" + - This is the differentiator field, now explicit instead of buried in Design Principles +- `PRODUCT.md` → `## Conversion & proof` (added in PR #315, **brand register only** — product register omits the section, heading included) + - Primary and secondary CTA + - The line a visitor remembers after 10 seconds + - Belief ladder (what a visitor must believe, in order, before the primary CTA) + - Proof on hand: testimonials, case studies, press, client/partner logos — referenced by path, collected under `.impeccable/assets/proof/` + - This fills the proof-points sub-item directly, and the belief ladder / memorable line fill "what must be clear first" +- Use cases — still only implicit + - Hanazono's three commission lanes are named in Product Purpose + - Noren's app routes (Dashboard/People/PTO/Onboarding/Settings) only show up in agent-log crawl notes, never written into `PRODUCT.md` itself + - Untouched by PR #315 + +Gap: +- Proof points / conversion framing don't exist for product-register projects — `Conversion & proof` is explicitly omitted there +- Use cases remain implicit rather than an enumerated field, for both registers + +Also mapped here (resolved orphans — see [`ORPHANS.md`](./ORPHANS.md)): +- **Register** (`product`/`brand`) — travels with Product Purpose as the metadata switch for the rest of the doc +- **Name** (`DESIGN.md` frontmatter `name`) — the literal project name; an identifier, not a design decision. (`description` moved to Brand — see below.) + +--- + +## Brand + +*Identity, wordmark/logo fallback, voice, tone, cues, taste boundaries, anti-references.* + +Found in: +- `DESIGN.md` → `## 1. Overview` + - Creative North Star name + - Narrative + key characteristics +- `PRODUCT.md` → `Brand Personality` + - Three words + - Voice, tone + - References +- `PRODUCT.md` → `## Anti-references` + - Echoed in `DESIGN.md` Overview and `## 6. Do's and Don'ts` + +Also mapped here (resolved orphans — see [`ORPHANS.md`](./ORPHANS.md)): +- **Design Principles** (the numbered list as a unit) — e.g. "Quiet authority over persuasion," "Restraint in every frame" read as voice/taste judgment calls, closer to Brand's cues and taste boundaries than to Product's purpose/differentiator framing. Kept intact as a unit rather than decomposed per-bullet, even though a few individual principles (e.g. "One workspace, not three spreadsheets") lean Product. +- **Description** (`DESIGN.md` frontmatter `description`) — the short form of the same Creative North Star narrative above, not a separate fact. Was bundled with `name` under Product; that was a mistake, since it's brand-identity content compressed to one sentence, not header metadata. + +Gap: +- Wordmark / logo fallback is completely absent in both examples +- No logo file reference, no text-lockup rule for when no mark exists + +--- + +## Color + +*Palette, roles, tints, strategy, dark-mode inference, copyable values.* + +Found in: +- `DESIGN.md` frontmatter `colors:` block → palette + copyable hex values +- `DESIGN.md` → `## 2. Colors` + - `### Primary` / `### Neutral` → roles + - `### Named Rules` → strategy (One Accent Rule, Humane Ground Rule, One Plum Rule, Warm Ground Rule) +- Tints + - Hanazono: opacity variants in frontmatter (`ink-muted`, `ink-subtle`, `divider`) + - Noren: separate flat colors instead (`surface-raised`, `border-subtle`) + +Also mapped here (resolved orphan — see [`ORPHANS.md`](./ORPHANS.md)): +- **Tonal ramps** (`design.json` → `colorMeta.tonalRamp`, OKLCH 8-step ramps per color) — promoted out of the sidecar into the doc, alongside the canonical hex + +Gap: +- No dark-mode palette or inference logic anywhere +- The tonal ramps could seed one (they're dark-to-light ordered already) but nothing does yet — promoting them into the doc is a first step, not the full fix + +--- + +## Typography + +*Font families, hierarchy, font scale, readability rules.* + +Found in: +- `DESIGN.md` frontmatter `typography:` block + "Display Font / Body Font" callouts +- `### Hierarchy` — role, size, weight, line-height, tracking, usage per level +- Readability rules + - ch-width caps + - `text-wrap: balance/pretty` + - Named Rules (One Voice Display Rule, Measure Rule, Homepage Italic Rule) + +Gap: +- Best-covered category, no real gaps +- Possible exception: no explicit rule documenting non-Latin fallback rationale (Hanazono stacks Hiragino Mincho / Yu Mincho for Japanese rendering) + +Planned (upcoming visual document seed, not yet shipped): +- **Typescale** — a named modular scale/ratio (base size + ratio, e.g. Major Third) that generates the hierarchy, replacing hand-picked per-role sizes with a systematic rule. + +--- + +## Iconography + +*Icon style, stroke weight, metaphor rules, icon-button behavior, allowed/forbidden icon types.* + +Found in: +- Nothing today. Zero mentions in `PRODUCT.md`, `DESIGN.md`, or `design.json` for either project +- Closest adjacent item: Hanazono's don't — "no decorative floral motifs in UI chrome" — but that's illustration/decoration, not icons + +Planned (upcoming visual document seed, not yet shipped): +- **Icon library/style** — a direct question ("Which icon style should the design use?") with named library options (Lucide, Tabler Icons, Hugeicons), each with a one-line fit description. + +Gap: +- Library choice covers icon *style* only. Stroke weight, metaphor rules, icon-button behavior, and allowed/forbidden icon types are not part of this question and stay unfed even once it ships. + +--- + +## Material + +*Radius, borders, shadows, elevation, surfaces, folds/edges, texture, motion feel.* + +Found in: +- Radius: `rounded:` frontmatter tokens / Hanazono's explicit "square corners, no border-radius" +- Borders: Border Subtle / Hairline Rule / Lane Divider / CTA Stroke + - Their color *values* live in the Color section — Material only gets them by reference +- Shadows + elevation: `## 4. Elevation` section, backed by `design.json.shadows[]` +- Surfaces: Surface Raised is written up under Color's `### Neutral`, not a Material section + - Functionally a Material concept, textually filed under Color +- Motion feel — scattered + - `prefers-reduced-motion` compliance + per-component hover behavior in Do's/Don'ts and Components prose + - Actual motion tokens (`ease-standard`, `duration-fast`) only exist in `design.json`, never surfaced in `DESIGN.md` + +Gap: +- Texture/grain and folds/edges have no field, not even to declare "intentionally none" +- Both example systems are flat by doctrine, but there's no explicit material-honesty statement captured anywhere + +Also mapped here (resolved orphans — see [`ORPHANS.md`](./ORPHANS.md)): +- **Spacing / layout scale** (`xs/sm/md/lg/xl/gutter/section` tokens) — physical construction of the interface, alongside radius/borders/elevation +- **Breakpoints** (`sm/md/lg` px values) — responsive behavior of the same physical system +- **Motion tokens** (`design.json` → `extensions.motion`, e.g. `ease-standard`, `duration-fast`) — promoted out of the sidecar so the doc states the real easing curve and duration, not just qualitative motion feel + +Planned (upcoming visual document seed — Layout structure already exists in today's seed, just not previously captured in this analysis): +- **Layout structure / grid strictness** — "How strict should the layout feel?" with named points on a spectrum (Simple grid, Balanced, Editorial, Freeform). A rhythm/composition rule distinct from the spacing scale — this is about how strictly content aligns to a grid, not what the spacing values are. + +--- + +## Interface + +*Buttons, inputs, cards/panels, rails, tabs, swatches, copy controls, component states, interaction patterns.* + +Found in: +- `DESIGN.md` frontmatter `components:` token bindings +- `## 5. Components` + - Buttons, Chips, Cards, Inputs, Navigation + - Signature/future components: Metric Summary, Proof Quote, Commission Lane, Consultation Form, Gallery Grid +- Component states (hover/focus/error/disabled) documented inline per component +- `design.json.components[]` → concrete HTML/CSS per component + +Also mapped here (resolved orphan — see [`ORPHANS.md`](./ORPHANS.md)): +- **Accessibility & Inclusion** (`PRODUCT.md` · WCAG target, reduced motion, alt text, color-vision pairing, plain-language/bilingual notes) — kept as a unit rather than split; the majority of its actual content (keyboard nav, screen-reader behavior, focus visibility, alt text) is Interface, even though reduced motion (Material), contrast/color-vision (Color), and bilingual notes (Audience) also draw from it. Filed here instead of Material, where it originally landed. + +Gap: +- Best second-covered category +- Tabs/rails/swatches/copy-controls aren't exercised by either example — reads as "not needed by these two fixtures," not "no place to go" diff --git a/docs/design-context-categorization/MISSING.md b/docs/design-context-categorization/MISSING.md new file mode 100644 index 000000000..40f46ea69 --- /dev/null +++ b/docs/design-context-categorization/MISSING.md @@ -0,0 +1,41 @@ +# What's Missing + +Genuine content gaps not produced by either example run (`noren-ops-20260708-1832`, `hanazono-atelier-20260708-1832`), regardless of category. + +## Resolved by PR #315 + +[pbakaus/impeccable#315](https://github.com/pbakaus/impeccable/pull/315) ("Expand init to capture positioning, conversion, and proof context") adds two new `PRODUCT.md` sections to the Step 3 interview in `skill/reference/init.md`: + +- `## Positioning` (both registers) — "the single strategic claim every screen reinforces" +- `## Conversion & proof` (brand register only; product register omits the section entirely) — primary/secondary CTA, the line a visitor remembers after 10 seconds, belief ladder, and "Proof on hand" (testimonials, case studies, press, client/partner logos — referenced by path, collected under `.impeccable/assets/proof/`) + +What that resolves: + +- ~~A labeled differentiator field~~ → `## Positioning` is exactly this, now asked explicitly instead of living buried inside Design Principles. +- ~~Proof points as actual content~~ → `## Conversion & proof` → "Proof on hand" collects real testimonials/case studies/press/logos by path, not just the proof-quote UI container. +- ~~A "what must be clear first" content-priority statement~~ → covered by the memorable-line + belief-ladder pair for brand register, and the pre-existing "primary workflow / primary task on any screen" question for product register. +- Explicit trust-trigger content for Audience → the *content* now exists (proof assets), but it's captured under Product (`Conversion & proof`), not Audience. See the note in [`ORPHANS.md`](./ORPHANS.md) — this isn't a new orphan, just a reminder that trust-trigger content lives one category over from where "Audience" would intuitively look for it. + +Also tightened (not a new field, but changes what's captured): `## Users` now requires an explicit, confirmed primary/secondary audience split — no manufactured splits, and a direct question when the surface's audience differs from who actually uses the product. + +## Partially resolved by the upcoming visual document seed + +Planned feature (not yet a PR): a new visual, card-based document seed adds three question areas — Typescale, Layout structure, and Iconography. Layout structure already exists in today's seed ("How strict should the layout feel?" — Simple grid / Balanced / Editorial / Freeform); Typescale and Iconography are new. + +- ~~Iconography, full stop~~ → **partially resolved.** The new "Which icon style should the design use?" question captures icon library/style (Lucide / Tabler Icons / Hugeicons). Icon style itself is no longer a total gap, but stroke weight, metaphor rules, icon-button behavior, and allowed/forbidden icon types are not part of this question and remain open — library choice implies a default aesthetic, not a full spec. +- Typography's font scale goes from hand-picked sizes to a named, generative rule: a base size + ratio (modular scale, e.g. Major Third) that derives the hierarchy, rather than each role's size being picked independently. Not something `MISSING.md` had called out explicitly before, but it's a real upgrade to how "font scale" gets captured. +- Layout structure (grid strictness) adds a rhythm/composition rule to Material that neither example run's `DESIGN.md` states explicitly today — closest existing analogue is the spacing scale, but "how strict is the grid" is a distinct decision from "what are the spacing values." + +## Not missing, but not resettable either + +Everything answered today gets baked directly into prose with no persisted record of the original question/answer pair: `Questionnaire — In-chat (init)` (nearly everything in `PRODUCT.md`), `Questionnaire — In-chat (document)`'s shipped fields (`DESIGN.md`'s North Star/color narrative/elevation feel), and `Questionnaire — Browser (document)`'s shipped fields (today's Layout-strictness card UI). There's nothing to reset any of them *to* — a user who wants to redo just the brand personality answer, or just the layout-strictness pick, has to manually edit the doc, not reopen a slide. Only the new fields being added to `In-chat (document)` and `Browser (document)` by the upcoming seed work are getting the persisted/resettable treatment. See [`PROVENANCE.md`](./PROVENANCE.md) open question #1 for whether the already-shipped fields should eventually get it too. + +## Still missing + +Untouched by PR #315 and the upcoming visual document seed: + +- Stroke weight, metaphor rules, icon-button behavior, and allowed/forbidden icon types within Iconography (library/style choice alone doesn't cover these) +- Dark-mode palette / inference logic +- Wordmark / logo fallback rule +- Texture/grain and folds/edges material language (or an explicit "none, by doctrine" statement) +- Conversion & proof content for product-register projects — the section is explicitly omitted there, so proof-point/trust-trigger content stays absent for product-register work (arguably correct scope for an internal app, but worth a second look if a given product-register project does have testimonials or logos worth capturing) diff --git a/docs/design-context-categorization/ORPHANS.md b/docs/design-context-categorization/ORPHANS.md new file mode 100644 index 000000000..8a08e11db --- /dev/null +++ b/docs/design-context-categorization/ORPHANS.md @@ -0,0 +1,34 @@ +# Doesn't Have a Clean Place in the 8-Category Schema (Orphans) + +Present in current output, but no obvious bucket among Audience / Product / Brand / Color / Typography / Iconography / Material / Interface. + +## Resolved + +- **Register** (`product`/`brand`) → **Product**. It's the metadata switch that determines everything downstream, but downstream of what if not the product's own framing — it belongs next to Product Purpose. +- **Name** (`DESIGN.md` frontmatter `name`) → **Product**. The literal project name — an identifier, not a design decision; travels with Product Purpose rather than standing alone. (Originally bundled with `description` as "header metadata," but `description` isn't metadata — see next entry.) +- **Description** (`DESIGN.md` frontmatter `description`, e.g. "Calm HR ops workspace — dense tables, flat surfaces, dusty plum accent on warm cream.") → **Brand**, merged with Identity name + narrative. It's the same brand story as the `## 1. Overview` narrative, just compressed to one sentence — a short-form/long-form pair, not two separate facts. Filing it under Product next to `name` was a mistake carried over from treating the whole frontmatter block as one undifferentiated metadata unit. +- **Design Principles** (the numbered list as a unit) → **Brand**. Kept intact rather than decomposed per-bullet. Individual principles split close to 50/50 between voice/taste judgment calls (e.g. "Quiet authority over persuasion," "Restraint in every frame" — Brand) and product/workflow claims (e.g. "One workspace, not three spreadsheets" — Product), but the majority reads as brand cues and taste boundaries rather than product strategy, so the list stays with Brand instead of Product. +- **Spacing / layout scale** (`xs/sm/md/lg/xl/gutter/section` tokens) → **Material**. Treated as part of the physical construction of the interface alongside radius, borders, and elevation. +- **Breakpoints** (`sm/md/lg` px values) → **Material**. Responsive behavior of the same physical system: how surfaces reflow. +- **Accessibility & Inclusion** (WCAG target, reduced motion, alt text, color-vision pairing, plain-language/bilingual notes) → **Interface**. Per-item count across the two example runs: keyboard nav, screen-reader behavior, focus visibility, and alt text (Interface, the plurality) outweigh reduced motion (Material), contrast/color-vision pairing (Color), and bilingual/plain-language notes (Audience). Kept intact as a unit rather than split four ways, same treatment as Design Principles → Brand. +- **Tonal ramps** (`design.json` → `colorMeta.tonalRamp`, OKLCH 8-step scales per color) → **Color**. Promoted out of the sidecar into the human-readable doc, alongside the canonical hex — gives dark-mode inference raw material to work from instead of staying generated-only. +- **Motion tokens** (`design.json` → `extensions.motion`, e.g. `ease-standard`, `duration-fast`) → **Material**. Promoted out of the sidecar so the doc states the actual easing curve and duration behind "subtle lift on hover," not just the qualitative motion feel. + +## Checked against PR #315 — no new orphans + +[pbakaus/impeccable#315](https://github.com/pbakaus/impeccable/pull/315) adds `## Positioning` and `## Conversion & proof` to the `PRODUCT.md` template. Both map cleanly into the existing **Product** bucket, no new orphan: + +- `## Positioning` → **Product**, filling the differentiator sub-item. +- `## Conversion & proof` (CTAs, memorable line, belief ladder, proof assets) → **Product**, filling the proof-points and what's-must-be-clear-first sub-items. + +One nuance, not an orphan: the CTA/conversion-goal content doesn't exactly match any of the five sub-items in Product's current definition ("Purpose, differentiator, proof points, use cases, what must be clear first") — it's closest to Purpose but is more actionable/specific ("book a consultation" vs. "why this exists"). Worth a small wording update to Product's definition (e.g. adding "conversion goal") rather than a schema change. + +## Checked against the upcoming visual document seed (Typescale, Layout structure, Iconography) + +No new orphans. All three land inside categories that already exist in the schema: + +- **Typescale** (modular scale/ratio) → **Typography**, filling the font-scale sub-item with a generative rule instead of hand-picked sizes. +- **Layout structure** (grid strictness spectrum) → **Material**, alongside the already-resolved spacing/breakpoints orphans — same "physical construction of the interface" bucket. +- **Iconography** (icon library/style) → **Iconography** itself. This is the first content this category has ever had; it fills the "icon style" sub-item specifically, but stroke weight, metaphor rules, icon-button behavior, and forbidden types are outside this question's scope and stay open (tracked in [`MISSING.md`](./MISSING.md)). + +No open questions remain from this pass. diff --git a/docs/design-context-categorization/PROVENANCE.md b/docs/design-context-categorization/PROVENANCE.md new file mode 100644 index 000000000..a78fb6eb2 --- /dev/null +++ b/docs/design-context-categorization/PROVENANCE.md @@ -0,0 +1,76 @@ +# Provenance: Derived vs. Questionnaire-Answered Fields + +A requirement surfaced from the "Upgrading `/impeccable init`" landscape doc: the new visual document seed (chat asset/reference upload + local-browser slides for Color, Typography, Motion, Layout, Elevation, Iconography) must be **resettable**. A user can go back and redo one slide (e.g. Iconography) or the whole seed, without disturbing fields that came from somewhere else. + +Everything that isn't scanned off code is a **questionnaire answer** in the broad sense — nothing in `PRODUCT.md` or `DESIGN.md` is invented by the agent unprompted. But "questionnaire" isn't one thing: it happens through different commands and different UIs (chat vs. a local-browser slide). "Planned" vs. "already shipped" is a maturity detail on the specific field (called out in that field's `Value`/`Derived from` columns in [`CONTENTS-TABLE.md`](./CONTENTS-TABLE.md)), not a different provenance category — the new seed flow's chat portion is more of the same `init.md`/`document.md`-style chat interview, just under `document.md`; its browser portion is more of the same card-UI pattern the Layout-strictness slide already uses today. So provenance is four tags, not seven: + +- **Derived** — read directly off existing code (scanned CSS tokens, component classes). Changing it means re-scanning, not re-asking. +- **Questionnaire — In-chat (init)** — `PRODUCT.md` fields, asked in chat during `/impeccable init`'s Step 3. Today's mechanism for every non-Derived `PRODUCT.md` field (Users, Purpose, Positioning, Brand Personality, Anti-references, Conversion & proof, Design Principles, Accessibility). Free-form, not persisted for reset. +- **Questionnaire — In-chat (document)** — `DESIGN.md` fields asked in chat during `document.md`. Covers both today's qualitative round (North Star name, color role names/strategy, elevation/component feel, signature-component ideas) and the new seed flow's chat portion (asset/logo/moodboard upload, three named references, one named anti-reference, the image-gen capability check). Free-form, not persisted for reset — except where the new seed work adds persistence (see below). +- **Questionnaire — Browser (document)** — `DESIGN.md` fields asked through a visual card UI during `document.md`. Covers both today's shipped Layout-strictness slide (Simple grid / Balanced / Editorial / Freeform) and the new seed flow's browser slides (Color amount, Visual cues, Palette tokens, Typography, Motion, Layout, Elevation, Iconography). +- **Computed** — generated by the agent from other values (tonal ramps, breakpoints, HTML/CSS snippets). Never directly asked at all. + +The persisted/resettable mechanism below is in scope only for the **new, not-yet-shipped fields** within `Browser (document)` and `In-chat (document)` — i.e. the fields `CONTENTS-TABLE.md` marks `(planned)`. The fields already shipped under those same two tags (today's qualitative round, today's Layout-strictness slide) aren't persisted and stay that way unless retrofitted — see [`MISSING.md`](./MISSING.md) and open question #1 below. + +## Where it's stored + +In `design.json`, not inline in `DESIGN.md`. `DESIGN.md` stays a clean, human-readable artifact; `design.json` is already the structured sidecar other tooling reads (`colorMeta`, `typographyMeta`, `shadows`, `motion`, `breakpoints`, `components`, `narrative`), so provenance belongs next to it, not as HTML comments scattered through the markdown. + +## Proposed schema addition + +Two changes to `design.json`: + +**1. A `source` tag on existing per-field metadata**, so a value's provenance travels with it: + +```json +"extensions": { + "colorMeta": { + "dusty-plum": { + "role": "primary", + "source": "crawl" + } + }, + "typographyMeta": { + "display": { + "source": "questionnaire" + } + } +} +``` + +Values: `"crawl"`, `"chat-init"`, `"chat-document"`, `"browser-document"`, `"computed"`. A field's `(planned)` status (i.e. whether it's part of the new, not-yet-shipped seed work) is tracked by whether it has a `questionnaire.sections` entry at all, not by a separate source value — see the block below. + +**2. A top-level `questionnaire` block** — the actual answer log, keyed by slide/section, needed for reset at either granularity: + +```json +"questionnaire": { + "version": 1, + "lastRunAt": "2026-07-09T22:40:00.000Z", + "sections": { + "assets": { "status": "answered", "answeredAt": "...", "answers": { "logos": [], "referenceImages": [], "moodboards": [] }, "producedPaths": [] }, + "references": { "status": "answered", "answeredAt": "...", "answers": { "named": ["Kinfolk", "Cereal"] }, "producedPaths": ["narrative.overview"] }, + "antiReference": { "status": "answered", "answeredAt": "...", "answers": { "named": "Squarespace wedding-florist template" }, "producedPaths": ["narrative.donts"] }, + "color": { "status": "answered", "answeredAt": "...", "answers": { "amount": "restrained", "visualCue": "...", "paletteTokens": {"...": "..."} }, "producedPaths": ["extensions.colorMeta"] }, + "typography": { "status": "answered", "answeredAt": "...", "answers": { "fontPair": "Shippori Mincho / Source Sans 3", "typeDirection": "editorial" }, "producedPaths": ["extensions.typographyMeta"] }, + "motion": { "status": "answered", "answeredAt": "...", "answers": { "easing": "ease-standard", "duration": "150ms", "animationType": "crossfade" }, "producedPaths": ["extensions.motion"] }, + "layout": { "status": "answered", "answeredAt": "...", "answers": { "radii": "square", "spacingStrategy": "fluid", "boundaryStyle": "position/distance" }, "producedPaths": ["rounded", "spacing"] }, + "elevation": { "status": "answered", "answeredAt": "...", "answers": { "shadowSystem": "flat-by-doctrine" }, "producedPaths": ["extensions.shadows"] }, + "iconography": { "status": "answered", "answeredAt": "...", "answers": { "pack": "Lucide" }, "producedPaths": ["extensions.iconography"] } + } +} +``` + +`producedPaths` is what makes reset safe: it tells the reset routine exactly which `design.json`/`DESIGN.md` fields that section's answer wrote to, so redoing one slide only touches those paths and leaves everything else (crawl-derived or answered in a different slide) untouched. + +## Reset, both granularities + +- **Whole-flow reset**: clear all `questionnaire.sections`, re-run the seed from the assets/references chat step through every browser slide, same as a first-time seed. +- **Per-section reset**: user says "redo iconography" (or picks it from a menu). Agent reads `questionnaire.sections.iconography.answers` to show what was previously picked, lets the user redo just that slide, and overwrites only `producedPaths` for that section — no re-scan, no touching Color/Typography/etc. +- **Fields with `source: "crawl"`**: not resettable through this mechanism at all. Changing them means re-running `/impeccable document`'s scan (Step 1-2), or a manual code edit. The questionnaire reset flow should refuse to "reset" a crawl-derived field and say so. + +## Open questions + +1. **Scope**: this spec covers the new, not-yet-shipped fields within `In-chat (document)` and `Browser (document)` only. Should the fields already shipped under those same two tags (today's qualitative round, today's Layout-strictness slide), plus all of `In-chat (init)`, get the same persisted/resettable treatment eventually, so *all* of `PRODUCT.md`/`DESIGN.md` becomes resettable, not just the new seed's slice? That's a bigger lift — it touches `init.md`'s chat rounds too, and it would mean retrofitting a `questionnaire.sections` entry for flows that already shipped without one. +2. **Drift after manual edits**: if a user hand-edits a questionnaire-sourced value directly in `DESIGN.md` after the seed ran, does `source` flip to a fourth state (`"manual"`), or does the stale `questionnaire.sections` entry silently go out of sync with the doc? Needs a decision before reset ships, or resets could clobber a deliberate manual change. +3. **Existing repos with `design.json` but no `questionnaire` block** (every file generated before this ships): reset should degrade gracefully — treat missing `questionnaire` as "nothing resettable yet, only a fresh full seed is available" rather than erroring. +4. **`producedPaths` granularity**: paths above are section-level (e.g. `extensions.colorMeta`). If two slides ever write into the same object (unlikely given the current slide breakdown, but worth checking against the final slide list), path-level conflicts need a tie-break rule. diff --git a/docs/design-context-categorization/README.md b/docs/design-context-categorization/README.md new file mode 100644 index 000000000..ecca008c6 --- /dev/null +++ b/docs/design-context-categorization/README.md @@ -0,0 +1,17 @@ +# Design Context Categorization + +Analysis of what `/impeccable init` + `/impeccable document` currently produce (`PRODUCT.md`, `DESIGN.md`, `.impeccable/design.json`) against a proposed 8-category **design context doc**: Audience, Product, Brand, Color, Typography, Iconography, Material, Interface. + +Source examples analyzed (local scratch runs, not tracked in this repo): + +- `tmp/notes/init-tests/runs/noren-ops-20260708-1832/DESIGN.md` + `proof/PRODUCT.md` + `proof/design.json` +- `tmp/notes/init-tests/runs/hanazono-atelier-20260708-1832/DESIGN.md` + `/Users/abdulwahab/hanazono-atelier/PRODUCT.md` + +## Documents + +- [`WHAT.md`](./WHAT.md) — first draft of what we're building: a visual, browsable rendering of this categorization on the live site. +- [`CONTENTS.md`](./CONTENTS.md) — what maps where, per category, with gaps and rationale. +- [`CONTENTS-TABLE.md`](./CONTENTS-TABLE.md) — the same mapping as lean per-category Value / Derived from / Source tables, no prose. +- [`MISSING.md`](./MISSING.md) — content gaps neither example run produces, regardless of category. +- [`ORPHANS.md`](./ORPHANS.md) — fields that exist today but don't have a clean home in the 8-category schema, plus open questions. +- [`PROVENANCE.md`](./PROVENANCE.md) — how the new visual document seed marks which fields are user-answered (resettable) vs. derived from code, and where that's stored. diff --git a/docs/design-context-categorization/WHAT.md b/docs/design-context-categorization/WHAT.md new file mode 100644 index 000000000..bc624d295 --- /dev/null +++ b/docs/design-context-categorization/WHAT.md @@ -0,0 +1,216 @@ +# What We're Building (First Draft) + +A visual, browsable rendering of the 8-category design context map — Audience, Product, Brand, Color, Typography, Iconography, Material, Interface — so a user can click through and see, category by category, what `/impeccable` already extracts from a project (and, later, what it's still missing). + +This is the **WHAT**, not the how: scope and shape, not component code. Two sources feed it, each responsible for a different half: + +- **Interaction mechanics** (the tile-grid landing, the click-to-fullscreen morph, hash routing, responsive collapse) come from the `Puppy Wear Design Context V2.html` prototype — see "Reference architecture" below. +- **Visual UI** (the "inside" — sidebar, content layout, typography, color, both light and dark) comes from the live [impeccable.style/docs/](https://impeccable.style/docs/) page, i.e. the actual local files that render it in this repo — see "Local reference" below. Content and category definitions come from [`CONTENTS-TABLE.md`](./CONTENTS-TABLE.md). + +Nothing here invents a third design language: the shape of the interaction is the prototype's, the skin is the live site's. + +--- + +## Reference architecture — interaction mechanics only + +Source: `Puppy Wear Design Context V2.html` (local prototype, not in this repo). What we take from it is behavior and geometry — how the tile grid is arranged, how a click turns into a fullscreen view, how that view is structured into regions and addressed by URL. The prototype's own CSS for what fills those regions (its sidebar/topbar/row styling, its color palette) is **not** carried over — that's replaced wholesale by the live site's actual components, described in "Visual language" below. + +- **Landing state** — an 8-tile asymmetric mosaic grid (an 18-column × 12-row grid with hand-placed spans per tile, matching the prototype's exact tile placement) with a circular center badge sitting on the seam between tiles. One tile per category — Audience, Product, Brand, Color, Typography, Iconography, Material, Interface — label only, no content, until opened. +- **Open transition** — clicking a tile creates a container positioned/sized to start exactly at the clicked tile's on-screen rect, then animates position/size/corner-radius to fullscreen, with the inner content fading/sliding in after a short delay. Closing runs the same transition in reverse, back to the originating tile's rect, then removes the element. (This is the prototype's `.expander` / `.expander.is-full` / `.expander.is-ready` mechanism — same tween, same timing, just filled with the live site's markup instead of the prototype's own.) +- **Detail shell regions** — once open, the view splits into: a **sidebar** (all 8 categories listed flat, in `CONTENTS-TABLE.md`'s order — Audience, Product, Brand, Color, Typography, Iconography, Material, Interface — active one highlighted, with jump-target sub-links for categories that have sub-content; no thematic clustering, unlike the prototype's own Context/Foundations/System grouping), a **topbar** (current category name + close control), and a **main pane** (that category's content, scrollable). What renders inside each region is the live site's, not the prototype's — see "Visual language." +- **Hash routing** — the open category is reflected in the URL (`#color`), so it's deep-linkable and back/forward-navigable via `popstate`. `Escape` and the close button both dismiss it. +- **Responsive fallback** (below ~920px) — the mosaic grid becomes a flex column, one tile per row in a fixed reading order (Product, Audience, Brand, Color, Typography, Iconography, Material, Interface); the sidebar collapses to a horizontal scroll strip. + +--- + +## Local reference — where impeccable.style/docs/ comes from + +[impeccable.style/docs/](https://impeccable.style/docs/) is not an external design to imitate from a screenshot — it's this repo, live. The exact files that render it: + +| What it renders | File | +| --- | --- | +| `/docs` landing page (start rail, command chooser, "Understand the system" list, full reference list) | [`site/pages/docs/index.astro`](../../site/pages/docs/index.astro) | +| `/docs/:slug` detail pages (one per command/reference topic — closest existing analog to a category detail view) | [`site/pages/docs/[...slug].astro`](../../site/pages/docs/%5B...slug%5D.astro) → [`site/layouts/Doc.astro`](../../site/layouts/Doc.astro) | +| Shared sidebar (grouped nav, one entry per item, `aria-current` for the active one) | [`site/components/DocsSidebar.astro`](../../site/components/DocsSidebar.astro) | +| Shared docs/sub-page layout shell (`.skills-layout`, `.skills-sidebar`, `.skills-main`, `.skills-detail`) | [`site/styles/sub-pages.css`](../../site/styles/sub-pages.css) | +| `/docs`-specific dark-lacquer skin + the `html.light` remap for this section | [`site/styles/docs-kinpaku.css`](../../site/styles/docs-kinpaku.css) | +| Sitewide brand tokens (colors, type) that everything above consumes | [`site/styles/kinpaku-tokens.css`](../../site/styles/kinpaku-tokens.css), [`site/styles/kinpaku-kit.css`](../../site/styles/kinpaku-kit.css) | +| Sitewide light-mode override layer | [`site/styles/light-mode.css`](../../site/styles/light-mode.css) | +| Theme toggle (auto/light/dark, `localStorage`-backed, sets `html.light`/`html.dark`) | [`site/scripts/utils/theme.js`](../../site/scripts/utils/theme.js), wired up in [`site/layouts/Base.astro`](../../site/layouts/Base.astro) (inline pre-paint script + `initThemeToggle()` call) | + +We build against these files directly, in both resolved themes (`html.light` and `html.dark`), not a one-off recreation of how the page happens to look in a screenshot. + +--- + +## UI sketch (ASCII) + +Proportions are illustrative, not to scale — the point is the asymmetry and the center badge, not exact grid math. Labels like "sidebar" and "topbar" below are generic region names; what actually renders inside them is `DocsSidebar` / `Doc.astro`'s header+prose pattern per "Visual language" above, not the prototype's own chrome. + +**Landing — 8-tile mosaic, nothing open:** + +``` +┌─────────────┬──────────────┬───────────────────┬─────────────┐ +│ │ │ │ │ +│ │ BRAND │ │ TYPOGRAPHY │ +│ │ │ AUDIENCE │ │ +│ PRODUCT ├──────┬───────┤ ├─────────────┤ +│ │ │ (●) │ │ │ +│ │ COLOR│ logo ├─────────────────────┤ MATERIAL │ +├─────────────┤ │ │ │ │ +│ ICONOGRAPHY │ │ │ INTERFACE │ │ +└─────────────┴──────┴───────┴─────────────────────┴─────────────┘ +``` + +**A tile mid-click, morphing toward fullscreen (FLIP animation):** + +``` +┌──────┬──────┬──────┐ ┌──────┬──────┬──────┐ ┌───────────────────────┐ +│ P │ B │ A │ │ P │▓▓▓▓▓▓│ A │ │ │ +├──────┼──────┼──────┤ → ├──────┼▓▓▓▓▓▓┼──────┤ → │ Color (open) │ +│ Ic │ [C]★ │ In │ │ Ic │▓▓▓▓▓▓│ In │ │ │ +└──────┴──────┴──────┘ └──────┴──────┴──────┘ └───────────────────────┘ + ★ user clicks "Color" same element grows in place sidebar + content + (top/left/width/height tween) fade in ~260ms later +``` + +**Expanded — sidebar + topbar + main, "Color" open:** + +``` +┌───────────────┬─────────────────────────────────────────────────────────┐ +│ Impeccable │ Color ✕ │ +├───────────────┼─────────────────────────────────────────────────────────┤ +│ · Audience │ Color │ +│ · Product │ Palette, roles, tints, strategy, tonal ramps. │ +│ · Brand │ │ +│ ► Color │ ┌────────────────────────────────────────────────────┐ │ +│ ↳ Palette │ │ ░░░░ ▒▒▒▒ ▓▓▓▓ ████ ▓▓▓▓ ▒▒▒▒ │ │ +│ · Typography │ │ (swatch fan — hover fans out, click copies) │ │ +│ · Iconography│ └────────────────────────────────────────────────────┘ │ +│ · Material │ │ +│ · Interface │ ───────────────────────────────────────────────────── │ +│ │ Palette (hex) DESIGN.md · colors Derived │ +│ │ ───────────────────────────────────────────────────── │ +│ │ Roles DESIGN.md · Colors In-chat │ +│ │ ───────────────────────────────────────────────────── │ +│ │ Strategy Colors → Named Rules In-chat │ +│ │ ───────────────────────────────────────────────────── │ +│ │ Tints colors (opacity) Derived │ +│ │ ───────────────────────────────────────────────────── │ +│ │ Tonal ramps (OKLCH) colorMeta.tonalRamp Computed │ +└───────────────┴─────────────────────────────────────────────────────────┘ +``` + +The sidebar is flat, in `CONTENTS-TABLE.md`'s own category order — no thematic clustering like the prototype's Context/Foundations/System groups. Decided; see "Decisions" at the end. + +**Responsive (< ~920px) — grid stacks, sidebar becomes a scroll strip:** + +``` +┌─────────────────────────┐ ┌──────────────────────────────────┐ +│ PRODUCT │ │[Product][Audience][Brand][►Color]│ +├─────────────────────────┤ │ ...horizontal scroll... →│ +│ AUDIENCE │ ├──────────────────────────────────┤ +├─────────────────────────┤ │ Color ✕ │ +│ BRAND │ │ │ +├─────────────────────────┤ │ ░░░ ▒▒▒ ▓▓▓ ███ ▓▓▓ ▒▒▒ │ +│ COLOR │ tap → │ │ +├─────────────────────────┤ │ Palette (hex) ... Derived │ +│ TYPOGRAPHY │ │ Roles ... In-chat │ +├─────────────────────────┤ │ Strategy ... In-chat │ +│ ICONOGRAPHY │ │ ... │ +├─────────────────────────┤ │ │ +│ MATERIAL │ │ │ +├─────────────────────────┤ │ │ +│ INTERFACE │ │ │ +└─────────────────────────┘ └──────────────────────────────────┘ +``` + +--- + +## UX flow (ASCII) + +``` + ┌─────────────────────────┐ + ┌───────►│ LANDING │ + │ │ 8-tile mosaic grid, │ + │ │ nothing open │ + │ └────────────┬────────────┘ + │ │ click a tile + │ │ (or page load with #hash) + │ ▼ + │ ┌─────────────────────────┐ + │ │ OPEN TRANSITION │ + │ │ tile rect → fullscreen │ + │ │ (FLIP morph, ~500ms) │ + │ └────────────┬────────────┘ + │ │ morph completes + │ ▼ + │ ┌─────────────────────────┐ + │ ┌───►│ EXPANDED │◄───┐ + │ │ │ sidebar + topbar + main │ │ + │ │ └────────────┬────────────┘ │ + │ │ │ │ + │ │ ┌───────────┼────────────┐ │ + │ │ ▼ ▼ ▼ │ + │ │ click a click a click a │ + │ │ different subnav swatch │ + │ │ category link → copy │ + │ │ in sidebar → scroll to hex, │ + │ │ that row "Copied!" │ + │ │ │ │ │ │ + │ │ └───────────┴────────────┘ │ + │ │ re-render main, update hash, │ + │ └───────────── no re-morph ─────────────┘ + │ │ + │ │ Escape, or click ✕ + │ ▼ + │ ┌─────────────────────────┐ + │ │ CLOSE TRANSITION │ + └────────┤ fullscreen → tile rect │ + │ (reverse morph) │ + └─────────────────────────┘ +``` + +Two entry points worth calling out: a direct tile click always starts from the tile's own rect (so the morph has somewhere real to animate from), while a page load with a `#category` hash in the URL skips straight to `EXPANDED` — the prototype does this via a short `setTimeout` rather than an instant snap, so the shell still feels like it "arrived" rather than being static markup. + +--- + +## Visual language — the inside (and the mosaic's skin) is impeccable.style/docs/, not the prototype + +The prototype supplies geometry and motion (previous section). Everything you'd actually call "the design" — color, type, borders, the sidebar, how a list of facts is laid out — comes from the live `/docs` implementation listed above, applied in both the regions the prototype defines (mosaic tiles, sidebar, topbar, main pane) and rendered correctly in both themes. + +- **Mosaic tiles (landing)** — keep the prototype's asymmetric grid *placement* (which tile sits where, the center badge), but the tile surface itself — border, corner radius, hover/focus treatment, background — pulls from the same surface tokens the rest of the site already uses for card-like elements (`--docs-panel-bg`, `--docs-row-bg`, `--docs-row-hover-bg`, `--docs-accent` from `docs-kinpaku.css`), so a tile reads as a natural extension of the site's existing surfaces, not the prototype's flat white/black `oklch(0% 0 0)` outline treatment. Tile label typography uses the site's existing display font (whatever `docs-kinpaku.css` / `kinpaku-tokens.css` already sets for large display text), not the prototype's Alumni Sans Pinstripe. +- **Sidebar (inside)** — structurally and visually **`DocsSidebar.astro`**: the `skills-sidebar` / `skills-sidebar-label` / `skills-sidebar-list` / `aria-current` pattern, applied as a single flat `skills-sidebar-list` of all 8 categories in `CONTENTS-TABLE.md`'s own order (Audience, Product, Brand, Color, Typography, Iconography, Material, Interface) — no `skills-sidebar-group` clustering, since there's no thematic grouping to apply. Not the prototype's own `docs-sidebar` / `docs-nav-link` / `docs-subnav` CSS, nor its Context/Foundations/System clusters — those are discarded entirely; only the per-category jump-target sub-links survive as `docs-subnav`-equivalent behavior under the active entry. +- **Topbar + main pane (inside)** — structurally and visually **`Doc.astro`**'s `.skills-main` / `.skills-detail` pattern: a header (`.sub-page-header` → `.sub-page-title` + `.sub-page-lede`) playing the role of the prototype's topbar-plus-title, followed by a content body (`.skills-detail-body.docs-body.prose`) playing the role of the main pane. Each category's Value / Derived-from / Source rows render as the same label-plus-secondary-line row shape the live `/docs` index already uses for its "Full command reference" list (primary text + a muted description line), not the prototype's bordered `.detail-row` two-column table look. +- **Color's swatch fan / Typography's type sample / Material's line diagram** — these three specialized blocks have no equivalent on the live site (nothing there needs a color-swatch strip), so they're the one place the prototype's own markup and interaction *do* carry over structurally — but skinned with the site's color tokens, not the prototype's `--care-glow` / `--paw-proof` custom palette. +- **Theme** — driven entirely by the existing toggle (`theme.js`, `html.light` / `html.dark`, wired in `Base.astro`). Both themes are first-class from the start because we're building against `docs-kinpaku.css`'s existing dark/light rule pairs, not authoring a new palette that then needs a light variant invented after the fact. +- **Motion** — the tile→fullscreen morph timing/easing and the responsive breakpoint stay as defined in "Reference architecture"; none of that is affected by this section. + +--- + +## Content model + +Source of truth: [`CONTENTS-TABLE.md`](./CONTENTS-TABLE.md). + +- **Legend** (5 source types: Derived, Questionnaire — In-chat (init), Questionnaire — In-chat (document), Questionnaire — Browser (document), Computed) — surfaced once, not per category (a persistent key, e.g. a footnote strip or an info toggle in the topbar). +- **8 categories**, each a list of rows: Value / Derived from / Source. Row counts vary today from 1 (Iconography) to 10 (Material). +- To avoid a second hand-maintained copy of the same data, the first build pass should read `CONTENTS-TABLE.md` directly (parsed into a small structured module at build time) rather than re-typing rows into markup. This doc stays the single source; the page is a renderer, not a fork. + +--- + +## Explicitly out of scope for this first draft + +- No live wiring to a real project's `PRODUCT.md` / `DESIGN.md` — this renders the fixed categorization, not a per-user report. +- No authoring/editing UI — read-only browse experience. +- Color's swatch-fan block (`.panel-colors`) carries over structurally, but `CONTENTS-TABLE.md` describes color *concepts* ("Palette (hex)", "Tints") rather than literal hex values — so it renders with placeholder/illustrative swatches for this first draft, not real per-project colors. Click-to-copy still works, just copies placeholder values. +- No new visual theme, font, or motion language beyond what gets set up by the token swap above. + +--- + +## Decisions + +1. **Sidebar grouping — flat, `CONTENTS-TABLE.md` order.** No thematic clusters (the prototype's Context/Foundations/System is dropped). All 8 categories sit at one level, in the exact order `CONTENTS-TABLE.md`'s own "Categories" list uses: Audience, Product, Brand, Color, Typography, Iconography, Material, Interface. +2. **Verification pass — confirmed.** Once built, screenshot-compare against the real `/docs` and `/docs/:slug` pages in both `html.light` and `html.dark`, same as any other `docs-kinpaku.css`-consuming page — not eyeballed once in whichever theme happens to be active during development. + +## Deferred + +- **Where this lives** (a page under `/docs`, e.g. filling the existing unused `Design Context` → `/docs/context` stub in `manualTopics` in [`site/pages/docs/index.astro`](../../site/pages/docs/index.astro), vs. its own standalone route) is not being decided as part of this draft. Revisit once the build is closer. diff --git a/docs/design-context-categorization/design-context.html b/docs/design-context-categorization/design-context.html new file mode 100644 index 000000000..f2616ed71 --- /dev/null +++ b/docs/design-context-categorization/design-context.html @@ -0,0 +1,2447 @@ + + + + + +Design Context — Hanazono Atelier + + + + + + + + + + + + + + + + +
+

Design context — Hanazono Atelier

+ +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index 7605e59b0..bccf131e1 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,11 @@ "LICENSE" ], "scripts": { + "build:picker": "node scripts/build-picker.mjs", "build:skills": "bun run scripts/build.js --skip-root-sync", "build:skills:release": "bun run scripts/build.js", - "build": "bun run build:skills && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", - "build:release": "bun run build:skills:release && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", + "build": "bun run build:picker && bun run build:skills && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", + "build:release": "bun run build:picker && bun run build:skills:release && mkdir -p build/_data && rm -rf build/_data/dist && cp -R dist build/_data/dist", "build:browser": "node scripts/build-browser-detector.js", "build:extension": "node scripts/build-extension.js", "clean": "rm -rf dist build", @@ -93,6 +94,7 @@ "@babel/parser": "^8.0.4", "ai": "^7.0.14", "archiver": "^8.0.0", + "astro": "^7.0.0", "playwright": "^1.59.1", "svelte": "^5", "zod": "^4.3.6" diff --git a/picker/assets/favicon.svg b/picker/assets/favicon.svg new file mode 100644 index 000000000..a6c6d2979 --- /dev/null +++ b/picker/assets/favicon.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/picker/assets/hero-dark.jpg b/picker/assets/hero-dark.jpg new file mode 100644 index 000000000..1bfecfefa Binary files /dev/null and b/picker/assets/hero-dark.jpg differ diff --git a/picker/assets/kinpaku-gold-leaf.jpg b/picker/assets/kinpaku-gold-leaf.jpg new file mode 100644 index 000000000..401c103fd Binary files /dev/null and b/picker/assets/kinpaku-gold-leaf.jpg differ diff --git a/picker/astro.config.mjs b/picker/astro.config.mjs new file mode 100644 index 000000000..93269ac59 --- /dev/null +++ b/picker/astro.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + srcDir: './picker', + outDir: './build-picker', + output: 'static', + build: { + assets: 'assets', + assetsPrefix: '.', + format: 'directory', + }, + devToolbar: { + enabled: false, + }, + vite: { + build: { + assetsInlineLimit: 0, + }, + }, +}); diff --git a/picker/components/Artboard.astro b/picker/components/Artboard.astro new file mode 100644 index 000000000..f7fcf5276 --- /dev/null +++ b/picker/components/Artboard.astro @@ -0,0 +1,554 @@ +--- +/* + The wireframe landing page every option screen previews its answer on. One + copy so a change to the page's anatomy lands on every screen at once. + + mode: 'bars' fills the hero with bar stacks, which is what a screen judging + color, layout, or depth needs. 'type' swaps them for real text elements and + adds the data-type-* hooks the font screen writes specimen copy into; that + screen is judging glyphs, so bars would tell it nothing. + + Every body is drawn both ways for the same reason. The slots stay one tree and + one set of class names; what changes is whether a slot holds a data-type-* + hook or the bars that stand in for the words. Only the font screen's boards are + filled, so a hook on any other board would promise copy that never arrives, + and a paragraph is a stack of bars rather than one because the block a + structural answer acts on is the shape of the passage, not a single line. + + phone: false drops the handset and leaves the desktop artboard alone in the + frame, for a screen whose answer only reads at desktop width. +*/ +interface Props { + mode?: 'bars' | 'type'; + /* + Which surface the board draws. The chrome and the frame are shared and only + the two bodies change, the same arrangement the four wireframes on the + surface question already use, so a change to the nav lands on all of them at + once. Named boards are the ones the font screen switches between. + */ + surface?: 'persuade' | 'operate' | 'read' | 'experience'; + phone?: boolean; + class?: string; + strategyHook?: boolean; + typeHook?: boolean; + cursor?: boolean; + /* + Marks an artboard as one that accumulates the structural answers. The + committed layout, boundary, corner, and depth selectors key on it, which is + how each screen shows the page as decided so far without those answers + reaching back to the screens that came before them. + */ + carry?: boolean; + /* + The layout screen's boards carry parts no other board draws: the shared + alignment-grid component on every desktop and handset body, and on the + index a pagination rail for the handset plus the project rail and support + row that only the freeform answer places (layout-portfolio.css parks them + until it does). The index projects also take the data-project-index and + data-project-tone hooks that screen's sheet keys plate placement and + image treatment on. Mounted only where asked, because the motion screen + reads the same index body through :nth-of-type and an extra sibling would + shift its scenes. + */ + portfolioExtras?: boolean; +} + +const { + mode = 'bars', + surface, + phone = true, + class: className = '', + strategyHook = false, + typeHook = false, + cursor = false, + carry = false, + portfolioExtras = false, +} = Astro.props; +const type = mode === 'type'; +const ops = surface === 'operate'; +const docs = surface === 'read'; +const index = surface === 'experience'; +const classes = ['picker-artboard', className].filter(Boolean).join(' '); +// Empty string renders the bare attribute the font screen's selectors expect; +// undefined drops it entirely on the other screens. +const hook = type ? '' : undefined; +--- + + diff --git a/picker/components/QuestionScreen.astro b/picker/components/QuestionScreen.astro new file mode 100644 index 000000000..6f37465b7 --- /dev/null +++ b/picker/components/QuestionScreen.astro @@ -0,0 +1,135 @@ +--- +/* + One question, one artboard: the shape screens 03 onward all share. A screen + that only remaps artboard slots needs nothing of its own here, so it arrives + as data and its design lives in its own stylesheet. +*/ +import Artboard from './Artboard.astro'; +import { SURFACE_ANSWERS, surfacesAsked, surfaceTabsAttrs } from '../data/surfaces.js'; + +interface Option { + value: string; + title: string; + desc: string; + /* The sentence that stands in for the description on a surface this option + does not suit. Present only on options some surface rules out. */ + blocked?: string; +} + +/* A screen whose answer reads differently on each surface draws a board per + surface instead of repainting one, the arrangement the font screen already + uses. `class` is the modifier that board's own scene keys on. */ +interface Board { + surface: 'persuade' | 'operate' | 'read' | 'experience'; + class?: string; +} + +interface Props { + id: string; + step: string; + slug: string; + name: string; + title: string; + legend: string; + cta: string; + options: Option[]; + previewClass: string; + phone?: boolean; + boards?: Board[]; +} + +const { id, step, slug, name, title, legend, cta, options, previewClass, phone = true, boards } = Astro.props; +const titleId = `picker-${slug}-title`; +const artboardClass = phone ? previewClass : `${previewClass} picker-artboard--solo`; +// A question listed in the matrix is answered once per chosen surface, which +// buys it a tab strip on the frame and a field per surface to answer into. +// Everything else about the screen is unchanged by opting in. +const perSurface = SURFACE_ANSWERS[name]; +// Only the surfaces the question is put to. A surface with no field here is a +// surface that was never asked, and that absence is the whole of how the script +// and the final document know it. +const asked = perSurface ? surfacesAsked(name) : []; +const tabsAttrs = perSurface ? surfaceTabsAttrs(name) : null; +--- + + diff --git a/picker/data/icon-packs.json b/picker/data/icon-packs.json new file mode 100644 index 000000000..11caacc00 --- /dev/null +++ b/picker/data/icon-packs.json @@ -0,0 +1 @@ +{"generated":"2026-07-26","concepts":["home","search","user","settings","bell","heart","star","calendar","mail","message","trash","download","upload","plus","check","close","chevron-right","arrow-right","menu","filter","edit","lock","eye","external-link"],"packs":[{"id":"lucide","name":"Lucide","license":"ISC","url":"https://lucide.dev","note":"Feather’s successor. Same 2px round-cap hand, far wider catalog, still shipping.","grid":"24","weight":"2px stroke","count":2007,"version":"1.27.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash-2","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"feather","name":"Feather","license":"MIT","url":"https://feathericons.com","note":"The 2px original most stroke sets descend from. Small, even, no longer growing.","grid":"24","weight":"2px stroke","count":287,"version":"4.29.2","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash-2","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"edit-3","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"phosphor","name":"Phosphor","license":"MIT","url":"https://phosphoricons.com","note":"Drawn on a 256 grid with a soft geometric hand. Six weights upstream, regular here.","grid":"256","weight":"regular weight","count":1512,"version":"2.1.1","viewBox":"0 0 256 256","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"house","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"gear-six","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat-circle","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"caret-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"list","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"arrow-square-out","body":""}]},{"id":"heroicons","name":"Heroicons","license":"MIT","url":"https://heroicons.com","note":"Tailwind’s set. 1.5px strokes at 24, with solid and 20px mini twins for dense UI.","grid":"24","weight":"1.5px stroke","count":324,"version":"2.2.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"cog","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat-bubble-left","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"arrow-down-tray","body":""},{"concept":"upload","name":"arrow-up-tray","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x-mark","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"bars-3","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock-closed","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"arrow-top-right-on-square","body":""}]},{"id":"tabler","name":"Tabler","license":"MIT","url":"https://tabler.io/icons","note":"The widest stroke catalog here, uniform enough that mixed rows read as one hand.","grid":"24","weight":"2px stroke","count":5112,"version":"3.45.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu-2","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"radix","name":"Radix Icons","license":"MIT","url":"https://www.radix-ui.com/icons","note":"A 15px grid built for product chrome. Crisp at control size, thin if you scale it up.","grid":"15","weight":"filled paths","count":332,"version":"112af91ad275a63c3a29b0da2588342af74ef9bf","viewBox":"0 0 15 15","attrs":{"fill":"none"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"person","body":""},{"concept":"settings","name":"gear","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope-closed","body":""},{"concept":"message","name":"chat-bubble","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"cross-1","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"hamburger-menu","body":""},{"concept":"filter","name":"mixer-horizontal","body":""},{"concept":"edit","name":"pencil-1","body":""},{"concept":"lock","name":"lock-closed","body":""},{"concept":"eye","name":"eye-open","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"bootstrap","name":"Bootstrap Icons","license":"MIT","url":"https://icons.getbootstrap.com","note":"Filled paths on a 16 grid, so weight holds at any size. More literal than the stroke sets.","grid":"16","weight":"filled paths","count":1408,"version":"1.13.1","viewBox":"0 0 16 16","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"house","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"person","body":""},{"concept":"settings","name":"gear","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"list","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"box-arrow-up-right","body":""}]},{"id":"iconoir","name":"Iconoir","license":"MIT","url":"https://iconoir.com","note":"1.5px strokes on 24 with an open, geometric hand. Roomier than Lucide at the same size.","grid":"24","weight":"1.5px stroke","count":1383,"version":"7.11.1","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"chat-bubble","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"xmark","body":""},{"concept":"chevron-right","name":"nav-arrow-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"edit-pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"open-new-window","body":""}]},{"id":"octicons","name":"Octicons","license":"MIT","url":"https://primer.style/octicons","note":"GitHub’s set, redrawn per size rather than scaled. Compact and developer-fluent.","grid":"24","weight":"filled paths","count":350,"version":"19.31.0","viewBox":"0 0 24 24","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"home-24","body":""},{"concept":"search","name":"search-24","body":""},{"concept":"user","name":"person-24","body":""},{"concept":"settings","name":"gear-24","body":""},{"concept":"bell","name":"bell-24","body":""},{"concept":"heart","name":"heart-24","body":""},{"concept":"star","name":"star-24","body":""},{"concept":"calendar","name":"calendar-24","body":""},{"concept":"mail","name":"mail-24","body":""},{"concept":"message","name":"comment-24","body":""},{"concept":"trash","name":"trash-24","body":""},{"concept":"download","name":"download-24","body":""},{"concept":"upload","name":"upload-24","body":""},{"concept":"plus","name":"plus-24","body":""},{"concept":"check","name":"check-24","body":""},{"concept":"close","name":"x-24","body":""},{"concept":"chevron-right","name":"chevron-right-24","body":""},{"concept":"arrow-right","name":"arrow-right-24","body":""},{"concept":"menu","name":"three-bars-24","body":""},{"concept":"filter","name":"filter-24","body":""},{"concept":"edit","name":"pencil-24","body":""},{"concept":"lock","name":"lock-24","body":""},{"concept":"eye","name":"eye-24","body":""},{"concept":"external-link","name":"link-external-24","body":""}]},{"id":"hugeicons","name":"Hugeicons","license":"MIT (free core)","url":"https://hugeicons.com","note":"1.5px rounded strokes on 24. The free core is one style; the other styles are paid.","grid":"24","weight":"1.5px stroke","count":5443,"version":"4.2.3","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"Home01Icon","body":""},{"concept":"search","name":"Search01Icon","body":""},{"concept":"user","name":"UserIcon","body":""},{"concept":"settings","name":"Settings01Icon","body":""},{"concept":"bell","name":"Notification01Icon","body":""},{"concept":"heart","name":"FavouriteIcon","body":""},{"concept":"star","name":"StarIcon","body":""},{"concept":"calendar","name":"Calendar01Icon","body":""},{"concept":"mail","name":"Mail01Icon","body":""},{"concept":"message","name":"BubbleChatIcon","body":""},{"concept":"trash","name":"Delete02Icon","body":""},{"concept":"download","name":"Download01Icon","body":""},{"concept":"upload","name":"Upload01Icon","body":""},{"concept":"plus","name":"Add01Icon","body":""},{"concept":"check","name":"Tick02Icon","body":""},{"concept":"close","name":"Cancel01Icon","body":""},{"concept":"chevron-right","name":"ChevronRightIcon","body":""},{"concept":"arrow-right","name":"ArrowRight02Icon","body":""},{"concept":"menu","name":"Menu01Icon","body":""},{"concept":"filter","name":"FilterIcon","body":""},{"concept":"edit","name":"PencilEdit01Icon","body":""},{"concept":"lock","name":"SquareLock01Icon","body":""},{"concept":"eye","name":"ViewIcon","body":""},{"concept":"external-link","name":"ExternalLinkIcon","body":""}]},{"id":"remix","name":"Remix Icon","license":"Apache-2.0","url":"https://remixicon.com","note":"Line and fill twins across a broad catalog. Neutral, a touch heavier than the 2px sets.","grid":"24","weight":"filled paths","count":1539,"version":"4.9.1","viewBox":"0 0 24 24","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"home-line","body":""},{"concept":"search","name":"search-line","body":""},{"concept":"user","name":"user-line","body":""},{"concept":"settings","name":"settings-3-line","body":""},{"concept":"bell","name":"notification-2-line","body":""},{"concept":"heart","name":"heart-line","body":""},{"concept":"star","name":"star-line","body":""},{"concept":"calendar","name":"calendar-line","body":""},{"concept":"mail","name":"mail-line","body":""},{"concept":"message","name":"message-line","body":""},{"concept":"trash","name":"delete-bin-line","body":""},{"concept":"download","name":"download-line","body":""},{"concept":"upload","name":"upload-line","body":""},{"concept":"plus","name":"add-line","body":""},{"concept":"check","name":"check-line","body":""},{"concept":"close","name":"close-line","body":""},{"concept":"chevron-right","name":"arrow-right-s-line","body":""},{"concept":"arrow-right","name":"arrow-right-line","body":""},{"concept":"menu","name":"menu-line","body":""},{"concept":"filter","name":"filter-line","body":""},{"concept":"edit","name":"pencil-line","body":""},{"concept":"lock","name":"lock-line","body":""},{"concept":"eye","name":"eye-line","body":""},{"concept":"external-link","name":"external-link-line","body":""}]}]} diff --git a/picker/data/surfaces.js b/picker/data/surfaces.js new file mode 100644 index 000000000..6ec9afdaf --- /dev/null +++ b/picker/data/surfaces.js @@ -0,0 +1,256 @@ +/* + The four kinds of surface a design system can be asked to serve, straight from + the skill's modes, and the questions that are answered once for each of them. + + Values travel as `surface-modes` (multi-select) from screen 01b; the agent + pre-checks what PRODUCT.md suggests via cues.json, and the visitor corrects + it. Persuade is the markup default so the answer can never arrive empty on + runs whose cues carry no hint. +*/ +export const SURFACE_MODES = ['persuade', 'operate', 'read', 'experience']; + +/* + A question listed here is answered once per chosen surface rather than once + per run, because the answer that suits the marketing page rarely suits the + tool it sells. Each entry carries: + + - `allow`, the options that surface can take. Anything left out stays in the + list, turned off, with the option's own `data-blocked-reason` in place of + its description. Whether an option is out belongs to the option and not to + the pairing, so the reason is written once beside the copy it replaces. + Omitted where the question rules nothing out, which is not the same as + listing nothing, so the attribute is left off rather than left empty. + - `fallback`, what the surface lands on when nobody ever opens its tab. Every + chosen surface leaves an answer, so every surface needs one. Omitted where + the options are not known until the run deals them, and the first row on + the list stands in. + - `answered` and `unanswered`, what the tab tells a screen reader. `{}` is + where the chosen option's own label goes, lowercased so it reads as part of + the sentence around it. `properName` turns that off for a question whose + options are named after something rather than described. + - `flat`, for a question whose tabs are there to look with rather than to + split. The per-surface reading is still kept while the screen is open, so an + option a surface rules out cannot be the answer left behind, but only the + leading surface's choice is written down. A key per surface would promise + whoever reads the answers a distinction the run has nowhere to spend. + + A question that leaves a surface out of `surfaces` is not asked of that + surface at all, which is a stronger statement than withholding an option from + it. Withholding says the surface would answer this badly; leaving it out says + the surface has no stake in the question. So the tab strip only offers the + surfaces the question names, and a run whose surfaces are all left out never + sees the screen and records no answer for it. Motion and layout are scoped + that way today; the rest name all four. + + Persuade is allowed everything on every question: earning attention is the + whole job of the surface, and no answer here is too much for it. The other + three are ruled by what the surface is for rather than by how loud an option + is. A tool has to stay scannable, which rules out the loudest answers and, + on boundaries, the emptiest one as well. A page read at length is one plane + and one column. On a portfolio the work leads, so it is the interface that + has to recede; the page itself is still allowed to be dramatic, which is why + drenched survives there and four working colors do not. +*/ +export const SURFACE_ANSWERS = { + 'color-strategy': { + tablist: 'Surface being colored', + answered: 'colored {}', + unanswered: 'no color strategy chosen yet', + surfaces: { + persuade: { allow: 'restrained committed full-palette drenched', fallback: 'full-palette' }, + operate: { allow: 'restrained committed full-palette', fallback: 'full-palette' }, + read: { allow: 'restrained committed full-palette', fallback: 'full-palette' }, + // The one surface that cannot take four working colors, so it keeps the + // quietest answer its allow list has. + experience: { allow: 'restrained committed drenched', fallback: 'restrained' }, + }, + }, + + /* + Flat, and the only question here that rules nothing out. There is no pair + that suits a landing page and is forbidden on a dashboard; a pair that fails + the dashboard is a bad pair, and the model composing fonts.json is told to + rank all six against the strictest surface the run names. So no allow list + is written, and every row stays live on every tab. + + The tabs earn their place all the same. One pair is chosen and one type + system comes out of the run, and the thing a visitor cannot otherwise do is + see that pair set as a dashboard, as a document, and as an index before + committing to it. The strip here is a way of looking rather than a second + decision. + */ + 'font-pair': { + tablist: 'Surface the pair is shown on', + answered: 'set in {}', + unanswered: 'no pair chosen yet', + properName: true, + flat: true, + surfaces: { + persuade: {}, + operate: {}, + read: {}, + experience: {}, + }, + }, + + /* + One of the two questions that are not put to every surface. Motion energy is + a claim on attention, and only two of the four surfaces are in a position to + make one: a landing page earning a decision and a portfolio presenting work. + A tool and a document are moved through rather than watched, and their motion + follows from what the interface is doing rather than from a house style, so + asking them would collect an answer nothing should act on. An app-UI-only or + docs-only run therefore never reaches this screen. + + Both surfaces that are asked can take all three energies, so nothing is + withheld and no option carries a reason. Their defaults differ, because what + a surface does with movement when nobody says otherwise is the whole of what + it is for: a page earning a decision has to answer the pointer it is trying + to keep, and a portfolio stages the work's arrival, which is what a reveal + is for. + */ + 'motion-energy': { + tablist: 'Surface being moved', + answered: '{} movement', + unanswered: 'no motion energy chosen yet', + surfaces: { + persuade: { fallback: 'responsive' }, + experience: { fallback: 'choreographed' }, + }, + }, + + /* + The other question that is not put to every surface, and the only one that is + scoped and flat at once. How strict a layout feels is a claim about the + composition of the page itself, so it is asked of the two surfaces whose page + is the thing being looked at: one earning a decision, one presenting work. A + tool and a document are composed by what they have to hold instead, a rail + against a working column against a panel, one column at one measure, so the + question has nowhere to move on them and an answer taken there would be a + decision nobody made. An app-UI-only or docs-only run therefore never reaches + this screen either. + + Both surfaces that are asked can take all three answers, so nothing is + withheld and no option carries a reason. Their defaults differ: a page + earning a decision needs one block to lead, which is what the breaks in the + aligned grid are for, and a gallery hangs work square so the only irregular + edges on the page are the work's own. + + Flat, because one answer is all the run has to spend. The twelve-column ruler + on this screen describes a page, and it describes one whether the run ships a + dashboard or a gallery, so what the two of them leave is previewed on every + board the screens after it draw, the shell and the document included. A key + per surface would promise whoever reads the answers a distinction with + nowhere to land, and the tabs are left as a way of looking at the one answer + on both pages that asked for it. + */ + 'layout-structure': { + tablist: 'Surface being laid out', + answered: '{} layout', + unanswered: 'no layout structure chosen yet', + flat: true, + surfaces: { + persuade: { fallback: 'balanced' }, + experience: { fallback: 'simple-grid' }, + }, + }, + + /* + Open space is out on a tool. Operate asks for a ground of its own under + sidebars, toolbars, and panels, and the open answer is defined by every + ground being the same one. Its default is that second ground rather than + panels, which spend an edge and an inset on every object and buy density + back at the price of it. + + Cards and panels are out on the two surfaces whose content is the point. + A page read at length is one column, and a page of work is the work. + */ + 'boundary-style': { + tablist: 'Surface being separated', + answered: 'separated by {}', + unanswered: 'no boundary style chosen yet', + surfaces: { + persuade: { allow: 'open-space thin-dividers surface-changes cards-and-panels', fallback: 'open-space' }, + operate: { allow: 'thin-dividers surface-changes cards-and-panels', fallback: 'surface-changes' }, + read: { allow: 'open-space thin-dividers surface-changes', fallback: 'open-space' }, + experience: { allow: 'open-space thin-dividers surface-changes', fallback: 'open-space' }, + }, + }, + + /* + The least mode-sensitive question in the run, and the matrix says so. A + radius is not a claim on the reader's attention the way a color or a shadow + is, and the two surfaces that look like candidates both survive scrutiny: + fully round controls are the house style of an entire mobile platform, so + ruling them out of app UI would put this screen against Material rather + than against a mistake, and a document has too few shapes for the answer to + reach. Only the portfolio blocks anything, and its default is sharp, since + a gallery hangs work square and the work's own edges are the shapes on the + page. + */ + 'corner-style': { + tablist: 'Surface being shaped', + answered: '{} corners', + unanswered: 'no corner style chosen yet', + surfaces: { + persuade: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' }, + operate: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' }, + read: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' }, + experience: { allow: 'sharp slightly-soft friendly', fallback: 'sharp' }, + }, + }, + + /* + Floating is out wherever the page is worked in or read at length, which is + the line color strategy already drew for drenched. Lift that deep is how an + overlay says it is temporary; spent on every resting panel it stops meaning + anything and leaves a dense screen harder to scan. The portfolio keeps it: + lifting the work off the page is a way of presenting the work. + */ + 'depth-style': { + tablist: 'Surface being lifted', + answered: '{} depth', + unanswered: 'no depth style chosen yet', + surfaces: { + persuade: { allow: 'flat soft-lift floating', fallback: 'soft-lift' }, + operate: { allow: 'flat soft-lift', fallback: 'flat' }, + read: { allow: 'flat soft-lift', fallback: 'flat' }, + experience: { allow: 'flat soft-lift floating', fallback: 'flat' }, + }, + }, +}; + +/* The matrix reaches the browser on the surface tiles, which is already where + the script looks for everything a surface knows about itself. One pair of + attributes per question, read by name rather than by dataset key so the + question's own value is the lookup. A question this surface is not asked + contributes nothing, and the absent per-surface field is what the script + reads that from. */ +export const surfaceAttrs = (mode) => Object.fromEntries( + Object.entries(SURFACE_ANSWERS).flatMap(([name, question]) => { + const { allow, fallback } = question.surfaces[mode] ?? {}; + return [ + ...(allow ? [[`data-allow-${name}`, allow]] : []), + ...(fallback ? [[`data-default-${name}`, fallback]] : []), + ]; + }), +); + +/* Which surfaces a question is put to at all, in tile order. The per-surface + fields a screen renders are the browser's copy of this, and the leading + applicable surface is the one whose answer the bare key carries. */ +export const surfacesAsked = (name) => SURFACE_MODES.filter( + (mode) => mode in SURFACE_ANSWERS[name].surfaces, +); + +/* Everything the script needs to run a per-surface question is on its tab + strip, so a screen opts in by rendering one of these and nothing else. */ +export const surfaceTabsAttrs = (name) => ({ + 'data-surface-tabs': name, + 'data-surface-flat': SURFACE_ANSWERS[name].flat ? '' : undefined, + 'data-surface-proper-name': SURFACE_ANSWERS[name].properName ? '' : undefined, + 'data-surface-answered': SURFACE_ANSWERS[name].answered, + 'data-surface-unanswered': SURFACE_ANSWERS[name].unanswered, + role: 'tablist', + 'aria-label': SURFACE_ANSWERS[name].tablist, +}); diff --git a/picker/layouts/Picker.astro b/picker/layouts/Picker.astro new file mode 100644 index 000000000..997527b4b --- /dev/null +++ b/picker/layouts/Picker.astro @@ -0,0 +1,47 @@ +--- +import '../styles/picker.css'; +// Loaded after the shared sheet so a screen's own answers win on source order +// as well as specificity. One file per question, each owning only the slots +// its options rewrite. +import '../styles/screens/motion.css'; +import '../styles/screens/layout.css'; +import '../styles/screens/boundaries.css'; +import '../styles/screens/corners.css'; +import '../styles/screens/depth.css'; +// The layout screen's portfolio board, loaded after the three sheets above on +// purpose: it pins that board's material against their committed defaults by +// winning ties on source order. The header of the file has the argument. +import '../styles/screens/layout-portfolio.css'; +// The design context document that replaces the picker after submission. +import '../styles/design-context.css'; + +interface Props { + title?: string; + description?: string; +} + +const { + title = 'Impeccable design interview', + description = 'Choose the design direction your agent will use.', +} = Astro.props; +--- + + + + + + + + {title} + + + + + + + + + + diff --git a/picker/pages/index.astro b/picker/pages/index.astro new file mode 100644 index 000000000..33dfed1f2 --- /dev/null +++ b/picker/pages/index.astro @@ -0,0 +1,1708 @@ +--- +import Picker from '../layouts/Picker.astro'; +import Artboard from '../components/Artboard.astro'; +import QuestionScreen from '../components/QuestionScreen.astro'; +import iconPacks from '../data/icon-packs.json'; +import { SURFACE_MODES, surfaceAttrs, surfaceTabsAttrs } from '../data/surfaces.js'; + +const roles = [ + ['primary', 'Primary'], + ['secondary', 'Secondary'], + ['tertiary', 'Tertiary'], + ['neutral', 'Neutral'], +]; + +// Musical intervals, smallest step first. The ratio is the multiplier between +// one size and the next; the description says what that much contrast is for, +// since the number alone tells nobody which to pick. +const scales = [ + ['minor-second', 1.067, 'Minor Second', 'Almost no contrast. For dense tools where weight and color carry hierarchy instead of size.'], + ['major-second', 1.125, 'Major Second', 'A quiet step. Long documents and data screens that need many levels close together.'], + ['minor-third', 1.2, 'Minor Third', 'The workhorse. Clear order without shouting, and it survives on small screens.'], + ['major-third', 1.25, 'Major Third', 'Confident separation. A safe default when the page has both marketing and interface on it.'], + ['perfect-fourth', 1.333, 'Perfect Fourth', 'Real jumps between levels. Landing pages where the headline has to lead.'], + ['augmented-fourth', 1.414, 'Augmented Fourth', 'Sharp contrast. Editorial layouts built from few sizes used decisively.'], + ['perfect-fifth', 1.5, 'Perfect Fifth', 'Display type against small text, with little in between. Poster thinking.'], + ['golden-ratio', 1.618, 'Golden Ratio', 'The widest range here. Beautiful with one big statement, awkward with six levels.'], +]; + +// Exponent above the 16px base: P sits at the base, H1 six steps up. +const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6', 1], ['P', 0]]; + +// Only the row copy is rendered here. The glyphs are 109KB of markup for sets +// the run will use one of, so they are fetched when the screen first opens. +const iconRows = iconPacks.packs.map((pack) => ({ + id: pack.id, + name: pack.name, + license: pack.license, + url: pack.url, + note: pack.note, + meta: `${pack.grid} grid / ${pack.weight} / ${pack.license}`, +})); + +// The surface tiles and the per-surface answer matrix both live in +// data/surfaces.js, so the tiles carry what each surface allows without the +// matrix being restated on every screen that reads it. + +// The five questions that are answered by looking at a wireframe page. Each one +// only rewrites the artboard slots it owns, so by the last screen the page is +// showing every decision made before it. Motion is the one question whose answer +// cannot be read on a handset, so it takes the frame alone. +// +// All five draw a board per surface they are put to, which is four on +// boundaries, corners, and depth and a pair each on the two scoped questions, +// motion and layout. A structural answer is a claim about a kind of page, and the +// kinds are not the same shape: an app shell divides into a rail, a working +// column, and a panel, a document is a rail and a measure, an index is a run of +// entries. Repainting one landing page under four labels showed the same page +// four times and asked the visitor to imagine the rest. +// +// Which board is showing is read off `data-surface`, the attribute the component +// already publishes and the one the tab strip toggles, rather than a class per +// screen per surface. Screen 06 takes a class because its scenes are written per +// board; the rest ask the same question of every board and only need to know +// which anatomy is under them. +const boards = [ + { surface: 'persuade' }, + { surface: 'operate' }, + { surface: 'read' }, + { surface: 'experience' }, +]; + +const questions = [ + { + id: '06', + step: 'Motion', + slug: 'motion', + name: 'motion-energy', + title: 'How much movement should it have?', + legend: 'Motion energy', + cta: 'Select motion energy', + previewClass: 'picker-preview-motion', + phone: false, + // The two surfaces this question is put to, and the only screen where a + // surface answers on a page of its own kind: an energy is demonstrated by + // watching a page use it, and a landing page and a portfolio use movement + // for different ends. Repainting one wireframe would show the same scene + // twice under two labels. + boards: [ + { surface: 'persuade' }, + { surface: 'experience', class: 'picker-preview-motion--index' }, + ], + options: [ + { + value: 'restrained', + title: 'Restrained', + desc: 'State changes only. Things are already changed by the time you see them.', + }, + { + value: 'responsive', + title: 'Responsive', + desc: 'Feedback and transitions on whatever you touch. Nothing is orchestrated.', + }, + { + value: 'choreographed', + title: 'Choreographed', + desc: 'Entrances are staged and scrolling drives sequences.', + }, + ], + }, + { + id: '07', + step: 'Layout', + slug: 'layout', + name: 'layout-structure', + title: 'How strict should the layout feel?', + legend: 'Layout structure', + cta: 'Select layout structure', + previewClass: 'picker-strategy-preview picker-preview-layout', + // The two surfaces this question is put to, for the reason in + // data/surfaces.js: a landing page answers in the spans its hero and its + // card row take and a portfolio in how each entry splits between the work + // and its caption, where a shell and a document are composed by what they + // hold. The answer is one value all the same, and the boards on the screens + // after this one show it on all four pages. + boards: [ + { surface: 'persuade' }, + { surface: 'experience' }, + ], + options: [ + { + value: 'simple-grid', + title: 'Simple grid', + desc: 'Everything lines up. Equal columns and one rhythm down the page.', + }, + { + value: 'balanced', + title: 'Balanced', + desc: 'An aligned grid with a few deliberate breaks that carry the emphasis.', + }, + { + value: 'freeform', + title: 'Freeform', + desc: 'Blocks step out of the grid and overlap where it pays off.', + }, + ], + }, + { + id: '08', + step: 'Boundaries', + slug: 'boundaries', + name: 'boundary-style', + title: 'How should sections be separated?', + legend: 'Boundary style', + cta: 'Select boundary style', + previewClass: 'picker-preview-boundaries', + boards, + options: [ + { + value: 'open-space', + title: 'Open space', + desc: 'Nothing between sections but room. Spacing does the dividing.', + blocked: 'Sidebars and toolbars need a ground of their own, which is the one thing air cannot give them.', + }, + { + value: 'thin-dividers', + title: 'Thin dividers', + desc: 'A hairline marks where one section ends and the next starts.', + }, + { + value: 'surface-changes', + title: 'Surface changes', + desc: 'Bands of tone. Sections are told apart by the ground they sit on.', + }, + { + value: 'cards-and-panels', + title: 'Cards and panels', + desc: 'Content sits inside containers with edges you can see.', + blocked: 'An edge around every block comes between people and the thing they came for.', + }, + ], + }, + { + id: '09', + step: 'Corners', + slug: 'corners', + name: 'corner-style', + title: 'How round should shapes be?', + legend: 'Corner style', + cta: 'Select corner style', + previewClass: 'picker-preview-corners', + boards, + options: [ + { + value: 'sharp', + title: 'Sharp', + desc: 'Square corners throughout. Precise, drafted, architectural.', + }, + { + value: 'slightly-soft', + title: 'Slightly soft', + desc: 'Just enough radius to take the edge off without being noticed.', + }, + { + value: 'friendly', + title: 'Friendly', + desc: 'Cards, buttons, and images are clearly rounded.', + }, + { + value: 'pill', + title: 'Pill-like', + desc: 'Buttons and tags go fully round.', + blocked: 'Fully round controls are the loudest shape on a page where the work should lead.', + }, + ], + }, + { + id: '10', + step: 'Depth', + slug: 'depth', + name: 'depth-style', + title: 'How much depth should the surface have?', + legend: 'Depth style', + cta: 'Select depth style', + previewClass: 'picker-preview-depth', + boards, + options: [ + { + value: 'flat', + title: 'Flat', + desc: 'One plane. Nothing casts a shadow.', + }, + { + value: 'soft-lift', + title: 'Soft lift', + desc: 'Cards and controls sit a little off the page.', + }, + { + value: 'floating', + title: 'Floating', + desc: 'Panels are well above the surface, with the lift to prove it.', + blocked: 'Lift this deep belongs to menus and dialogs, not to a page people work in or read at length.', + }, + ], + }, +]; +--- + + +
+ + +
+
+
+
+
+ + Impeccable +
+ +

Let's build your
design system.

+ +
+ +
+ +
+ +

Arrow keys to move

+
+
+
+
+ + + + + + + + + + + + {questions.filter((question) => question.id === '07').map((question) => )} + + {/* Screen 04b: the configure hub. Every remaining question already + holds an answer (defaults are pre-filled the moment a surface tile + is checked), so the run can end here. The cards are skeletons; + summaries and edited marks are written by renderHub() in + scripts/palette-picker.js on every arrival. Placement is the + approved masonry: tall per-surface cards anchor the outer columns. */} + + + {questions.filter((question) => question.id !== '07').map((question) => )} + + + + + + + +
+ + {/* Design context document — hidden until the review screen saves the + answers. Mosaic, morph, and article vocabulary ported unchanged from + docs/design-context-categorization/design-context.html; the detail + templates start empty and are filled from the interview by + scripts/design-context.js. */} + + + {/* Detail articles: empty shells filled per run by design-context.js. */} + + + + + + + + + + {/* Expander shell: sidebar + topbar + main, cloned per open. */} + + + {/* Edit-request modal: complex changes the document cannot apply itself + are written up here and queued for the agent over the doc session. */} + +
+
+

Request a change

+

Describe the change; the agent applies it to the design document and the page updates here.

+
+
+ Change + + + +
+
+ + +
+
+
+ + {/* Live edit tray: every queued request with its status, fed by polling. */} + + + +
+
+

Add a custom font

+

Link a hosted stylesheet or upload a face. Nothing is checked until you finish.

+
+ + {[['heading', 'Heading'], ['body', 'Body']].map(([role, label]) => ( +
+ {label} + + +

or

+
+
+ + +
+ +
+
+ ))} + +

+ +

+ +
+ + +
+
+
+ + + + +
diff --git a/picker/scripts/color.js b/picker/scripts/color.js new file mode 100644 index 000000000..8766ac2d9 --- /dev/null +++ b/picker/scripts/color.js @@ -0,0 +1,154 @@ +const clamp = (value) => Math.min(1, Math.max(0, value)); +const linearize = (value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +const gamma = (value) => value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; +const parseHex = (hex) => hex.match(/[\da-f]{2}/gi).map((value) => Number.parseInt(value, 16) / 255); + +function toLinearRgb([L, C, H]) { + const angle = H * Math.PI / 180; + const a = C * Math.cos(angle); + const b = C * Math.sin(angle); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; +} + +export function oklchToHex([lightness, chroma, hue]) { + const L = clamp(lightness); + let C = Math.max(0, chroma); + let rgb = toLinearRgb([L, C, hue]); + while (C > 0 && rgb.some((channel) => channel < 0 || channel > 1)) { + C = Math.max(0, C - 0.005); + rgb = toLinearRgb([L, C, hue]); + } + return `#${rgb.map((channel) => Math.round(clamp(gamma(channel)) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`; +} + +export function hexToOklch(hex) { + const [red, green, blue] = parseHex(hex).map(linearize); + const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue); + const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue); + const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + const C = Math.hypot(a, b); + return [L, C, C < 0.00001 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360]; +} + +export function formatOklch(hex) { + const [L, C, H] = hexToOklch(hex); + return `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)})`; +} + +/** Expand one curated seed into the four questionnaire roles. */ +export function seedToRoles(seed) { + const [L, C, H] = seed.oklch; + return { + primary: oklchToHex([L, C, H]), + secondary: oklchToHex([L < 0.5 ? L + 0.18 : L - 0.18, C * 0.6, H]), + tertiary: oklchToHex([0.62, Math.min(0.23, Math.max(C, 0.15)), (H + 60) % 360]), + neutral: oklchToHex([L < 0.55 ? 0.96 : 0.2, 0.01, H]), + }; +} + +// Relative luminance of the colors --pk-ink-dark and --pk-ink-light resolve +// to in styles/picker.css: oklch(14% 0.018 95) and oklch(99% 0.008 95). +const INK_DARK_LUMINANCE = 0.0027; +const INK_LIGHT_LUMINANCE = 0.9716; + +// The ink sits on a color the user picked, so it must not follow the picker's +// own theme: --ks-champagne and friends invert between light and dark and +// would blank the label on exactly the swatches that need it most. Comparing +// both ratios beats a fixed lightness threshold, which picks the losing ink +// for mid-tones sitting near the cutoff. +export function contrastInk(hex) { + const swatch = relativeLuminance(hex); + const against = (ink) => ratio(ink, swatch); + return against(INK_DARK_LUMINANCE) >= against(INK_LIGHT_LUMINANCE) + ? 'var(--pk-ink-dark)' + : 'var(--pk-ink-light)'; +} + +/** Hex counterpart to contrastInk(), for readableOn() grounds that are CSS vars. */ +export function contrastInkHex(hex) { + return contrastInk(hex).includes('light') + ? oklchToHex([0.99, 0.008, 95]) + : oklchToHex([0.14, 0.018, 95]); +} + +function relativeLuminance(hex) { + const [red, green, blue] = parseHex(hex).map(linearize); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function ratio(a, b) { + const [hi, lo] = a > b ? [a, b] : [b, a]; + return (hi + 0.05) / (lo + 0.05); +} + +/** + * The accent as type rather than as a fill. A color picked because it looks + * right as a shape on the page is not thereby readable as words on it, and a + * pale primary on paper is the common way that goes wrong. Only lightness + * moves, so the hue the user chose is the hue that shows up; a color that + * already clears the target comes back untouched. + */ +export function readableOn(accent, ground, target = 4.5) { + const groundLuminance = relativeLuminance(ground); + if (ratio(relativeLuminance(accent), groundLuminance) >= target) return accent; + const [L, C, H] = hexToOklch(accent); + // Toward whichever end of the range the ground leaves room in, by the same + // comparison contrastInk makes rather than a fixed lightness cutoff. + const darker = ratio(INK_DARK_LUMINANCE, groundLuminance) >= ratio(INK_LIGHT_LUMINANCE, groundLuminance); + const step = darker ? -0.015 : 0.015; + for (let lightness = L + step; lightness > 0.03 && lightness < 1; lightness += step) { + const candidate = oklchToHex([lightness, C, H]); + if (ratio(relativeLuminance(candidate), groundLuminance) >= target) return candidate; + } + return darker ? '#000000' : '#FFFFFF'; +} + +// Floors for judging the neutral on screen 02. The neutral paints the large +// surfaces of every preview, so two pairs matter: the fixed swatch inks that +// set text on it, and the primary fills that sit on it. +const NEUTRAL_INK_FLOOR = 7; +const NEUTRAL_PRIMARY_FLOOR = 3; + +/** + * Plain-language warnings when the palette's neutral will cause contrast + * trouble: an array with one line per failed check, empty when it is safe. + * The checks run independently, so a neutral that fails both reports both, + * mid-tone first. + * + * Check 1: the better of the two fixed inks must reach 7:1 (the WCAG AAA + * body-text figure) on the neutral. The inks are near-black and near-white, + * so 4.5:1 is nearly impossible to fail; 7:1 is the floor that catches + * mid-tone neutrals which leave no headroom for muted and secondary text. + * + * Check 2: the primary must reach 3:1 (WCAG 1.4.11 non-text contrast) + * against the neutral, because primary button fills and accents sit directly + * on neutral surfaces and readableOn() only rescues text, never fills. + * + * The strings are read by people configuring a palette, not by developers: + * no ratios, no standards names. Step 8's test matches on them. + */ +export function neutralContrastIssue({ neutral, primary }) { + const surface = relativeLuminance(neutral); + const bestInk = Math.max( + ratio(INK_DARK_LUMINANCE, surface), + ratio(INK_LIGHT_LUMINANCE, surface), + ); + const issues = []; + if (bestInk < NEUTRAL_INK_FLOOR) { + issues.push('This background is too close to a middle gray, so text on it will be hard to read. Try a much lighter or much darker color.'); + } + if (ratio(relativeLuminance(primary), surface) < NEUTRAL_PRIMARY_FLOOR) { + issues.push('Your main color and this background are too similar, so buttons and cards will blend in. Try more difference between them.'); + } + return issues; +} diff --git a/picker/scripts/design-context.js b/picker/scripts/design-context.js new file mode 100644 index 000000000..1505dcc92 --- /dev/null +++ b/picker/scripts/design-context.js @@ -0,0 +1,1359 @@ +/* Design context document — the questionnaire's final act. + * + * When the run reaches the review screen this module saves the answers, then + * swaps the picker for the eight-category design context document. The mosaic + * landing, tile-to-fullscreen morph, sidebar shell, and article vocabulary are + * ported unchanged from docs/design-context-categorization/design-context.html; + * what changed is the content: the prototype rendered one example project, this + * renders the interview that just ended. Everything is assembled client-side + * before the POST resolves, because the server's exit on /submit is the + * completion signal the agent waits on — after it there is nothing to fetch. + */ + +import { contrastInk, contrastInkHex, formatOklch, readableOn } from './color.js'; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +const form = $('#picker-form'); +const shell = $('[data-dcx-shell]'); + +/* Seed context (the chat half of the interview) and the dealt palettes ride + in on cues.json. Fetched at load, before the server can exit: the context + block feeds the chat-sourced pages, and the palette map is what the + provenance tags compare committed values against. */ +let seedContext = null; +let seedModes = null; +let seedPalettes = null; +fetch('/cues.json') + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + seedContext = data?.context || null; + seedModes = Array.isArray(data?.modes) ? data.modes : null; + seedPalettes = data?.palette || null; + }) + .catch(() => {}); + +/* The winning cue's dealt value for one role, read the way the deck's own + createState reads it in palette-picker.js: the pixel-snapped value when + the search landed, the planned hex otherwise, uppercased. A palette + source that is not a cue in cues.json (a seed-deck card, a custom + palette) has no entry here and returns nothing, which is what turns the + provenance tag off. */ +const seedHexFor = (source, role) => { + const slot = seedPalettes?.[source]?.[role]; + if (!slot) return ''; + return String(slot.snapped || slot.hex || '').toUpperCase(); +}; + +/* ============================================================ + Snapshot — everything the document renders, read once. + ============================================================ */ + +const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; +const SURFACE_ORDER = ['persuade', 'operate', 'read', 'experience']; +const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' }; +const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style']; + +const fieldValue = (name) => { + const field = form.elements[name]; + return field && typeof field.value === 'string' ? field.value : ''; +}; + +/* The option copy is already on the page, on the radios the user answered + with, so the document quotes the screens instead of keeping a second copy + of every title and description. */ +function optionCopy(name, value) { + const input = form.querySelector(`input[name="${name}"][value="${value}"]`); + const label = input?.closest('label'); + if (!label) return { title: value, desc: '' }; + const title = label.querySelector('.picker-strategy-title, .picker-icon-title'); + const desc = label.querySelector('.picker-strategy-desc, .picker-icon-meta'); + return { + title: (title?.textContent || value).replace(/^[\d.]+\s*/, '').trim(), + desc: (desc?.textContent || '').trim(), + }; +} + +function chosenSurfaces() { + return $$('input[name="surface-modes"]:checked', form) + .sort((a, b) => SURFACE_ORDER.indexOf(a.value) - SURFACE_ORDER.indexOf(b.value)) + .map((input) => { + const tile = input.closest('.picker-mode-tile'); + return { + mode: input.value, + label: input.dataset.surfaceLabel || input.value, + goal: tile?.querySelector('.picker-mode-goal')?.textContent.trim() || '', + examples: $$('.picker-mode-pills i', tile || form).map((pill) => pill.textContent.trim()), + }; + }); +} + +/* Per-surface answers: the base key holds the leading surface, and each chosen + surface has its own hidden field, marked data-chosen when the user actually + visited it rather than inheriting the default for its kind. + + A question is not always put to every surface, and the field it rendered is + which: a surface with nowhere to answer was never asked, so it is left out + rather than shown holding the leading surface's pick. An empty list means the + run never saw the screen at all. */ +function perSurface(name, surfaces) { + return surfaces.flatMap((surface) => { + const field = form.querySelector(`input[data-surface-field="${name}-${surface.mode}"]`); + if (!field) return []; + const value = field.value || fieldValue(name); + return [{ + ...surface, + value, + chosen: field.dataset.chosen === 'yes', + ...optionCopy(name, value), + }]; + }); +} + +/* A flat question keeps one answer rather than one per surface, and its fields + are still what says whether it was put at all: none of the chosen surfaces + holding one means the run never saw the screen. The leading applicable surface + owns the answer, which is the rule the bare key in answers.json is written by + too. */ +function flatAnswer(name, surfaces) { + const [leader] = perSurface(name, surfaces); + return leader ?? { value: '', title: '', desc: '' }; +} + +function takeSnapshot() { + const surfaces = chosenSurfaces(); + const palette = ROLES.map((role) => ({ + role: role[0].toUpperCase() + role.slice(1), + hex: fieldValue(`palette-${role}`).toUpperCase(), + })).filter((entry) => entry.hex); + + const scaleInput = form.querySelector('input[name="type-scale"]:checked'); + const pairCard = form.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option'); + + return { + context: seedContext, + suggestedModes: seedModes, + surfaces, + palette, + paletteSource: fieldValue('palette-source'), + strategy: perSurface('color-strategy', surfaces), + boundaries: perSurface('boundary-style', surfaces), + corners: perSurface('corner-style', surfaces), + depth: perSurface('depth-style', surfaces), + motion: perSurface('motion-energy', surfaces), + layout: flatAnswer('layout-structure', surfaces), + fonts: { + heading: fieldValue('font-heading'), + body: fieldValue('font-body'), + headingSource: fieldValue('font-heading-source'), + bodySource: fieldValue('font-body-source'), + why: pairCard?.querySelector('[data-pair-why]')?.textContent.trim() || '', + }, + scale: { + name: scaleInput?.dataset.scaleName || '', + ratio: Number(fieldValue('type-scale-ratio') || scaleInput?.dataset.ratio || 0), + desc: scaleInput ? optionCopy('type-scale', scaleInput.value).desc : '', + }, + icons: { + pack: fieldValue('icon-pack-name'), + license: fieldValue('icon-pack-license'), + url: fieldValue('icon-pack-url'), + }, + }; +} + +/* ============================================================ + Article builders — the prototype's block vocabulary, filled + from the snapshot. Every builder returns innerHTML for one + dcx-detail template. + ============================================================ */ + +const escapeHtml = (value) => String(value) + .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); + +const heading = (index, title, lede, productName) => ` +
+ Design context · 0${index} / 08${productName ? ` · ${escapeHtml(productName)}` : ''} +

${escapeHtml(title)}

+

${escapeHtml(lede)}

+
`; + +const block = (label, inner) => ` +
+ ${escapeHtml(label)} + ${inner} +
`; + +const defs = (items) => ` +
${items.map(({ dt, dd }) => ` +
${dt}
${dd}
`).join('')} +
`; + +const callout = (name, body, accent = false, extra = '') => ` +
+

${escapeHtml(name)}

+

${body}

${extra} +
`; + +const list = (items) => ` +
    ${items.map((item) => `
  • ${item}
  • `).join('')}
`; + +const principlesList = (items) => ` +
    ${items.map((item) => { + if (item && typeof item === 'object' && item.title) { + return `
  1. ${escapeHtml(item.title)}${item.detail ? ` — ${escapeHtml(item.detail)}` : ''}
  2. `; + } + return `
  3. ${escapeHtml(item)}
  4. `; + }).join('')}
`; + +const chips = (items) => ` +
${items.map((item) => `${escapeHtml(item)}`).join('')}
`; + +const empty = (title, body) => ` +
+

${escapeHtml(title)}

+

${body}

+
`; + +const note = (text) => `

${text}

`; + +/* Chat-round material renders when the agent passed it along, and says where + it lives when it did not — an interview that skipped a question is a fact + the document reports, not a gap it papers over. */ +const fromChat = (what, home) => empty( + 'Captured in chat', + `${what} in chat, before the browser questionnaire. ${home} is the durable copy.`, +); + +/* Readable ink for a fan panel, from the swatch's own luminance. */ +function inkFor(hex) { + const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255); + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return lum > 0.55 ? 'oklch(20% 0.01 95)' : 'oklch(95% 0.005 95)'; +} + +/* ============================================================ + Proofs — the questionnaire's own drawings, borrowed. + + The form never leaves the DOM, only the flow: every preview the + questionnaire painted (mode tiles, strategy artboards, the type + scale sheet, the icon grid) is still standing behind the document, + inline variables and all. A detail view that wants to show a + decision clones the drawing that sold it instead of describing it. + ============================================================ */ + +/* The committed palette under the --pkc-* names the strategy remaps read, + mirroring palette-picker's syncCommittedPalette for nodes that live + outside the strategy stage. */ +function paintCommitted(node) { + const colors = {}; + for (const role of ROLES) colors[role] = fieldValue(`palette-${role}`); + if (Object.values(colors).some((hex) => !hex)) return; + const set = (name, value) => node.style.setProperty(`--pkc-${name}`, value); + for (const role of ROLES) set(role, colors[role]); + set('n-ink', contrastInk(colors.neutral)); + set('p-ink', contrastInk(colors.primary)); + set('t-ink', contrastInk(colors.tertiary)); + set('p-on-n', readableOn(colors.primary, colors.neutral)); + set('t-on-n', readableOn(colors.tertiary, colors.neutral)); + set('t-on-p', readableOn(colors.tertiary, colors.primary)); + set('p-on-p', readableOn(colors.primary, colors.primary)); + set('t-on-t', readableOn(colors.tertiary, colors.tertiary)); + set('p-on-i', readableOn(colors.primary, contrastInkHex(colors.primary))); +} + +/* Clone one questionnaire drawing into a dcx frame. Inline styles travel + with the clone; ids do not (they would collide with the originals). */ +function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {}) { + if (!source) return ''; + const clone = source.cloneNode(true); + clone.hidden = false; + clone.removeAttribute('data-surface'); + for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id'); + for (const node of $$('button, input', clone)) { + node.setAttribute('tabindex', '-1'); + node.setAttribute('disabled', ''); + } + const wrap = document.createElement('div'); + wrap.className = `dcx-proof dcx-proof--${kind}`; + wrap.setAttribute('aria-hidden', 'true'); + if (strategy) wrap.dataset.dcxStrategy = strategy; + /* Committed structural answers, restated on the frame. On the screens they + reach a carried artboard through checked inputs inside #picker-form, + which a clone in the document is outside of, so design-context.css + mirrors those bodies against these attributes instead. */ + if (marks) { + for (const [key, value] of Object.entries(marks)) { + if (value) wrap.setAttribute(`data-dcx-${key}`, value); + } + } + paintCommitted(wrap); + wrap.appendChild(clone); + return wrap.outerHTML; +} + +/* The strategy screen mounts one painted artboard per chosen surface and + leaves them standing; the tile previews on the surfaces screen never move + at all. Both are lookups, not rebuilds. */ +const surfaceBoard = (mode) => $(`[data-surface-stage] [data-surface="${mode}"]`); +const surfaceTilePreview = (mode) => $(`input[name="surface-modes"][value="${mode}"]`) + ?.closest('.picker-mode-tile')?.querySelector('.picker-preview'); + +/* The boundaries screen keeps one carried ps artboard per surface, and by + that screen the drawing is the page as chosen so far. It is the clone + source for any proof that wants the whole material story on one page. */ +const carriedBoard = (mode) => $(`[data-question="boundaries"] .picker-artboard[data-surface="${mode}"]`); + +/* One surface's committed structural answers, as the data-dcx-* marks the + document stylesheet keys its mirrored bodies on. Layout is one answer for + the whole run, so every surface's frame carries the same value. */ +const materialMarks = (s, mode) => ({ + surface: mode, + boundary: s.boundaries.find((entry) => entry.mode === mode)?.value || '', + corner: s.corners.find((entry) => entry.mode === mode)?.value || '', + depth: s.depth.find((entry) => entry.mode === mode)?.value || '', + layout: s.layout.value || '', +}); + +/* One line per surface for the per-surface questions, marking whether the + user configured the surface or it kept the default for its kind. */ +const surfaceDefs = (entries) => defs(entries.map((entry) => ({ + dt: escapeHtml(entry.label), + dd: `${escapeHtml(entry.title)} · ${escapeHtml(entry.desc)}${entry.chosen ? '' : ' (default for this surface)'}`, +}))); + +function buildAudience(s, name) { + const audience = s.context?.audience || {}; + const parts = [heading(1, 'Audience', 'Who it is for, emotional state, needs, trust triggers.', name)]; + const who = [ + audience.primary && { dt: 'Primary', dd: escapeHtml(audience.primary) }, + audience.secondary && { dt: 'Secondary', dd: escapeHtml(audience.secondary) }, + ].filter(Boolean); + parts.push(block('Who they are', who.length + ? defs(who) + : fromChat('The primary and secondary user read was confirmed', 'PRODUCT.md · Users'))); + /* Arrival-only context keeps the old single-callout block; a leaving line + widens it into the two-beat journey, side by side. */ + if (audience.emotion || audience.leaving) { + const arrival = audience.emotion ? callout('On arrival', escapeHtml(audience.emotion), true) : ''; + const leaving = audience.leaving ? callout('Leaving with', escapeHtml(audience.leaving), true) : ''; + if (arrival && leaving) { + parts.push(block('Emotional journey', `
${arrival}${leaving}
`)); + } else { + parts.push(block(arrival ? 'Emotional state' : 'Emotional journey', arrival || leaving)); + } + } + /* Needs and trust triggers share a two-column row when both exist; either + alone keeps the full measure. The nested blocks keep their data-label, + which is what the expander subnav is built from. */ + const needsBlock = Array.isArray(audience.needs) && audience.needs.length + ? block('Needs', list(audience.needs.map(escapeHtml))) : ''; + const trustBlock = Array.isArray(audience.trust) && audience.trust.length + ? block('Trust triggers', list(audience.trust.map(escapeHtml))) : ''; + if (needsBlock && trustBlock) parts.push(`
${needsBlock}${trustBlock}
`); + else if (needsBlock || trustBlock) parts.push(needsBlock || trustBlock); + if (Array.isArray(audience.inclusion) && audience.inclusion.length) { + parts.push(block('Who must not be excluded', list(audience.inclusion.map(escapeHtml)))); + } + return parts.join(''); +} + +/* A surface card in the questionnaire's own anatomy: the tile's dual-artboard + drawing on top, dressed in the committed palette, then the label and goal + the tile carried. */ +function surfaceCards(s, body) { + return `
${s.surfaces.map((surface, index) => ` +
+ ${proofHtml(surfaceBoard(surface.mode) || surfaceTilePreview(surface.mode), { kind: 'board' })} +
+

${escapeHtml(surface.label)}${index === 0 ? ' leading' : ''}

+ ${body(surface)} +
+
`).join('')}
`; +} + +function surfaceProvenanceNote(s) { + if (!Array.isArray(s.suggestedModes) || !s.suggestedModes.length) return ''; + const chosen = s.surfaces.map((surface) => surface.mode); + const suggestedSet = new Set(s.suggestedModes); + const chosenSet = new Set(chosen); + const label = (mode) => s.surfaces.find((surface) => surface.mode === mode)?.label + || SURFACE_LABELS[mode] + || mode; + const added = chosen.filter((mode) => !suggestedSet.has(mode)); + const removed = s.suggestedModes.filter((mode) => !chosenSet.has(mode)); + let tail = 'you kept the suggested set'; + if (added.length && removed.length) { + tail = `you added ${added.map(label).join(', ')} and dropped ${removed.map(label).join(', ')}`; + } else if (added.length) { + tail = `you added ${added.map(label).join(', ')}`; + } else if (removed.length) { + tail = `you dropped ${removed.map(label).join(', ')}`; + } + return note(`Suggested from PRODUCT.md: ${s.suggestedModes.map(label).join(', ')}; ${tail}.`); +} + +/* Display labels for the PRODUCT.md platform value; an unrecognized value + renders as written rather than being dropped. */ +const PLATFORM_LABELS = { web: 'Web', ios: 'iOS', android: 'Android', adaptive: 'Adaptive' }; + +function buildProduct(s, name) { + const product = s.context?.product || {}; + const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)]; + const purposeCallout = product.purpose + ? callout(product.name || name || 'This product', escapeHtml(product.purpose), false, + product.success ? `\n

${escapeHtml(product.success)}

` : '') + : fromChat('The purpose and success definition were confirmed', 'PRODUCT.md · Product Purpose'); + const platform = typeof product.platform === 'string' && product.platform.trim() + ? `${escapeHtml(PLATFORM_LABELS[product.platform.trim()] || product.platform.trim())}` + : ''; + parts.push(block('Purpose', platform + ? `
${purposeCallout}
${platform}
` + : purposeCallout)); + if (product.positioning && (product.positioning.not || product.positioning.this)) { + const cells = [ + product.positioning.not && callout('Not this', escapeHtml(product.positioning.not)), + product.positioning.this && callout('This', escapeHtml(product.positioning.this), true), + ].filter(Boolean).join(''); + parts.push(block('Positioning', `
${cells}
`)); + } + if (product.conversion) { + parts.push(block('Primary conversion', callout('The one action', escapeHtml(product.conversion), true))); + } + if (Array.isArray(product.clarities) && product.clarities.length) { + parts.push(block('What must be clear first', list(product.clarities.map(escapeHtml)))); + } + if (Array.isArray(product.principles) && product.principles.length) { + parts.push(block('Product principles', principlesList(product.principles))); + } + if (product.operatingContext) { + parts.push(block('Operating context', `

${escapeHtml(product.operatingContext)}

`)); + } + const surfaceMap = product.surfaces && typeof product.surfaces === 'object' ? product.surfaces : {}; + parts.push(block('Surfaces', surfaceCards(s, (surface) => { + const specific = typeof surfaceMap[surface.mode] === 'string' ? surfaceMap[surface.mode].trim() : ''; + return ` +

${escapeHtml(specific || surface.goal || '')}

+ ${surface.examples.length ? chips(surface.examples) : ''}`; + }) + + surfaceProvenanceNote(s) + + note('Chosen on the questionnaire’s first screen, drawn in the committed palette; the leading surface owns every bare answer key in the sections that follow.'))); + return parts.join(''); +} + +function buildBrand(s, name) { + const brand = s.context?.brand || {}; + const interview = s.context?.interview || {}; + const parts = [heading(3, 'Brand', 'Identity, voice, references, taste boundaries.', name)]; + /* Personality: the confirmed sentence when the agent passed it; the three + words alone over the pointer to the durable copy when only they arrived; + the plain pointer otherwise. */ + parts.push(block('Personality', brand.personality + ? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true) + : (Array.isArray(brand.words) && brand.words.length + ? callout(brand.words.join(' · '), 'Three words, voice, and tone were confirmed in chat, before the browser questionnaire. PRODUCT.md · Brand Personality is the durable copy.', true) + : fromChat('Three words, voice, and tone were confirmed', 'PRODUCT.md · Brand Personality')))); + /* Voice: say / not wording pairs the agent derived from Brand Personality + and Brand Commitments at cues-write time. Concrete lines to write with, + never adjectives, and no interview question stands behind the field. + Pairs missing either half are dropped rather than rendered lopsided. */ + const voicePairs = (Array.isArray(brand.voice) ? brand.voice : []) + .filter((pair) => pair && typeof pair === 'object' && pair.say && pair.not); + if (voicePairs.length) { + parts.push(block('Voice', `
${voicePairs.map((pair) => ` +
+
Say

${escapeHtml(pair.say)}

+
Not

${escapeHtml(pair.not)}

+
`).join('')}
` + + note('Derived from Brand Personality and Brand Commitments in PRODUCT.md: wording to write with beside wording to refuse.'))); + } + /* Principles: PRODUCT.md's own list, in the prototype's numbered anatomy, + folded into two columns. */ + if (Array.isArray(brand.principles) && brand.principles.length) { + parts.push(block('Principles', `
    ${brand.principles.map((line) => `
  1. ${escapeHtml(line)}
  2. `).join('')}
` + + note('From PRODUCT.md’s principles section; the durable copy lives there.'))); + } + if (Array.isArray(brand.commitments) && brand.commitments.length) { + parts.push(block('Commitments', list(brand.commitments.map(escapeHtml)))); + } + if (Array.isArray(interview.references) && interview.references.length) { + /* Q4 references arrive as plain strings from old cues.json files and as + { name, takeaway } objects from new ones; a mixed list renders each + entry in its own form. Strings stay the bare pills they were. */ + const cards = interview.references.filter((ref) => ref && typeof ref === 'object' && ref.name); + const plain = interview.references.filter((ref) => typeof ref === 'string'); + const inner = (cards.length ? `
${cards.map((ref) => ` +
+

${escapeHtml(ref.name)}

${ref.takeaway ? ` +

${escapeHtml(ref.takeaway)}

` : ''} +
`).join('')}
` : '') + (plain.length ? chips(plain) : ''); + parts.push(block('Named references', inner + + note('Q4 of the seed interview: brands, products, printed objects — not adjectives.'))); + } + if (interview.antiReference) { + /* Q5 stays a bare string in old files and gains a why as { name, why }. */ + const anti = typeof interview.antiReference === 'object' + ? callout('Not this', `${escapeHtml(interview.antiReference.name || '')}${ + interview.antiReference.why ? ` · ${escapeHtml(interview.antiReference.why)}` : ''}`) + : callout('Not this', escapeHtml(interview.antiReference)); + parts.push(block('Anti-reference', anti + + note('Q5 of the seed interview. A hard constraint on every palette and pair that followed.'))); + } + if (Array.isArray(s.context?.assets) && s.context.assets.length) { + parts.push(block('Assets provided', list(s.context.assets.map(escapeHtml)) + + note('Gathered before the interview; the questions were grounded in what they showed.'))); + } + return parts.join(''); +} + +/* The role descriptions the palette screen taught with, reused so the board + reads like the screen that made the decision. */ +const ROLE_STORY = { + Primary: 'Your main brand color: buttons, links, the color people remember.', + Secondary: 'Supports the primary: section accents, hovers, secondary buttons.', + Tertiary: 'The rare accent: badges, highlights, one detail per screen.', + Neutral: 'Backgrounds and large surfaces: most of every page.', +}; + +function buildColor(s, name) { + const interview = s.context?.interview || {}; + const parts = [heading(4, 'Color', 'Palette, roles, per-surface strategy, copyable values.', name)]; + if (s.palette.length) { + const step = 100 / (s.palette.length + 1); + const fan = s.palette.map((entry, index) => ` + `).join(''); + parts.push(block('Palette', `
${fan}
` + + note(`Committed on the palette screen${s.paletteSource ? ` from the ${escapeHtml(s.paletteSource)} cue` : ''}, roles in the order you arranged them. Hover to fan; click to copy the hex.`))); + + /* One full-width band per role: the swatch at real size with both value + notations, the role's job, the ink that survives on it, where the value + came from, and a copy affordance. The provenance tag compares the + committed value against the winning cue's dealt value and stays away + when the source is not a cue; the ink chip is a pure derivation and + always renders. */ + const colorContext = s.context?.color || {}; + parts.push(block('Roles and values', `
${s.palette.map((entry) => { + const inkHex = contrastInkHex(entry.hex); + const seedHex = seedHexFor(s.paletteSource, entry.role.toLowerCase()); + const provenance = seedHex ? (seedHex === entry.hex ? 'as dealt' : 'edited') : ''; + return ` +
+ +
+

${escapeHtml(entry.role)}

+

${escapeHtml(ROLE_STORY[entry.role] || '')}

+ ${escapeHtml(formatOklch(entry.hex))} + Ink${inkHex} + + + + +
+
`; + }).join('')}
` + + (Array.isArray(colorContext.assetLocks) && colorContext.assetLocks.length + ? note(`From the assets: ${colorContext.assetLocks.map(escapeHtml).join(' · ')}.`) + : ''))); + + /* Every chosen surface gets its artboard back, remapped by the strategy + that surface answered with — the strategy screen's preview, kept. */ + parts.push(block('Strategy per surface', `
${s.strategy.map((entry) => ` +
+ ${proofHtml(surfaceBoard(entry.mode) || surfaceTilePreview(entry.mode), { strategy: entry.value, kind: 'board' })} +
+

${escapeHtml(entry.label)} · ${escapeHtml(entry.title)}${entry.chosen ? '' : ' default'}

+

${escapeHtml(entry.desc)}

+
+
`).join('')}
` + + note('How much of each surface the palette is allowed to carry, drawn the way the strategy screen previewed it. Options a surface cannot take were withheld there.'))); + } else { + parts.push(block('Palette', empty('No palette committed', 'The palette screen was not completed on this run.'))); + parts.push(block('Strategy per surface', surfaceDefs(s.strategy))); + } + if (interview.colorStrategy || interview.hueAnchor) { + parts.push(block('Interview direction', defs([ + interview.colorStrategy && { dt: 'Strategy asked for', dd: escapeHtml(interview.colorStrategy) }, + interview.hueAnchor && { dt: 'Hue anchor', dd: escapeHtml(interview.hueAnchor) }, + ].filter(Boolean)) + note('Q1 of the seed interview. The cues were generated from this; the picks above are the decision.'))); + } + return parts.join(''); +} + +function buildTypography(s, name) { + const interview = s.context?.interview || {}; + const parts = [heading(5, 'Typography', 'Font families, type scale, hierarchy.', name)]; + if (s.fonts.heading) { + /* Each family gets the fonttrio anatomy the font screen used: the name + set large in its own face, then a sentence in the partner. */ + parts.push(block('The pair', `
+
+ Headings +

${escapeHtml(s.fonts.heading)}

+ ${s.fonts.headingSource ? `${escapeHtml(s.fonts.headingSource)}` : ''} +
+
+ Body +

${escapeHtml(s.fonts.body)}

+ ${s.fonts.bodySource ? `${escapeHtml(s.fonts.bodySource)}` : ''} +
+
+

${escapeHtml(s.fonts.why || 'Chosen on the font pair screen against every surface this product ships.')}

+ `)); + } else { + parts.push(block('The pair', empty('No pair selected', 'The font pair screen was not completed on this run.'))); + } + if (s.scale.ratio) { + /* The scale screen's sheet, cloned with its computed sizes: every step + at true rendered size in the chosen faces, px and rem alongside. */ + parts.push(block('Type scale', `

${escapeHtml(s.scale.name)} · ratio ${s.scale.ratio.toFixed(3)} on a 16px base. ${escapeHtml(s.scale.desc)}

` + + proofHtml($('[data-scale-sheet]'), { kind: 'scale' }) + + note('The scale screen’s sheet, kept at rendered size in the chosen faces. Values at a 16px base.'))); + parts.push(block('In running text', proofHtml($('[data-scale-specimen]'), { kind: 'specimen' }) + + note('The same scale on the components a page is built from — headings, ledes, lists, quotes, code.'))); + } + if (interview.typeDirection) { + parts.push(block('Interview direction', callout('Direction asked for', escapeHtml(interview.typeDirection)) + + note('Q2 of the seed interview. All six candidate pairs were composed inside this direction.'))); + } + return parts.join(''); +} + +function buildIconography(s, name) { + const parts = [heading(6, 'Iconography', 'Icon library, license, where it lives.', name)]; + if (s.icons.pack) { + /* The glyphs are fetched when screen 11 first opens, so a fast run can + reach the document before any landed, and a failed fetch leaves only + an error line. A sheet with no icon cells proves nothing about the + hand, so it is dropped rather than framed. */ + const sheetSource = $('[data-icon-sheet]'); + const sheet = sheetSource?.querySelector('.picker-icon-cell') ? proofHtml(sheetSource, { kind: 'icons' }) : ''; + if (sheet) { + parts.push(block('The hand', sheet + + note(`${escapeHtml(s.icons.pack)}’s canonical set, as the icons screen previewed it. One pack, one hand: no mixed sets.`))); + } + /* The chosen radio's own row copy: the character note and the grid, + stroke, and license summary the screen showed beside the name. Read + off the DOM rather than kept twice, the optionCopy convention. */ + const packLabel = form.querySelector('input[name="icon-pack"]:checked')?.closest('label'); + const packNote = packLabel?.querySelector('.picker-strategy-desc')?.textContent.trim() || ''; + const packMeta = packLabel?.querySelector('.picker-icon-meta')?.textContent.trim() || ''; + parts.push(block('Library', defs([ + { dt: 'Pack', dd: `${escapeHtml(s.icons.pack)}${packMeta ? ` · ${escapeHtml(packMeta)}` : ''}` }, + packNote ? { dt: 'Why this hand', dd: escapeHtml(packNote) } : null, + s.icons.license ? { dt: 'License', dd: escapeHtml(s.icons.license) } : null, + s.icons.url ? { dt: 'Home', dd: `${escapeHtml(s.icons.url)}` } : null, + ].filter(Boolean)) + note('Chosen on the icons screen. The pack names the hand; stroke weight and metaphor rules resolve during implementation.'))); + } else { + parts.push(block('Library', empty('No pack selected', 'The icons screen was not completed on this run.'))); + } + return parts.join(''); +} + +/* A per-surface question laid out the way its screen asked it: one card per + surface, the pick's title carrying the card, the description under it. */ +const pickGrid = (entries) => `
${entries.map((entry) => ` +
+ ${escapeHtml(entry.label)}${entry.chosen ? '' : ' default'} +

${escapeHtml(entry.title)}

+

${escapeHtml(entry.desc)}

+
`).join('')}
`; + +function buildMaterial(s, name) { + const interview = s.context?.interview || {}; + const parts = [heading(7, 'Material', 'Motion, layout structure, boundaries, corners, depth.', name)]; + /* Every structural answer on one drawing per surface: the boundaries + screen's carried artboard, cloned with the surface's committed strategy + and material answers restated on its frame. A surface with no board in + the DOM drops its card rather than framing nothing. */ + const proofCards = s.surfaces.map((surface) => { + const board = carriedBoard(surface.mode); + if (!board) return ''; + return ` +
+ ${proofHtml(board, { + strategy: s.strategy.find((entry) => entry.mode === surface.mode)?.value || '', + kind: 'board', + marks: materialMarks(s, surface.mode), + })} +
+

${escapeHtml(surface.label)}

+
+
`; + }).filter(Boolean); + if (proofCards.length) { + parts.push(block('The page, as chosen', `
${proofCards.join('')}
` + + note('Each surface’s page anatomy carrying every structural answer below at once, the way the question screens accumulated them: color strategy, layout, boundaries, corners, depth.'))); + } + /* Movement is only asked of the two surfaces that make a claim on attention, + so this list is one or two entries long and empty on a run of neither. An + empty one says the question was never put, which is a different thing from + a quiet answer and is worth saying out loud. */ + if (s.motion.length) { + parts.push(block('Motion per surface', pickGrid(s.motion) + + note(interview.motionEnergy + ? `The seed interview asked for ${escapeHtml(interview.motionEnergy)}; the motion screen answered it per surface above.` + : 'Asked of the landing page and the portfolio only. A tool and a document are moved through rather than watched, so their movement follows the interface rather than a house style.'))); + } else { + parts.push(block('Motion', empty( + 'Not asked on this run', + 'The motion screen is shown for a landing page and a portfolio. This run has neither, so no motion energy was chosen and none is recorded.', + ))); + } + /* Asked of the same two surfaces as movement, and left out on a run of + neither. Movement's own block above already says the screen was not part of + the run, so this one goes quiet rather than repeating it. */ + if (s.layout.value) { + parts.push(block('Layout structure', callout(s.layout.title, escapeHtml(s.layout.desc)) + + note('Asked of the landing page and the portfolio, where the composition of the page is itself the decision. One answer is kept for the run, and every surface is drawn on it.'))); + } + parts.push(block('Boundaries per surface', pickGrid(s.boundaries) + + note('How sections separate on each surface.'))); + parts.push(block('Corners per surface', pickGrid(s.corners) + + note('How round shapes are on each surface.'))); + parts.push(block('Depth per surface', pickGrid(s.depth) + + note('How far off the page things sit on each surface.'))); + return parts.join(''); +} + +function buildInterface(s, name) { + const parts = [heading(8, 'Interface', 'Per-surface decisions at a glance, component status.', name)]; + parts.push(block('Decisions per surface', `
${s.surfaces.map((surface) => { + const strategyValue = s.strategy.find((entry) => entry.mode === surface.mode)?.value || ''; + const board = carriedBoard(surface.mode); + /* The matrix quotes every per-surface answer. A question this surface + was never asked reads Not asked rather than borrowing another + surface's answer; layout is one answer for the run and is quoted on + every card, the rule the Material page states. */ + const rows = [ + ['Color strategy', s.strategy.find((entry) => entry.mode === surface.mode)], + ['Boundaries', s.boundaries.find((entry) => entry.mode === surface.mode)], + ['Corners', s.corners.find((entry) => entry.mode === surface.mode)], + ['Depth', s.depth.find((entry) => entry.mode === surface.mode)], + ['Motion', s.motion.find((entry) => entry.mode === surface.mode)], + ['Layout', s.layout.value ? s.layout : undefined], + ]; + return ` +
+ ${board + ? proofHtml(board, { strategy: strategyValue, kind: 'board', marks: materialMarks(s, surface.mode) }) + : proofHtml(surfaceBoard(surface.mode) || surfaceTilePreview(surface.mode), { strategy: strategyValue, kind: 'board' })} +
+

${escapeHtml(surface.label)}

+
${rows.map(([label, entry]) => ` +
${escapeHtml(label)}
${entry ? escapeHtml(entry.title) : 'Not asked'}${entry && !entry.chosen ? ' default' : ''}
`).join('')}
+
+
`; + }).join('')}
` + + note('Each surface’s anatomy carrying its committed answers, with every per-surface decision beneath it.'))); + /* The run's own tokens in one place: what the seed DESIGN.md frontmatter + will carry. Every row omits when its screen was not completed. */ + const kit = [ + s.fonts.heading ? { dt: 'Faces', dd: `${escapeHtml(s.fonts.heading)} for headings, ${escapeHtml(s.fonts.body)} for body` } : null, + s.scale.ratio ? { dt: 'Type scale', dd: `${escapeHtml(s.scale.name)} · ratio ${s.scale.ratio.toFixed(3)} on a 16px base` } : null, + s.icons.pack ? { dt: 'Icons', dd: `${escapeHtml(s.icons.pack)}${s.icons.license ? ` · ${escapeHtml(s.icons.license)}` : ''}` } : null, + s.palette.length ? { dt: 'Palette', dd: s.palette.map((entry) => `${entry.hex}`).join(' · ') } : null, + s.layout.value ? { dt: 'Layout', dd: `${escapeHtml(s.layout.title)}, one answer for the run` } : null, + ].filter(Boolean); + if (kit.length) { + parts.push(block('The kit', defs(kit) + + note('The tokens the seed DESIGN.md will carry in its frontmatter, gathered from their own pages in this document.'))); + } + parts.push(block('Components', empty( + 'No component library seeded yet', + 'Components are documented on the first scan pass, once there is code to capture actual tokens and states from. Re-run /impeccable document then.', + ))); + return parts.join(''); +} + +const BUILDERS = { + audience: buildAudience, + product: buildProduct, + brand: buildBrand, + color: buildColor, + typography: buildTypography, + iconography: buildIconography, + material: buildMaterial, + interface: buildInterface, +}; + +function renderDocument() { + const snapshot = takeSnapshot(); + const name = snapshot.context?.product?.name || ''; + for (const [id, build] of Object.entries(BUILDERS)) { + const template = document.getElementById(`dcx-detail-${id}`); + template.innerHTML = `
${build(snapshot, name)}
`; + } + if (name) document.title = `Design context — ${name}`; +} + +/* ============================================================ + Finish sequence — save, then reveal. + ============================================================ */ + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function collectAnswers() { + const answers = {}; + for (const [name, value] of new FormData(form)) { + if (!(name in answers)) answers[name] = value; + else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value]; + } + return answers; +} + +async function submitAnswers() { + const response = await fetch('/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collectAnswers()), + }); + /* 409 means an earlier attempt landed before its response was read; the + answers are on disk, which is all this step owes. */ + if (!response.ok && response.status !== 409) throw new Error(`Submit failed: ${response.status}`); + if (response.ok) { + const body = await response.json().catch(() => null); + if (body?.doc?.base && body?.doc?.token) startDocSession(body.doc); + } +} + +async function finishSequence() { + const errorBox = $('[data-doc-error]'); + const loader = $('[data-doc-loader]'); + errorBox.hidden = true; + loader.removeAttribute('data-stalled'); + + renderDocument(); + + try { + /* The pause is real work plus a floor: the document is already built, but + a reveal that beats the reader's blink reads as a broken redirect. */ + await Promise.all([submitAnswers(), wait(2400)]); + revealDocument(); + } catch { + loader.setAttribute('data-stalled', ''); + errorBox.hidden = false; + } +} + +let finished = false; +document.addEventListener('picker:screenchange', ({ detail }) => { + if (detail.screen !== '12' || finished) return; + finished = true; + finishSequence(); +}); + +$('[data-doc-retry]')?.addEventListener('click', finishSequence); + +/* ============================================================ + Reveal + expander — ported from the prototype. The morph, + subnav, hash routing, fan, and stroke sizing are its code; + only the theme toggle and site header went (the picker has + neither). + ============================================================ */ + +const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const MORPH_MS = reduceMotion ? 0 : 540; +const READY_MS = reduceMotion ? 0 : 260; + +const tiles = $$('.dcx-tile'); +const names = {}; +tiles.forEach((tile) => { names[tile.dataset.category] = tile.dataset.name; }); +const shellTemplate = document.getElementById('dcx-shell-template'); + +let current = null; +let revealed = false; + +function revealDocument() { + revealed = true; + document.body.classList.add('dcx-open'); + shell.hidden = false; + window.scrollTo(0, 0); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + $$('[data-reveal]', shell).forEach((el) => el.classList.add('revealed')); + sizeDrawStrokes(); + }); + }); + const initial = location.hash.replace('#', ''); + if (initial && initial in names) { + window.setTimeout(() => openCategory(initial, false), 120); + } +} + +function copyText(value) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(value).catch(() => fallbackCopy(value)); + } + fallbackCopy(value); + return Promise.resolve(); +} + +function fallbackCopy(value) { + const field = document.createElement('textarea'); + field.value = value; + field.setAttribute('readonly', ''); + field.style.position = 'fixed'; + field.style.opacity = '0'; + document.body.appendChild(field); + field.select(); + document.execCommand('copy'); + field.remove(); +} + +function initFan(root) { + $$('.dcx-fan', root).forEach((fan) => { + const panels = $$('.dcx-fan-panel', fan); + if (!panels.length) return; + + function setActive(index) { + fan.classList.add('is-engaged'); + panels.forEach((panel, i) => { + panel.classList.toggle('is-active', i === index); + panel.classList.toggle('is-neighbor', Math.abs(i - index) === 1); + }); + } + + function clearActive() { + fan.classList.remove('is-engaged'); + panels.forEach((panel) => panel.classList.remove('is-active', 'is-neighbor')); + } + + fan.addEventListener('mousemove', (event) => { + const rect = fan.getBoundingClientRect(); + const progress = Math.min(0.999, Math.max(0, (event.clientX - rect.left) / rect.width)); + let active = 0; + panels.forEach((panel, i) => { + const left = parseFloat(panel.style.getPropertyValue('--panel-left')) / 100; + if (progress >= left) active = i; + }); + setActive(active); + }); + fan.addEventListener('mouseleave', clearActive); + + panels.forEach((panel, i) => { + panel.addEventListener('focus', () => setActive(i)); + panel.addEventListener('blur', () => { + if (!fan.matches(':focus-within')) clearActive(); + }); + panel.addEventListener('click', () => { + const value = panel.dataset.copyColor; + if (!value) return; + copyText(value); + const label = panel.querySelector('.dcx-fan-name'); + const original = panel.dataset.colorName || 'Color'; + panel.classList.add('is-copied'); + if (label) label.textContent = 'Copied!'; + window.clearTimeout(panel._copyTimer); + panel._copyTimer = window.setTimeout(() => { + panel.classList.remove('is-copied'); + if (label) label.textContent = original; + }, 900); + }); + }); + }); +} + +function buildSubnav(expander, activeId) { + $$('.dcx-nav-list li', expander).forEach((li) => { + const isActive = li.dataset.category === activeId; + li.classList.toggle('is-active', isActive); + const subnav = li.querySelector('.dcx-subnav'); + if (!subnav) return; + subnav.innerHTML = ''; + if (!isActive) return; + $$('.dcx-main .dcx-block[data-label]', expander).forEach((blockEl, i) => { + const btn = document.createElement('button'); + btn.className = 'dcx-sub-link'; + btn.type = 'button'; + btn.textContent = blockEl.dataset.label.replace(/&/g, '&'); + btn.setAttribute('data-dcx-subsection', String(i)); + subnav.appendChild(btn); + }); + }); + $$('.dcx-nav-link', expander).forEach((link) => { + if (link.dataset.dcxNav === activeId) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); +} + +function scrollToBlock(expander, index) { + const blocks = $$('.dcx-main .dcx-block[data-label]', expander); + const target = blocks[Number(index)]; + if (!target) return; + const main = expander.querySelector('.dcx-main'); + const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - 18; + main.scrollTo({ top: Math.max(0, top), behavior: reduceMotion ? 'auto' : 'smooth' }); +} + +function renderCategory(id, expander, updateHash) { + const template = document.getElementById(`dcx-detail-${id}`); + if (!template) return; + + expander.querySelector('.dcx-current').textContent = names[id] || id; + + const main = expander.querySelector('.dcx-main'); + main.innerHTML = ''; + main.appendChild(template.content.cloneNode(true)); + main.scrollTop = 0; + initFan(main); + + if (current) { + const tile = $(`.dcx-tile--${id}`); + current.id = id; + current.tile = tile; + current.rect = tile.getBoundingClientRect(); + } + + buildSubnav(expander, id); + if (updateHash) history.pushState({ category: id }, '', `#${id}`); +} + +function openCategory(id, updateHash) { + if (!(id in names)) return; + if (current && current.id === id) return; + closeCategory(false); + + const tile = $(`.dcx-tile--${id}`); + if (!tile) return; + + const rect = tile.getBoundingClientRect(); + const expander = document.createElement('section'); + expander.className = 'dcx-expander'; + expander.setAttribute('role', 'dialog'); + expander.setAttribute('aria-modal', 'true'); + expander.setAttribute('aria-label', `${names[id]} details`); + expander.style.top = `${rect.top}px`; + expander.style.left = `${rect.left}px`; + expander.style.width = `${rect.width}px`; + expander.style.height = `${rect.height}px`; + + expander.appendChild(shellTemplate.content.cloneNode(true)); + document.body.appendChild(expander); + document.body.classList.add('is-locked'); + current = { id: null, expander, tile, rect, opener: tile }; + renderCategory(id, expander, false); + current.id = id; + + requestAnimationFrame(() => { + expander.classList.add('is-full'); + window.setTimeout(() => { + expander.classList.add('is-ready'); + expander.querySelector('.dcx-close')?.focus({ preventScroll: true }); + }, READY_MS); + }); + + expander.querySelector('.dcx-close').addEventListener('click', () => closeCategory(true)); + + expander.querySelector('.dcx-nav').addEventListener('click', (event) => { + const subLink = event.target.closest('[data-dcx-subsection]'); + if (subLink) { + scrollToBlock(expander, subLink.getAttribute('data-dcx-subsection')); + return; + } + const navLink = event.target.closest('[data-dcx-nav]'); + if (!navLink) return; + event.preventDefault(); + if (current && navLink.dataset.dcxNav === current.id) return; + renderCategory(navLink.dataset.dcxNav, expander, true); + }); + + if (updateHash) history.pushState({ category: id }, '', `#${id}`); +} + +function closeCategory(updateHash) { + if (!current) return; + const { expander, rect, opener } = current; + expander.classList.remove('is-ready', 'is-full'); + expander.style.top = `${rect.top}px`; + expander.style.left = `${rect.left}px`; + expander.style.width = `${rect.width}px`; + expander.style.height = `${rect.height}px`; + window.setTimeout(() => { + expander.remove(); + document.body.classList.remove('is-locked'); + }, MORPH_MS); + current = null; + if (opener) opener.focus({ preventScroll: true }); + if (updateHash && location.hash) history.pushState(null, '', location.pathname + location.search); +} + +tiles.forEach((tile) => { + tile.addEventListener('click', () => openCategory(tile.dataset.category, true)); +}); + +/* Vignette draw animations use the homepage's stroke-dasharray: 100 + (user units), but non-scaling-stroke makes Chromium measure dashes in + screen pixels. Translate: --pl = 100 viewBox units at the rendered + scale, refreshed on resize. */ +function sizeDrawStrokes() { + $$('.dcx-viz-svg').forEach((svg) => { + const paths = $$('.anim-draw, .anim-draw-delay', svg); + if (!paths.length) return; + const rect = svg.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + const scale = Math.min(rect.width, rect.height) / 40; + paths.forEach((path) => { + /* The homepage icons fit inside dasharray 100; custom paths longer + than 60 units (breathe shows the first 60% of the dash) get a + proportionally larger dash so they still finish drawing. */ + const units = Math.max(100, path.getTotalLength() / 0.6); + path.style.setProperty('--pl', `${(units * scale).toFixed(1)}px`); + }); + }); +} +let drawResizeTimer; +window.addEventListener('resize', () => { + window.clearTimeout(drawResizeTimer); + drawResizeTimer = window.setTimeout(() => { if (revealed) sizeDrawStrokes(); }, 150); +}); + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && revealed) closeCategory(true); +}); + +window.addEventListener('popstate', () => { + if (!revealed) return; + const id = location.hash.replace('#', ''); + if (id && id in names) { + if (current) renderCategory(id, current.expander, false); + else openCategory(id, false); + } else { + closeCategory(false); + } +}); + +/* ============================================================ + Live edit session — the document as a working surface. + + The picker server forks a doc-session sibling on submit and hands + this tab its address and token. From then on the document is + editable through two paths: + + - Simple edits (a palette color) POST /doc/edit and the session + applies them itself: answers.json and DESIGN.md are rewritten by + a deterministic function, no model in the loop. + - Anything needing judgment (fonts, freeform asks) POSTs + /doc/request; the agent long-polls the queue, does the work, and + replies. The tray shows each request move pending -> working -> + done. + + The tab learns about the outside world the way live mode's browser + does, scaled to polling: /doc/state every couple of seconds, and a + version bump means re-fetch answers.json and re-render. + ============================================================ */ + +let docSession = null; +let docVersion = 1; +let docOnline = false; +let trayRequests = []; + +const tray = $('[data-dcx-tray]'); +const requestModal = $('[data-dcx-request-modal]'); + +const docLive = () => Boolean(docSession); + +function startDocSession(doc) { + docSession = doc; + document.body.classList.add('dcx-live'); + schedulePoll(1500); +} + +async function docPost(pathname, body) { + const response = await fetch(`${docSession.base}${pathname}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: docSession.token, ...body }), + }); + if (!response.ok) throw new Error(`${pathname} failed: ${response.status}`); + return response.json(); +} + +let pollTimer; +function schedulePoll(ms) { + window.clearTimeout(pollTimer); + pollTimer = window.setTimeout(pollDocState, ms); +} + +async function pollDocState() { + if (!docSession) return; + try { + const response = await fetch(`${docSession.base}/doc/state?token=${encodeURIComponent(docSession.token)}`); + if (!response.ok) throw new Error('state'); + const state = await response.json(); + setDocOnline(true); + trayRequests = state.requests || []; + renderTray(); + if (state.version !== docVersion) { + docVersion = state.version; + await adoptAnswers(); + refreshDocument(); + } + schedulePoll(2000); + } catch { + setDocOnline(false); + renderTray(); + schedulePoll(8000); + } +} + +function setDocOnline(value) { + docOnline = value; + document.body.classList.toggle('dcx-live', Boolean(docSession) && value); +} + +/* The form is still the single source the renderer reads, so an edit made + anywhere lands there: values fetched back from answers.json are written + into the same named fields the questionnaire filled. */ +async function adoptAnswers() { + const response = await fetch(`${docSession.base}/doc/answers?token=${encodeURIComponent(docSession.token)}`); + if (!response.ok) return; + const { answers } = await response.json(); + for (const [name, value] of Object.entries(answers || {})) { + if (Array.isArray(value) || typeof value !== 'string') continue; + const field = form.elements[name]; + if (!field) continue; + if (typeof RadioNodeList !== 'undefined' && field instanceof RadioNodeList) field.value = value; + else if ('value' in field && field.type !== 'checkbox') field.value = value; + } + ensureFace(fieldValue('font-heading')); + ensureFace(fieldValue('font-body')); +} + +/* A family the agent swapped in may not be loaded on this page yet; ask + Google Fonts for it and let the browser fall back if it is not there. */ +function ensureFace(family) { + if (!family || document.fonts?.check?.(`16px '${family}'`)) return; + const id = `dcx-face-${family.replace(/\W+/g, '-').toLowerCase()}`; + if (document.getElementById(id)) return; + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, '+')}:wght@300..800&display=swap`; + document.head.appendChild(link); +} + +function refreshDocument() { + renderDocument(); + if (current) renderCategory(current.id, current.expander, false); +} + +/* ---------- Simple edits: palette colors ---------- */ + +document.addEventListener('click', (event) => { + const button = event.target.closest('[data-edit-color]'); + if (!button) return; + const input = button.parentElement.querySelector(`[data-color-input-for="${button.dataset.editColor}"]`); + input?.click(); +}); + +document.addEventListener('change', (event) => { + const input = event.target.closest?.('[data-color-input-for]'); + if (!input) return; + applyColorEdit(input.dataset.colorInputFor, input.value.toUpperCase()); +}); + +async function applyColorEdit(role, hex) { + const field = form.elements[`palette-${role}`]; + if (!field || field.value.toUpperCase() === hex) return; + field.value = hex; + refreshDocument(); + if (!docLive()) return; + try { + const result = await docPost('/doc/edit', { kind: 'color', role, value: hex }); + docVersion = result.version; + } catch { + setDocOnline(false); + renderTray(); + } +} + +/* ---------- Complex edits: the request modal ---------- */ + +let requestKind = 'freeform'; + +document.addEventListener('click', (event) => { + const trigger = event.target.closest('[data-dcx-request], [data-dcx-request-kind]'); + if (!trigger || !requestModal) return; + requestKind = trigger.dataset.dcxRequestKind || 'freeform'; + const category = current ? names[current.id] : 'Design context'; + $('[data-dcx-request-scope]', requestModal).textContent = requestKind === 'font' + ? 'Typography change' : `${category} change`; + $('[data-dcx-request-fonts]', requestModal).hidden = requestKind !== 'font'; + const prompt = $('.dcx-request-prompt', requestModal); + prompt.value = ''; + $('[data-dcx-request-upload-note]', requestModal).textContent = ''; + requestModal.showModal(); + prompt.focus(); +}); + +$('[data-dcx-request-cancel]', requestModal)?.addEventListener('click', () => requestModal.close()); + +$('[data-dcx-request-send]', requestModal)?.addEventListener('click', async () => { + const prompt = $('.dcx-request-prompt', requestModal).value.trim(); + if (!prompt || !docLive()) return; + const send = $('[data-dcx-request-send]', requestModal); + send.disabled = true; + try { + const files = $('[data-dcx-request-files]', requestModal)?.files || []; + const uploaded = []; + for (const file of files) { + const response = await fetch(`${docSession.base}/font-upload?token=${encodeURIComponent(docSession.token)}`, { + method: 'POST', + headers: { 'X-Font-Filename': file.name }, + body: file, + }); + if (response.ok) uploaded.push((await response.json()).path); + } + const result = await docPost('/doc/request', { + kind: requestKind, + prompt, + category: current ? current.id : '', + payload: uploaded.length ? { fonts: uploaded } : {}, + }); + docVersion = result.version; + requestModal.close(); + schedulePoll(400); + } catch { + $('[data-dcx-request-upload-note]', requestModal).textContent = 'The edit session is unreachable; the request was not sent.'; + } finally { + send.disabled = false; + } +}); + +/* ---------- The tray ---------- */ + +const TRAY_LABELS = { + pending: 'Queued for the agent', + working: 'The agent is on it', + done: 'Applied', + error: 'Could not apply', +}; + +function renderTray() { + if (!tray) return; + const items = trayRequests.slice(-4); + const offline = docSession && !docOnline; + tray.hidden = !offline && items.length === 0; + tray.innerHTML = [ + offline ? '

Edit session offline

Changes stay in this tab; reconnecting…

' : '', + ...items.map((entry) => ` +
+ +
+

${escapeHtml(entry.prompt)}

+

${escapeHtml(entry.message || TRAY_LABELS[entry.status] || entry.status)}

+
+
`), + ].join(''); +} diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js new file mode 100644 index 000000000..ae9e9292c --- /dev/null +++ b/picker/scripts/palette-picker.js @@ -0,0 +1,2461 @@ +import { contrastInk, contrastInkHex, formatOklch, hexToOklch, neutralContrastIssue, oklchToHex, readableOn, seedToRoles } from './color.js'; + +const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; +const screen = document.querySelector('[data-screen="02"]'); +const $ = (selector, root = screen) => root.querySelector(selector); +const $$ = (selector, root = screen) => root.querySelectorAll(selector); +const scroller = $('[data-deck-scroll]'); +const points = $('[data-deck-points]'); +const layer = $('[data-deck-cards]'); +const count = $('[data-deck-count]'); +const panel = $('.picker-palette-panel'); +const hint = $('[data-palette-hint]'); +const ringGuide = $('[data-ring-guide]'); +const loupe = $('[data-loupe]'); +let preview = $('.picker-preview'); +const typeStage = document.querySelector('[data-type-stage]'); +const typeBoards = [...document.querySelectorAll('[data-type-preview]')]; +const fontOptions = document.querySelector('[data-font-options]'); +const pairTemplate = document.querySelector('[data-pair-card]'); +const scaleOptions = document.querySelector('[data-scale-options]'); +const scaleSheet = document.querySelector('[data-scale-sheet]'); +const scaleSpecimen = document.querySelector('[data-scale-specimen]'); +const states = new Map(); +const canvases = new WeakMap(); +let cards = []; +let current = 0; +let openTint; +let fontManifest; + +/* Headlines and labels come from the product so the display face is judged on + words it will really set. Running text stays lorem on purpose: a body face + is judged on texture, and real prose pulls the eye into reading it. */ +const LOREM = { + sentence: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.', + paragraph: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo.', + /* Two of them rather than one long one, because what the Read board has to + show is the texture of a block and the step between blocks, and a single + paragraph shows only the first. Their length is what the widest-setting + pair leaves room for at the smallest frame the card is drawn at. */ + passages: [ + 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur sint occaecat cupidatat.', + 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione.', + ], + note: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque.', + /* Short enough to set on one line at the handset's measure, so a list item + stays a list item there instead of becoming a third paragraph. */ + items: [ + 'Excepteur sint occaecat', + 'Non proident, sunt in culpa', + ], + caption: 'Lorem ipsum dolor sit amet, consectetur adipiscing.', +}; + +/* Every word slot on the font boards, in lorem. A pair is judged on glyphs, and + product words pull the eye into reading the page instead of the setting, so + the type screen draws placeholder copy in every slot rather than only in the + running text. Titles are one word because the slot is one line wide. */ +const LOREM_PREVIEW = { + brand: 'Lo', + nav: ['Lorem', 'Ipsum', 'Dolor', 'Amet'], + navAction: 'Consectetur', + menuAction: 'Ipsum', + ctaPrimary: 'Lorem ipsum', + ctaSecondary: 'Dolor sit', + proof: ['Lorem ipsum dolor', 'Sit amet elit dolor', 'Sed do eiusmod', 'Tempor incididunt'], + sectionTitle: 'Lorem ipsum dolor', + sectionLink: 'Consectetur adipiscing', + gallery: [ + { title: 'Lorem ipsum dolor', meta: 'sit amet consectetur' }, + { title: 'Dolor sit amet', meta: 'consectetur elit' }, + { title: 'Eiusmod tempor', meta: 'sed do eiusmod' }, + ], + footerLinks: ['Lorem', 'Ipsum', 'Dolor', 'Amet'], + footerMark: '© Lorem ipsum', +}; + +const LOREM_SPECIMEN = { headline: 'Lorem ipsum dolor sit amet' }; + +const LOREM_APP = { + rail: ['Lorem', 'Ipsum', 'Dolor'], + columns: ['Lorem', 'Ipsum', 'Dolor'], + figures: ['1,284', '98.2%', '41'], + amounts: ['$12,400', '$3,860', '$9,215'], + panel: ['Lorem ipsum', 'Dolor sit amet'], + switches: ['Lorem ipsum', 'Dolor sit'], + chartTitle: 'Lorem ipsum dolor', + lanes: ['Lorem', 'Ipsum', 'Dolor', 'Amet', 'Elit'], +}; + +const LOREM_DOCS = { + rail: ['Lorem ipsum', 'Dolor', 'Consectetur', 'Adipiscing elit'], + crumb: 'Lorem / Ipsum dolor', + note: 'Nota', +}; + +const LOREM_INDEX = { + stops: ['Lorem', 'Ipsum', 'Dolor', 'Amet'], + caption: + 'Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit sed do eiusmod tempor.', +}; + +/* The desktop artboard sets three cards and the phone two, so a fourth would + be words the agent writes and nobody ever reads. */ +const GALLERY_CARDS = 3; + +/* The words an interface supplies rather than the product: a tool's own rail, + the headings over its columns, the figures under them, and the two rows of a + settings panel. Kept here beside LOREM and for the same reason. What this + board has to prove is that the pair draws lining numerals that hold a column + and a semibold label that stays inside one, and both are properties of the + face. A column is also the tightest slot on any of the boards, so its words + cannot be left to whatever the run happens to be selling. */ +const APP = { + rail: ['Overview', 'Reports', 'Settings'], + columns: ['Item', 'Status', 'Amount'], + figures: ['1,284', '98.2%', '41'], + amounts: ['$12,400', '$3,860', '$9,215'], + panel: ['Preferences', 'Last 30 days'], + switches: ['Email digest', 'Compact rows'], + chartTitle: 'Volume by channel', + /* One word each, because the label under a bar has the bar's own width and + nothing more: a category that wraps or truncates here is a fault in the + drawing rather than a report on the pair. The handset takes the first + three, which is why the widest of them comes early. */ + lanes: ['Direct', 'Search', 'Social', 'Email', 'Other'], +}; + +/* The same argument as APP, for the surface where the words belong to the + document rather than to the product. The rail lists sections of one page and + the crumb says where that page sits, neither of which the manifest's nav can + stand in for without the board reading as the same four words twice. */ +const DOCS = { + rail: ['Getting started', 'Install', 'Configuration', 'API reference'], + crumb: 'Docs / Getting started', + note: 'Note', +}; + +/* Same again for the index. The carousel's stops name parts of a body of work, + so the footer's links cannot stand in for them: the two lists sit a band + apart on the same board and would read as one list printed twice. */ +const INDEX = { + stops: ['Selected', 'Archive', 'Studio', 'Contact'], +}; + +/* Deliberately Latin faces with English copy, same as the constraint on + dealt pairs in visual-cues.md Step 6. TODO: lift both together when the + picker's previews learn non-Latin scripts and per-language font subsets. */ +const FALLBACK_FONTS = { + version: 1, + specimen: { + headline: 'Built for the work at hand', + }, + preview: { + brand: 'Ab', + nav: ['Product', 'Pricing', 'Docs', 'About'], + navAction: 'Sign in', + menuAction: 'Menu', + ctaPrimary: 'Get started', + ctaSecondary: 'Learn more', + proof: ['Fast to set up', 'Works anywhere', 'No lock-in', 'Free to try'], + sectionTitle: 'Everything in one place', + sectionLink: 'Read the guide', + gallery: [ + { title: 'Overview', meta: 'Start here' }, + { title: 'Library', meta: 'Browse all' }, + { title: 'Reports', meta: 'See results' }, + ], + footerLinks: ['Product', 'Company', 'Support', 'Legal'], + footerMark: '© Your product', + }, + pairs: [ + { + id: 'source-editorial', + name: 'Source editorial', + heading: { family: 'Source Serif 4', weight: 600 }, + body: { family: 'Source Sans 3', weight: 400 }, + why: 'Considered and editorial', + }, + { + id: 'literary-clarity', + name: 'Literary clarity', + heading: { family: 'Libre Baskerville', weight: 700 }, + body: { family: 'Libre Franklin', weight: 400 }, + why: 'Bookish and plain-spoken', + }, + { + id: 'warm-structure', + name: 'Warm structure', + heading: { family: 'Bitter', weight: 600 }, + body: { family: 'Cabin', weight: 400 }, + why: 'Sturdy slab warmth', + }, + { + id: 'bold-utility', + name: 'Bold utility', + heading: { family: 'Archivo Black', weight: 400 }, + body: { family: 'Archivo', weight: 400 }, + why: 'Headlines that shout', + }, + { + id: 'technical-signal', + name: 'Technical signal', + heading: { family: 'Azeret Mono', weight: 600 }, + body: { family: 'Noto Sans', weight: 400 }, + why: 'Machined and precise', + }, + { + id: 'classical-poise', + name: 'Classical poise', + heading: { family: 'Marcellus', weight: 400 }, + body: { family: 'Karla', weight: 400 }, + why: 'Inscriptional and formal', + }, + ], +}; + +const roleMap = (value) => Object.fromEntries(ROLES.map((role) => [role, value(role)])); +const card = () => cards[current]; +const state = () => states.get(card().id); +const dismissRingGuide = () => ringGuide.setAttribute('aria-hidden', 'true'); +const serifFamily = /serif|mincho|baskerville|bitter|marcellus|slab|antiqua|garamond|didot|bodoni/i; +const fontStack = (family) => `"${family.replaceAll('"', '\\"')}", ${serifFamily.test(family) ? 'serif' : 'sans-serif'}`; + +function createState(item) { + const colors = item.type === 'cue' + ? roleMap((role) => (item.palette[role].snapped || item.palette[role].hex).toUpperCase()) + : seedToRoles(item); + return { + colors, + detached: roleMap(() => false), + rings: roleMap(() => [50, 50]), + }; +} + +function sourceCanvas(image) { + let canvas = canvases.get(image); + if (canvas) return canvas; + canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + canvas.getContext('2d', { willReadFrequently: true }).drawImage(image, 0, 0); + canvases.set(image, canvas); + return canvas; +} + +function syncRings(item) { + if (item.type !== 'cue') return; + const itemState = states.get(item.id); + $$('.picker-ring', item.node).forEach((ring) => { + const role = ring.dataset.role; + const [x, y] = itemState.rings[role]; + ring.style.setProperty('--x', `${x}%`); + ring.style.setProperty('--y', `${y}%`); + ring.style.setProperty('--marker-color', itemState.colors[role]); + ring.setAttribute('aria-valuetext', itemState.colors[role]); + ring.toggleAttribute('data-detached', itemState.detached[role]); + }); +} + +function drawLoupe(ring, item, image) { + const [x, y] = states.get(item.id).rings[ring.dataset.role]; + const source = sourceCanvas(image); + const canvas = $('canvas', loupe); + const context = canvas.getContext('2d'); + const px = x / 100 * (source.width - 1); + const py = y / 100 * (source.height - 1); + const crop = Math.max(8, Math.min(source.width, source.height) / 128); + context.clearRect(0, 0, 80, 80); + context.imageSmoothingEnabled = false; + context.drawImage(source, px - crop / 2, py - crop / 2, crop, crop, 0, 0, 80, 80); + const stage = loupe.parentElement.getBoundingClientRect(); + const box = ring.getBoundingClientRect(); + loupe.style.left = `${box.left - stage.left + box.width / 2}px`; + loupe.style.top = `${box.top - stage.top}px`; + loupe.dataset.visible = ''; +} + +function renderBand(role) { + const hex = state().colors[role]; + const band = $(`[data-band="${role}"]`, panel); + band.style.setProperty('--band-color', hex); + band.style.setProperty('--band-ink', contrastInk(hex)); + $('output', band).textContent = hex; + $('input', band).value = hex; + renderPreview(); +} + +function renderPreview() { + // The preview is swapped for the chosen mode's variant before the deck is + // dealt, so this runs at least once with no card to read a color off. + if (!cards.length) return; + for (const role of ROLES) preview.style.setProperty(`--pv-${role}`, state().colors[role]); + preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral)); + syncNeutralAlert(); +} + +/* The prefix exists for the strategy stage, which needs the committed colors + under names its own CSS never rewrites: the remap there reads these to know + what was chosen, and reading the live --pv-* would read its own output. */ +function syncCommittedPalette(target, prefix = 'pv') { + const committed = roleMap((role) => $(`[name="palette-${role}"]`).value); + if (!target || Object.values(committed).some((hex) => !hex)) return; + const set = (name, value) => target.style.setProperty(`--${prefix}-${name}`, value); + for (const role of ROLES) set(role, committed[role]); + // One ink per fill a preview can paint a label on: the strategy decides + // which of the three carries the button on any given artboard. + set('n-ink', contrastInk(committed.neutral)); + set('p-ink', contrastInk(committed.primary)); + set('t-ink', contrastInk(committed.tertiary)); + // And one reading version of each accent that the type artboards set words + // in, per ground it can land on: the neutral page, or the primary once the + // strategy drenches the page in it. + set('p-on-n', readableOn(committed.primary, committed.neutral)); + set('t-on-n', readableOn(committed.tertiary, committed.neutral)); + set('t-on-p', readableOn(committed.tertiary, committed.primary)); + // Labels on filled buttons: hue is the role that paints the fill, ground is + // that same fill, not the page neutral that produced the 1.63:1 regression. + set('p-on-p', readableOn(committed.primary, committed.primary)); + set('t-on-t', readableOn(committed.tertiary, committed.tertiary)); + set('p-on-i', readableOn(committed.primary, contrastInkHex(committed.primary))); +} + +/* Every field is checked only when it is present. The manifest merges over + FALLBACK_FONTS, so a partial file is legal by design: the spec asks for the + full set on the shared block, and a per-pair override is allowed to carry + the one string it changes. Demanding the full shape anywhere it appears + would reject that override and drop the whole manifest, six chosen pairs + included, back to defaults. */ +function isPreviewCopy(value) { + if (!value || typeof value !== 'object') return false; + const strings = ['brand', 'navAction', 'menuAction', 'ctaPrimary', 'ctaSecondary', 'sectionTitle', 'sectionLink', 'footerMark']; + const lists = { nav: 4, proof: 4, footerLinks: 4 }; + return strings.every((key) => value[key] === undefined || typeof value[key] === 'string') + && Object.entries(lists).every(([key, length]) => value[key] === undefined || ( + Array.isArray(value[key]) + && value[key].length === length + && value[key].every((entry) => typeof entry === 'string') + )) + && (value.gallery === undefined || ( + Array.isArray(value.gallery) + && value.gallery.length === GALLERY_CARDS + && value.gallery.every((entry) => ( + typeof entry?.title === 'string' + && typeof entry?.meta === 'string' + )) + )); +} + +function normalizeFontManifest(manifest) { + return { + ...manifest, + preview: { ...FALLBACK_FONTS.preview, ...manifest.preview }, + }; +} + +function isFontManifest(value) { + return value?.version === 1 + && typeof value.specimen?.headline === 'string' + && (!value.preview || isPreviewCopy(value.preview)) + && value.pairs?.length === 6 + && value.pairs.every((pair) => ( + typeof pair.id === 'string' + && typeof pair.name === 'string' + && typeof pair.heading?.family === 'string' + && Number.isFinite(pair.heading?.weight) + && typeof pair.body?.family === 'string' + && Number.isFinite(pair.body?.weight) + && typeof pair.why === 'string' + && (!pair.preview || isPreviewCopy(pair.preview)) + )); +} + +function loadFontStylesheet(pairs) { + const families = new Map(); + const addWeight = (family, weight) => { + if (!families.has(family)) families.set(family, new Set()); + families.get(family).add(weight); + }; + for (const pair of pairs) { + addWeight(pair.heading.family, pair.heading.weight); + addWeight(pair.body.family, pair.body.weight); + addWeight(pair.body.family, 700); + } + const query = [...families].map(([family, weights]) => { + const name = encodeURIComponent(family).replaceAll('%20', '+'); + return `family=${name}:wght@${[...weights].sort((a, b) => a - b).join(';')}`; + }).join('&'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?${query}&display=swap`; + link.dataset.pickerFonts = ''; + document.head.append(link); +} + +/* A board takes the slots its surface has and stops, so a list longer than the + slots is ordinary. The other direction is a fault in the markup, and it is + left blank here rather than hidden: a hidden slot would leave the board + drawing one item fewer than the composition its grid was measured at, and + the blank is what makes the miscount visible. */ +function fillIndexed(root, selector, values) { + if (!root) return; + root.querySelectorAll(selector).forEach((node, index) => { + node.textContent = values[index] ?? ''; + }); +} + +/* One board's worth of copy. Every board is filled through the same hooks, so + a slot means the same thing wherever it appears: [data-type-gallery-title] + names an item whether the item is a card, a table row, or a piece of work, + and a board takes the slots its surface has and leaves the rest alone. The + desktop and the phone are filled separately because the indexed slots start + counting again on each. */ +function fillBoard(board) { + const preview = LOREM_PREVIEW; + const specimen = LOREM_SPECIMEN; + const desktop = board.querySelector('.ps-desktop'); + const phoneBody = board.querySelector('.ps-phone-body'); + // A rail means the sections of a document on one board and the areas of a + // tool on the other, so it is the one slot whose words the surface decides. + const rail = board.dataset.surface === 'read' ? LOREM_DOCS.rail : LOREM_APP.rail; + const fill = (selector, value) => { + for (const node of board.querySelectorAll(selector)) node.textContent = value; + }; + fill('[data-type-brand]', preview.brand); + fill('[data-type-nav-action]', preview.navAction); + fill('[data-type-menu-action]', preview.menuAction); + fill('[data-type-headline]', specimen.headline); + fill('[data-type-body]', LOREM.sentence); + fill('[data-type-cta-primary]', preview.ctaPrimary); + fill('[data-type-cta-secondary]', preview.ctaSecondary); + fill('[data-type-section-title]', preview.sectionTitle); + fill( + '[data-type-section-body]', + `${LOREM.paragraph} Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.`, + ); + fill('[data-type-section-link]', preview.sectionLink); + fill('[data-type-footer-mark]', preview.footerMark); + fill('[data-type-note-label]', LOREM_DOCS.note); + fill('[data-type-note-body]', LOREM.note); + fill('[data-type-crumb]', LOREM_DOCS.crumb); + fill('[data-type-caption]', LOREM_INDEX.caption); + fill('[data-type-chart-title]', LOREM_APP.chartTitle); + const fillGalleryCaptions = (root, items) => { + if (!root) return; + root.querySelectorAll('.ps-gallery-item [data-type-gallery-caption]').forEach((node, index) => { + const item = items[index]; + if (item) node.textContent = `${item.title} ${item.meta}`; + }); + }; + for (const card of [desktop, phoneBody]) { + fillIndexed(card, '[data-type-nav]', preview.nav); + fillIndexed(card, '[data-type-proof]', preview.proof); + fillIndexed(card, '[data-type-gallery-title]', preview.gallery.map(({ title }) => title)); + fillIndexed(card, '[data-type-gallery-meta]', preview.gallery.map(({ meta }) => meta)); + fillIndexed(card, '[data-type-passage]', LOREM.passages); + fillIndexed(card, '[data-type-item]', LOREM.items); + fillIndexed(card, '[data-type-stop]', LOREM_INDEX.stops); + fillIndexed(card, '[data-type-rail]', rail); + fillIndexed(card, '[data-type-lane]', LOREM_APP.lanes); + fillIndexed(card, '[data-type-column]', LOREM_APP.columns); + fillIndexed(card, '[data-type-figure]', LOREM_APP.figures); + fillIndexed(card, '[data-type-amount]', LOREM_APP.amounts); + fillIndexed(card, '[data-type-panel]', LOREM_APP.panel); + fillIndexed(card, '[data-type-switch]', LOREM_APP.switches); + fillGalleryCaptions(card, preview.gallery); + } + fillIndexed(desktop?.querySelector('.ps-footer'), '[data-type-footer-link]', preview.footerLinks); +} + +function syncFontPair(pair) { + // Written once on the stage the boards share, and on screen 05's two preview + // columns, which are set in the pair chosen here so the faces travel with it. + for (const target of [typeStage, scaleSheet, scaleSpecimen]) { + target.style.setProperty('--pt-heading', fontStack(pair.heading.family)); + target.style.setProperty('--pt-body', fontStack(pair.body.family)); + target.style.setProperty('--pt-heading-weight', pair.heading.weight); + } + for (const board of typeBoards) fillBoard(board); + document.querySelector('[name="font-heading"]').value = pair.heading.family; + document.querySelector('[name="font-body"]').value = pair.body.family; + document.querySelector('[name="font-heading-source"]').value = pair.heading.source || ''; + document.querySelector('[name="font-body-source"]').value = pair.body.source || ''; +} + +/* Where each pair belongs when it is not the chosen one. The rail renders a + rotation of this list rather than the list itself, so hoisting the answer + to the top never loses the order the rest were dealt in. An uploaded pair + joins at the front: it is the one the user made. */ +const pairOrder = []; + +function addPairCard(pair, { checked = false, first = false } = {}) { + const node = pairTemplate.content.firstElementChild.cloneNode(true); + const input = node.querySelector('input'); + input.value = pair.id; + input.checked = checked; + input.setAttribute( + 'aria-label', + `${pair.heading.family} for headings with ${pair.body.family} for body text. ${pair.why}`, + ); + node.style.setProperty('--pair-heading', fontStack(pair.heading.family)); + node.style.setProperty('--pair-body', fontStack(pair.body.family)); + node.style.setProperty('--pair-heading-weight', pair.heading.weight); + node.style.setProperty('--pair-body-weight', pair.body.weight); + node.querySelector('[data-pair-heading]').textContent = pair.heading.family; + node.querySelector('[data-pair-body]').textContent = pair.body.family; + node.querySelector('[data-pair-why]').textContent = pair.why; + if (first) pairOrder.unshift(node); + else pairOrder.push(node); + fontOptions.append(node); + return node; +} + +function removePairCard(node) { + const index = pairOrder.indexOf(node); + if (index !== -1) pairOrder.splice(index, 1); + node.remove(); +} + +function renderFontPairs(manifest, fallback) { + fontManifest = normalizeFontManifest(manifest); + fontOptions.toggleAttribute('data-fallback', fallback); + manifest.pairs.forEach((pair, index) => addPairCard(pair, { checked: index === 0 })); + loadFontStylesheet(manifest.pairs); + syncFontPair(manifest.pairs[0]); + // The tab strip was built before the run had a pair to name, so it is told + // once the rows exist. + syncSurfaces(); + applyHoist(); +} + +fontOptions.onchange = ({ target }) => { + if (!target.matches('input[name="font-pair"]')) return; + const pair = fontManifest.pairs.find(({ id }) => id === target.value); + if (pair) syncFontPair(pair); + requestHoist(); +}; + +/* Scroll by whole rows so an option never ends up half in frame, and disable + an arrow at the end it points to, since a live arrow that does nothing is + the reason the list looked unscrollable in the first place. + + Screen 05's specimen wears this too, and it has no rows: a page of prose is + paged by most of its own frame instead, which leaves a couple of lines of + overlap so the reader can find where they were. */ +function wireListScroll(list) { + const buttons = [...list.closest('.picker-type-rail, .picker-scale-column').querySelectorAll('[data-list-scroll]')]; + const sync = () => { + const room = list.scrollHeight - list.clientHeight; + for (const button of buttons) { + const down = button.dataset.listScroll === '1'; + const spent = down ? list.scrollTop >= room - 1 : list.scrollTop <= 1; + button.disabled = room < 2 || spent; + } + }; + for (const button of buttons) { + button.onclick = () => { + const step = list.querySelector('.picker-strategy-option')?.offsetHeight + || Math.round(list.clientHeight * 0.82); + list.scrollBy({ top: step * Number(button.dataset.listScroll), behavior: 'smooth' }); + }; + } + list.addEventListener('scroll', sync, { passive: true }); + new ResizeObserver(sync).observe(list); + return sync; +} + +const syncScrollButtons = wireListScroll(fontOptions); +wireListScroll(scaleOptions); + +/* Screens where the cursor previews and the click commits need one rule for + what the preview falls back to, and `focusout` on its own is not it. Clicking + a row blurs whatever held focus before the browser focuses that row's input, + and the intermediate event carries no relatedTarget, so a listener that + trusts it shows the old answer for a frame at the exact moment the user picks + a new one. Waiting a frame lets the pointer settle the question: still inside + the list means the user is browsing and the preview is already right. */ +function restWhenIdle(list, rest) { + let queued; + return () => { + cancelAnimationFrame(queued); + queued = requestAnimationFrame(() => { + if (list.matches(':hover')) return; + rest(list.contains(document.activeElement) ? document.activeElement : null); + }); + }; +} + +/* The chosen pair takes the top of the rail, so the answer is the first thing + the list shows and the rest keep their dealt order underneath it. + + The move is not made at the moment of choosing, and that is the whole + design. Reordering under a live cursor drags the row the user just clicked + out from under the pointer and parks a different pair where the next click + is already aimed. Reordering on a radio group's arrow keys is worse: every + press both moves focus and commits, so a list that re-sorts per press + re-sorts between presses and the group cannot be crossed at all. + + So the rail reorders only while nobody is working it. A selection sets the + request; the pointer leaving, or focus leaving, spends it. Arriving on the + screen spends it too, which is the backstop if a settle is ever missed. */ +const typeRail = fontOptions.closest('.picker-type-rail'); +const RAIL_NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', ' ']); +let hoistPending = false; +let pointerInRail = false; +let keyboardInRail = false; +let hoistFlash; + +/* :hover covers the one case the pointer events miss: the cursor already + resting where the rail appears, which fires no enter of its own. */ +const railBusy = () => pointerInRail + || typeRail.matches(':hover') + || (keyboardInRail && typeRail.contains(document.activeElement)); + +/* Nothing is under the cursor when the rows move, so the move itself needs no + transition to be readable. What it needs is somewhere for the eye to land + after it: the row that just took the top comes up bright and settles to its + resting checked state. Color only, so the acknowledgement cannot disturb + the list it is pointing at. */ +function flashHoisted(node) { + clearTimeout(hoistFlash); + for (const other of pairOrder) other.removeAttribute('data-hoisted'); + // Reading a layout property between the two writes is what restarts the + // animation on a row that is still marked from the previous hoist. + void node.offsetWidth; + node.dataset.hoisted = ''; + hoistFlash = setTimeout(() => node.removeAttribute('data-hoisted'), 700); +} + +function applyHoist({ force = false } = {}) { + hoistPending = false; + const chosen = fontOptions.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option'); + if (!chosen) return; + const wanted = [chosen, ...pairOrder.filter((node) => node !== chosen)]; + const shown = [...fontOptions.querySelectorAll('.picker-type-option')]; + const moved = wanted.some((node, index) => node !== shown[index]); + if (moved) { + // Rows move as real nodes so tab order, reading order, and what is on + // screen stay one order. Re-parenting can drop focus on the way, which + // would strand a keyboard user outside the group they were just in. + const focused = document.activeElement; + for (const node of wanted) fontOptions.append(node); + if (fontOptions.contains(focused) && document.activeElement !== focused) { + focused.focus({ preventScroll: true }); + } + } + if (!moved && !force) return; + fontOptions.scrollTo({ top: 0 }); + syncScrollButtons(); + if (moved) flashHoisted(chosen); +} + +/* Disabled: the reorder pulled the row the user had just clicked away from + where they left it, which reads as the list moving on its own. The rail now + keeps the order the pairs were dealt in and the checked state alone says + which one is chosen. */ +function requestHoist() { + hoistPending = false; +} + +function settleHoist() { + if (hoistPending && !railBusy()) applyHoist(); +} + +typeRail.addEventListener('pointerenter', () => { + pointerInRail = true; +}); + +// A frame of slack so :hover has resolved before the guard reads it. +typeRail.addEventListener('pointerleave', () => { + pointerInRail = false; + requestAnimationFrame(settleHoist); +}); + +typeRail.addEventListener('pointerdown', () => { + keyboardInRail = false; +}); + +typeRail.addEventListener('keydown', ({ key }) => { + if (RAIL_NAV_KEYS.has(key)) keyboardInRail = true; +}); + +typeRail.addEventListener('focusout', ({ relatedTarget }) => { + if (typeRail.contains(relatedTarget)) return; + keyboardInRail = false; + settleHoist(); +}); + +/* Type scale. + + The numbers are the real ones: step n is 16px * ratio^n, and a Golden Ratio + H1 really is 287px. The specimen cannot be, because 287px of "Golden Ratio" + is three times the width of the sheet, and a sheet fitted to that H1 would + set its paragraph at 2px. + + So the rendering compresses the exponent by half, which keeps every scale in + its own character (a Minor Second sheet still reads as nearly flat, a Golden + Ratio one as dramatic) while bringing the range from 18x down to about 4x. + The measured fit below then scales the whole sheet if even that overflows. + The quoted px and rem stay untouched, which is the point of showing them. + + Both preview columns are set from these sizes. The sheet takes the measured + fit on top of them, because seven rows have to hold inside a box that cannot + grow; the reading column scrolls instead, so it sets the step values as they + come. That is the only place the two columns differ. + + The sheet's sample is the scale's name and nothing more. A sentence there was + costing the sheet its size: the longest name inside "This is the ... scale" + ran to twice the width of a column shared three ways, so the fit halved every + step to hold it, and the scale with the longest name came out flatter than + the one below it. Running text belongs to the specimen column now. */ +const SCALE_BASE = 16; +const SCALE_BODY_PX = 13; +const SCALE_COMPRESSION = 0.5; +const scaleRows = [...scaleSheet.querySelectorAll('[data-scale-row]')]; +const scaleRatioInput = document.querySelector('[name="type-scale-ratio"]'); +const checkedScale = () => scaleOptions.querySelector('input[name="type-scale"]:checked'); +const scaleRowInput = (node) => node?.closest('.picker-strategy-option')?.querySelector('input'); + +const trimZeros = (value) => value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1'); + +/* One pass is exact: every size is linear in the fit factor, and the row boxes + the text has to fit inside do not move when it changes. */ +function fitScaleSheet() { + scaleSheet.style.setProperty('--ts-fit', '1'); + if (!scaleSheet.clientHeight) return; + let over = 1; + for (const row of scaleRows) { + const sample = row.querySelector('[data-scale-sample]'); + over = Math.max( + over, + sample.scrollWidth / Math.max(sample.clientWidth, 1), + sample.scrollHeight / Math.max(row.clientHeight, 1), + ); + } + if (over > 1.001) scaleSheet.style.setProperty('--ts-fit', (1 / over).toFixed(4)); +} + +function drawTypeScale(input) { + const ratio = Number(input.dataset.ratio); + const name = input.dataset.scaleName; + for (const row of scaleRows) { + const step = Number(row.dataset.scaleRow); + const px = SCALE_BASE * ratio ** step; + const rendered = (SCALE_BODY_PX * ratio ** (step * SCALE_COMPRESSION)).toFixed(3); + row.style.setProperty('--ts-size', rendered); + scaleSpecimen.style.setProperty(`--ts-step-${step}`, rendered); + row.querySelector('[data-scale-sample]').textContent = name; + row.querySelector('[data-scale-px]').textContent = `${Math.round(px)}px`; + row.querySelector('[data-scale-rem]').textContent = `${trimZeros((px / SCALE_BASE).toFixed(2))}rem`; + } + fitScaleSheet(); +} + +/* The cursor previews and the click commits, the contract screens 03 and 11 + already use. Drawing is everything the two columns show; committing is the + one line that records an answer, so a browsed row cannot leave one behind. */ +function commitTypeScale(input) { + drawTypeScale(input); + scaleRatioInput.value = Number(input.dataset.ratio).toFixed(3); +} + +scaleOptions.addEventListener('pointerover', (event) => { + const input = scaleRowInput(event.target); + if (input) drawTypeScale(input); +}); +scaleOptions.addEventListener('focusin', (event) => { + const input = scaleRowInput(event.target); + if (input) drawTypeScale(input); +}); +const restScalePreview = restWhenIdle(scaleOptions, (focused) => { + drawTypeScale(scaleRowInput(focused) ?? checkedScale()); +}); +scaleOptions.addEventListener('pointerleave', restScalePreview); +scaleOptions.addEventListener('focusout', restScalePreview); +scaleOptions.onchange = ({ target }) => { + if (target.matches('input[name="type-scale"]')) commitTypeScale(target); +}; + +const syncSpecimenScroll = wireListScroll(scaleSpecimen); + +new ResizeObserver(fitScaleSheet).observe(scaleSheet); +// Every face swap changes the width of the same string, fit included. +document.fonts?.addEventListener('loadingdone', fitScaleSheet); +commitTypeScale(checkedScale()); + +/* Screen 11: the icon specimen. Every pack draws the same twenty-four concepts, + so the sheet compares hands rather than catalogs. The drawings are vendored + at build time and fetched on first arrival: eleven packs of markup is more + than the first paint should carry for a screen most runs reach late. + + The cursor previews and the click commits, the same contract screen 03 uses + for color strategies. What differs is that no CSS can swap a drawing, so the + preview is painted here rather than remapped through custom properties. */ +const iconOptions = document.querySelector('[data-icon-options]'); +const iconSheet = document.querySelector('[data-icon-sheet]'); +const iconField = iconSheet.querySelector('[data-icon-field]'); +const iconStrip = iconSheet.querySelector('[data-icon-strip]'); +const iconPackInputs = ['name', 'license', 'url'].map((key) => [ + key, + document.querySelector(`[name="icon-pack-${key}"]`), +]); +let iconPacks; +let iconRequest; +let iconPainted; + +wireListScroll(iconOptions); + +const checkedIconPack = () => iconOptions.querySelector('input[name="icon-pack"]:checked'); +const iconRowInput = (node) => node?.closest('.picker-icon-row')?.querySelector('input'); + +/* Packs disagree on grid size and on whether they are drawn as strokes or as + filled paths, so the wrapper carries the pack's own viewBox and paint rather + than one house style the drawings were never made for. */ +function iconSvg(pack, glyph) { + const attrs = Object.entries(pack.attrs) + .map(([key, value]) => `${key}="${value}"`) + .join(' '); + return ``; +} + +function paintIconPack(id) { + const pack = iconPacks?.get(id); + if (!pack || iconPainted === id) return; + iconPainted = id; + delete iconField.dataset.empty; + iconField.innerHTML = pack.glyphs + .map((glyph) => `${iconSvg(pack, glyph)}`) + .join(''); + iconStrip.innerHTML = pack.glyphs.map((glyph) => iconSvg(pack, glyph)).join(''); +} + +/* The answer carries the pack's name, license, and home page, not just its + slug: whoever reads the answers has to credit it without looking it up. */ +function commitIconPack(input) { + for (const [key, field] of iconPackInputs) field.value = input.dataset[`pack${key[0].toUpperCase()}${key.slice(1)}`]; + paintIconPack(input.value); +} + +function loadIconPacks() { + iconRequest ??= fetch('/icon-packs.json') + .then((response) => (response.ok ? response.json() : Promise.reject())) + .then((data) => { + iconPacks = new Map(data.packs.map((pack) => [pack.id, pack])); + paintIconPack(checkedIconPack().value); + }) + .catch(() => { + iconField.dataset.empty = ''; + iconField.textContent = 'Icon sets could not be loaded.'; + }); + return iconRequest; +} + +iconOptions.addEventListener('pointerover', (event) => { + const input = iconRowInput(event.target); + if (input) paintIconPack(input.value); +}); +iconOptions.addEventListener('focusin', (event) => { + const input = iconRowInput(event.target); + if (input) paintIconPack(input.value); +}); +/* With the pointer away, the keyboard is driving: the row it is on is the one + being asked about, and only once focus has left too is the answer itself. */ +const restIconPreview = restWhenIdle(iconOptions, (focused) => { + paintIconPack(iconRowInput(focused)?.value ?? checkedIconPack().value); +}); +iconOptions.addEventListener('pointerleave', restIconPreview); +iconOptions.addEventListener('focusout', restIconPreview); +iconOptions.onchange = ({ target }) => { + if (target.matches('input[name="icon-pack"]')) commitIconPack(target); +}; + +commitIconPack(checkedIconPack()); + +/* Screen 06: the motion scene. Its three energies are told apart by amplitude + on one shared 8s timeline, and the loudest difference between them, whether + the page arrives or is simply there, is over inside the first fifth of it. + A visitor who hovers a row five seconds into the loop is therefore comparing + two energies on press depth alone, which is why hovering restarts the scene: + the answer to "what does this one look like" is the scene from its first + frame. CSS cannot rewind an animation, so this is the one part of the screen + that is not a custom property. + + Only a change of energy restarts it. Sliding the pointer across a row it is + already previewing would otherwise keep the page in a permanent entrance. */ +const motionOptions = document.querySelector('[data-question="motion"] .picker-strategy-choices'); +// One board per surface the question is put to. All of them are mounted and one +// is shown, so the replay covers every board rather than the visible one: a +// hidden board's timeline is cancelled by its own display: none and starts over +// when its tab is opened, and the two must not disagree about which frame is +// first. +const motionScenes = [...document.querySelectorAll('.picker-preview-motion')]; +const checkedMotion = () => motionOptions.querySelector('input:checked').value; +let motionShown; + +function replayMotion(energy) { + if (energy === motionShown) return; + motionShown = energy; + // The hover rules resolve on their own; this only puts the timeline back to + // its first frame, pseudo-elements and all. + for (const scene of motionScenes) { + for (const animation of scene.getAnimations({ subtree: true })) { + animation.cancel(); + animation.play(); + } + } +} + +/* Every scene's pointer route is written in container units, but the elements + it visits space themselves in fixed pixels, so where a given one sits as a + fraction of the frame changes with the frame's size. The route is measured + off the live layout instead: one custom property per stop, re-resolved + whenever the frame resizes, so the pointer lands on the element that + reacts at every viewport. + + The stops a board does not have are simply not set, which is what lets one + table serve both boards: the landing page's scenes visit its buttons and + cards, the portfolio's visit its plates and its carousel arrow, and neither + keyframe list names a property its own board cannot measure. */ +const MOTION_STOPS = { + '--mtr-nav1': '.ps-nav-bars i:nth-child(1)', + '--mtr-nav2': '.ps-nav-bars i:nth-child(2)', + '--mtr-cta1': '.ps-actions i:first-child', + '--mtr-cta2': '.ps-actions i:last-child', + '--mtr-card1': '.ps-gallery-item:nth-child(1) > i', + '--mxi-work1': '.ps-index-row:nth-of-type(1) > .ps-image', + '--mxi-work2': '.ps-index-row:nth-of-type(2) > .ps-image', + '--mxi-rail': '.ps-index-arrow--next', +}; + +function plotMotionRoute(scene = null) { + for (const board of scene ? [scene] : motionScenes) { + const desk = board.querySelector('.ps-desktop'); + if (!desk?.clientWidth) continue; + // Summed up the offsetParent chain rather than read once: an element's + // offsets are relative to its nearest positioned ancestor, which for the + // buttons is not the frame. + const center = (el) => { + let x = el.offsetWidth / 2; + let y = el.offsetHeight / 2; + for (let node = el; node && node !== desk; node = node.offsetParent) { + x += node.offsetLeft; + y += node.offsetTop; + } + return { x: (x / desk.clientWidth) * 100, y: (y / desk.clientHeight) * 100 }; + }; + for (const [name, selector] of Object.entries(MOTION_STOPS)) { + const el = desk.querySelector(selector); + if (!el) continue; + const c = center(el); + board.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`); + } + // The entry and exit point: straight above the first nav item, off-frame. + const nav1 = desk.querySelector('.ps-nav-bars i:nth-child(1)'); + board.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`); + } +} + +for (const scene of motionScenes) { + new ResizeObserver(() => plotMotionRoute(scene)).observe(scene.querySelector('.ps-desktop')); +} + +const motionRowValue = (node) => node?.closest('.picker-strategy-option')?.querySelector('input').value; + +motionOptions.addEventListener('pointerover', (event) => { + const value = motionRowValue(event.target); + if (value) replayMotion(value); +}); +motionOptions.addEventListener('focusin', (event) => { + const value = motionRowValue(event.target); + if (value) replayMotion(value); +}); +const restMotionPreview = restWhenIdle(motionOptions, (focused) => { + replayMotion(motionRowValue(focused) ?? checkedMotion()); +}); +motionOptions.addEventListener('pointerleave', restMotionPreview); +motionOptions.addEventListener('focusout', restMotionPreview); +motionOptions.addEventListener('change', ({ target }) => { + if (target.matches('input[name="motion-energy"]')) replayMotion(target.value); +}); + +/* Custom fonts. A URL is carried through as-is; an uploaded face is handed to + the server, which stores the bytes and returns the path the answers record. + Neither is parsed here: the questionnaire validates at the end. */ +const fontModal = document.querySelector('[data-font-modal]'); +const customStatus = fontModal.querySelector('[data-custom-status]'); +const customFile = (role) => fontModal.querySelector(`[data-custom-file="${role}"]`); +const customUrl = (role) => fontModal.querySelector(`[data-custom-url="${role}"]`); +const customSave = fontModal.querySelector('[data-font-custom-save]'); +const customHint = fontModal.querySelector('[data-custom-hint]'); +const FONT_SOURCE_SEP = '\n'; +const FONT_FILE_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']); +const customFilesByRole = { heading: [], body: [] }; + +function fontFileExtension(name) { + const index = name.lastIndexOf('.'); + return index === -1 ? '' : name.slice(index).toLowerCase(); +} + +function isFontFile(file) { + return FONT_FILE_EXTENSIONS.has(fontFileExtension(file.name)); +} + +function setChosenFontFiles(input, files) { + const transfer = new DataTransfer(); + for (const file of files) transfer.items.add(file); + input.files = transfer.files; +} + +function sameFontFile(a, b) { + return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified; +} + +function mergeFontFiles(existing, incoming) { + const merged = [...existing]; + for (const file of incoming) { + if (!merged.some((entry) => sameFontFile(entry, file))) merged.push(file); + } + return merged; +} + +function renderCustomFileList(role) { + const list = fontModal.querySelector(`[data-custom-file-list="${role}"]`); + const files = customFilesByRole[role]; + list.replaceChildren(); + list.hidden = files.length === 0; + for (const file of files) { + const item = document.createElement('li'); + item.className = 'picker-modal-file-item'; + const name = document.createElement('span'); + name.textContent = file.name; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'picker-modal-file-remove'; + remove.setAttribute('aria-label', `Remove ${file.name}`); + remove.textContent = '×'; + remove.onclick = () => { + customFilesByRole[role] = customFilesByRole[role].filter((entry) => !sameFontFile(entry, file)); + setChosenFontFiles(customFile(role), customFilesByRole[role]); + renderCustomFileList(role); + syncCustomSave(); + customStatus.textContent = ''; + }; + item.append(name, remove); + list.append(item); + } +} + +/* Either kind of source satisfies a role: a URL or at least one file. */ +const roleHasSource = (role) => customFilesByRole[role].length > 0 || customUrl(role).value.trim() !== ''; + +/* Both roles are required. A heading-only sheet used to commit and let the + body fall back to whichever pair happened to be selected, which is a + substitution the user never asked for and never saw. + + The hint carries the reason. A commit that is dead with no explanation + leaves the user clicking an inert control to find out why, so the footer + names the role that is still open and the button is never the only + feedback. It reads as instruction rather than correction: nothing here is + wrong yet, the sheet is just unfinished. */ +function syncCustomSave() { + const missing = ['heading', 'body'].filter((role) => !roleHasSource(role)); + customSave.disabled = missing.length > 0; + if (missing.length === 2) { + customHint.textContent = 'Heading and body each need a URL or a file.'; + } else if (missing.length === 1) { + const role = missing[0] === 'heading' ? 'Heading' : 'Body'; + customHint.textContent = `${role} still needs a URL or a file.`; + } else { + customHint.textContent = ''; + } +} + +function parseFontSources(source) { + if (!source) return []; + if (source.includes(FONT_SOURCE_SEP)) return source.split(FONT_SOURCE_SEP).filter(Boolean); + return [source]; +} + +document.querySelector('[data-font-custom-open]').onclick = () => { + customStatus.textContent = ''; + for (const role of ['heading', 'body']) { + customFilesByRole[role] = []; + customFile(role).value = ''; + setChosenFontFiles(customFile(role), []); + // The URL goes with the files. Clearing half the sheet leaves a field + // holding an address whose companion upload is already gone, and the + // commit would then read as available for work the user did last time. + customUrl(role).value = ''; + renderCustomFileList(role); + } + syncCustomSave(); + fontModal.showModal(); +}; + +document.querySelector('[data-font-custom-close]').onclick = () => fontModal.close(); + +for (const role of ['heading', 'body']) { + customFile(role).onchange = ({ target }) => { + const incoming = [...target.files].filter(isFontFile); + if (incoming.length !== target.files.length) { + customStatus.textContent = 'Only .woff2, .woff, .ttf, and .otf files are accepted.'; + } else { + customStatus.textContent = ''; + } + customFilesByRole[role] = mergeFontFiles(customFilesByRole[role], incoming); + setChosenFontFiles(target, customFilesByRole[role]); + target.value = ''; + renderCustomFileList(role); + syncCustomSave(); + }; + customUrl(role).oninput = syncCustomSave; +} + +async function uploadFontFile(file) { + const response = await fetch('/font-upload', { + method: 'POST', + headers: { 'X-Font-Filename': file.name, 'Content-Type': 'application/octet-stream' }, + body: file, + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Upload failed'); + return result.path; +} + +async function resolveCustomFace(role) { + const files = customFilesByRole[role].filter(isFontFile); + if (files.length) { + const paths = await Promise.all(files.map((file) => uploadFontFile(file))); + return { + family: files[0].name.replace(/\.[^.]+$/, ''), + source: paths.join(FONT_SOURCE_SEP), + }; + } + const url = customUrl(role).value.trim(); + return url ? { family: url.split('/').pop().replace(/\.[^.]+$/, '') || 'Custom', source: url } : null; +} + +customSave.onclick = async () => { + customStatus.textContent = 'Saving…'; + let heading; + let body; + try { + [heading, body] = await Promise.all([resolveCustomFace('heading'), resolveCustomFace('body')]); + } catch (error) { + customStatus.textContent = error.message; + return; + } + // The button is already gated on both roles; this is the same rule stated + // where the pair is built, so no path can assemble a half pair. + if (!heading || !body) { + customStatus.textContent = 'Add a URL or a file for both the heading and the body.'; + return; + } + const pair = { + id: 'custom', + name: 'Custom', + heading, + body, + why: 'Your own faces', + }; + fontManifest.pairs = [pair, ...fontManifest.pairs.filter(({ id }) => id !== 'custom')]; + // A second upload replaces the first rather than stacking a second row of + // the same id, which the rail would then have to order against itself. + for (const node of [...pairOrder]) { + if (node.querySelector('input').value === 'custom') removePairCard(node); + } + addPairCard(pair, { checked: true, first: true }); + loadCustomFace(pair); + syncFontPair(pair); + // The dialog holds the pointer and focus, so the rail is free to reorder. + applyHoist({ force: true }); + fontModal.close(); +}; + +// A hosted stylesheet is linked; an uploaded file is registered as a face so +// the specimen and the artboards can render it immediately. +function loadCustomFace({ heading, body }) { + for (const face of [heading, body]) { + if (!face?.source) continue; + if (/\.(css)(\?|$)/i.test(face.source) || (!face.source.includes(FONT_SOURCE_SEP) && !/\.(woff2?|ttf|otf)(\?|$)/i.test(face.source))) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = face.source; + document.head.append(link); + continue; + } + for (const entry of parseFontSources(face.source)) { + const src = entry.startsWith('http') ? entry : `/fonts/${entry.split('/').pop()}`; + document.fonts.add(new FontFace(face.family, `url(${src})`)); + } + document.fonts.load(`16px "${face.family}"`); + } +} + +function setActiveRole(role) { + if (hint.textContent === hint.dataset[role]) return; + hint.classList.add('is-changing'); + setTimeout(() => { + hint.textContent = hint.dataset[role]; + hint.classList.remove('is-changing'); + }, 90); +} + +/* The contrast alert: a danger badge on the neutral swatch, with its + explanation in the sibling tooltip. Both strips carry one. This strip's + badge is re-read from renderPreview() on every render, so every path + that can change which color sits in the neutral slot (inputs, tints, + rings, card switch, reset, reorder) lands here; the strategy strip's + badge is re-read by paintStrategyBands() against the committed fields + that strip is painted from. One block-level line per failed check, + mid-tone first. */ +const alertBadge = $('[data-contrast-alert]', panel); +const alertTip = $('[data-contrast-tip]', panel); + +function paintContrastAlert(badge, tip, issues) { + badge.hidden = issues.length === 0; + tip.replaceChildren(...issues.map((text) => { + const line = document.createElement('span'); + line.textContent = text; + return line; + })); +} + +function syncNeutralAlert() { + paintContrastAlert(alertBadge, alertTip, cards.length ? neutralContrastIssue(state().colors) : []); +} + +function setColor(role, hex, detached = true) { + const itemState = state(); + itemState.colors[role] = hex.toUpperCase(); + itemState.detached[role] = detached; + renderBand(role); + syncRings(card()); + setActiveRole(role); +} + +function sample(ring, item, image, x, y) { + const saved = states.get(item.id); + const source = sourceCanvas(image); + const role = ring.dataset.role; + x = Math.min(100, Math.max(0, x)); + y = Math.min(100, Math.max(0, y)); + const pixel = source.getContext('2d').getImageData( + Math.round(x / 100 * (source.width - 1)), + Math.round(y / 100 * (source.height - 1)), + 1, + 1, + ).data; + const hex = `#${[pixel[0], pixel[1], pixel[2]].map((value) => value.toString(16).padStart(2, '0')).join('').toUpperCase()}`; + saved.rings[role] = [x, y]; + saved.colors[role] = hex; + saved.detached[role] = false; + syncRings(item); + if (item === card()) renderBand(role); + setActiveRole(role); + drawLoupe(ring, item, image); +} + +function wireRing(ring, item, image) { + const move = (e) => { + const box = image.getBoundingClientRect(); + sample(ring, item, image, (e.clientX - box.left) / box.width * 100, (e.clientY - box.top) / box.height * 100); + }; + ring.onpointerdown = (e) => { + if (e.button !== 0) return; + ring.focus(); + ring.setPointerCapture(e.pointerId); + ring.dataset.dragging = ''; + move(e); + }; + ring.onpointermove = (e) => { + if (ring.hasPointerCapture(e.pointerId)) { + dismissRingGuide(); + move(e); + } + }; + ring.onpointerup = (e) => { + move(e); + ring.releasePointerCapture(e.pointerId); + delete ring.dataset.dragging; + if (document.activeElement !== ring) delete loupe.dataset.visible; + }; + ring.onfocus = () => image.complete && drawLoupe(ring, item, image); + ring.onblur = () => { + if (!('dragging' in ring.dataset)) delete loupe.dataset.visible; + }; + ring.onkeydown = (e) => { + const moves = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + if (!moves[e.key]) return; + e.preventDefault(); + dismissRingGuide(); + const step = e.shiftKey ? 5 : 1; + const [x, y] = states.get(item.id).rings[ring.dataset.role]; + sample(ring, item, image, x + moves[e.key][0] * step, y + moves[e.key][1] * step); + }; +} + +function buildCard(item) { + const node = $(`[data-${item.type}-card]`).content.firstElementChild.cloneNode(true); + node.dataset.id = item.id; + const face = $('.picker-card-face', node); + if (item.type === 'seed') { + $$('span', face).forEach((stripe, index) => { + stripe.style.setProperty('--seed-color', states.get(item.id).colors[ROLES[index]]); + }); + } else { + const image = $('img', face); + image.alt = `Visual cue ${item.id}`; + image.src = `/cues/${encodeURIComponent(item.id)}.png`; + $$('.picker-ring', face).forEach((ring) => { + const role = ring.dataset.role; + ring.setAttribute('aria-valuetext', states.get(item.id).colors[role]); + wireRing(ring, item, image); + }); + image.addEventListener('load', () => { + item.defaultRings = roleMap((role) => { + const [x, y] = item.palette[role].at; + return [x / image.naturalWidth * 100, y / image.naturalHeight * 100]; + }); + states.get(item.id).rings = structuredClone(item.defaultRings); + syncRings(item); + if (item === card()) sourceCanvas(image); + }); + } + item.node = node; + return node; +} + +function closeTints() { + if (!openTint) return; + const role = openTint; + const item = $(`[data-band-item="${role}"]`, panel); + const strip = $('[data-tints]', item); + // Focus cannot stay on a button that is about to be hidden, and the control + // that opened the strip is the one the user is back to deciding about. + const held = strip.contains(document.activeElement); + delete item.dataset.tintOpen; + strip.hidden = true; + openTint = null; + const toggle = $(`[data-edit-tints="${role}"]`, panel); + toggle.setAttribute('aria-expanded', 'false'); + toggle.dataset.tip = 'Edit tints'; + if (held) toggle.focus(); +} + +function render() { + const active = card(); + cards.forEach(({ node }, index) => { + const delta = index - current; + node.dataset.pos = Math.max(-2, Math.min(2, delta)); + node.classList.toggle('is-far', Math.abs(delta) > 2); + node.setAttribute('aria-hidden', delta !== 0); + }); + $('[data-deck-prev]').disabled = current === 0; + $('[data-deck-next]').disabled = current === cards.length - 1; + count.textContent = `${current + 1} / ${cards.length}`; + for (const role of ROLES) renderBand(role); + syncRings(active); + if (active.type === 'cue') { + const image = $('img', active.node); + if (image.complete && image.naturalWidth) sourceCanvas(image); + } + closeTints(); +} + +function browse(index) { + const next = Math.min(cards.length - 1, Math.max(0, index)); + const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; + points.children[next].scrollIntoView({ behavior, block: 'start' }); +} + +function deckKeys(e) { + if (e.key === 'Escape' && openTint) { + e.preventDefault(); + closeTints(); + return; + } + const delta = { ArrowLeft: -1, ArrowRight: 1 }[e.key]; + if (!delta) return; + if (e.target instanceof Element && e.target.closest('[role="slider"], input')) return; + // A band held by the keyboard owns the arrows until it is dropped. + if (drag?.keyboard) return; + e.preventDefault(); + browse(current + delta); +} + +const activate = (value) => document[value ? 'addEventListener' : 'removeEventListener']('keydown', deckKeys, true); + +function openTints(role) { + closeTints(); + openTint = role; + setActiveRole(role); + const item = $(`[data-band-item="${role}"]`, panel); + const strip = $('[data-tints]', item); + const current = state().colors[role]; + const [L, C, H] = hexToOklch(current); + // The strip pivots on the band's color: the middle swatch is that color + // exactly, the three to its left step toward near-white, the three to its + // right toward near-black. Chroma eases off toward the ends so the + // extremes stay in gamut instead of clipping to a different hue. + $$('[data-tint]', strip).forEach((button, index) => { + const offset = index - 3; + const tintL = offset < 0 + ? L + (0.96 - L) * (-offset / 3) + : L - (L - 0.16) * (offset / 3); + const hex = offset === 0 + ? current + : oklchToHex([tintL, C * (1 - 0.3 * (Math.abs(offset) / 3)), H]); + button.dataset.tint = hex; + button.style.setProperty('--tint-color', hex); + button.setAttribute('aria-label', hex); + button.toggleAttribute('data-current', offset === 0); + }); + strip.hidden = false; + item.dataset.tintOpen = ''; + const toggle = $(`[data-edit-tints="${role}"]`, panel); + toggle.setAttribute('aria-expanded', 'true'); + toggle.dataset.tip = 'Close tints'; + $('button', strip)?.focus(); +} + +/* Reordering the palette. + + The four slots keep their roles. Their labels sit in the feet and hold still; + what a drag carries is the color, so dropping the neutral band in second + place is what makes that color the secondary. + + The bands travel and the feet do not, which is also what makes the swap + invisible. When a band lands, every slot on screen is already showing the + color it is about to be given, so the colors can be rearranged and the + transforms dropped in the same frame with nothing to see. + + Pointer events rather than HTML5 drag and drop: the native API cannot be + animated and behaves badly by touch. */ +const REORDER_MS = 220; +const LIFT_SCALE = 1.02; +const EASE = getComputedStyle(document.documentElement).getPropertyValue('--ks-ease').trim() || 'ease'; +const REORDER_KEYS = new Set([' ', 'Enter', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape']); +const moves = new WeakMap(); +// The editable strip on the palette screen, and its reading copy under the +// strategy screen's choices. Which one a drag started in decides how far the +// result has to be carried. +const paletteBands = $('.picker-bands', panel); +const strategyBands = document.querySelector('[data-band-scope="strategy"]'); +const strategyGrid = document.querySelector('.picker-screen[data-screen="03"] .picker-strategy-grid'); +const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches; +const bandNodes = (scope) => ROLES.map((role) => $(`[data-band="${role}"]`, scope)); +const gripNodes = (scope) => ROLES.map((role) => $(`[data-grip="${role}"]`, scope)); +const roleLabel = (index, scope) => $(`[data-band-item="${ROLES[index]}"] .picker-band-foot h2`, scope).textContent; +const announceReorder = (scope, message) => { + const status = $('[data-reorder-status]', scope.closest('.picker-screen') ?? scope); + if (status) status.textContent = message; +}; +let drag = null; +let landing = null; + +/* Which band stands in which slot while the lifted one is headed for `to`: + the entry at slot n is the index of the band that belongs there. */ +function slotOrder(from, to) { + const order = ROLES.map((_, index) => index); + order.splice(to, 0, ...order.splice(from, 1)); + return order; +} + +/* FLIP: the node is put where it belongs first and then played back from where + it was, so nothing downstream ever measures a half-finished position. */ +function travel(node, previous, offset, animate) { + node.style.transform = offset ? `translateX(${offset}px)` : ''; + moves.get(node)?.cancel(); + if (!animate) return; + moves.set(node, node.animate( + [{ transform: `translateX(${previous}px)` }, { transform: `translateX(${offset}px)` }], + { duration: REORDER_MS, easing: EASE }, + )); +} + +function lift(offset, animate) { + const node = drag.nodes[drag.from]; + const previous = drag.offsets[drag.from]; + const at = (value) => `translateX(${value}px) scale(${LIFT_SCALE})`; + drag.offsets[drag.from] = offset; + node.style.transform = at(offset); + moves.get(node)?.cancel(); + if (!animate) return; + moves.set(node, node.animate( + [{ transform: at(previous) }, { transform: at(offset) }], + { duration: REORDER_MS, easing: EASE }, + )); +} + +function openGap(to, animate) { + slotOrder(drag.from, to).forEach((index, slot) => { + if (index === drag.from) return; + const offset = drag.homes[slot].left - drag.homes[index].left; + if (offset === drag.offsets[index]) return; + travel(drag.nodes[index], drag.offsets[index], offset, animate); + drag.offsets[index] = offset; + }); + drag.to = to; +} + +function nearestSlot(shift) { + const { homes, from } = drag; + const center = homes[from].left + homes[from].width / 2 + shift; + const away = (index) => Math.abs(homes[index].left + homes[index].width / 2 - center); + return homes.reduce((best, home, index) => (away(index) < away(best) ? index : best), 0); +} + +/* The rearranged palette goes back through the same path a reset takes: the + card's saved colors are rewritten and the screen is rendered from them, so + the readouts, the rings, the preview, and the values the select button copies + into the form all still come off one source. */ +function commitOrder(from, to, scope) { + const saved = state(); + const colors = {}; + const detached = {}; + const rings = {}; + slotOrder(from, to).forEach((index, slot) => { + const role = ROLES[slot]; + const source = ROLES[index]; + colors[role] = saved.colors[source]; + detached[role] = saved.detached[source]; + // The ring that sampled the color travels with it, so no marker is left + // sitting on a pixel it no longer matches. + rings[role] = saved.rings[source]; + }); + Object.assign(saved, { colors, detached, rings }); + render(); + // `render` reaches the palette screen, which draws itself from the deck. The + // rest of the run reads the palette out of the committed fields instead, so a + // reorder made on the strategy screen has to rewrite those too or it would + // last exactly as long as the screen it happened on. + if (scope !== paletteBands) recommitPalette(); +} + +/* The fields the select button writes, rewritten from the state the bands were + just rearranged into, and everything that reads them repainted. Silent before + a palette has been chosen: there is nothing to keep in step yet. */ +function recommitPalette() { + if (!$('[name="palette-source"]').value) return; + for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role]; + paintStrategyBands(); + paintStage(); + for (const artboard of document.querySelectorAll('.picker-screen[data-active] [data-artboard]')) { + syncCommittedPalette(artboard); + } +} + +/* The strategy screen's band is a reading of the committed palette rather than + of the deck, which is also what makes it correct after a reorder on either + screen: both end in the fields this paints from. Its neutral carries the + same contrast alert the editable strip does, judged against the same + committed colors this strip is painted from, so a reorder made here that + drags a mid-tone into the neutral slot is reported here, on the swatch + the visitor just dropped. */ +const strategyAlertBadge = $('[data-contrast-alert]', strategyBands); +const strategyAlertTip = $('[data-contrast-tip]', strategyBands); + +function paintStrategyBands() { + const committed = roleMap((role) => $(`[name="palette-${role}"]`).value); + if (Object.values(committed).some((hex) => !hex)) return; + for (const role of ROLES) { + const band = $(`[data-band="${role}"]`, strategyBands); + band.style.setProperty('--band-color', committed[role]); + band.style.setProperty('--band-ink', contrastInk(committed[role])); + $('output', band).textContent = committed[role]; + } + paintContrastAlert(strategyAlertBadge, strategyAlertTip, neutralContrastIssue(committed)); +} + +/* Everything screen 03 spends on something other than the answer: the block's + own padding, the question over the grid, and the gap between them. The grid + caps itself against what is left, so this is the number that decides whether + the screen fits the window. + + Measured as the difference between the block and the grid inside it rather + than added up from the parts, because the parts are not knowable from here: + the question is one or two lines at a size that tracks the window's width. It + comes to between 259 and 312px across the laptop range. The literal in the + rule was 250, short by 9 to 62, and the screen paid the difference by running + past the fold. Nothing here reads the grid's height back into itself, so the + value settles on the first pass. */ +function fitStrategyColumn() { + const block = strategyGrid?.parentElement; + if (!block || !block.offsetParent) return; + const chrome = `${Math.ceil( + block.getBoundingClientRect().height - strategyGrid.getBoundingClientRect().height, + )}px`; + // Writing an unchanged value would still be a style change, and the resize it + // provokes is what turns a settling measurement into a loop. + if (block.style.getPropertyValue('--pk-chrome') === chrome) return; + block.style.setProperty('--pk-chrome', chrome); +} + +/* The block's height is the only thing that can move what sits above the grid, + so it is what is watched: a question that reflowed to a second line and a + window that moved the padding clamp both arrive here. */ +if (strategyGrid) new ResizeObserver(fitStrategyColumn).observe(strategyGrid.parentElement); + +function reorderedSummary(from, to) { + const { colors } = state(); + return slotOrder(from, to) + .map((index, slot) => `${ROLES[slot]} ${colors[ROLES[index]]}`) + .join(', '); +} + +function beginDrag(index, keyboard, scope) { + // A second grab with one still in the air puts the first back first, so no + // measurement is taken off a row that is standing somewhere temporary. + if (drag) endDrag(true); + landing?.(); + closeTints(); + const nodes = bandNodes(scope); + for (const node of nodes) { + moves.get(node)?.cancel(); + node.style.transform = ''; + } + drag = { + from: index, + to: index, + keyboard, + scope, + nodes, + grips: gripNodes(scope), + homes: nodes.map((node) => node.getBoundingClientRect()), + offsets: ROLES.map(() => 0), + id: card().id, + pointerId: null, + startX: 0, + }; + nodes[index].dataset.dragging = ''; + drag.grips[index].dataset.dragging = ''; + lift(0, false); +} + +function endDrag(cancel, after) { + const { nodes, grips, from, to, homes, id, scope } = drag; + const target = cancel ? 0 : homes[to].left - homes[from].left; + if (cancel) openGap(from, !reduceMotion()); + drag = null; + delete nodes[from].dataset.dragging; + delete grips[from].dataset.dragging; + landing = () => { + landing = null; + // The deck can be browsed under a drag, and another card's colors are not + // the ones that were picked up. + if (!cancel && to !== from && card().id === id) commitOrder(from, to, scope); + for (const node of nodes) { + moves.get(node)?.cancel(); + node.style.transform = ''; + } + after?.(); + }; + if (reduceMotion()) { + landing(); + return; + } + const node = nodes[from]; + const start = node.style.transform; + const end = `translateX(${target}px) scale(1)`; + node.style.transform = end; + const animation = node.animate( + [{ transform: start }, { transform: end }], + { duration: REORDER_MS, easing: EASE }, + ); + moves.set(node, animation); + animation.finished.then(() => landing?.(), () => {}); +} + +function finishDrag(keyboard) { + const { from, to, scope } = drag; + if (to === from) { + endDrag(true); + return; + } + const summary = `Palette reordered. ${reorderedSummary(from, to)}`; + // Focus follows the color rather than the slot it left, and only once the + // band has landed: a focus ring drawn on a band in flight points at nothing. + endDrag(false, keyboard ? () => gripNodes(scope)[to].focus() : null); + announceReorder(scope, summary); +} + +const gripOf = (target) => (target instanceof Element ? target.closest('[data-grip]') : null); +const scopeOf = (grip) => grip.closest('.picker-bands'); + +/* Both strips reorder, and they reorder the same palette. The handlers are + written once against whichever strip the grip that was grabbed belongs to. */ +function wireReorder(root) { + root.addEventListener('pointerdown', (event) => { + const grip = gripOf(event.target); + if (!grip || event.button !== 0 || !cards.length) return; + // Keeps the press off the focus ring and out of a text selection. The grip is + // reached by keyboard through Tab, and the pointer drag needs neither. + event.preventDefault(); + beginDrag(ROLES.indexOf(grip.dataset.grip), false, scopeOf(grip)); + drag.pointerId = event.pointerId; + drag.startX = event.clientX; + grip.setPointerCapture(event.pointerId); + }); + + root.addEventListener('pointermove', (event) => { + if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return; + const { homes, from } = drag; + // Held inside the strip: a band that can be flown across the screen covers + // the image the colors were pulled from and says nothing more than this does. + const shift = Math.min( + homes.at(-1).left - homes[from].left, + Math.max(homes[0].left - homes[from].left, event.clientX - drag.startX), + ); + lift(shift, false); + const to = nearestSlot(shift); + if (to !== drag.to) openGap(to, !reduceMotion()); + }); + + root.addEventListener('pointerup', (event) => { + if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return; + finishDrag(false); + }); + + root.addEventListener('pointercancel', (event) => { + if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return; + endDrag(true); + }); + + root.addEventListener('keydown', (event) => { + const grip = gripOf(event.target); + if (!grip || !REORDER_KEYS.has(event.key) || !cards.length) return; + const scope = scopeOf(grip); + const held = Boolean(drag?.keyboard); + const grab = event.key === ' ' || event.key === 'Enter'; + if (!held && (drag || !grab)) return; + event.preventDefault(); + if (!held) { + beginDrag(ROLES.indexOf(grip.dataset.grip), true, scope); + announceReorder(scope, `${roleLabel(drag.from, scope)} color lifted, position ${drag.from + 1} of ${ROLES.length}. Arrow keys move it, space drops it, escape puts it back.`); + return; + } + if (grab) { + finishDrag(true); + return; + } + if (event.key === 'Escape') { + endDrag(true); + announceReorder(scope, 'Reorder cancelled.'); + return; + } + const step = { ArrowLeft: -1, ArrowRight: 1, Home: -ROLES.length, End: ROLES.length }[event.key]; + const to = Math.min(ROLES.length - 1, Math.max(0, drag.to + step)); + if (to === drag.to) return; + openGap(to, !reduceMotion()); + lift(drag.homes[to].left - drag.homes[drag.from].left, !reduceMotion()); + announceReorder(scope, `Position ${to + 1} of ${ROLES.length}.`); + }); + + // A lifted band with nobody holding it would keep answering arrow keys aimed + // at whatever took the focus. + root.addEventListener('focusout', (event) => { + if (!drag?.keyboard || event.target !== drag.grips[drag.from]) return; + const scope = drag.scope; + endDrag(true); + announceReorder(scope, 'Reorder cancelled.'); + }); +} + +wireReorder(panel); +wireReorder(strategyBands); + +panel.onpointerover = panel.onfocusin = ({ target }) => { + const band = target.closest('[data-band]'); + if (band) setActiveRole(band.dataset.band); +}; +panel.oninput = ({ target }) => { + if (target.matches('[data-color-input]')) setColor(target.dataset.colorInput, target.value); +}; +panel.onclick = async (e) => { + const data = e.target.closest('button')?.dataset; + if (!data) return; + if (data.copyColor) { + const hex = state().colors[data.copyColor]; + await navigator.clipboard.writeText(`${hex}\n${formatOklch(hex)}`); + const tip = data.tip; + data.tip = 'Copied'; + setTimeout(() => data.tip = tip, 1200); + } else if (data.editTints) { + // The button that opened the strip closes it, so the × is a second way out + // rather than the only one. + if (openTint === data.editTints) closeTints(); + else openTints(data.editTints); + } else if (data.customColor) $(`[data-color-input="${data.customColor}"]`, panel).click(); + else if (data.tint) setColor(openTint, data.tint); + else if ('closeTints' in data) closeTints(); + else if ('reset' in data) { + const item = card(); + const fresh = createState(item); + if (item.type === 'cue' && item.defaultRings) fresh.rings = structuredClone(item.defaultRings); + states.set(item.id, fresh); + render(); + } else if ('selectPalette' in data) { + const item = card(); + $('[name="palette-source"]').value = item.id; + for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role]; + // Painted before the screen changes rather than on arrival: the next screen + // is captured for the transition as it is handed over, and a strip still + // holding last run's colors is what would be captured. + paintStrategyBands(); + paintStage(); + } +}; + +/* The select button lives with the screen's actions rather than inside the + fieldset, so the delegated handler above never reached it and the palette + left the run as four empty fields. It commits the same state the bands are + rendered from, reordered or not, so it is that handler and not a copy. */ +$('[data-select-palette]').addEventListener('click', panel.onclick); + +$('[data-deck-prev]').onclick = () => browse(current - 1); +$('[data-deck-next]').onclick = () => browse(current + 1); +scroller.addEventListener('scroll', () => { + const height = points.firstElementChild?.offsetHeight || 1; + const next = Math.min(cards.length - 1, Math.round(scroller.scrollTop / height)); + if (next !== current) { + const node = card().node; + node.dataset.exit = next > current ? 'left' : 'right'; + setTimeout(() => delete node.dataset.exit, 280); + current = next; + delete card().node.dataset.exit; + render(); + } +}, { passive: true }); +document.addEventListener('picker:screenchange', (event) => { + activate(event.detail.screen === '02'); + // The hub re-reads every answer on arrival, so an edit made on a + // question screen is on its card by the time the return lands. + if (event.detail.screen === '04b') renderHub(); + // Every artboard on the screen being shown, whatever question it belongs to, + // gets the committed palette. The scale sheet is deliberately not one: it is + // picker chrome in the picker's own theme, not a page in the user's palette. + const screenNode = document.querySelector(`.picker-screen[data-screen="${event.detail.screen}"]`); + for (const artboard of screenNode?.querySelectorAll('[data-artboard]') ?? []) { + syncCommittedPalette(artboard); + } + // Coming back to the strategy screen from further along, where the palette may + // have been reordered on the screen it was left on. A hidden block measures + // zero, so its budget is resolved on arrival for the same reason the scale + // sheet's fit is. + if (event.detail.screen === '03') { + paintStrategyBands(); + fitStrategyColumn(); + } + // A per-surface question holds whatever tab it was left on while its own + // screen is up, and the rest follow it. + const leader = surfaceQuestions.find((question) => question.screen === event.detail.screen); + leader?.paint(); + alignSurfaces(leader); + // Arriving is the quietest moment there is, so the rail settles here even + // if it is already in order: the chosen pair is the row you land on. + if (event.detail.screen === '04') { + keyboardInRail = false; + applyHoist({ force: true }); + } + // A hidden screen has no animations to rewind, so the scene starts on the + // frame after the one that revealed it, not on the frame that asked. + if (event.detail.screen === '06') { + requestAnimationFrame(() => { + plotMotionRoute(); + motionShown = null; + replayMotion(checkedMotion()); + }); + } + // A hidden sheet measures zero, so the fit can only be resolved on arrival. + // The rest waits a frame: the screen change focuses the first control after + // this event, and that scrolls the list back to the top. + // + // It also puts focus on the first interval rather than the chosen one, and + // this list's preview reads focus twice: once to draw, and once to decide + // what to fall back to when the pointer leaves. Left alone, a visitor arrives + // to two columns set in an interval they never picked, and gets it again the + // first time they browse the list and come back. So the answer takes the + // focus, which is where a radio group's focus belongs anyway. + if (event.detail.screen === '05') { + fitScaleSheet(); + const chosen = checkedScale(); + requestAnimationFrame(() => { + chosen?.parentElement.scrollIntoView({ block: 'center' }); + chosen?.focus({ preventScroll: true }); + if (chosen) drawTypeScale(chosen); + syncSpecimenScroll(); + }); + } + // The specimen is fetched the first time the screen is asked for, and the + // scroll waits a frame for the same reason screen 05's does. + if (event.detail.screen === '11') { + void loadIconPacks(); + requestAnimationFrame(() => { + checkedIconPack()?.parentElement.scrollIntoView({ block: 'center' }); + }); + } +}); + +// Screen 01b: the surface tiles. Multi-select with a floor of one, so the +// answer can never arrive empty; the continue button holds that line while +// the markup's default (persuade) keeps scripted runs moving. +const modeInputs = [...document.querySelectorAll('input[name="surface-modes"]')]; +const modesNext = document.querySelector('[data-modes-next]'); +const syncModesNext = () => { + if (modesNext) modesNext.disabled = !modeInputs.some((input) => input.checked); +}; + +/* The palette is judged on a page, and which page that should be is answered + here: the palette screen shows the preview of the surface being designed. + Each tile already holds a finished drawing of its mode, so the chosen one is + lifted out of the tile rather than restated on screen 02, which is the only + way a new variant lands on both screens at once. + + First in tile order, not first clicked. A multi-select answer has no other + stable primary, and click order would move the palette's test page around + for reasons the visitor cannot see. */ +const modeTiles = modeInputs.map((input) => input.closest('.picker-mode-tile')); +const modePreviews = modeTiles.map((tile) => tile?.querySelector('.picker-preview')); +const landingPreview = preview.cloneNode(true); +let previewSource; + +function syncModePreview() { + // The per-surface questions read the tiles rather than watch them, so they + // are rebuilt from here: every path that changes the tiles already runs this. + syncSurfaces(); + const chosen = modeInputs.findIndex((input) => input.checked); + /* Which tile leads is also what the stylesheet needs, to hang the view + transition's name on the one drawing screen 02 goes on to show. Marked + ahead of the early return below, so the answer never rests on whether the + drawing itself changed, and left off a tile whose drawing is not this + component, since nothing of that tile arrives on the next screen. */ + modeTiles.forEach((tile, index) => { + tile?.toggleAttribute('data-lead', index === chosen && Boolean(modePreviews[index])); + }); + // A tile drawn in something other than this component keeps the landing page, + // which is also the floor for the empty answer the continue button blocks. + const source = (chosen === -1 ? null : modePreviews[chosen]) ?? landingPreview; + if (source === previewSource) return; + previewSource = source; + const clone = source.cloneNode(true); + // Decorative on both screens, but the marker sits on the tile's wrapper + // rather than on the component, so it does not survive the lift by itself. + clone.setAttribute('aria-hidden', 'true'); + for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id'); + preview.replaceWith(clone); + preview = clone; + renderPreview(); +} + +/* ============================================================ + Questions answered once per surface. + + Several screens ask the same shape of question: the answer that suits the + marketing page rarely suits the tool it sells, so it is asked once for each + surface chosen on 01b. A tab on the frame's corner carries between them, an + option a surface cannot take is turned off in place with the reason where + its description was, and every chosen surface leaves an answer whether or + not anyone ever opened its tab. + + A screen opts in by rendering one tab strip and nothing else: + +