diff --git a/picker/pages/index.astro b/picker/pages/index.astro index 09fe25b99..b979021aa 100644 --- a/picker/pages/index.astro +++ b/picker/pages/index.astro @@ -292,7 +292,7 @@ const questions = [ Surfaces + {/* Every chosen surface is colored separately and leaves its own + answer, so each has a field to leave it in. They stay disabled + until their tile is chosen, which is what keeps a surface + nobody asked for out of the answers. */} + {surfaces.map(({ value }) => ( + + ))} +
{roles.map(([role, label]) => (
@@ -787,7 +795,13 @@ const questions = [

- + {/* The stage the chosen surfaces are drawn on. Its contents are + lifted from the tiles on screen 01b, so it is filled by script; + the ratio is the artboard's so the CTA sits where it sits on + every other screen. */} +
+ +
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js index afef5053f..637d23afc 100644 --- a/picker/scripts/palette-picker.js +++ b/picker/scripts/palette-picker.js @@ -185,26 +185,30 @@ function renderPreview() { preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral)); } -function syncCommittedPalette(target) { +/* The prefix exists for the strategy stage, which needs the committed colors + under names its own CSS never rewrites: the remap there reads these to know + what was chosen, and reading the live --pv-* would read its own output. */ +function syncCommittedPalette(target, prefix = 'pv') { const committed = roleMap((role) => $(`[name="palette-${role}"]`).value); if (!target || Object.values(committed).some((hex) => !hex)) return; - for (const role of ROLES) target.style.setProperty(`--pv-${role}`, committed[role]); + const set = (name, value) => target.style.setProperty(`--${prefix}-${name}`, value); + for (const role of ROLES) set(role, committed[role]); // One ink per fill a preview can paint a label on: the strategy decides // which of the three carries the button on any given artboard. - target.style.setProperty('--pv-n-ink', contrastInk(committed.neutral)); - target.style.setProperty('--pv-p-ink', contrastInk(committed.primary)); - target.style.setProperty('--pv-t-ink', contrastInk(committed.tertiary)); + set('n-ink', contrastInk(committed.neutral)); + set('p-ink', contrastInk(committed.primary)); + set('t-ink', contrastInk(committed.tertiary)); // And one reading version of each accent that the type artboards set words // in, per ground it can land on: the neutral page, or the primary once the // strategy drenches the page in it. - target.style.setProperty('--pv-p-on-n', readableOn(committed.primary, committed.neutral)); - target.style.setProperty('--pv-t-on-n', readableOn(committed.tertiary, committed.neutral)); - target.style.setProperty('--pv-t-on-p', readableOn(committed.tertiary, committed.primary)); + set('p-on-n', readableOn(committed.primary, committed.neutral)); + set('t-on-n', readableOn(committed.tertiary, committed.neutral)); + set('t-on-p', readableOn(committed.tertiary, committed.primary)); // Labels on filled buttons: hue is the role that paints the fill, ground is - // that same fill — not the page neutral that produced the 1.63:1 regression. - target.style.setProperty('--pv-p-on-p', readableOn(committed.primary, committed.primary)); - target.style.setProperty('--pv-t-on-t', readableOn(committed.tertiary, committed.tertiary)); - target.style.setProperty('--pv-p-on-i', readableOn(committed.primary, contrastInkHex(committed.primary))); + // that same fill, not the page neutral that produced the 1.63:1 regression. + set('p-on-p', readableOn(committed.primary, committed.primary)); + set('t-on-t', readableOn(committed.tertiary, committed.tertiary)); + set('p-on-i', readableOn(committed.primary, contrastInkHex(committed.primary))); } /* Every field is checked only when it is present. The manifest merges over @@ -1311,6 +1315,7 @@ function recommitPalette() { if (!$('[name="palette-source"]').value) return; for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role]; paintStrategyBands(); + paintStage(); for (const artboard of document.querySelectorAll('.picker-screen[data-active] [data-artboard]')) { syncCommittedPalette(artboard); } @@ -1536,6 +1541,7 @@ panel.onclick = async (e) => { // is captured for the transition as it is handed over, and a strip still // holding last run's colors is what would be captured. paintStrategyBands(); + paintStage(); } }; @@ -1570,7 +1576,16 @@ document.addEventListener('picker:screenchange', (event) => { } // Coming back to the strategy screen from further along, where the palette may // have been reordered on the screen it was left on. - if (event.detail.screen === '03') paintStrategyBands(); + if (event.detail.screen === '03') { + paintStrategyBands(); + paintStage(); + } else { + /* Everywhere else previews the leading surface's answer, so the radio the + later screens read is parked there whenever 03 is off screen. Without + this the run would carry whichever surface was last on the tab, and a + strategy switched off for that surface would leave the answer empty. */ + showSurface(chosenSurfaces()[0]?.value); + } // Arriving is the quietest moment there is, so the rail settles here even // if it is already in order: the chosen pair is the row you land on. if (event.detail.screen === '04') { @@ -1630,6 +1645,9 @@ const landingPreview = preview.cloneNode(true); let previewSource; function syncModePreview() { + // Screen 03 draws every chosen surface, not just the leading one, so it is + // rebuilt from here: every path that changes the tiles already runs this. + syncSurfaces(); const chosen = modeInputs.findIndex((input) => input.checked); // A tile drawn in something other than this component keeps the landing page, // which is also the floor for the empty answer the continue button blocks. @@ -1646,6 +1664,163 @@ function syncModePreview() { renderPreview(); } +/* Screen 03 colors the surfaces that were chosen rather than one fixed page, + and it colors each of them separately: the answer that suits the marketing + page rarely suits the tool it sells. Every chosen tile's drawing is mounted + on the stage, one is shown, and a tab in the frame's corner carries between + them when there is more than one to carry between. */ +const stage = document.querySelector('[data-strategy-stage]'); +const surfaceTabs = document.querySelector('[data-surface-tabs]'); +const strategyInputs = [...document.querySelectorAll('input[name="color-strategy"]')]; +const strategyRows = new Map(strategyInputs.map((input) => [input.value, input.closest('.picker-strategy-option')])); + +/* Why a strategy is out belongs to the strategy, not to the pairing, so it is + written once here rather than once per surface that rules it out. */ +const BLOCKED_BECAUSE = { + drenched: 'Too loud for a page people work in or read at length.', + 'full-palette': 'Four colors on duty compete with the work on show.', +}; + +const chosenSurfaces = () => modeInputs.filter((input) => input.checked); +const surfaceInput = (value) => modeInputs.find((input) => input.value === value); +const allowedFor = (value) => (surfaceInput(value)?.dataset.strategies ?? '').split(' ').filter(Boolean); +const defaultFor = (value) => surfaceInput(value)?.dataset.strategyDefault ?? 'restrained'; +const strategyField = (value) => document.querySelector(`[data-surface-strategy="${value}"]`); +const strategyTitle = (value) => strategyRows.get(value)?.querySelector('.picker-strategy-title').textContent ?? value; +let activeSurface = null; + +/* One drawing per chosen surface, painted with the committed palette. 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 syncSurfaces() { + if (!stage) return; + const chosen = chosenSurfaces(); + for (const node of stage.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; + stage.append(clone); + } + paintStage(); + + /* 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 = strategyField(input.value); + if (!field) continue; + field.disabled = !input.checked; + if (!input.checked) { + field.value = ''; + delete field.dataset.chosen; + } else if (!field.value) { + field.value = defaultFor(input.value); + } + } + + buildTabs(chosen); + showSurface(chosen.some((input) => input.value === activeSurface) ? activeSurface : chosen[0]?.value); +} + +/* 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 paintStage() { + syncCommittedPalette(stage, 'pkc'); +} + +/* One surface needs no tabs: the frame is already showing the only answer + there is. The dot is the whole report on state, filled once the surface has + been answered deliberately and hollow while it is still holding a default. */ +function buildTabs(chosen) { + if (!surfaceTabs) return; + surfaceTabs.hidden = chosen.length < 2; + surfaceTabs.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 = () => showSurface(input.value); + return tab; + })); + markTabs(); +} + +function markTabs() { + for (const tab of surfaceTabs?.children ?? []) { + const value = tab.dataset.surfaceTab; + const field = strategyField(value); + const set = Boolean(field?.dataset.chosen); + const on = value === activeSurface; + tab.dataset.set = set ? 'yes' : 'no'; + tab.setAttribute('aria-pressed', on ? 'true' : 'false'); + tab.tabIndex = on ? 0 : -1; + tab.setAttribute('aria-label', set + ? `${tab.textContent}, colored ${strategyTitle(field.value).toLowerCase()}` + : `${tab.textContent}, no color strategy chosen yet`); + } +} + +function showSurface(value) { + if (!value || !stage) return; + activeSurface = value; + for (const clone of stage.querySelectorAll('[data-surface]')) { + clone.hidden = clone.dataset.surface !== value; + } + applyApplicability(); + const field = strategyField(value); + const wanted = field?.value || defaultFor(value); + const input = strategyInputs.find((radio) => radio.value === wanted); + if (input) input.checked = true; + markTabs(); +} + +/* A strategy 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 = new Set(allowedFor(activeSurface)); + for (const [value, row] of strategyRows) { + if (!row) continue; + const ok = allowed.has(value); + const desc = row.querySelector('.picker-strategy-desc'); + desc.dataset.copy ??= desc.textContent; + desc.textContent = ok ? desc.dataset.copy : BLOCKED_BECAUSE[value] ?? desc.dataset.copy; + row.classList.toggle('is-blocked', !ok); + row.querySelector('input').disabled = !ok; + } +} + +for (const input of strategyInputs) { + input.addEventListener('change', () => { + const field = strategyField(activeSurface); + if (!input.checked || !field) return; + field.value = input.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. */ +surfaceTabs?.addEventListener('keydown', (event) => { + const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key]; + if (!step) return; + const tabs = [...surfaceTabs.children]; + const next = tabs[(tabs.findIndex((tab) => tab.dataset.surfaceTab === activeSurface) + step + tabs.length) % tabs.length]; + event.preventDefault(); + showSurface(next.dataset.surfaceTab); + next.focus(); +}); + for (const input of modeInputs) { input.addEventListener('change', () => { syncModesNext(); diff --git a/picker/styles/picker.css b/picker/styles/picker.css index f636001db..21a14ecfc 100644 --- a/picker/styles/picker.css +++ b/picker/styles/picker.css @@ -185,8 +185,13 @@ body.picker-page { view-transition-name: pk-band-neutral; } +/* Both screens now judge their answer on the same drawing, so the pair the + transition carries is one component in two sizes: the frame grows out of the + palette screen's rather than dissolving into a different page. The hidden + surfaces are display: none and so are not rendered, which is what keeps the + name unique while every chosen surface stays mounted. */ .picker-screen[data-screen="02"] .picker-preview, -.picker-screen[data-screen="03"] .picker-strategy-preview { +.picker-screen[data-screen="03"] .picker-strategy-stage > .picker-preview { view-transition-name: pk-test-page; } @@ -2565,15 +2570,23 @@ body.picker-page { only holds where there are four options to divide. */ .picker-screen[data-screen="03"] .picker-strategy-grid > .picker-type-rail { grid-template-rows: minmax(0, 1fr) auto; - /* The artboard is width: 100% at 1.865/1, so its height is this column's own - width over that ratio: the container's 1500 cap less its padding, less the - rail and the gap. What the rail can spend without growing the row. */ - --pk-art: calc((min(1500px, 100vw) - 582px) / 1.865); - /* And the band is what gives when that budget will not cover four legible - rows: 368px is the four at their floor plus the gap and the panel's - hairline. A shorter reading of the palette costs less than a squeezed - sentence, so the band yields first and the rows yield last. */ - --pk-band: clamp(72px, calc(var(--pk-art) - 368px), 96px); + /* The rail is measured against the viewport rather than against the frame + beside it. The frame draws the chosen surface at that drawing's own ratio, + which is wider than the artboard this column used to hold, so matching its + height would spend the whole column on four rows of 78px. + + It is also the tallest thing in its row now, which makes it the column + that decides where the CTA lands. The shared budget is measured for a row + an artboard sets and runs about 30px short of what this screen spends + around its grid; at a 900px viewport that difference is the whole + overhang, so this screen measures itself. Taken at 1600x900, the tightest + of the common sizes. */ + --pk-fit: calc(100svh - 376px); + /* 546px is the comfortable rail: four rows at 108, the band at 96, the gap, + and the panel's hairline. Below that the band is the first thing to give, + down to a floor where the palette is still readable, because a shorter + reading of four colors costs less than a squeezed sentence. */ + --pk-band: clamp(72px, calc(var(--pk-fit) - 450px), 96px); } /* And the rows come in under what the artboard beside them asks for, so the row @@ -2590,7 +2603,7 @@ body.picker-page { measures against the column budget alone, which on a short viewport asks for less than the floor and clips the fourth option to reach it. */ max-height: none; - grid-auto-rows: max(88px, min(108px, calc((var(--pk-art) - var(--pk-band) - 16px) / 4), calc((var(--pk-column) - 2px) / 4))); + grid-auto-rows: max(88px, min(108px, calc((var(--pk-fit) - var(--pk-band) - 18px) / 4))); } /* Equal rows, sized by the tallest one's copy rather than by how many there @@ -2773,6 +2786,180 @@ body.picker-page { gap: 0; } +/* ============================================================ + The strategy stage. 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 + drawings are lifted whole from the tiles, which is why the frame takes their + ratio rather than imposing the artboard's: two of the four are drawn in + pixels and two scale off their own height, and stretching either to a taller + frame breaks the drawing rather than filling it. + + The rail beside it is the taller column, so the frame is centred against it + and the tabs ride in the space above, where they cover none of the page they + are switching between. + ============================================================ */ +.picker-strategy-stage { + min-width: 0; + display: grid; + grid-template-rows: auto auto; + align-content: center; + gap: 12px; +} + +/* 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. */ +.picker-surface-tabs { + justify-self: end; + display: flex; + gap: 2px; + padding: 2px; + background: var(--ks-lacquer-raised); + border: 1px solid var(--ks-rule); + border-radius: 2px; +} + +.picker-surface-tabs[hidden] { + display: none; +} + +.picker-surface-tab { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + color: var(--ks-text-muted); + background: transparent; + border: 0; + border-radius: 2px; + font-family: inherit; + font-size: 0.82rem; + line-height: 1.2; + 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 { + width: 6px; + height: 6px; + border: 1px solid color-mix(in oklab, var(--ks-patina) 55%, transparent); + border-radius: 50%; +} + +.picker-surface-tab[data-set="yes"] .picker-surface-dot { + background: var(--ks-patina); + border-color: var(--ks-patina); +} + +/* A strategy 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 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); +} + +/* 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 diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md index 39b1b71d2..dd134c87c 100644 --- a/skill/reference/visual-cues.md +++ b/skill/reference/visual-cues.md @@ -438,6 +438,8 @@ Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every f Before launching, add a top-level `modes` array to `cues.json` naming the surface kinds this product already implies, judged from PRODUCT.md and the codebase: any of `persuade`, `operate`, `read`, `experience`. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gives no clear signal; the picker then starts from `persuade` alone. +Color is then answered per surface rather than once for the whole run, because the distribution that suits a marketing page rarely suits the tool it sells. The answers carry `color-strategy` for the leading surface, which is the first chosen tile in tile order and the one every later screen previews, plus one `color-strategy-` key for each surface chosen. Surfaces the user never opened are included too, holding the default for their kind. When more than one comes back, DESIGN.md's color section says what each surface does with the palette instead of stating one distribution for the product. + Tell the user in one line that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then run `node {{scripts_path}}/picker-server.mjs` from the project root as a foreground command and parse its `PICKER_URL` line. - **The harness has a browser tool**: open the URL with it and let the user drive. The tool is a viewport only; never drive the questionnaire yourself, because the answers are the user's.