See 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.
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Impeccable
+
+
+
Who it is for, emotional state, needs, trust triggers.
+
+
+
+ Who they are
+
+
+
Primary (next 6 months)
+
Wedding planners sourcing ceremony florals for discerning clients. They arrive with a brief already formed and need proof of craft, a clear seasonal point of view, and a fast path to book a consultation before their client meeting.
+
+
+
Secondary
+
Couples commissioning ceremony florals directly; corporate hospitality teams and office managers evaluating gifting or subscription programs.
+
+
+
+
+
+ Emotional state
+
+
Quiet authority
+
All commissioners arrive with intent, not casual browsing. The landing experience should read as calm proof of craft rather than persuasion: one hero arrangement per viewport, copy that doesn't oversell.
+
+
+
+
+ Needs
+
+
Proof of craft before they will put the atelier in front of a client.
+
A clear seasonal point of view that distinguishes the atelier from directory florists.
+
A fast path to book a consultation, often before their own client meeting.
+
+
+
+
+ Trust triggers
+
+
Partner quotes from planners and past commissions.
+
Seasonal POV expressed as a disciplined editorial stance, not a portfolio dump.
+
Studio story that establishes the Tokyo atelier credential in person-referral conversations.
Purpose, differentiator, proof points, use cases, what must be clear first.
+
+
+
+ Purpose
+
+
Hanazono Atelier register: brand
+
Marketing site for a boutique Tokyo flower atelier. Communicate craft, seasonal restraint, and three commission lanes. The visual experience sells the atelier.
+
+
+
+
+ Conversion
+
+
+
Primary conversion
+
Book a consultation. The booking path stays obvious without urgency gimmicks, price grids, or package upsell scaffolding.
+
+
+
Success definition
+
8–12 qualified consultation requests per month from planners and corporate buyers combined; at least half convert to a paid commission within 90 days. Brand credibility for in-person referrals matters as much as direct bookings.
+
+
+
What must be clear first
+
Proof before pitch: planners need credible signals (partner quotes, seasonal POV, studio story) before they will book.
+
+
+
+
+
+ Use cases
+
+
+
Weddings
+
Ceremony florals commissioned through planners or directly by couples; the flagship lane.
+
+
+
Corporate gifting
+
Hospitality teams and office managers commissioning seasonal gifting programs.
+
+
+
Subscriptions
+
Recurring arrangements for offices and hospitality spaces, evaluated by the same corporate buyers.
A commission-first visual system that treats each page like a museum craft gallery: one object per viewport, bilingual hierarchy, specimen metadata in mono, and captions that never compete with the work. Warm mechanical opinion shows up as disciplined spacing and label codes, not decoration.
+
+
+
+
+ Personality & voice
+
+ Warm
+ Mechanical
+ Opinionated
+
+
Seasonal craft with editorial restraint. Tokyo atelier, not Pinterest floristry.
+
+
+
+ References (specific steals)
+
+
+
Studio Mondine
+
Commission-gallery pacing: one hero arrangement per viewport, no thumbnail clutter.
+
+
+
Aesop
+
Product-page restraint: generous whitespace, one focal image, copy that doesn't oversell.
+
+
+
Ikenobo Tokyo exhibitions
+
Seasonal asymmetry and disciplined negative space; not Instagram-perfect symmetry.
+
+
+
+
+
+ Anti-references
+
+
The Knot / WeddingWire directory style: stock rose photos, price tables, “packages from $X” grids.
+
Canva wedding mood-board aesthetic: blush-to-gold gradients, script fonts on every heading, floating petal PNG overlays.
+
Squarespace “Flora” theme family: identical three-column service cards with leaf icons.
+
Pinterest-pastel monoculture: identical card grids with icon + heading + blurb; italic display serif + mono labels on every section.
+
+
+
+
+ Design principles
+
+
One focal point per viewport — gallery pacing over thumbnail clutter; let a single arrangement or proof moment carry the screen.
+
Restraint is the sell — generous whitespace, copy that doesn't oversell; Aesop-level discipline on every lane page.
+
Seasonal asymmetry, not Instagram symmetry — compose like an exhibition, not a mood board; negative space is part of the craft.
+
Proof before pitch — planners need credible signals before they will book.
+
Earn the consultation — make the booking path obvious without urgency gimmicks.
From the colors: frontmatter of DESIGN.md. Hover to fan; click to copy the hex.
+
+
+
+ Roles
+
+
+
Muted Plum #8b4a6b
+
CTA fills, primary links, consultation path emphasis. Must pass 4.5:1 on cream for labels; darker hover state required.
+
+
+
Deep Umber Ink #2a2118
+
Headlines, primary body text, filled primary button ground. The authoritative voice, not pure black.
+
+
+
Seasonal Moss
+
Lane labels, season codes, commission metadata accents. Marks the seasonal point of view without becoming a second CTA color. Exact token to be resolved during implementation.
+
+
+
Warm Cream Ground #faf6f0
+
Page background, exhibition hall surface. Not Pinterest-pastel; a true warm ground with controlled chroma.
+
+
+
Caption Charcoal
+
Figcaptions, secondary metadata, mono label text. Subordinate to umber ink; never competes with display headlines.
+
+
+
+
+
+ Named rules
+
+
The No-Wash Rule
+
Backgrounds stay flat cream or tonal ink fills. Gradient washes, blush-to-gold transitions, and floating PNG petal overlays are prohibited.
+
+
+
The Accent Rarity Rule
+
Plum is for conversion and proof-path emphasis, not eyebrow decoration on every section. Moss is for seasonal lane identity, not a second hero accent.
Font families, hierarchy, font scale, readability rules.
+
+
+
+ Type sample
+
+
花園アトリエ — seasonal craft, quiet authority.
+
Shippori Mincho carries the display voice: editorial warmth and Japanese headline authority, never faux-bolded. Source Sans 3 carries English body clarity at sixteen pixels and above, with captions that defer to the object.
Zero iconography mentions in PRODUCT.md, DESIGN.md, or the design.json sidecar for this project. Closest adjacent decision is a taste boundary, not an icon rule: no decorative floral motifs in UI chrome.
+
+
+
+
+ Planned (visual document seed)
+
The upcoming seed flow asks the icon-library question directly, with named options:
+
+ Lucide
+ Tabler Icons
+ Hugeicons
+
+
Library choice covers icon style only; stroke weight, metaphor rules, and icon-button behavior remain open fields.
Flat by default. Depth comes from tonal layering: cream ground, umber ink hierarchy, hairline rules at 8–12% opacity. Shadows are not part of the vocabulary; section separation uses spacing rhythm and 1px rules, not cards with drop shadows.
+
+
+
+
+ Motion feel
+
+
Responsive motion only: hover and focus feedback; no scroll choreography, no auto-playing reveals.
+
Subtle opacity shifts on hover/focus; no lift-and-glow card elevation.
+
prefers-reduced-motion collapses transitions to instant state changes.
Components are documented on the first scan pass, once there's code to capture actual tokens and states from.
+
+
+
+
Commission Lane
+
Signature component: a lane page hero pairing one arrangement with the seasonal POV and the booking path.
+
+
+
Consultation Form
+
The conversion surface. Labels, error states, and focus order are screen-reader priorities.
+
+
+
Gallery Grid
+
Exhibition pacing: one object per viewport, no thumbnail clutter grids.
+
+
+
Proof Quote
+
Partner quotes with clear attribution order; the proof-before-pitch signal.
+
+
+
+
+
+ Interaction states
+
+
Hover/focus feedback on every interactive element; instant state changes under reduced motion.
+
Plum CTA hover requires a darker state that still passes 4.5:1 on cream.
+
No decorative borders, glassmorphism, or side-stripe accents on cards or callouts.
+
+
+
+
+ Accessibility & inclusion
+
+
+
Target
+
WCAG 2.2 AA on all public pages.
+
+
+
Bilingual type
+
Japanese and English both at ≥16px body; display mincho for JP headings, Source Sans 3 for EN body; no faux-bold on mincho.
+
+
+
Contrast
+
Plum accent #8b4a6b must pass 4.5:1 on cream for links and CTA labels, with a darker hover state.
+
+
+
Reduced motion
+
Respect prefers-reduced-motion; no auto-playing scroll reveals.
+
+
+
Screen reader priority
+
Consultation form labels, error states, and proof-quote attribution order.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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;
+---
+
+
+
+ {portfolioExtras && }
+
+
+ {!type && }
+
+ {!type && }
+
+ {type ? (
+
+ ) : (
+
+ )}
+ {!type && (
+
+
+
+
+
+ )}
+
+ {ops ? (
+
+ {/* The rail is where a tool keeps its navigation, so it carries the
+ longest run of micro labels on any board. The open one is a filled
+ plate rather than a recolor: a selection is a state, not a word. */}
+
+ {[0, 1, 2].map((n) => (
+
+
+
+ ))}
+
+
+
+
+ {[0, 1, 2].map(() => (
+
+
+
+
+ ))}
+
+ {/* A chart is the one thing a dashboard has that no other surface
+ does, and it sets two kinds of text a pair has to be judged on
+ together: a heading over a plot, and a row of category labels
+ sitting under it at the smallest step on the board. The bars
+ themselves stay schematic, drawn as a share of the plot rather
+ than from numbers, because what is being chosen here is a pair
+ and not a reading of the data. */}
+
+ {/* The rail names sections of one document rather than pages of a site,
+ which is why it cannot take the nav's words: the two would read as
+ the same list drawn twice. */}
+
+ {[0, 1, 2, 3].map((n) => (
+
+
+
+ ))}
+
+ {/* Two heading levels and the passages between them. A documentation
+ page is the one surface where the relationship between a page title
+ and the heading under it is visible at a glance, so the board shows
+ both rather than the single heading the drawing carries. */}
+
+
{!type && }
+
{!type && [0, 1].map(() => )}
+
+
{!type && [0, 1, 2].map(() => )}
+
{!type && [0, 1, 2].map(() => )}
+ {/* Prose, a list, and a callout are what a documentation page is
+ made of, and the board carried two of the three. The list is
+ also the only setting here that asks the body face to hold a
+ short ragged line against a marker. */}
+
+ {[0, 1].map(() => )}
+
+
+
+
+ {!type && [0, 1].map(() => )}
+
+
+
+
+ ) : index && !type ? (
+ /* The index as a wireframe, for the screen that judges movement rather
+ than glyphs. Same four bands as the set version below, drawn in bars:
+ a page title, two staggered entries, and the carousel rail. */
+
+ {portfolioExtras && (
+
+ )}
+
+ {[0, 1].map((n) => (
+
+
+
+
+
+
+
+
+ ))}
+ {portfolioExtras && (
+
+
+
+
+ )}
+
+
+
+ {[0, 1, 2, 3].map((n) => (
+
+ ))}
+
+
+
+
+ ) : index ? (
+
+ {/* The page title the drawing has no room for, over the two staggered
+ entries it draws. The second entry is what the handset shows too,
+ and it is the one that proves the caption ladder repeats: a pair set
+ on an index is read down a run of entries rather than at one of
+ them. Alternating the plate keeps the run from collapsing into a
+ single column at a width that has room for two. */}
+
+ {[0, 1].map((n) => (
+
+
+
+
+
+
+
+
+
+ ))}
+ {/* The chevrons stay shapes because an arrow is not a word. The track's
+ segments are, so they are set: a carousel's stops are named. */}
+
+
+
+ {[0, 1, 2, 3].map((n) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+
+
+ {type ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+
+
+ {[0, 1, 2, 3].map((n) => (
+
+ {n > 0 && }
+
+
+ {type
+ ?
+ : }
+
+
+ ))}
+
+
+
+
+ {type
+ ?
+ : }
+
+ {/* Three cards once the labels are words, four while they are bars. The
+ type board sets three because a fourth column leaves the copy beside
+ it under the width the widest pairing needs, and the preview copy is
+ written to that count: a fourth card here would draw a picture with
+ no caption under it. The bar composition is measured at four by the
+ layout screens, so it keeps them. */}
+
+
+ )}
+
+ {/*
+ Off-grid marks, drawn only where the page has already left the field:
+ opacity rides --pvs-drift, so on the disciplined answers they are
+ rendered and invisible and the answers that pay for them are the ones
+ that asked. Absolute, so the desktop grid still has exactly its five
+ declared rows.
+ */}
+ {!type && (
+
+
+
+
+ )}
+ {cursor && }
+
+
+ {phone && (
+
+
+
+
+ {!type && }
+
+ {!type && (
+
+
+
+
+ )}
+
+ {ops ? (
+
+
+
+
+
+
+ {/* Three lanes rather than five. The handset keeps the leading one, so
+ the emphasis stays on the same lane the desktop board marks, and it
+ drops the tail: five labels at this width would either collide or
+ be set under the floor this preview is allowed to reach. */}
+
+
+
+ {[52, 74, 100].map((h, n) => (
+
+ ))}
+
+
+ {[0, 1, 2].map(() => )}
+
+
+ {/* The table has nowhere to put four columns at this width, so it
+ collapses to the two cells that carry the row: what it is and what
+ it comes to. */}
+
+ {[0, 1, 2].map((n) => (
+
+
+
+
+
+ ))}
+
+
+ {[0, 1].map((n) => (
+
+
+
+
+ ))}
+
+
+ ) : docs ? (
+
+ {/* The crumb is the drawing's, and it is a phone element there because
+ the rail it stands in for has nowhere to go at this width. */}
+
+
{!type && }
+
{!type && [0, 1].map(() => )}
+
+
{!type && [0, 1, 2].map(() => )}
+
+ {[0, 1].map(() => )}
+
+
+
+
+ {!type && [0, 1].map(() => )}
+
+
+
+ ) : index ? (
+
+ {/* No page title here. At this width the display step would take the
+ card, and what a handset shows of an index is the work. */}
+ {portfolioExtras && (
+
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;
+---
+
+
+
+
+
{title}
+
+
+
+
+
+ {/* One field per surface, disabled until its tile is chosen, so a
+ surface nobody asked for stays out of the answers and a surface
+ nobody opened still leaves one. A flat question keeps the same
+ fields for the same reading and withholds the name, which is the
+ whole of what a form submits. */}
+ {asked.map((mode) => (
+
+ ))}
+
+
+ {boards || tabsAttrs ? (
+ /* All of them mounted and one shown, so a tab switch costs a hidden
+ attribute rather than a rebuild. The stage is the box the strip takes
+ its row on, above the frame, which is why a screen with one board and
+ a strip is wrapped in it too. */
+
+
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.',
+ },
+ ],
+ },
+];
+---
+
+
+
+
+
+
+
+
+ {/* 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. */}
+
+
+ {/* Live edit tray: every queued request with its status, fed by polling. */}
+
+
+
+
+
+
+
+
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)}` : ''}
+
`;
+
+/* 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 `
+ ${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) => `
${escapeHtml(line)}
`).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', `
`
+ + 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', `
${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) => `
`;
+
+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' })}
+
+
+ inside the stage the frame is drawn on, plus one disabled hidden field per
+ surface marked data-surface-field="-". That field is where
+ the surface's answer is kept; whether it also carries a name, and so whether
+ the run records a key per surface or only the leading surface's choice, is
+ the question's own business. What each surface may take and where it
+ lands untouched are already on the tiles as data-allow- and
+ data-default-; why an option is out is on the option itself as
+ data-blocked-reason. All of that comes from data/surfaces.js.
+
+ Which surfaces the question is put to at all is the set of fields it
+ rendered. A screen that leaves one out is asking nothing of that surface, so
+ its tab is never offered, its answer is never defaulted, and the bare key
+ falls to the leading surface that was asked. A screen every chosen surface
+ was left out of is not part of the run: it goes out of the form and the
+ navigation steps over it, because a key holding an answer nobody was asked
+ for reads downstream as a decision, and nothing can tell the two apart.
+
+ data-surface-stage on the frame additionally mounts one drawing per chosen
+ surface, lifted from that surface's tile and painted with the committed
+ palette. Screen 03 is the only screen that wants that today. A screen that
+ draws its own per-surface variants instead marks each of them
+ data-surface="" in its own markup and leaves the attribute off; the
+ showing and hiding is the same work either way.
+ ============================================================ */
+const chosenSurfaces = () => modeInputs.filter((input) => input.checked);
+const surfaceInput = (value) => modeInputs.find((input) => input.value === value);
+
+function buildSurfaceQuestion(tabs) {
+ const name = tabs.dataset.surfaceTabs;
+ // The stage the strip sits on is also the box a per-surface drawing has to
+ // land inside, so it is read off the DOM rather than named a second time in
+ // the markup.
+ const frame = tabs.parentElement;
+ const screen = tabs.closest('.picker-screen')?.dataset.screen;
+ const mounts = 'surfaceStage' in frame.dataset;
+ const flat = 'surfaceFlat' in tabs.dataset;
+ const properName = 'surfaceProperName' in tabs.dataset;
+ /* The rows are markup on every screen but the font one, where they are dealt
+ from fonts.json after this runs and can still be added to afterwards. So
+ the group is read when it is needed rather than captured once. */
+ const optionInputs = () => [...document.querySelectorAll(`input[name="${name}"]`)];
+ const rowOf = (value) => optionInputs().find((input) => input.value === value)?.closest('.picker-strategy-option');
+ // Read by attribute name rather than through dataset, so the group's own
+ // value is the lookup and no screen has to restate it in camel case. A
+ // question that rules nothing out carries no attribute at all, which is a
+ // different answer from an empty one and is kept apart from it here.
+ const allowedFor = (value) => {
+ const allow = surfaceInput(value)?.getAttribute(`data-allow-${name}`);
+ return allow == null ? null : new Set(allow.split(' ').filter(Boolean));
+ };
+ const defaultFor = (value) => surfaceInput(value)?.getAttribute(`data-default-${name}`) || optionInputs()[0]?.value;
+ const fieldFor = (value) => document.querySelector(`input[type="hidden"][data-surface-field="${name}-${value}"]`);
+ const titleOf = (value) => rowOf(value)?.querySelector('.picker-strategy-title')?.textContent ?? value;
+ // The fields are the scope: a surface with nowhere to leave an answer is one
+ // this question was never put to.
+ const applies = (value) => Boolean(fieldFor(value));
+ const applicable = () => chosenSurfaces().filter((input) => applies(input.value));
+ // A question every surface takes is asked on every run; only a scoped one can
+ // end up with nothing to ask.
+ const scoped = modeInputs.some((input) => !applies(input.value));
+ const host = tabs.closest('.picker-screen');
+ let activeSurface = null;
+
+ /* One drawing per chosen surface. All of them stay mounted and one is shown,
+ so a tab switch costs a hidden attribute rather than a rebuild and the
+ frame never blinks. */
+ function mount(chosen) {
+ for (const node of frame.querySelectorAll('[data-surface]')) node.remove();
+ for (const input of chosen) {
+ const source = modePreviews[modeInputs.indexOf(input)];
+ if (!source) continue;
+ const clone = source.cloneNode(true);
+ // Decorative here as on the tile, but the marker sits on the tile's
+ // wrapper rather than on the drawing, so it does not survive the lift.
+ clone.setAttribute('aria-hidden', 'true');
+ for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
+ clone.dataset.surface = input.value;
+ frame.append(clone);
+ }
+ paint();
+ }
+
+ /* Painted once on the frame rather than on each drawing inside it, so the
+ strategy layer keeps a fixed reading of what was chosen and the drawings
+ themselves carry no inline color for it to argue with. */
+ function paint() {
+ if (mounts) syncCommittedPalette(frame, 'pkc');
+ }
+
+ function sync() {
+ const chosen = applicable();
+ /* Nothing left to ask, so the screen leaves the run: marked for the
+ navigation to step over, and its group taken out of the form so no key
+ comes back for it. applyApplicability() re-enables the rows the moment a
+ surface that takes the question is chosen again. */
+ if (scoped) {
+ host?.toggleAttribute('data-skip', chosen.length === 0);
+ for (const input of optionInputs()) input.disabled = chosen.length === 0;
+ }
+ if (mounts) mount(chosen);
+
+ /* Every chosen surface leaves an answer whether or not it was ever opened,
+ so the field is filled with the default the moment the tile is chosen and
+ the tab reports it as unset until someone says otherwise. */
+ for (const input of modeInputs) {
+ const field = fieldFor(input.value);
+ if (!field) continue;
+ field.disabled = !input.checked;
+ if (!input.checked) {
+ field.value = '';
+ delete field.dataset.chosen;
+ } else if (!field.value) {
+ // The font screen's rows are dealt after this first runs, so a question
+ // with nothing to fall back on yet leaves the field to the sync that
+ // follows the fetch.
+ field.value = defaultFor(input.value) ?? '';
+ }
+ }
+
+ buildTabs(chosen);
+ show(chosen.some((input) => input.value === activeSurface) ? activeSurface : chosen[0]?.value);
+ }
+
+ /* One surface needs no tabs: the frame is already showing the only answer
+ there is. A tab carries the surface it names and the dot that reports
+ whether that surface has been answered; which of the two states it is in is
+ markTabs()'s to write. */
+ function buildTabs(chosen) {
+ tabs.hidden = chosen.length < 2;
+ tabs.replaceChildren(...chosen.map((input) => {
+ const tab = document.createElement('button');
+ tab.type = 'button';
+ tab.className = 'picker-surface-tab';
+ tab.dataset.surfaceTab = input.value;
+ tab.innerHTML = '';
+ tab.append(input.dataset.surfaceLabel ?? input.value);
+ tab.onclick = () => choose(input.value);
+ return tab;
+ }));
+ markTabs();
+ }
+
+ /* The answer a tab reports is its own surface's field rather than the radio
+ on screen, which belongs to whichever tab is open. The dot says which kind
+ of answer it is: filled once someone chose it, hollow while it is still the
+ default the surface was given. The label says the answer itself, which the
+ tab no longer shows. */
+ function markTabs() {
+ for (const tab of tabs.children) {
+ const value = tab.dataset.surfaceTab;
+ const field = fieldFor(value);
+ const set = Boolean(field?.dataset.chosen);
+ const on = value === activeSurface;
+ const title = field.value ? titleOf(field.value) : '';
+ const name = tab.textContent;
+ tab.dataset.set = set ? 'yes' : 'no';
+ tab.setAttribute('aria-pressed', on ? 'true' : 'false');
+ tab.tabIndex = on ? 0 : -1;
+ /* A screen with no rows dealt yet has no answer to report, which is a
+ different state from an answer nobody chose and is the one the
+ question's unanswered sentence was written for. */
+ const answered = title
+ && tabs.dataset.surfaceAnswered.replace('{}', properName ? title : title.toLowerCase());
+ tab.setAttribute('aria-label', title
+ ? `${name}, ${answered}${set ? '' : ' by default'}`
+ : `${name}, ${tabs.dataset.surfaceUnanswered}`);
+ }
+ }
+
+ function show(value) {
+ if (!value) return;
+ activeSurface = value;
+ for (const drawing of frame.querySelectorAll('[data-surface]')) {
+ drawing.hidden = drawing.dataset.surface !== value;
+ }
+ applyApplicability();
+ const field = fieldFor(value);
+ const wanted = field?.value || defaultFor(value);
+ const input = optionInputs().find((radio) => radio.value === wanted);
+ if (input) input.checked = true;
+ markTabs();
+ }
+
+ /* A tab the visitor moved to leads: every other per-surface question follows
+ it, because the drawing on screen is painted from all of their answers at
+ once and a board shown on the Docs tab has to be colored with the answer
+ Docs was given. */
+ function choose(value) {
+ show(value);
+ alignSurfaces(api);
+ }
+
+ /* An option a surface cannot carry is left in place and turned off rather
+ than removed: the list keeps its shape as you move between surfaces, and
+ the row says why it is out instead of vanishing without a reason. */
+ function applyApplicability() {
+ const allowed = allowedFor(activeSurface);
+ for (const input of optionInputs()) {
+ const row = input.closest('.picker-strategy-option');
+ if (!row) continue;
+ const ok = !allowed || allowed.has(input.value);
+ const desc = row.querySelector('.picker-strategy-desc');
+ desc.dataset.copy ??= desc.textContent;
+ desc.textContent = ok ? desc.dataset.copy : row.dataset.blockedReason ?? desc.dataset.copy;
+ row.classList.toggle('is-blocked', !ok);
+ input.disabled = !ok;
+ }
+ }
+
+ /* Delegated rather than bound row by row, for the same reason the group is
+ read live: a pair uploaded halfway through the run has to answer into the
+ surface it was chosen on like any row the page was built with. */
+ document.addEventListener('change', ({ target }) => {
+ if (target?.name !== name || !target.checked) return;
+ record(target.value);
+ });
+
+ /* A flat question leaves one answer, so a choice made on one tab is the
+ choice on every tab that can take it. Anything that rules it out keeps
+ what it had, which is the whole reason the fields are kept per surface on
+ a question that only writes one of them down. */
+ function record(value) {
+ const surfaces = flat ? applicable().map((input) => input.value) : [activeSurface];
+ for (const surface of surfaces) {
+ const field = fieldFor(surface);
+ const allowed = allowedFor(surface);
+ if (!field || (allowed && !allowed.has(value))) continue;
+ field.value = value;
+ field.dataset.chosen = 'yes';
+ }
+ markTabs();
+ }
+
+ /* Arrow keys walk the group, which is the one thing a row of buttons owes a
+ keyboard once only the current tab is in the tab order. */
+ tabs.addEventListener('keydown', (event) => {
+ const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
+ if (!step) return;
+ const buttons = [...tabs.children];
+ const next = buttons[(buttons.findIndex((tab) => tab.dataset.surfaceTab === activeSurface) + step + buttons.length) % buttons.length];
+ event.preventDefault();
+ choose(next.dataset.surfaceTab);
+ next.focus();
+ });
+
+ /* A question that is off screen previews an answer it did not ask for, so
+ the radio the rest of the run reads is parked on whichever surface is
+ being looked at, and on the leading one when nothing on screen is showing
+ tabs. Without it the run would carry whichever surface was last on the tab,
+ and an option switched off for that surface would leave the answer empty.
+
+ A surface this question was never put to cannot lead it, so the leading
+ surface overall is followed only where it was asked and the first surface
+ in tile order that was asked leads otherwise. That is the rule the bare key
+ is written by: a run of app UI plus a portfolio has app UI leading the
+ questions both surfaces answer, and the portfolio leading motion. */
+ const park = (surface) => show(applies(surface) ? surface : applicable()[0]?.value);
+
+ const api = { screen, sync, paint, park, active: () => activeSurface };
+ return api;
+}
+
+const surfaceQuestions = [...document.querySelectorAll('[data-surface-tabs]')].map(buildSurfaceQuestion);
+
+/* One tab is showing at a time and every per-surface question reads it, so the
+ one whose screen is up owns it and the rest are moved to match. */
+function alignSurfaces(leader) {
+ for (const question of surfaceQuestions) {
+ if (question !== leader) question.park(leader?.active());
+ }
+}
+const syncSurfaces = () => {
+ for (const question of surfaceQuestions) question.sync();
+};
+const paintStage = () => {
+ for (const question of surfaceQuestions) question.paint();
+};
+
+/* ============================================================
+ Screen 04b: the configure hub.
+
+ Five questions remain after the layout screen, and every one of
+ them already holds an answer: sync() pre-fills each per-surface
+ field with its surface's default the moment the tile is checked,
+ and the flat radios ship with a default checked. The hub reads
+ those answers back out of the DOM (hidden fields for per-surface
+ questions, the checked radio for flat ones), maps each value to
+ the title on its own option row, and marks the cards whose
+ answer a person actually picked. Nothing here writes an answer;
+ the cards are a reading of the form.
+ ============================================================ */
+const hubScreen = document.querySelector('.picker-screen[data-screen="04b"]');
+/* Card target screen mapped to the radio group that screen answers. */
+const HUB_GROUPS = {
+ '06': 'motion-energy',
+ '08': 'boundary-style',
+ '09': 'corner-style',
+ '10': 'depth-style',
+ '11': 'icon-pack',
+};
+/* The icon set renders no per-surface fields, so it has no
+ dataset.chosen. A change event on its group is the one signal
+ that a person picked rather than the markup default: programmatic
+ checks never fire it. */
+const hubEdited = new Set();
+document.addEventListener('change', ({ target }) => {
+ if (target?.name === 'icon-pack') hubEdited.add(target.name);
+});
+
+const hubSurfaceLabel = (mode) => modeInputs.find((input) => input.value === mode)?.dataset.surfaceLabel ?? mode;
+
+/* The display title lives on the option row of the question's own
+ screen, so the hub never restates copy. The two dealt-data groups
+ carry their names as data attributes instead of a row label. */
+function hubOptionTitle(group, value) {
+ const input = document.querySelector(`input[name="${group}"][value="${value}"]`);
+ if (!input) return value;
+ if (input.dataset.scaleName) return `${input.dataset.scaleName} · ${input.dataset.ratio}`;
+ if (input.dataset.packName) return input.dataset.packName;
+ return input.closest('.picker-strategy-option')?.querySelector('.picker-strategy-title')?.textContent.trim() ?? value;
+}
+
+/* The enabled fields are exactly the chosen surfaces this question
+ was put to; sync() disables the rest and empties their values. */
+function hubSurfaceRows(group) {
+ return [...document.querySelectorAll(`input[type="hidden"][data-surface-field^="${group}-"]`)]
+ .filter((field) => !field.disabled && field.value)
+ .map((field) => ({
+ mode: field.dataset.surfaceField.slice(group.length + 1),
+ value: field.value,
+ chosen: field.dataset.chosen === 'yes',
+ }));
+}
+
+/* A summary row is a surface pill on the left and the value docked
+ right behind a patina dot, the same signal the surface tabs and the
+ option rows spend on a committed choice. The plain line survives
+ only for the skipped-card message. */
+function hubPlainLine(text) {
+ const line = document.createElement('span');
+ line.className = 'picker-hub-line';
+ line.append(text);
+ return line;
+}
+
+function hubRow(surfaceLabel, valueTitle, group, value) {
+ const entry = document.createElement('span');
+ entry.className = 'picker-hub-entry';
+ const pill = document.createElement('span');
+ pill.className = 'picker-hub-pill';
+ const label = document.createElement('span');
+ label.className = 'picker-hub-pill-label';
+ label.textContent = surfaceLabel;
+ const val = document.createElement('span');
+ val.className = 'picker-hub-value';
+ const dot = document.createElement('i');
+ dot.className = 'picker-hub-dot';
+ dot.setAttribute('aria-hidden', 'true');
+ val.append(dot, valueTitle);
+ pill.append(label, val);
+ entry.append(pill);
+ if (group !== 'icon-pack') {
+ const note = document.createElement('span');
+ note.className = 'picker-hub-note';
+ const why = value ? hubOptionDesc(group, value) : '';
+ note.textContent = why;
+ note.hidden = !why;
+ entry.append(note);
+ }
+ return entry;
+}
+
+/* The one-line reason the current answer is a sound default, read off
+ the option row's own description so the hub never restates copy.
+ dataset.copy is read first because applyAllows swaps textContent for
+ a blocked reason on surfaces that rule the option out. */
+function hubOptionDesc(group, value) {
+ const input = document.querySelector(`input[name="${group}"][value="${value}"]`);
+ const desc = input?.closest('.picker-strategy-option')?.querySelector('.picker-strategy-desc');
+ if (!desc) return '';
+ return (desc.dataset.copy ?? desc.textContent).trim();
+}
+
+function renderHub() {
+ if (!hubScreen) return;
+ for (const cardNode of hubScreen.querySelectorAll('.picker-hub-card')) {
+ const group = HUB_GROUPS[cardNode.dataset.hubTarget];
+ if (!group) continue;
+ const summary = cardNode.querySelector('[data-hub-summary]');
+ const mark = cardNode.querySelector('[data-hub-mark]');
+ const target = document.querySelector(`.picker-screen[data-screen="${cardNode.dataset.hubTarget}"]`);
+ const skipped = Boolean(target?.hasAttribute('data-skip'));
+ cardNode.classList.toggle('is-skipped', skipped);
+ cardNode.disabled = skipped;
+ cardNode.setAttribute('aria-disabled', skipped ? 'true' : 'false');
+ if (skipped) {
+ summary.replaceChildren(hubPlainLine('Not asked of these surfaces'));
+ cardNode.classList.remove('is-edited');
+ mark.hidden = true;
+ continue;
+ }
+ const rows = hubSurfaceRows(group);
+ let edited;
+ let lines;
+ if (rows.length === 0) {
+ const checked = document.querySelector(`input[name="${group}"]:checked`);
+ edited = hubEdited.has(group);
+ lines = [hubRow(
+ 'All surfaces',
+ checked ? hubOptionTitle(group, checked.value) : '',
+ group,
+ checked?.value,
+ )];
+ } else {
+ edited = rows.some((row) => row.chosen);
+ if (rows.length > 1 && rows.every((row) => row.value === rows[0].value)) {
+ lines = [hubRow('All surfaces', hubOptionTitle(group, rows[0].value), group, rows[0].value)];
+ } else if (rows.length === 1) {
+ lines = [hubRow(hubSurfaceLabel(rows[0].mode), hubOptionTitle(group, rows[0].value), group, rows[0].value)];
+ } else {
+ lines = rows.map((row) => hubRow(
+ hubSurfaceLabel(row.mode),
+ hubOptionTitle(group, row.value),
+ group,
+ row.value,
+ ));
+ }
+ }
+ summary.replaceChildren(...lines);
+ cardNode.classList.toggle('is-edited', edited);
+ mark.hidden = !edited;
+ }
+}
+
+/* Cards and the finish CTA jump by screen id. The inline nav script
+ owns goTo(); it listens for this event, so no swap logic is
+ duplicated here. A disabled card never reaches this handler. */
+hubScreen?.addEventListener('click', (event) => {
+ const cardNode = event.target.closest('[data-hub-target]');
+ if (!cardNode || cardNode.disabled) return;
+ document.dispatchEvent(new CustomEvent('picker:goto', {
+ detail: { screen: cardNode.dataset.hubTarget },
+ }));
+});
+
+for (const input of modeInputs) {
+ input.addEventListener('change', () => {
+ syncModesNext();
+ syncModePreview();
+ });
+}
+syncModesNext();
+syncModePreview();
+
+try {
+ const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
+ const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]);
+ // The agent's reading of PRODUCT.md arrives as cues.modes and pre-checks
+ // the surface tiles. Applied only when it names at least one real tile, so
+ // a bad hint cannot uncheck everything.
+ if (Array.isArray(cueData.modes)) {
+ const wanted = new Set(cueData.modes);
+ if (modeInputs.some((input) => wanted.has(input.value))) {
+ for (const input of modeInputs) input.checked = wanted.has(input.value);
+ syncModesNext();
+ syncModePreview();
+ }
+ }
+ cards = [
+ ...cueData.cues.map((id) => ({ id, type: 'cue', palette: cueData.palette[id] })),
+ ...seedData.seeds.map((seed) => ({ ...seed, type: 'seed' })),
+ ];
+ for (const item of cards) states.set(item.id, createState(item));
+ layer.append(...cards.map(buildCard));
+ points.innerHTML = ''.repeat(cards.length);
+ $('[data-select-palette]').disabled = false;
+ render();
+ activate(screen.hasAttribute('data-active'));
+} catch {
+ count.textContent = 'Palette sources could not be loaded.';
+}
+
+let manifest = FALLBACK_FONTS;
+let usingFallback = true;
+try {
+ const response = await fetch('/fonts.json');
+ const candidate = response.ok ? await response.json() : null;
+ if (isFontManifest(candidate)) {
+ manifest = normalizeFontManifest(candidate);
+ usingFallback = false;
+ }
+} catch {
+ // The built-in pairs keep older and incomplete runs moving.
+}
+renderFontPairs(manifest, usingFallback);
diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css
new file mode 100644
index 000000000..c8ac676d5
--- /dev/null
+++ b/picker/styles/design-context.css
@@ -0,0 +1,2129 @@
+/* ============================================================
+ Design context document — ported from
+ docs/design-context-categorization/design-context.html.
+ The mosaic landing, tile-to-fullscreen morph, sidebar shell,
+ and article vocabulary are the prototype's; only the theme
+ plumbing changed (picker vendors the kinpaku tokens and runs
+ dark-only, so the light remaps and the standalone token copy
+ are gone). Section vars scope to the doc's own roots.
+ ============================================================ */
+.dcx-shell,
+.dcx-expander {
+ --accent: var(--ks-kinpaku);
+ --accent-hover: var(--ks-kinpaku-pale);
+ --accent-wash: oklch(78% 0.12 82 / 0.1);
+ --accent-line: oklch(78% 0.12 82 / 0.32);
+ --panel-bg: oklch(11% 0.006 95);
+ --shadow-color: oklch(0% 0 0 / 0.4);
+
+ --viz-ink: var(--ks-text);
+ --viz-mist: oklch(78% 0 0 / 0.3);
+ --viz-accent: var(--ks-kinpaku);
+
+ font-family: var(--ks-font);
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--ks-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+body.is-locked { overflow: hidden; }
+
+/* The document takes the page over: the picker's shell (form, progress,
+ hero art) leaves the flow entirely, and the mosaic becomes the page.
+ The width gate stands down too: the questionnaire needs a wide viewport,
+ but the document has its own stacked layout below 920px. */
+body.dcx-open .picker-shell { display: none; }
+body.dcx-open .picker-width-gate { display: none; }
+body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep)); }
+
+.dcx-shell {
+ height: 100svh;
+ max-height: 100svh;
+ overflow: hidden;
+ background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep));
+}
+
+.dcx-expander code {
+ font-family: var(--ks-mono);
+ font-size: 0.82em;
+ color: var(--ks-code-fg);
+ background: var(--ks-code-bg);
+ border-radius: 3px;
+ padding: 0.14em 0.4em;
+ white-space: nowrap;
+}
+
+/* ============================================================
+ Mosaic landing
+ ============================================================ */
+.dcx-shell {
+ --dcx-gap: clamp(10px, 0.9vw, 14px);
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ padding: var(--dcx-gap) clamp(14px, 2vw, 26px) clamp(16px, 2.2vw, 28px);
+}
+
+/* The author display above would defeat the hidden attribute the shell ships
+ with, leaving an invisible 100svh box under the questionnaire. */
+.dcx-shell[hidden] { display: none; }
+
+.dcx-grid {
+ position: relative;
+ flex: 1 1 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: repeat(18, minmax(0, 1fr));
+ grid-template-rows: repeat(12, minmax(0, 1fr));
+ gap: var(--dcx-gap);
+}
+
+.dcx-tile--product { grid-column: 1 / 5; grid-row: 1 / 7; --vz-delay: -1.1s; }
+.dcx-tile--brand { grid-column: 5 / 11; grid-row: 1 / 6; --vz-delay: -1.9s; }
+.dcx-tile--audience { grid-column: 11 / 15; grid-row: 1 / 7; --vz-delay: -0.4s; }
+.dcx-tile--typography { grid-column: 15 / 19; grid-row: 1 / 6; --vz-delay: -2.6s; }
+.dcx-tile--color { grid-column: 5 / 10; grid-row: 6 / 13; --vz-delay: -0.7s; }
+.dcx-tile--iconography { grid-column: 1 / 5; grid-row: 7 / 13; --vz-delay: -1.3s; }
+.dcx-tile--interface { grid-column: 10 / 15; grid-row: 7 / 13; --vz-delay: -1.6s; }
+.dcx-tile--material { grid-column: 15 / 19; grid-row: 6 / 13; --vz-delay: -3.2s; }
+
+.dcx-tile {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: clamp(12px, 1.6vw, 24px);
+ min-width: 0;
+ min-height: 0;
+ padding: clamp(18px, 2.2vw, 36px);
+ background: var(--panel-bg);
+ border: 1px solid var(--ks-rule);
+ border-radius: 8px;
+ overflow: hidden;
+ color: var(--ks-champagne);
+ cursor: pointer;
+ text-align: left;
+ isolation: isolate;
+ container-type: inline-size;
+ font-family: inherit;
+ transition:
+ transform 220ms var(--ks-ease),
+ border-color 220ms var(--ks-ease),
+ background 220ms var(--ks-ease);
+}
+
+/* Hover follows the kit's pagination treatment: the surface stays put,
+ only the border (and title) take the accent. */
+.dcx-tile:hover,
+.dcx-tile:focus-visible {
+ transform: translateY(-2px);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.dcx-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+
+/* Staggered reveal on load — the homepage foundation-grid treatment. */
+.dcx-tile[data-reveal],
+.dcx-badge[data-reveal] {
+ opacity: 0;
+ transform: translateY(30px);
+ transition:
+ opacity 0.8s var(--ks-ease),
+ transform 0.8s var(--ks-ease);
+ transition-delay: var(--reveal-delay, 0s);
+}
+
+.dcx-badge[data-reveal] { transform: translateY(0) scale(0.6); }
+
+.dcx-tile[data-reveal].revealed {
+ opacity: 1;
+ transform: translateY(0);
+ /* Hand hover transforms back to the fast transition once revealed. */
+ transition:
+ transform 220ms var(--ks-ease),
+ border-color 220ms var(--ks-ease),
+ background 220ms var(--ks-ease),
+ opacity 0.8s var(--ks-ease);
+ transition-delay: 0s;
+}
+
+.dcx-badge[data-reveal].revealed {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+.dcx-tile-title {
+ position: relative;
+ z-index: 2;
+ margin: 0;
+ font-family: var(--ks-font-display);
+ font-size: clamp(2.3rem, 4.8vw, 4.4rem);
+ font-weight: 100;
+ line-height: 1.02;
+ letter-spacing: 0.01em;
+ color: inherit;
+ text-wrap: balance;
+ transition: color 220ms var(--ks-ease);
+}
+
+.dcx-tile:hover .dcx-tile-title,
+.dcx-tile:focus-visible .dcx-tile-title { color: var(--accent); }
+
+.dcx-tile--typography .dcx-tile-title,
+.dcx-tile--iconography .dcx-tile-title {
+ font-size: min(clamp(2.3rem, 4.8vw, 4.4rem), 20cqw);
+}
+
+/* Center mark on the gutter seam — the bare glyph in kinpaku gold,
+ inset by the same gap the mosaic tiles use. The padding ring is
+ painted page-background so it reads as seam, not as a frame. */
+.dcx-badge {
+ position: relative;
+ z-index: 0;
+ grid-column: 10 / 11;
+ grid-row: 6 / 7;
+ width: clamp(82px, 7vw, 118px);
+ height: clamp(82px, 7vw, 118px);
+ display: grid;
+ place-items: center;
+ align-self: center;
+ justify-self: center;
+ padding: var(--dcx-gap);
+ background: var(--ks-lacquer);
+ /* glyph corner radius (~9% of the box) plus the gap, so the ring hugs the mark */
+ border-radius: calc(9% + var(--dcx-gap));
+ color: var(--ks-kinpaku);
+ pointer-events: none;
+}
+
+.dcx-badge svg { width: 100%; height: 100%; display: block; fill: currentColor; }
+
+/* ============================================================
+ Tile vignettes — foundation-card animation system.
+ The title renders first; the vignette sits below it and scales
+ to fill whatever space the tile has left.
+ ============================================================ */
+.dcx-tile-title { order: 1; }
+
+.dcx-tile-viz {
+ order: 2;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--viz-ink);
+}
+
+.dcx-viz-svg {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+}
+
+/* Every vignette stroke uses vector-effect: non-scaling-stroke, so the
+ line weight is a fixed pixel value no matter how large the tile lets
+ the SVG scale. One thinness across all eight tiles. */
+.dcx-viz-svg .vz-stroke { stroke-width: 3.5px; }
+.dcx-viz-svg .vz-ghost { stroke-width: 2.5px; }
+
+@media (max-width: 920px) {
+ .dcx-viz-svg .vz-stroke { stroke-width: 1.5px; }
+ .dcx-viz-svg .vz-ghost { stroke-width: 1px; }
+}
+
+/* Draw (Typography, Material, Brand, Audience figure).
+ Same draw-breathe / draw-in choreography as the impeccable homepage.
+ The homepage uses stroke-dasharray: 100 in user units, but with
+ vector-effect: non-scaling-stroke Chromium measures dashes in screen
+ pixels, so a fixed 100 chops scaled-up paths into segments. A script
+ sets --pl on each vignette SVG to "100 user units in screen px" and
+ the dash keyframes work from that, giving the exact site animation
+ at any tile size. */
+.anim-draw {
+ stroke-dasharray: var(--pl, 999px);
+ stroke-dashoffset: var(--pl, 999px);
+ animation: draw-breathe 4s ease-in-out var(--vz-delay, 0s) infinite;
+}
+.dcx-tile:hover .anim-draw { animation: draw-in 0.8s var(--ks-ease) forwards; }
+.anim-draw-delay {
+ stroke-dasharray: var(--pl, 999px);
+ stroke-dashoffset: var(--pl, 999px);
+}
+.dcx-tile:hover .anim-draw-delay { animation: draw-in 1s var(--ks-ease) 0.2s forwards; }
+@keyframes draw-breathe {
+ 0%, 100% { stroke-dashoffset: var(--pl, 999px); }
+ 50% { stroke-dashoffset: calc(var(--pl, 999px) * 0.4); }
+}
+@keyframes draw-in {
+ from { stroke-dashoffset: var(--pl, 999px); }
+ to { stroke-dashoffset: 0px; }
+}
+
+/* Color venn (breathing pulse, spread on hover) */
+.anim-move-x { animation: pulse-x 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-move-x { animation: spread-x 0.6s ease-in-out forwards; }
+.anim-move-x-opp { animation: pulse-x-opp 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-move-x-opp { animation: spread-x-opp 0.6s ease-in-out forwards; }
+.anim-fade-in { opacity: 0; transition: opacity 0.6s ease-in-out; }
+.dcx-tile:hover .anim-fade-in { opacity: 1; }
+@keyframes pulse-x { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(1.5px); } }
+@keyframes pulse-x-opp { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-1.5px); } }
+@keyframes spread-x { from { transform: translateX(0); } to { transform: translateX(4px); } }
+@keyframes spread-x-opp { from { transform: translateX(0); } to { transform: translateX(-4px); } }
+
+/* Interface toggle (gentle drift, full snap on hover) */
+.anim-toggle-move { animation: toggle-drift 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-toggle-move { animation: toggle-snap 0.35s ease-in-out forwards; }
+@keyframes toggle-drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
+@keyframes toggle-snap {
+ from { transform: translateX(0); fill: var(--viz-mist); }
+ to { transform: translateX(8px); fill: var(--viz-accent); }
+}
+
+/* Product pitch lines (cursor always blinks) */
+.anim-blink { animation: blink-key 1s step-end var(--vz-delay, 0s) infinite; }
+@keyframes blink-key { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
+
+/* Iconography glyph grid. At rest the four glyphs brighten one by one on
+ a shared 4.8s cycle (1.2s apart). Opacity only — scaling the glyphs
+ blurs the strokes into a halo and makes them balloon. Hovering kills
+ the cycle and lights all four with a small stagger. */
+.anim-pop {
+ opacity: 0.35;
+ transition: opacity 0.35s var(--ks-ease);
+ animation: pop-glow 4.8s ease-in-out infinite;
+}
+.anim-pop-1 { transition-delay: 0s; animation-delay: calc(var(--vz-delay, 0s) + 0s); }
+.anim-pop-2 { transition-delay: 0.07s; animation-delay: calc(var(--vz-delay, 0s) + 1.2s); }
+.anim-pop-3 { transition-delay: 0.14s; animation-delay: calc(var(--vz-delay, 0s) + 2.4s); }
+.anim-pop-4 { transition-delay: 0.21s; animation-delay: calc(var(--vz-delay, 0s) + 3.6s); }
+.dcx-tile:hover .anim-pop { animation: none; opacity: 1; }
+@keyframes pop-glow {
+ 0%, 25%, 100% { opacity: 0.35; }
+ 10% { opacity: 1; }
+}
+
+/* Audience second figure joins on hover */
+.anim-join { opacity: 0.25; transition: opacity 0.5s var(--ks-ease); }
+.dcx-tile:hover .anim-join { opacity: 1; }
+
+/* Typography: ink-only at rest; gold draws in on hover. */
+.dcx-tile--typography:not(:hover) .anim-join {
+ opacity: 0;
+}
+
+.dcx-tile--typography:not(:hover) :is(.anim-draw, .anim-draw-delay) {
+ animation: none;
+ stroke-dashoffset: var(--pl, 999px);
+}
+
+/* Material: wireframe reads quieter than full ink until hover. */
+.dcx-tile--material .dcx-viz-svg :is(rect, line).vz-stroke {
+ transition: stroke 220ms var(--ks-ease);
+}
+
+.dcx-tile--material:not(:hover) .dcx-viz-svg :is(rect, line).vz-stroke {
+ stroke: var(--viz-mist);
+}
+
+/* ============================================================
+ Expander — tile-to-fullscreen morph
+ ============================================================ */
+.dcx-expander {
+ position: fixed;
+ z-index: 500;
+ overflow: hidden;
+ border-radius: 8px;
+ background: var(--ks-lacquer);
+ color: var(--ks-text);
+ box-shadow: 0 18px 46px var(--shadow-color);
+ transition:
+ top 520ms var(--ks-ease),
+ left 520ms var(--ks-ease),
+ width 520ms var(--ks-ease),
+ height 520ms var(--ks-ease),
+ border-radius 520ms var(--ks-ease);
+}
+
+.dcx-expander.is-full {
+ top: 0 !important;
+ left: 0 !important;
+ width: 100vw !important;
+ height: 100svh !important;
+ border-radius: 0;
+}
+
+.dcx-expander-inner {
+ width: 100%;
+ height: 100svh;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 264px minmax(0, 1fr);
+ opacity: 0;
+ transform: translateY(44px);
+ transition: opacity 420ms var(--ks-ease), transform 520ms var(--ks-ease);
+ transition-delay: 260ms;
+}
+
+.dcx-expander.is-ready .dcx-expander-inner { opacity: 1; transform: translateY(0); }
+
+/* ============================================================
+ Expander sidebar — DocsSidebar visual language
+ ============================================================ */
+.dcx-sidebar {
+ min-height: 0;
+ overflow: hidden auto;
+ padding: 26px 22px 32px;
+ border-right: 1px solid var(--ks-rule);
+ background: var(--ks-lacquer-deep);
+ scrollbar-width: thin;
+ scrollbar-color: var(--ks-rule) transparent;
+}
+
+.dcx-sidebar-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 2px 0 28px;
+ color: var(--ks-kinpaku);
+ text-decoration: none;
+}
+
+
+.dcx-sidebar-brand svg { width: 26px; height: 26px; fill: currentColor; flex-shrink: 0; }
+
+.dcx-sidebar-brand-name {
+ font-family: var(--ks-font-display);
+ font-weight: 400;
+ font-size: 1.02rem;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ line-height: 1;
+ -webkit-font-smoothing: auto;
+}
+
+.dcx-nav-label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 650;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 10px;
+ padding-left: 14px;
+}
+
+.dcx-nav-list { list-style: none; padding: 0; margin: 0; }
+.dcx-nav-list li { margin: 0; }
+
+.dcx-nav-link {
+ display: block;
+ width: 100%;
+ padding: 5px 0 5px 12px;
+ border: 0;
+ border-left: 2px solid transparent;
+ background: transparent;
+ font-family: var(--ks-font);
+ font-size: 0.94rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: var(--ks-text);
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+ transition: color 160ms var(--ks-ease), border-color 160ms var(--ks-ease);
+}
+
+.dcx-nav-link:hover,
+.dcx-nav-link:focus-visible { color: var(--accent-hover); outline: none; }
+
+.dcx-nav-link[aria-current="page"] {
+ color: var(--accent);
+ font-weight: 600;
+ border-left-color: var(--accent);
+}
+
+.dcx-subnav { display: none; padding: 2px 0 8px 24px; }
+
+.dcx-nav-list li.is-active .dcx-subnav {
+ display: grid;
+ gap: 2px;
+ animation: subnav-in 180ms var(--ks-ease) both;
+}
+
+@keyframes subnav-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.dcx-sub-link {
+ display: block;
+ width: 100%;
+ padding: 3px 0;
+ border: 0;
+ background: transparent;
+ font-family: var(--ks-font);
+ font-size: 0.85rem;
+ color: var(--ks-text-muted);
+ text-align: left;
+ cursor: pointer;
+ transition: color 160ms var(--ks-ease);
+}
+
+.dcx-sub-link:hover,
+.dcx-sub-link:focus-visible { color: var(--accent-hover); outline: none; }
+
+/* ============================================================
+ Expander topbar + main
+ ============================================================ */
+.dcx-panel {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: 64px minmax(0, 1fr);
+}
+
+.dcx-topbar {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 clamp(22px, 4vw, 48px);
+ border-bottom: 1px solid var(--ks-rule);
+}
+
+.dcx-current {
+ min-width: 0;
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--ks-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dcx-close {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--ks-rule);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ks-text);
+ cursor: pointer;
+ transition:
+ color 160ms var(--ks-ease),
+ border-color 160ms var(--ks-ease),
+ transform 220ms var(--ks-ease);
+}
+
+.dcx-close:hover,
+.dcx-close:focus-visible {
+ color: var(--accent);
+ border-color: var(--accent-line);
+ transform: rotate(90deg);
+ outline: none;
+}
+
+.dcx-close::before,
+.dcx-close::after {
+ content: "";
+ grid-area: 1 / 1;
+ width: 16px;
+ height: 1.5px;
+ background: currentColor;
+}
+
+.dcx-close::before { transform: rotate(45deg); }
+.dcx-close::after { transform: rotate(-45deg); }
+
+.dcx-main {
+ min-height: 0;
+ overflow: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--ks-rule) transparent;
+}
+
+.dcx-main:focus { outline: none; }
+
+/* ============================================================
+ Article
+ ============================================================ */
+/* Wide enough for the questionnaire's own drawings to breathe; running text
+ holds its measure through the caps on lede, defs, and callouts below. */
+.dcx-article {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: clamp(36px, 5vw, 64px) clamp(22px, 4vw, 56px) 110px;
+}
+
+.dcx-eyebrow {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 16px;
+}
+
+.dcx-title {
+ margin: 0 0 18px;
+ font-family: var(--ks-font-display);
+ font-weight: 100;
+ font-size: clamp(3rem, 6vw, 5.2rem);
+ line-height: 1.02;
+ letter-spacing: -0.01em;
+ color: var(--ks-kinpaku);
+ text-wrap: balance;
+}
+
+
+.dcx-lede {
+ margin: 0;
+ max-width: 56ch;
+ font-size: 1.08rem;
+ line-height: 1.65;
+ color: var(--ks-text);
+}
+
+.dcx-block { margin-top: clamp(34px, 5vw, 52px); }
+
+.dcx-block-label {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 14px;
+}
+
+/* Definition rows: term + description. */
+.dcx-defs { border-top: 1px solid var(--ks-rule); }
+
+.dcx-def {
+ display: grid;
+ grid-template-columns: minmax(180px, 0.44fr) minmax(0, 1fr);
+ gap: 6px 26px;
+ align-items: baseline;
+ padding: 15px 0;
+ border-bottom: 1px solid var(--ks-rule);
+}
+
+.dcx-def dt {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--ks-champagne);
+}
+
+.dcx-def dd {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+/* Chips. */
+.dcx-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+
+.dcx-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 13px;
+ border: 1px solid var(--accent-line);
+ border-radius: 999px;
+ background: var(--accent-wash);
+ color: var(--accent-hover);
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+
+.dcx-chip--muted {
+ border-color: var(--ks-rule);
+ background: transparent;
+ color: var(--ks-text-muted);
+}
+
+/* Callout card (north star, named rules). */
+.dcx-callout {
+ padding: clamp(20px, 3vw, 30px);
+ border: 1px solid var(--ks-rule);
+ border-radius: 10px;
+ background: var(--panel-bg);
+}
+
+.dcx-callout + .dcx-callout { margin-top: 12px; }
+
+.dcx-callout-name {
+ margin: 0 0 8px;
+ font-size: 1.02rem;
+ font-weight: 600;
+ color: var(--ks-champagne);
+}
+
+.dcx-callout p:not(.dcx-callout-name) {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+.dcx-callout--accent { border-color: var(--accent-line); background: var(--accent-wash); }
+
+/* ============================================================
+ Context enrichment: the success line, paired callouts, the
+ needs/trust columns, the platform pill, reference cards, and
+ the operating-context prose. Each rule exists for a block
+ that renders only when the agent passed the matching context
+ field; every value is a dcx or ks token already in this file.
+ ============================================================ */
+.dcx-callout p.dcx-callout-success {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--ks-rule);
+}
+
+/* Two callouts sharing a row: the emotional journey's arrival and
+ leaving beats, and positioning's not-this / this cells. */
+.dcx-callout-pair {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
+ gap: 12px;
+}
+
+/* Two sibling blocks sharing a row (needs beside trust triggers). The
+ children stay real .dcx-block elements so the subnav still finds them. */
+.dcx-cols {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
+ gap: 0 clamp(24px, 4vw, 48px);
+ align-items: start;
+}
+
+/* The purpose callout with the platform pill at its shoulder. */
+.dcx-purpose {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px;
+ align-items: start;
+}
+
+.dcx-platform-pill {
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+/* Named references as cards, the .dcx-pick anatomy with a takeaway. */
+.dcx-ref-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: clamp(14px, 2vw, 22px);
+}
+
+.dcx-ref-cards + .dcx-chips { margin-top: 12px; }
+
+.dcx-ref-card {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(18px, 2.5vw, 26px);
+}
+
+.dcx-ref-name {
+ margin: 0 0 8px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.3rem;
+ color: var(--ks-text);
+}
+
+.dcx-ref-takeaway {
+ margin: 0;
+ color: var(--ks-text-muted);
+ font-size: 0.92rem;
+ line-height: 1.6;
+}
+
+/* Operating context: one paragraph at reading measure. */
+.dcx-prose {
+ margin: 0;
+ max-width: 62ch;
+ color: var(--ks-text-muted);
+ font-size: 0.96rem;
+ line-height: 1.6;
+}
+
+/* Numbered principle list. */
+.dcx-principles {
+ list-style: none;
+ counter-reset: principle;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--ks-rule);
+}
+
+.dcx-principles li {
+ counter-increment: principle;
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr);
+ gap: 20px;
+ align-items: baseline;
+ padding: 14px 0;
+ border-bottom: 1px solid var(--ks-rule);
+ font-size: 0.94rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+.dcx-principles li::before {
+ content: counter(principle, decimal-leading-zero);
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--accent);
+}
+
+.dcx-principles strong { color: var(--ks-champagne); font-weight: 600; }
+
+/* ============================================================
+ Brand voice and principles: the say / not wording pairs and
+ the two-column principles list. Rendered only when the agent
+ passed brand.voice or brand.principles; every value is a dcx
+ or ks token already in this file.
+ ============================================================ */
+.dcx-voice { display: grid; gap: 12px; }
+
+/* One pair per row: the line to write beside the line to refuse. */
+.dcx-voice-pair {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: 12px;
+}
+
+.dcx-voice-cell {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ border-radius: 10px;
+ padding: 14px 18px;
+}
+
+.dcx-voice-cell--say { border-color: var(--accent-line); background: var(--accent-wash); }
+
+.dcx-voice-tag {
+ display: block;
+ margin-bottom: 6px;
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+.dcx-voice-cell p {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+.dcx-voice-cell--say p { color: var(--ks-text); }
+
+/* The numbered principle anatomy above, folded into two columns. The
+ counters keep DOM order, so the first row reads 01 then 02. */
+.dcx-principles--cols {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
+ gap: 0 clamp(24px, 4vw, 48px);
+}
+
+/* Plain bullet list. */
+.dcx-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 9px;
+}
+
+.dcx-list li {
+ position: relative;
+ padding-left: 20px;
+ font-size: 0.94rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+.dcx-list li::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 0.62em;
+ width: 7px;
+ height: 1.5px;
+ background: var(--accent);
+}
+
+.dcx-list strong { color: var(--ks-champagne); font-weight: 600; }
+
+/* Swatch fan (Color) — hover fans up, click copies. */
+.dcx-fan {
+ position: relative;
+ height: clamp(150px, 16vw, 195px);
+ overflow: hidden;
+ isolation: isolate;
+ border-radius: 10px 10px 0 0;
+}
+
+.dcx-fan::after {
+ content: "";
+ position: absolute;
+ left: 0; right: 0; bottom: 0;
+ z-index: 30;
+ height: 1px;
+ background: var(--ks-rule);
+ pointer-events: none;
+}
+
+.dcx-fan-panel {
+ position: absolute;
+ inset-block: 0;
+ left: var(--panel-left);
+ z-index: var(--panel-z);
+ width: var(--panel-width, 40%);
+ border: 0;
+ border-radius: 22px 22px 0 0;
+ padding: 0;
+ background: var(--panel-swatch);
+ box-shadow: inset 0 0 0 1px oklch(78% 0 0 / 0.14);
+ color: var(--panel-ink);
+ cursor: copy;
+ text-align: left;
+ transform: translate3d(0, 58%, 0);
+ transform-origin: 50% 100%;
+ transition:
+ transform 740ms cubic-bezier(0.16, 1, 0.3, 1),
+ filter 460ms cubic-bezier(0.16, 1, 0.3, 1);
+ will-change: transform;
+ backface-visibility: hidden;
+}
+
+.dcx-fan.is-engaged .dcx-fan-panel { transform: translate3d(0, 54%, 0); }
+.dcx-fan.is-engaged .dcx-fan-panel.is-neighbor { transform: translate3d(0, 48%, 0); }
+
+.dcx-fan.is-engaged .dcx-fan-panel.is-active,
+.dcx-fan-panel:focus-visible,
+.dcx-fan-panel.is-copied {
+ outline: none;
+ filter: saturate(1.04);
+ transform: translate3d(0, 10%, 0) rotate(-0.48deg);
+}
+
+.dcx-fan-info {
+ position: absolute;
+ top: clamp(20px, 2.6vw, 34px);
+ left: clamp(20px, 2.6vw, 34px);
+ right: clamp(20px, 2.6vw, 34px);
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 14px;
+ opacity: 0;
+ transform: translateY(10px);
+ transition: opacity 320ms var(--ks-ease), transform 420ms var(--ks-ease);
+ pointer-events: none;
+}
+
+.dcx-fan-panel.is-active .dcx-fan-info,
+.dcx-fan-panel:focus-visible .dcx-fan-info,
+.dcx-fan-panel.is-copied .dcx-fan-info { opacity: 1; transform: translateY(0); }
+
+.dcx-fan-name {
+ margin: 0;
+ font-family: var(--ks-font);
+ font-size: clamp(0.95rem, 1.6vw, 1.3rem);
+ font-weight: 700;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.dcx-fan-value {
+ margin: 0;
+ flex: 0 0 auto;
+ font-family: var(--ks-mono);
+ font-size: clamp(0.6rem, 1vw, 0.72rem);
+ letter-spacing: 0.04em;
+ opacity: 0.82;
+}
+
+.dcx-fan-note {
+ margin: 10px 0 0;
+ font-size: 0.8rem;
+ color: var(--ks-text-faint);
+}
+
+/* Type specimen (Typography) — set in the project's real faces. */
+.dcx-specimen {
+ display: grid;
+ gap: 18px;
+ padding: clamp(24px, 3.4vw, 40px);
+ border: 1px solid var(--ks-rule);
+ border-radius: 10px;
+ background: var(--panel-bg);
+}
+
+.dcx-specimen-display {
+ margin: 0;
+ font-family: "Shippori Mincho", "Hiragino Mincho ProN", "Yu Mincho", Georgia, serif;
+ font-weight: 400;
+ font-size: clamp(2.2rem, 4.6vw, 3.6rem);
+ line-height: 1.15;
+ color: var(--ks-champagne);
+ text-wrap: balance;
+}
+
+.dcx-specimen-body {
+ margin: 0;
+ max-width: 62ch;
+ font-family: "Source Sans 3", system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+.dcx-specimen-label {
+ margin: 0;
+ font-family: "Source Sans 3", system-ui, sans-serif;
+ font-size: 0.875rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* Radius samples (Material). */
+.dcx-radius-row { display: flex; flex-wrap: wrap; gap: 18px; }
+
+.dcx-radius-sample { display: grid; gap: 8px; justify-items: center; }
+
+.dcx-radius-box {
+ width: 76px;
+ height: 76px;
+ border: 1.5px solid var(--accent-line);
+ background: var(--accent-wash);
+}
+
+.dcx-radius-caption {
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ color: var(--ks-text-muted);
+}
+
+/* Spacing scale bars (Material). */
+.dcx-space-rows { display: grid; gap: 8px; }
+
+.dcx-space-row {
+ display: grid;
+ grid-template-columns: 74px minmax(0, 1fr) 84px;
+ align-items: center;
+ gap: 14px;
+}
+
+.dcx-space-name,
+.dcx-space-value {
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ color: var(--ks-text-muted);
+}
+
+.dcx-space-value { text-align: right; color: var(--ks-text-faint); }
+
+.dcx-space-bar {
+ height: 12px;
+ width: var(--bar, 20%);
+ min-width: 6px;
+ border-radius: 3px;
+ background: var(--accent-wash);
+ border: 1px solid var(--accent-line);
+}
+
+/* Empty state (Iconography). */
+.dcx-empty {
+ padding: clamp(26px, 4vw, 40px);
+ border: 1px dashed var(--ks-rule);
+ border-radius: 10px;
+ text-align: left;
+}
+
+.dcx-empty-title {
+ margin: 0 0 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ks-champagne);
+}
+
+.dcx-empty p:not(.dcx-empty-title) {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+/* ============================================================
+ Responsive fallback
+ ============================================================ */
+@media (max-width: 920px) {
+ .dcx-shell { padding: 10px; }
+
+ .dcx-grid {
+ height: auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .dcx-tile {
+ grid-column: auto;
+ grid-row: auto;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto;
+ align-items: center;
+ align-content: center;
+ gap: 16px;
+ min-height: clamp(72px, 9svh, 104px);
+ padding: 16px 20px;
+ }
+
+ /* Compact row: title left, small vignette right. */
+ .dcx-tile-viz { width: 44px; height: 44px; }
+
+ .dcx-tile-title,
+ .dcx-tile--typography .dcx-tile-title,
+ .dcx-tile--iconography .dcx-tile-title {
+ font-size: clamp(1.9rem, 8vw, 2.6rem);
+ }
+
+ .dcx-tile--product { order: 1; }
+ .dcx-tile--audience { order: 2; }
+ .dcx-tile--brand { order: 3; }
+ .dcx-tile--color { order: 4; }
+ .dcx-tile--typography { order: 5; }
+ .dcx-tile--iconography { order: 6; }
+ .dcx-tile--material { order: 7; }
+ .dcx-tile--interface { order: 8; }
+
+ .dcx-badge { display: none; }
+
+ .dcx-expander-inner {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .dcx-sidebar {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-right: 0;
+ border-bottom: 1px solid var(--ks-rule);
+ overflow: hidden;
+ }
+
+ .dcx-sidebar-brand { margin: 0; }
+ .dcx-sidebar-brand-name { display: none; }
+ .dcx-nav-label { display: none; }
+
+ .dcx-nav { min-width: 0; overflow-x: auto; scrollbar-width: none; }
+ .dcx-nav::-webkit-scrollbar { display: none; }
+ .dcx-nav-list { display: flex; gap: 4px; }
+
+ .dcx-nav-link {
+ width: auto;
+ padding: 8px 10px;
+ border-left: 0;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+ }
+
+ .dcx-nav-link[aria-current="page"] {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+
+ .dcx-nav-list li.is-active .dcx-subnav { display: none; }
+
+ .dcx-panel { grid-template-rows: 56px minmax(0, 1fr); }
+ .dcx-topbar { padding: 0 16px; }
+ .dcx-article { padding: 30px 18px 84px; }
+
+ .dcx-def { grid-template-columns: 1fr; gap: 4px; }
+
+ .dcx-fan { height: 210px; }
+
+ .dcx-fan-info {
+ top: 18px; left: 16px; right: 16px;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ .dcx-space-row { grid-template-columns: 58px minmax(0, 1fr) 70px; }
+}
+
+/* ============================================================
+ Borrowed proofs — questionnaire drawings inside the document.
+
+ A .dcx-proof frames a clone of a drawing the questionnaire
+ painted: a surface's dual artboard, the type scale sheet, the
+ long-form specimen, the icon grid. The clone arrives with its
+ inline variables; the frame supplies the context selectors the
+ originals took from their screens.
+ ============================================================ */
+.dcx-proof {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(14px, 2vw, 22px);
+}
+
+/* The strategy stage's base mapping, restated for clones that live outside
+ it: the artboard reads the committed palette painted on the frame. */
+.dcx-proof--board > .picker-preview {
+ --pv-primary: var(--pkc-primary, var(--ks-champagne));
+ --pv-secondary: var(--pkc-secondary, var(--ks-patina));
+ --pv-tertiary: var(--pkc-tertiary, var(--ks-kinpaku));
+ --pv-neutral: var(--pkc-neutral, var(--ks-lacquer-raised));
+ --pv-n-ink: var(--pkc-n-ink, var(--ks-champagne));
+ --pv-p-ink: var(--pkc-p-ink, var(--ks-champagne));
+}
+
+/* The three strategy remaps, mirrored from picker.css's strategy stage.
+ There they key off hover and checked state inside #picker-form; here the
+ answer is settled, so a data attribute on the frame carries it. Change the
+ remap bodies in both places together. */
+.dcx-proof--board[data-dcx-strategy="restrained"] > .picker-preview {
+ --pv-secondary: color-mix(in oklab, var(--pkc-neutral) 78%, var(--pkc-n-ink));
+ --pv-tertiary: color-mix(in oklab, var(--pkc-neutral) 86%, var(--pkc-n-ink));
+ --pv-t-ink: var(--pkc-n-ink);
+ --pv-t-on-t: var(--pkc-n-ink);
+ --pv-t-on-n: color-mix(in oklab, var(--pkc-n-ink) 62%, var(--pkc-neutral));
+ --pv-t-on-p: var(--pkc-p-ink);
+}
+
+.dcx-proof--board[data-dcx-strategy="committed"] > .picker-preview {
+ --pv-neutral: color-mix(in oklab, var(--pkc-neutral) 95%, var(--pkc-primary));
+ --pv-secondary: var(--pkc-primary);
+ --pv-tertiary: var(--pkc-primary);
+ --pv-t-ink: var(--pkc-p-ink);
+ --pv-t-on-t: var(--pkc-p-on-p);
+ --pv-t-on-n: var(--pkc-p-on-n);
+ --pv-t-on-p: var(--pkc-p-on-p);
+}
+
+.dcx-proof--board[data-dcx-strategy="drenched"] > .picker-preview {
+ --pv-neutral: var(--pkc-primary);
+ --pv-n-ink: var(--pkc-p-ink);
+ --pv-primary: var(--pkc-p-ink);
+ --pv-p-ink: var(--pkc-primary);
+ --pv-p-on-n: var(--pkc-p-ink);
+ --pv-p-on-p: var(--pkc-primary);
+ --pv-secondary: var(--pkc-tertiary);
+ --pv-tertiary: var(--pkc-tertiary);
+ --pv-t-on-n: var(--pkc-t-on-p);
+}
+/* ============================================================
+ Material proofs: the structural screens' own artboards, cloned.
+
+ A .dcx-proof--board can also frame a ps artboard clone (the anatomy
+ screens 07 to 10 draw on), not just a tile drawing. On screen those
+ boards take every committed answer through checked inputs inside
+ #picker-form, which a clone in the document is outside of, so the frame
+ restates each answer as a data-dcx-* attribute and the bodies below are
+ verbatim copies of the committed bodies the screens set. Change a body
+ here and in its named source together. The palette rides two ways: the
+ screens paint all thirteen --pv-* slots inline on every artboard they
+ showed (inline styles travel with a clone), and for a board whose screen
+ the run never opened, the frame's --pkc-* paint reaches the clone
+ through the same six-slot map the tile clones above use, with the
+ artboard's own class defaults covering the contrast slots. */
+.dcx-proof--board > .picker-artboard {
+ --pv-primary: var(--pkc-primary, var(--ks-champagne));
+ --pv-secondary: var(--pkc-secondary, var(--ks-patina));
+ --pv-tertiary: var(--pkc-tertiary, var(--ks-kinpaku));
+ --pv-neutral: var(--pkc-neutral, var(--ks-lacquer-raised));
+ --pv-n-ink: var(--pkc-n-ink, var(--ks-champagne));
+ --pv-p-ink: var(--pkc-p-ink, var(--ks-champagne));
+}
+
+/* Two rules the clone loses with its data-surface attribute (proofHtml
+ strips it): the landing board's right-gutter chrome, and the handset
+ print's freeform cut, which rides --pvs-drift and stays a rectangle
+ under the other layout answers. The frame's data-dcx-surface carries
+ the surface instead. Sources: picker/styles/screens/layout.css, the
+ [data-carry][data-surface="persuade"] rules. */
+.dcx-proof--board[data-dcx-surface="persuade"] > .picker-artboard :is(.ps-nav, .ps-editorial, .ps-footer) {
+ padding-right: var(--pvs-gutter-end);
+}
+
+.dcx-proof--board[data-dcx-surface="persuade"] > .picker-artboard .ps-phone-body > .ps-image {
+ clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--pvs-drift, 0) * 16%), 0 100%);
+}
+
+/* The strategy, restated for artboard clones: the [data-artboard] bodies
+ from picker.css. Restrained is the artboard's own defaults there and
+ stays bodiless here, exactly as the tile remaps above leave full
+ palette alone. */
+.dcx-proof--board[data-dcx-strategy="committed"] > .picker-artboard {
+ --pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ --pvs-headline-text: var(--pv-p-on-n);
+ --pvs-eyebrow: var(--pv-primary);
+ --pvs-ghost: color-mix(in oklab, var(--pv-primary) 14%, var(--pv-neutral));
+ --pvs-ghost-edge: var(--pv-primary);
+ --pvs-image: var(--pv-primary);
+ --pvs-image-a: var(--pv-primary);
+ --pvs-image-b: color-mix(in oklab, var(--pv-primary) 72%, var(--pv-neutral));
+ --pvs-image-edge: var(--pv-primary);
+ --pvs-accent-a: var(--pv-primary);
+ --pvs-accent-b: color-mix(in oklab, var(--pv-primary) 70%, var(--pv-neutral));
+ --pvs-accent-c: var(--pv-primary);
+ --pvs-accent-d: color-mix(in oklab, var(--pv-primary) 18%, var(--pv-neutral));
+}
+
+.dcx-proof--board[data-dcx-strategy="full-palette"] > .picker-artboard {
+ --pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ --pvs-headline-text: var(--pv-p-on-n);
+ --pvs-accent-text: var(--pv-t-on-n);
+ --pvs-eyebrow: var(--pv-tertiary);
+ --pvs-ghost: color-mix(in oklab, var(--pv-secondary) 18%, var(--pv-neutral));
+ --pvs-ghost-edge: var(--pv-secondary);
+ --pvs-image: var(--pv-primary);
+ --pvs-image-a: var(--pv-primary);
+ --pvs-image-b: color-mix(in oklab, var(--pv-secondary) 32%, var(--pv-primary));
+ --pvs-image-edge: var(--pv-primary);
+ --pvs-signal: var(--pv-tertiary);
+ --pvs-signal-ink: var(--pv-t-ink);
+ --pvs-signal-text: var(--pv-t-on-t);
+ --pvs-accent-a: var(--pv-secondary);
+ --pvs-accent-b: color-mix(in oklab, var(--pv-secondary) 42%, var(--pv-neutral));
+ --pvs-accent-c: var(--pv-primary);
+ --pvs-accent-d: var(--pv-tertiary);
+}
+
+.dcx-proof--board[data-dcx-strategy="drenched"] > .picker-artboard {
+ --pvs-ground: var(--pv-primary);
+ --pvs-ink: var(--pv-p-ink);
+ --pvs-surface: color-mix(in oklab, var(--pv-p-ink) 10%, var(--pv-primary));
+ --pvs-headline: var(--pv-p-ink);
+ --pvs-eyebrow: color-mix(in oklab, var(--pv-p-ink) 70%, var(--pv-primary));
+ --pvs-copy: color-mix(in oklab, var(--pv-p-ink) 55%, var(--pv-primary));
+ --pvs-title: var(--pv-p-ink);
+ --pvs-cta: var(--pv-p-ink);
+ --pvs-cta-ink: var(--pv-primary);
+ --pvs-cta-text: var(--pv-p-on-i);
+ --pvs-mark-text: var(--pv-p-ink);
+ --pvs-accent-text: var(--pv-t-on-p);
+ --pvs-copy-mix: 14%;
+ --pvs-bars-mix: 16%;
+ --pvs-ghost: color-mix(in oklab, var(--pv-p-ink) 22%, var(--pv-primary));
+ --pvs-ghost-edge: color-mix(in oklab, var(--pv-p-ink) 45%, var(--pv-primary));
+ --pvs-image: color-mix(in oklab, var(--pv-p-ink) 20%, var(--pv-primary));
+ --pvs-image-a: color-mix(in oklab, var(--pv-p-ink) 16%, var(--pv-primary));
+ --pvs-image-b: color-mix(in oklab, var(--pv-p-ink) 28%, var(--pv-primary));
+ --pvs-image-edge: color-mix(in oklab, var(--pv-p-ink) 40%, var(--pv-primary));
+ --pvs-signal: var(--pv-tertiary);
+ --pvs-signal-ink: var(--pv-t-ink);
+ --pvs-signal-text: var(--pv-t-on-t);
+ --pvs-bars: color-mix(in oklab, var(--pv-p-ink) 45%, var(--pv-primary));
+ --pvs-rule: color-mix(in oklab, var(--pv-p-ink) 28%, var(--pv-primary));
+ --pvs-accent-a: var(--pv-secondary);
+ --pvs-accent-b: var(--pv-tertiary);
+ --pvs-accent-c: color-mix(in oklab, var(--pv-p-ink) 30%, var(--pv-primary));
+ --pvs-accent-d: color-mix(in oklab, var(--pv-p-ink) 14%, var(--pv-primary));
+}
+
+/* Boundaries: the four committed bodies from styles/screens/boundaries.css. */
+.dcx-proof--board[data-dcx-boundary="open-space"] > .picker-artboard {
+ --pvs-divider-w: 0px;
+ --pvs-band: transparent;
+ --pvs-band-alt: transparent;
+ --pvs-band-top: transparent;
+ --pvs-panel: transparent;
+ --pvs-panel-edge-w: 0px;
+ --pvs-panel-pad: 0px;
+ --pvs-proof-inset: 0px;
+}
+
+.dcx-proof--board[data-dcx-boundary="thin-dividers"] > .picker-artboard {
+ --pvs-divider-w: 1px;
+ --pvs-divider-c: color-mix(in oklab, var(--pvs-ink) 24%, var(--pvs-ground));
+ --pvs-band: transparent;
+ --pvs-band-alt: transparent;
+ --pvs-band-top: transparent;
+ --pvs-panel: transparent;
+ --pvs-panel-edge-w: 0px;
+ --pvs-panel-pad: 0px;
+ --pvs-proof-inset: 0px;
+}
+
+.dcx-proof--board[data-dcx-boundary="surface-changes"] > .picker-artboard {
+ --pvs-divider-w: 0px;
+ --pvs-band: color-mix(in oklab, var(--pvs-ink) 7%, var(--pvs-ground));
+ --pvs-band-alt: color-mix(in oklab, var(--pvs-ink) 13%, var(--pvs-ground));
+ --pvs-band-top: color-mix(in oklab, var(--pvs-ink) 7%, var(--pvs-ground));
+ --pvs-panel: transparent;
+ --pvs-panel-edge-w: 0px;
+ --pvs-panel-pad: 0px;
+ --pvs-proof-inset: 0px;
+}
+
+.dcx-proof--board[data-dcx-boundary="cards-and-panels"] > .picker-artboard {
+ --pvs-divider-w: 0px;
+ --pvs-band: transparent;
+ --pvs-band-alt: color-mix(in oklab, var(--pvs-ink) 5%, var(--pvs-ground));
+ --pvs-band-top: transparent;
+ --pvs-panel: color-mix(in oklab, var(--pvs-ink) 5%, var(--pvs-ground));
+ --pvs-panel-edge: color-mix(in oklab, var(--pvs-ink) 20%, var(--pvs-ground));
+ --pvs-panel-edge-w: 1px;
+ --pvs-panel-pad: 5%;
+ --pvs-proof-inset: var(--pvs-gutter);
+}
+
+/* The open rail plate joins the card answer, as on the screen. */
+.dcx-proof--board[data-dcx-boundary="cards-and-panels"] > .picker-artboard .ps-ops-item {
+ background-color: var(--pvs-panel);
+}
+
+/* Corners: the four committed bodies from styles/screens/corners.css. */
+.dcx-proof--board[data-dcx-corner="sharp"] > .picker-artboard {
+ --pvs-radius-frame: 0px;
+ --pvs-radius-frame-phone: 0px;
+ --pvs-radius-surface: 0px;
+ --pvs-radius-control: 0px;
+ --pvs-radius-bar: 0px;
+}
+
+.dcx-proof--board[data-dcx-corner="slightly-soft"] > .picker-artboard {
+ --pvs-radius-frame: 4px;
+ --pvs-radius-frame-phone: 5px;
+ --pvs-radius-surface: 3px;
+ --pvs-radius-control: 3px;
+ --pvs-radius-bar: 2px;
+}
+
+.dcx-proof--board[data-dcx-corner="friendly"] > .picker-artboard {
+ --pvs-radius-frame: 7px;
+ --pvs-radius-frame-phone: 11px;
+ --pvs-radius-surface: 8px;
+ --pvs-radius-control: 8px;
+ --pvs-radius-bar: 3px;
+}
+
+.dcx-proof--board[data-dcx-corner="pill"] > .picker-artboard {
+ --pvs-radius-frame: 9px;
+ --pvs-radius-frame-phone: 15px;
+ --pvs-radius-surface: 12px;
+ --pvs-radius-control: 999px;
+ --pvs-radius-bar: 3px;
+}
+
+/* Depth: the three committed bodies from styles/screens/depth.css. */
+.dcx-proof--board[data-dcx-depth="flat"] > .picker-artboard {
+ --pvs-shadow-chrome: none;
+ --pvs-shadow-card: none;
+ --pvs-shadow-control: none;
+ --pvs-shadow-surface: none;
+}
+
+.dcx-proof--board[data-dcx-depth="soft-lift"] > .picker-artboard {
+ --pvs-shadow-chrome: 0 1px 2px rgb(0 0 0 / 0.1);
+ --pvs-shadow-card:
+ 0 1px 1px rgb(0 0 0 / 0.15),
+ 0 3px 7px rgb(0 0 0 / 0.14);
+ --pvs-shadow-control: 0 1px 2px rgb(0 0 0 / 0.24);
+ --pvs-shadow-surface: 0 2px 5px rgb(0 0 0 / 0.14);
+}
+
+.dcx-proof--board[data-dcx-depth="floating"] > .picker-artboard {
+ --pvs-shadow-chrome: 0 2px 6px rgb(0 0 0 / 0.16);
+ --pvs-shadow-card:
+ 0 2px 3px rgb(0 0 0 / 0.2),
+ 0 9px 18px rgb(0 0 0 / 0.24);
+ --pvs-shadow-control: 0 3px 6px rgb(0 0 0 / 0.32);
+ --pvs-shadow-surface: 0 6px 14px rgb(0 0 0 / 0.22);
+}
+
+/* Where a cast shadow is allowed to land, keyed to the boundary answer
+ exactly as depth.css keys it to the checked boundary input. */
+.dcx-proof--board[data-dcx-boundary="cards-and-panels"] > .picker-artboard :is(.ps-gallery-item, .ps-desktop > .ps-proof),
+.dcx-proof--board[data-dcx-boundary="surface-changes"] > .picker-artboard .ps-desktop > .ps-proof {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+.dcx-proof--board:not([data-dcx-boundary="cards-and-panels"]) > .picker-artboard .ps-gallery-item {
+ box-shadow: none;
+}
+
+.dcx-proof--board:not([data-dcx-boundary="cards-and-panels"]) > .picker-artboard .ps-gallery-item > i {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+.dcx-proof--board:not([data-dcx-boundary="surface-changes"]) > .picker-artboard :is(.ps-nav, .ps-phone-top) {
+ box-shadow: none;
+}
+
+.dcx-proof--board:not([data-dcx-boundary="surface-changes"]) > .picker-artboard :is(.ps-ops-rail, .ps-docs-rail) {
+ box-shadow: none;
+}
+
+.dcx-proof--board:not([data-dcx-boundary="surface-changes"]):not([data-dcx-boundary="cards-and-panels"]) > .picker-artboard .ps-ops-panel {
+ box-shadow: none;
+}
+
+.dcx-proof--board:not([data-dcx-boundary="cards-and-panels"]) > .picker-artboard :is(.ps-ops-chart, .ps-ops-table) {
+ box-shadow: none;
+}
+
+.dcx-proof--board:not([data-dcx-boundary="cards-and-panels"]) > .picker-artboard .ps-ops-plot > i {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+.dcx-proof--board:not([data-dcx-boundary="surface-changes"]) > .picker-artboard .ps-docs-note {
+ box-shadow: none;
+}
+
+.dcx-proof--board[data-dcx-boundary="surface-changes"] > .picker-artboard .ps-index-row--flip {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+/* Layout: the committed bodies from styles/screens/layout.css, the landing
+ measure and the region tracks folded into one body per answer (their
+ sources are two rules on the same committed selector). */
+.dcx-proof--board[data-dcx-layout="simple-grid"] > .picker-artboard {
+ --pvs-gutter: 6%;
+ --pvs-gutter-end: 6%;
+ --pvs-hero-cols: var(--pvs-w6) minmax(0, 1fr);
+ --pvs-hero-gap: var(--pvs-gut);
+ --pvs-editorial-cols: var(--pvs-w4) minmax(0, 1fr);
+ --pvs-editorial-gap: var(--pvs-gut);
+ --pvs-gallery-cols: repeat(4, minmax(0, 1fr));
+ --pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 66.1333);
+ --pvs-section-align: start;
+ --pvs-rows: 9.4% 45.5% 9% 27.8% 8.3%;
+ --pvs-phone-gutter: 7%;
+ --pvs-phone-gallery-cols: 1fr 1fr;
+ --pvs-shell-cols:
+ calc(var(--pvs-w2) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w7) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-measure-cols:
+ calc(var(--pvs-w3) + var(--pvs-gut) / 2)
+ minmax(0, 1fr);
+ --pvs-metric-cols: repeat(3, minmax(0, 1fr));
+ --pvs-entry-cols: var(--pvs-w6) minmax(0, 1fr);
+ --pvs-entry-flip-cols: var(--pvs-w6) minmax(0, 1fr);
+}
+
+.dcx-proof--board[data-dcx-layout="simple-grid"] > .picker-artboard .ps-index-row--flip > .ps-image {
+ grid-area: 1 / 1;
+}
+
+.dcx-proof--board[data-dcx-layout="simple-grid"] > .picker-artboard .ps-index-row--flip > .ps-index-cap {
+ grid-area: 1 / 2;
+}
+
+.dcx-proof--board[data-dcx-layout="balanced"] > .picker-artboard {
+ --pvs-gutter: 6%;
+ --pvs-gutter-end: 6%;
+ --pvs-hero-cols: var(--pvs-w5) minmax(0, 1fr);
+ --pvs-hero-gap: var(--pvs-gut);
+ --pvs-editorial-cols: var(--pvs-w3) minmax(0, 1fr);
+ --pvs-editorial-gap: var(--pvs-gut);
+ --pvs-gallery-cols: 23.8fr 15.3333fr 15.3333fr 15.3333fr;
+ --pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 74.6);
+ --pvs-section-align: center;
+ --pvs-rows: 9.4% 50.6% 9% 22.7% 8.3%;
+ --pvs-phone-gutter: 6%;
+ --pvs-phone-gallery-cols: 1.35fr 0.85fr;
+ --pvs-shell-cols:
+ calc(var(--pvs-w2) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w8) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-measure-cols:
+ calc(var(--pvs-w3) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w6) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-metric-cols: 1.45fr 1fr 1fr;
+ --pvs-entry-cols: var(--pvs-w7) minmax(0, 1fr);
+ --pvs-entry-flip-cols: minmax(0, 1fr) var(--pvs-w7);
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"] > .picker-artboard {
+ --pvs-gutter: 6%;
+ --pvs-gutter-end: 6%;
+ --pvs-hero-cols: var(--pvs-w5) minmax(0, 1fr);
+ --pvs-hero-gap: var(--pvs-gut);
+ --pvs-editorial-cols: var(--pvs-w3) minmax(0, 1fr);
+ --pvs-editorial-gap: var(--pvs-gut);
+ --pvs-gallery-cols: repeat(4, minmax(0, 1fr));
+ --pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 74.6);
+ --pvs-section-align: start;
+ --pvs-rows: 9.4% 48% 8.4% 26.2% 8%;
+ --pvs-phone-gutter: 6%;
+ --pvs-phone-gallery-cols: 1.5fr 0.9fr;
+ --pvs-drift: 1;
+ --pvs-entry-cols: var(--pvs-w7) minmax(0, 1fr);
+ --pvs-entry-flip-cols: minmax(0, 1fr) var(--pvs-w7);
+}
+
+/* Freeform's one move on the landing hero: the print cut loose of the
+ grid, with the copy holding the page's left measure. */
+.dcx-proof--board[data-dcx-layout="freeform"] > .picker-artboard .ps-desktop .ps-hero {
+ position: relative;
+ display: block;
+ padding: 0;
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"] > .picker-artboard .ps-desktop .ps-hero > .ps-image {
+ position: absolute;
+ inset: 0;
+ height: 100%;
+ clip-path: polygon(40% 0, 100% 0, 100% 78%, 66% 84%);
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"] > .picker-artboard .ps-desktop .ps-hero > .ps-hero-copy {
+ position: relative;
+ z-index: 1;
+ box-sizing: border-box;
+ width: 44%;
+ height: 100%;
+ align-content: center;
+ padding-left: var(--pvs-gutter, 6%);
+}
+
+/* Freeform on the index: the prints laid down askew with the caption
+ stepping onto the corner, the committed rules that are surface-scoped
+ on screen and read the frame's surface mark here. */
+.dcx-proof--board[data-dcx-layout="freeform"][data-dcx-surface="experience"] > .picker-artboard .ps-index-row > .ps-image {
+ rotate: -1.7deg;
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"][data-dcx-surface="experience"] > .picker-artboard .ps-index-row--flip > .ps-image {
+ rotate: 1.4deg;
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"][data-dcx-surface="experience"] > .picker-artboard .ps-index-cap {
+ position: relative;
+ z-index: 1;
+ translate: -9% 0;
+}
+
+.dcx-proof--board[data-dcx-layout="freeform"][data-dcx-surface="experience"] > .picker-artboard .ps-index-row--flip > .ps-index-cap {
+ translate: 9% 0;
+}
+
+/* The scale sheet and specimen size themselves against their rail on screen
+ 05; in the document they stand at natural height and full width. */
+.dcx-proof--scale > .picker-scale-sheet {
+ grid-template-rows: none;
+ height: auto;
+ row-gap: 10px;
+}
+
+.dcx-proof--specimen > .picker-scale-specimen {
+ height: auto;
+ max-height: none;
+ overflow: visible;
+}
+
+.dcx-proof--icons > .picker-icon-sheet {
+ height: auto;
+ border: 0;
+ background: transparent;
+}
+
+/* ============================================================
+ Surface cards — the tile anatomy, kept.
+ ============================================================ */
+.dcx-surfaces {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(440px, 100%), 1fr));
+ gap: clamp(18px, 2.5vw, 28px);
+}
+
+.dcx-surfaces[data-count="1"] { grid-template-columns: minmax(0, 1fr); }
+
+.dcx-surface-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.dcx-surface-copy { padding: 0 2px; }
+
+.dcx-surface-name {
+ margin: 0 0 8px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.45rem;
+ letter-spacing: 0.01em;
+ color: var(--ks-text);
+}
+
+.dcx-surface-name em {
+ font-style: normal;
+ color: var(--accent);
+}
+
+.dcx-surface-goal {
+ margin: 0 0 12px;
+ max-width: 52ch;
+ color: var(--ks-text-muted);
+ font-size: 0.94rem;
+ line-height: 1.6;
+}
+
+.dcx-default-mark {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 6px;
+ padding: 2px 8px;
+ border: 1px solid var(--ks-rule);
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* ============================================================
+ Swatch board — each role at real size, both notations.
+ ============================================================ */
+.dcx-swatches {
+ display: grid;
+ gap: clamp(14px, 2vw, 20px);
+}
+
+.dcx-swatch {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
+ gap: clamp(16px, 2.5vw, 30px);
+ align-items: center;
+}
+
+.dcx-swatch-chip {
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ min-height: 128px;
+ padding: 14px 16px;
+ border: 1px solid var(--ks-rule);
+ background: var(--swatch);
+ color: var(--swatch-ink);
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ transition: transform 0.25s var(--ks-ease);
+}
+
+.dcx-swatch-chip:hover { transform: translateY(-2px); }
+
+.dcx-swatch-chip:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.dcx-swatch-hex {
+ font-family: var(--ks-mono);
+ font-size: 0.88rem;
+ letter-spacing: 0.08em;
+}
+
+.dcx-swatch-copy-hint {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ opacity: 0;
+ transition: opacity 0.2s var(--ks-ease);
+}
+
+.dcx-swatch-chip:hover .dcx-swatch-copy-hint,
+.dcx-swatch-chip:focus-visible .dcx-swatch-copy-hint { opacity: 0.85; }
+
+.dcx-swatch-meta h3 {
+ margin: 0 0 6px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.3rem;
+ color: var(--ks-text);
+}
+
+.dcx-swatch-meta p {
+ margin: 0 0 8px;
+ max-width: 40ch;
+ color: var(--ks-text-muted);
+ font-size: 0.9rem;
+ line-height: 1.55;
+}
+
+.dcx-swatch-meta code {
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--ks-text-faint);
+}
+
+/* The ink that survives on this role, from the same contrast pick the
+ artboards use: the hex drawn in itself on a mini sample of the role
+ color. The sample is a span, not a code element, so the meta column's
+ code rule cannot outrank the sample's own colors. */
+.dcx-ink-pair {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.dcx-ink-tag {
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+.dcx-ink-sample {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border: 1px solid var(--ks-rule);
+ background: var(--ink-ground);
+ color: var(--ink-text);
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ letter-spacing: 0.08em;
+}
+
+/* Where the committed value came from, at the hex's shoulder on the chip.
+ Inherits the chip's own contrast ink. */
+.dcx-swatch-provenance {
+ margin-left: auto;
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ opacity: 0.75;
+}
+
+/* ============================================================
+ Font pair — the fonttrio anatomy from the font screen.
+ ============================================================ */
+.dcx-pair {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
+ gap: clamp(16px, 2.5vw, 26px);
+}
+
+.dcx-pair-card {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(20px, 3vw, 32px);
+}
+
+.dcx-pair-role {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 14px;
+}
+
+.dcx-pair-name {
+ margin: 0;
+ font-size: clamp(2.1rem, 4vw, 3.2rem);
+ line-height: 1.1;
+ color: var(--ks-text);
+ text-wrap: balance;
+}
+
+.dcx-pair-source {
+ display: inline-block;
+ margin-top: 12px;
+ font-family: var(--ks-mono);
+ font-size: 0.74rem;
+ color: var(--ks-text-faint);
+}
+
+.dcx-pair-why {
+ margin: 18px 0 0;
+ max-width: 62ch;
+ font-size: 1.02rem;
+ line-height: 1.65;
+ color: var(--ks-text-muted);
+}
+
+.dcx-scale-head {
+ margin: 0 0 16px;
+ max-width: 62ch;
+ color: var(--ks-text-muted);
+ font-size: 0.96rem;
+ line-height: 1.6;
+}
+
+.dcx-scale-head strong { color: var(--ks-text); font-weight: 500; }
+
+/* ============================================================
+ Pick grid — one card per surface for the material questions.
+ ============================================================ */
+.dcx-picks {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: clamp(14px, 2vw, 22px);
+}
+
+.dcx-pick {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(18px, 2.5vw, 26px);
+}
+
+.dcx-pick-surface {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 12px;
+}
+
+.dcx-pick-title {
+ margin: 0 0 8px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.5rem;
+ color: var(--ks-text);
+}
+
+.dcx-pick-desc {
+ margin: 0;
+ color: var(--ks-text-muted);
+ font-size: 0.92rem;
+ line-height: 1.6;
+}
+
+/* ============================================================
+ Decision matrix — the Interface category's per-surface recap.
+ ============================================================ */
+.dcx-matrix {
+ margin: 0;
+ display: grid;
+ gap: 8px;
+}
+
+.dcx-matrix-row {
+ display: grid;
+ grid-template-columns: 130px minmax(0, 1fr);
+ gap: 12px;
+ padding: 8px 0;
+ border-bottom: 1px solid color-mix(in oklab, var(--ks-rule) 60%, transparent);
+}
+
+.dcx-matrix-row dt {
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ align-self: center;
+}
+
+.dcx-matrix-row dd {
+ margin: 0;
+ color: var(--ks-text);
+ font-size: 0.95rem;
+}
+
+@media (max-width: 920px) {
+ .dcx-swatch { grid-template-columns: 1fr; }
+ .dcx-matrix-row { grid-template-columns: 110px minmax(0, 1fr); }
+}
+
+/* ============================================================
+ Live editing — affordances appear once the edit session is up
+ (body.dcx-live), and stand down when it goes away.
+ ============================================================ */
+.dcx-edit,
+.dcx-request {
+ display: none;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 14px;
+ border: 1px solid var(--ks-rule);
+ background: transparent;
+ color: var(--ks-text-muted);
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: color 0.2s var(--ks-ease), border-color 0.2s var(--ks-ease);
+}
+
+body.dcx-live .dcx-edit,
+body.dcx-live .dcx-request { display: inline-flex; }
+
+.dcx-edit:hover,
+.dcx-request:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.dcx-edit:focus-visible,
+.dcx-request:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.dcx-swatch-actions {
+ position: relative;
+ display: block;
+ margin-top: 12px;
+}
+
+/* Same trick as the palette screen's custom-color input: present for the
+ picker dialog, invisible to layout. */
+.dcx-native-color {
+ position: absolute;
+ inset: auto auto 0 0;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+ border: 0;
+ padding: 0;
+}
+
+.dcx-pair-why + .dcx-edit { margin-top: 18px; }
+
+.dcx-topbar .dcx-request { margin-left: auto; margin-right: 14px; }
+
+/* The topbar is a grid of current + close today; let the request button sit
+ between them without re-authoring the bar. */
+.dcx-topbar { display: flex; align-items: center; }
+.dcx-topbar .dcx-current { flex: 1; min-width: 0; }
+
+/* ============================================================
+ Request tray — queued work and its status, bottom right.
+ ============================================================ */
+.dcx-tray {
+ position: fixed;
+ right: 22px;
+ bottom: 22px;
+ z-index: 300;
+ display: grid;
+ gap: 10px;
+ width: min(340px, calc(100vw - 44px));
+}
+
+.dcx-tray[hidden] { display: none; }
+
+.dcx-tray-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ padding: 12px 14px;
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
+}
+
+.dcx-tray-dot {
+ width: 9px;
+ height: 9px;
+ margin-top: 5px;
+ border-radius: 50%;
+ background: var(--ks-text-faint);
+}
+
+.dcx-tray-item[data-status="pending"] .dcx-tray-dot { background: var(--ks-text-muted); }
+
+.dcx-tray-item[data-status="working"] .dcx-tray-dot {
+ background: var(--accent);
+ animation: dcx-tray-pulse 1.2s ease-in-out infinite;
+}
+
+.dcx-tray-item[data-status="done"] .dcx-tray-dot { background: var(--ks-patina, #7ba98f); }
+.dcx-tray-item[data-status="error"] .dcx-tray-dot,
+.dcx-tray-item[data-status="offline"] .dcx-tray-dot { background: #c26d5a; }
+
+@keyframes dcx-tray-pulse {
+ 50% { opacity: 0.35; }
+}
+
+.dcx-tray-prompt {
+ margin: 0 0 3px;
+ font-size: 0.86rem;
+ line-height: 1.4;
+ color: var(--ks-text);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.dcx-tray-note {
+ margin: 0;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* ============================================================
+ Request modal — rides the picker's modal shell.
+ ============================================================ */
+.dcx-request-modal .dcx-request-prompt {
+ width: 100%;
+ resize: vertical;
+ min-height: 96px;
+}
+
+/* ============================================================
+ Reduced motion
+ ============================================================ */
+@media (prefers-reduced-motion: reduce) {
+ [class^="dcx-"], [class^="dcx-"]::before, [class^="dcx-"]::after,
+ [class*=" dcx-"], [class*=" dcx-"]::before, [class*=" dcx-"]::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ transition-delay: 0ms !important;
+ }
+
+ .dcx-tile[data-reveal],
+ .dcx-badge[data-reveal] {
+ opacity: 1;
+ transform: none;
+ }
+}
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
new file mode 100644
index 000000000..57d88648d
--- /dev/null
+++ b/picker/styles/picker.css
@@ -0,0 +1,8102 @@
+/* Vendored from the site's design system (now in the private impeccable-site
+ repo) so the picker stays self-contained. Update by re-copying from there. */
+@import "./vendor/kinpaku-tokens.css";
+@import "./vendor/kinpaku-kit.css";
+
+/* Ink for surfaces painted in a color the user chose, not in one of ours:
+ swatch labels and the wireframe artboards. Every --ks-* text token swaps
+ between the light and dark themes, which would invert these against
+ palettes that never changed. Values are the system's own ink and raised
+ paper, so neither is pure black or pure white. contrastInk() in
+ scripts/color.js mirrors their luminance; change both together. */
+:root {
+ --pk-ink-dark: oklch(14% 0.018 95);
+ --pk-ink-light: oklch(99% 0.008 95);
+}
+
+/* Scoped subset of the reset imported by site/styles/main.css through
+ site/styles/tokens.css (lines 12-27). */
+.picker-page,
+.picker-page *,
+.picker-page *::before,
+.picker-page *::after {
+ box-sizing: border-box;
+}
+
+.picker-page * {
+ margin: 0;
+}
+
+.picker-page :is(button, input, textarea, select) {
+ font-family: inherit;
+}
+
+body.picker-page {
+ margin: 0;
+ min-height: 100vh;
+ min-height: 100dvh;
+ background: var(--ks-lacquer);
+ color: var(--ks-text);
+ font-family: var(--ks-font);
+ font-size: var(--ks-type-body-size);
+ line-height: var(--ks-type-body-line);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+/* The gold-leaf face, as the site sets it: a flat kinpaku fill with the leaf
+ asset over it at 38%, not the leaf itself as the fill. The texture is a
+ pseudo-element because a background-image cannot carry its own opacity, and
+ the fixed 340px render keeps the crack structure readable at button size
+ where `cover` would average the asset into flat gold.
+
+ Kept here rather than in the vendored kit for one reason: the picker serves
+ its own compressed copy of the leaf from its own assets directory, so the
+ URL has to come from --pk-foil (set on in layouts/Picker.astro). The
+ rest matches `.ks-button.ks-button-primary` in the site's kinpaku-kit.css,
+ including the hover that lifts to vivid gold rather than the pale beige the
+ vendored copy still carries. */
+.picker-page .ks-button.ks-button-primary {
+ position: relative;
+ isolation: isolate;
+}
+
+/* The paint is gated on :not([disabled]) so it never competes with the disabled
+ state below. Without the gate this selector is (0,3,0) and the kit's
+ `.ks-button[disabled]` is (0,2,0), so a disabled button kept the full gold
+ fill and read as clickable. */
+.picker-page .ks-button.ks-button-primary:not([disabled]) {
+ color: var(--ks-dark-ink);
+ background: var(--ks-kinpaku);
+ border-color: var(--ks-kinpaku);
+}
+
+.picker-page .ks-button.ks-button-primary::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ background: var(--pk-foil) 68% 44% / 340px auto no-repeat;
+ opacity: 0.38;
+ pointer-events: none;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+.picker-page .ks-button.ks-button-primary:not([disabled]):hover {
+ color: var(--ks-dark-ink);
+ background: var(--ks-kinpaku-vivid);
+ border-color: var(--ks-kinpaku-vivid);
+ transform: translateY(-1px);
+}
+
+.picker-page .ks-button.ks-button-primary:not([disabled]):hover::before {
+ opacity: 0.26;
+}
+
+.picker-page .ks-button.ks-button-primary:not([disabled]):active {
+ background: var(--ks-kinpaku-rich);
+ border-color: var(--ks-kinpaku-rich);
+ transform: translateY(0);
+}
+
+/* Select this palette waits for a committed color, and the kit's disabled state
+ drops the fill to transparent. The leaf has to go with it, or the texture
+ floats on the page with no gold under it. */
+.picker-page .ks-button.ks-button-primary[disabled]::before {
+ opacity: 0;
+}
+
+/* A disabled button answering the pointer is the other half of the same bug:
+ the kit's `.ks-button.ks-button-secondary:hover` is (0,3,0) and outranks its
+ own `.ks-button[disabled]` at (0,2,0), so the tint and the lift still fired
+ on a button that cannot be pressed. Restating the state here puts it at
+ (0,3,0) at rest and (0,4,0) under the pointer, which clears every variant
+ rule in the kit without an !important. */
+.picker-page .ks-button[disabled],
+.picker-page .ks-button[disabled]:hover,
+.picker-page .ks-button[disabled]:active {
+ color: var(--ks-text-mute-deep);
+ background: transparent;
+ border-color: var(--ks-rule);
+ cursor: not-allowed;
+ transform: none;
+}
+
+.picker-shell {
+ position: relative;
+ min-height: 100vh;
+ min-height: 100svh;
+ overflow: hidden;
+}
+
+.picker-hero-art {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ background-position: left center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ /* Same grade the site gives .hero-rebuild-art over this asset. */
+ filter: saturate(1.2) contrast(1.08);
+ pointer-events: none;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+#picker-form {
+ position: relative;
+ z-index: 1;
+}
+
+.picker-screen {
+ min-height: 100vh;
+ min-height: 100svh;
+ display: grid;
+ align-items: center;
+}
+
+.picker-screen:not([data-active]) {
+ display: none;
+}
+
+/* The palette screen and the strategy screen show the same two things: the four
+ chosen colors, and the page they are being judged on. Between those two the
+ objects move and resize rather than being redrawn, so each carries a name on
+ both of its copies. An inactive screen is display:none and therefore not
+ rendered, which is what leaves each name on exactly one element at capture.
+
+ Named per role rather than once for the whole strip: each color then travels
+ to its own slot, so a strip that was reordered reads as four colors moving to
+ new places instead of one block sliding. */
+:is(.picker-screen[data-screen="02"], .picker-screen[data-screen="03"]) .picker-band[data-band="primary"] {
+ view-transition-name: pk-band-primary;
+}
+
+:is(.picker-screen[data-screen="02"], .picker-screen[data-screen="03"]) .picker-band[data-band="secondary"] {
+ view-transition-name: pk-band-secondary;
+}
+
+:is(.picker-screen[data-screen="02"], .picker-screen[data-screen="03"]) .picker-band[data-band="tertiary"] {
+ view-transition-name: pk-band-tertiary;
+}
+
+:is(.picker-screen[data-screen="02"], .picker-screen[data-screen="03"]) .picker-band[data-band="neutral"] {
+ view-transition-name: pk-band-neutral;
+}
+
+/* Three screens judge their answer on the same drawing, so what the transition
+ carries is one component in three sizes: it grows out of the surface tile
+ into the palette screen's panel, and again onto the strategy stage, rather
+ than dissolving into a different page at each step. The hidden surfaces are
+ display: none and so are not rendered, which is what keeps the name unique
+ while every chosen surface stays mounted.
+
+ On 01b the frame is named rather than the component inside it. The tile draws
+ that component at its canonical width under a scale transform, and a group
+ animating its own size as well as that transform counts the reduction twice.
+ The frame is the drawing's real box at tile scale and carries the same 16 / 7
+ the other two do, so the pair interpolates without distortion. Only the
+ leading tile is marked, which is both the drawing screen 02 goes on to show
+ and what keeps the name on one element. */
+.picker-screen[data-screen="01b"] .picker-mode-tile[data-lead] .picker-mode-preview,
+.picker-screen[data-screen="02"] .picker-preview,
+.picker-screen[data-screen="03"] .picker-strategy-stage > .picker-preview {
+ view-transition-name: pk-test-page;
+}
+
+::view-transition-group(*) {
+ animation-duration: 340ms;
+ animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+/* Mirrors site/styles/home-rebuild.css's .hero-rebuild-container width math. */
+.picker-container {
+ position: relative;
+ width: 100%;
+ max-width: 1500px;
+ margin: 0 auto;
+ padding-inline: 56px;
+}
+
+.picker-screen[data-screen="01"] .picker-container,
+.picker-screen[data-screen="12"] .picker-container {
+ padding-inline: 75px;
+}
+
+.picker-start {
+ max-width: 620px;
+ display: grid;
+ justify-items: start;
+ gap: 28px;
+}
+
+/* Brand lockup, matching the site header: carved-tile mark plus tracked
+ wordmark, both in kinpaku gold. */
+.picker-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--ks-kinpaku);
+ margin-bottom: 4px;
+}
+
+.picker-brand-logo {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+}
+
+.picker-brand-logo svg {
+ width: 32px;
+ height: 32px;
+}
+
+.picker-brand-name {
+ font-family: var(--ks-font-wordmark);
+ font-size: 1.3rem;
+ font-weight: 400;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.picker-title {
+ color: var(--ks-champagne);
+ font-family: var(--ks-font-display);
+ font-size: var(--ks-type-display-size);
+ font-weight: var(--ks-type-display-weight);
+ line-height: var(--ks-type-display-line);
+ letter-spacing: var(--ks-type-display-track);
+ text-wrap: balance;
+}
+
+.picker-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ margin-top: 4px;
+}
+
+.picker-key-hint {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ margin-top: 12px;
+ color: var(--ks-text-muted);
+}
+
+.picker-key-cluster {
+ display: grid;
+ gap: 4px;
+}
+
+.picker-key-row {
+ display: flex;
+ gap: 4px;
+}
+
+.picker-key-row-top {
+ justify-content: center;
+}
+
+.picker-keycap {
+ min-width: 26px;
+ height: 26px;
+ display: inline-grid;
+ place-items: center;
+ padding-inline: 6px;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ color: var(--ks-champagne);
+ font-family: var(--ks-mono);
+ font-size: var(--ks-type-mono-size);
+ line-height: 1;
+}
+
+.picker-key-caption {
+ color: var(--ks-text-muted);
+ font-size: var(--ks-type-mono-size);
+ line-height: 1.5;
+}
+
+/* The art belongs to the start screen and the finish. Every question in
+ between is judged against a plain ground, because a photograph behind a
+ palette or a wireframe is a second opinion nobody asked for. */
+.picker-shell:has(#picker-form[data-current="01b"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="02"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="03"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="04"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="04b"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="05"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="06"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="07"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="08"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="09"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="10"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="11"]) .picker-hero-art {
+ opacity: 0;
+}
+
+/* One hairline per step, gold once passed, plus the count and the name of the
+ step in hand so the position is legible without counting ticks. */
+.picker-progress {
+ position: fixed;
+ top: 32px;
+ left: max(56px, calc((100vw - 1500px) / 2 + 56px));
+ z-index: 5;
+ display: grid;
+ gap: 10px;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+/* Eleven steps hold the same total width five did: the row is a measure of
+ how far along the interview is, and a wider one would start competing with
+ the question. The ticks divide whatever that width is.
+
+ Eleven is the ceiling rather than the count. A question no chosen surface
+ takes is not a step, so the navigation writes the run's own count here and
+ the ticks past it leave the grid: the row still measures the same distance,
+ divided between the questions this run is actually asked. */
+.picker-progress-track {
+ width: 232px;
+ display: grid;
+ grid-template-columns: repeat(var(--pk-steps, 11), minmax(0, 1fr));
+ gap: 5px;
+}
+
+.picker-progress-track i {
+ height: 2px;
+ background: var(--ks-rule);
+ transition: background-color 260ms var(--ks-ease);
+}
+
+.picker-progress-track i[data-off] {
+ display: none;
+}
+
+.picker-progress[data-step="1"] .picker-progress-track i:nth-child(-n + 1),
+.picker-progress[data-step="2"] .picker-progress-track i:nth-child(-n + 2),
+.picker-progress[data-step="3"] .picker-progress-track i:nth-child(-n + 3),
+.picker-progress[data-step="4"] .picker-progress-track i:nth-child(-n + 4),
+.picker-progress[data-step="5"] .picker-progress-track i:nth-child(-n + 5),
+.picker-progress[data-step="6"] .picker-progress-track i:nth-child(-n + 6),
+.picker-progress[data-step="7"] .picker-progress-track i:nth-child(-n + 7),
+.picker-progress[data-step="8"] .picker-progress-track i:nth-child(-n + 8),
+.picker-progress[data-step="9"] .picker-progress-track i:nth-child(-n + 9),
+.picker-progress[data-step="10"] .picker-progress-track i:nth-child(-n + 10),
+.picker-progress[data-step="11"] .picker-progress-track i {
+ background: var(--ks-kinpaku);
+}
+
+.picker-progress-label {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ font-family: var(--ks-font);
+ font-size: 0.68rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-muted);
+}
+
+.picker-progress-index b {
+ color: var(--ks-kinpaku);
+ font-weight: 500;
+}
+
+.picker-progress-name {
+ color: var(--ks-champagne);
+ letter-spacing: 0.1em;
+}
+
+#picker-form[data-current="01"] .picker-progress {
+ opacity: 0;
+ visibility: hidden;
+}
+
+.picker-palette {
+ display: grid;
+ gap: 48px;
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+/* Every question wears the site's section-heading treatment: the same thin
+ display face `.slop-teaser-title` gets inside `.slop-pattern-head` on the
+ homepage. Centered rather than set in a 15ch column, because a question owns
+ the top of the screen instead of leading a left-hand text block. */
+.picker-question-title {
+ margin: 0;
+ color: var(--ks-champagne);
+ font-family: var(--ks-font-display);
+ font-size: clamp(3rem, 5.6vw, 5.5rem);
+ font-weight: 100;
+ line-height: 1.04;
+ letter-spacing: -0.005em;
+ text-align: center;
+ text-wrap: balance;
+}
+
+.picker-palette-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
+ gap: 50px;
+ align-items: center;
+}
+
+.picker-palette-subgrid {
+ display: grid;
+ grid-template-rows: auto auto;
+ gap: 30px;
+ min-width: 0;
+ align-self: start;
+}
+
+.picker-palette-subgrid > .picker-actions-stack {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+}
+
+.picker-deck-column {
+ --picker-card-size: min(39vw, 660px, calc(100svh - 292px));
+ display: grid;
+ justify-items: center;
+ gap: 16px;
+ min-width: 0;
+}
+
+.picker-deck-stage {
+ position: relative;
+ width: calc(var(--picker-card-size) + 64px);
+ height: var(--picker-card-size);
+ perspective: 1000px;
+ perspective-origin: center 42%;
+}
+
+.picker-deck-scroller {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scroll-snap-type: y mandatory;
+ scrollbar-width: none;
+ overscroll-behavior: contain;
+}
+
+.picker-deck-scroller::-webkit-scrollbar {
+ display: none;
+}
+
+.picker-snap-point {
+ height: var(--picker-card-size);
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
+}
+
+.picker-card-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ transform-style: preserve-3d;
+}
+
+.picker-card {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 0;
+ width: var(--picker-card-size);
+ opacity: 0;
+ backface-visibility: hidden;
+ transform-origin: center center;
+ transform-style: preserve-3d;
+ transition:
+ opacity 240ms var(--ks-ease),
+ transform 240ms var(--ks-ease);
+}
+
+.picker-card[data-pos="0"] {
+ z-index: 5;
+ opacity: 1;
+ transform: translateX(0) rotateY(0) rotateZ(0) rotateX(0) translateZ(0);
+}
+
+.picker-card[data-pos="1"] {
+ z-index: 4;
+ opacity: 0.52;
+ transform: translateX(11%) rotateY(0) rotateZ(6deg) rotateX(0) translateZ(-160px);
+}
+
+.picker-card[data-pos="2"] {
+ z-index: 3;
+ opacity: 0.2;
+ transform: translateX(19%) rotateY(0) rotateZ(8deg) rotateX(0) translateZ(-180px);
+}
+
+.picker-card[data-pos="-1"] {
+ z-index: 4;
+ opacity: 0.52;
+ transform: translateX(-11%) rotateY(0) rotateZ(-6deg) rotateX(0) translateZ(-160px);
+}
+
+.picker-card[data-pos="-2"] {
+ z-index: 3;
+ opacity: 0.2;
+ transform: translateX(-19%) rotateY(0) rotateZ(-8deg) rotateX(0) translateZ(-180px);
+}
+
+.picker-card.is-far {
+ opacity: 0;
+ transform: translateX(0) rotateY(0) rotateZ(0) rotateX(0) translateZ(-220px);
+}
+
+.picker-card[data-exit="left"] {
+ z-index: 6;
+ animation: picker-card-exit-left 280ms var(--ks-ease) both;
+}
+
+.picker-card[data-exit="right"] {
+ z-index: 6;
+ animation: picker-card-exit-right 280ms var(--ks-ease) both;
+}
+
+@keyframes picker-card-exit-left {
+ 0% {
+ opacity: 1;
+ transform: translateX(0) rotateY(0) rotateZ(0) rotateX(0) translateZ(0);
+ }
+
+ 52% {
+ opacity: 0.78;
+ transform: translateX(-116%) rotateY(-24deg) rotateZ(0) rotateX(2deg) translateZ(-156px);
+ }
+
+ 100% {
+ opacity: 0.52;
+ transform: translateX(-11%) rotateY(0) rotateZ(-6deg) rotateX(0) translateZ(-160px);
+ }
+}
+
+@keyframes picker-card-exit-right {
+ 0% {
+ opacity: 1;
+ transform: translateX(0) rotateY(0) rotateZ(0) rotateX(0) translateZ(0);
+ }
+
+ 52% {
+ opacity: 0.78;
+ transform: translateX(116%) rotateY(24deg) rotateZ(0) rotateX(2deg) translateZ(-156px);
+ }
+
+ 100% {
+ opacity: 0.52;
+ transform: translateX(11%) rotateY(0) rotateZ(6deg) rotateX(0) translateZ(-160px);
+ }
+}
+
+.picker-card-face {
+ position: relative;
+ width: var(--picker-card-size);
+ aspect-ratio: 1;
+ overflow: hidden;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+}
+
+.picker-cue-face img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+}
+
+.picker-seed-face {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+}
+
+.picker-seed-face span {
+ background: var(--seed-color);
+}
+
+.picker-ring {
+ position: absolute;
+ left: var(--x);
+ top: var(--y);
+ z-index: 2;
+ width: 45px;
+ height: 45px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ cursor: grab;
+ opacity: 1;
+ pointer-events: none;
+ transform: translate(-50%, -50%);
+ touch-action: none;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+.picker-ring::before {
+ content: "";
+ position: absolute;
+ inset: 6px;
+ background: var(--marker-color);
+ border: 2px solid var(--ks-lacquer-raised);
+ border-radius: 50%;
+ box-shadow:
+ 0 0 12px color-mix(in srgb, var(--ks-champagne) 16%, transparent),
+ inset 0 0 0 1px color-mix(in srgb, var(--ks-champagne) 40%, transparent);
+}
+
+.picker-ring::after {
+ content: "";
+ position: absolute;
+ inset: 1px;
+ border: 2px solid white;
+ border-radius: 50%;
+ /* Dark edge on both sides of the white hairline so the hover highlight
+ reads over the neutral's pale image regions, not only over saturated
+ ones. Fixed near-black, not a theme token: it sits on the photo. */
+ box-shadow:
+ 0 0 0 1.5px oklch(15% 0.006 95 / 0.6),
+ inset 0 0 0 1.5px oklch(15% 0.006 95 / 0.6);
+ opacity: 0;
+ pointer-events: none;
+ transform: scale(0.78);
+ transition:
+ opacity 180ms var(--ks-ease),
+ transform 180ms var(--ks-ease);
+}
+
+.picker-palette:has(.picker-band[data-band="primary"]:is(:hover, :focus-within))
+ .picker-card[data-pos="0"] .picker-ring[data-role="primary"]::after,
+.picker-palette:has(.picker-band[data-band="secondary"]:is(:hover, :focus-within))
+ .picker-card[data-pos="0"] .picker-ring[data-role="secondary"]::after,
+.picker-palette:has(.picker-band[data-band="tertiary"]:is(:hover, :focus-within))
+ .picker-card[data-pos="0"] .picker-ring[data-role="tertiary"]::after,
+.picker-palette:has(.picker-band[data-band="neutral"]:is(:hover, :focus-within))
+ .picker-card[data-pos="0"] .picker-ring[data-role="neutral"]::after {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.picker-card[data-pos="0"] .picker-ring {
+ pointer-events: auto;
+}
+
+.picker-ring:is(:focus-visible, [data-dragging]) {
+ z-index: 3;
+}
+
+.picker-ring[data-dragging] {
+ cursor: grabbing;
+}
+
+.picker-ring[data-detached] {
+ opacity: 0.4;
+}
+
+.picker-ring:focus-visible {
+ outline: none;
+}
+
+.picker-ring:focus-visible::before {
+ outline: 2px solid var(--ks-patina);
+ outline-offset: 3px;
+}
+
+.picker-deck-controls {
+ min-width: 260px;
+ display: grid;
+ grid-template-columns: 44px minmax(120px, 1fr) 44px;
+ gap: 14px;
+ align-items: center;
+}
+
+.picker-ring-guide {
+ min-height: 2.9em;
+ max-width: 38ch;
+ color: var(--ks-text-muted);
+ font-size: 0.82rem;
+ line-height: 1.45;
+ text-align: center;
+ transition: opacity 200ms var(--ks-ease);
+}
+
+.picker-ring-guide span {
+ display: block;
+}
+
+.picker-ring-guide[aria-hidden="true"] {
+ opacity: 0;
+}
+
+.picker-icon-button {
+ position: relative;
+ width: 44px;
+ height: 44px;
+ display: inline-grid;
+ place-items: center;
+ padding: 0;
+ background: transparent;
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ color: var(--ks-champagne);
+ cursor: pointer;
+ transition:
+ background-color 180ms var(--ks-ease),
+ border-color 180ms var(--ks-ease),
+ color 180ms var(--ks-ease);
+}
+
+.picker-icon-button:hover {
+ background: var(--ks-graphite);
+ border-color: var(--ks-patina);
+ color: var(--ks-patina);
+}
+
+.picker-icon-button:focus-visible,
+.picker-band:focus-visible,
+.picker-band-tool:focus-visible,
+.picker-tint:focus-visible,
+.picker-tint-close:focus-visible {
+ outline: 2px solid var(--ks-patina);
+ outline-offset: 3px;
+}
+
+.picker-icon-button:disabled {
+ color: var(--ks-text-mute-deep);
+ cursor: not-allowed;
+}
+
+.picker-icon-button svg,
+.picker-band-tool svg {
+ width: 18px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.picker-deck-count {
+ color: var(--ks-text-muted);
+ font-family: var(--ks-mono);
+ font-size: var(--ks-type-mono-size);
+ line-height: 1.35;
+ text-align: center;
+}
+
+.picker-palette-panel {
+ position: relative;
+ min-width: 0;
+ display: grid;
+ gap: 18px;
+ padding: 30px;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+}
+
+.picker-palette-panel > legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+.picker-reset {
+ position: absolute;
+ top: -22px;
+ right: 18px;
+ z-index: 4;
+ background: var(--ks-lacquer-raised);
+}
+
+.picker-bands {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1px;
+ background: var(--ks-rule);
+ border: 1px solid var(--ks-rule);
+}
+
+.picker-band-item {
+ min-width: 0;
+ background: var(--ks-lacquer-raised);
+}
+
+/* Tips on grips and tools sit outside their band's box. Grid paint order
+ otherwise buries them under the next swatch; the deck's stacked cards
+ can overlap the panel edge the same way. */
+.picker-band-item:has(.picker-band:is(:hover, :focus-within)),
+.picker-band-item:has(.picker-band[data-dragging]) {
+ position: relative;
+ z-index: 3;
+}
+
+.picker-band-item:has(.picker-band[data-dragging]) {
+ z-index: 6;
+}
+
+.picker-screen[data-screen="02"] .picker-palette-subgrid {
+ position: relative;
+ z-index: 2;
+}
+
+.picker-reset:is(:hover, :focus-visible) {
+ z-index: 5;
+}
+
+.picker-band {
+ --band-color: var(--ks-graphite);
+ --band-ink: var(--ks-champagne);
+ position: relative;
+ height: 175px;
+ background: var(--band-color);
+ color: var(--band-ink);
+}
+
+.picker-band output {
+ position: absolute;
+ right: 14px;
+ bottom: 14px;
+ left: 14px;
+ font-family: var(--ks-mono);
+ font-size: var(--ks-type-mono-size);
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ line-height: 1;
+ pointer-events: none;
+}
+
+.picker-band-tools {
+ position: absolute;
+ top: 12px;
+ right: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+.picker-band:hover .picker-band-tools,
+.picker-band:focus-within .picker-band-tools {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+/* With the strip open the edit button is also its close button, so it is the
+ one tool that stays live. The other two would act on a color the strip is in
+ the middle of changing. */
+.picker-band-item[data-tint-open] .picker-band-tool:is([data-copy-color], [data-custom-color]) {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.picker-band-tool {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-radius: 2px;
+ color: inherit;
+ cursor: pointer;
+ opacity: 0.72;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+.picker-band-tool:is(:hover, :focus-visible) {
+ opacity: 1;
+}
+
+.picker-band-tool svg {
+ width: 17px;
+ height: 17px;
+}
+
+.picker-tip::after {
+ content: attr(data-tip);
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 8px);
+ z-index: 12;
+ width: max-content;
+ max-width: 210px;
+ padding: 7px 9px;
+ opacity: 0;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ color: var(--ks-text);
+ font-size: 0.76rem;
+ line-height: 1.25;
+ pointer-events: none;
+ transform: translate(-50%, 3px);
+ transition:
+ opacity 180ms var(--ks-ease),
+ transform 180ms var(--ks-ease);
+}
+
+.picker-tip:hover::after,
+.picker-tip:focus-visible::after {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+
+.picker-band-tool.picker-tip::after {
+ top: 50%;
+ right: calc(100% + 8px);
+ bottom: auto;
+ left: auto;
+ transform: translate(3px, -50%);
+}
+
+.picker-band-tool.picker-tip:is(:hover, :focus-visible)::after {
+ transform: translate(0, -50%);
+}
+
+.picker-native-color {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* Reorder grip. Dragging a band carries its color to another slot; the slots
+ keep their roles, so the label in the foot holds still while the band above
+ it travels. The affordance stays quiet because the color is the subject of
+ the row: no border of its own, and it strengthens on hover and on focus. */
+.picker-band-grip {
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ z-index: 2;
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-radius: 2px;
+ color: inherit;
+ cursor: grab;
+ opacity: 0.42;
+ touch-action: none;
+ transition: opacity 180ms var(--ks-ease);
+}
+
+.picker-band-grip svg {
+ width: 22px;
+ height: 22px;
+ fill: currentColor;
+ stroke: none;
+}
+
+.picker-band:hover .picker-band-grip,
+.picker-band:focus-within .picker-band-grip {
+ opacity: 0.9;
+}
+
+.picker-band-grip:focus-visible {
+ opacity: 1;
+ outline: 2px solid var(--ks-patina);
+ outline-offset: 3px;
+}
+
+.picker-band-grip[data-dragging] {
+ cursor: grabbing;
+ opacity: 1;
+}
+
+/* The leftmost grip sits against the panel's left edge, so its tip reads to the
+ side there is room on rather than to the left like the tools' tips do. */
+.picker-band-grip.picker-tip::after {
+ top: 50%;
+ right: auto;
+ bottom: auto;
+ left: calc(100% + 8px);
+ transform: translate(-3px, -50%);
+}
+
+.picker-band-grip.picker-tip:is(:hover, :focus-visible)::after {
+ transform: translate(0, -50%);
+}
+
+/* The tip has said its piece by the time the band is moving, and under the
+ pointer it would cover the color being placed. */
+.picker-band-grip[data-dragging].picker-tip::after {
+ opacity: 0;
+ transition: none;
+}
+
+/* The contrast alert. A small raised chip pinned to the neutral swatch's
+ free corner (grip top left, tools top right, hex readout bottom left),
+ shown by syncNeutralAlert() whenever the working neutral fails a check.
+ The chip borrows the tools' raised-surface vocabulary so the vermilion
+ glyph reads on any color the visitor picks. */
+.picker-band-alert {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ z-index: 2;
+ width: 24px;
+ height: 24px;
+ display: grid;
+ place-items: center;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ color: var(--ks-vermilion);
+ cursor: help;
+}
+
+/* display: grid beats the hidden attribute's UA rule, so the healthy
+ state needs the attribute spelled out. */
+.picker-band-alert[hidden] {
+ display: none;
+}
+
+.picker-band-alert svg {
+ width: 16px;
+ height: 16px;
+}
+
+.picker-band-alert:focus-visible {
+ outline: 2px solid var(--ks-patina);
+ outline-offset: 2px;
+}
+
+/* The badge's explanation. A real element rather than a .picker-tip
+ pseudo-element so it can carry role="tooltip", be named by
+ aria-describedby, and stack one line per issue. Right-aligned over the
+ badge because the neutral band is the strip's last column and a
+ centered tip would run off the panel; the arrow below points back at
+ the badge. Revealed by the badge's hover and keyboard focus only. */
+.picker-band-alert-tip {
+ position: absolute;
+ right: 6px;
+ bottom: 44px;
+ z-index: 12;
+ width: max-content;
+ max-width: 240px;
+ padding: 8px 10px;
+ display: grid;
+ gap: 6px;
+ opacity: 0;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ color: var(--ks-text);
+ font-size: 0.76rem;
+ line-height: 1.35;
+ pointer-events: none;
+ transform: translateY(3px);
+ transition:
+ opacity 180ms var(--ks-ease),
+ transform 180ms var(--ks-ease);
+}
+
+.picker-band-alert-tip span {
+ display: block;
+}
+
+.picker-band-alert-tip::after {
+ content: "";
+ position: absolute;
+ right: 13px;
+ bottom: -5px;
+ width: 8px;
+ height: 8px;
+ background: var(--ks-lacquer-raised);
+ border-right: 1px solid var(--ks-rule);
+ border-bottom: 1px solid var(--ks-rule);
+ transform: rotate(45deg);
+}
+
+.picker-band-alert:is(:hover, :focus-visible) + .picker-band-alert-tip {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* Same rule the grip's tip follows: nothing explains anything mid-drag. */
+.picker-band[data-dragging] .picker-band-alert-tip {
+ opacity: 0;
+ transition: none;
+}
+
+/* A lifted band rides over its neighbours and carries a hairline of its own
+ ink, since this screen has no shadows to borrow. The ring fades rather than
+ cuts, so it is gone by the time the band hands its color to the slot it
+ landed on and the swap itself is invisible. */
+.picker-band[data-dragging] {
+ z-index: 6;
+ cursor: grabbing;
+}
+
+.picker-band::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ outline: 1px solid color-mix(in srgb, currentColor 55%, transparent);
+ outline-offset: -1px;
+ pointer-events: none;
+ transition: opacity 140ms var(--ks-ease);
+}
+
+.picker-band[data-dragging]::after {
+ opacity: 1;
+}
+
+.picker-band-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .picker-band-grip,
+ .picker-band::after {
+ transition: none;
+ }
+}
+
+.picker-band-foot {
+ position: relative;
+ height: 46px;
+ display: grid;
+ place-items: center;
+}
+
+.picker-band-foot h2 {
+ color: var(--ks-champagne);
+ font-size: var(--ks-type-title-size);
+ font-weight: var(--ks-type-title-weight);
+ line-height: var(--ks-type-title-line);
+}
+
+.picker-band-item[data-tint-open] .picker-band-foot h2 {
+ visibility: hidden;
+}
+
+/* The same band, restated on the strategy screen under the choices. It is a
+ reading of the palette and a handle to reorder it, not a place to edit it, so
+ the tools come off and what is left fits in the column the options leave
+ behind: a little over a third of the height the editable band needs. */
+.picker-bands--compact .picker-band {
+ /* The strip's own height less the foot and the group's two hairlines. */
+ height: calc(var(--pk-band, 96px) - 32px);
+}
+
+/* Read from the far corner rather than under the grip: at the short end of the
+ band's range the swatch is not tall enough to stack them. */
+.picker-bands--compact .picker-band output {
+ right: 8px;
+ bottom: 8px;
+ left: 8px;
+ text-align: right;
+}
+
+.picker-bands--compact .picker-band-grip {
+ top: 3px;
+ left: 3px;
+ width: 30px;
+ height: 30px;
+}
+
+.picker-bands--compact .picker-band-grip svg {
+ width: 19px;
+ height: 19px;
+}
+
+/* The compact band's contrast alert. The editable band parks the badge in
+ its free lower right corner; here that corner belongs to the hex readout,
+ so the badge mirrors the grip in the free upper right one. It ducks under
+ the grip (z-index 1 against the grip's 2) so the transient "Drag to
+ reorder" tip, which reads to the right of the grip, is not punched
+ through by the glyph while it shows. The explanation floats above the
+ strip: inside it the short band could not clear the badge, and the strip
+ is its block's last row, so the room is above. */
+.picker-bands--compact .picker-band-alert {
+ top: 3px;
+ right: 3px;
+ bottom: auto;
+ z-index: 1;
+}
+
+.picker-bands--compact .picker-band-alert-tip {
+ right: 3px;
+ bottom: calc(100% + 9px);
+}
+
+.picker-bands--compact .picker-band-foot {
+ height: 30px;
+}
+
+.picker-bands--compact .picker-band-foot h2 {
+ font-size: 0.96rem;
+}
+
+.picker-tint-strip {
+ position: absolute;
+ top: 9px;
+ left: 0;
+ z-index: 8;
+ display: flex;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+}
+
+.picker-band-item[data-band-item="secondary"] .picker-tint-strip {
+ left: -56px;
+}
+
+.picker-band-item:is([data-band-item="tertiary"], [data-band-item="neutral"]) .picker-tint-strip {
+ right: 0;
+ left: auto;
+}
+
+.picker-tint-strip[hidden] {
+ display: none;
+}
+
+.picker-tint,
+.picker-tint-close {
+ position: relative;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-right: 1px solid var(--ks-rule);
+ border-radius: 0;
+ cursor: pointer;
+}
+
+.picker-tint {
+ background: var(--tint-color);
+}
+
+.picker-tint[data-current]::after {
+ content: "";
+ position: absolute;
+ right: 8px;
+ bottom: 3px;
+ left: 8px;
+ height: 2px;
+ background: var(--ks-champagne);
+}
+
+.picker-tint-close {
+ background: var(--ks-lacquer-raised);
+ border-right: 0;
+ color: var(--ks-champagne);
+ font-size: 1.1rem;
+ line-height: 1;
+}
+
+.picker-preview {
+ --pv-primary: var(--ks-champagne);
+ --pv-secondary: var(--ks-patina);
+ --pv-tertiary: var(--ks-kinpaku);
+ --pv-neutral: var(--ks-lacquer-raised);
+ --pv-n-ink: var(--ks-champagne);
+ --pv-p-ink: var(--ks-champagne);
+ --pv-surface-2: color-mix(in oklab, var(--pv-neutral) 92%, var(--pv-n-ink));
+ --pv-bone: color-mix(in oklab, var(--pv-neutral) 62%, var(--pv-n-ink));
+ --pv-strong: color-mix(in oklab, var(--pv-neutral) 22%, var(--pv-n-ink));
+ --pv-secondary-wash: color-mix(in oklab, var(--pv-secondary) 28%, var(--pv-neutral));
+ --pv-tertiary-wash: color-mix(in oklab, var(--pv-tertiary) 22%, var(--pv-neutral));
+ width: 100%;
+ aspect-ratio: 16 / 7;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ /* A share of the frame rather than a fixed channel: on screen 03 the two
+ cards are drawn near twice the size they are on 01b, and a gutter that
+ stays at 16px there reads as a seam between them. */
+ gap: 3.229163%;
+}
+
+/* Two artboards floating on the page itself: a desktop card and a phone card,
+ both in the same shell of shared chrome. What fills their bodies is the
+ variant the chosen surface brings, so the palette is judged on the kind of
+ page it is going to dress. The phone's fixed portrait ratio keeps the wide
+ frame from stretching a single page mock.
+
+ Both cards are inline-size containers, so the chrome each one carries is cut
+ from the card it sits on. A nav band is a drawn object rather than page
+ length, which is why its own vertical padding is counted in `--pv-x` with
+ the rest: a taller frame is a longer page behind the same window, not a
+ thicker window. Every count below is a canonical pixel over the card's own
+ height, 214.78 on both, counted the way the four bodies are counted against
+ theirs. The two corner radii are the exception and stay in pixels: a
+ container measures for what is inside it, so a card cannot spend its own
+ `--pv-x` on itself. */
+/* A line of type is drawn as a bar, and how heavy that bar reads is not settled
+ by its authored count: the phone spends nearly four times the share of its
+ card on the same number, which is why the two cards side by side can be cut
+ from one drawing and still disagree about how present the text is. The factor
+ is the room to say that, per card and without moving anything else in the
+ drawing. It stays 1 wherever a card was signed off, so a card only carries a
+ different weight where something turned it on by name. */
+.pv-desktop,
+.pv-phone {
+ --pv-text: 1;
+ container-type: inline-size;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--pv-neutral);
+ border: 1px solid var(--ks-rule);
+}
+
+.pv-desktop {
+ /* 1 / 1.745162, this card's width over its height at the component's authored
+ 16 / 7, measured on the built picker. */
+ --pv-x: 0.573013cqw;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ border-radius: 4px;
+}
+
+.pv-phone {
+ /* 1 / 0.468718: the portrait card is narrower than it is tall, so the same
+ count spends a larger share of this card's width than of the desktop's. */
+ --pv-x: 2.133479cqw;
+ height: 100%;
+ aspect-ratio: 9 / 19;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ border-radius: 10px;
+}
+
+.pv-nav {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: calc(4.6559 * var(--pv-x));
+ align-items: center;
+ padding: calc(4.6559 * var(--pv-x)) calc(5.58708 * var(--pv-x));
+}
+
+.pv-logo {
+ width: calc(3.72472 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pv-primary);
+ border-radius: 50%;
+}
+
+.pv-nav-bars {
+ display: flex;
+ justify-content: flex-end;
+ gap: calc(3.72472 * var(--pv-x));
+}
+
+.pv-nav-bars i {
+ display: block;
+ width: calc(8.38062 * var(--pv-x));
+ height: calc(1.39677 * var(--pv-x));
+ background: var(--pv-bone);
+ border-radius: 2px;
+}
+
+.pv-pill {
+ width: calc(14.89888 * var(--pv-x));
+ height: calc(5.58708 * var(--pv-x));
+ background: var(--pv-primary);
+ border-radius: 2px;
+ display: grid;
+ place-items: center;
+ color: var(--pv-neutral);
+}
+
+.pv-pill::after {
+ content: "";
+ width: calc(8.5 * var(--pv-x));
+ height: calc(1.3 * var(--pv-x));
+ background: currentColor;
+ border-radius: 2px;
+}
+
+/* The hero is a size container so the page it draws scales with the card rather
+ than sitting at one drawn size inside it. Its counts are canonical pixels
+ divided by the box's own height, 164.78. Down the page they are spent in
+ `cqh`, so a taller card is a longer hero rather than a magnified one; across
+ it they are spent in `--pv-x` against the width, as the ops variant below
+ sets out, so a bar keeps its thickness when the frame changes shape.
+ Its own padding is a percentage, since a container cannot measure itself. */
+.pv-hero {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
+ gap: 8%;
+ align-items: center;
+ padding: 1.06729% 8% 3.735179%;
+ /* 1 / 1.910772, this box's width over its height. */
+ --pv-x: 0.523349cqw;
+}
+
+.pv-hero-copy {
+ display: grid;
+ gap: 4.85493cqh;
+}
+
+.pv-eyebrow {
+ width: 22%;
+ height: calc(2.42746 * var(--pv-text) * var(--pv-x));
+ background: var(--pv-tertiary);
+ border-radius: 2px;
+}
+
+/* The headline is display type set in the brand color, not permanent black
+ ink: it must visibly react when the primary changes. */
+.pv-headline {
+ display: grid;
+ gap: 4.24806cqh;
+ margin-block: 1.21374cqh 1.8206cqh;
+}
+
+.pv-headline i {
+ display: block;
+ width: 88%;
+ height: calc(4.85492 * var(--pv-text) * var(--pv-x));
+ background: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ border-radius: 2px;
+}
+
+.pv-headline i:last-child {
+ width: 58%;
+}
+
+.pv-copy-bar {
+ width: 64%;
+ height: calc(3.03433 * var(--pv-text) * var(--pv-x));
+ background: var(--pv-bone);
+ border-radius: 2px;
+}
+
+.pv-actions {
+ display: flex;
+ gap: calc(4.24806 * var(--pv-x));
+ margin-top: 6.06866cqh;
+}
+
+/* Buttons are objects on the page rather than page rhythm, so they keep their
+ drawn size and the room above them takes the extra height instead. */
+.pv-actions i {
+ width: calc(23.06088 * var(--pv-x));
+ height: calc(7.88925 * var(--pv-x));
+ background: var(--pv-primary);
+ border-radius: 2px;
+}
+
+.pv-actions i:last-child {
+ background: transparent;
+ border: 1px solid var(--pv-secondary);
+}
+
+.pv-image {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ background: linear-gradient(135deg, var(--pv-secondary-wash), color-mix(in oklab, var(--pv-secondary) 52%, var(--pv-neutral)));
+ border: 1px solid var(--pv-secondary);
+ border-radius: 2px;
+}
+
+.pv-image::after {
+ content: "";
+ position: absolute;
+ top: calc(5.46179 * var(--pv-x));
+ right: calc(5.46179 * var(--pv-x));
+ width: calc(4.24806 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pv-tertiary);
+ border-radius: 50%;
+}
+
+.pv-avatar {
+ width: calc(4.19031 * var(--pv-x));
+ aspect-ratio: 1;
+ border: 1px solid var(--pv-bone);
+ border-radius: 50%;
+}
+
+/* Phone artboard: the desktop page narrowed, same hero plate over the same two
+ blocks, so the palette is judged making one argument at two widths. The
+ blocks each carry a different role, keeping the secondary and tertiary
+ visible as accents rather than only as washes. */
+.pv-phone-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: calc(4.19031 * var(--pv-x)) calc(4.6559 * var(--pv-x));
+ background: var(--pv-surface-2);
+ border-bottom: 1px solid var(--ks-rule);
+}
+
+/* A size container counted the way the hero is: canonical pixels over this
+ padded box's own height, 145.78, spent in `cqh` down the page and in
+ `--pv-x` across it. The padding itself is a percentage of the phone's width,
+ since a container cannot measure itself. */
+.pv-phone-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ /* The plate takes the row that flexes and the two blocks keep their drawn
+ size, so a narrower window shortens the hero rather than pushing a block
+ out through the bottom of the phone. */
+ grid-template-rows: minmax(0, 1fr) auto auto;
+ /* A percentage of this box's own height rather than a count in `cqh`: a
+ container unit on the container's own property is measured against the box
+ outside it, which here is the phone card, and the row gap would be cut from
+ the wrong page. */
+ row-gap: 4.802058%;
+ padding: 8.940432% 9.933758% 9.933758%;
+ /* 1 / 0.553376, this box's width over its height. */
+ --pv-x: 1.807089cqw;
+}
+
+/* The desktop plate's mark is counted against a taller box than this one, so
+ the phone restates it. */
+.pv-phone-body .pv-image::after {
+ top: calc(6.17364 * var(--pv-x));
+ right: calc(6.17364 * var(--pv-x));
+ width: calc(4.80172 * var(--pv-x));
+}
+
+/* Its 4 / 3 is honored wherever the row is tall enough to hold it, which is
+ every window past the width the component stops growing at. Below that the
+ plate gives up height instead of overflowing. */
+.pv-phone-body > .pv-image {
+ align-self: center;
+ max-height: 100%;
+}
+
+/* A block is a drawn object rather than a share of the page, so everything
+ inside it is counted across the box: the row it sits in is `auto`, and a
+ block that took its rhythm from the card's height would grow into the plate
+ above it every time the frame got taller. */
+.pv-mini-card {
+ display: grid;
+ gap: calc(3.4298 * var(--pv-x));
+ padding: calc(5.48768 * var(--pv-x)) calc(6.17364 * var(--pv-x));
+ background: var(--pv-surface-2);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+}
+
+.pv-mini-card i,
+.pv-mini-card span {
+ display: block;
+ height: calc(2.05788 * var(--pv-x));
+ background: var(--pv-bone);
+ border-radius: 2px;
+}
+
+.pv-mini-card span {
+ width: 62%;
+ height: calc(4.11576 * var(--pv-x));
+ background: var(--pv-secondary);
+}
+
+/* Keyed off the pair rather than a position, so the hero plate sitting above
+ them cannot shift which block reads as tertiary. */
+.pv-mini-card + .pv-mini-card span {
+ background: var(--pv-tertiary);
+}
+
+.pv-tabbar {
+ display: flex;
+ justify-content: space-evenly;
+ align-items: center;
+ padding: calc(3.72472 * var(--pv-x)) 0;
+ border-top: 1px solid var(--ks-rule);
+}
+
+.pv-tabbar i {
+ width: calc(2.32795 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pv-bone);
+ border-radius: 50%;
+}
+
+.pv-tabbar i:first-child {
+ background: var(--pv-primary);
+}
+
+/* ── Screen 01b: which surfaces ──────────────────────────────
+ Four tiles, one per mode the skill recognizes, chosen before any visual
+ question so every later answer knows what kind of page it is dressing.
+ Multi-select: a product is allowed to be an app with a landing page and
+ docs. All four previews are drawn in the one demo palette set on the frame
+ below: color has not been chosen yet, so no tile is allowed to read as a
+ better color decision than the three beside it. */
+/* Everything on this screen is cut from one measure: the width the palette
+ screen's preview is currently rendering at, and the scale this tile reduces
+ it to. Every tile shows that component itself under a transform rather than
+ a second copy tuned by hand, which is the only way the two screens can
+ agree pixel for pixel.
+
+ --pk-pv-natural mirrors the palette screen's own preview width, which grows
+ at 5/12 of the viewport and pins at 495.5px once the window passes 1500px.
+ Measured at 1280 / 1366 / 1440 / 1512 wide, where the line is exact. Re-measure
+ it if screen 02's grid ever changes. The scale steps by viewport height
+ because CSS cannot divide one length by another to reach the unitless number
+ scale() needs, and the frame's height is derived from both so the reduced
+ component fills the frame exactly. */
+.picker-modes {
+ --pk-pv-natural: min(495.5px, calc(41.667vw - 129.5px));
+ --pk-pv-scale: 0.62;
+ --pk-mode-frame: calc(var(--pk-pv-natural) * 7 / 16 * var(--pk-pv-scale));
+ --pk-mode-pad: clamp(14px, 2svh, 24px);
+ --pk-mode-gap: clamp(16px, 2.2svh, 24px);
+ display: grid;
+ gap: clamp(16px, 2.6svh, 36px);
+ justify-items: center;
+ padding-block: clamp(20px, 3svh, 44px) clamp(14px, 2.2svh, 34px);
+}
+
+@media (min-height: 1040px) {
+ .picker-modes {
+ --pk-pv-scale: 0.68;
+ }
+}
+
+/* Short windows spend their room on the frames rather than on the chrome
+ around them: below roughly 100px tall the drawn previews start pressing
+ their bars against the frame edge, so the page padding and gutters give way
+ first. */
+@media (max-height: 760px) {
+ .picker-modes {
+ --pk-pv-scale: 0.58;
+ --pk-mode-pad: 12px;
+ --pk-mode-gap: 12px;
+ gap: 14px;
+ padding-block: 14px 10px;
+ }
+}
+
+/* A wide short window is the hard case: the reference width has already pinned
+ at its maximum, so only the scale is left to give. Narrow short windows need
+ no such step, because their reference width is smaller to begin with. */
+@media (max-height: 760px) and (min-width: 1500px) {
+ .picker-modes {
+ --pk-pv-scale: 0.5;
+ }
+}
+
+@media (max-height: 700px) and (min-width: 1500px) {
+ .picker-modes {
+ --pk-pv-scale: 0.47;
+ }
+}
+
+.picker-modes-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ /* Both rows take the taller row's height, so a goal or chip row that wraps
+ in one tile cannot leave the other three sitting shorter than it. */
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+ gap: var(--pk-mode-gap);
+ /* Two tiles wide at the frame's largest, so a tall viewport never drives a
+ frame wider than the tile holding it. Below that the frame is matted by
+ the tile rather than the tile shrinking, because a narrower tile wraps
+ the chips and goal onto extra lines and costs more height than the
+ matting saves. */
+ width: min(100%, 860px);
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.picker-modes-grid legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.picker-mode-tile {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ gap: clamp(12px, 1.9svh, 20px);
+ padding: var(--pk-mode-pad);
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ cursor: pointer;
+ transition:
+ border-color 180ms var(--ks-ease),
+ background-color 180ms var(--ks-ease);
+}
+
+.picker-mode-tile input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.picker-mode-tile:hover {
+ border-color: color-mix(in oklab, var(--ks-patina) 45%, var(--ks-rule));
+}
+
+.picker-mode-tile:has(input:checked) {
+ background: color-mix(in oklab, var(--ks-patina) 9%, var(--ks-lacquer-raised));
+ border-color: var(--ks-patina);
+}
+
+.picker-mode-tile:has(input:focus-visible) {
+ outline: 2px solid var(--ks-patina-deep);
+ outline-offset: -2px;
+ z-index: 1;
+}
+
+/* The box every tile's preview is matted into: one measure, cut from the
+ palette screen's own preview width and handed to all four modes. */
+.picker-mode-preview {
+ /* The height is the budget and the width follows it, at the same 16 / 7 the
+ palette screen's preview uses. Letting the tile set the width instead
+ stretched the desktop card to about five times its own ratio. */
+ height: var(--pk-mode-frame);
+ aspect-ratio: 16 / 7;
+ width: auto;
+ max-width: 100%;
+ justify-self: center;
+}
+
+/* Every mode shows the palette screen's preview itself, at that component's own
+ canonical size, reduced by a single transform. Nothing inside is restated
+ here: a second set of sizes tuned to this smaller box is how the two screens
+ drifted apart in the first place. The frame is sized from the same scale, so
+ the reduced component fills it exactly. */
+.picker-mode-preview--exact {
+ display: block;
+ position: relative;
+}
+
+.picker-mode-preview--exact > .picker-preview {
+ /* The demo palette every tile is drawn in. It lives here rather than inline
+ on each tile so the four cannot drift: the palette screen has not been
+ reached yet, and without it the component falls back to the picker's own
+ dark chrome.
+
+ These four are hex and outside DESIGN.md on purpose. They stand in for the
+ palette the visitor is about to pick, so they are an example rather than a
+ system color, which is the carve-out DESIGN.md's OKLCH-Only Rule already
+ names. Do not move them into DESIGN.md: that file mirrors
+ kinpaku-tokens.css, and admitting a teal here would make every stray teal
+ in the repo pass. `detect skill/scripts/picker/` reports the first three as
+ design-system-color and is expected to; --pv-neutral lands 4 channels from
+ the documented neutral-94, inside the rule's 6-channel tolerance, so it
+ never registers. CLAUDE.md's Picker anti-pattern gate holds the count. */
+ --pv-primary: #195856;
+ --pv-secondary: #5f7c7a;
+ --pv-tertiary: #3a958b;
+ --pv-neutral: #edefef;
+ --pv-n-ink: var(--pk-ink-dark);
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--pk-pv-natural);
+ transform: scale(var(--pk-pv-scale));
+ transform-origin: top left;
+}
+
+/* ── Operate variant of the preview ──────────────────────────
+ Same shell, same nav, same phone chrome as the landing tile. The body is an
+ app screen instead: a section rail, a work area holding a chart above a
+ selectable list, and a settings panel of fields and switches. Sizes come from
+ measuring the reference illustration and mapping its card box onto this one,
+ the same way the docs variant below was built. */
+.picker-preview--ops {
+ --po-ink: var(--pv-bone);
+ --po-ink-soft: color-mix(in oklab, var(--pv-bone) 72%, var(--pv-neutral));
+ --po-wash: color-mix(in oklab, var(--pv-tertiary) 12%, var(--pv-neutral));
+ --po-rule: color-mix(in oklab, var(--pv-neutral) 86%, var(--pv-n-ink));
+}
+
+.picker-preview--ops .pv-nav {
+ border-bottom: 1px solid var(--po-rule);
+}
+
+/* Both bodies are size containers so the screen inside them scales with the
+ card rather than spilling out of it. Every measure below is a canonical pixel
+ read off the reference illustration, divided by the body height it sits in:
+ 181.78 on the desktop, 133.77 inside the phone's padded box. Vertical ones
+ spend that count in `cqh`, so a taller card is a taller page. Horizontal ones
+ spend it in `--pv-x`, so the same count holds when the frame's proportions
+ move: the width of a column and the thickness of a bar are not the frame's
+ height to give away. The chrome all four variants share (nav, phone top bar,
+ tab bar) is counted on the cards themselves, above. */
+.po-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ /* Percentages, not a container unit: a container cannot measure itself, and
+ the three panels are horizontal dimensions anyway. */
+ grid-template-columns: 18.68% 51.84% minmax(0, 1fr);
+ /* One canonical measure counted against this body's width rather than its
+ height: 1 / 2.061974, this box's width over its height at the component's
+ authored 16 / 7, measured on the built picker. Every horizontal measure is
+ then a fixed fraction of the width it has to fit inside, so no column can
+ be squeezed out and no row can reach past the card no matter what the frame
+ does with its height. Each body is a different box, so each restates it.
+
+ The count is what the reference illustration was measured in, so a value
+ stays comparable to its vertical neighbours: `13.23` is the same 13.23 in
+ both axes. */
+ --pv-x: 0.484972cqw;
+}
+
+.po-side {
+ display: grid;
+ align-content: start;
+ gap: 5.25cqh;
+ padding: 6.38cqh calc(2.86 * var(--pv-x)) 0 calc(2.22 * var(--pv-x));
+ border-right: 1px solid var(--po-rule);
+}
+
+.po-item {
+ display: flex;
+ align-items: center;
+ gap: calc(3.39 * var(--pv-x));
+ padding-left: calc(2.84 * var(--pv-x));
+}
+
+.po-dot {
+ flex: none;
+ width: calc(4.07 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--po-ink);
+ border-radius: 50%;
+}
+
+.po-line {
+ width: calc(13.23 * var(--pv-x));
+ height: calc(1.53 * var(--pv-text) * var(--pv-x));
+ background: var(--po-ink);
+ border-radius: 2px;
+}
+
+/* The open section is a filled plate, not a tinted row: it is the mark that
+ tells a reader this rail belongs to an app and not to a document. */
+.po-item--on {
+ padding-block: 2.97cqh;
+ background: var(--pv-primary);
+ border-radius: 2px;
+}
+
+.po-item--on .po-dot,
+.po-item--on .po-line {
+ background: var(--pv-neutral);
+}
+
+.po-main {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ padding: 8.14cqh calc(4.68 * var(--pv-x)) 0 calc(5.23 * var(--pv-x));
+ border-right: 1px solid var(--po-rule);
+}
+
+/* The bars stand on the content box and the axis rules it, so nothing can come
+ off the baseline at any window width. The padding below the axis is the zone
+ the category ticks hang in: they are positioned against the chart rather than
+ against a bar so no element ends up with a scrolling overflow. */
+.po-chart {
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ gap: calc(7.23 * var(--pv-x));
+ height: 33.26cqh;
+ margin-inline: calc(9.52 * var(--pv-x)) calc(14.63 * var(--pv-x));
+ /* The bottom is the tick zone rather than page rhythm, so it is measured with
+ the ticks it holds. */
+ padding: 0 calc(4.29 * var(--pv-x)) calc(2.56 * var(--pv-x)) calc(4.46 * var(--pv-x));
+}
+
+.po-chart::before {
+ content: "";
+ position: absolute;
+ right: 0;
+ bottom: calc(2.56 * var(--pv-x));
+ left: 0;
+ height: 1px;
+ background: var(--po-ink-soft);
+}
+
+.po-chart i {
+ flex: none;
+ width: calc(6.92 * var(--pv-x));
+ height: var(--h);
+ background: var(--po-ink-soft);
+}
+
+.po-chart .po-peak {
+ background: var(--pv-primary);
+}
+
+/* No `left`, so the tick takes the static position it would have had inside its
+ bar and the margin walks it to that bar's centre. */
+.po-chart i::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ width: 1px;
+ height: calc(1.29 * var(--pv-x));
+ margin-left: calc(3.19 * var(--pv-x));
+ background: var(--po-ink-soft);
+}
+
+.po-divider {
+ height: 1px;
+ margin-top: 6.46cqh;
+ background: var(--po-rule);
+}
+
+.po-rows {
+ display: grid;
+ gap: 0.5cqh;
+ margin-top: 0.85cqh;
+}
+
+.po-row {
+ display: flex;
+ align-items: center;
+ gap: calc(4.9 * var(--pv-x));
+ height: 11.61cqh;
+ padding-left: calc(3.41 * var(--pv-x));
+}
+
+.po-row .po-dot {
+ width: calc(3.85 * var(--pv-x));
+}
+
+/* The column widths differ per cell, so they arrive on the element. Only the
+ count does: a container unit reached through a second custom property is
+ dropped when it arrives in an inline style, which left every cell here at
+ auto width and so at nothing, since a cell has no content to be wide for.
+ Spending the unit in the sheet keeps the drawn width and leaves the markup
+ carrying the same counts the rest of the drawing is authored in. */
+.po-cell {
+ flex: none;
+ width: calc(var(--w) * var(--pv-x));
+ height: calc(1.45 * var(--pv-text) * var(--pv-x));
+ background: var(--po-ink-soft);
+ border-radius: 2px;
+}
+
+/* The reference's two column gaps differ by less than half a canonical pixel,
+ so one step carries both. */
+.po-cell + .po-cell {
+ margin-left: calc(12.71 * var(--pv-x));
+}
+
+.po-row--on {
+ background: var(--po-wash);
+}
+
+.po-row--on .po-dot {
+ background: var(--pv-primary);
+}
+
+.po-panel {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ padding: 6.82cqh calc(6.71 * var(--pv-x)) 0 calc(7.21 * var(--pv-x));
+}
+
+.po-heading {
+ width: calc(17.38 * var(--pv-x));
+ height: calc(1.62 * var(--pv-text) * var(--pv-x));
+ background: var(--pv-primary);
+ border-radius: 2px;
+}
+
+/* The controls in this panel are drawn objects rather than page rhythm, so the
+ field, the switches and the toggles keep their measured size while the
+ margins between them are what a taller card spends its extra height on. */
+.po-field {
+ display: flex;
+ align-items: center;
+ height: calc(8.75 * var(--pv-x));
+ margin-top: 5.81cqh;
+ border: 1px solid var(--pv-primary);
+ border-radius: 2px;
+}
+
+/* A square stood on its corner, two borders lit. Half of it falls outside the
+ drawn chevron, so the margins are placing the tip rather than the box. */
+.po-chev {
+ align-self: center;
+ width: calc(2 * var(--pv-x));
+ aspect-ratio: 1;
+ margin: 0 calc(3.14 * var(--pv-x)) 0 auto;
+ border-right: 1px solid var(--po-ink);
+ border-bottom: 1px solid var(--po-ink);
+ transform: rotate(45deg);
+}
+
+.po-switch {
+ display: flex;
+ align-items: center;
+ height: calc(5.34 * var(--pv-x));
+ margin-top: 8.39cqh;
+}
+
+.po-switch + .po-switch {
+ margin-top: 6.19cqh;
+}
+
+.po-label {
+ flex: none;
+ width: calc(17.22 * var(--pv-x));
+ height: calc(1.62 * var(--pv-text) * var(--pv-x));
+ background: var(--po-ink);
+ border-radius: 2px;
+}
+
+.po-toggle {
+ position: relative;
+ flex: none;
+ width: calc(10.29 * var(--pv-x));
+ height: calc(5.34 * var(--pv-x));
+ margin-left: auto;
+ background: var(--po-ink);
+ border-radius: 999px;
+}
+
+.po-toggle--on {
+ background: var(--pv-primary);
+}
+
+.po-toggle::after {
+ content: "";
+ position: absolute;
+ top: calc(0.66 * var(--pv-x));
+ left: calc(0.66 * var(--pv-x));
+ width: calc(4.02 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pv-neutral);
+ border-radius: 50%;
+}
+
+.po-toggle--on::after {
+ right: calc(0.66 * var(--pv-x));
+ left: auto;
+}
+
+/* The phone is the same screen narrowed: one chart, the same field, two setting
+ rows. Its padded box is 133.77 tall, so every shared measure above is
+ restated here against that number. The padding itself is a percentage of the
+ phone's width, since a container cannot measure itself.
+
+ The single column is stated rather than left implicit. The chart's five bars
+ and four gaps come to the full width of this box, and an implicit `auto`
+ column takes its floor from its widest item: it opens past the card and
+ carries the field and the setting rows out through the right edge with it. */
+.po-phone-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-content: start;
+ padding: 16.19% 4.57% 14.63% 4.27%;
+ --pv-x: 1.457438cqw;
+}
+
+.po-phone-body .po-chart {
+ gap: calc(6.79 * var(--pv-x));
+ height: 36.93cqh;
+ margin-inline: calc(0.67 * var(--pv-x)) calc(2.92 * var(--pv-x));
+ padding: 0 calc(3.51 * var(--pv-x)) calc(3.36 * var(--pv-x)) calc(3.36 * var(--pv-x));
+}
+
+.po-phone-body .po-chart::before {
+ bottom: calc(3.36 * var(--pv-x));
+}
+
+.po-phone-body .po-chart i {
+ width: calc(6.21 * var(--pv-x));
+}
+
+.po-phone-body .po-chart i::after {
+ height: calc(1.94 * var(--pv-x));
+ margin-left: calc(2.73 * var(--pv-x));
+}
+
+.po-phone-body .po-field {
+ height: calc(11.81 * var(--pv-x));
+ margin-top: 10.47cqh;
+}
+
+.po-phone-body .po-chev {
+ align-self: center;
+ width: calc(2.77 * var(--pv-x));
+ margin: 0 calc(5.03 * var(--pv-x)) 0 auto;
+}
+
+.po-prow {
+ display: flex;
+ align-items: center;
+ gap: calc(4.49 * var(--pv-x));
+ height: 18.47cqh;
+ padding-left: calc(2.24 * var(--pv-x));
+}
+
+.po-field + .po-prow {
+ margin-top: 3.85cqh;
+}
+
+.po-prow + .po-prow {
+ border-top: 1px solid var(--po-rule);
+}
+
+.po-phone-body .po-dot {
+ width: calc(5.76 * var(--pv-x));
+}
+
+.po-phone-body .po-label {
+ width: calc(18.62 * var(--pv-x));
+ height: calc(2.17 * var(--pv-x));
+}
+
+.po-phone-body .po-toggle {
+ width: calc(12.64 * var(--pv-x));
+ height: calc(7.25 * var(--pv-x));
+}
+
+.po-phone-body .po-toggle::after {
+ top: calc(0.9 * var(--pv-x));
+ left: calc(0.9 * var(--pv-x));
+ width: calc(5.46 * var(--pv-x));
+}
+
+.po-phone-body .po-toggle--on::after {
+ right: calc(0.9 * var(--pv-x));
+ left: auto;
+}
+
+/* The row that opens a subpage points instead of switching: same square, the
+ other two borders lit. */
+.po-phone-body .po-chev--next {
+ align-self: center;
+ width: calc(2.99 * var(--pv-x));
+ margin: 0 calc(4.86 * var(--pv-x)) 0 auto;
+ border-top: 1px solid var(--po-ink);
+ border-bottom: 0;
+}
+
+/* ── Docs variant of the preview ─────────────────────────────
+ Same shell as the landing preview, same nav, same phone chrome, so all four
+ tiles read as one drawing system. The body is a documentation page instead:
+ a section rail beside a measured column of prose, and a called-out note at
+ the end of it. Sizes are canonical pixels, matching how the rest of the
+ component is authored, and the proportions come from measuring the reference
+ illustration and mapping its card box onto this one. */
+.picker-preview--docs {
+ --pd-ink: var(--pv-bone);
+ --pd-quote: var(--pv-tertiary);
+ --pd-strong: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ --pd-rule: color-mix(in oklab, var(--pv-neutral) 86%, var(--pv-n-ink));
+}
+
+.picker-preview--docs .pv-nav {
+ border-bottom: 1px solid var(--pd-rule);
+}
+
+/* Both bodies are size containers so the page inside them scales with the card
+ rather than spilling out of it. Every measure below is a canonical pixel
+ divided by the body height it sits in: 181.78 on the desktop, 146.5 inside
+ the phone. Vertical ones spend that count in `cqh`, so a taller card is a
+ longer article rather than a magnified one, and horizontal ones spend it in
+ `--pv-x` against the width, as the ops variant above sets out. The chrome
+ around both bodies is counted on the cards themselves. */
+.pd-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ /* A percentage, not a container unit: a container cannot measure itself, and
+ the rail is a horizontal dimension anyway. */
+ grid-template-columns: 23.75% minmax(0, 1fr);
+ --pv-x: 0.484972cqw;
+}
+
+.pd-side {
+ display: grid;
+ align-content: start;
+ gap: 8.69cqh;
+ padding: 9.24cqh 0 0 calc(7.15 * var(--pv-x));
+ border-right: 1px solid var(--pd-rule);
+}
+
+.pd-item {
+ display: flex;
+ align-items: center;
+ gap: calc(3.03 * var(--pv-x));
+}
+
+.pd-item i {
+ width: calc(2.48 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pd-ink);
+ border-radius: 50%;
+}
+
+.pd-item span {
+ width: calc(19.36 * var(--pv-x));
+ height: calc(1.27 * var(--pv-text) * var(--pv-x));
+ background: var(--pd-ink);
+ border-radius: 2px;
+}
+
+/* The open section reads in the palette rather than in ink, so the rail shows
+ where the reader is without needing a highlight plate. */
+.pd-item--on i {
+ background: var(--pv-primary);
+}
+
+.pd-item--on span {
+ background: var(--pv-tertiary);
+}
+
+/* The right padding is the measure: prose stops well short of the card edge,
+ which is what makes the column read as a document rather than as a page. */
+.pd-main {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ padding: 9.74cqh calc(50.06 * var(--pv-x)) 0 calc(16.94 * var(--pv-x));
+}
+
+.pd-heading {
+ width: var(--w, 100%);
+ height: calc(2.53 * var(--pv-text) * var(--pv-x));
+ background: var(--pd-strong);
+ border-radius: 2px;
+}
+
+.pd-para {
+ display: grid;
+ gap: 3.74cqh;
+}
+
+.pd-para--lede {
+ margin-top: 6.38cqh;
+}
+
+/* Two passage gaps, not one: the reference opens a slightly narrower gap after
+ the lede than it does between the passages that follow. */
+.pd-para + .pd-para {
+ margin-top: 9.9cqh;
+}
+
+.pd-para--lede + .pd-para {
+ margin-top: 9.46cqh;
+}
+
+.pd-para i {
+ width: var(--w, 100%);
+ height: calc(1.1 * var(--pv-text) * var(--pv-x));
+ background: var(--pd-ink);
+ border-radius: 2px;
+}
+
+.pd-note {
+ display: flex;
+ gap: calc(3.41 * var(--pv-x));
+ height: 14.03cqh;
+ margin-top: 7.37cqh;
+ padding: 3.52cqh calc(4.4 * var(--pv-x)) 0 calc(5.17 * var(--pv-x));
+ background: var(--pv-surface-2);
+ border-left: calc(1.27 * var(--pv-x)) solid var(--pv-primary);
+ border-radius: 0 2px 2px 0;
+}
+
+.pd-note-dot {
+ flex: none;
+ align-self: start;
+ width: calc(2.64 * var(--pv-x));
+ aspect-ratio: 1;
+ background: var(--pd-quote);
+ border-radius: 50%;
+}
+
+.pd-note-lines {
+ min-width: 0;
+ flex: 1;
+ display: grid;
+ gap: 3.69cqh;
+ align-content: start;
+}
+
+.pd-note-lines i {
+ width: var(--w, 100%);
+ height: calc(0.83 * var(--pv-text) * var(--pv-x));
+ background: var(--pd-quote);
+ border-radius: 2px;
+}
+
+/* The phone is the same document narrowed: breadcrumb, heading, the same two
+ passages, the same note. */
+/* The phone body's own padding is a percentage of the phone's width, since a
+ container cannot measure itself. Everything inside it is measured against the
+ padded box, in `cqh` down the page and in `--pv-x` across it. The single
+ column is stated so a child that asks for more width than the box has cannot
+ open the column and carry its siblings out through the card edge. */
+.pd-phone-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-content: start;
+ padding: 8.84% 11.42% 9.33% 10.23%;
+ --pv-x: 1.857cqw;
+}
+
+.pd-crumb {
+ width: calc(13.11 * var(--pv-x));
+ height: calc(1.77 * var(--pv-x));
+ margin-inline: auto;
+ background: var(--pd-ink);
+ border-radius: 2px;
+}
+
+.pd-phone-body .pd-heading {
+ height: calc(3.14 * var(--pv-x));
+ margin-top: 9.62cqh;
+}
+
+.pd-phone-body .pd-para {
+ gap: 4.1cqh;
+}
+
+.pd-phone-body .pd-para i {
+ height: calc(1.37 * var(--pv-x));
+}
+
+.pd-phone-body .pd-para--lede {
+ margin-top: 6.55cqh;
+ gap: 3.62cqh;
+}
+
+.pd-phone-body .pd-para + .pd-para {
+ margin-top: 10.03cqh;
+}
+
+.pd-phone-body .pd-para--lede + .pd-para {
+ margin-top: 11.4cqh;
+}
+
+.pd-phone-body .pd-note {
+ height: 14.27cqh;
+ gap: calc(3.34 * var(--pv-x));
+ margin-top: 6.76cqh;
+ padding: 4.37cqh calc(1.37 * var(--pv-x)) 0 calc(3.14 * var(--pv-x));
+ border-left-width: calc(1.23 * var(--pv-x));
+}
+
+.pd-phone-body .pd-note-dot {
+ width: calc(3.34 * var(--pv-x));
+}
+
+.pd-phone-body .pd-note-lines {
+ gap: 2.8cqh;
+}
+
+.pd-phone-body .pd-note-lines i {
+ height: calc(1.37 * var(--pv-x));
+}
+
+/* ── Gallery variant of the preview ──────────────────────────
+ Same shell, same nav, same phone chrome as the other tiles. The body is a
+ portfolio index instead: two shots set alternately against their captions,
+ and a carousel rail under them saying the set continues. Sizes come from
+ measuring the reference illustration and mapping its card box onto this one,
+ the same way the docs variant was built.
+
+ Both bodies are size containers so the page inside scales with the card
+ rather than spilling out of it. Every measure below is a canonical pixel
+ divided by the body height it sits in: 182.78 on the desktop, 148.53 inside
+ the phone's padded box. Vertical ones spend that count in `cqh`, which is
+ what makes a taller card show taller plates rather than a zoomed index, and
+ horizontal ones spend it in `--pv-x` against the width, as the ops variant
+ above sets out. The chrome around both bodies is counted on the cards
+ themselves. */
+.picker-preview--gallery {
+ --pg-ink: var(--pv-bone);
+}
+
+/* The work is the page, so the plates carry the drawn size and the shared
+ 4 / 3 gives way to it. */
+.picker-preview--gallery .pv-image {
+ aspect-ratio: auto;
+}
+
+/* The illustration marks each shot in the brand color rather than the accent,
+ which is what ties the two rows back to the nav. */
+/* The mark is a fixed inset from the plate's own corner, so both insets are
+ measured with it rather than with the page. */
+.picker-preview--gallery .pv-image::after {
+ top: calc(3.41 * var(--pv-x));
+ right: calc(3.15 * var(--pv-x));
+ width: calc(3.37 * var(--pv-x));
+ background: var(--pv-primary);
+}
+
+.pg-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ --pv-x: 0.48764cqw;
+}
+
+/* The insets differ per row because the illustration staggers the pair rather
+ than mirroring it, which is what keeps the index from reading as a grid.
+ The plate is the drawn column and the caption takes what is left, so a frame
+ that changes shape spends it on the plate's height and never on the caption's
+ width. */
+.pg-row {
+ display: grid;
+ grid-template-columns: calc(73.39 * var(--pv-x)) minmax(0, 1fr);
+ column-gap: calc(10.55 * var(--pv-x));
+ height: 37.31cqh;
+ margin-top: 2.31cqh;
+ padding-inline: calc(31.34 * var(--pv-x)) calc(32.13 * var(--pv-x));
+}
+
+.pg-row--flip {
+ grid-template-columns: minmax(0, 1fr) calc(73.39 * var(--pv-x));
+ column-gap: calc(11.18 * var(--pv-x));
+ margin-top: 7.28cqh;
+ padding-inline: calc(34.97 * var(--pv-x)) calc(33.07 * var(--pv-x));
+}
+
+.pg-row .pv-image {
+ height: 100%;
+}
+
+.pg-cap {
+ display: grid;
+ align-content: start;
+ gap: 3.1cqh;
+ padding-top: 12.39cqh;
+}
+
+.pg-cap i {
+ width: var(--w, 100%);
+ height: calc(1.5 * var(--pv-text) * var(--pv-x));
+ background: var(--pg-ink);
+ border-radius: 2px;
+}
+
+.pg-cap i.pg-cap-title {
+ height: calc(2.01 * var(--pv-text) * var(--pv-x));
+ margin-bottom: 0.93cqh;
+}
+
+/* The insets are measured to the chevrons' drawn tips, which sit outside their
+ layout boxes once the squares below are turned. The rail's own height is the
+ arrows it holds rather than a share of the page, so a taller card gives its
+ room to the plates above and leaves this strip alone. */
+.pg-rail {
+ display: flex;
+ align-items: center;
+ height: calc(3.25 * var(--pv-x));
+ margin-top: 4.95cqh;
+ padding-inline: calc(7.78 * var(--pv-x)) calc(8.24 * var(--pv-x));
+}
+
+/* A square stood on its corner: two borders draw the chevron without pulling
+ in an inline SVG nothing else in the component uses. */
+.pg-arrow {
+ flex: none;
+ width: calc(2.3 * var(--pv-x));
+ aspect-ratio: 1;
+ border-left: 1px solid var(--pg-ink);
+ border-bottom: 1px solid var(--pg-ink);
+ transform: rotate(45deg);
+}
+
+.pg-arrow--next {
+ border-right: 1px solid var(--pg-ink);
+ border-top: 1px solid var(--pg-ink);
+ border-bottom: 0;
+ border-left: 0;
+}
+
+.pg-track {
+ display: flex;
+ align-items: center;
+ gap: calc(3.7 * var(--pv-x));
+ margin-left: calc(7.26 * var(--pv-x));
+ margin-right: auto;
+}
+
+/* Segment widths arrive as counts for the reason the table cells above do. */
+.pg-track i {
+ flex: none;
+ width: calc(var(--w) * var(--pv-x));
+ height: calc(1.48 * var(--pv-x));
+ background: var(--pg-ink);
+ border-radius: 2px;
+}
+
+.pg-track i.pg-track-on {
+ background: var(--pv-primary);
+}
+
+/* The phone is the same index at one column: shot, caption, shot, caption. */
+/* Its own padding is a percentage of the phone's width, since a container
+ cannot measure itself. Everything inside is measured against the padded box,
+ in `cqh` down the page and in `--pv-x` across it, and the single column is
+ stated so no child can open it wider than the phone. */
+.pg-phone-body {
+ container-type: size;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ align-content: start;
+ padding: 5.65% 8.34% 10.49%;
+ --pv-x: 1.770721cqw;
+}
+
+.pg-phone-body .pv-image {
+ height: 33.27cqh;
+}
+
+.pg-phone-body > .pg-cap + .pv-image {
+ height: 31.95cqh;
+ margin-top: 5.59cqh;
+}
+
+/* The phone container is shorter than the desktop one, so every shared measure
+ has to be restated against it. */
+.pg-phone-body .pv-image::after {
+ top: calc(3.02 * var(--pv-x));
+ right: calc(3.26 * var(--pv-x));
+ width: calc(3.4 * var(--pv-x));
+}
+
+.pg-phone-body .pg-cap {
+ gap: 2.93cqh;
+ margin-top: 4.15cqh;
+ padding-top: 0;
+}
+
+.pg-phone-body > .pg-cap ~ .pg-cap {
+ margin-top: 3.68cqh;
+}
+
+.pg-phone-body .pg-cap i {
+ height: calc(1.52 * var(--pv-x));
+}
+
+/* At this size the caption's first line is a shorter line, not a heavier one. */
+.pg-phone-body .pg-cap i.pg-cap-title {
+ height: calc(1.52 * var(--pv-x));
+ margin-bottom: 0.28cqh;
+}
+
+/* Three page dots rather than four tabs, closer together than the tab bar's
+ even spread. */
+.picker-preview--gallery .pv-tabbar {
+ justify-content: center;
+ gap: calc(5.12149 * var(--pv-x));
+}
+
+.picker-mode-copy {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+}
+
+.picker-mode-goal {
+ color: var(--ks-text-muted);
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+/* The examples name the tile now that the mode word is gone, so they lead the
+ copy block and carry its strongest weight. Sized to keep a four-chip row
+ within two lines at the narrowest tile the grid produces. */
+.picker-mode-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.picker-mode-pills i {
+ padding: 4px 9px;
+ color: var(--ks-champagne);
+ font-size: 12.5px;
+ font-style: normal;
+ letter-spacing: 0.015em;
+ border: 1px solid color-mix(in oklab, var(--ks-champagne) 24%, var(--ks-rule));
+ border-radius: 2px;
+ transition:
+ color 180ms var(--ks-ease),
+ border-color 180ms var(--ks-ease);
+}
+
+.picker-mode-tile:hover .picker-mode-pills i {
+ border-color: color-mix(in oklab, var(--ks-patina) 30%, var(--ks-rule));
+}
+
+.picker-mode-tile:has(input:checked) .picker-mode-pills i {
+ color: color-mix(in oklab, var(--ks-patina) 85%, var(--ks-champagne));
+ border-color: color-mix(in oklab, var(--ks-patina) 55%, var(--ks-rule));
+}
+
+/* The wording here is doing careful work: every preview in the interview is
+ an isolating instrument, not a sketch of the deliverable, and this is the
+ one place the visitor is told so before meeting the rest of them. */
+.picker-modes-note {
+ max-width: 52ch;
+ color: var(--ks-text-muted);
+ font-size: 1.08rem;
+ line-height: 1.5;
+ text-align: center;
+ text-wrap: balance;
+}
+
+.picker-modes-note strong,
+.picker-mode-goal strong {
+ color: var(--ks-text);
+ font-weight: 600;
+}
+
+/* Screen 03 mirrors screen 02's rhythm: the same centered headline class,
+ the same vertical gaps, options left and preview right. */
+.picker-strategy {
+ display: grid;
+ gap: 48px;
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+.picker-strategy-grid {
+ /* Named so the option panel can cap itself against the same budget: its
+ height no longer comes from the column, so nothing else would stop it. */
+ --pk-column: calc(100svh - 346px);
+ max-height: var(--pk-column);
+ display: grid;
+ grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
+ gap: 50px;
+ align-items: stretch;
+}
+
+/* One raised panel, hairline rows — same vocabulary as the palette bands
+ on screen 02, not four floating SaaS cards. Screen 04 reuses the base
+ shell; screen 03 opts into the choices treatment below. */
+.picker-strategy-options {
+ min-width: 0;
+ display: grid;
+ gap: 0;
+ padding: 0;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.picker-strategy-options > legend,
+.picker-strategy-option input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.picker-strategy-option {
+ position: relative;
+ display: grid;
+ gap: 6px;
+ align-items: start;
+ padding: 20px 24px 20px 28px;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--ks-rule);
+ border-radius: 0;
+ cursor: pointer;
+ transition:
+ background-color 180ms var(--ks-ease),
+ color 180ms var(--ks-ease);
+}
+
+.picker-strategy-option:last-child {
+ border-bottom: 0;
+}
+
+/* ============================================================
+ The standard option row. Every list of choices in the picker wears this
+ and nothing else: a patina spine that previews under the cursor and fills
+ in once the answer is committed, a wash of the same patina behind the row
+ at two strengths, and the row's own heading taking the patina in both
+ states. It came from screen 04's type rail; screens 03 and 05 read these
+ same rules.
+
+ A new question brings its own copy scale and its own row content. It does
+ not bring its own state colors, its own spine, or its own hover. If a
+ screen appears to need a different treatment, change it here for all of
+ them rather than adding a fourth variant.
+ ============================================================ */
+.picker-strategy-option::before {
+ content: "";
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 3px;
+ background: transparent;
+ transition: background-color 180ms var(--ks-ease);
+}
+
+/* Mixes are keyed to --ks-patina, not --ks-patina-deep: the deep oxide sits at
+ 49% lightness and a single-digit mix of it into an 11% panel is invisible. */
+.picker-strategy-option:hover {
+ background: color-mix(in oklab, var(--ks-patina) 9%, var(--ks-lacquer-raised));
+}
+
+.picker-strategy-option:hover::before {
+ background: color-mix(in oklab, var(--ks-patina) 55%, transparent);
+}
+
+.picker-strategy-option:has(input:checked) {
+ background: color-mix(in oklab, var(--ks-patina) 14%, var(--ks-lacquer-raised));
+}
+
+.picker-strategy-option:has(input:checked)::before {
+ background: var(--ks-patina);
+}
+
+/* Whatever names the row is what takes the accent: a strategy label, a scale
+ name, or the face a font pair is set in. */
+.picker-strategy-option:hover :is(.picker-strategy-title, .picker-type-sample-heading),
+.picker-strategy-option:has(input:checked) :is(.picker-strategy-title, .picker-type-sample-heading) {
+ color: var(--ks-patina);
+}
+
+.picker-strategy-option:has(input:focus-visible) {
+ outline: 2px solid var(--ks-patina-deep);
+ outline-offset: -2px;
+ z-index: 1;
+}
+
+.picker-strategy-copy {
+ display: grid;
+ gap: 6px;
+}
+
+/* Scoped to the copy block the two were drawn for. Both classes are also the
+ handles the per-surface machinery reads a row's name and its description
+ through, and the font rail names itself in the pair's own faces, so away from
+ this block they are hooks and carry no design of their own. */
+.picker-strategy-copy > .picker-strategy-title {
+ color: var(--ks-champagne);
+ font-family: var(--ks-font-display);
+ font-size: 1.22rem;
+ font-weight: 400;
+ line-height: 1.15;
+ letter-spacing: -0.01em;
+ transition: color 180ms var(--ks-ease);
+}
+
+.picker-strategy-copy > .picker-strategy-desc {
+ color: var(--ks-text-muted);
+ font-size: 0.88rem;
+ line-height: 1.5;
+}
+
+/* Screen 03 uses the same rail shell as screen 04: scrollable fieldset inside
+ a height-capped column. Row rhythm, hover, and checked states come from the
+ standard option row above; only the copy scale differs, because these are
+ strategy labels and not font specimens. Four rows share the preview column's
+ height equally so a one-line description does not leave a short band beside
+ a taller neighbor. */
+.picker-strategy-grid > .picker-type-rail {
+ min-height: 0;
+ height: 100%;
+ grid-template-rows: minmax(0, 1fr);
+}
+
+/* Screen 03 carries the palette under its choices and the buttons under its
+ frame, and the two pairs sit on the same two rows: the choices beside the
+ frame, then the palette beside the buttons. Both columns subgrid onto them, so
+ the frame's bottom edge is the options panel's bottom edge by construction
+ rather than by two sizing formulas agreeing. The row gap is the rail's own
+ former 14px, which now also sets the air between the frame and the CTA.
+
+ Everything this screen shows lives inside the grid, so what the grid may
+ spend is the window less whatever the block spends around it: the padding,
+ the question, and the gap under it. --pk-chrome is that sum, measured by
+ fitStrategyColumn() rather than counted here, because the question is one or
+ two lines at a size that tracks the window's width. The literal is the value
+ before the script has measured,
+ and it is the number this rule used to carry outright; at laptop heights it
+ was short enough that the screen ran past the fold.
+
+ --pk-column, which the later question screens still read, is measured for a
+ grid with the buttons underneath it and leaves this one 90px short of what it
+ now owns. */
+.picker-screen[data-screen="03"] .picker-strategy-grid {
+ max-height: calc(100svh - var(--pk-chrome, 346px));
+}
+
+.picker-screen[data-screen="03"] .picker-strategy-grid > .picker-type-rail {
+ height: auto;
+ align-self: start;
+ grid-template-rows: auto auto auto;
+}
+
+.picker-strategy-choices.picker-type-options {
+ align-self: start;
+ max-height: var(--pk-column);
+ /* Four is the largest option count in the run, so the row is a quarter of the
+ column whether a screen has four of them or three, and one formula gives
+ every screen in the run the same band. Below 432px of column they shrink
+ together; the row padding absorbs it before the copy does. The 2px is the
+ panel's own hairline, which the column budget does not account for. */
+ grid-template-rows: none;
+ grid-auto-rows: min(108px, calc((var(--pk-column) - 2px) / 4));
+}
+
+/* Centred rather than top-aligned: whatever the row has left over after the
+ label and its sentence reads as air around them, not as a gap underneath. */
+.picker-strategy-choices.picker-type-options .picker-strategy-option {
+ align-content: center;
+}
+
+.picker-strategy-choices .picker-strategy-copy {
+ gap: 2px;
+}
+
+.picker-strategy-choices .picker-strategy-title {
+ color: var(--ks-champagne);
+ font-family: var(--ks-font);
+ font-size: 24px;
+ font-weight: 400;
+ line-height: 1.15;
+ letter-spacing: -0.015em;
+ transition: color 180ms var(--ks-ease);
+}
+
+.picker-strategy-choices .picker-strategy-desc {
+ font-size: 15px;
+ line-height: 1.4;
+ max-width: 34ch;
+}
+
+/* The wireframe landing page every option screen previews its answer on, at
+ desktop and phone sizes. Each screen remaps the slots its own question owns:
+ color on 03, glyphs on 04, then motion, grid, dividers, radius, and shadow.
+ Answers travel forward, so by the last screen the artboard is showing every
+ decision made so far rather than one at a time.
+
+ Two of the color slots name an ink rather than a fill: a filled button's
+ label has to come from the fill it sits on, and which fill that is changes
+ with the strategy. --pv-t-ink is set alongside the palette by the picker
+ script; the fallback here matches the gold that stands in before a palette
+ lands. */
+.picker-artboard {
+ --pv-primary: var(--ks-champagne);
+ --pv-secondary: var(--ks-patina);
+ --pv-tertiary: var(--ks-kinpaku);
+ --pv-neutral: var(--ks-lacquer-raised);
+ --pv-n-ink: var(--ks-champagne);
+ --pv-p-ink: var(--ks-champagne);
+ --pv-t-ink: var(--ks-dark-ink);
+ /* The accents again, moved in lightness until they can be read as words on
+ the ground named in the suffix. The script publishes them; these values
+ stand in for the run before a palette is committed, where the accents are
+ the picker's own and already legible. */
+ --pv-p-on-n: var(--pv-primary);
+ --pv-t-on-n: var(--pv-tertiary);
+ --pv-t-on-p: var(--pv-tertiary);
+ --pv-p-on-p: var(--pk-ink-dark);
+ --pv-t-on-t: var(--pk-ink-light);
+ --pv-p-on-i: var(--pk-ink-light);
+ --pv-surface-2: color-mix(in oklab, var(--pv-neutral) 92%, var(--pv-n-ink));
+ --pv-bone: color-mix(in oklab, var(--pv-neutral) 62%, var(--pv-n-ink));
+ --pv-muted: color-mix(in oklab, var(--pv-neutral) 84%, var(--pv-n-ink));
+ --pv-strong: color-mix(in oklab, var(--pv-neutral) 22%, var(--pv-n-ink));
+ --pvs-ground: var(--pv-neutral);
+ --pvs-ink: var(--pv-n-ink);
+ --pvs-surface: var(--pv-surface-2);
+ --pvs-headline: var(--pv-strong);
+ --pvs-eyebrow: var(--pv-bone);
+ --pvs-copy: var(--pv-muted);
+ --pvs-title: var(--pv-strong);
+ --pvs-cta: var(--pv-primary);
+ --pvs-cta-ink: var(--pv-p-ink);
+ --pvs-cta-text: var(--pv-p-on-p);
+ --pvs-signal: var(--pv-primary);
+ --pvs-signal-ink: var(--pv-p-ink);
+ /* Three slots the wireframe never reads: where screen 04 turns a bar into
+ glyphs, the slot that filled the bar is not the one that can set the
+ type. The mix percentages are the same idea for running copy, tightened
+ by whichever strategy leaves the least room between ground and ink. */
+ --pvs-headline-text: var(--pvs-headline);
+ --pvs-mark-text: var(--pv-p-on-n);
+ --pvs-accent-text: var(--pv-p-on-n);
+ --pvs-signal-text: var(--pv-p-on-p);
+ --pvs-copy-mix: 34%;
+ --pvs-bars-mix: 42%;
+ --pvs-ghost: color-mix(in oklab, var(--pv-neutral) 88%, var(--pv-secondary));
+ --pvs-ghost-edge: color-mix(in oklab, var(--pv-secondary) 55%, var(--pv-neutral));
+ --pvs-image: var(--pv-surface-2);
+ --pvs-image-a: color-mix(in oklab, var(--pv-secondary) 26%, var(--pv-neutral));
+ --pvs-image-b: color-mix(in oklab, var(--pv-secondary) 50%, var(--pv-neutral));
+ --pvs-image-edge: color-mix(in oklab, var(--pv-secondary) 58%, var(--pv-neutral));
+ --pvs-bars: var(--pv-muted);
+ --pvs-rule: var(--ks-rule);
+ --pvs-accent-a: var(--pv-bone);
+ --pvs-accent-b: var(--pv-bone);
+ --pvs-accent-c: var(--pv-primary);
+ --pvs-accent-d: var(--pv-bone);
+ --ps-proof-gap: 25px;
+
+ /* Geometry slots. Every default below is the value the artboard already had,
+ so screens 03 and 04 are unchanged by their existence. A later screen
+ answers its question by rewriting a handful of them.
+
+ Radius is four slots rather than one because a wireframe bar standing in
+ for a line of text must never round like a button: at pill radius it stops
+ reading as text and starts reading as a tag. So controls, surfaces, and
+ text bars move independently, and the dot stays a dot unless a screen
+ says otherwise. */
+ --pvs-radius-frame: 4px;
+ --pvs-radius-frame-phone: 5px;
+ --pvs-radius-surface: 2px;
+ --pvs-radius-control: 2px;
+ --pvs-radius-bar: 2px;
+ --pvs-radius-dot: 50%;
+
+ /* Section separation. The frame is the browser window and stays put; the
+ dividers are the page's own, and are what a boundary answer removes. */
+ --pvs-frame-w: 1px;
+ --pvs-frame-c: var(--pvs-rule);
+ --pvs-divider-w: 1px;
+ --pvs-divider-c: var(--pvs-rule);
+
+ /* Section grounds and element containers. Transparent by default, which is
+ why they cost nothing until a screen fills them. */
+ --pvs-band: transparent;
+ --pvs-band-alt: transparent;
+ --pvs-panel: transparent;
+ --pvs-panel-edge: transparent;
+ --pvs-panel-edge-w: 0px;
+ --pvs-panel-pad: 0px;
+
+ --pvs-shadow-card: none;
+ --pvs-shadow-control: none;
+ --pvs-shadow-surface: none;
+ --pvs-shadow-chrome: none;
+
+ /* Page grid. Gutters are percentages of the artboard, so they hold at both
+ sizes and at any zoom. */
+ --pvs-gutter: 5.5%;
+ --pvs-gutter-end: 3.2%;
+ --pvs-rows: 9.4% 50.6% 9% 22.7% 8.3%;
+ --pvs-nav-cols: auto 1fr auto;
+ --pvs-hero-cols: 35.8% minmax(0, 1fr);
+ --pvs-hero-gap: 11.3%;
+ --pvs-editorial-cols: 27.9% minmax(0, 1fr);
+ --pvs-editorial-gap: 6%;
+ --pvs-gallery-cols: 1.12fr 0.9fr 0.9fr 0.9fr;
+ --pvs-gallery-gap: 3%;
+ --pvs-section-align: center;
+
+ width: 100%;
+ aspect-ratio: 1.865 / 1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 5.25%;
+}
+
+/* One artboard filling the frame, for a screen whose answer only reads at
+ desktop width. It keeps the paired frame's ratio rather than taking a
+ taller one of its own: the preview then occupies the same block on every
+ screen, and the CTA underneath does not move as you page through. */
+.picker-artboard--solo {
+ aspect-ratio: 1.865 / 1;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0;
+}
+
+/* ============================================================
+ The strategy stage and the column it sits in. Screen 03 colors the surfaces
+ chosen on screen 01b, so the frame holds one drawing per chosen surface and
+ shows one at a time. The tabs sit on the frame's top right corner rather than
+ in a row of their own, and the buttons sit under it, so the column is the
+ whole answer to the question rather than a preview with the commitment parked
+ somewhere else on the page.
+
+ The frame takes the height of the row the choices set instead of a ratio of
+ its own, so it is wider than tall here and squarer on a short viewport. Three
+ of the four drawings key their horizontal measures to their own width and so
+ hold either way. Persuade is still drawn in pixels and sits loose in a frame
+ this size.
+ ============================================================ */
+.picker-strategy-stage {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.picker-strategy-stage > .picker-preview {
+ /* Every chosen surface is mounted and all but one are display: none, so the
+ row is named rather than left to auto-placement: a frame where two are
+ briefly shown puts them on top of each other instead of stacking the stage
+ into a third row and taking the column past the choices' bottom edge. */
+ grid-row: 2;
+ /* The frame runs down to the bottom of the choices beside it, so it takes the
+ row's height rather than the 16 / 7 it carries everywhere else. */
+ aspect-ratio: auto;
+ width: 100%;
+ height: 100%;
+}
+
+/* The tiles and the palette screen show the drawing at a fraction of this
+ size, and this stage is the one place a visitor reads the desktop card as a
+ page rather than as a thumbnail. At that size the tool, the document and the
+ index draw their body text as hairlines beside their own phone card, which
+ spends nearly four times the share of its card on the same count. The step is
+ on the thickness only, so the page behind the window is unchanged: same
+ measure, same rhythm, same everything the counts were measured for.
+
+ The landing page is left at its authored weight. Its counts are the largest
+ of the four, and at this size they already read as type; a headline bar taken
+ any heavier stops being display type and becomes a slab. The nav is left out
+ for the same kind of reason: it is the chrome all four cards share, and a
+ visitor moving between tabs should not watch it change weight. */
+.picker-strategy-stage > .picker-preview:is(.picker-preview--ops, .picker-preview--gallery) .pv-desktop {
+ --pv-text: 1.35;
+}
+
+/* Docs wireframe counts run lighter than ops; the shared 1.35 step still reads
+ as hairlines beside the app UI on the same screen. Match ops legibility on
+ the stage for both desktop and phone. */
+.picker-strategy-stage > .picker-preview.picker-preview--docs {
+ --pv-text: 1.65;
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-item i {
+ width: calc(4.07 * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-item span {
+ height: calc(1.53 * var(--pv-text) * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-para i {
+ height: calc(1.35 * var(--pv-text) * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-note-dot {
+ width: calc(4.07 * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-note-lines i {
+ height: calc(1 * var(--pv-text) * var(--pv-x));
+}
+
+/* Phone body lines keep the authored pv-x counts; only desktop needed the extra
+ step because strategy stage boosts --pv-text on the preview root while the
+ phone overrides below still bypass it. Restate phone lines with the same
+ pv-text mix as desktop so the two artboards stay in proportion. */
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-phone-body .pd-para i {
+ height: calc(1.37 * var(--pv-text) * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-phone-body .pd-note-lines i {
+ height: calc(0.83 * var(--pv-text) * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-phone-body .pd-heading {
+ height: calc(2.53 * var(--pv-text) * var(--pv-x));
+}
+
+.picker-strategy-stage > .picker-preview.picker-preview--docs .pd-phone-body .pd-note-dot {
+ width: calc(2.64 * var(--pv-text) * var(--pv-x));
+}
+
+/* Board stage: the read artboard draws type as bars through [data-carry]. At
+ frame size those counts read as hairlines beside strategy's wireframe, which
+ carries its own step. Scale the bar ladder to match. */
+.picker-board-stage > [data-carry][data-surface="read"] {
+ --pvs-carry-weight: 1.65;
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] .ps-docs-item > i {
+ width: calc(var(--pt-base) * 0.34 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] :is(
+ .ps-docs-item b,
+ .ps-docs-crumb,
+ .ps-docs-note-copy b
+) {
+ height: calc(var(--pt-base) * 0.26 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] :is(
+ .ps-docs-main .pt-headline i,
+ .ps-phone-body .pt-headline i
+) {
+ height: calc(var(--pt-base) * 0.72 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] .ps-docs-sub {
+ height: calc(var(--pt-base) * 0.5 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] :is(
+ .ps-docs-lede i,
+ .ps-docs-para i,
+ .ps-docs-list li
+) {
+ height: calc(var(--pt-base) * 0.3 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] .ps-docs-note-copy > span i {
+ height: calc(var(--pt-base) * 0.26 * var(--pvs-carry-weight));
+}
+
+.picker-board-stage > [data-carry][data-surface="read"] .ps-docs-list li::before {
+ width: calc(var(--pt-base) * 0.3 * var(--pvs-carry-weight));
+}
+
+/* One drawing is shown and the rest stay mounted, so switching costs an
+ attribute rather than a rebuild. [hidden] is a display: none that the
+ component's own display: grid outranks, so it is restated here. */
+.picker-preview[hidden] {
+ display: none;
+}
+
+/* The group only appears with a second surface to switch to; a single surface
+ is already the only thing the frame can be showing.
+
+ In the flow, on the stage's first row, right-aligned over the frame. The row
+ is what reserves the strip's height, so nothing downstream has to guess at it
+ and the question above keeps the gap every screen shares.
+
+ On the edge rather than inside the frame. Every drawing puts its nav across
+ the frame's top and the strip is taller than that band, so sitting inside
+ covers the one place the primary shows up as chrome. Outside it the strip
+ also keeps its own ground: raised lacquer and a hairline are read against the
+ page, not against a drawing repainted in whatever palette and strategy were
+ chosen. The 1px pull is the join, landing the strip's bottom hairline on the
+ frame's top one, and the z-index is what keeps that edge visible, since the
+ drawings raise plates of their own that would otherwise paint over it. */
+.picker-surface-tabs {
+ position: relative;
+ z-index: 1;
+ grid-row: 1;
+ justify-self: end;
+ margin-bottom: 16px;
+ display: flex;
+ gap: 3px;
+ padding: 5px;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+}
+
+.picker-surface-tabs[hidden] {
+ display: none;
+}
+
+/* One line: the dot and the surface this tab names. The picker's chrome
+ control, the same 0.8rem on the same tracking as `.picker-type-custom`, since
+ a strip docked on a frame's corner names which drawing is showing and should
+ not read as the page's loudest button. */
+.picker-surface-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 13px;
+ padding: 11px 15px;
+ color: var(--ks-text-muted);
+ background: transparent;
+ border: 0;
+ border-radius: 2px;
+ font-family: inherit;
+ font-size: 1rem;
+ letter-spacing: 0.04em;
+ line-height: 1.15;
+ white-space: nowrap;
+ cursor: pointer;
+ transition:
+ background-color 180ms var(--ks-ease),
+ color 180ms var(--ks-ease);
+}
+
+.picker-surface-tab:hover {
+ color: var(--ks-champagne);
+ background: color-mix(in oklab, var(--ks-patina) 9%, var(--ks-lacquer-raised));
+}
+
+.picker-surface-tab[aria-pressed="true"] {
+ color: var(--ks-champagne);
+ background: color-mix(in oklab, var(--ks-patina) 14%, var(--ks-lacquer-raised));
+}
+
+.picker-surface-tab:focus-visible {
+ outline: 2px solid var(--ks-patina-deep);
+ outline-offset: -2px;
+}
+
+/* The whole report on state: filled once the surface has been answered
+ deliberately, hollow while it is still carrying the default it was given. */
+.picker-surface-dot {
+ flex: none;
+ width: 10px;
+ height: 10px;
+ border: 1px solid color-mix(in oklab, var(--ks-patina) 55%, transparent);
+ border-radius: 50%;
+}
+
+/* The open tab's dot, checked the way a radio is. The plate behind the tab
+ already says which one is open, but a row of hollow dots reads as a row of
+ unanswered questions and the plate is too quiet to argue with it. Kept above
+ the fill so the answered report still outranks this on the open tab. */
+.picker-surface-tab[aria-pressed="true"] .picker-surface-dot {
+ border-color: var(--ks-patina);
+ background: radial-gradient(circle at center, var(--ks-patina) 2px, transparent 2px);
+}
+
+.picker-surface-tab[data-set="yes"] .picker-surface-dot {
+ background: var(--ks-patina);
+ border-color: var(--ks-patina);
+}
+
+/* An option the surface in the frame cannot carry. The row keeps its place so
+ the list does not reshuffle as you move between surfaces, and its sentence
+ is replaced by the reason it is out. */
+.picker-strategy-option.is-blocked {
+ opacity: 0.42;
+ /* Deaf to the cursor as well as to the click, so a row the surface cannot
+ carry cannot preview itself either. It also keeps the hover and checked
+ branches below written the way the artboard's are: no blocked row can be
+ the one being hovered, so neither branch has to ask. */
+ pointer-events: none;
+}
+
+/* The type boards under Restrained, which is the answer no strategy block
+ below rewrites. Left to the artboard's own defaults these slots sit on bone
+ and the near-neutral surface step, so the font screen drew a page the chosen
+ palette had never reached while the drawing on screen 03 kept its color under
+ the same answer. Restrained still means mostly neutral: the mixes stay on the
+ quiet end and the ground is untouched. Declared ahead of the strategy section
+ so the three answers that do rewrite these slots still win. */
+.picker-preview-type {
+ --pvs-ghost: color-mix(in oklab, var(--pv-primary) 14%, var(--pv-neutral));
+ --pvs-ghost-edge: color-mix(in oklab, var(--pv-primary) 42%, var(--pv-neutral));
+ --pvs-image: color-mix(in oklab, var(--pv-primary) 22%, var(--pv-neutral));
+ --pvs-image-a: color-mix(in oklab, var(--pv-primary) 26%, var(--pv-neutral));
+ --pvs-image-b: color-mix(in oklab, var(--pv-primary) 48%, var(--pv-neutral));
+ --pvs-image-edge: color-mix(in oklab, var(--pv-primary) 40%, var(--pv-neutral));
+ --pvs-accent-a: var(--pv-primary);
+ --pvs-accent-b: color-mix(in oklab, var(--pv-primary) 62%, var(--pv-neutral));
+ --pvs-accent-c: color-mix(in oklab, var(--pv-primary) 38%, var(--pv-neutral));
+ --pvs-accent-d: color-mix(in oklab, var(--pv-primary) 20%, var(--pv-neutral));
+ /* Primary fills label in the palette neutral; contrast-computed ink reads minty
+ on teal and fights the pair judgment this screen is for. Drenched overrides
+ below when the fill is page ink, not primary. */
+ --pvs-cta-text: var(--pv-neutral);
+}
+
+/* ============================================================
+ The strategy, applied to the surface in the frame.
+
+ The drawings read the palette through role names, and every shade they mix
+ is mixed from those same names, so a strategy is a remapping of the roles
+ rather than a second set of rules per drawing. Redistributing four roles
+ reaches all four surfaces at once, and a new surface arrives already
+ answering to it.
+
+ The committed colors are painted onto the stage under --pkc-*, which gives
+ each remap a fixed handle on what was chosen. Reading --pv-* instead would
+ read whatever the remap had just written, which is how drenched would end
+ up mixing its ground out of its own ink.
+ ============================================================ */
+.picker-strategy-stage > .picker-preview {
+ --pv-primary: var(--pkc-primary, var(--ks-champagne));
+ --pv-secondary: var(--pkc-secondary, var(--ks-patina));
+ --pv-tertiary: var(--pkc-tertiary, var(--ks-kinpaku));
+ --pv-neutral: var(--pkc-neutral, var(--ks-lacquer-raised));
+ --pv-n-ink: var(--pkc-n-ink, var(--ks-champagne));
+ --pv-p-ink: var(--pkc-p-ink, var(--ks-champagne));
+}
+
+/* Restrained: the page is its neutral and the primary is the only color that
+ arrives. The other two stand down into the ground rather than disappearing,
+ so what they were holding is still legible as structure. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="restrained"]) .picker-strategy-stage > .picker-preview,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="restrained"]:checked) .picker-strategy-stage > .picker-preview {
+ --pv-secondary: color-mix(in oklab, var(--pkc-neutral) 78%, var(--pkc-n-ink));
+ --pv-tertiary: color-mix(in oklab, var(--pkc-neutral) 86%, var(--pkc-n-ink));
+ --pv-t-ink: var(--pkc-n-ink);
+ --pv-t-on-t: var(--pkc-n-ink);
+ --pv-t-on-n: color-mix(in oklab, var(--pkc-n-ink) 62%, var(--pkc-neutral));
+ --pv-t-on-p: var(--pkc-p-ink);
+}
+
+/* Restrained stands tertiary down into the ground; on a lifted note surface that
+ is the same mix as the panel, so the quote reads through body ink instead. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="restrained"]) .picker-strategy-stage > .picker-preview.picker-preview--docs,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="restrained"]:checked) .picker-strategy-stage > .picker-preview.picker-preview--docs {
+ --pd-quote: var(--pd-ink);
+}
+
+/* Committed: one color doing all the work the other two were doing, and a
+ trace of it in the paper as well. Without the paper, a surface whose accents
+ are small (a dashboard, a doc) separates from restrained by a few tinted
+ pixels, which is not what "does real work across the page" describes. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="committed"]) .picker-strategy-stage > .picker-preview,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="committed"]:checked) .picker-strategy-stage > .picker-preview {
+ --pv-neutral: color-mix(in oklab, var(--pkc-neutral) 95%, var(--pkc-primary));
+ --pv-secondary: var(--pkc-primary);
+ --pv-tertiary: var(--pkc-primary);
+ --pv-t-ink: var(--pkc-p-ink);
+ --pv-t-on-t: var(--pkc-p-on-p);
+ --pv-t-on-n: var(--pkc-p-on-n);
+ --pv-t-on-p: var(--pkc-p-on-p);
+}
+
+/* Full palette is the drawing as it was authored: four roles, each already
+ holding a different part of the page. It needs no block of its own, which
+ is the same reason the artboard's restrained does not have one. */
+
+/* Drenched: the ground becomes the primary, and everything that was picked out
+ in the primary is now picked out against it in ink. The shades the drawings
+ mix from the neutral follow the ground without being told, which is what
+ keeps the images and panels tinted rather than left as white holes. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="drenched"]) .picker-strategy-stage > .picker-preview,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="drenched"]:checked) .picker-strategy-stage > .picker-preview {
+ --pv-neutral: var(--pkc-primary);
+ --pv-n-ink: var(--pkc-p-ink);
+ --pv-primary: var(--pkc-p-ink);
+ --pv-p-ink: var(--pkc-primary);
+ --pv-p-on-n: var(--pkc-p-ink);
+ --pv-p-on-p: var(--pkc-primary);
+ --pv-secondary: var(--pkc-tertiary);
+ --pv-tertiary: var(--pkc-tertiary);
+ --pv-t-on-n: var(--pkc-t-on-p);
+}
+
+/* Hovering an option previews its strategy before the click commits it.
+ While any strategy option is hovered, the hovered branch drives the
+ preview and the checked branch stands down; hovering Restrained needs
+ no block of its own because standing down lands on the defaults.
+
+ The last two selectors in each block carry the committed answer onto every
+ later screen's artboard, so a screen deciding motion or radius shows it on
+ the page the visitor actually chose. Only the committed answer travels: a
+ hover preview belongs to the screen whose options are under the cursor,
+ which is what the no-hover guard on the [data-artboard] selector is for.
+ Both hold themselves at one class of specificity on purpose, so the
+ artboard's own defaults are still beaten on source order while the
+ reading-contrast slots the type preview declares further down still win. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="committed"]) .picker-strategy-preview:not(.picker-preview-type),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="committed"]:checked) .picker-strategy-preview:not(.picker-preview-type),
+:where(#picker-form:has(input[name="color-strategy"][value="committed"]:checked)) .picker-preview-type,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="committed"]:checked)) [data-artboard] {
+ --pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ --pvs-headline-text: var(--pv-p-on-n);
+ --pvs-eyebrow: var(--pv-primary);
+ --pvs-ghost: color-mix(in oklab, var(--pv-primary) 14%, var(--pv-neutral));
+ --pvs-ghost-edge: var(--pv-primary);
+ --pvs-image: var(--pv-primary);
+ --pvs-image-a: var(--pv-primary);
+ --pvs-image-b: color-mix(in oklab, var(--pv-primary) 72%, var(--pv-neutral));
+ --pvs-image-edge: var(--pv-primary);
+ --pvs-accent-a: var(--pv-primary);
+ --pvs-accent-b: color-mix(in oklab, var(--pv-primary) 70%, var(--pv-neutral));
+ --pvs-accent-c: var(--pv-primary);
+ --pvs-accent-d: color-mix(in oklab, var(--pv-primary) 18%, var(--pv-neutral));
+}
+
+/* Full palette: every role on duty. Primary carries the headline, image,
+ and CTA; secondary the ghost action and gallery; tertiary the eyebrow,
+ signal, and accents; neutral stays the ground. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="full-palette"]) .picker-strategy-preview:not(.picker-preview-type),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="full-palette"]:checked) .picker-strategy-preview:not(.picker-preview-type),
+:where(#picker-form:has(input[name="color-strategy"][value="full-palette"]:checked)) .picker-preview-type,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="full-palette"]:checked)) [data-artboard] {
+ --pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
+ --pvs-headline-text: var(--pv-p-on-n);
+ --pvs-accent-text: var(--pv-t-on-n);
+ --pvs-eyebrow: var(--pv-tertiary);
+ --pvs-ghost: color-mix(in oklab, var(--pv-secondary) 18%, var(--pv-neutral));
+ --pvs-ghost-edge: var(--pv-secondary);
+ --pvs-image: var(--pv-primary);
+ --pvs-image-a: var(--pv-primary);
+ --pvs-image-b: color-mix(in oklab, var(--pv-secondary) 32%, var(--pv-primary));
+ --pvs-image-edge: var(--pv-primary);
+ --pvs-signal: var(--pv-tertiary);
+ --pvs-signal-ink: var(--pv-t-ink);
+ --pvs-signal-text: var(--pv-t-on-t);
+ --pvs-accent-a: var(--pv-secondary);
+ --pvs-accent-b: color-mix(in oklab, var(--pv-secondary) 42%, var(--pv-neutral));
+ --pvs-accent-c: var(--pv-primary);
+ --pvs-accent-d: var(--pv-tertiary);
+}
+
+/* Drenched: the primary is the page. Everything on top is either ink,
+ an ink wash of the primary, or the secondary and tertiary doing accent
+ work; nothing is left bare white and the hairlines tint with the ground. */
+#picker-form:has(.picker-strategy-option:hover input[name="color-strategy"][value="drenched"]) .picker-strategy-preview:not(.picker-preview-type),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="drenched"]:checked) .picker-strategy-preview:not(.picker-preview-type),
+:where(#picker-form:has(input[name="color-strategy"][value="drenched"]:checked)) .picker-preview-type,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="color-strategy"])):has(input[name="color-strategy"][value="drenched"]:checked)) [data-artboard] {
+ --pvs-ground: var(--pv-primary);
+ --pvs-ink: var(--pv-p-ink);
+ --pvs-surface: color-mix(in oklab, var(--pv-p-ink) 10%, var(--pv-primary));
+ --pvs-headline: var(--pv-p-ink);
+ --pvs-eyebrow: color-mix(in oklab, var(--pv-p-ink) 70%, var(--pv-primary));
+ --pvs-copy: color-mix(in oklab, var(--pv-p-ink) 55%, var(--pv-primary));
+ --pvs-title: var(--pv-p-ink);
+ --pvs-cta: var(--pv-p-ink);
+ --pvs-cta-ink: var(--pv-primary);
+ --pvs-cta-text: var(--pv-p-on-i);
+ --pvs-mark-text: var(--pv-p-ink);
+ --pvs-accent-text: var(--pv-t-on-p);
+ /* A saturated primary sits far from both ends of the range, so copy mixed
+ toward it at the paper percentages stops being readable. The drenched
+ page holds its running text closer to the ink. */
+ --pvs-copy-mix: 14%;
+ --pvs-bars-mix: 16%;
+ --pvs-ghost: color-mix(in oklab, var(--pv-p-ink) 22%, var(--pv-primary));
+ --pvs-ghost-edge: color-mix(in oklab, var(--pv-p-ink) 45%, var(--pv-primary));
+ --pvs-image: color-mix(in oklab, var(--pv-p-ink) 20%, var(--pv-primary));
+ --pvs-image-a: color-mix(in oklab, var(--pv-p-ink) 16%, var(--pv-primary));
+ --pvs-image-b: color-mix(in oklab, var(--pv-p-ink) 28%, var(--pv-primary));
+ --pvs-image-edge: color-mix(in oklab, var(--pv-p-ink) 40%, var(--pv-primary));
+ --pvs-signal: var(--pv-tertiary);
+ --pvs-signal-ink: var(--pv-t-ink);
+ --pvs-signal-text: var(--pv-t-on-t);
+ --pvs-bars: color-mix(in oklab, var(--pv-p-ink) 45%, var(--pv-primary));
+ --pvs-rule: color-mix(in oklab, var(--pv-p-ink) 28%, var(--pv-primary));
+ --pvs-accent-a: var(--pv-secondary);
+ --pvs-accent-b: var(--pv-tertiary);
+ --pvs-accent-c: color-mix(in oklab, var(--pv-p-ink) 30%, var(--pv-primary));
+ --pvs-accent-d: color-mix(in oklab, var(--pv-p-ink) 14%, var(--pv-primary));
+}
+
+.ps-desktop,
+.ps-phone {
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background-color: var(--pvs-ground);
+ border: var(--pvs-frame-w) solid var(--pvs-frame-c);
+}
+
+.ps-desktop {
+ display: grid;
+ grid-template-rows: var(--pvs-rows);
+ border-radius: var(--pvs-radius-frame);
+}
+
+.ps-phone {
+ height: 100%;
+ aspect-ratio: 8 / 19;
+ align-self: stretch;
+ display: grid;
+ grid-template-rows: 7.2% 1fr 7.5%;
+ border-radius: var(--pvs-radius-frame-phone);
+}
+
+.ps-nav {
+ display: grid;
+ grid-template-columns: var(--pvs-nav-cols);
+ gap: 24px;
+ align-items: center;
+ padding: 0 var(--pvs-gutter);
+ background-color: var(--pvs-band);
+ border-bottom: var(--pvs-divider-w) solid var(--pvs-divider-c);
+ box-shadow: var(--pvs-shadow-chrome);
+}
+
+.ps-brand-block {
+ width: clamp(8px, 1.15vw, 12px);
+ height: auto;
+ aspect-ratio: 1;
+ background-color: var(--pvs-cta);
+ border-radius: var(--pvs-radius-dot);
+}
+
+.ps-nav-bars {
+ display: flex;
+ justify-content: flex-end;
+ gap: 27px;
+}
+
+.ps-nav-bars i {
+ display: block;
+ width: clamp(18px, 7%, 34px);
+ height: 4px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-nav-action {
+ width: clamp(34px, 3.4vw, 48px);
+ height: clamp(7px, 26%, 13px);
+ background-color: var(--pvs-signal);
+ border-radius: var(--pvs-radius-control);
+ box-shadow: var(--pvs-shadow-control);
+}
+
+.ps-hero {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: var(--pvs-hero-cols);
+ gap: var(--pvs-hero-gap);
+ align-items: var(--pvs-section-align);
+ padding: 2% var(--pvs-gutter-end) 2% var(--pvs-gutter);
+}
+
+.ps-hero-copy {
+ display: grid;
+ gap: 0;
+}
+
+.ps-eyebrow {
+ width: 54%;
+ height: 10px;
+ margin-bottom: 14px;
+ background-color: var(--pvs-eyebrow);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-headline {
+ display: grid;
+ gap: 8px;
+ margin: 0 0 14px;
+}
+
+.ps-headline i {
+ display: block;
+ width: 100%;
+ height: 15px;
+ background-color: var(--pvs-headline);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-headline i:last-child {
+ width: 72%;
+}
+
+.ps-copy {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 18px;
+}
+
+.ps-copy i {
+ display: block;
+ width: 64%;
+ height: 8px;
+ background-color: var(--pvs-copy);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-copy i:last-child {
+ width: 48%;
+}
+
+.ps-actions {
+ display: flex;
+ gap: clamp(5px, 1vw, 10px);
+ margin-top: 0;
+}
+
+.ps-actions i {
+ width: clamp(42px, 42%, 86px);
+ height: clamp(12px, 2.35vw, 29px);
+ background-color: var(--pvs-cta);
+ border-radius: var(--pvs-radius-control);
+ box-shadow: var(--pvs-shadow-control);
+}
+
+.ps-actions i:last-child {
+ background-color: var(--pvs-ghost);
+ border: 1px solid var(--pvs-ghost-edge);
+}
+
+.ps-image {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ background-color: var(--pvs-image);
+ background-image: linear-gradient(135deg, var(--pvs-image-a), var(--pvs-image-b));
+ border: 1px solid var(--pvs-image-edge);
+ border-radius: var(--pvs-radius-surface);
+ box-shadow: var(--pvs-shadow-surface);
+}
+
+.ps-image::after {
+ content: "";
+ position: absolute;
+ top: 8%;
+ right: 8%;
+ width: clamp(6px, 1.1vw, 10px);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-d);
+ border-radius: var(--pvs-radius-dot);
+}
+
+.ps-proof {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: 1.05fr 1fr 1fr 1.15fr;
+ align-items: stretch;
+ column-gap: var(--ps-proof-gap);
+ padding: 2% var(--pvs-gutter-end) 2% var(--pvs-gutter);
+ background-color: var(--pvs-band-alt);
+ position: relative;
+}
+
+.ps-proof::before,
+.ps-proof::after {
+ content: "";
+ position: absolute;
+ left: var(--pvs-gutter);
+ right: var(--pvs-gutter-end);
+ height: var(--pvs-divider-w);
+ background-color: var(--pvs-divider-c);
+}
+
+.ps-proof::before {
+ top: 0;
+}
+
+.ps-proof::after {
+ bottom: 0;
+}
+
+.ps-proof-item {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 10%;
+ height: 100%;
+ padding: 0;
+}
+
+.ps-proof-item + .ps-proof-item {
+ border-left: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+.ps-proof-item i {
+ flex: none;
+ width: clamp(7px, 1.5vw, 13px);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-a);
+ border-radius: var(--pvs-radius-dot);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof {
+ grid-template-columns:
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof-item + .ps-proof-item {
+ border-left: 0;
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof-divider {
+ width: var(--pvs-divider-w);
+ align-self: stretch;
+ background-color: var(--pvs-divider-c);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-phone-body .ps-proof {
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof-item:nth-of-type(2) i {
+ background-color: var(--pvs-accent-b);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof-item:nth-of-type(3) i {
+ background-color: var(--pvs-accent-c);
+}
+
+.picker-artboard:not(.picker-preview-type) .ps-proof-item:nth-of-type(4) i {
+ background-color: var(--pvs-accent-d);
+}
+
+/* The label takes whatever the dot leaves, so a claim reads as one line of
+ the cell rather than a bar sized to a share of it. */
+.ps-proof-item span {
+ flex: 1;
+ height: 6px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-editorial {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: var(--pvs-editorial-cols);
+ gap: var(--pvs-editorial-gap);
+ align-items: var(--pvs-section-align);
+ padding: 2.2% var(--pvs-gutter);
+}
+
+.ps-editorial-copy {
+ display: grid;
+ gap: clamp(3px, 0.55vw, 6px);
+}
+
+.ps-editorial-copy strong {
+ width: 78%;
+ height: 11px;
+ background-color: var(--pvs-title);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-editorial-copy span {
+ display: grid;
+ gap: 6px;
+}
+
+.ps-editorial-copy span i {
+ display: block;
+ width: 90%;
+ height: 6px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-editorial-copy span i:last-child {
+ width: 80%;
+}
+
+.ps-editorial-copy > em {
+ width: 30%;
+ height: 6px;
+ margin-top: 3px;
+ background-color: var(--pvs-signal);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-gallery {
+ min-width: 0;
+ height: 100%;
+ display: grid;
+ grid-template-columns: var(--pvs-gallery-cols);
+ gap: var(--pvs-gallery-gap);
+}
+
+.ps-gallery-item {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 12px;
+ padding: var(--pvs-panel-pad);
+ background-color: var(--pvs-panel);
+ border: var(--pvs-panel-edge-w) solid var(--pvs-panel-edge);
+ border-radius: var(--pvs-radius-surface);
+ box-shadow: var(--pvs-shadow-card);
+}
+
+.ps-desktop .ps-editorial-copy {
+ gap: 9px;
+}
+
+.ps-desktop .ps-editorial-copy strong {
+ height: 11px;
+}
+
+.ps-desktop .ps-editorial-copy > em {
+ height: 5px;
+ margin-top: 9px;
+}
+
+.ps-gallery-item > i {
+ display: block;
+ min-height: 0;
+ background-color: var(--pvs-accent-a);
+ border-radius: var(--pvs-radius-surface);
+}
+
+.ps-gallery-item:nth-child(2) > i {
+ background-color: var(--pvs-accent-b);
+}
+
+.ps-gallery-item:nth-child(3) > i {
+ background-color: var(--pvs-accent-c);
+}
+
+.ps-gallery-item:nth-child(4) > i {
+ background-color: var(--pvs-accent-d);
+}
+
+.ps-gallery-item span {
+ display: grid;
+ gap: 6px;
+}
+
+.ps-gallery-item b {
+ display: block;
+ width: 76%;
+ height: 6px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-gallery-item b:last-child {
+ width: 58%;
+}
+
+.ps-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-inline: var(--pvs-gutter);
+ background-color: var(--pvs-band);
+ border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+.ps-footer-links {
+ display: flex;
+ gap: clamp(8px, 2vw, 18px);
+}
+
+.ps-footer-links i,
+.ps-phone-footer i {
+ width: clamp(12px, 3vw, 28px);
+ height: 4px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-footer-mark {
+ width: 7%;
+ height: 5px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+/* Phone artboard: the same site on mobile. */
+.ps-phone-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ min-height: 0;
+ padding: 0 6%;
+ background-color: var(--pvs-surface);
+ border-bottom: var(--pvs-divider-w) solid var(--pvs-divider-c);
+ box-shadow: var(--pvs-shadow-chrome);
+}
+
+.ps-phone-top .ps-brand-block {
+ width: 9px;
+}
+
+.ps-phone-top .ps-nav-action {
+ width: 16%;
+ height: 6px;
+}
+
+/* The picture is the one row here whose height is a share of the frame rather
+ than a count of what is in it, so it is the row that gives when the frame is
+ shorter than the page: the bars below it are drawn at fixed pixel heights and
+ do not shrink with the window. Capped at its share rather than set to it, so
+ a tall frame is unchanged and a short one takes the difference out of the
+ picture instead of pushing the card row through the footer. The card row asks
+ for its own height first and takes the leftover after: as a bare fraction it
+ was handed whatever the rows above had finished with, which on a short frame
+ was a third of what a card is. */
+.ps-phone-body {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 31%) auto auto auto 34px auto minmax(min-content, 1fr);
+ gap: 6px;
+ padding: 6%;
+}
+
+.ps-phone-body .ps-image {
+ margin: 0;
+ min-height: 0;
+}
+
+.ps-phone-body .ps-headline i {
+ height: clamp(5px, 0.75vw, 8px);
+}
+
+.ps-phone-body .ps-headline {
+ margin: 8px 0 0;
+ gap: 7px;
+}
+
+.ps-phone-body .ps-headline i:first-child {
+ width: 54%;
+ height: 9px;
+}
+
+.ps-phone-body .ps-headline i:last-child {
+ width: 82%;
+ height: 8px;
+}
+
+.ps-phone-body .ps-copy {
+ margin: 4px 0 0;
+ gap: 7px;
+}
+
+.ps-phone-body .ps-copy i {
+ width: 68%;
+ height: 6px;
+}
+
+.ps-phone-body .ps-copy i:last-child {
+ display: block;
+ width: 52%;
+}
+
+.ps-phone-body .ps-actions {
+ gap: 7px;
+ margin-top: 6px;
+}
+
+.ps-phone-body .ps-actions i {
+ width: 43%;
+ height: 18px;
+}
+
+.ps-phone-body .ps-proof {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: stretch;
+ margin-inline: 0;
+ margin-top: 5px;
+ padding: 2% 3.2% 2% 5.5%;
+}
+
+.ps-phone-body .ps-proof-item {
+ height: 100%;
+ padding: 0;
+}
+
+.ps-phone-body .ps-proof-item i {
+ width: 10px;
+}
+
+.ps-phone-body .ps-proof-item span {
+ height: 6px;
+}
+
+.ps-phone-body .ps-editorial-copy strong {
+ width: 38%;
+ height: 7px;
+}
+
+.ps-phone-body .ps-editorial-copy {
+ margin-top: 10px;
+}
+
+.ps-phone-body .ps-editorial-copy span i {
+ height: 6px;
+}
+
+.ps-phone-body .ps-proof::before,
+.ps-phone-body .ps-proof::after {
+ left: 0;
+ right: 0;
+}
+
+.ps-phone-body .ps-gallery {
+ grid-template-columns: 1.05fr 0.95fr;
+ gap: 10%;
+ margin-left: 1px;
+ margin-top: 6px;
+}
+
+.ps-phone-body .ps-gallery-item {
+ grid-template-rows: 36px auto;
+ gap: 6px;
+}
+
+.ps-phone-body .ps-gallery-item b {
+ height: 5px;
+}
+
+.ps-phone-body .ps-gallery-item span {
+ align-content: start;
+ gap: 6px;
+}
+
+.ps-phone-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ min-height: 0;
+ padding-inline: 6%;
+ background-color: var(--pvs-surface);
+ border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+.picker-artboard:not(.picker-preview-type) :is(
+ .ps-desktop,
+ .ps-phone,
+ .ps-nav,
+ .ps-proof,
+ .ps-proof::before,
+ .ps-proof::after,
+ .ps-proof-item,
+ .ps-footer,
+ .ps-phone-top,
+ .ps-phone-footer,
+ .ps-brand-block,
+ .ps-nav-bars i,
+ .ps-nav-action,
+ .ps-eyebrow,
+ .ps-headline i,
+ .ps-copy i,
+ .ps-actions i,
+ .ps-image,
+ .ps-image::after,
+ .ps-proof-item i,
+ .ps-proof-item span,
+ .ps-editorial-copy strong,
+ .ps-editorial-copy span i,
+ .ps-editorial-copy > em,
+ .ps-gallery,
+ .ps-gallery-item,
+ .ps-gallery-item > i,
+ .ps-gallery-item b,
+ .ps-footer-links i,
+ .ps-footer-mark,
+ .ps-phone-footer i,
+ .ps-ops-rail,
+ .ps-ops-main,
+ .ps-ops-item,
+ .ps-ops-chart,
+ .ps-ops-table,
+ .ps-ops-row,
+ .ps-ops-panel,
+ .ps-ops-plot > i,
+ .ps-docs-rail,
+ .ps-docs-note,
+ .ps-index-row,
+ .ps-index-rail
+) {
+ /* Width, margin, and padding ride along so the boundary answers morph rather
+ than snap: a hairline grows out of nothing, the proof band slides off the
+ page margins, and a card's padding opens instead of jumping. */
+ transition:
+ background-color 280ms var(--ks-ease),
+ background-image 280ms var(--ks-ease),
+ border-color 280ms var(--ks-ease),
+ border-width 280ms var(--ks-ease),
+ border-radius 280ms var(--ks-ease),
+ box-shadow 280ms var(--ks-ease),
+ margin 280ms var(--ks-ease),
+ padding 280ms var(--ks-ease);
+}
+
+.picker-preview-type :is(
+ .ps-desktop,
+ .ps-phone,
+ .ps-nav,
+ .ps-proof,
+ .ps-proof::before,
+ .ps-proof::after,
+ .ps-proof-item,
+ .ps-footer,
+ .ps-phone-top,
+ .ps-phone-footer,
+ .ps-brand-block,
+ .ps-nav-bars i,
+ .ps-nav-action,
+ .ps-eyebrow,
+ .ps-headline i,
+ .ps-copy i,
+ .ps-actions i,
+ .ps-image,
+ .ps-image::after,
+ .ps-proof-item i,
+ .ps-proof-item span,
+ .ps-editorial-copy strong,
+ .ps-editorial-copy > em,
+ .ps-gallery-item > i,
+ .ps-gallery-item b,
+ .ps-footer-links i,
+ .ps-footer-mark,
+ .ps-phone-footer i,
+ .pt-headline,
+ .pt-body,
+ .pt-section-body
+) {
+ transition:
+ background-color 280ms var(--ks-ease),
+ background-image 280ms var(--ks-ease),
+ border-color 280ms var(--ks-ease),
+ color 280ms var(--ks-ease);
+}
+
+/* Every screen past the start ends the same way: the commitment centered and
+ foiled, retreat parked underneath it and deliberately quiet. */
+.picker-actions-stack {
+ display: grid;
+ justify-items: center;
+ gap: 14px;
+}
+
+.picker-back {
+ padding: 4px 8px;
+ border: 0;
+ background: none;
+ color: var(--ks-text-muted);
+ font-family: var(--ks-font);
+ font-size: 0.86rem;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition: color 180ms var(--ks-ease);
+}
+
+.picker-back:hover {
+ color: var(--ks-champagne);
+}
+
+.picker-back:focus-visible {
+ outline: 1px solid var(--ks-kinpaku);
+ outline-offset: 3px;
+}
+
+/* Screen 04 mirrors screen 03's frame exactly: same centered headline, same
+ options-left preview-right grid, same dual artboards. Only two things are
+ its own: the option cards are large type specimens, and the wireframe's
+ headline and body are real text so the pair is judged as glyphs. */
+.picker-type {
+ display: grid;
+ gap: 48px;
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+/* Six pair cards are taller than four strategy rows, and left to themselves
+ they set the row height and push Select this pair below the fold. The row
+ is capped against the viewport instead, leaving the list to scroll inside
+ it; the subtraction covers the title above and the CTA stack below. */
+.picker-type-grid {
+ max-height: calc(100svh - 346px);
+ display: grid;
+ grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
+ gap: 50px;
+ align-items: stretch;
+}
+
+/* The frame's own box on the font screen. Every chosen surface gets a board and
+ they are all mounted, so the row is named rather than left to auto-placement:
+ four boards would otherwise stack into four rows and take the column past the
+ rail beside it. The tab strip takes the row above them, and the stage is also
+ the node the chosen pair's faces are written to, which is how one write sets
+ all four boards. */
+.picker-type-stage {
+ /* The chosen pair is the run's answer rather than one board's, so the two
+ faces are declared here and written here, and every board is set in them
+ by inheriting. Declared on a board instead, these would outrank the write
+ and the boards would all draw in the picker's own faces. */
+ --pt-heading: var(--ks-font-display);
+ --pt-body: var(--ks-font);
+ --pt-heading-weight: 600;
+
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.picker-type-stage > .picker-artboard {
+ grid-row: 2;
+ /* Both sides are named so the declared ratio has nothing left to decide. It
+ still does the asking: with no height of its own the stage measures the
+ ratio, which is what the row across from the rail is offered, and the taller
+ of the two wins. Leave the height out and the ratio answers instead of the
+ row, and the board comes up short of the rail beside it. */
+ width: 100%;
+ height: 100%;
+}
+
+/* One board is shown and the rest stay mounted, so switching costs an attribute
+ rather than a rebuild. [hidden] is a display: none that the component's own
+ display: grid outranks, so it is restated here. */
+.picker-artboard[hidden] {
+ display: none;
+}
+
+/* The same box for a question screen that draws a board per surface, which is
+ screen 06 today. The font stage above carries the pair's two faces as well,
+ and that is the whole of the difference between them. */
+.picker-board-stage {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.picker-board-stage > .picker-artboard {
+ grid-row: 2;
+ width: 100%;
+ height: 100%;
+}
+
+/* The rail keeps the row height owned by the preview: the fieldset scrolls
+ inside it instead of stretching the page when six tall cards stack up. The
+ control row underneath is what tells you the list runs past its frame. On
+ wide screens the rail stops growing at 570px so a tall preview does not
+ turn six font cards into a page-length column. */
+.picker-type-rail {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 14px;
+}
+
+@media (min-width: 1200px) {
+ .picker-type-rail {
+ max-height: 570px;
+ }
+}
+
+.picker-type-options {
+ min-height: 0;
+ overflow-y: auto;
+ align-content: start;
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--ks-patina-deep) 35%, transparent) transparent;
+}
+
+/* The rail keeps the chosen pair in the first row, and it makes that move only
+ once the pointer and focus have both left the list, so no row is ever pulled
+ out from under a cursor and arrow keys can cross the group without it
+ re-sorting between presses. Waiting costs the user the sight of the travel,
+ so the row that has taken the top says so once: its wash and its spine come
+ up bright and settle to the resting checked state. Both keyframes animate
+ color and nothing else, so the acknowledgement cannot move the list it is
+ drawing the eye to. */
+@keyframes picker-pair-hoisted {
+ from {
+ background-color: color-mix(in oklab, var(--ks-patina) 32%, var(--ks-lacquer-raised));
+ }
+}
+
+@keyframes picker-pair-hoisted-spine {
+ from {
+ background-color: var(--ks-patina-pale);
+ }
+}
+
+.picker-type-option[data-hoisted] {
+ animation: picker-pair-hoisted 640ms var(--ks-ease);
+}
+
+.picker-type-option[data-hoisted]::before {
+ animation: picker-pair-hoisted-spine 640ms var(--ks-ease);
+}
+
+.picker-type-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+/* Screen 05 keeps screen 04's frame and swaps the artboards for a specimen
+ sheet. It is the one question that needs explaining before it can be
+ answered, so the title carries a lede; every other screen asks something the
+ options themselves make obvious. */
+.picker-scale {
+ display: grid;
+ gap: clamp(24px, 4.4svh, 40px);
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+.picker-question-head {
+ display: grid;
+ justify-items: center;
+ gap: clamp(12px, 2svh, 18px);
+}
+
+/* Screen 05 and the configure hub carry a lede, and one sentence at
+ caption size under a display headline reads as a footnote. Set above the
+ body size instead, with the measure pulled in so the sentence breaks into
+ two balanced lines rather than one long ruler. */
+.picker-question-lede {
+ max-width: 46ch;
+ color: var(--ks-text);
+ font-size: 1.18rem;
+ line-height: 1.5;
+ text-align: center;
+ text-wrap: balance;
+}
+
+/* Eight intervals and a lede leave less room than screen 04 has, so the sheet
+ is capped harder. The subtraction covers the title, the lede, and the CTA
+ stack, which is what keeps Select this scale above the fold.
+
+ Three columns share 1388px at the container's cap, and the two previews are
+ what pay for the third. The rail goes first: its widest row is the ratio and
+ Augmented Fourth on one line, which needs 300px of column and not the 420 it
+ was taking, and its description was already wrapping. The sheet gives the
+ rest. It can afford to because its sample string shortened to the scale's
+ name in the same pass, and a name is under half the width of the sentence
+ that was there, so the sheet renders its steps larger in 538px than it did in
+ 918. The reading column is last and smallest: 45 characters of lorem at the
+ base step is a measure, and more than that is width the other two need. */
+.picker-scale-grid {
+ max-height: calc(100svh - 420px);
+ display: grid;
+ grid-template-columns: minmax(300px, 352px) minmax(0, 1.25fr) minmax(0, 1fr);
+ gap: clamp(22px, 2.2vw, 34px);
+ align-items: stretch;
+}
+
+/* Eight rows scroll rather than share the height four strategy rows can split,
+ so this list takes screen 04's rail and screen 03's copy scale, not screen
+ 03's fixed four-row grid. States come from the standard option row; the only
+ thing this list adds is the ratio sitting in front of the name. */
+.picker-scale-options .picker-strategy-copy {
+ gap: 4px;
+}
+
+.picker-scale-options .picker-strategy-title {
+ display: flex;
+ align-items: baseline;
+ font-family: var(--ks-font);
+ font-size: 20px;
+ letter-spacing: -0.015em;
+}
+
+.picker-scale-options .picker-strategy-desc {
+ max-width: 38ch;
+ font-size: 14px;
+ line-height: 1.45;
+}
+
+/* The ratio leads the name: it is the thing being chosen, and eight of them
+ in a column read as a ladder when the numbers line up. */
+.picker-scale-ratio {
+ margin-inline-end: 12px;
+ color: var(--ks-text-faint);
+ font-family: var(--ks-mono);
+ font-size: 0.94rem;
+ font-weight: 400;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0;
+ transition: color 180ms var(--ks-ease);
+}
+
+/* The ratio answers to the same two states as the name it sits in front of,
+ held one step back so the name still leads the row. */
+.picker-strategy-option:hover .picker-scale-ratio,
+.picker-strategy-option:has(input:checked) .picker-scale-ratio {
+ color: color-mix(in oklab, var(--ks-patina) 70%, var(--ks-champagne));
+}
+
+.picker-scale-note {
+ color: var(--ks-text-faint);
+ font-size: 0.76rem;
+ letter-spacing: 0.04em;
+}
+
+/* The sheet is picker chrome, not an artboard: it answers a question about
+ size, and every other screen already showed the palette. So it belongs to
+ the picker's own dark theme and reads the --ks tokens directly, matching the
+ raised panel the options sit in on the left. The one thing it borrows from
+ the run is the pair chosen on screen 04, because a scale is judged in the
+ faces that will set it.
+
+ Rows are not equal: the top three are the ones being judged, so they take a
+ larger share of the sheet and leave the small steps as the tail they are. */
+.picker-scale-sheet {
+ --ts-fit: 1;
+ --pt-heading: var(--ks-font-display);
+ --pt-body: var(--ks-font);
+ --pt-heading-weight: 400;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: 1.34fr 1.2fr 1.1fr repeat(4, minmax(0, 1fr));
+ padding: clamp(14px, 2.4%, 28px) clamp(18px, 3.2%, 36px);
+ background: var(--ks-lacquer-raised);
+ color: var(--ks-champagne);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+/* Fixed side columns, because each row is its own grid: sized to their content
+ they would land wherever that row's specimen and numbers happened to end,
+ and the tag column would step left and right down the sheet and again on
+ every font change. Rem holds them still; the sheet's own face does not.
+
+ The gutters answer to the row rather than the viewport, because the row is
+ one of three columns now and a viewport-scaled gutter was spending 48px of a
+ 538px sheet on air the numbers did not ask for. */
+.ts-row {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 2.4rem 4.4rem;
+ align-items: center;
+ gap: clamp(12px, 3%, 24px);
+ border-bottom: 1px dotted var(--ks-rule);
+}
+
+.ts-row:last-child {
+ border-bottom: 0;
+}
+
+.ts-sample {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--ks-text-muted);
+ font-family: var(--pt-heading);
+ font-weight: var(--pt-heading-weight);
+ font-size: calc(var(--ts-size) * var(--ts-fit) * 1px);
+ line-height: 1.12;
+ white-space: nowrap;
+}
+
+/* The three steps a display scale is actually chosen on. They carry the
+ brightest ink in the sheet; everything below them is there to show the
+ ratio continuing. */
+.ts-row[data-scale-row="6"] .ts-sample,
+.ts-row[data-scale-row="5"] .ts-sample,
+.ts-row[data-scale-row="4"] .ts-sample {
+ color: var(--ks-champagne);
+}
+
+.ts-row[data-scale-row="0"] .ts-sample {
+ color: var(--ks-text-faint);
+ font-family: var(--pt-body);
+ font-weight: 400;
+}
+
+/* The tag and the numbers hold one size while everything to their left moves,
+ which is what makes the movement legible. Both are trimmed to their glyphs
+ so they center against the specimen rather than against a line box whose
+ height belongs to the font's metrics.
+
+ The tag is not chrome. It is the name of the step being judged, so it takes
+ the sheet's brightest ink and enough weight to be read across the row
+ without leaning in. The numbers under it are reference, and sit one step
+ back, but a size that needs a squint is not reference either. */
+.ts-tag {
+ color: var(--ks-champagne);
+ font-family: var(--ks-font);
+ font-size: 1.02rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.04em;
+ text-box: trim-both cap alphabetic;
+}
+
+.ts-values {
+ display: grid;
+ justify-items: end;
+ gap: 5px;
+ font-family: var(--ks-mono);
+ font-size: 0.8rem;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.25;
+}
+
+.ts-values b,
+.ts-values i {
+ text-box: trim-both cap alphabetic;
+}
+
+.ts-values b {
+ color: var(--ks-text-muted);
+ font-weight: 500;
+}
+
+.ts-values i {
+ color: var(--ks-text-faint);
+ font-style: normal;
+}
+
+/* The sheet answers what each step measures. This column answers what the steps
+ do to each other once a page is built out of them, which is a different
+ question and the one a scale is actually chosen on. Same panel, same ink, same
+ sizes: it reads the step values syncTypeScale computed for the sheet, so one
+ scale cannot render two ways on one screen.
+
+ What it deliberately has none of is color, and no chrome past the panel it
+ sits in. A palette here would ask screen 03's question again inside screen
+ 05's, and a specimen that has to be read past its own decoration is not
+ showing a type scale. The annotation is held to the same rule: thirteen
+ accented marks on a colorless field were the brightest thing on the screen,
+ which put the loudest ink on the layer that is only there to be looked up.
+ Mono, faint, and out in the margin says annotation three times over. */
+.picker-scale-specimen {
+ --pt-heading: var(--ks-font-display);
+ --pt-body: var(--ks-font);
+ --pt-heading-weight: 400;
+ /* One rhythm unit for the column, taken from the step the running text sets
+ at, so the air between components is measured in the scale being judged
+ rather than against it. */
+ --sp-rhythm: calc(var(--ts-step-0) * 1px);
+ /* The margin the annotation hangs in. Reserved as padding so a label can
+ never be measured against the line it names. Sized to the longest label
+ the column sets, five mono characters, plus enough slack that a fallback
+ mono face with wider glyphs still clears the panel edge. */
+ --sp-rail: 2.6rem;
+ --sp-rail-gap: 0.85rem;
+ --sp-pad: clamp(18px, 3.2%, 36px);
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ /* Two intervals, not one. A page is read in sections, and a column set at a
+ single interval has none: the heading, its paragraph, and its list arrive
+ as three unrelated blocks, which is the one thing a specimen of a scale
+ must not say. Blocks inside a section sit close; the break before a
+ heading is the wide one. Both are counted in the base step, so the cadence
+ holds while every size in the column moves. */
+ gap: calc(var(--sp-rhythm) * 1.5);
+ padding-block: calc(clamp(14px, 2.4%, 28px) + 10px) clamp(14px, 2.4%, 28px);
+ padding-inline: calc(var(--sp-rail) + var(--sp-rail-gap) + 8px) 16px;
+ background: var(--ks-lacquer-raised);
+ color: var(--ks-text);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ overflow-y: auto;
+ /* Reaching the end of the specimen should not then carry the page with it. */
+ overscroll-behavior: contain;
+ /* Screen 04's list says it scrolls with a thin patina track, and the same
+ track says it here. What that list adds on top, arrows that page by a row,
+ has nothing to page through in a column of prose. */
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--ks-patina-deep) 35%, transparent) transparent;
+}
+
+/* Both preview columns take the rail's shape, a panel with a line under it, so
+ the three panels end on one edge. What goes in that line is what the column
+ owes the reader: the specimen hides more than either list on this screen and
+ an overlay scrollbar says so only once the reader has already guessed, so it
+ gets the rail's arrows; the sheet hides nothing and gets the sentence that
+ explains why its numbers and its rendered sizes disagree, which is also the
+ one place that sentence has ever been next to the numbers. */
+.picker-scale-column {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 14px;
+}
+
+/* A column of prose is the one item in this row whose content is taller than
+ any viewport, so left alone it takes the whole cap and the rail, which stops
+ at 570, ends 90px short of it. Taking the rail's ceiling keeps the columns
+ landing on one line and keeps the row the height the CTA above the fold was
+ calibrated against. */
+@media (min-width: 1200px) {
+ .picker-scale-column {
+ max-height: 570px;
+ }
+}
+
+/* One sentence is shorter than the 44px buttons the other two lines are made
+ of, and a line that measures less takes height back from its panel, which
+ loses the shared edge the shape exists for. */
+.picker-scale-column .picker-type-controls {
+ min-height: 44px;
+}
+
+/* A grid item's automatic minimum is its min-content width, so one word of the
+ display heading at the widest ratio can hold this column open past the panel
+ and hand it a horizontal scrollbar. Zeroing that floor and letting the word
+ break keeps every overflow on the axis the column already scrolls. */
+.picker-scale-specimen > * {
+ min-width: 0;
+ overflow-wrap: break-word;
+}
+
+/* Headings take the display face and running text the body face, which is the
+ division screen 04 just committed to. */
+.picker-scale-specimen :is(.sp-h1, .sp-h2, .sp-h3, .sp-h4) {
+ margin: 0;
+ color: var(--ks-champagne);
+ font-family: var(--pt-heading);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.14;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+
+.sp-h1 {
+ font-size: calc(var(--ts-step-6) * 1px);
+}
+
+.sp-h2 {
+ font-size: calc(var(--ts-step-5) * 1px);
+}
+
+.sp-h3 {
+ font-size: calc(var(--ts-step-4) * 1px);
+}
+
+.sp-h4 {
+ font-size: calc(var(--ts-step-3) * 1px);
+}
+
+/* A heading belongs to what follows it, so the break in the column goes above
+ it and not below. The column gap alone reads as a list of unrelated blocks. */
+.picker-scale-specimen :is(.sp-h2, .sp-h3, .sp-h4) {
+ margin-block-start: calc(var(--sp-rhythm) * 2.4);
+}
+
+/* Which component each block is, said in the annotation layer rather than in
+ the specimen: the mono face where the specimen is proportional, the same
+ faint ink the sheet's reference numbers take, and the smallest step the kit
+ labels anything at.
+
+ It hangs in the reserved margin instead of entering the block. A float or a
+ right inset both shorten the line box they share, and this column exists to
+ show how a ratio wraps: at the widest ratios a floated label was breaking
+ the display heading onto a third line and leaving one character alone on the
+ first, so the annotation was deciding the texture it was there to describe.
+ Out here it decides nothing, and thirteen marks that were ragged against
+ thirteen different line endings share one edge. */
+.picker-scale-specimen [data-sp-label] {
+ position: relative;
+}
+
+.picker-scale-specimen [data-sp-label]::before {
+ content: attr(data-sp-label);
+ position: absolute;
+ top: 0;
+ padding-top: 6px;
+ inset-inline-end: calc(100% + var(--sp-rail-gap));
+ color: var(--ks-text-faint);
+ font-family: var(--ks-mono);
+ font-size: var(--ks-type-eyebrow-size);
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ line-height: 1.5;
+ text-transform: uppercase;
+ white-space: nowrap;
+ /* Trimmed so the label's cap, not its leading, is what lands on the block's
+ top edge. The blocks above and below it are set at whatever the ratio
+ dealt, so any alignment measured from a line box moves with the scale. */
+ text-box: trim-both cap alphabetic;
+}
+
+.picker-scale-specimen :is(.sp-lede, .sp-p, .sp-list, .sp-quote) {
+ margin: 0;
+ font-family: var(--pt-body);
+ font-weight: 400;
+}
+
+.sp-lede {
+ color: var(--ks-champagne);
+ font-size: calc(var(--ts-step-1) * 1px);
+ line-height: 1.5;
+}
+
+.sp-p {
+ font-size: calc(var(--ts-step-0) * 1px);
+ line-height: 1.62;
+}
+
+.picker-scale-specimen strong {
+ color: var(--ks-champagne);
+ font-weight: 600;
+}
+
+/* Clear of the descenders the body face will have, whichever face it is. */
+.picker-scale-specimen u {
+ text-decoration-thickness: from-font;
+ text-underline-offset: 0.16em;
+}
+
+.sp-list {
+ padding-inline-start: calc(var(--sp-rhythm) * 1.5);
+ font-size: calc(var(--ts-step-0) * 1px);
+ line-height: 1.58;
+}
+
+.sp-list li + li {
+ margin-block-start: calc(var(--sp-rhythm) * 0.5);
+}
+
+.sp-list-ordered {
+ font-variant-numeric: tabular-nums;
+}
+
+.sp-list::marker,
+.sp-list li::marker {
+ color: var(--ks-text-faint);
+}
+
+/* The rule is the quote's only mark. It is a typographic convention rather
+ than picker chrome, which is why it survives the no-decoration rule that
+ removed everything else from this column. */
+.sp-quote {
+ padding-inline-start: calc(var(--sp-rhythm) * 1.2);
+ border-inline-start: 1px solid var(--ks-rule);
+}
+
+.sp-quote p {
+ margin: 0;
+ color: var(--ks-champagne);
+ font-size: calc(var(--ts-step-2) * 1px);
+ line-height: 1.42;
+}
+
+.sp-cite {
+ display: block;
+ margin-block-start: calc(var(--sp-rhythm) * 0.7);
+ color: var(--ks-text-muted);
+ font-size: calc(var(--ts-step-0) * 1px);
+ font-style: normal;
+ letter-spacing: 0.02em;
+}
+
+/* The mono face is not part of the pair screen 04 deals, so code takes the
+ kit's own stack. Guessing a third face here would put a claim in the
+ specimen that the run never made. */
+.sp-code,
+.sp-pre {
+ /* Mono runs large against a proportional face set at the same size. */
+ font-size: calc(var(--ts-step-0) * 0.94px);
+ font-family: var(--ks-mono);
+}
+
+.sp-code {
+ color: var(--ks-champagne);
+}
+
+/* The block wraps instead of scrolling sideways, and not for taste: an
+ overflowing box is a scroll container, a scroll container's automatic minimum
+ height is zero, and this column always has more content than height. The grid
+ spends that overflow on the one track it is allowed to shrink, so a code block
+ with overflow-x lands two pixels tall and the snippet disappears. */
+.sp-pre {
+ margin: 0;
+ padding: 14px 16px;
+ color: var(--ks-text-muted);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+
+/* ============================================================
+ Screen 11: the icon set. Screen 05's frame again, because the question has
+ the same shape: more candidates than fit the rail, and one specimen that
+ redraws as the cursor moves down the list.
+ ============================================================ */
+.picker-icons {
+ display: grid;
+ gap: clamp(24px, 4.4svh, 40px);
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+.picker-icons-grid {
+ max-height: calc(100svh - 420px);
+ display: grid;
+ grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
+ gap: 50px;
+ align-items: stretch;
+}
+
+/* Eleven rows scroll, so this list takes screen 04's rail rather than screen
+ 03's fixed row split. States come from the standard option row; what the
+ row adds is the pack's own numbers and the way out to its site. */
+.picker-icon-options .picker-strategy-option {
+ padding-block: 16px;
+ /* The link sits in this gutter. */
+ padding-inline-end: 60px;
+}
+
+.picker-icon-options .picker-strategy-copy {
+ gap: 5px;
+}
+
+.picker-icon-options .picker-strategy-title {
+ font-family: var(--ks-font);
+ font-size: 21px;
+ letter-spacing: -0.015em;
+}
+
+.picker-icon-options .picker-strategy-desc {
+ max-width: 40ch;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+/* Grid, weight, license: the three facts that decide this question before any
+ description does. Measurements, so they are set in the mono face. */
+.picker-icon-meta {
+ color: var(--ks-text-faint);
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+}
+
+/* An anchor inside a