mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
update
This commit is contained in:
+11
-1
@@ -4,7 +4,8 @@
|
||||
"ignoreFiles": [
|
||||
"tests/fixtures/**",
|
||||
"tests/framework-fixtures/**",
|
||||
"tests/detect-antipatterns.test.js"
|
||||
"tests/detect-antipatterns.test.js",
|
||||
"picker/styles/design-context.css"
|
||||
],
|
||||
"ignoreValues": [
|
||||
{
|
||||
@@ -32,6 +33,15 @@
|
||||
],
|
||||
"createdAt": "2026-07-23T21:00:00.000Z",
|
||||
"reason": "The picker is a self-contained questionnaire widget with its own kit-token type scale, and screen 04's cards render arbitrary Google Fonts at specimen sizes; DESIGN.md's ramp describes the impeccable website, not this tool (same footing as the live-browser.js entry)"
|
||||
},
|
||||
{
|
||||
"rule": "design-system-font",
|
||||
"value": "*",
|
||||
"files": [
|
||||
"picker/scripts/design-context.js"
|
||||
],
|
||||
"createdAt": "2026-07-30T13:11:54.730Z",
|
||||
"reason": "Runtime font loader: builds a Google Fonts URL from the family the agent/user chose in the questionnaire; no literal face is being introduced"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -238,16 +238,20 @@ After `bun run build:picker`, scan the **built directory**:
|
||||
node cli/bin/cli.js detect skill/scripts/picker/
|
||||
```
|
||||
|
||||
**Name the directory, not `index.html`.** The built page only links its stylesheet, and the rules that read CSS text run per file, so scanning the page alone never reads a line of the picker's CSS. That is how three `design-system-color` findings vanished with nothing fixed: the mode-tile demo palette moved out of an inline `style` attribute and into `picker.css`, and the gate went quiet while the values stayed put. Naming the bundle by path is not a fix either, because Vite content-hashes the filename and the stale path silently narrows the scan back to the page.
|
||||
**Name the directory, not `index.html`.** The built page only links its stylesheet and its script, and the rules that read CSS or JS text run per file, so scanning the page alone never reads a line of either. That is how three `design-system-color` findings vanished with nothing fixed: the mode-tile demo palette moved out of an inline `style` attribute and into `picker.css`, and the gate went quiet while the values stayed put. Naming a bundle by path is not a fix either, because Vite content-hashes the filenames and a stale path silently narrows the scan back to the page.
|
||||
|
||||
Baseline: **39 findings**, 27 on `index.html` and 12 on the bundled stylesheet. The groups below are accepted with a reason on record, so hold 39 rather than treating it as a backlog:
|
||||
Baseline: **54 findings**, 29 on `index.html`, 24 on the bundled stylesheet, and 1 on the bundled script. The groups below are accepted with a reason on record, so hold 54 rather than treating it as a backlog:
|
||||
|
||||
- 18 `cramped-padding` on the palette strips, nine for each of the two: the editable one on screen 02 and the reading copy under screen 03's choices. Each strip reports its group, its four items, and its four swatches. A swatch is a color that has to reach its own edges, so `.picker-band` carries no padding and places its hex readout and drag grip by inset instead, which the rule has no way to see. Adding padding here would put a frame around every color the visitor picked.
|
||||
- 3 `design-system-color` for the mode-tile demo palette (`#195856`, `#5f7c7a`, `#3a958b`). They are examples standing in for the palette the visitor is about to pick, which is the carve-out DESIGN.md's OKLCH-Only Rule already names. The argument is at the declaration in `picker/styles/picker.css`; read it before touching them.
|
||||
- 7 `design-system-font-size` already waived for `picker/styles/picker.css` in `.impeccable/config.json`. They reappear under the built path only because that waiver is scoped to the source path.
|
||||
- 8 `design-system-font-size` from `picker/styles/picker.css`, seven of them already waived there in `.impeccable/config.json`. They reappear under the built path only because that waiver is scoped to the source path. The eighth is `1.05rem` on `.picker-finish-lede`, the review copy on the last screen, which sits on the questionnaire's own kit-token ramp for the same reason the other seven do.
|
||||
- 2 `border-accent-on-rounded` on `.ks-bento` in the vendored `kinpaku-kit.css`, which carries no `border-radius` at all. The rule wants a radius on the same line, and minification puts the whole stylesheet on one line, so every line-scoped regex rule reads this bundle with no line context. Check a snippet against the source before acting on it.
|
||||
- 13 from the design context document, the review UI the picker hands off to after the last question. Eleven are `picker/styles/design-context.css`: 5 `design-system-font-size` and 1 `design-system-radius` (`22px`) for the ported prototype's own type ramp and shape vocabulary, 2 `design-system-color` (`#7ba98f`, `#c26d5a`) for the tray status dots, 2 `design-system-font` naming the two faces the document sets itself in, and 1 `layout-transition` on the tile expander, which animates `width` and `height` because the tile morphing into a panel is the geometry it is animating. One is `picker/scripts/design-context.js`, whose Google Fonts URL is built from whichever family the run chose, so the rule sees a template where it wants a declared face. The last is `all-caps-body` on the doc masthead's mono note. The user locked that UI; both files carry a waiver in `.impeccable/config.json` saying so, and like the picker.css group those waivers are source-scoped, so the values resurface under the built path.
|
||||
- 1 `undersized-ui-text` on the `Change` legend in the document's request-scope control, at 10.88px. Same slot and same argument as the micro-labels below.
|
||||
|
||||
The last 9 are the page findings the questionnaire has carried since those screens were built: `cramped-padding` on the option panel and the icon sheet, both edge-to-edge grids whose rows hold their own padding, and the mono readouts and micro-labels under the text-size floors. They are the group to revisit first if the number is ever worth lowering.
|
||||
The last 9 are the page findings the questionnaire has carried since those screens were built: 3 `cramped-padding` on the option panel and the icon sheet, both edge-to-edge grids whose rows hold their own padding, and 6 mono readouts and micro-labels under the text-size and tracking floors. They are the group to revisit first if the number is ever worth lowering.
|
||||
|
||||
**When the number moves, find out which file moved it before editing this section.** The baseline read 39 until the design context document landed, and all 15 findings that arrived with it are named above: 13 in its own two files, plus the finish-screen lede and the `Change` legend that ship in the picker's files but belong to that feature. The five other workstreams in the same batch added none. Building the tree at the previous commit in a `git worktree` and scanning both builds is how that was established, and it beats reasoning about which change is responsible.
|
||||
|
||||
For line numbers during triage, `node cli/bin/cli.js detect picker/styles/` scans the unminified sources. It is not the gate: the source scan's style-context heuristic skips custom-property declarations, so it misses the palette entirely. A served URL is worse than either, because the browser pass only measures the screen in front of it and the picker hides all but the active one.
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
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.
|
||||
*/
|
||||
@@ -89,41 +96,59 @@ const hook = type ? '' : undefined;
|
||||
<div class="ps-ops-rail">
|
||||
{[0, 1, 2].map((n) => (
|
||||
<span class:list={['ps-ops-item', n === 1 && 'ps-ops-item--on']}>
|
||||
<i></i><b data-type-rail></b>
|
||||
<i></i><b data-type-rail={hook}></b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class="ps-ops-main">
|
||||
<strong class="ps-ops-title" data-type-section-title></strong>
|
||||
<strong class="ps-ops-title" data-type-section-title={hook}></strong>
|
||||
<div class="ps-ops-metrics">
|
||||
{[0, 1, 2].map(() => (
|
||||
<span class="ps-ops-metric">
|
||||
<b data-type-figure></b>
|
||||
<span data-type-proof></span>
|
||||
<b data-type-figure={hook}></b>
|
||||
<span data-type-proof={hook}></span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div class="ps-ops-chart">
|
||||
<b class="ps-ops-chart-title" data-type-chart-title={hook}></b>
|
||||
<div class="ps-ops-plot">
|
||||
{[46, 66, 100, 58, 44].map((h, n) => (
|
||||
<i class:list={[n === 2 && 'ps-ops-bar--lead']} style={`--h: ${h}%`}></i>
|
||||
))}
|
||||
</div>
|
||||
<div class="ps-ops-lanes">
|
||||
{[0, 1, 2, 3, 4].map(() => <b data-type-lane={hook}></b>)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ps-ops-table">
|
||||
<span class="ps-ops-row ps-ops-row--head">
|
||||
<i></i>
|
||||
<b data-type-column></b><b data-type-column></b><b data-type-column></b>
|
||||
<b data-type-column={hook}></b><b data-type-column={hook}></b><b data-type-column={hook}></b>
|
||||
</span>
|
||||
{[0, 1, 2].map((n) => (
|
||||
<span class:list={['ps-ops-row', n === 1 && 'ps-ops-row--on']}>
|
||||
<i></i>
|
||||
<b data-type-gallery-title></b>
|
||||
<b data-type-gallery-meta></b>
|
||||
<b data-type-amount></b>
|
||||
<b data-type-gallery-title={hook}></b>
|
||||
<b data-type-gallery-meta={hook}></b>
|
||||
<b data-type-amount={hook}></b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ps-ops-panel">
|
||||
<b class="ps-ops-panel-title" data-type-panel></b>
|
||||
<span class="ps-ops-field"><b data-type-panel></b><i class="ps-ops-chev"></i></span>
|
||||
<b class="ps-ops-panel-title" data-type-panel={hook}></b>
|
||||
<span class="ps-ops-field"><b data-type-panel={hook}></b><i class="ps-ops-chev"></i></span>
|
||||
{[0, 1].map((n) => (
|
||||
<span class="ps-ops-switch">
|
||||
<b data-type-switch></b>
|
||||
<b data-type-switch={hook}></b>
|
||||
<i class:list={['ps-ops-toggle', n === 0 && 'ps-ops-toggle--on']}></i>
|
||||
</span>
|
||||
))}
|
||||
@@ -137,7 +162,7 @@ const hook = type ? '' : undefined;
|
||||
<div class="ps-docs-rail">
|
||||
{[0, 1, 2, 3].map((n) => (
|
||||
<span class:list={['ps-docs-item', n === 1 && 'ps-docs-item--on']}>
|
||||
<i></i><b data-type-rail></b>
|
||||
<i></i><b data-type-rail={hook}></b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -146,35 +171,71 @@ const hook = type ? '' : undefined;
|
||||
and the heading under it is visible at a glance, so the board shows
|
||||
both rather than the single heading the drawing carries. */}
|
||||
<div class="ps-docs-main">
|
||||
<h2 class="pt-headline" data-type-headline></h2>
|
||||
<p class="ps-docs-lede" data-type-body></p>
|
||||
<strong class="ps-docs-sub" data-type-section-title></strong>
|
||||
<p class="ps-docs-para" data-type-passage></p>
|
||||
<p class="ps-docs-para" data-type-passage></p>
|
||||
<h2 class="pt-headline" data-type-headline={hook}>{!type && <Fragment><i></i><i></i></Fragment>}</h2>
|
||||
<p class="ps-docs-lede" data-type-body={hook}>{!type && [0, 1].map(() => <i></i>)}</p>
|
||||
<strong class="ps-docs-sub" data-type-section-title={hook}></strong>
|
||||
<p class="ps-docs-para" data-type-passage={hook}>{!type && [0, 1, 2].map(() => <i></i>)}</p>
|
||||
<p class="ps-docs-para" data-type-passage={hook}>{!type && [0, 1, 2].map(() => <i></i>)}</p>
|
||||
{/* 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. */}
|
||||
<ul class="ps-docs-list">
|
||||
{[0, 1].map(() => <li data-type-item={hook}></li>)}
|
||||
</ul>
|
||||
<div class="ps-docs-note">
|
||||
<i class="ps-docs-note-dot"></i>
|
||||
<span class="ps-docs-note-copy">
|
||||
<b data-type-note-label></b>
|
||||
<span data-type-note-body></span>
|
||||
<b data-type-note-label={hook}></b>
|
||||
<span data-type-note-body={hook}>{!type && [0, 1].map(() => <i></i>)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : 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. */
|
||||
<div class="ps-index">
|
||||
<i class="ps-index-head"></i>
|
||||
{[0, 1].map((n) => (
|
||||
<div class:list={['ps-index-row', n === 1 && 'ps-index-row--flip']}>
|
||||
<div class="ps-image"></div>
|
||||
<div class="ps-index-cap">
|
||||
<i class="ps-index-name"></i>
|
||||
<i class="ps-index-tag"></i>
|
||||
<span class="ps-index-lines"><b></b><b></b></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="ps-index-rail">
|
||||
<i class="ps-index-arrow"></i>
|
||||
<div class="ps-index-track">
|
||||
{[0, 1, 2, 3].map((n) => (
|
||||
<b class:list={[n === 0 && 'ps-index-stop--on']}></b>
|
||||
))}
|
||||
</div>
|
||||
<i class="ps-index-arrow ps-index-arrow--next"></i>
|
||||
</div>
|
||||
</div>
|
||||
) : index ? (
|
||||
<div class="ps-index">
|
||||
{/* The drawing staggers two plates and neither says anything the other
|
||||
does not. One row buys the page title above it, which is the only
|
||||
place in the set where the heading face is allowed to be as large
|
||||
as this surface would really set it. */}
|
||||
{/* 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. */}
|
||||
<h2 class="pt-headline" data-type-headline></h2>
|
||||
<div class="ps-index-row">
|
||||
<div class="ps-image"></div>
|
||||
<div class="ps-index-cap">
|
||||
<strong class="ps-index-title" data-type-gallery-title></strong>
|
||||
<span class="ps-index-meta" data-type-gallery-meta></span>
|
||||
<span class="ps-index-note" data-type-caption></span>
|
||||
{[0, 1].map((n) => (
|
||||
<div class:list={['ps-index-row', n === 1 && 'ps-index-row--flip']}>
|
||||
<div class="ps-image"></div>
|
||||
<div class="ps-index-cap">
|
||||
<strong class="ps-index-title" data-type-gallery-title></strong>
|
||||
<span class="ps-index-meta" data-type-gallery-meta></span>
|
||||
<span class="ps-index-note" data-type-caption></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 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. */}
|
||||
<div class="ps-index-rail">
|
||||
@@ -229,8 +290,14 @@ const hook = type ? '' : undefined;
|
||||
: <span><i></i><i></i></span>}
|
||||
<em data-type-section-link={hook}></em>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div class="ps-gallery">
|
||||
{[0, 1, 2, 3].map(() => (
|
||||
{(type ? [0, 1, 2] : [0, 1, 2, 3]).map(() => (
|
||||
<div class="ps-gallery-item">
|
||||
<i></i>
|
||||
{type
|
||||
@@ -280,11 +347,26 @@ const hook = type ? '' : undefined;
|
||||
</div>
|
||||
{ops ? (
|
||||
<div class="ps-phone-body">
|
||||
<strong class="ps-ops-title" data-type-section-title></strong>
|
||||
<strong class="ps-ops-title" data-type-section-title={hook}></strong>
|
||||
<span class="ps-ops-metric">
|
||||
<b data-type-figure></b>
|
||||
<span data-type-proof></span>
|
||||
<b data-type-figure={hook}></b>
|
||||
<span data-type-proof={hook}></span>
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<div class="ps-ops-chart">
|
||||
<b class="ps-ops-chart-title" data-type-chart-title={hook}></b>
|
||||
<div class="ps-ops-plot">
|
||||
{[52, 74, 100].map((h, n) => (
|
||||
<i class:list={[n === 2 && 'ps-ops-bar--lead']} style={`--h: ${h}%`}></i>
|
||||
))}
|
||||
</div>
|
||||
<div class="ps-ops-lanes">
|
||||
{[0, 1, 2].map(() => <b data-type-lane={hook}></b>)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
@@ -292,15 +374,15 @@ const hook = type ? '' : undefined;
|
||||
{[0, 1, 2].map((n) => (
|
||||
<span class:list={['ps-ops-row', n === 1 && 'ps-ops-row--on']}>
|
||||
<i></i>
|
||||
<b data-type-gallery-title></b>
|
||||
<b data-type-amount></b>
|
||||
<b data-type-gallery-title={hook}></b>
|
||||
<b data-type-amount={hook}></b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div class="ps-ops-panel">
|
||||
{[0, 1].map((n) => (
|
||||
<span class="ps-ops-switch">
|
||||
<b data-type-switch></b>
|
||||
<b data-type-switch={hook}></b>
|
||||
<i class:list={['ps-ops-toggle', n === 0 && 'ps-ops-toggle--on']}></i>
|
||||
</span>
|
||||
))}
|
||||
@@ -310,16 +392,18 @@ const hook = type ? '' : undefined;
|
||||
<div class="ps-phone-body">
|
||||
{/* 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. */}
|
||||
<span class="ps-docs-crumb" data-type-crumb></span>
|
||||
<h2 class="pt-headline" data-type-headline></h2>
|
||||
<p class="ps-docs-lede" data-type-body></p>
|
||||
<strong class="ps-docs-sub" data-type-section-title></strong>
|
||||
<p class="ps-docs-para" data-type-passage></p>
|
||||
<span class="ps-docs-crumb" data-type-crumb={hook}></span>
|
||||
<h2 class="pt-headline" data-type-headline={hook}>{!type && <Fragment><i></i><i></i></Fragment>}</h2>
|
||||
<p class="ps-docs-lede" data-type-body={hook}>{!type && [0, 1].map(() => <i></i>)}</p>
|
||||
<strong class="ps-docs-sub" data-type-section-title={hook}></strong>
|
||||
<p class="ps-docs-para" data-type-passage={hook}>{!type && [0, 1, 2].map(() => <i></i>)}</p>
|
||||
<ul class="ps-docs-list">
|
||||
{[0, 1].map(() => <li data-type-item={hook}></li>)}
|
||||
</ul>
|
||||
<div class="ps-docs-note">
|
||||
<i class="ps-docs-note-dot"></i>
|
||||
<span class="ps-docs-note-copy">
|
||||
<b data-type-note-label></b>
|
||||
<span data-type-note-body></span>
|
||||
<b data-type-note-label={hook}></b>
|
||||
<span data-type-note-body={hook}>{!type && [0, 1].map(() => <i></i>)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -331,9 +415,19 @@ const hook = type ? '' : undefined;
|
||||
<Fragment>
|
||||
<div class="ps-image"></div>
|
||||
<div class="ps-index-cap">
|
||||
<strong class="ps-index-title" data-type-gallery-title></strong>
|
||||
<span class="ps-index-meta" data-type-gallery-meta></span>
|
||||
<span class="ps-index-note" data-type-caption></span>
|
||||
{type ? (
|
||||
<Fragment>
|
||||
<strong class="ps-index-title" data-type-gallery-title></strong>
|
||||
<span class="ps-index-meta" data-type-gallery-meta></span>
|
||||
<span class="ps-index-note" data-type-caption></span>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<i class="ps-index-name"></i>
|
||||
<i class="ps-index-tag"></i>
|
||||
<span class="ps-index-lines"><b></b><b></b></span>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -396,8 +490,5 @@ const hook = type ? '' : undefined;
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chrome that belongs to the frame rather than to the page drawn in it,
|
||||
such as the surface tab strip. Taken out of the flow by its own rules, so
|
||||
the artboard's grid is still the two columns declared above. */}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
as data and its design lives in its own stylesheet.
|
||||
*/
|
||||
import Artboard from './Artboard.astro';
|
||||
import { SURFACE_MODES, SURFACE_ANSWERS, surfaceTabsAttrs } from '../data/surfaces.js';
|
||||
import { SURFACE_ANSWERS, surfacesAsked, surfaceTabsAttrs } from '../data/surfaces.js';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
@@ -16,6 +16,14 @@ interface Option {
|
||||
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;
|
||||
@@ -27,15 +35,21 @@ interface Props {
|
||||
options: Option[];
|
||||
previewClass: string;
|
||||
phone?: boolean;
|
||||
boards?: Board[];
|
||||
}
|
||||
|
||||
const { id, step, slug, name, title, legend, cta, options, previewClass, phone = true } = Astro.props;
|
||||
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;
|
||||
---
|
||||
|
||||
<section class="picker-screen" data-screen={id} data-step={step} aria-hidden="true" aria-labelledby={titleId}>
|
||||
@@ -67,7 +81,7 @@ const perSurface = SURFACE_ANSWERS[name];
|
||||
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. */}
|
||||
{perSurface && SURFACE_MODES.map((mode) => (
|
||||
{asked.map((mode) => (
|
||||
<input
|
||||
type="hidden"
|
||||
data-surface-field={`${name}-${mode}`}
|
||||
@@ -77,16 +91,31 @@ const perSurface = SURFACE_ANSWERS[name];
|
||||
))}
|
||||
</div>
|
||||
|
||||
{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. */
|
||||
<div class="picker-board-stage">
|
||||
{tabsAttrs && <div class="picker-surface-tabs" {...tabsAttrs} hidden></div>}
|
||||
{(boards ?? [{} as Board]).map((board) => (
|
||||
<Artboard
|
||||
class={[artboardClass, board.class].filter(Boolean).join(' ')}
|
||||
surface={board.surface}
|
||||
phone={phone}
|
||||
cursor={slug === 'motion'}
|
||||
carry={slug !== 'motion'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Artboard
|
||||
class={artboardClass}
|
||||
phone={phone}
|
||||
cursor={slug === 'motion'}
|
||||
carry={slug !== 'motion'}
|
||||
>
|
||||
{perSurface && (
|
||||
<div class="picker-surface-tabs" {...surfaceTabsAttrs(name)} hidden></div>
|
||||
)}
|
||||
</Artboard>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="picker-actions-stack">
|
||||
|
||||
+45
-2
@@ -34,6 +34,14 @@ export const SURFACE_MODES = ['persuade', 'operate', 'read', 'experience'];
|
||||
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. Only motion is 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
|
||||
@@ -83,6 +91,32 @@ export const SURFACE_ANSWERS = {
|
||||
},
|
||||
},
|
||||
|
||||
/*
|
||||
The one question that is 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' },
|
||||
},
|
||||
},
|
||||
|
||||
/*
|
||||
Flat, and the tabs here are worth having anyway. The twelve-column ruler on
|
||||
this screen describes a page, and it describes one whether the run ships a
|
||||
@@ -190,10 +224,12 @@ export const SURFACE_ANSWERS = {
|
||||
/* 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. */
|
||||
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];
|
||||
const { allow, fallback } = question.surfaces[mode] ?? {};
|
||||
return [
|
||||
...(allow ? [[`data-allow-${name}`, allow]] : []),
|
||||
...(fallback ? [[`data-default-${name}`, fallback]] : []),
|
||||
@@ -201,6 +237,13 @@ export const surfaceAttrs = (mode) => Object.fromEntries(
|
||||
}),
|
||||
);
|
||||
|
||||
/* 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) => ({
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../styles/screens/layout.css';
|
||||
import '../styles/screens/boundaries.css';
|
||||
import '../styles/screens/corners.css';
|
||||
import '../styles/screens/depth.css';
|
||||
// The design context document that replaces the picker after submission.
|
||||
import '../styles/design-context.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
|
||||
+304
-54
@@ -44,10 +44,29 @@ const iconRows = iconPacks.packs.map((pack) => ({
|
||||
// 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 the same 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.
|
||||
// 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. A structural answer is a claim about a kind
|
||||
// of page, and the four 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; these four 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',
|
||||
@@ -59,6 +78,15 @@ const questions = [
|
||||
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',
|
||||
@@ -86,6 +114,7 @@ const questions = [
|
||||
legend: 'Layout structure',
|
||||
cta: 'Select layout structure',
|
||||
previewClass: 'picker-strategy-preview picker-preview-layout',
|
||||
boards,
|
||||
options: [
|
||||
{
|
||||
value: 'simple-grid',
|
||||
@@ -114,6 +143,7 @@ const questions = [
|
||||
legend: 'Boundary style',
|
||||
cta: 'Select boundary style',
|
||||
previewClass: 'picker-preview-boundaries',
|
||||
boards,
|
||||
options: [
|
||||
{
|
||||
value: 'open-space',
|
||||
@@ -148,6 +178,7 @@ const questions = [
|
||||
legend: 'Corner style',
|
||||
cta: 'Select corner style',
|
||||
previewClass: 'picker-preview-corners',
|
||||
boards,
|
||||
options: [
|
||||
{
|
||||
value: 'sharp',
|
||||
@@ -181,6 +212,7 @@ const questions = [
|
||||
legend: 'Depth style',
|
||||
cta: 'Select depth style',
|
||||
previewClass: 'picker-preview-depth',
|
||||
boards,
|
||||
options: [
|
||||
{
|
||||
value: 'flat',
|
||||
@@ -947,46 +979,46 @@ const questions = [
|
||||
no editorial page would spend a heading level on. */}
|
||||
<div class="picker-scale-column">
|
||||
<div class="picker-scale-specimen" data-scale-specimen aria-hidden="true">
|
||||
<p class="sp-h1">A generic display heading</p>
|
||||
<p class="sp-lede">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.</p>
|
||||
<p class="sp-p">
|
||||
<p class="sp-h1" data-sp-label="H1">A generic display heading</p>
|
||||
<p class="sp-lede" data-sp-label="Lede">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.</p>
|
||||
<p class="sp-p" data-sp-label="P">
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute <em>irure dolor in reprehenderit</em> in voluptate velit esse cillum dolore eu
|
||||
fugiat nulla pariatur, <u>excepteur sint occaecat</u> cupidatat non proident.
|
||||
</p>
|
||||
|
||||
<p class="sp-h2">A section heading</p>
|
||||
<p class="sp-p">
|
||||
<p class="sp-h2" data-sp-label="H2">A section heading</p>
|
||||
<p class="sp-p" data-sp-label="P">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
|
||||
totam rem aperiam <strong>eaque ipsa quae</strong> ab illo inventore veritatis.
|
||||
</p>
|
||||
<ul class="sp-list">
|
||||
<ul class="sp-list" data-sp-label="List">
|
||||
<li>Lorem ipsum dolor sit amet, consectetur</li>
|
||||
<li>Adipiscing elit, sed do eiusmod tempor incididunt ut labore</li>
|
||||
<li>Magna aliqua ut enim ad minim veniam</li>
|
||||
</ul>
|
||||
|
||||
<blockquote class="sp-quote">
|
||||
<blockquote class="sp-quote" data-sp-label="Quote">
|
||||
<p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae.</p>
|
||||
<cite class="sp-cite">Attribution line</cite>
|
||||
</blockquote>
|
||||
|
||||
<p class="sp-h3">A subsection heading</p>
|
||||
<p class="sp-p">
|
||||
<p class="sp-h3" data-sp-label="H3">A subsection heading</p>
|
||||
<p class="sp-p" data-sp-label="P">
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum
|
||||
deleniti atque, held together by <code class="sp-code">a run of inline code</code> mid sentence.
|
||||
</p>
|
||||
<ol class="sp-list sp-list-ordered">
|
||||
<ol class="sp-list sp-list-ordered" data-sp-label="List">
|
||||
<li>Nam libero tempore, cum soluta nobis</li>
|
||||
<li>Eligendi optio cumque nihil impedit</li>
|
||||
<li>Quo minus id quod maxime placeat</li>
|
||||
</ol>
|
||||
<pre class="sp-pre"><code>function step(base, ratio, n) {
|
||||
<pre class="sp-pre" data-sp-label="Code"><code>function step(base, ratio, n) {
|
||||
return base * ratio ** n;
|
||||
}</code></pre>
|
||||
|
||||
<p class="sp-h4">A minor heading</p>
|
||||
<p class="sp-p">
|
||||
<p class="sp-h4" data-sp-label="H4">A minor heading</p>
|
||||
<p class="sp-p" data-sp-label="P">
|
||||
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et
|
||||
voluptates repudiandae sint et molestiae non recusandae.
|
||||
</p>
|
||||
@@ -1105,11 +1137,15 @@ const questions = [
|
||||
|
||||
<section class="picker-screen" data-screen="12" data-step="Review" aria-hidden="true" aria-labelledby="picker-finish-copy">
|
||||
<div class="picker-container">
|
||||
<div class="picker-placeholder">
|
||||
<p id="picker-finish-copy">Your visual direction is ready.</p>
|
||||
<div class="picker-actions-stack">
|
||||
<button class="ks-button ks-button-primary" type="submit">
|
||||
Finish
|
||||
<div class="picker-placeholder picker-finish">
|
||||
<h2 id="picker-finish-copy" class="picker-question-title">Thank you — that’s everything.</h2>
|
||||
<p class="picker-finish-lede">We have all we need to build your design context document. Assembling it now.</p>
|
||||
<div class="picker-finish-loader" data-doc-loader aria-hidden="true"><i></i></div>
|
||||
<p class="picker-finish-status" data-doc-status role="status" aria-live="polite"></p>
|
||||
<div class="picker-actions-stack" data-doc-error hidden>
|
||||
<p class="picker-finish-error">Your answers could not be saved — the picker server may have closed.</p>
|
||||
<button class="ks-button ks-button-primary" type="button" data-doc-retry>
|
||||
Try again
|
||||
<span class="ks-button-arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
|
||||
<path d="M0 4h14M10 0l4 4-4 4"></path>
|
||||
@@ -1127,13 +1163,212 @@ const questions = [
|
||||
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
|
||||
</div>
|
||||
<p class="picker-progress-label">
|
||||
<span class="picker-progress-index"><b data-progress-index>1</b> / 11</span>
|
||||
<span class="picker-progress-index"><b data-progress-index>1</b> / <span data-progress-total>11</span></span>
|
||||
<span class="picker-progress-name" data-progress-name></span>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
{/* 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. */}
|
||||
<section class="dcx-shell" data-dcx-shell hidden aria-label="Design context categories">
|
||||
<header class="dcx-masthead">
|
||||
<span class="dcx-masthead-brand">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"></path>
|
||||
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"></path>
|
||||
</svg>
|
||||
<span>Impeccable</span>
|
||||
</span>
|
||||
<span class="dcx-masthead-title" data-dcx-product>Design context</span>
|
||||
<span class="dcx-masthead-note">Assembled from your interview · answers saved</span>
|
||||
</header>
|
||||
<div class="dcx-grid">
|
||||
|
||||
<div class="dcx-badge" data-reveal style="--reveal-delay: 0.45s" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"></path>
|
||||
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button class="dcx-tile dcx-tile--audience" type="button" data-category="audience" data-name="Audience" data-reveal style="--reveal-delay: 0s" aria-label="Open Audience">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<circle cx="15" cy="14" r="4" stroke="var(--viz-ink)" class="vz-stroke" vector-effect="non-scaling-stroke"></circle>
|
||||
<path d="M7 30c0-5 3.6-8 8-8s8 3 8 8" stroke="var(--viz-ink)" stroke-linecap="round" class="vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
<g class="anim-join">
|
||||
<circle cx="28" cy="16" r="3.4" stroke="var(--viz-accent)" class="vz-stroke" vector-effect="non-scaling-stroke"></circle>
|
||||
<path d="M21.5 30c0-4.4 3-7 6.5-7s6.5 2.6 6.5 7" stroke="var(--viz-accent)" stroke-linecap="round" class="anim-draw vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Audience</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--product" type="button" data-category="product" data-name="Product" data-reveal style="--reveal-delay: 0.05s" aria-label="Open Product">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<line x1="8" y1="13" x2="26" y2="13" stroke="var(--viz-ink)" stroke-linecap="round" class="vz-stroke" vector-effect="non-scaling-stroke"></line>
|
||||
<line x1="8" y1="19" x2="30" y2="19" stroke="var(--viz-mist)" stroke-linecap="round" class="vz-stroke" vector-effect="non-scaling-stroke"></line>
|
||||
<line x1="8" y1="25" x2="22" y2="25" stroke="var(--viz-accent)" stroke-linecap="round" class="vz-stroke" vector-effect="non-scaling-stroke"></line>
|
||||
<line x1="26" y1="23" x2="26" y2="27" stroke="var(--viz-accent)" class="anim-blink vz-stroke" vector-effect="non-scaling-stroke"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Product</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--brand" type="button" data-category="brand" data-name="Brand" data-reveal style="--reveal-delay: 0.1s" aria-label="Open Brand">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<path d="M6 17 L34 17" stroke="var(--viz-mist)" class="vz-ghost" vector-effect="non-scaling-stroke"></path>
|
||||
<path d="M12 10 L16 17 L20 33 M28 10 L24 17 L20 33" stroke="var(--viz-mist)" stroke-linejoin="round" class="vz-ghost" vector-effect="non-scaling-stroke"></path>
|
||||
<path d="M12 10 L28 10 L34 17 L20 33 L6 17 Z" stroke="var(--viz-accent)" stroke-linejoin="round" class="anim-draw vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
<circle cx="20" cy="13.5" r="1.4" fill="var(--viz-accent)" class="anim-fade-in"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Brand</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--color" type="button" data-category="color" data-name="Color" data-reveal style="--reveal-delay: 0.15s" aria-label="Open Color">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<circle cx="16" cy="20" r="8" stroke="var(--viz-ink)" class="anim-move-x vz-stroke" vector-effect="non-scaling-stroke"></circle>
|
||||
<circle cx="24" cy="20" r="8" stroke="var(--viz-accent)" class="anim-move-x-opp anim-join vz-stroke" vector-effect="non-scaling-stroke"></circle>
|
||||
<path d="M20 14.5C21.5 16 22.5 18 22.5 20C22.5 22 21.5 24 20 25.5C18.5 24 17.5 22 17.5 20C17.5 18 18.5 16 20 14.5Z" fill="var(--viz-accent)" class="anim-fade-in"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Color</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--typography" type="button" data-category="typography" data-name="Typography" data-reveal style="--reveal-delay: 0.2s" aria-label="Open Typography">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<path d="M10 30L20 10L30 30" stroke="var(--viz-mist)" stroke-linecap="round" stroke-linejoin="round" class="vz-ghost" vector-effect="non-scaling-stroke"></path>
|
||||
<path d="M10 30L20 10L30 30" stroke="var(--viz-accent)" stroke-linecap="round" stroke-linejoin="round" class="anim-draw vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
<path d="M15 22H25" stroke="var(--viz-accent)" stroke-linecap="round" class="anim-draw-delay vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Typography</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--iconography" type="button" data-category="iconography" data-name="Iconography" data-reveal style="--reveal-delay: 0.25s" aria-label="Open Iconography">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<circle cx="14" cy="14" r="4.5" stroke="var(--viz-ink)" class="anim-pop anim-pop-1 vz-stroke" vector-effect="non-scaling-stroke"></circle>
|
||||
<rect x="22" y="9.5" width="9" height="9" rx="1.5" stroke="var(--viz-ink)" class="anim-pop anim-pop-2 vz-stroke" vector-effect="non-scaling-stroke"></rect>
|
||||
<path d="M14 22.5L18.5 30.5H9.5L14 22.5Z" stroke="var(--viz-ink)" stroke-linejoin="round" class="anim-pop anim-pop-3 vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
<path d="M26.5 22.5V30.5M22.5 26.5H30.5" stroke="var(--viz-accent)" stroke-linecap="round" class="anim-pop anim-pop-4 vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Iconography</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--material" type="button" data-category="material" data-name="Material" data-reveal style="--reveal-delay: 0.3s" aria-label="Open Material">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<rect x="5" y="10" width="30" height="18.5" stroke="var(--viz-mist)" class="vz-ghost" vector-effect="non-scaling-stroke"></rect>
|
||||
<line x1="23.5" y1="10" x2="23.5" y2="28.5" stroke="var(--viz-mist)" class="vz-ghost" vector-effect="non-scaling-stroke"></line>
|
||||
<line x1="23.5" y1="21.5" x2="35" y2="21.5" stroke="var(--viz-mist)" class="vz-ghost" vector-effect="non-scaling-stroke"></line>
|
||||
<path d="M5 28.5C5 18.28 13.28 10 23.5 10C29.85 10 35 15.15 35 21.5C35 25.42 31.82 28.5 27.9 28.5" fill="none" stroke="var(--viz-accent)" stroke-linecap="round" class="anim-draw vz-stroke" vector-effect="non-scaling-stroke"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Material</h2>
|
||||
</button>
|
||||
|
||||
<button class="dcx-tile dcx-tile--interface" type="button" data-category="interface" data-name="Interface" data-reveal style="--reveal-delay: 0.35s" aria-label="Open Interface">
|
||||
<span class="dcx-tile-viz" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" class="dcx-viz-svg">
|
||||
<rect x="10" y="14" width="20" height="12" rx="6" stroke="var(--viz-ink)" class="vz-stroke" vector-effect="non-scaling-stroke"></rect>
|
||||
<circle cx="16" cy="20" r="4" fill="var(--viz-mist)" class="anim-toggle-move"></circle>
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="dcx-tile-title">Interface</h2>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Detail articles: empty shells filled per run by design-context.js. */}
|
||||
<template id="dcx-detail-audience"></template>
|
||||
<template id="dcx-detail-product"></template>
|
||||
<template id="dcx-detail-brand"></template>
|
||||
<template id="dcx-detail-color"></template>
|
||||
<template id="dcx-detail-typography"></template>
|
||||
<template id="dcx-detail-iconography"></template>
|
||||
<template id="dcx-detail-material"></template>
|
||||
<template id="dcx-detail-interface"></template>
|
||||
|
||||
{/* Expander shell: sidebar + topbar + main, cloned per open. */}
|
||||
<template id="dcx-shell-template">
|
||||
<div class="dcx-expander-inner">
|
||||
<aside class="dcx-sidebar">
|
||||
<a class="dcx-sidebar-brand" href="https://impeccable.style/" target="_blank" rel="noopener" aria-label="Impeccable home (opens the live site)">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"></path>
|
||||
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"></path>
|
||||
</svg>
|
||||
<span class="dcx-sidebar-brand-name">Impeccable</span>
|
||||
</a>
|
||||
<nav class="dcx-nav" aria-label="Design context categories">
|
||||
<span class="dcx-nav-label">Design context</span>
|
||||
<ul class="dcx-nav-list">
|
||||
<li data-category="audience"><a class="dcx-nav-link" href="#audience" data-dcx-nav="audience">Audience</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="product"><a class="dcx-nav-link" href="#product" data-dcx-nav="product">Product</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="brand"><a class="dcx-nav-link" href="#brand" data-dcx-nav="brand">Brand</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="color"><a class="dcx-nav-link" href="#color" data-dcx-nav="color">Color</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="typography"><a class="dcx-nav-link" href="#typography" data-dcx-nav="typography">Typography</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="iconography"><a class="dcx-nav-link" href="#iconography" data-dcx-nav="iconography">Iconography</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="material"><a class="dcx-nav-link" href="#material" data-dcx-nav="material">Material</a><div class="dcx-subnav"></div></li>
|
||||
<li data-category="interface"><a class="dcx-nav-link" href="#interface" data-dcx-nav="interface">Interface</a><div class="dcx-subnav"></div></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="dcx-panel">
|
||||
<header class="dcx-topbar">
|
||||
<div class="dcx-current"></div>
|
||||
<button class="dcx-request" type="button" data-dcx-request>Request a change</button>
|
||||
<button class="dcx-close" type="button" aria-label="Close"></button>
|
||||
</header>
|
||||
<div class="dcx-main" tabindex="-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{/* Edit-request modal: complex changes the document cannot apply itself
|
||||
are written up here and queued for the agent over the doc session. */}
|
||||
<dialog class="picker-modal dcx-request-modal" data-dcx-request-modal aria-labelledby="dcx-request-title">
|
||||
<div class="picker-modal-inner">
|
||||
<header class="picker-modal-head">
|
||||
<h2 id="dcx-request-title">Request a change</h2>
|
||||
<p data-dcx-request-lede>Describe the change; the agent applies it to the design document and the page updates here.</p>
|
||||
</header>
|
||||
<fieldset class="picker-modal-field">
|
||||
<legend data-dcx-request-scope>Change</legend>
|
||||
<label class="picker-modal-label" for="dcx-request-prompt">What should change?</label>
|
||||
<textarea class="picker-modal-input dcx-request-prompt" id="dcx-request-prompt" rows="4" maxlength="4000"
|
||||
placeholder="e.g. Swap the heading font for something with more contrast at display sizes."></textarea>
|
||||
<div class="picker-modal-file-block" data-dcx-request-fonts hidden>
|
||||
<label class="picker-modal-label" for="dcx-request-font-files">Font files (optional)</label>
|
||||
<input class="picker-modal-file-input" id="dcx-request-font-files" type="file" multiple
|
||||
accept=".woff2,.woff,.ttf,.otf" data-dcx-request-files />
|
||||
<p class="picker-modal-or" data-dcx-request-upload-note></p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<footer class="picker-modal-actions">
|
||||
<button class="picker-back" type="button" data-dcx-request-cancel>Cancel</button>
|
||||
<button class="ks-button ks-button-primary" type="button" data-dcx-request-send>Send to the agent</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{/* Live edit tray: every queued request with its status, fed by polling. */}
|
||||
<div class="dcx-tray" data-dcx-tray hidden aria-live="polite"></div>
|
||||
|
||||
<dialog class="picker-modal" data-font-modal aria-labelledby="picker-font-modal-title">
|
||||
<div class="picker-modal-inner">
|
||||
<header class="picker-modal-head">
|
||||
@@ -1211,6 +1446,7 @@ const questions = [
|
||||
|
||||
<script>
|
||||
import '../scripts/palette-picker.js';
|
||||
import '../scripts/design-context.js';
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const form = $('#picker-form');
|
||||
@@ -1231,22 +1467,37 @@ const questions = [
|
||||
// Neither the start screen nor the review is a question, so the stepper
|
||||
// counts from the surfaces screen and fills on the last one, icons.
|
||||
// Review holds it full rather than counting past the end.
|
||||
const STEPS = 11;
|
||||
//
|
||||
// A question no chosen surface takes is not a step either, so the count and
|
||||
// the row of ticks come from the screens this run actually asks rather than
|
||||
// from the markup: a docs-only run is ten questions long and says so.
|
||||
const progress = $('.picker-progress');
|
||||
const setProgress = (index, name) => {
|
||||
const step = Math.min(Math.max(index, 1), STEPS);
|
||||
const track = $('.picker-progress-track');
|
||||
const steps = () => screens.filter((screen, index) => (
|
||||
index > 0 && index < screens.length - 1 && !screen.hasAttribute('data-skip')
|
||||
));
|
||||
const setProgress = (target) => {
|
||||
const list = steps();
|
||||
const total = list.length;
|
||||
const at = list.indexOf(target);
|
||||
const step = at >= 0 ? at + 1 : (target === screens[0] ? 1 : total);
|
||||
progress.dataset.step = String(step);
|
||||
progress.style.setProperty('--pk-steps', String(total));
|
||||
[...track.children].forEach((tick, index) => tick.toggleAttribute('data-off', index >= total));
|
||||
$('[data-progress-index]').textContent = String(step);
|
||||
$('[data-progress-name]').textContent = name || '';
|
||||
$('[data-progress-total]').textContent = String(total);
|
||||
$('[data-progress-name]').textContent = target.dataset.step || '';
|
||||
};
|
||||
|
||||
// The palette screen and the strategy screen are the one pair in the run
|
||||
// that shows the same objects twice, so they are the pair whose change is
|
||||
// worth animating: the four colors and the test page travel between the two
|
||||
// positions instead of being redrawn. Everywhere else there is nothing to
|
||||
// carry, and capturing a screen that is mid-animation reads worse than a
|
||||
// plain swap does.
|
||||
const MORPH = new Set(['02', '03']);
|
||||
// The surfaces, palette, and strategy screens are the one stretch of the run
|
||||
// that shows the same objects more than once, so they are where a change is
|
||||
// worth animating: the test page travels from the leading surface tile into
|
||||
// the palette panel and on to the strategy stage, and the four colors travel
|
||||
// with it over that second step, instead of each being redrawn. Everywhere
|
||||
// else there is nothing to carry, and capturing a screen that is
|
||||
// mid-animation reads worse than a plain swap does. Only adjacent screens
|
||||
// are ever swapped, so listing the three ids names exactly the two pairs.
|
||||
const MORPH = new Set(['01b', '02', '03']);
|
||||
const morphs = (from, to) => (
|
||||
typeof document.startViewTransition === 'function'
|
||||
&& !matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -1254,14 +1505,20 @@ const questions = [
|
||||
&& MORPH.has(to?.dataset.screen)
|
||||
);
|
||||
|
||||
const goTo = (index) => {
|
||||
const target = screens[index];
|
||||
/* A question the run does not ask is not a screen the run has, so the step
|
||||
carries on in the direction it was already travelling. That is what makes
|
||||
Back from the screen after a skipped one land on the screen before it
|
||||
rather than on the one nobody was shown. */
|
||||
const goTo = (index, step = 1) => {
|
||||
let at = index;
|
||||
while (screens[at]?.hasAttribute('data-skip')) at += step;
|
||||
const target = screens[at];
|
||||
if (!target) return;
|
||||
if (morphs(screens[current], target)) {
|
||||
document.startViewTransition(() => swap(index));
|
||||
document.startViewTransition(() => swap(at));
|
||||
return;
|
||||
}
|
||||
swap(index);
|
||||
swap(at);
|
||||
};
|
||||
|
||||
const swap = (index) => {
|
||||
@@ -1275,7 +1532,7 @@ const questions = [
|
||||
current = index;
|
||||
const id = target.dataset.screen;
|
||||
form.dataset.current = id;
|
||||
setProgress(index, target.dataset.step);
|
||||
setProgress(target);
|
||||
document.dispatchEvent(new CustomEvent('picker:screenchange', {
|
||||
detail: { screen: id },
|
||||
}));
|
||||
@@ -1284,7 +1541,9 @@ const questions = [
|
||||
|
||||
form.onclick = (e) => {
|
||||
const control = e.target.closest('[data-advance]');
|
||||
if (control) goTo(current + (control.dataset.advance === 'prev' ? -1 : 1));
|
||||
if (!control) return;
|
||||
const step = control.dataset.advance === 'prev' ? -1 : 1;
|
||||
goTo(current + step, step);
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -1309,19 +1568,10 @@ const questions = [
|
||||
items[index]?.focus();
|
||||
});
|
||||
|
||||
form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const answers = {};
|
||||
for (const [name, value] of new FormData(form)) {
|
||||
if (!(name in answers)) answers[name] = value;
|
||||
else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value];
|
||||
}
|
||||
await fetch('/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(answers),
|
||||
});
|
||||
};
|
||||
// Submission belongs to the review screen now: arriving on it triggers
|
||||
// design-context.js's finish sequence (save, then reveal the design
|
||||
// context document), so a stray native submit has nothing left to do.
|
||||
form.onsubmit = (e) => e.preventDefault();
|
||||
|
||||
copyButton.onclick = async () => {
|
||||
try {
|
||||
@@ -1339,6 +1589,6 @@ const questions = [
|
||||
};
|
||||
|
||||
form.dataset.current = screens[current].dataset.screen;
|
||||
setProgress(current, screens[current].dataset.step);
|
||||
setProgress(screens[current]);
|
||||
</script>
|
||||
</Picker>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,12 @@ const LOREM = {
|
||||
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione.',
|
||||
],
|
||||
note: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque.',
|
||||
/* Short enough to set on one line at the handset's measure, so a list item
|
||||
stays a list item there instead of becoming a third paragraph. */
|
||||
items: [
|
||||
'Excepteur sint occaecat',
|
||||
'Non proident, sunt in culpa',
|
||||
],
|
||||
caption: 'Lorem ipsum dolor sit amet, consectetur adipiscing.',
|
||||
};
|
||||
|
||||
@@ -63,6 +69,12 @@ const APP = {
|
||||
amounts: ['$12,400', '$3,860', '$9,215'],
|
||||
panel: ['Preferences', 'Last 30 days'],
|
||||
switches: ['Email digest', 'Compact rows'],
|
||||
chartTitle: 'Volume by channel',
|
||||
/* One word each, because the label under a bar has the bar's own width and
|
||||
nothing more: a category that wraps or truncates here is a fault in the
|
||||
drawing rather than a report on the pair. The handset takes the first
|
||||
three, which is why the widest of them comes early. */
|
||||
lanes: ['Direct', 'Search', 'Social', 'Email', 'Other'],
|
||||
};
|
||||
|
||||
/* The same argument as APP, for the surface where the words belong to the
|
||||
@@ -328,6 +340,11 @@ function loadFontStylesheet(pairs) {
|
||||
document.head.append(link);
|
||||
}
|
||||
|
||||
/* A board takes the slots its surface has and stops, so a list longer than the
|
||||
slots is ordinary. The other direction is a fault in the markup, and it is
|
||||
left blank here rather than hidden: a hidden slot would leave the board
|
||||
drawing one item fewer than the composition its grid was measured at, and
|
||||
the blank is what makes the miscount visible. */
|
||||
function fillIndexed(root, selector, values) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll(selector).forEach((node, index) => {
|
||||
@@ -366,14 +383,17 @@ function fillBoard(board, preview, specimen) {
|
||||
fill('[data-type-note-body]', LOREM.note);
|
||||
fill('[data-type-crumb]', DOCS.crumb);
|
||||
fill('[data-type-caption]', LOREM.caption);
|
||||
fill('[data-type-chart-title]', APP.chartTitle);
|
||||
for (const card of [desktop, phoneBody]) {
|
||||
fillIndexed(card, '[data-type-nav]', preview.nav);
|
||||
fillIndexed(card, '[data-type-proof]', preview.proof);
|
||||
fillIndexed(card, '[data-type-gallery-title]', preview.gallery.map(({ title }) => title));
|
||||
fillIndexed(card, '[data-type-gallery-meta]', preview.gallery.map(({ meta }) => meta));
|
||||
fillIndexed(card, '[data-type-passage]', LOREM.passages);
|
||||
fillIndexed(card, '[data-type-item]', LOREM.items);
|
||||
fillIndexed(card, '[data-type-stop]', INDEX.stops);
|
||||
fillIndexed(card, '[data-type-rail]', rail);
|
||||
fillIndexed(card, '[data-type-lane]', APP.lanes);
|
||||
fillIndexed(card, '[data-type-column]', APP.columns);
|
||||
fillIndexed(card, '[data-type-figure]', APP.figures);
|
||||
fillIndexed(card, '[data-type-amount]', APP.amounts);
|
||||
@@ -804,7 +824,12 @@ commitIconPack(checkedIconPack());
|
||||
Only a change of energy restarts it. Sliding the pointer across a row it is
|
||||
already previewing would otherwise keep the page in a permanent entrance. */
|
||||
const motionOptions = document.querySelector('[data-question="motion"] .picker-strategy-choices');
|
||||
const motionScene = document.querySelector('.picker-preview-motion');
|
||||
// One board per surface the question is put to. All of them are mounted and one
|
||||
// is shown, so the replay covers every board rather than the visible one: a
|
||||
// hidden board's timeline is cancelled by its own display: none and starts over
|
||||
// when its tab is opened, and the two must not disagree about which frame is
|
||||
// first.
|
||||
const motionScenes = [...document.querySelectorAll('.picker-preview-motion')];
|
||||
const checkedMotion = () => motionOptions.querySelector('input:checked').value;
|
||||
let motionShown;
|
||||
|
||||
@@ -813,51 +838,67 @@ function replayMotion(energy) {
|
||||
motionShown = energy;
|
||||
// The hover rules resolve on their own; this only puts the timeline back to
|
||||
// its first frame, pseudo-elements and all.
|
||||
for (const animation of motionScene.getAnimations({ subtree: true })) {
|
||||
animation.cancel();
|
||||
animation.play();
|
||||
for (const scene of motionScenes) {
|
||||
for (const animation of scene.getAnimations({ subtree: true })) {
|
||||
animation.cancel();
|
||||
animation.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The restrained scene's pointer route is written in container units, but the
|
||||
nav bars space themselves in fixed pixels, so where a given bar sits as a
|
||||
/* Every scene's pointer route is written in container units, but the elements
|
||||
it visits space themselves in fixed pixels, so where a given one sits as a
|
||||
fraction of the frame changes with the frame's size. The route is measured
|
||||
off the live layout instead: one custom property per stop, re-resolved
|
||||
whenever the frame resizes, so the pointer lands on the element that
|
||||
reacts at every viewport. offsetLeft rather than getBoundingClientRect,
|
||||
because the entrance animations translate the bands, and a route measured
|
||||
mid-entrance would aim below the nav. */
|
||||
const motionDesk = motionScene.querySelector('.ps-desktop');
|
||||
reacts at every viewport.
|
||||
|
||||
function plotMotionRoute() {
|
||||
if (!motionDesk.clientWidth) return;
|
||||
// Summed up the offsetParent chain rather than read once: an element's
|
||||
// offsets are relative to its nearest positioned ancestor, which for the
|
||||
// buttons is not the frame.
|
||||
const center = (el) => {
|
||||
let x = el.offsetWidth / 2;
|
||||
let y = el.offsetHeight / 2;
|
||||
for (let node = el; node && node !== motionDesk; node = node.offsetParent) {
|
||||
x += node.offsetLeft;
|
||||
y += node.offsetTop;
|
||||
The stops a board does not have are simply not set, which is what lets one
|
||||
table serve both boards: the landing page's scenes visit its buttons and
|
||||
cards, the portfolio's visit its plates and its carousel arrow, and neither
|
||||
keyframe list names a property its own board cannot measure. */
|
||||
const MOTION_STOPS = {
|
||||
'--mtr-nav1': '.ps-nav-bars i:nth-child(1)',
|
||||
'--mtr-nav2': '.ps-nav-bars i:nth-child(2)',
|
||||
'--mtr-cta1': '.ps-actions i:first-child',
|
||||
'--mtr-cta2': '.ps-actions i:last-child',
|
||||
'--mtr-card1': '.ps-gallery-item:nth-child(1) > i',
|
||||
'--mxi-work1': '.ps-index-row:nth-of-type(1) > .ps-image',
|
||||
'--mxi-work2': '.ps-index-row:nth-of-type(2) > .ps-image',
|
||||
'--mxi-rail': '.ps-index-arrow--next',
|
||||
};
|
||||
|
||||
function plotMotionRoute(scene = null) {
|
||||
for (const board of scene ? [scene] : motionScenes) {
|
||||
const desk = board.querySelector('.ps-desktop');
|
||||
if (!desk?.clientWidth) continue;
|
||||
// Summed up the offsetParent chain rather than read once: an element's
|
||||
// offsets are relative to its nearest positioned ancestor, which for the
|
||||
// buttons is not the frame.
|
||||
const center = (el) => {
|
||||
let x = el.offsetWidth / 2;
|
||||
let y = el.offsetHeight / 2;
|
||||
for (let node = el; node && node !== desk; node = node.offsetParent) {
|
||||
x += node.offsetLeft;
|
||||
y += node.offsetTop;
|
||||
}
|
||||
return { x: (x / desk.clientWidth) * 100, y: (y / desk.clientHeight) * 100 };
|
||||
};
|
||||
for (const [name, selector] of Object.entries(MOTION_STOPS)) {
|
||||
const el = desk.querySelector(selector);
|
||||
if (!el) continue;
|
||||
const c = center(el);
|
||||
board.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`);
|
||||
}
|
||||
return { x: (x / motionDesk.clientWidth) * 100, y: (y / motionDesk.clientHeight) * 100 };
|
||||
};
|
||||
const stop = (name, el) => {
|
||||
const c = center(el);
|
||||
motionScene.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`);
|
||||
};
|
||||
const nav1 = motionDesk.querySelector('.ps-nav-bars i:nth-child(1)');
|
||||
stop('--mtr-nav1', nav1);
|
||||
stop('--mtr-nav2', motionDesk.querySelector('.ps-nav-bars i:nth-child(2)'));
|
||||
stop('--mtr-cta1', motionDesk.querySelector('.ps-actions i:first-child'));
|
||||
stop('--mtr-cta2', motionDesk.querySelector('.ps-actions i:last-child'));
|
||||
stop('--mtr-card1', motionDesk.querySelector('.ps-gallery-item:nth-child(1) > i'));
|
||||
// The entry and exit point: straight above the first nav item, off-frame.
|
||||
motionScene.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`);
|
||||
// The entry and exit point: straight above the first nav item, off-frame.
|
||||
const nav1 = desk.querySelector('.ps-nav-bars i:nth-child(1)');
|
||||
board.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`);
|
||||
}
|
||||
}
|
||||
|
||||
new ResizeObserver(plotMotionRoute).observe(motionDesk);
|
||||
for (const scene of motionScenes) {
|
||||
new ResizeObserver(() => plotMotionRoute(scene)).observe(scene.querySelector('.ps-desktop'));
|
||||
}
|
||||
|
||||
const motionRowValue = (node) => node?.closest('.picker-strategy-option')?.querySelector('input').value;
|
||||
|
||||
@@ -1318,6 +1359,7 @@ const moves = new WeakMap();
|
||||
// result has to be carried.
|
||||
const paletteBands = $('.picker-bands', panel);
|
||||
const strategyBands = document.querySelector('[data-band-scope="strategy"]');
|
||||
const strategyGrid = document.querySelector('.picker-screen[data-screen="03"] .picker-strategy-grid');
|
||||
const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const bandNodes = (scope) => ROLES.map((role) => $(`[data-band="${role}"]`, scope));
|
||||
const gripNodes = (scope) => ROLES.map((role) => $(`[data-grip="${role}"]`, scope));
|
||||
@@ -1435,6 +1477,35 @@ function paintStrategyBands() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Everything screen 03 spends on something other than the answer: the block's
|
||||
own padding, the question over the grid, and the gap between them. The grid
|
||||
caps itself against what is left, so this is the number that decides whether
|
||||
the screen fits the window.
|
||||
|
||||
Measured as the difference between the block and the grid inside it rather
|
||||
than added up from the parts, because the parts are not knowable from here:
|
||||
the question is one or two lines at a size that tracks the window's width. It
|
||||
comes to between 259 and 312px across the laptop range. The literal in the
|
||||
rule was 250, short by 9 to 62, and the screen paid the difference by running
|
||||
past the fold. Nothing here reads the grid's height back into itself, so the
|
||||
value settles on the first pass. */
|
||||
function fitStrategyColumn() {
|
||||
const block = strategyGrid?.parentElement;
|
||||
if (!block || !block.offsetParent) return;
|
||||
const chrome = `${Math.ceil(
|
||||
block.getBoundingClientRect().height - strategyGrid.getBoundingClientRect().height,
|
||||
)}px`;
|
||||
// Writing an unchanged value would still be a style change, and the resize it
|
||||
// provokes is what turns a settling measurement into a loop.
|
||||
if (block.style.getPropertyValue('--pk-chrome') === chrome) return;
|
||||
block.style.setProperty('--pk-chrome', chrome);
|
||||
}
|
||||
|
||||
/* The block's height is the only thing that can move what sits above the grid,
|
||||
so it is what is watched: a question that reflowed to a second line and a
|
||||
window that moved the padding clamp both arrive here. */
|
||||
if (strategyGrid) new ResizeObserver(fitStrategyColumn).observe(strategyGrid.parentElement);
|
||||
|
||||
function reorderedSummary(from, to) {
|
||||
const { colors } = state();
|
||||
return slotOrder(from, to)
|
||||
@@ -1675,8 +1746,13 @@ document.addEventListener('picker:screenchange', (event) => {
|
||||
syncCommittedPalette(artboard);
|
||||
}
|
||||
// Coming back to the strategy screen from further along, where the palette may
|
||||
// have been reordered on the screen it was left on.
|
||||
if (event.detail.screen === '03') paintStrategyBands();
|
||||
// have been reordered on the screen it was left on. A hidden block measures
|
||||
// zero, so its budget is resolved on arrival for the same reason the scale
|
||||
// sheet's fit is.
|
||||
if (event.detail.screen === '03') {
|
||||
paintStrategyBands();
|
||||
fitStrategyColumn();
|
||||
}
|
||||
// A per-surface question holds whatever tab it was left on while its own
|
||||
// screen is up, and the rest follow it.
|
||||
const leader = surfaceQuestions.find((question) => question.screen === event.detail.screen);
|
||||
@@ -1745,9 +1821,8 @@ const syncModesNext = () => {
|
||||
First in tile order, not first clicked. A multi-select answer has no other
|
||||
stable primary, and click order would move the palette's test page around
|
||||
for reasons the visitor cannot see. */
|
||||
const modePreviews = modeInputs.map((input) => (
|
||||
input.closest('.picker-mode-tile')?.querySelector('.picker-preview')
|
||||
));
|
||||
const modeTiles = modeInputs.map((input) => input.closest('.picker-mode-tile'));
|
||||
const modePreviews = modeTiles.map((tile) => tile?.querySelector('.picker-preview'));
|
||||
const landingPreview = preview.cloneNode(true);
|
||||
let previewSource;
|
||||
|
||||
@@ -1756,6 +1831,14 @@ function syncModePreview() {
|
||||
// are rebuilt from here: every path that changes the tiles already runs this.
|
||||
syncSurfaces();
|
||||
const chosen = modeInputs.findIndex((input) => input.checked);
|
||||
/* Which tile leads is also what the stylesheet needs, to hang the view
|
||||
transition's name on the one drawing screen 02 goes on to show. Marked
|
||||
ahead of the early return below, so the answer never rests on whether the
|
||||
drawing itself changed, and left off a tile whose drawing is not this
|
||||
component, since nothing of that tile arrives on the next screen. */
|
||||
modeTiles.forEach((tile, index) => {
|
||||
tile?.toggleAttribute('data-lead', index === chosen && Boolean(modePreviews[index]));
|
||||
});
|
||||
// A tile drawn in something other than this component keeps the landing page,
|
||||
// which is also the floor for the empty answer the continue button blocks.
|
||||
const source = (chosen === -1 ? null : modePreviews[chosen]) ?? landingPreview;
|
||||
@@ -1787,7 +1870,7 @@ function syncModePreview() {
|
||||
data-surface-answered="colored {}"
|
||||
data-surface-unanswered="no color strategy chosen yet" hidden>
|
||||
|
||||
inside the box that draws the frame, plus one disabled hidden field per
|
||||
inside the stage the frame is drawn on, plus one disabled hidden field per
|
||||
surface marked data-surface-field="<group>-<surface>". 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
|
||||
@@ -1796,6 +1879,14 @@ function syncModePreview() {
|
||||
data-default-<group>; 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
|
||||
@@ -1808,9 +1899,9 @@ const surfaceInput = (value) => modeInputs.find((input) => input.value === value
|
||||
|
||||
function buildSurfaceQuestion(tabs) {
|
||||
const name = tabs.dataset.surfaceTabs;
|
||||
// The frame is the strip's own positioned ancestor, which 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.
|
||||
// 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;
|
||||
@@ -1832,6 +1923,14 @@ function buildSurfaceQuestion(tabs) {
|
||||
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,
|
||||
@@ -1861,7 +1960,15 @@ function buildSurfaceQuestion(tabs) {
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const chosen = chosenSurfaces();
|
||||
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,
|
||||
@@ -1887,8 +1994,9 @@ function buildSurfaceQuestion(tabs) {
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
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) => {
|
||||
@@ -1904,19 +2012,30 @@ function buildSurfaceQuestion(tabs) {
|
||||
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;
|
||||
const title = titleOf(field.value);
|
||||
tab.setAttribute('aria-label', set
|
||||
? `${tab.textContent}, ${tabs.dataset.surfaceAnswered.replace('{}', properName ? title : title.toLowerCase())}`
|
||||
: `${tab.textContent}, ${tabs.dataset.surfaceUnanswered}`);
|
||||
/* 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1973,7 +2092,7 @@ function buildSurfaceQuestion(tabs) {
|
||||
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 ? chosenSurfaces().map((input) => input.value) : [activeSurface];
|
||||
const surfaces = flat ? applicable().map((input) => input.value) : [activeSurface];
|
||||
for (const surface of surfaces) {
|
||||
const field = fieldFor(surface);
|
||||
const allowed = allowedFor(surface);
|
||||
@@ -2000,8 +2119,14 @@ function buildSurfaceQuestion(tabs) {
|
||||
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. */
|
||||
const park = (surface) => show(surface || chosenSurfaces()[0]?.value);
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1169
-131
File diff suppressed because it is too large
Load Diff
@@ -115,3 +115,90 @@
|
||||
[data-carry] .ps-phone-body .ps-gallery-item {
|
||||
padding: calc(var(--pvs-panel-pad, 0px) * 0.5);
|
||||
}
|
||||
|
||||
/* ── The three region bodies ──────────────────────────────────
|
||||
A shell, a document, and an index are separated in different places from a
|
||||
landing page, so the same four answers are spent on different parts. What is
|
||||
shared is the material: the answers above set a hairline, a ground, or an edge,
|
||||
and each body below reads the one it has a use for.
|
||||
|
||||
The dividing rules on these bodies were hardcoded hairlines, which is the
|
||||
thing this screen is asking about. A shell with a permanent rule down its rail
|
||||
answers Open space with a line still on screen. */
|
||||
[data-carry] :is(.ps-ops-rail, .ps-ops-main, .ps-docs-rail) {
|
||||
border-right: var(--pvs-divider-w) solid var(--pvs-divider-c);
|
||||
}
|
||||
|
||||
/* A table is a run of sections stacked, so the hairline answer rules it. The
|
||||
plot's own baseline is not in here: an axis is part of the chart's drawing and
|
||||
not a boundary between two things. */
|
||||
[data-carry] .ps-ops-table {
|
||||
border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
|
||||
}
|
||||
|
||||
[data-carry] .ps-ops-row + .ps-ops-row {
|
||||
border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
|
||||
}
|
||||
|
||||
[data-carry] .ps-index-row + .ps-index-row,
|
||||
[data-carry] .ps-index-rail {
|
||||
border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
|
||||
}
|
||||
|
||||
[data-carry] :is(.ps-index-row + .ps-index-row, .ps-index-rail) {
|
||||
padding-top: calc(var(--pvs-divider-w) * 8);
|
||||
}
|
||||
|
||||
/* Grounds. Each one fills its own region and stops at the page margin rather than
|
||||
bleeding to the frame edge. That is the same claim the layout screen's header
|
||||
makes about these boards: their regions sit on the page grid, so the rail's
|
||||
ground starts where the rail starts, on the first column line. Reaching the
|
||||
edge would need the margin restated as a share of a different box, and the two
|
||||
percentages do not resolve against the same width, so the tone would land a
|
||||
point or two off the very line the board is drawn to check. */
|
||||
[data-carry] :is(.ps-ops-rail, .ps-ops-panel, .ps-docs-rail) {
|
||||
background-color: var(--pvs-band);
|
||||
}
|
||||
|
||||
/* Alternating grounds are what a run of entries has instead of bands: the second
|
||||
entry sits on a different tone from the first, and the boundary is where the
|
||||
tone changes. */
|
||||
[data-carry] .ps-index-row--flip {
|
||||
background-color: var(--pvs-band-alt);
|
||||
}
|
||||
|
||||
/* A table's heading row is the one place on a shell where a change of ground
|
||||
does the whole job a rule would have done. */
|
||||
[data-carry] .ps-ops-row--head {
|
||||
background-color: var(--pvs-band-alt);
|
||||
}
|
||||
|
||||
/* The callout is the document's only container, so it is where this screen's
|
||||
answer lands. Its accent edge is not part of the answer: that is what marks
|
||||
the block as a callout, and it stays under all three. */
|
||||
[data-carry] .ps-docs-note {
|
||||
padding: calc(var(--pt-base) * 0.7) calc(var(--pt-base) * 0.9);
|
||||
background-color: var(--pvs-band-alt);
|
||||
border-left: calc(var(--pt-base) * 0.25) solid var(--pvs-accent-c);
|
||||
}
|
||||
|
||||
/* Cards, on a tool. Offered to Persuade and Operate only, so a shell is the one
|
||||
region body that draws it: the chart, the table, and the side panel become
|
||||
objects on a ground, and the rules that had been dividing them go, because an
|
||||
edge and a hairline saying the same thing twice is the fault the answer after
|
||||
Open space is meant to avoid. */
|
||||
[data-carry] :is(.ps-ops-chart, .ps-ops-table, .ps-ops-panel) {
|
||||
background-color: var(--pvs-panel);
|
||||
border: var(--pvs-panel-edge-w) solid var(--pvs-panel-edge);
|
||||
}
|
||||
|
||||
[data-carry] :is(.ps-ops-chart, .ps-ops-table) {
|
||||
padding: var(--pvs-panel-pad, 0px);
|
||||
}
|
||||
|
||||
/* An open rail plate is a state and reads as one already, so under the card
|
||||
answer the rest of the rail joins it rather than the plate growing an edge. */
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="boundary-style"])):has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-item,
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="boundary-style"][value="cards-and-panels"]) .picker-preview-boundaries .ps-ops-item {
|
||||
background-color: var(--pvs-panel);
|
||||
}
|
||||
|
||||
@@ -80,3 +80,42 @@
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] :is(.ps-nav, .ps-phone-top) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── The same rule, on the three region bodies ────────────────
|
||||
Each part is asked the same question the card above was: does the boundary
|
||||
answer leave a ground under it. The answer differs part by part, because a
|
||||
shell's regions, a document's callout, and an index's alternating band are
|
||||
each filled by a different one of the four answers.
|
||||
|
||||
A rail and a document's rail take a ground only where sections are told apart
|
||||
by tone, so that is the only answer they lift under. The side panel is filled
|
||||
by that answer and by the card answer, so it lifts under both. */
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] :is(.ps-ops-rail, .ps-docs-rail) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)):not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-panel {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The chart and the table are containers only under the card answer. Under the
|
||||
others the lift moves inward to the plot's bars, which are the one part of a
|
||||
chart that is a surface in its own right, exactly as the landing page's card
|
||||
hands its lift to the picture inside it. */
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] :is(.ps-ops-chart, .ps-ops-table) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-plot > i {
|
||||
box-shadow: var(--pvs-shadow-card);
|
||||
}
|
||||
|
||||
/* The callout and the index's second entry are both filled by the tone answer
|
||||
alone, so both are held to it. */
|
||||
#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] .ps-docs-note {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#picker-form:has(input[name="boundary-style"][value="surface-changes"]:checked) [data-carry] .ps-index-row--flip {
|
||||
box-shadow: var(--pvs-shadow-card);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,49 @@
|
||||
plus boundaries, corners, and depth, so each later question is asked about
|
||||
the page as it has been chosen so far. The measure itself does not travel:
|
||||
it is an instrument for this question, drawn only where it is asked.
|
||||
|
||||
── One instrument, four pages ──────────────────────────────
|
||||
This screen now draws a board per surface: a landing page, an app shell, a
|
||||
document, an index. The paragraph below used to end by saying the instrument
|
||||
is the same one under all three answers and what moves is the page standing on
|
||||
it. That reading is kept, and it decides how a surface is handled.
|
||||
|
||||
The argument was never about answers in particular. It is that a measure must
|
||||
not be cut from the thing it is measuring, because then the thing and the
|
||||
ruler agree by construction and the ruler says nothing. A surface is another
|
||||
thing being measured. Re-cutting the field per surface would fail in exactly
|
||||
the same way and lose something the single field buys: with one origin and one
|
||||
pitch under all four, an answer can be compared across them, and a dashboard
|
||||
whose regions sit on the same lines a landing page's blocks sit on is making a
|
||||
claim you can check.
|
||||
|
||||
So the field is unchanged. Six and six, twelve columns, one pitch, on every
|
||||
board. What is now per surface is the body standing on it, and the answer is
|
||||
read where each body actually keeps its proportion:
|
||||
|
||||
- the landing page, in the spans its hero and its card row take;
|
||||
- the app shell, in where the rail, the working column, and the panel divide;
|
||||
- the document, in how wide the measure runs beside its rail;
|
||||
- the index, in how each entry splits between the work and its caption.
|
||||
|
||||
Two consequences worth stating, because both look like oversights otherwise.
|
||||
|
||||
The three region bodies pin their margin to the field's own six rather than to
|
||||
the answer's page gutter. On the landing page the margin *is* part of the
|
||||
answer, which is why Balanced keeps the artboard's 5.5 and 3.2 and leans; a
|
||||
shell's regions are checked against the lines, and a rail three pixels shy of
|
||||
one reads as a mistake rather than as a lean. The margin on those boards is
|
||||
the field's, and the answer moves what is inside it.
|
||||
|
||||
And a region's dividing rule sits down the middle of a gutter rather than
|
||||
against one edge, which is why the tracks below add half a gutter and a whole
|
||||
one. A page's blocks are told apart by air and stop at column edges; a shell's
|
||||
regions abut and are told apart by a line. Both land on the drawn field, in
|
||||
the two different ways the drawn field can be met.
|
||||
|
||||
Freeform is offered to Persuade and Experience only (see
|
||||
picker/data/surfaces.js). Operate and Read rule it out, so there is no
|
||||
freeform shell and no freeform document here, and none is drawn.
|
||||
============================================================ */
|
||||
|
||||
/* ── The measure ─────────────────────────────────────────────
|
||||
@@ -51,8 +94,12 @@
|
||||
A drawn grid makes it obvious when only some rows honor the same margin, so
|
||||
chrome and the section band are brought onto the one measure. Scoped to the
|
||||
structural artboards: screens 03 and 04 are judging color and glyphs, and
|
||||
their page is not under a ruler. */
|
||||
[data-carry]:not(.picker-preview-layout) :is(.ps-nav, .ps-editorial, .ps-footer) {
|
||||
their page is not under a ruler.
|
||||
|
||||
The landing board only. The nav and the footer are shared chrome, and on the
|
||||
three region boards they take the field's margin on both sides along with the
|
||||
body under them, for the reason in the header. */
|
||||
[data-carry][data-surface="persuade"]:not(.picker-preview-layout) :is(.ps-nav, .ps-editorial, .ps-footer) {
|
||||
padding-right: var(--pvs-gutter-end);
|
||||
}
|
||||
|
||||
@@ -86,8 +133,9 @@
|
||||
that re-fits itself to whatever page is on it is not a measure: the origin
|
||||
and the column width would both follow the answer, and three pages each
|
||||
checked against a ruler cut to fit would be three pages checked against
|
||||
nothing. The instrument is the same one under all three. What moves is the
|
||||
page standing on it, which is the whole of what this screen is asking.
|
||||
nothing. The instrument is the same one under every answer and on every
|
||||
surface. What moves is the page standing on it, which is the whole of what
|
||||
this screen is asking.
|
||||
|
||||
Balanced is the answer that still disagrees. Its page is the shared
|
||||
artboard's own 5.5 and 3.2 margins, so a field cut from those numbers would
|
||||
@@ -234,9 +282,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout .ps-phone-body,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) .picker-preview-layout .ps-phone-body {
|
||||
grid-template-rows: 31% auto auto auto 34px minmax(0, 1fr);
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout[data-surface="persuade"] .ps-phone-body,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) .picker-preview-layout[data-surface="persuade"] .ps-phone-body {
|
||||
grid-template-rows: minmax(0, 31%) auto auto auto 34px minmax(min-content, 1fr);
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout .ps-phone-body .ps-gallery,
|
||||
@@ -384,7 +432,7 @@
|
||||
angular cut, running uphill to the right like the desktop shape. The cut
|
||||
depth rides the flag inside the polygon, so the other answers keep a
|
||||
rectangle. Everything else on the handset stays on its lines. */
|
||||
[data-carry] .ps-phone-body > .ps-image {
|
||||
[data-carry][data-surface="persuade"] .ps-phone-body > .ps-image {
|
||||
clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--pvs-drift, 0) * 16%), 0 100%);
|
||||
}
|
||||
|
||||
@@ -403,3 +451,117 @@
|
||||
[data-carry]:not(.picker-preview-layout) .ps-phone-body .ps-gallery {
|
||||
grid-template-columns: var(--pvs-phone-gallery-cols, 1.05fr 0.95fr);
|
||||
}
|
||||
|
||||
/* ── The regions, per surface ────────────────────────────────
|
||||
One block per answer, and each one says the same thing four ways because that
|
||||
is the point of the four boards: the answer is a claim about proportion, and
|
||||
proportion lives somewhere different on each kind of page.
|
||||
|
||||
The tracks are whole counts of the drawn field's columns. A number is written
|
||||
as the span variable plus the gutter arithmetic that lands its edge on a line,
|
||||
never as the percentage that comes out, so a reader can check the count: the
|
||||
shell below is two, seven, and three, and it says so.
|
||||
|
||||
Both selectors, as everywhere on this screen: the hover preview, then the
|
||||
committed answer. Unlike the landing blocks above, the two carry the same
|
||||
values. A region's proportion is the answer, so there is no separate reading
|
||||
of it to preview. */
|
||||
|
||||
/* ── Simple grid, on the three region bodies ─────────────────
|
||||
Even shares and one rule for the whole board. The shell divides two, seven,
|
||||
three, which is the count a tool falls into when nothing is being emphasised.
|
||||
The document runs its measure to the end of the page beside a three-column
|
||||
rail. The index splits every entry six and six and stops alternating, because
|
||||
two entries mirrored are two different rhythms and this is the answer with
|
||||
one. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry],
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] {
|
||||
--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);
|
||||
}
|
||||
|
||||
/* The stagger is placement rather than order, so undoing it is placement too:
|
||||
both entries put the work first and the caption second. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry] .ps-index-row--flip > .ps-image,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] .ps-index-row--flip > .ps-image {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry] .ps-index-row--flip > .ps-index-cap,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] .ps-index-row--flip > .ps-index-cap {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
|
||||
/* ── Balanced, on the three region bodies ────────────────────
|
||||
The same field spent unevenly, and each body spends it on the block that
|
||||
carries the emphasis. The shell gives the working column a column off the
|
||||
panel and lets its lead figure take more of the metrics row than its
|
||||
neighbours. The document is the clearest of the three: the measure stops three
|
||||
columns short of the page instead of running to the edge, which is the choice
|
||||
a considered document actually makes and the one a strict grid will not.
|
||||
The index leans each entry seven and five, and keeps the alternation, so the
|
||||
run reads as a rhythm rather than a column. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="balanced"]) [data-carry],
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="balanced"]:checked) [data-carry] {
|
||||
--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);
|
||||
}
|
||||
|
||||
/* ── Freeform, on the index ──────────────────────────────────
|
||||
Offered to Persuade and Experience only, so this is the one region body that
|
||||
draws it. Same reading as the landing page's: the composition sits on the
|
||||
lines, and then one thing is granted permission to leave them. On an index
|
||||
that thing is the work itself, set down at a slight angle like a print laid on
|
||||
a table, with the caption stepping onto the corner it left. Held next to the
|
||||
drawn field it is teasing, each move reads as a decision.
|
||||
|
||||
The plates lean opposite ways so the run has a beat, and the angle is under
|
||||
two degrees: a print laid down askew, not a page falling over. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry],
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry] {
|
||||
--pvs-entry-cols: var(--pvs-w7) minmax(0, 1fr);
|
||||
--pvs-entry-flip-cols: minmax(0, 1fr) var(--pvs-w7);
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row > .ps-image,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row > .ps-image {
|
||||
rotate: -1.7deg;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-image,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-image {
|
||||
rotate: 1.4deg;
|
||||
}
|
||||
|
||||
/* The caption crosses the gutter onto the plate's corner, which is the move the
|
||||
landing page makes with its buttons. It stays above the plate it is crossing,
|
||||
and it is the caption that moves rather than the work, because a caption
|
||||
overlapping a photograph is a printed page's own habit. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-cap,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-cap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
translate: -9% 0;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-index-cap,
|
||||
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-index-cap {
|
||||
translate: 9% 0;
|
||||
}
|
||||
|
||||
@@ -1249,6 +1249,553 @@
|
||||
87.5%, 100% { opacity: 0; translate: 0 0; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
The portfolio board, and its three scenes.
|
||||
|
||||
A landing page and a portfolio spend movement on different things, so the
|
||||
two surfaces this question is put to are shown as two pages rather than as
|
||||
one page relabelled. This board is the index the font screen already draws,
|
||||
in bars: a page title, two staggered entries with a caption ladder beside
|
||||
each, and the carousel rail under them.
|
||||
|
||||
Everything the two boards have in common is drawn once. The frame, the nav
|
||||
and its dropdown, the pointer, the click ring, and the loop curtain all key
|
||||
on the class both boards carry, so the nav station of every scene, the
|
||||
curtain, and the nav's own entrance arrive here already written. What
|
||||
follows is the part a portfolio does differently: the work is the interface,
|
||||
so the stations that are not chrome are plates, captions, and the carousel.
|
||||
|
||||
The three scenes keep the landing page's timings to the frame, because the
|
||||
two are compared by moving between tabs on one screen and a portfolio that
|
||||
ran to a different clock would read as a different answer. Restrained keeps
|
||||
its four stations and its 3.8s, responsive its three and its 4.2s,
|
||||
choreographed its 5.6s reveal.
|
||||
|
||||
These rules are no more specific than the landing page's and come after
|
||||
them, so source order is what settles the elements the two boards share.
|
||||
Anything added here has to stay after them.
|
||||
============================================================ */
|
||||
.picker-preview-motion--index {
|
||||
/* Three bands rather than five: the nav, the index, the footer. */
|
||||
--pvs-rows: 9.4% minmax(0, 1fr) 8.3%;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index {
|
||||
gap: 13px;
|
||||
padding: 2.4% var(--pvs-gutter-end) 2.4% var(--pvs-gutter);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-head {
|
||||
display: block;
|
||||
width: 26%;
|
||||
height: 13px;
|
||||
background-color: var(--pvs-headline);
|
||||
border-radius: var(--pvs-radius-bar);
|
||||
}
|
||||
|
||||
/* The plate clips because the caption band the scenes bring up starts below
|
||||
its bottom edge. */
|
||||
.picker-preview-motion--index .ps-index-row > .ps-image {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The view state: a caption band rising over the foot of the plate, which is
|
||||
what a portfolio index answers a pointer with. The plate keeps its own
|
||||
radius on the two corners it shares with the band. */
|
||||
.picker-preview-motion--index .ps-index-row > .ps-image::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: 26%;
|
||||
background-color: var(--pvs-cta);
|
||||
border-radius: 0 0 var(--pvs-radius-surface) var(--pvs-radius-surface);
|
||||
opacity: 0;
|
||||
translate: 0 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-cap {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-name {
|
||||
display: block;
|
||||
width: 66%;
|
||||
height: 11px;
|
||||
background-color: var(--pvs-title);
|
||||
border-radius: var(--pvs-radius-bar);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-tag {
|
||||
display: block;
|
||||
width: 32%;
|
||||
height: 5px;
|
||||
background-color: var(--pvs-bars);
|
||||
border-radius: var(--pvs-radius-bar);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-lines {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-lines b {
|
||||
display: block;
|
||||
width: 76%;
|
||||
height: 6px;
|
||||
background-color: var(--pvs-copy);
|
||||
border-radius: var(--pvs-radius-bar);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-lines b:last-child {
|
||||
width: 54%;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-arrow {
|
||||
width: 7px;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-track {
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-track b {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 5px;
|
||||
background-color: var(--pvs-bars);
|
||||
border-radius: var(--pvs-radius-bar);
|
||||
}
|
||||
|
||||
/* The track is in the selector so the current stop outranks the row it sits
|
||||
in, which is scoped one element deeper. */
|
||||
.picker-preview-motion--index .ps-index-track .ps-index-stop--on {
|
||||
background-color: var(--pvs-cta);
|
||||
}
|
||||
|
||||
/* ── Restrained on the index: four stations, no travel but the pointer ──
|
||||
The two nav items are the landing page's own first two stations and arrive
|
||||
with them. The two that follow are plates: the caption band is present on
|
||||
the frame the pointer earns it and gone on the frame it leaves, the title
|
||||
takes the accent with it, and the plate's corner mark answers as well. The
|
||||
first plate resets the instant the pointer is off it, so no two entries are
|
||||
ever both showing their caption. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-cursor,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-cursor {
|
||||
animation: mxr-path 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
|
||||
animation: mxr-band-1 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
|
||||
animation: mxr-name-1 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
|
||||
animation: mxr-mark-1 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::before,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::before {
|
||||
animation: mxr-band-2 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-name,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-name {
|
||||
animation: mxr-name-2 3.8s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::after,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::after {
|
||||
animation: mxr-mark-2 3.8s linear infinite;
|
||||
}
|
||||
|
||||
/* The landing page's own restrained stops, with the two plates standing in for
|
||||
its two buttons: in 0-0.35, first nav 0.35-0.95, second 1.25-1.55, first
|
||||
plate 2.20-2.85, second 3.15-3.45, out by 3.67. */
|
||||
@keyframes mxr-path {
|
||||
0% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
|
||||
9.21%, 25% { translate: var(--mtr-nav1, 64.8cqw 4.8cqh); }
|
||||
32.89%, 40.79% { translate: var(--mtr-nav2, 71.4cqw 4.8cqh); }
|
||||
57.89%, 75% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
|
||||
82.89%, 90.79% { translate: var(--mxi-work2, 74.4cqw 60.9cqh); }
|
||||
96.5%, 100% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
|
||||
}
|
||||
|
||||
@keyframes mxr-band-1 {
|
||||
0%, 57.88% { opacity: 0; translate: 0 100%; }
|
||||
57.89%, 74.99% { opacity: 1; translate: 0 0; }
|
||||
75%, 100% { opacity: 0; translate: 0 100%; }
|
||||
}
|
||||
|
||||
@keyframes mxr-name-1 {
|
||||
0%, 57.88% { background-color: var(--pvs-title); }
|
||||
57.89%, 74.99% { background-color: var(--pvs-cta); }
|
||||
75%, 100% { background-color: var(--pvs-title); }
|
||||
}
|
||||
|
||||
@keyframes mxr-mark-1 {
|
||||
0%, 57.88% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
57.89%, 74.99% { background-color: var(--pvs-cta); scale: 1.4; }
|
||||
75%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxr-band-2 {
|
||||
0%, 82.88% { opacity: 0; translate: 0 100%; }
|
||||
82.89%, 90.78% { opacity: 1; translate: 0 0; }
|
||||
90.79%, 100% { opacity: 0; translate: 0 100%; }
|
||||
}
|
||||
|
||||
@keyframes mxr-name-2 {
|
||||
0%, 82.88% { background-color: var(--pvs-title); }
|
||||
82.89%, 90.78% { background-color: var(--pvs-cta); }
|
||||
90.79%, 100% { background-color: var(--pvs-title); }
|
||||
}
|
||||
|
||||
@keyframes mxr-mark-2 {
|
||||
0%, 82.88% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
82.89%, 90.78% { background-color: var(--pvs-cta); scale: 1.4; }
|
||||
90.79%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
}
|
||||
|
||||
/* ── Responsive on the index: three stations, each answered ──
|
||||
The nav station and its staggered dropdown are the landing page's and run
|
||||
here unchanged. The second is the first plate: the caption band rises over
|
||||
its foot in 200ms, the title recolors and the corner mark answers with it.
|
||||
The third is the carousel, which is the one control only this board has: the
|
||||
next arrow turns, the track shifts under it, and the stop that was current
|
||||
hands over to the next one.
|
||||
|
||||
A carousel that snapped back the moment the pointer left would be reporting
|
||||
a state nobody put it in, so the advance is the one thing here that stays.
|
||||
It returns across the single frame the pointer teleports off-frame on, which
|
||||
is also what makes 100% equal 0% and the loop seamless. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-cursor,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-cursor {
|
||||
animation: mxv-path 4.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
|
||||
animation: mxv-band 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
|
||||
animation: mxv-name 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
|
||||
animation: mxv-mark 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-arrow--next,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-arrow--next {
|
||||
animation: mxv-arrow 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track {
|
||||
animation: mxv-track 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track b:nth-child(1),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(1) {
|
||||
animation: mxv-stop-off 4.2s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track b:nth-child(2),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(2) {
|
||||
animation: mxv-stop-on 4.2s linear infinite;
|
||||
}
|
||||
|
||||
/* Arrivals: nav at 5.95% (0.25s), plate at 34.52% (1.45s), arrow at 60.71%
|
||||
(2.55s). Departures at 25%, 50%, and 83.33%, the landing page's own. */
|
||||
@keyframes mxv-path {
|
||||
0% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
|
||||
5.95%, 25% { translate: var(--mtr-nav1, 64.8cqw 4.8cqh); }
|
||||
34.52%, 50% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
|
||||
60.71%, 83.33% { translate: var(--mxi-rail, 92.6cqw 87.9cqh); }
|
||||
90%, 99.99% { translate: 32cqw 115cqh; }
|
||||
100% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
|
||||
}
|
||||
|
||||
@keyframes mxv-band {
|
||||
0%, 34.52% {
|
||||
opacity: 0;
|
||||
translate: 0 100%;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
39.29%, 50% { opacity: 1; translate: 0 0; }
|
||||
52.86%, 100% { opacity: 0; translate: 0 100%; }
|
||||
}
|
||||
|
||||
@keyframes mxv-name {
|
||||
0%, 34.52% {
|
||||
background-color: var(--pvs-title);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
38.33%, 50% { background-color: var(--pvs-cta); }
|
||||
52.86%, 100% { background-color: var(--pvs-title); }
|
||||
}
|
||||
|
||||
@keyframes mxv-mark {
|
||||
0%, 34.52% {
|
||||
background-color: var(--pvs-accent-d);
|
||||
scale: 1;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
39.29%, 50% { background-color: var(--pvs-cta); scale: 1.4; }
|
||||
52.86%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxv-arrow {
|
||||
0%, 60.71% {
|
||||
border-color: var(--pvs-bars);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
63.57%, 83.33% { border-color: var(--pvs-cta); }
|
||||
86.19%, 100% { border-color: var(--pvs-bars); }
|
||||
}
|
||||
|
||||
@keyframes mxv-track {
|
||||
0%, 61.9% {
|
||||
translate: 0 0;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
67.62%, 99.99% { translate: -6px 0; }
|
||||
100% { translate: 0 0; }
|
||||
}
|
||||
|
||||
@keyframes mxv-stop-off {
|
||||
0%, 61.9% {
|
||||
background-color: var(--pvs-cta);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
65.71%, 99.99% { background-color: var(--pvs-bars); }
|
||||
100% { background-color: var(--pvs-cta); }
|
||||
}
|
||||
|
||||
@keyframes mxv-stop-on {
|
||||
0%, 61.9% {
|
||||
background-color: var(--pvs-bars);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
65.71%, 99.99% { background-color: var(--pvs-cta); }
|
||||
100% { background-color: var(--pvs-bars); }
|
||||
}
|
||||
|
||||
/* ── Choreographed on the index: the index hangs itself ──
|
||||
The curtain, the nav's arrival, and the footer's are the landing page's and
|
||||
run here as they are. What this board sequences is an index being hung: the
|
||||
page title wipes in, the first plate uncovers left to right and its caption
|
||||
composes after it, the second plate uncovers from the other side because the
|
||||
row is flipped and a reveal that ignored the flip would run away from the
|
||||
caption it belongs to, then the carousel's arrows and stops arrive on an
|
||||
80ms walk. The finale is the pointer coming up to the first entry, which is
|
||||
the same hover the responsive scene demonstrates, arriving as the last beat
|
||||
of a sequence instead of as an answer.
|
||||
|
||||
In seconds of the 5.6: title 0.45-1.05, first plate 0.9-1.7 with its caption
|
||||
1.5-2.0, second plate 1.9-2.7 with its caption 2.5-3.0, arrows 3.1-3.4,
|
||||
stops 3.2-3.6, footer 3.3-3.6, the hover 3.9-4.9, curtain 5.4-5.6.
|
||||
|
||||
The caption reveals sit on the ladder rather than on its bars, because the
|
||||
title bar spends its own keyframes on the finale and an element takes one
|
||||
animation per property. */
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-cursor,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-cursor {
|
||||
animation: mxc-path 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-head,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-head {
|
||||
animation: mxc-head 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image {
|
||||
animation: mxc-plate-1 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image {
|
||||
animation: mxc-plate-2 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-cap,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-cap {
|
||||
animation: mxc-cap-1 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-cap,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-cap {
|
||||
animation: mxc-cap-2 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-arrow,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-arrow {
|
||||
animation: mxc-arrow 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(1),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(1) {
|
||||
animation: mxc-stop-1 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(2),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(2) {
|
||||
animation: mxc-stop-2 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(3),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(3) {
|
||||
animation: mxc-stop-3 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(4),
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(4) {
|
||||
animation: mxc-stop-4 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
|
||||
animation: mxc-band 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
|
||||
animation: mxc-name 5.6s linear infinite;
|
||||
}
|
||||
|
||||
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
|
||||
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
|
||||
animation: mxc-mark 5.6s linear infinite;
|
||||
}
|
||||
|
||||
/* The pointer waits below the frame until the index is hung, comes up to the
|
||||
first entry at 3.9s, and is gone again before the curtain, so both ends of
|
||||
the loop are off-frame. */
|
||||
@keyframes mxc-path {
|
||||
0%, 62.5% { translate: 45cqw 115cqh; }
|
||||
69.64%, 87.5% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
|
||||
93%, 100% { translate: 45cqw 115cqh; }
|
||||
}
|
||||
|
||||
@keyframes mxc-head {
|
||||
0%, 8.04% {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
18.75%, 100% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes mxc-plate-1 {
|
||||
0%, 16.07% {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
30.36%, 100% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes mxc-plate-2 {
|
||||
0%, 33.93% {
|
||||
clip-path: inset(0 0 0 100%);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
48.21%, 100% { clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes mxc-cap-1 {
|
||||
0%, 26.79% {
|
||||
opacity: 0;
|
||||
translate: 0 6px;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
35.71%, 100% { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
|
||||
@keyframes mxc-cap-2 {
|
||||
0%, 44.64% {
|
||||
opacity: 0;
|
||||
translate: 0 6px;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
53.57%, 100% { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
|
||||
@keyframes mxc-arrow {
|
||||
0%, 55.36% { opacity: 0; }
|
||||
60.71%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxc-stop-1 {
|
||||
0%, 57.14% { opacity: 0; }
|
||||
62.5%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxc-stop-2 {
|
||||
0%, 58.57% { opacity: 0; }
|
||||
63.93%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxc-stop-3 {
|
||||
0%, 60% { opacity: 0; }
|
||||
65.36%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxc-stop-4 {
|
||||
0%, 61.43% { opacity: 0; }
|
||||
66.79%, 100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mxc-band {
|
||||
0%, 69.64% {
|
||||
opacity: 0;
|
||||
translate: 0 100%;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
73.21%, 87.5% { opacity: 1; translate: 0 0; }
|
||||
89.29%, 100% { opacity: 0; translate: 0 100%; }
|
||||
}
|
||||
|
||||
/* One animation for two beats: the title's own reveal, then the accent and the
|
||||
extra measure it takes when the pointer arrives at the end of the scene. */
|
||||
@keyframes mxc-name {
|
||||
0%, 26.79% {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
width: 66%;
|
||||
background-color: var(--pvs-title);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
33.93%, 70.54% {
|
||||
clip-path: inset(0 0 0 0);
|
||||
width: 66%;
|
||||
background-color: var(--pvs-title);
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
75%, 87.5% { clip-path: inset(0 0 0 0); width: 74%; background-color: var(--pvs-cta); }
|
||||
89.29%, 100% { clip-path: inset(0 0 0 0); width: 66%; background-color: var(--pvs-title); }
|
||||
}
|
||||
|
||||
@keyframes mxc-mark {
|
||||
0%, 70.54% {
|
||||
background-color: var(--pvs-accent-d);
|
||||
scale: 1;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
74.11%, 87.5% { background-color: var(--pvs-cta); scale: 1.4; }
|
||||
89.29%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
|
||||
}
|
||||
|
||||
/* A loop nobody asked for is the definition of a nonessential one. Reduced
|
||||
motion gets the end of the scene instead: the pointer parked over the card
|
||||
it chose, with every state already changed, which is the same information
|
||||
@@ -1263,12 +1810,19 @@
|
||||
.picker-preview-motion .ps-actions i::after,
|
||||
.picker-preview-motion .ps-gallery-item > i::before,
|
||||
.picker-preview-motion .ps-gallery-item > i::after,
|
||||
.picker-preview-motion .ps-image::before,
|
||||
.picker-preview-motion .ps-image::after,
|
||||
.picker-preview-motion .ps-desktop::after,
|
||||
.picker-preview-motion :is(
|
||||
.ps-cursor,
|
||||
.ps-nav,
|
||||
.ps-nav-bars i,
|
||||
.ps-index-head,
|
||||
.ps-index-cap,
|
||||
.ps-index-name,
|
||||
.ps-index-arrow,
|
||||
.ps-index-track,
|
||||
.ps-index-track b,
|
||||
.ps-hero-copy > *,
|
||||
.ps-eyebrow,
|
||||
.ps-headline i,
|
||||
@@ -1316,4 +1870,25 @@
|
||||
filter: opacity(var(--mt-dim));
|
||||
translate: 0 var(--mt-sink);
|
||||
}
|
||||
|
||||
/* The portfolio board's own resting frame: the pointer on the first entry
|
||||
with that entry's caption band up, which is where all three of its scenes
|
||||
end up. */
|
||||
.picker-preview-motion--index .ps-cursor {
|
||||
translate: var(--mxi-work1, 25.6cqw 33.3cqh);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
|
||||
background-color: var(--pvs-cta);
|
||||
}
|
||||
|
||||
.picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
|
||||
background-color: var(--pvs-cta);
|
||||
scale: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user
|
||||
## Two paths
|
||||
|
||||
- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
|
||||
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Gather any existing brand assets, interview for five high-level answers, optionally generate visual cues for the palette pick, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
|
||||
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Gather any existing brand assets, interview for five high-level answers, optionally generate visual cues and run the browser questionnaire, then write a seed DESIGN.md marked `<!-- SEED -->` that carries every decision the interview and the questionnaire made. Re-run in scan mode once there's code.
|
||||
|
||||
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `/impeccable document --seed` forces seed mode on a pre-implementation project, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
|
||||
|
||||
@@ -410,29 +410,50 @@ Interview answers are words; a palette is easier picked by eye. Before writing t
|
||||
- **No usable native path, no key**: pause and {{ask_instruction}} whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the seed?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies.
|
||||
- **The user opts out, or no key arrives**: go to Step 5 and seed from the answers alone.
|
||||
|
||||
When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. Do not write DESIGN.md in that turn; Steps 5-6 run when a completed picker is handed to the later seed consumer, or immediately when the user opted out of generation.
|
||||
When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-interview/answers.json` lands, come back here and run Steps 5-6 with that file in hand. When the user opted out of generation (or no key arrived), run Steps 5-6 immediately from the interview alone.
|
||||
|
||||
### Step 5: Write seed DESIGN.md
|
||||
|
||||
Use the canonical section order from Scan mode. Populate what the interview, the assets, and any cue pick answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec.
|
||||
Use the canonical section order from Scan mode. Populate what the interview, the assets, and the questionnaire answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec, but a decision the user actually made in the picker is real and belongs in the file at full strength.
|
||||
|
||||
Lead the file with:
|
||||
Mark the file as a seed with this comment as the first line of the markdown body, immediately after the frontmatter's closing `---` (the frontmatter must open the file or token parsers will not see it):
|
||||
|
||||
```markdown
|
||||
<!-- SEED: established with the user before implementation; re-run /impeccable document once there's code to capture the actual tokens and components. -->
|
||||
```
|
||||
|
||||
Per-section guidance in seed mode:
|
||||
**Two seeds exist**, and which one you write depends on whether Step 4's picker ran:
|
||||
|
||||
**Interview-only seed** (the user opted out of generation, or no key arrived). Per-section guidance:
|
||||
|
||||
- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
|
||||
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. Colors sampled from a provided logo, or from a cue image the user picked in Step 4, are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex a seed may carry.
|
||||
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. Colors sampled from a provided logo are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex this seed may carry.
|
||||
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
|
||||
- **Layout** and **Shapes**: omit unless an asset or answer established a spatial or form preference; do not invent grids or corner language pre-implementation.
|
||||
- **Elevation & Depth**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
|
||||
- **Components**: omit entirely; no components exist yet.
|
||||
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
|
||||
|
||||
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
|
||||
This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet.
|
||||
|
||||
**Questionnaire seed** (`.impeccable/design-interview/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
|
||||
|
||||
- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
|
||||
- **Overview**: as the interview-only seed, plus name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
|
||||
- `motion-energy-<mode>` keys present, all agreeing: one philosophy sentence for the product, as before.
|
||||
- Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
|
||||
- **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the interview's Q3 or from the register. The next Scan-mode run reads the real transitions out of the code.
|
||||
- **Colors**: the four roles with their picked hex, noting the cue they came from. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-<mode>` keys), state each surface's strategy and which surface leads (the bare key's owner).
|
||||
- **Typography**: the real pair by name, the pairing's character, and the type scale as a rule: `type-scale` names it, `type-scale-ratio` is the ratio (e.g. *"Major third: each heading step is 1.25x the last"*). Base size and exact steps stay `[resolved at implementation]`. A `font-heading-source` / `font-body-source` value means a user-provided font file; record where it lives.
|
||||
- **Layout**: `layout-structure` (how pages are composed) and `boundary-style` (how sections separate), per surface when the `-<mode>` keys differ. No invented grids beyond what the answer states.
|
||||
- **Elevation & Depth**: `depth-style` per surface, stated directly; the questionnaire answered this, so do not re-infer it from motion energy.
|
||||
- **Shapes**: `corner-style` per surface.
|
||||
- **Components**: still omit; nothing exists yet.
|
||||
- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets.
|
||||
|
||||
Per-surface answers come back for every chosen surface, defaults included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface). Where all surfaces agree, state the answer once for the product. `motion-energy` is the one key that can be missing entirely, since only a landing page and a portfolio are asked about movement; [visual-cues.md](visual-cues.md) has the full contract.
|
||||
|
||||
Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
|
||||
|
||||
### Step 6: Confirm
|
||||
|
||||
@@ -441,6 +462,8 @@ Seed mode writes a minimal frontmatter with `name` and `description` only; no co
|
||||
|
||||
Your own write is the freshest source; no reload needed.
|
||||
|
||||
When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after lands in DESIGN.md without you (the session swaps the hex itself, journaled in `.impeccable/design-interview/doc-edits.jsonl` for the name-reconciliation pass at exit).
|
||||
|
||||
## Style guidelines
|
||||
|
||||
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places; the frontmatter is normative.
|
||||
|
||||
@@ -453,11 +453,31 @@ Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every f
|
||||
|
||||
Before launching, write the surface set from Step 6 into `cues.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. 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 gave no clear signal; the picker then starts from `persuade` alone.
|
||||
|
||||
Four of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the four comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `<key>-<mode>` key for every surface chosen, `<mode>` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
|
||||
In the same write, add a top-level `context` object carrying the chat half of the run, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest:
|
||||
|
||||
The picker does not offer every option on every surface. A landing page can take any answer to all four questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
|
||||
```json
|
||||
"context": {
|
||||
"product": { "name": "[product name]", "purpose": "[one-sentence purpose from PRODUCT.md]" },
|
||||
"audience": { "primary": "[who]", "secondary": "[who]", "emotion": "[emotional goal on landing]", "needs": ["[need]"] },
|
||||
"brand": { "words": ["[word]"], "personality": "[one sentence from PRODUCT.md Brand Personality]" },
|
||||
"assets": ["[asset name: what Step 2 read off it]"],
|
||||
"interview": {
|
||||
"colorStrategy": "[Q1 pick]", "hueAnchor": "[Q1 anchor]",
|
||||
"typeDirection": "[Q2 pick]", "motionEnergy": "[Q3 pick]",
|
||||
"references": ["[Q4, all three]"], "antiReference": "[Q5]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When more than one surface comes back, DESIGN.md says what each of them does with color, section separation, corner radius, and depth, instead of stating one answer for the product.
|
||||
Quote the user's answers, not paraphrases of them; the document labels interview fields as the questions they answered. A missing block renders as a pointer to where that truth lives (PRODUCT.md), so an old `cues.json` without `context` still produces a complete document.
|
||||
|
||||
Five of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `motion-energy` (how much movement there is), `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the five comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `<key>-<mode>` key for every surface chosen, `<mode>` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
|
||||
|
||||
`motion-energy` is the one exception to that shape, because the question is only put to two of the four surfaces. A landing page and a portfolio are watched, so how much they move is a house decision; a tool and a document are worked in, and their movement follows the interface. So the motion keys cover the chosen surfaces among `persuade` and `experience` only, and the bare key holds the first of those two in tile order rather than the run's leading surface: on an app UI plus portfolio run, `motion-energy` is the portfolio's answer. **When a run chooses neither of those surfaces the question is never asked, and no `motion-energy` key comes back at all.** Read it as absent rather than defaulted, and say nothing about movement in DESIGN.md; a default written as a decision is a decision the user never made.
|
||||
|
||||
The picker does not offer every option on every surface. A landing page can take any answer to all five questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
|
||||
|
||||
When more than one surface comes back, DESIGN.md says what each of them does with color, movement, section separation, corner radius, and depth, instead of stating one answer 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.
|
||||
|
||||
@@ -466,5 +486,32 @@ Tell the user in one line that the visual cues are ready at `.impeccable/visual-
|
||||
|
||||
The server process exiting is the completion signal; never poll or watch the answers file while it runs.
|
||||
|
||||
- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then stop. Do not show or describe the cues, ask for a pick in chat, or write DESIGN.md in this turn.
|
||||
- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then return to [document.md](document.md) Steps 5-6 and write the seed DESIGN.md from that file (its questionnaire-seed mapping owns which key lands where). Do not show or describe the cues or ask for a pick in chat; the picker already settled the pick. The user's tab is meanwhile showing the design context document the picker built from the run, and that document is now a working surface: on submit the server forked a detached edit session (`picker-doc-session.mjs`) that keeps the tab connected. After the seed DESIGN.md is written, enter the edit loop below.
|
||||
- **Exit 2**: tell the user the picker closed unanswered and that they can relaunch it with the same command. Never restart it unprompted.
|
||||
|
||||
## The document edit loop
|
||||
|
||||
The revealed document is editable in place, on live mode's division of labor:
|
||||
|
||||
- **Simple edits never reach you.** A palette color change is applied by the session process itself: it rewrites `answers.json`, swaps the old hex for the new one across DESIGN.md, and journals the change to `.impeccable/design-interview/doc-edits.jsonl`. If the color edit landed before your seed write, the answers file you seed from already carries it.
|
||||
- **Complex edits queue for you.** Font changes (including uploaded faces, saved under `.impeccable/design-interview/fonts/`) and freeform asks arrive as `edit_request` events.
|
||||
|
||||
After writing the seed DESIGN.md, tell the user in one line that the document in their tab is live for edits, then poll:
|
||||
|
||||
```
|
||||
node {{scripts_path}}/picker-doc-poll.mjs
|
||||
```
|
||||
|
||||
One-shot, exactly like live mode's poll: it blocks until one event and prints it as JSON. Run it on live mode's harness policy: on Claude Code as a background task; on Cursor as a one-shot poll in a background terminal with notify on `"type":"(edit_request|exit)"`; on Codex as a yielded foreground exec; elsewhere one-shot foreground. Never `--timeout` it short.
|
||||
|
||||
- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md (and `answers.json` where a questionnaire key names the same fact, so the tab re-renders it), move any uploaded font files where the project keeps assets, then reply and poll again:
|
||||
|
||||
```
|
||||
node {{scripts_path}}/picker-doc-poll.mjs --reply <id> done "One line the user sees in the tab"
|
||||
```
|
||||
|
||||
Reply `error` with a reason when the ask cannot be applied; reply `retry` to put it back in the queue untouched.
|
||||
- `{"type":"timeout"}`: nothing arrived in the budget; poll again.
|
||||
- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `doc-edits.jsonl` and reconcile any prose the deterministic edits left stale: a swapped hex whose descriptive color name in DESIGN.md no longer matches its value gets a fresh name. Then stop polling; the loop is over.
|
||||
|
||||
The user may keep working in chat while the document sits open; treat an `edit_request` like any other user instruction, just delivered through the tab.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
/** Agent poll CLI for the design-document edit session.
|
||||
*
|
||||
* The picker forks picker-doc-session.mjs on submit; this is how the agent
|
||||
* hears from it, on the live-poll.mjs contract: one-shot by default, block
|
||||
* until one event arrives, print it as JSON on stdout, exit.
|
||||
*
|
||||
* node picker-doc-poll.mjs # block, print one event
|
||||
* node picker-doc-poll.mjs --timeout=600000 # total budget in ms
|
||||
* node picker-doc-poll.mjs --reply <id> <status> [message]
|
||||
*
|
||||
* Events printed: {"type":"edit_request","id","kind","prompt","category",
|
||||
* "payload"} for work, {"type":"timeout"} when the budget runs out (poll
|
||||
* again), {"type":"exit"} when the session ended (stop polling).
|
||||
*
|
||||
* Reply statuses: done (change applied; message shown to the user in the
|
||||
* document), error (could not apply; message explains), retry (release the
|
||||
* request back to pending).
|
||||
*
|
||||
* Session discovery: .impeccable/design-interview/doc-session.json, written
|
||||
* by the session process and removed when it exits; a missing file prints
|
||||
* {"type":"exit"} so a finished session never hangs the loop.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const sessionPath = path.resolve(process.cwd(), '.impeccable/design-interview/doc-session.json');
|
||||
/* Sliced under undici's 300s header timeout, same as live-poll. */
|
||||
const PER_REQUEST_MS = 270_000;
|
||||
const DEFAULT_TOTAL_MS = 600_000;
|
||||
|
||||
async function session() {
|
||||
try {
|
||||
return JSON.parse(await readFile(sessionPath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function readFlag(name, fallback) {
|
||||
const exact = args.find((arg) => arg.startsWith(`${name}=`));
|
||||
if (exact) return exact.slice(name.length + 1);
|
||||
const at = args.indexOf(name);
|
||||
if (at !== -1 && args[at + 1]) return args[at + 1];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const info = await session();
|
||||
if (!info) {
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'no-session' }));
|
||||
process.exit(0);
|
||||
}
|
||||
const base = `http://127.0.0.1:${info.port}`;
|
||||
|
||||
if (args.includes('--reply')) {
|
||||
const at = args.indexOf('--reply');
|
||||
const [id, status, ...rest] = args.slice(at + 1).filter((arg) => !arg.startsWith('--'));
|
||||
if (!id || !status) {
|
||||
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message]');
|
||||
process.exit(1);
|
||||
}
|
||||
const response = await fetch(`${base}/doc/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' ') }),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) {
|
||||
console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(await response.json()));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const totalBudget = Number(readFlag('--timeout', DEFAULT_TOTAL_MS));
|
||||
const deadline = Date.now() + (Number.isFinite(totalBudget) && totalBudget > 0 ? totalBudget : DEFAULT_TOTAL_MS);
|
||||
|
||||
for (;;) {
|
||||
const slice = Math.min(deadline - Date.now(), PER_REQUEST_MS);
|
||||
if (slice <= 0) {
|
||||
console.log(JSON.stringify({ type: 'timeout' }));
|
||||
process.exit(0);
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
const response = await fetch(`${base}/doc/poll?token=${encodeURIComponent(info.token)}&timeout=${slice}`);
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* The session process exited between polls. */
|
||||
console.log(JSON.stringify({ type: 'exit', reason: 'session-gone' }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (payload.type === 'timeout') continue;
|
||||
console.log(JSON.stringify(payload));
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env node
|
||||
/** Design-document edit session (self-contained, zero dependencies).
|
||||
*
|
||||
* The picker server forks this detached sibling the moment the questionnaire
|
||||
* submits, so the review tab's design context document stays connected after
|
||||
* the picker itself exits 0 (the agent's completion signal). It runs on its
|
||||
* own pre-scanned port with CORS open to the picker origin, and it mediates
|
||||
* three parties the way the live server does, scaled down to polling:
|
||||
*
|
||||
* browser --POST /doc/edit-----------> applied here (simple edits)
|
||||
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
|
||||
* agent --POST /doc/reply---------> queue status + version bump
|
||||
* browser --GET /doc/state (poll)--> { version, requests } -> re-render
|
||||
*
|
||||
* Simple edits (a palette color) are deterministic: this process rewrites
|
||||
* answers.json and swaps the value in DESIGN.md itself, no model involved.
|
||||
* Anything needing judgment queues for the agent, which long-polls through
|
||||
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
|
||||
*
|
||||
* Session discovery for the agent CLI: .impeccable/design-interview/
|
||||
* doc-session.json { pid, port, token }. Removed on exit. Every applied
|
||||
* simple edit is journaled to doc-edits.jsonl in the same directory so the
|
||||
* agent can reconcile prose (a renamed color's description) at session end.
|
||||
*
|
||||
* Usage (spawned by picker-server.mjs, not by hand):
|
||||
* node picker-doc-session.mjs --port 8501 --timeout 60
|
||||
* with IMPECCABLE_DOC_TOKEN in the environment.
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview');
|
||||
const answersPath = path.join(interviewDir, 'answers.json');
|
||||
const sessionPath = path.join(interviewDir, 'doc-session.json');
|
||||
const ledgerPath = path.join(interviewDir, 'doc-edits.jsonl');
|
||||
const fontsDir = path.join(interviewDir, 'fonts');
|
||||
const designPath = path.resolve(process.cwd(), 'DESIGN.md');
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
const ROLES = new Set(['primary', 'secondary', 'tertiary', 'neutral']);
|
||||
const REQUEST_KINDS = new Set(['font', 'freeform']);
|
||||
/* Long polls are sliced under common proxy/undici header timeouts, the same
|
||||
270s ceiling live-poll uses. */
|
||||
const MAX_POLL_MS = 270_000;
|
||||
/* The tab polls /doc/state every couple of seconds while open; when it has
|
||||
been quiet this long the session is over and the agent's poll gets exit. */
|
||||
const BROWSER_GONE_MS = 10 * 60_000;
|
||||
/* A tab adopts the session within seconds of the submit that forked it. If
|
||||
no poll ever arrives (a test harness, a closed tab), die young instead of
|
||||
holding a port for the full ceiling. */
|
||||
const ADOPT_GRACE_MS = 90_000;
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const readArg = (name, fallback) => {
|
||||
const at = args.indexOf(name);
|
||||
return at !== -1 && args[at + 1] ? args[at + 1] : fallback;
|
||||
};
|
||||
const port = Number(readArg('--port', '0'));
|
||||
const timeoutMinutes = Number(readArg('--timeout', '60'));
|
||||
const token = process.env.IMPECCABLE_DOC_TOKEN || '';
|
||||
if (!port || !token) {
|
||||
console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let version = 1;
|
||||
let requestSeq = 0;
|
||||
const requests = [];
|
||||
let lastBrowserSeen = Date.now();
|
||||
let adopted = false;
|
||||
const parkedPolls = [];
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function httpError(statusCode, message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function readJsonBody(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
throw httpError(400, 'Body must be valid JSON');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
|
||||
return value;
|
||||
}
|
||||
|
||||
const summarize = (entry) => ({
|
||||
id: entry.id,
|
||||
kind: entry.kind,
|
||||
prompt: entry.prompt,
|
||||
category: entry.category,
|
||||
status: entry.status,
|
||||
message: entry.message || '',
|
||||
});
|
||||
|
||||
async function appendLedger(entry) {
|
||||
await mkdir(interviewDir, { recursive: true });
|
||||
await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' });
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Simple edits — deterministic, applied here.
|
||||
============================================================ */
|
||||
|
||||
async function applyColorEdit({ role, value }) {
|
||||
if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role');
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color');
|
||||
const hex = value.toUpperCase();
|
||||
|
||||
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
||||
const previous = String(answers[`palette-${role}`] || '').toUpperCase();
|
||||
answers[`palette-${role}`] = hex;
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
|
||||
/* DESIGN.md may not exist yet (the agent writes the seed while the user
|
||||
reads the document); the answers file is the source it will seed from,
|
||||
so an early edit is already carried. */
|
||||
let designTouched = false;
|
||||
if (previous && previous !== hex) {
|
||||
try {
|
||||
const source = await readFile(designPath, 'utf8');
|
||||
/* A hex value is regex-safe: a literal # and hex digits. */
|
||||
const swapped = source.replace(new RegExp(previous, 'gi'), hex);
|
||||
if (swapped !== source) {
|
||||
await writeFile(designPath, swapped);
|
||||
designTouched = true;
|
||||
}
|
||||
} catch {
|
||||
/* No DESIGN.md yet. */
|
||||
}
|
||||
}
|
||||
|
||||
await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched });
|
||||
return { role, from: previous, to: hex, designTouched };
|
||||
}
|
||||
|
||||
const SIMPLE_EDITS = { color: applyColorEdit };
|
||||
|
||||
/* ============================================================
|
||||
Complex edits — queued for the agent.
|
||||
============================================================ */
|
||||
|
||||
function wakeParkedPolls() {
|
||||
while (parkedPolls.length) {
|
||||
const parked = parkedPolls.shift();
|
||||
clearTimeout(parked.timer);
|
||||
parked.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPending() {
|
||||
return requests.find((entry) => entry.status === 'pending');
|
||||
}
|
||||
|
||||
async function handleDocPoll(response, query) {
|
||||
const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS);
|
||||
const deadline = Date.now() + budget;
|
||||
|
||||
for (;;) {
|
||||
if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) {
|
||||
sendJson(response, 200, { type: 'exit', reason: 'browser-gone' });
|
||||
return;
|
||||
}
|
||||
const entry = nextPending();
|
||||
if (entry) {
|
||||
entry.status = 'working';
|
||||
bumpVersion();
|
||||
sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
|
||||
return;
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
sendJson(response, 200, { type: 'timeout' });
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) };
|
||||
parkedPolls.push(parked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bumpVersion() {
|
||||
version += 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Server
|
||||
============================================================ */
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
void handleRequest(request, response).catch((error) => {
|
||||
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
|
||||
else response.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const requestPath = url.pathname;
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename',
|
||||
'Access-Control-Max-Age': '600',
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Font uploads carry bytes, not JSON; token rides the query string. */
|
||||
if (request.method === 'POST' && requestPath === '/font-upload') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
const name = path.basename(request.headers['x-font-filename'] || '');
|
||||
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
|
||||
throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename');
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
|
||||
sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/state') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
lastBrowserSeen = Date.now();
|
||||
adopted = true;
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
version,
|
||||
requests: requests.map(summarize),
|
||||
agentWaiting: parkedPolls.length > 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/answers') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
|
||||
sendJson(response, 200, { ok: true, version, answers });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/poll') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
await handleDocPoll(response, url.searchParams);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') throw httpError(404, 'Not found');
|
||||
const body = await readJsonBody(request);
|
||||
if (body.token !== token) throw httpError(403, 'Bad token');
|
||||
|
||||
if (requestPath === '/doc/edit') {
|
||||
const apply = SIMPLE_EDITS[body.kind];
|
||||
if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`);
|
||||
const applied = await apply(body);
|
||||
bumpVersion();
|
||||
sendJson(response, 200, { ok: true, version, applied });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPath === '/doc/request') {
|
||||
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
|
||||
const prompt = String(body.prompt || '').trim();
|
||||
if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max');
|
||||
requestSeq += 1;
|
||||
const entry = {
|
||||
id: `req-${String(requestSeq).padStart(3, '0')}`,
|
||||
kind: body.kind,
|
||||
prompt,
|
||||
category: String(body.category || ''),
|
||||
payload: body.payload && typeof body.payload === 'object' ? body.payload : {},
|
||||
status: 'pending',
|
||||
message: '',
|
||||
};
|
||||
requests.push(entry);
|
||||
bumpVersion();
|
||||
wakeParkedPolls();
|
||||
sendJson(response, 200, { ok: true, id: entry.id, version });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestPath === '/doc/reply') {
|
||||
const entry = requests.find((item) => item.id === body.id);
|
||||
if (!entry) throw httpError(404, 'Unknown request id');
|
||||
if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
|
||||
entry.status = body.status === 'retry' ? 'pending' : body.status;
|
||||
entry.message = String(body.message || '');
|
||||
bumpVersion();
|
||||
if (entry.status === 'pending') wakeParkedPolls();
|
||||
sendJson(response, 200, { ok: true, version });
|
||||
return;
|
||||
}
|
||||
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', async () => {
|
||||
await mkdir(interviewDir, { recursive: true });
|
||||
await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`);
|
||||
});
|
||||
|
||||
server.on('error', () => process.exit(1));
|
||||
|
||||
/* The session dies with its audience: no browser poll for BROWSER_GONE_MS,
|
||||
or the hard ceiling, whichever lands first. */
|
||||
const reaper = setInterval(() => {
|
||||
const quiet = Date.now() - lastBrowserSeen;
|
||||
if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown();
|
||||
}, 15_000);
|
||||
const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(reaper);
|
||||
clearTimeout(ceiling);
|
||||
wakeParkedPolls();
|
||||
await rm(sessionPath, { force: true }).catch(() => {});
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
setTimeout(() => process.exit(0), 1_000).unref();
|
||||
}
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
@@ -203,11 +205,19 @@ async function handleRequest(request, response) {
|
||||
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
/* The document the review tab is about to reveal stays editable through a
|
||||
detached sibling: it owns the edit endpoints on its own port, so this
|
||||
process can still exit as the agent's completion signal. The tab learns
|
||||
where to reach it from this response; the agent learns from
|
||||
doc-session.json, which the sibling writes at boot. */
|
||||
const doc = await spawnDocSession();
|
||||
response.once('finish', () => {
|
||||
console.log(`ANSWERS ${answersPath}`);
|
||||
server.close(() => process.exit(0));
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
sendJson(response, 200, { ok: true });
|
||||
sendJson(response, 200, { ok: true, doc });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,6 +285,28 @@ async function handleRequest(request, response) {
|
||||
await serveFile(response, pickerDir, assetPath);
|
||||
}
|
||||
|
||||
async function spawnDocSession() {
|
||||
try {
|
||||
const docPort = await findOpenPort(port + 1);
|
||||
const docToken = randomUUID();
|
||||
const child = spawn(process.execPath, [
|
||||
path.join(scriptDir, 'picker-doc-session.mjs'),
|
||||
'--port', String(docPort),
|
||||
'--timeout', String(options.timeoutMinutes),
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, IMPECCABLE_DOC_TOKEN: docToken },
|
||||
});
|
||||
child.unref();
|
||||
return { base: `http://127.0.0.1:${docPort}`, token: docToken };
|
||||
} catch {
|
||||
/* The document still renders read-only; only the edit loop is lost. */
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopWithoutSubmission(message) {
|
||||
if (completed) return;
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -51,11 +51,12 @@ const fontManifestFixture = {
|
||||
'chosen the morning it ships.',
|
||||
],
|
||||
sectionLink: 'Our growers',
|
||||
// Three, the count the artboard draws and the manifest validator requires:
|
||||
// a fourth card makes the whole file fall back to the default pairs.
|
||||
gallery: [
|
||||
{ title: 'Market bunch', meta: 'From $38' },
|
||||
{ title: 'Table vase', meta: 'From $52' },
|
||||
{ title: 'Ceremony', meta: 'From $120' },
|
||||
{ title: 'Workshop', meta: 'Next Sat' },
|
||||
],
|
||||
footerLinks: ['Care guide', 'Delivery', 'Contact', 'Instagram'],
|
||||
footerMark: '© Hanazono',
|
||||
@@ -245,7 +246,12 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
|
||||
body: JSON.stringify(answers),
|
||||
});
|
||||
assert.equal(submitResponse.status, 200);
|
||||
assert.deepEqual(await submitResponse.json(), { ok: true });
|
||||
const submitBody = await submitResponse.json();
|
||||
assert.equal(submitBody.ok, true);
|
||||
// Submit forks the detached doc-session sibling and hands the tab its
|
||||
// address; the picker itself still exits 0 as the completion signal.
|
||||
assert.match(submitBody.doc?.base || '', /^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
assert.equal(typeof submitBody.doc?.token, 'string');
|
||||
assert.equal((await exitPromise)[0], 0);
|
||||
|
||||
const answersPath = path.join(
|
||||
@@ -254,6 +260,20 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
|
||||
);
|
||||
assert.deepEqual(JSON.parse(await readFile(answersPath, 'utf8')), answers);
|
||||
assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
||||
|
||||
// Reap the doc session so the fixture directory can be removed.
|
||||
const sessionPath = path.join(fixture.cwd, '.impeccable/design-interview/doc-session.json');
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const session = JSON.parse(await readFile(sessionPath, 'utf8'));
|
||||
assert.equal(session.token, submitBody.doc.token);
|
||||
process.kill(session.pid);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_ASSERTION') throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fonts endpoint returns 404 when fonts.json is absent', async (t) => {
|
||||
|
||||
Reference in New Issue
Block a user