This commit is contained in:
Abdul Wahab
2026-09-01 10:02:40 +05:00
parent 4c1fbbc7c0
commit ecadbc9556
10 changed files with 2325 additions and 704 deletions
+190 -1
View File
@@ -13,6 +13,13 @@
*/
interface Props {
mode?: 'bars' | 'type';
/*
Which surface the board draws. The chrome and the frame are shared and only
the two bodies change, the same arrangement the four wireframes on the
surface question already use, so a change to the nav lands on all of them at
once. Named boards are the ones the font screen switches between.
*/
surface?: 'persuade' | 'operate' | 'read' | 'experience';
phone?: boolean;
class?: string;
strategyHook?: boolean;
@@ -29,6 +36,7 @@ interface Props {
const {
mode = 'bars',
surface,
phone = true,
class: className = '',
strategyHook = false,
@@ -37,6 +45,9 @@ const {
carry = false,
} = Astro.props;
const type = mode === 'type';
const ops = surface === 'operate';
const docs = surface === 'read';
const index = surface === 'experience';
const classes = ['picker-artboard', className].filter(Boolean).join(' ');
// Empty string renders the bare attribute the font screen's selectors expect;
// undefined drops it entirely on the other screens.
@@ -46,6 +57,7 @@ const hook = type ? '' : undefined;
<div
class={classes}
data-artboard=""
data-surface={surface}
data-carry={carry ? '' : undefined}
data-strategy-preview={strategyHook ? '' : undefined}
data-type-preview={typeHook ? '' : undefined}
@@ -69,6 +81,114 @@ const hook = type ? '' : undefined;
{!type && <i class="ps-avatar"></i>}
</div>
</div>
{ops ? (
<div class="ps-ops">
{/* The rail is where a tool keeps its navigation, so it carries the
longest run of micro labels on any board. The open one is a filled
plate rather than a recolor: a selection is a state, not a word. */}
<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>
</span>
))}
</div>
<div class="ps-ops-main">
<strong class="ps-ops-title" data-type-section-title></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>
</span>
))}
</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>
</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>
</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>
{[0, 1].map((n) => (
<span class="ps-ops-switch">
<b data-type-switch></b>
<i class:list={['ps-ops-toggle', n === 0 && 'ps-ops-toggle--on']}></i>
</span>
))}
</div>
</div>
) : docs ? (
<div class="ps-docs">
{/* The rail names sections of one document rather than pages of a site,
which is why it cannot take the nav's words: the two would read as
the same list drawn twice. */}
<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>
</span>
))}
</div>
{/* Two heading levels and the passages between them. A documentation
page is the one surface where the relationship between a page title
and the heading under it is visible at a glance, so the board shows
both rather than the single heading the drawing carries. */}
<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>
<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>
</span>
</div>
</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. */}
<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>
</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">
<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']} data-type-stop></b>
))}
</div>
<i class="ps-index-arrow ps-index-arrow--next"></i>
</div>
</div>
) : (
<Fragment>
<div class="ps-hero">
<div class="ps-hero-copy">
{type ? (
@@ -120,6 +240,8 @@ const hook = type ? '' : undefined;
))}
</div>
</div>
</Fragment>
)}
<div class="ps-footer">
{type ? (
<div class="ps-footer-links"><i data-type-footer-link></i><i data-type-footer-link></i><i data-type-footer-link></i><i data-type-footer-link></i></div>
@@ -130,7 +252,7 @@ const hook = type ? '' : undefined;
</div>
{/*
Off-grid marks, drawn only where the page has already left the field:
opacity rides --pvs-drift, so on the three disciplined answers they are
opacity rides --pvs-drift, so on the disciplined answers they are
rendered and invisible and the answers that pay for them are the ones
that asked. Absolute, so the desktop grid still has exactly its five
declared rows.
@@ -156,6 +278,67 @@ const hook = type ? '' : undefined;
{!type && <i class="ps-menu"></i>}
</div>
</div>
{ops ? (
<div class="ps-phone-body">
<strong class="ps-ops-title" data-type-section-title></strong>
<span class="ps-ops-metric">
<b data-type-figure></b>
<span data-type-proof></span>
</span>
{/* 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. */}
<div class="ps-ops-table">
{[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>
</span>
))}
</div>
<div class="ps-ops-panel">
{[0, 1].map((n) => (
<span class="ps-ops-switch">
<b data-type-switch></b>
<i class:list={['ps-ops-toggle', n === 0 && 'ps-ops-toggle--on']}></i>
</span>
))}
</div>
</div>
) : docs ? (
<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>
<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>
</span>
</div>
</div>
) : index ? (
<div class="ps-phone-body">
{/* No page title here. At this width the display step would take the
card, and what a handset shows of an index is the work. */}
{[0, 1].map(() => (
<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>
</div>
</Fragment>
))}
</div>
) : (
<div class="ps-phone-body">
<div class="ps-image"></div>
{type ? (
@@ -202,6 +385,7 @@ const hook = type ? '' : undefined;
))}
</div>
</div>
)}
<div class="ps-phone-footer">
{type ? (
<Fragment><i data-type-footer-link></i><i data-type-footer-link></i><i data-type-footer-link></i></Fragment>
@@ -211,4 +395,9 @@ const hook = type ? '' : undefined;
</div>
</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>
+28 -2
View File
@@ -5,11 +5,15 @@
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';
interface Option {
value: string;
title: string;
desc: string;
/* The sentence that stands in for the description on a surface this option
does not suit. Present only on options some surface rules out. */
blocked?: string;
}
interface Props {
@@ -28,6 +32,10 @@ interface Props {
const { id, step, slug, name, title, legend, cta, options, previewClass, phone = true } = 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];
---
<section class="picker-screen" data-screen={id} data-step={step} aria-hidden="true" aria-labelledby={titleId}>
@@ -44,7 +52,7 @@ const artboardClass = phone ? previewClass : `${previewClass} picker-artboard--s
<legend>{legend}</legend>
{options.map((option, index) => (
<label class="picker-strategy-option">
<label class="picker-strategy-option" data-blocked-reason={option.blocked}>
<input type="radio" name={name} value={option.value} checked={index === 0} />
<span class="picker-strategy-copy">
<span class="picker-strategy-title">{option.title}</span>
@@ -53,6 +61,20 @@ const artboardClass = phone ? previewClass : `${previewClass} picker-artboard--s
</label>
))}
</fieldset>
{/* One field per surface, disabled until its tile is chosen, so a
surface nobody asked for stays out of the answers and a surface
nobody opened still leaves one. A flat question keeps the same
fields for the same reading and withholds the name, which is the
whole of what a form submits. */}
{perSurface && SURFACE_MODES.map((mode) => (
<input
type="hidden"
data-surface-field={`${name}-${mode}`}
name={perSurface.flat ? undefined : `${name}-${mode}`}
disabled
/>
))}
</div>
<Artboard
@@ -60,7 +82,11 @@ const artboardClass = phone ? previewClass : `${previewClass} picker-artboard--s
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">
+214
View File
@@ -0,0 +1,214 @@
/*
The four kinds of surface a design system can be asked to serve, straight from
the skill's modes, and the questions that are answered once for each of them.
Values travel as `surface-modes` (multi-select) from screen 01b; the agent
pre-checks what PRODUCT.md suggests via cues.json, and the visitor corrects
it. Persuade is the markup default so the answer can never arrive empty on
runs whose cues carry no hint.
*/
export const SURFACE_MODES = ['persuade', 'operate', 'read', 'experience'];
/*
A question listed here is answered once per chosen surface rather than once
per run, because the answer that suits the marketing page rarely suits the
tool it sells. Each entry carries:
- `allow`, the options that surface can take. Anything left out stays in the
list, turned off, with the option's own `data-blocked-reason` in place of
its description. Whether an option is out belongs to the option and not to
the pairing, so the reason is written once beside the copy it replaces.
Omitted where the question rules nothing out, which is not the same as
listing nothing, so the attribute is left off rather than left empty.
- `fallback`, what the surface lands on when nobody ever opens its tab. Every
chosen surface leaves an answer, so every surface needs one. Omitted where
the options are not known until the run deals them, and the first row on
the list stands in.
- `answered` and `unanswered`, what the tab tells a screen reader. `{}` is
where the chosen option's own label goes, lowercased so it reads as part of
the sentence around it. `properName` turns that off for a question whose
options are named after something rather than described.
- `flat`, for a question the tabs exist to protect rather than to split. The
per-surface reading is still kept while the screen is open, so an option a
surface rules out cannot be the answer left behind, but only the leading
surface's choice is written down. A key per surface would promise whoever
reads the answers a distinction the run has nowhere to spend.
Persuade is allowed everything on every question: earning attention is the
whole job of the surface, and no answer here is too much for it. The other
three are ruled by what the surface is for rather than by how loud an option
is. A tool has to stay scannable, which rules out the loudest answers and,
on boundaries, the emptiest one as well. A page read at length is one plane
and one column. On a portfolio the work leads, so it is the interface that
has to recede; the page itself is still allowed to be dramatic, which is why
drenched survives there and four working colors do not.
*/
export const SURFACE_ANSWERS = {
'color-strategy': {
tablist: 'Surface being colored',
answered: 'colored {}',
unanswered: 'no color strategy chosen yet',
surfaces: {
persuade: { allow: 'restrained committed full-palette drenched', fallback: 'committed' },
operate: { allow: 'restrained committed full-palette', fallback: 'restrained' },
read: { allow: 'restrained committed full-palette', fallback: 'restrained' },
experience: { allow: 'restrained committed drenched', fallback: 'restrained' },
},
},
/*
Flat, and the only question here that rules nothing out. There is no pair
that suits a landing page and is forbidden on a dashboard; a pair that fails
the dashboard is a bad pair, and the model composing fonts.json is told to
rank all six against the strictest surface the run names. So no allow list
is written, and every row stays live on every tab.
The tabs earn their place all the same. One pair is chosen and one type
system comes out of the run, and the thing a visitor cannot otherwise do is
see that pair set as a dashboard, as a document, and as an index before
committing to it. The strip here is a way of looking rather than a second
decision.
*/
'font-pair': {
tablist: 'Surface the pair is shown on',
answered: 'set in {}',
unanswered: 'no pair chosen yet',
properName: true,
flat: true,
surfaces: {
persuade: {},
operate: {},
read: {},
experience: {},
},
},
/*
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
dashboard or a gallery, so the answer stays one value that every screen
after it inherits. What the tabs buy is the chance to say that a surface
cannot take an answer before it is chosen for it.
Freeform is out on the two surfaces you come back to. A block that has
left the grid is found by looking rather than by habit, and habit is what
a tool and a long document are read with: the sidebar was there last time,
the callout sat against the same measure a page ago. Persuade and the
portfolio keep it, because a page seen once has nothing to remember.
The two disciplined answers are kept by all four, even though the probe
showed them only a few points apart on a dashboard. What is being chosen
between them is which spans the page spends its columns on, and that is a
decision a tool makes as much as a landing page does; ruling one out for
being quiet would buy nothing.
Persuade lands on the aligned grid with breaks in it: a page earning a
decision needs one block to lead, and that is what the breaks are for.
The other three land on the even grid, for the reason each of them is
ruled by. A tool wants the layout predictable, a document is one column
and one rhythm, and a gallery hangs work square so the only irregular
edges on the page are the work's own.
*/
'layout-structure': {
tablist: 'Surface being laid out',
answered: '{} layout',
unanswered: 'no layout structure chosen yet',
flat: true,
surfaces: {
persuade: { allow: 'simple-grid balanced freeform', fallback: 'balanced' },
operate: { allow: 'simple-grid balanced', fallback: 'simple-grid' },
read: { allow: 'simple-grid balanced', fallback: 'simple-grid' },
experience: { allow: 'simple-grid balanced freeform', fallback: 'simple-grid' },
},
},
/*
Open space is out on a tool. Operate asks for a ground of its own under
sidebars, toolbars, and panels, and the open answer is defined by every
ground being the same one. Its default is that second ground rather than
panels, which spend an edge and an inset on every object and buy density
back at the price of it.
Cards and panels are out on the two surfaces whose content is the point.
A page read at length is one column, and a page of work is the work.
*/
'boundary-style': {
tablist: 'Surface being separated',
answered: 'separated by {}',
unanswered: 'no boundary style chosen yet',
surfaces: {
persuade: { allow: 'open-space thin-dividers surface-changes cards-and-panels', fallback: 'open-space' },
operate: { allow: 'thin-dividers surface-changes cards-and-panels', fallback: 'surface-changes' },
read: { allow: 'open-space thin-dividers surface-changes', fallback: 'open-space' },
experience: { allow: 'open-space thin-dividers surface-changes', fallback: 'open-space' },
},
},
/*
The least mode-sensitive question in the run, and the matrix says so. A
radius is not a claim on the reader's attention the way a color or a shadow
is, and the two surfaces that look like candidates both survive scrutiny:
fully round controls are the house style of an entire mobile platform, so
ruling them out of app UI would put this screen against Material rather
than against a mistake, and a document has too few shapes for the answer to
reach. Only the portfolio blocks anything, and its default is sharp, since
a gallery hangs work square and the work's own edges are the shapes on the
page.
*/
'corner-style': {
tablist: 'Surface being shaped',
answered: '{} corners',
unanswered: 'no corner style chosen yet',
surfaces: {
persuade: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' },
operate: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' },
read: { allow: 'sharp slightly-soft friendly pill', fallback: 'slightly-soft' },
experience: { allow: 'sharp slightly-soft friendly', fallback: 'sharp' },
},
},
/*
Floating is out wherever the page is worked in or read at length, which is
the line color strategy already drew for drenched. Lift that deep is how an
overlay says it is temporary; spent on every resting panel it stops meaning
anything and leaves a dense screen harder to scan. The portfolio keeps it:
lifting the work off the page is a way of presenting the work.
*/
'depth-style': {
tablist: 'Surface being lifted',
answered: '{} depth',
unanswered: 'no depth style chosen yet',
surfaces: {
persuade: { allow: 'flat soft-lift floating', fallback: 'soft-lift' },
operate: { allow: 'flat soft-lift', fallback: 'flat' },
read: { allow: 'flat soft-lift', fallback: 'flat' },
experience: { allow: 'flat soft-lift floating', fallback: 'flat' },
},
},
};
/* The matrix reaches the browser on the surface tiles, which is already where
the script looks for everything a surface knows about itself. One pair of
attributes per question, read by name rather than by dataset key so the
question's own value is the lookup. */
export const surfaceAttrs = (mode) => Object.fromEntries(
Object.entries(SURFACE_ANSWERS).flatMap(([name, question]) => {
const { allow, fallback } = question.surfaces[mode];
return [
...(allow ? [[`data-allow-${name}`, allow]] : []),
...(fallback ? [[`data-default-${name}`, fallback]] : []),
];
}),
);
/* Everything the script needs to run a per-surface question is on its tab
strip, so a screen opts in by rendering one of these and nothing else. */
export const surfaceTabsAttrs = (name) => ({
'data-surface-tabs': name,
'data-surface-flat': SURFACE_ANSWERS[name].flat ? '' : undefined,
'data-surface-proper-name': SURFACE_ANSWERS[name].properName ? '' : undefined,
'data-surface-answered': SURFACE_ANSWERS[name].answered,
'data-surface-unanswered': SURFACE_ANSWERS[name].unanswered,
role: 'tablist',
'aria-label': SURFACE_ANSWERS[name].tablist,
});
+152 -94
View File
@@ -3,6 +3,7 @@ import Picker from '../layouts/Picker.astro';
import Artboard from '../components/Artboard.astro';
import QuestionScreen from '../components/QuestionScreen.astro';
import iconPacks from '../data/icon-packs.json';
import { SURFACE_MODES, surfaceAttrs, surfaceTabsAttrs } from '../data/surfaces.js';
const roles = [
['primary', 'Primary'],
@@ -39,37 +40,9 @@ const iconRows = iconPacks.packs.map((pack) => ({
meta: `${pack.grid} grid / ${pack.weight} / ${pack.license}`,
}));
// The four kinds of surface a design system can be asked to serve, straight
// from the skill's modes. Values travel as `surface-modes` (multi-select);
// the agent pre-checks what PRODUCT.md suggests via cues.json, and the
// visitor corrects it. Persuade is the markup default so the answer can
// never arrive empty on runs whose cues carry no hint.
const surfaces = [
{
value: 'persuade',
title: 'Persuade',
goal: 'Grab attention and move people to act.',
examples: ['Landing pages', 'Marketing', 'Campaigns', 'Pricing'],
},
{
value: 'operate',
title: 'Operate',
goal: 'Feel familiar so work gets done fast.',
examples: ['App UI', 'Dashboards', 'Admin', 'Settings'],
},
{
value: 'read',
title: 'Read',
goal: 'Get out of the way of understanding.',
examples: ['Docs', 'Articles', 'Guides', 'Changelogs'],
},
{
value: 'experience',
title: 'Experience',
goal: 'Be the work itself.',
examples: ['Portfolios', 'Galleries', 'Showcases'],
},
];
// The surface tiles and the per-surface answer matrix both live in
// data/surfaces.js, so the tiles carry what each surface allows without the
// matrix being restated on every screen that reads it.
// The five questions that are answered by looking at the same wireframe page.
// Each one only rewrites the artboard slots it owns, so by the last screen the
@@ -124,15 +97,11 @@ const questions = [
title: 'Balanced',
desc: 'An aligned grid with a few deliberate breaks that carry the emphasis.',
},
{
value: 'editorial',
title: 'Editorial',
desc: 'Uneven columns, with one of them clearly leading.',
},
{
value: 'freeform',
title: 'Freeform',
desc: 'Blocks step out of the grid and overlap where it pays off.',
blocked: 'Blocks that step out of the grid have to be found again on a page people work in or read at length.',
},
],
},
@@ -150,6 +119,7 @@ const questions = [
value: 'open-space',
title: 'Open space',
desc: 'Nothing between sections but room. Spacing does the dividing.',
blocked: 'Sidebars and toolbars need a ground of their own, which is the one thing air cannot give them.',
},
{
value: 'thin-dividers',
@@ -165,6 +135,7 @@ const questions = [
value: 'cards-and-panels',
title: 'Cards and panels',
desc: 'Content sits inside containers with edges you can see.',
blocked: 'An edge around every block comes between people and the thing they came for.',
},
],
},
@@ -197,6 +168,7 @@ const questions = [
value: 'pill',
title: 'Pill-like',
desc: 'Buttons and tags go fully round.',
blocked: 'Fully round controls are the loudest shape on a page where the work should lead.',
},
],
},
@@ -215,11 +187,6 @@ const questions = [
title: 'Flat',
desc: 'One plane. Nothing casts a shadow.',
},
{
value: 'layered',
title: 'Layered',
desc: 'Tone stacks the page. Still no shadows anywhere.',
},
{
value: 'soft-lift',
title: 'Soft lift',
@@ -229,6 +196,7 @@ const questions = [
value: 'floating',
title: 'Floating',
desc: 'Panels are well above the surface, with the lift to prove it.',
blocked: 'Lift this deep belongs to menus and dialogs, not to a page people work in or read at length.',
},
],
},
@@ -292,7 +260,7 @@ const questions = [
<legend>Surfaces</legend>
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="persuade" aria-label="Landing pages, marketing, campaigns, and pricing" data-surface-label="Landing page" data-strategies="restrained committed full-palette drenched" data-strategy-default="committed" checked />
<input type="checkbox" name="surface-modes" value="persuade" aria-label="Landing pages, marketing, campaigns, and pricing" data-surface-label="Landing page" {...surfaceAttrs('persuade')} checked />
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview">
<span class="pv-desktop">
@@ -332,7 +300,7 @@ const questions = [
</label>
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="operate" aria-label="App UI, dashboards, admin, and settings" data-surface-label="App UI" data-strategies="restrained committed full-palette" data-strategy-default="restrained" />
<input type="checkbox" name="surface-modes" value="operate" aria-label="App UI, dashboards, admin, and settings" data-surface-label="App UI" {...surfaceAttrs('operate')} />
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--ops">
<span class="pv-desktop">
@@ -359,21 +327,21 @@ const questions = [
<span class="po-rows">
<span class="po-row">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 15.24"></i>
<i class="po-cell" style="--w: 12.76"></i>
<i class="po-cell" style="--w: 12.76"></i>
</span>
<span class="po-row po-row--on">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.61cqh"></i>
<i class="po-cell" style="--w: 15.24"></i>
<i class="po-cell" style="--w: 12.76"></i>
<i class="po-cell" style="--w: 12.61"></i>
</span>
<span class="po-row">
<i class="po-dot"></i>
<i class="po-cell" style="--w: 15.24cqh"></i>
<i class="po-cell" style="--w: 12.76cqh"></i>
<i class="po-cell" style="--w: 12.61cqh"></i>
<i class="po-cell" style="--w: 15.24"></i>
<i class="po-cell" style="--w: 12.76"></i>
<i class="po-cell" style="--w: 12.61"></i>
</span>
</span>
</span>
@@ -421,7 +389,7 @@ const questions = [
</label>
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="read" aria-label="Docs, articles, guides, and changelogs" data-surface-label="Docs" data-strategies="restrained committed full-palette" data-strategy-default="restrained" />
<input type="checkbox" name="surface-modes" value="read" aria-label="Docs, articles, guides, and changelogs" data-surface-label="Docs" {...surfaceAttrs('read')} />
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--docs">
<span class="pv-desktop">
@@ -476,7 +444,7 @@ const questions = [
</label>
<label class="picker-mode-tile">
<input type="checkbox" name="surface-modes" value="experience" aria-label="Portfolios, galleries, and showcases" data-surface-label="Portfolio" data-strategies="restrained committed drenched" data-strategy-default="restrained" />
<input type="checkbox" name="surface-modes" value="experience" aria-label="Portfolios, galleries, and showcases" data-surface-label="Portfolio" {...surfaceAttrs('experience')} />
<span class="picker-mode-preview picker-mode-preview--exact" aria-hidden="true">
<span class="picker-preview picker-preview--gallery">
<span class="pv-desktop">
@@ -505,10 +473,10 @@ const questions = [
<span class="pg-rail">
<i class="pg-arrow"></i>
<span class="pg-track">
<i class="pg-track-on" style="--w:20.31cqh"></i>
<i style="--w:9.14cqh"></i>
<i style="--w:8.98cqh"></i>
<i style="--w:12.44cqh"></i>
<i class="pg-track-on" style="--w: 20.31"></i>
<i style="--w: 9.14"></i>
<i style="--w: 8.98"></i>
<i style="--w: 12.44"></i>
</span>
<i class="pg-arrow pg-arrow--next"></i>
</span>
@@ -747,7 +715,10 @@ const questions = [
</span>
</label>
<label class="picker-strategy-option">
{/* The reason a surface cannot take an option replaces the
description on the row, so it is written here beside the
copy it stands in for rather than in the script. */}
<label class="picker-strategy-option" data-blocked-reason="Four colors on duty compete with the work on show.">
<input type="radio" name="color-strategy" value="full-palette" />
<span class="picker-strategy-copy">
<span class="picker-strategy-title">Full palette</span>
@@ -755,7 +726,7 @@ const questions = [
</span>
</label>
<label class="picker-strategy-option">
<label class="picker-strategy-option" data-blocked-reason="Too loud for a page people work in or read at length.">
<input type="radio" name="color-strategy" value="drenched" />
<span class="picker-strategy-copy">
<span class="picker-strategy-title">Drenched</span>
@@ -768,8 +739,8 @@ const questions = [
answer, so each has a field to leave it in. They stay disabled
until their tile is chosen, which is what keeps a surface
nobody asked for out of the answers. */}
{surfaces.map(({ value }) => (
<input type="hidden" name={`color-strategy-${value}`} data-surface-strategy={value} disabled />
{SURFACE_MODES.map((mode) => (
<input type="hidden" data-surface-field={`color-strategy-${mode}`} name={`color-strategy-${mode}`} disabled />
))}
<div class="picker-bands picker-bands--compact" data-band-scope="strategy">
@@ -795,25 +766,27 @@ const questions = [
<p class="picker-band-status" data-reorder-status role="status" aria-live="polite"></p>
</div>
{/* The stage the chosen surfaces are drawn on. Its contents are
lifted from the tiles on screen 01b, so it is filled by script;
the ratio is the artboard's so the CTA sits where it sits on
every other screen. */}
<div class="picker-strategy-stage" data-strategy-stage>
<div class="picker-surface-tabs" data-surface-tabs role="tablist" aria-label="Surface being colored" hidden></div>
</div>
</div>
<div class="picker-strategy-subgrid">
{/* The stage the chosen surfaces are drawn on. Its contents are
lifted from the tiles on screen 01b, so it is filled by
script. The clones land inside the stage, which is why the
buttons are its sibling rather than its last child. */}
<div class="picker-strategy-stage" data-surface-stage>
<div class="picker-surface-tabs" {...surfaceTabsAttrs('color-strategy')} hidden></div>
</div>
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this strategy
<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>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this strategy
<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>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</div>
</div>
@@ -847,20 +820,37 @@ const questions = [
</div>
</div>
<Artboard mode="type" class="picker-strategy-preview picker-preview-type" typeHook />
{/* One board per surface, all mounted and one shown. The stage is
the box the strip docks on and the node the chosen pair's
faces are written to, so every board is set in them at once. */}
<div class="picker-type-stage" data-type-stage>
<div class="picker-surface-tabs" {...surfaceTabsAttrs('font-pair')} hidden></div>
<Artboard mode="type" surface="persuade" class="picker-strategy-preview picker-preview-type" typeHook />
<Artboard mode="type" surface="operate" class="picker-strategy-preview picker-preview-type picker-preview-type--ops" typeHook />
<Artboard mode="type" surface="read" class="picker-strategy-preview picker-preview-type picker-preview-type--read" typeHook />
<Artboard mode="type" surface="experience" class="picker-strategy-preview picker-preview-type picker-preview-type--experience" typeHook />
</div>
</div>
<template data-pair-card>
<label class="picker-strategy-option picker-type-option">
<input type="radio" name="font-pair" />
<span class="picker-type-copy">
<span class="picker-type-sample-heading"><span data-pair-heading></span><span class="picker-type-role">headings</span></span>
<span class="picker-type-sample-heading"><span class="picker-strategy-title" data-pair-heading></span><span class="picker-type-role">headings</span></span>
<span class="picker-type-sample-body"><span data-pair-body></span><span class="picker-type-role">body text</span></span>
<span class="picker-type-desc" data-pair-why></span>
<span class="picker-type-desc picker-strategy-desc" data-pair-why></span>
</span>
</label>
</template>
{/* The pair is one answer for the whole run, so these fields carry
no name and nothing but the strip reads them. They are what
stops a tab from putting its own surface's reading of the
question into the answers. */}
{SURFACE_MODES.map((mode) => (
<input type="hidden" data-surface-field={`font-pair-${mode}`} disabled />
))}
<input type="hidden" name="font-heading" />
<input type="hidden" name="font-body" />
<input type="hidden" name="font-heading-source" />
@@ -926,22 +916,90 @@ const questions = [
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 7.5 5.5 5 5.5-5"></path></svg>
</button>
</div>
</div>
</div>
<div class="picker-scale-column">
<div class="picker-scale-sheet" data-scale-sheet aria-hidden="true">
{scaleRows.map(([tag, step]) => (
<div class="ts-row" data-scale-row={step}>
<p class="ts-sample" data-scale-sample></p>
<span class="ts-tag">{tag}</span>
<span class="ts-values">
<b data-scale-px></b>
<i data-scale-rem></i>
</span>
</div>
))}
</div>
<div class="picker-type-controls">
<p class="picker-scale-note">Values at a 16px base, specimen scaled to fit</p>
</div>
</div>
<div class="picker-scale-sheet" data-scale-sheet aria-hidden="true">
{scaleRows.map(([tag, step]) => (
<div class="ts-row" data-scale-row={step}>
<p class="ts-sample" data-scale-sample></p>
<span class="ts-tag">{tag}</span>
<span class="ts-values">
<b data-scale-px></b>
<i data-scale-rem></i>
</span>
</div>
))}
{/* The same scale on the components a page is actually built from.
Headings are placeholders and running text is lorem, for the
reason the wireframe copy is: a face and a ratio are judged on
texture, and real prose pulls the eye into reading it instead.
Every step the sheet lists has a component here, which is why
the lede and the quote exist at all: they carry the two steps
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">
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">
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">
<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">
<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">
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">
<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) &#123;
return base * ratio ** n;
&#125;</code></pre>
<p class="sp-h4">A minor heading</p>
<p class="sp-p">
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et
voluptates repudiandae sint et molestiae non recusandae.
</p>
</div>
<div class="picker-type-scroll">
<button class="picker-icon-button" type="button" data-list-scroll="-1" aria-label="Scroll the specimen up">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 12.5 5.5-5 5.5 5"></path></svg>
</button>
<button class="picker-icon-button" type="button" data-list-scroll="1" aria-label="Scroll the specimen down">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 7.5 5.5 5 5.5-5"></path></svg>
</button>
</div>
</div>
</div>
+413 -211
View File
@@ -13,11 +13,13 @@ const hint = $('[data-palette-hint]');
const ringGuide = $('[data-ring-guide]');
const loupe = $('[data-loupe]');
let preview = $('.picker-preview');
const typePreview = document.querySelector('[data-type-preview]');
const typeStage = document.querySelector('[data-type-stage]');
const typeBoards = [...document.querySelectorAll('[data-type-preview]')];
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
const scaleOptions = document.querySelector('[data-scale-options]');
const scaleSheet = document.querySelector('[data-scale-sheet]');
const scaleSpecimen = document.querySelector('[data-scale-specimen]');
const states = new Map();
const canvases = new WeakMap();
let cards = [];
@@ -31,12 +33,55 @@ let fontManifest;
const LOREM = {
sentence: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.',
paragraph: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo.',
/* Two of them rather than one long one, because what the Read board has to
show is the texture of a block and the step between blocks, and a single
paragraph shows only the first. Their length is what the widest-setting
pair leaves room for at the smallest frame the card is drawn at. */
passages: [
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur sint occaecat cupidatat.',
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione.',
],
note: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque.',
caption: 'Lorem ipsum dolor sit amet, consectetur adipiscing.',
};
/* The desktop artboard sets three cards and the phone two, so a fourth would
be words the agent writes and nobody ever reads. */
const GALLERY_CARDS = 3;
/* The words an interface supplies rather than the product: a tool's own rail,
the headings over its columns, the figures under them, and the two rows of a
settings panel. Kept here beside LOREM and for the same reason. What this
board has to prove is that the pair draws lining numerals that hold a column
and a semibold label that stays inside one, and both are properties of the
face. A column is also the tightest slot on any of the boards, so its words
cannot be left to whatever the run happens to be selling. */
const APP = {
rail: ['Overview', 'Reports', 'Settings'],
columns: ['Item', 'Status', 'Amount'],
figures: ['1,284', '98.2%', '41'],
amounts: ['$12,400', '$3,860', '$9,215'],
panel: ['Preferences', 'Last 30 days'],
switches: ['Email digest', 'Compact rows'],
};
/* The same argument as APP, for the surface where the words belong to the
document rather than to the product. The rail lists sections of one page and
the crumb says where that page sits, neither of which the manifest's nav can
stand in for without the board reading as the same four words twice. */
const DOCS = {
rail: ['Getting started', 'Install', 'Configuration', 'API reference'],
crumb: 'Docs / Getting started',
note: 'Note',
};
/* Same again for the index. The carousel's stops name parts of a body of work,
so the footer's links cannot stand in for them: the two lists sit a band
apart on the same board and would read as one list printed twice. */
const INDEX = {
stops: ['Selected', 'Archive', 'Studio', 'Contact'],
};
const FALLBACK_FONTS = {
version: 1,
specimen: {
@@ -290,14 +335,53 @@ function fillIndexed(root, selector, values) {
});
}
function fillGallery(root, gallery) {
if (!root) return;
root.querySelectorAll('.ps-gallery-item').forEach((item, index) => {
const title = item.querySelector('[data-type-gallery-title]');
const meta = item.querySelector('[data-type-gallery-meta]');
if (title) title.textContent = gallery[index]?.title ?? '';
if (meta) meta.textContent = gallery[index]?.meta ?? '';
});
/* One board's worth of copy. Every board is filled through the same hooks, so
a slot means the same thing wherever it appears: [data-type-gallery-title]
names an item whether the item is a card, a table row, or a piece of work,
and a board takes the slots its surface has and leaves the rest alone. The
desktop and the phone are filled separately because the indexed slots start
counting again on each. */
function fillBoard(board, preview, specimen) {
const desktop = board.querySelector('.ps-desktop');
const phoneBody = board.querySelector('.ps-phone-body');
const phoneFooter = board.querySelector('.ps-phone-footer');
// A rail means the sections of a document on one board and the areas of a
// tool on the other, so it is the one slot whose words the surface decides.
const rail = board.dataset.surface === 'read' ? DOCS.rail : APP.rail;
const fill = (selector, value) => {
for (const node of board.querySelectorAll(selector)) node.textContent = value;
};
fill('[data-type-brand]', preview.brand);
fill('[data-type-nav-action]', preview.navAction);
fill('[data-type-menu-action]', preview.menuAction);
fill('[data-type-headline]', specimen.headline);
fill('[data-type-body]', LOREM.sentence);
fill('[data-type-cta-primary]', preview.ctaPrimary);
fill('[data-type-cta-secondary]', preview.ctaSecondary);
fill('[data-type-section-title]', preview.sectionTitle);
fill('[data-type-section-body]', LOREM.paragraph);
fill('[data-type-section-link]', preview.sectionLink);
fill('[data-type-footer-mark]', preview.footerMark);
fill('[data-type-note-label]', DOCS.note);
fill('[data-type-note-body]', LOREM.note);
fill('[data-type-crumb]', DOCS.crumb);
fill('[data-type-caption]', LOREM.caption);
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-stop]', INDEX.stops);
fillIndexed(card, '[data-type-rail]', rail);
fillIndexed(card, '[data-type-column]', APP.columns);
fillIndexed(card, '[data-type-figure]', APP.figures);
fillIndexed(card, '[data-type-amount]', APP.amounts);
fillIndexed(card, '[data-type-panel]', APP.panel);
fillIndexed(card, '[data-type-switch]', APP.switches);
}
fillIndexed(desktop?.querySelector('.ps-footer'), '[data-type-footer-link]', preview.footerLinks);
fillIndexed(phoneFooter, '[data-type-footer-link]', preview.footerLinks);
}
function syncFontPair(pair) {
@@ -308,43 +392,14 @@ function syncFontPair(pair) {
...pair.preview,
};
const specimen = { ...manifest.specimen, ...pair.specimen };
const desktop = typePreview.querySelector('.ps-desktop');
const phoneBody = typePreview.querySelector('.ps-phone-body');
const phoneFooter = typePreview.querySelector('.ps-phone-footer');
// The scale sheet is set in the pair chosen here, so it travels with it.
for (const target of [typePreview, scaleSheet]) {
// Written once on the stage the boards share, and on screen 05's two preview
// columns, which are set in the pair chosen here so the faces travel with it.
for (const target of [typeStage, scaleSheet, scaleSpecimen]) {
target.style.setProperty('--pt-heading', fontStack(pair.heading.family));
target.style.setProperty('--pt-body', fontStack(pair.body.family));
target.style.setProperty('--pt-heading-weight', pair.heading.weight);
}
for (const node of document.querySelectorAll('[data-type-brand]')) node.textContent = preview.brand;
fillIndexed(desktop, '[data-type-nav]', preview.nav);
for (const node of document.querySelectorAll('[data-type-nav-action]')) node.textContent = preview.navAction;
for (const node of document.querySelectorAll('[data-type-menu-action]')) node.textContent = preview.menuAction;
for (const node of document.querySelectorAll('[data-type-headline]')) node.textContent = specimen.headline;
for (const node of document.querySelectorAll('[data-type-body]')) node.textContent = LOREM.sentence;
document.querySelectorAll('[data-type-cta-primary]').forEach((node) => {
node.textContent = preview.ctaPrimary;
});
document.querySelectorAll('[data-type-cta-secondary]').forEach((node) => {
node.textContent = preview.ctaSecondary;
});
fillIndexed(desktop, '[data-type-proof]', preview.proof);
fillIndexed(phoneBody, '[data-type-proof]', preview.proof);
document.querySelectorAll('[data-type-section-title]').forEach((node) => {
node.textContent = preview.sectionTitle;
});
for (const node of document.querySelectorAll('[data-type-section-body]')) node.textContent = LOREM.paragraph;
document.querySelectorAll('[data-type-section-link]').forEach((node) => {
node.textContent = preview.sectionLink;
});
fillGallery(desktop, preview.gallery);
fillGallery(phoneBody, preview.gallery);
fillIndexed(desktop.querySelector('.ps-footer'), '[data-type-footer-link]', preview.footerLinks);
fillIndexed(phoneFooter, '[data-type-footer-link]', preview.footerLinks);
document.querySelectorAll('[data-type-footer-mark]').forEach((node) => {
node.textContent = preview.footerMark;
});
for (const board of typeBoards) fillBoard(board, preview, specimen);
document.querySelector('[name="font-heading"]').value = pair.heading.family;
document.querySelector('[name="font-body"]').value = pair.body.family;
document.querySelector('[name="font-heading-source"]').value = pair.heading.source || '';
@@ -391,6 +446,9 @@ function renderFontPairs(manifest, fallback) {
manifest.pairs.forEach((pair, index) => addPairCard(pair, { checked: index === 0 }));
loadFontStylesheet(manifest.pairs);
syncFontPair(manifest.pairs[0]);
// The tab strip was built before the run had a pair to name, so it is told
// once the rows exist.
syncSurfaces();
applyHoist();
}
@@ -403,9 +461,13 @@ fontOptions.onchange = ({ target }) => {
/* Scroll by whole rows so an option never ends up half in frame, and disable
an arrow at the end it points to, since a live arrow that does nothing is
the reason the list looked unscrollable in the first place. */
the reason the list looked unscrollable in the first place.
Screen 05's specimen wears this too, and it has no rows: a page of prose is
paged by most of its own frame instead, which leaves a couple of lines of
overlap so the reader can find where they were. */
function wireListScroll(list) {
const buttons = [...list.closest('.picker-type-rail').querySelectorAll('[data-list-scroll]')];
const buttons = [...list.closest('.picker-type-rail, .picker-scale-column').querySelectorAll('[data-list-scroll]')];
const sync = () => {
const room = list.scrollHeight - list.clientHeight;
for (const button of buttons) {
@@ -416,7 +478,8 @@ function wireListScroll(list) {
};
for (const button of buttons) {
button.onclick = () => {
const step = list.querySelector('.picker-strategy-option')?.offsetHeight || 100;
const step = list.querySelector('.picker-strategy-option')?.offsetHeight
|| Math.round(list.clientHeight * 0.82);
list.scrollBy({ top: step * Number(button.dataset.listScroll), behavior: 'smooth' });
};
}
@@ -546,20 +609,33 @@ typeRail.addEventListener('focusout', ({ relatedTarget }) => {
/* Type scale.
The numbers are the real ones: step n is 16px * ratio^n, and a Golden Ratio
H1 really is 287px. The specimen cannot be, because 287px of "This is the
Golden Ratio scale" is eight times the width of the sheet, and a sheet fitted
to that H1 would set its paragraph at 2px.
H1 really is 287px. The specimen cannot be, because 287px of "Golden Ratio"
is three times the width of the sheet, and a sheet fitted to that H1 would
set its paragraph at 2px.
So the rendering compresses the exponent by half, which keeps every scale in
its own character (a Minor Second sheet still reads as nearly flat, a Golden
Ratio one as dramatic) while bringing the range from 18x down to about 4x.
The measured fit below then scales the whole sheet if even that overflows.
The quoted px and rem stay untouched, which is the point of showing them. */
The quoted px and rem stay untouched, which is the point of showing them.
Both preview columns are set from these sizes. The sheet takes the measured
fit on top of them, because seven rows have to hold inside a box that cannot
grow; the reading column scrolls instead, so it sets the step values as they
come. That is the only place the two columns differ.
The sheet's sample is the scale's name and nothing more. A sentence there was
costing the sheet its size: the longest name inside "This is the ... scale"
ran to twice the width of a column shared three ways, so the fit halved every
step to hold it, and the scale with the longest name came out flatter than
the one below it. Running text belongs to the specimen column now. */
const SCALE_BASE = 16;
const SCALE_BODY_PX = 13;
const SCALE_COMPRESSION = 0.5;
const scaleRows = [...scaleSheet.querySelectorAll('[data-scale-row]')];
const scaleRatioInput = document.querySelector('[name="type-scale-ratio"]');
const checkedScale = () => scaleOptions.querySelector('input[name="type-scale"]:checked');
const scaleRowInput = (node) => node?.closest('.picker-strategy-option')?.querySelector('input');
const trimZeros = (value) => value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
@@ -580,29 +656,53 @@ function fitScaleSheet() {
if (over > 1.001) scaleSheet.style.setProperty('--ts-fit', (1 / over).toFixed(4));
}
function syncTypeScale(input) {
function drawTypeScale(input) {
const ratio = Number(input.dataset.ratio);
const name = input.dataset.scaleName;
for (const row of scaleRows) {
const step = Number(row.dataset.scaleRow);
const px = SCALE_BASE * ratio ** step;
row.style.setProperty('--ts-size', (SCALE_BODY_PX * ratio ** (step * SCALE_COMPRESSION)).toFixed(3));
row.querySelector('[data-scale-sample]').textContent = `This is the ${name} scale`;
const rendered = (SCALE_BODY_PX * ratio ** (step * SCALE_COMPRESSION)).toFixed(3);
row.style.setProperty('--ts-size', rendered);
scaleSpecimen.style.setProperty(`--ts-step-${step}`, rendered);
row.querySelector('[data-scale-sample]').textContent = name;
row.querySelector('[data-scale-px]').textContent = `${Math.round(px)}px`;
row.querySelector('[data-scale-rem]').textContent = `${trimZeros((px / SCALE_BASE).toFixed(2))}rem`;
}
scaleRatioInput.value = ratio.toFixed(3);
fitScaleSheet();
}
/* The cursor previews and the click commits, the contract screens 03 and 11
already use. Drawing is everything the two columns show; committing is the
one line that records an answer, so a browsed row cannot leave one behind. */
function commitTypeScale(input) {
drawTypeScale(input);
scaleRatioInput.value = Number(input.dataset.ratio).toFixed(3);
}
scaleOptions.addEventListener('pointerover', (event) => {
const input = scaleRowInput(event.target);
if (input) drawTypeScale(input);
});
scaleOptions.addEventListener('focusin', (event) => {
const input = scaleRowInput(event.target);
if (input) drawTypeScale(input);
});
const restScalePreview = restWhenIdle(scaleOptions, (focused) => {
drawTypeScale(scaleRowInput(focused) ?? checkedScale());
});
scaleOptions.addEventListener('pointerleave', restScalePreview);
scaleOptions.addEventListener('focusout', restScalePreview);
scaleOptions.onchange = ({ target }) => {
if (target.matches('input[name="type-scale"]')) syncTypeScale(target);
if (target.matches('input[name="type-scale"]')) commitTypeScale(target);
};
const syncSpecimenScroll = wireListScroll(scaleSpecimen);
new ResizeObserver(fitScaleSheet).observe(scaleSheet);
// Every face swap changes the width of the same string, fit included.
document.fonts?.addEventListener('loadingdone', fitScaleSheet);
syncTypeScale(scaleOptions.querySelector('input:checked'));
commitTypeScale(checkedScale());
/* Screen 11: the icon specimen. Every pack draws the same twenty-four concepts,
so the sheet compares hands rather than catalogs. The drawings are vendored
@@ -1576,16 +1676,12 @@ document.addEventListener('picker:screenchange', (event) => {
}
// Coming back to the strategy screen from further along, where the palette may
// have been reordered on the screen it was left on.
if (event.detail.screen === '03') {
paintStrategyBands();
paintStage();
} else {
/* Everywhere else previews the leading surface's answer, so the radio the
later screens read is parked there whenever 03 is off screen. Without
this the run would carry whichever surface was last on the tab, and a
strategy switched off for that surface would leave the answer empty. */
showSurface(chosenSurfaces()[0]?.value);
}
if (event.detail.screen === '03') paintStrategyBands();
// A per-surface question holds whatever tab it was left on while its own
// screen is up, and the rest follow it.
const leader = surfaceQuestions.find((question) => question.screen === event.detail.screen);
leader?.paint();
alignSurfaces(leader);
// Arriving is the quietest moment there is, so the rail settles here even
// if it is already in order: the chosen pair is the row you land on.
if (event.detail.screen === '04') {
@@ -1602,12 +1698,23 @@ document.addEventListener('picker:screenchange', (event) => {
});
}
// A hidden sheet measures zero, so the fit can only be resolved on arrival.
// The scroll waits a frame: the screen change focuses the first control after
// The rest waits a frame: the screen change focuses the first control after
// this event, and that scrolls the list back to the top.
//
// It also puts focus on the first interval rather than the chosen one, and
// this list's preview reads focus twice: once to draw, and once to decide
// what to fall back to when the pointer leaves. Left alone, a visitor arrives
// to two columns set in an interval they never picked, and gets it again the
// first time they browse the list and come back. So the answer takes the
// focus, which is where a radio group's focus belongs anyway.
if (event.detail.screen === '05') {
fitScaleSheet();
const chosen = checkedScale();
requestAnimationFrame(() => {
scaleOptions.querySelector('input:checked')?.parentElement.scrollIntoView({ block: 'center' });
chosen?.parentElement.scrollIntoView({ block: 'center' });
chosen?.focus({ preventScroll: true });
if (chosen) drawTypeScale(chosen);
syncSpecimenScroll();
});
}
// The specimen is fetched the first time the screen is asked for, and the
@@ -1645,8 +1752,8 @@ const landingPreview = preview.cloneNode(true);
let previewSource;
function syncModePreview() {
// Screen 03 draws every chosen surface, not just the leading one, so it is
// rebuilt from here: every path that changes the tiles already runs this.
// The per-surface questions read the tiles rather than watch them, so they
// are rebuilt from here: every path that changes the tiles already runs this.
syncSurfaces();
const chosen = modeInputs.findIndex((input) => input.checked);
// A tile drawn in something other than this component keeps the landing page,
@@ -1664,162 +1771,257 @@ function syncModePreview() {
renderPreview();
}
/* Screen 03 colors the surfaces that were chosen rather than one fixed page,
and it colors each of them separately: the answer that suits the marketing
page rarely suits the tool it sells. Every chosen tile's drawing is mounted
on the stage, one is shown, and a tab in the frame's corner carries between
them when there is more than one to carry between. */
const stage = document.querySelector('[data-strategy-stage]');
const surfaceTabs = document.querySelector('[data-surface-tabs]');
const strategyInputs = [...document.querySelectorAll('input[name="color-strategy"]')];
const strategyRows = new Map(strategyInputs.map((input) => [input.value, input.closest('.picker-strategy-option')]));
/* ============================================================
Questions answered once per surface.
/* Why a strategy is out belongs to the strategy, not to the pairing, so it is
written once here rather than once per surface that rules it out. */
const BLOCKED_BECAUSE = {
drenched: 'Too loud for a page people work in or read at length.',
'full-palette': 'Four colors on duty compete with the work on show.',
};
Several screens ask the same shape of question: the answer that suits the
marketing page rarely suits the tool it sells, so it is asked once for each
surface chosen on 01b. A tab on the frame's corner carries between them, an
option a surface cannot take is turned off in place with the reason where
its description was, and every chosen surface leaves an answer whether or
not anyone ever opened its tab.
A screen opts in by rendering one tab strip and nothing else:
<div class="picker-surface-tabs" data-surface-tabs="<radio group name>"
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
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
the question's own business. What each surface may take and where it
lands untouched are already on the tiles as data-allow-<group> and
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.
data-surface-stage on the frame additionally mounts one drawing per chosen
surface, lifted from that surface's tile and painted with the committed
palette. Screen 03 is the only screen that wants that today. A screen that
draws its own per-surface variants instead marks each of them
data-surface="<surface>" in its own markup and leaves the attribute off; the
showing and hiding is the same work either way.
============================================================ */
const chosenSurfaces = () => modeInputs.filter((input) => input.checked);
const surfaceInput = (value) => modeInputs.find((input) => input.value === value);
const allowedFor = (value) => (surfaceInput(value)?.dataset.strategies ?? '').split(' ').filter(Boolean);
const defaultFor = (value) => surfaceInput(value)?.dataset.strategyDefault ?? 'restrained';
const strategyField = (value) => document.querySelector(`[data-surface-strategy="${value}"]`);
const strategyTitle = (value) => strategyRows.get(value)?.querySelector('.picker-strategy-title').textContent ?? value;
let activeSurface = null;
/* One drawing per chosen surface, painted with the committed palette. All of
them stay mounted and one is shown, so a tab switch costs a hidden attribute
rather than a rebuild and the frame never blinks. */
function syncSurfaces() {
if (!stage) return;
const chosen = chosenSurfaces();
for (const node of stage.querySelectorAll('[data-surface]')) node.remove();
for (const input of chosen) {
const source = modePreviews[modeInputs.indexOf(input)];
if (!source) continue;
const clone = source.cloneNode(true);
// Decorative here as on the tile, but the marker sits on the tile's
// wrapper rather than on the drawing, so it does not survive the lift.
clone.setAttribute('aria-hidden', 'true');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
clone.dataset.surface = input.value;
stage.append(clone);
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.
const frame = tabs.parentElement;
const screen = tabs.closest('.picker-screen')?.dataset.screen;
const mounts = 'surfaceStage' in frame.dataset;
const flat = 'surfaceFlat' in tabs.dataset;
const properName = 'surfaceProperName' in tabs.dataset;
/* The rows are markup on every screen but the font one, where they are dealt
from fonts.json after this runs and can still be added to afterwards. So
the group is read when it is needed rather than captured once. */
const optionInputs = () => [...document.querySelectorAll(`input[name="${name}"]`)];
const rowOf = (value) => optionInputs().find((input) => input.value === value)?.closest('.picker-strategy-option');
// Read by attribute name rather than through dataset, so the group's own
// value is the lookup and no screen has to restate it in camel case. A
// question that rules nothing out carries no attribute at all, which is a
// different answer from an empty one and is kept apart from it here.
const allowedFor = (value) => {
const allow = surfaceInput(value)?.getAttribute(`data-allow-${name}`);
return allow == null ? null : new Set(allow.split(' ').filter(Boolean));
};
const defaultFor = (value) => surfaceInput(value)?.getAttribute(`data-default-${name}`) || optionInputs()[0]?.value;
const fieldFor = (value) => document.querySelector(`input[type="hidden"][data-surface-field="${name}-${value}"]`);
const titleOf = (value) => rowOf(value)?.querySelector('.picker-strategy-title')?.textContent ?? value;
let activeSurface = null;
/* One drawing per chosen surface. All of them stay mounted and one is shown,
so a tab switch costs a hidden attribute rather than a rebuild and the
frame never blinks. */
function mount(chosen) {
for (const node of frame.querySelectorAll('[data-surface]')) node.remove();
for (const input of chosen) {
const source = modePreviews[modeInputs.indexOf(input)];
if (!source) continue;
const clone = source.cloneNode(true);
// Decorative here as on the tile, but the marker sits on the tile's
// wrapper rather than on the drawing, so it does not survive the lift.
clone.setAttribute('aria-hidden', 'true');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
clone.dataset.surface = input.value;
frame.append(clone);
}
paint();
}
paintStage();
/* Every chosen surface leaves an answer whether or not it was ever opened,
so the field is filled with the default the moment the tile is chosen and
the tab reports it as unset until someone says otherwise. */
for (const input of modeInputs) {
const field = strategyField(input.value);
if (!field) continue;
field.disabled = !input.checked;
if (!input.checked) {
field.value = '';
delete field.dataset.chosen;
} else if (!field.value) {
field.value = defaultFor(input.value);
/* Painted once on the frame rather than on each drawing inside it, so the
strategy layer keeps a fixed reading of what was chosen and the drawings
themselves carry no inline color for it to argue with. */
function paint() {
if (mounts) syncCommittedPalette(frame, 'pkc');
}
function sync() {
const chosen = chosenSurfaces();
if (mounts) mount(chosen);
/* Every chosen surface leaves an answer whether or not it was ever opened,
so the field is filled with the default the moment the tile is chosen and
the tab reports it as unset until someone says otherwise. */
for (const input of modeInputs) {
const field = fieldFor(input.value);
if (!field) continue;
field.disabled = !input.checked;
if (!input.checked) {
field.value = '';
delete field.dataset.chosen;
} else if (!field.value) {
// The font screen's rows are dealt after this first runs, so a question
// with nothing to fall back on yet leaves the field to the sync that
// follows the fetch.
field.value = defaultFor(input.value) ?? '';
}
}
buildTabs(chosen);
show(chosen.some((input) => input.value === activeSurface) ? activeSurface : chosen[0]?.value);
}
/* One surface needs no tabs: the frame is already showing the only answer
there is. The dot is the whole report on state, filled once the surface has
been answered deliberately and hollow while it is still holding a default. */
function buildTabs(chosen) {
tabs.hidden = chosen.length < 2;
tabs.replaceChildren(...chosen.map((input) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'picker-surface-tab';
tab.dataset.surfaceTab = input.value;
tab.innerHTML = '<span class="picker-surface-dot"></span>';
tab.append(input.dataset.surfaceLabel ?? input.value);
tab.onclick = () => choose(input.value);
return tab;
}));
markTabs();
}
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;
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}`);
}
}
buildTabs(chosen);
showSurface(chosen.some((input) => input.value === activeSurface) ? activeSurface : chosen[0]?.value);
}
/* Painted once on the frame rather than on each drawing inside it, so the
strategy layer keeps a fixed reading of what was chosen and the drawings
themselves carry no inline color for it to argue with. */
function paintStage() {
syncCommittedPalette(stage, 'pkc');
}
/* One surface needs no tabs: the frame is already showing the only answer
there is. The dot is the whole report on state, filled once the surface has
been answered deliberately and hollow while it is still holding a default. */
function buildTabs(chosen) {
if (!surfaceTabs) return;
surfaceTabs.hidden = chosen.length < 2;
surfaceTabs.replaceChildren(...chosen.map((input) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'picker-surface-tab';
tab.dataset.surfaceTab = input.value;
tab.innerHTML = '<span class="picker-surface-dot"></span>';
tab.append(input.dataset.surfaceLabel ?? input.value);
tab.onclick = () => showSurface(input.value);
return tab;
}));
markTabs();
}
function markTabs() {
for (const tab of surfaceTabs?.children ?? []) {
const value = tab.dataset.surfaceTab;
const field = strategyField(value);
const set = Boolean(field?.dataset.chosen);
const on = value === activeSurface;
tab.dataset.set = set ? 'yes' : 'no';
tab.setAttribute('aria-pressed', on ? 'true' : 'false');
tab.tabIndex = on ? 0 : -1;
tab.setAttribute('aria-label', set
? `${tab.textContent}, colored ${strategyTitle(field.value).toLowerCase()}`
: `${tab.textContent}, no color strategy chosen yet`);
}
}
function showSurface(value) {
if (!value || !stage) return;
activeSurface = value;
for (const clone of stage.querySelectorAll('[data-surface]')) {
clone.hidden = clone.dataset.surface !== value;
}
applyApplicability();
const field = strategyField(value);
const wanted = field?.value || defaultFor(value);
const input = strategyInputs.find((radio) => radio.value === wanted);
if (input) input.checked = true;
markTabs();
}
/* A strategy a surface cannot carry is left in place and turned off rather
than removed: the list keeps its shape as you move between surfaces, and the
row says why it is out instead of vanishing without a reason. */
function applyApplicability() {
const allowed = new Set(allowedFor(activeSurface));
for (const [value, row] of strategyRows) {
if (!row) continue;
const ok = allowed.has(value);
const desc = row.querySelector('.picker-strategy-desc');
desc.dataset.copy ??= desc.textContent;
desc.textContent = ok ? desc.dataset.copy : BLOCKED_BECAUSE[value] ?? desc.dataset.copy;
row.classList.toggle('is-blocked', !ok);
row.querySelector('input').disabled = !ok;
}
}
for (const input of strategyInputs) {
input.addEventListener('change', () => {
const field = strategyField(activeSurface);
if (!input.checked || !field) return;
field.value = input.value;
field.dataset.chosen = 'yes';
function show(value) {
if (!value) return;
activeSurface = value;
for (const drawing of frame.querySelectorAll('[data-surface]')) {
drawing.hidden = drawing.dataset.surface !== value;
}
applyApplicability();
const field = fieldFor(value);
const wanted = field?.value || defaultFor(value);
const input = optionInputs().find((radio) => radio.value === wanted);
if (input) input.checked = true;
markTabs();
}
/* A tab the visitor moved to leads: every other per-surface question follows
it, because the drawing on screen is painted from all of their answers at
once and a board shown on the Docs tab has to be colored with the answer
Docs was given. */
function choose(value) {
show(value);
alignSurfaces(api);
}
/* An option a surface cannot carry is left in place and turned off rather
than removed: the list keeps its shape as you move between surfaces, and
the row says why it is out instead of vanishing without a reason. */
function applyApplicability() {
const allowed = allowedFor(activeSurface);
for (const input of optionInputs()) {
const row = input.closest('.picker-strategy-option');
if (!row) continue;
const ok = !allowed || allowed.has(input.value);
const desc = row.querySelector('.picker-strategy-desc');
desc.dataset.copy ??= desc.textContent;
desc.textContent = ok ? desc.dataset.copy : row.dataset.blockedReason ?? desc.dataset.copy;
row.classList.toggle('is-blocked', !ok);
input.disabled = !ok;
}
}
/* Delegated rather than bound row by row, for the same reason the group is
read live: a pair uploaded halfway through the run has to answer into the
surface it was chosen on like any row the page was built with. */
document.addEventListener('change', ({ target }) => {
if (target?.name !== name || !target.checked) return;
record(target.value);
});
/* A flat question leaves one answer, so a choice made on one tab is the
choice on every tab that can take it. Anything that rules it out keeps
what it had, which is the whole reason the fields are kept per surface on
a question that only writes one of them down. */
function record(value) {
const surfaces = flat ? chosenSurfaces().map((input) => input.value) : [activeSurface];
for (const surface of surfaces) {
const field = fieldFor(surface);
const allowed = allowedFor(surface);
if (!field || (allowed && !allowed.has(value))) continue;
field.value = value;
field.dataset.chosen = 'yes';
}
markTabs();
}
/* Arrow keys walk the group, which is the one thing a row of buttons owes a
keyboard once only the current tab is in the tab order. */
tabs.addEventListener('keydown', (event) => {
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!step) return;
const buttons = [...tabs.children];
const next = buttons[(buttons.findIndex((tab) => tab.dataset.surfaceTab === activeSurface) + step + buttons.length) % buttons.length];
event.preventDefault();
choose(next.dataset.surfaceTab);
next.focus();
});
/* A question that is off screen previews an answer it did not ask for, so
the radio the rest of the run reads is parked on whichever surface is
being looked at, and on the leading one when nothing on screen is showing
tabs. Without it the run would carry whichever surface was last on the tab,
and an option switched off for that surface would leave the answer empty. */
const park = (surface) => show(surface || chosenSurfaces()[0]?.value);
const api = { screen, sync, paint, park, active: () => activeSurface };
return api;
}
/* Arrow keys walk the group, which is the one thing a row of buttons owes a
keyboard once only the current tab is in the tab order. */
surfaceTabs?.addEventListener('keydown', (event) => {
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!step) return;
const tabs = [...surfaceTabs.children];
const next = tabs[(tabs.findIndex((tab) => tab.dataset.surfaceTab === activeSurface) + step + tabs.length) % tabs.length];
event.preventDefault();
showSurface(next.dataset.surfaceTab);
next.focus();
});
const surfaceQuestions = [...document.querySelectorAll('[data-surface-tabs]')].map(buildSurfaceQuestion);
/* One tab is showing at a time and every per-surface question reads it, so the
one whose screen is up owns it and the rest are moved to match. */
function alignSurfaces(leader) {
for (const question of surfaceQuestions) {
if (question !== leader) question.park(leader?.active());
}
}
const syncSurfaces = () => {
for (const question of surfaceQuestions) question.sync();
};
const paintStage = () => {
for (const question of surfaceQuestions) question.paint();
};
for (const input of modeInputs) {
input.addEventListener('change', () => {
+1278 -216
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -105,15 +105,13 @@
}
/* Percentage padding resolves against the card's own width, so one value
holds at both artboard sizes instead of swamping the handset. The depth
answer can ask for an inset of its own (see --pvs-tint-pad in depth.css);
the wider of the two wins, so neither answer can undo the other. */
holds at both artboard sizes instead of swamping the handset. */
[data-carry] .ps-gallery-item {
padding: max(var(--pvs-panel-pad, 0px), var(--pvs-tint-pad, 0px));
padding: var(--pvs-panel-pad, 0px);
}
/* The handset's card row is already the block closest to the footer, so it
takes a shorter version of the same padding rather than growing into it. */
[data-carry] .ps-phone-body .ps-gallery-item {
padding: calc(max(var(--pvs-panel-pad, 0px), var(--pvs-tint-pad, 0px)) * 0.5);
padding: calc(var(--pvs-panel-pad, 0px) * 0.5);
}
+5 -52
View File
@@ -5,13 +5,10 @@
this file lifts individual elements, and the two answers compose instead
of arguing.
Two kinds of value, because a shadow and a tonal step are not the same
material. Cast shadows go in the --pvs-shadow-* slots and need something
solid under them, which is why the block at the bottom moves the card's
lift onto the picture inside it wherever the boundary answer left the card
transparent: a shadow around an invisible rectangle reads as a bug. Tonal
steps go in --pvs-tint-* and are painted as a ground, so they land on any
element whether or not it already has one.
Every answer here is a cast shadow. They go in the --pvs-shadow-* slots and
need something solid under them, which is why the block at the bottom moves
the card's lift onto the picture inside it wherever the boundary answer left
the card transparent: a shadow around an invisible rectangle reads as a bug.
Shadows are sized for the artboard rather than for a real page. The frame
is about a third of browser width, so a blur that would read as gentle at
@@ -23,41 +20,13 @@
============================================================ */
/* ── Flat ────────────────────────────────────────────────────
One plane. Nothing casts, nothing steps. */
One plane. Nothing casts. */
#picker-form:has(.picker-strategy-option:hover input[name="depth-style"][value="flat"]) .picker-preview-depth,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="depth-style"])):has(input[name="depth-style"][value="flat"]:checked)) [data-carry] {
--pvs-shadow-chrome: none;
--pvs-shadow-card: none;
--pvs-shadow-control: none;
--pvs-shadow-surface: none;
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* ── Layered ─────────────────────────────────────────────────
Two tonal steps off the ground, cards further up than chrome, and not one
shadow on the page.
A tonal step is only read as a step if some of it is visible, so this is the
one answer that asks for padding of its own. It is a floor rather than a
replacement: the boundary answer that already draws panels keeps its wider
inset. */
#picker-form:has(.picker-strategy-option:hover input[name="depth-style"][value="layered"]) .picker-preview-depth,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="depth-style"])):has(input[name="depth-style"][value="layered"]:checked)) [data-carry] {
--pvs-shadow-chrome: none;
--pvs-shadow-card: none;
--pvs-shadow-control: none;
--pvs-shadow-surface: none;
--pvs-tint-chrome: linear-gradient(
color-mix(in oklab, var(--pvs-ink) 6%, transparent),
color-mix(in oklab, var(--pvs-ink) 6%, transparent)
);
--pvs-tint-card: linear-gradient(
color-mix(in oklab, var(--pvs-ink) 12%, transparent),
color-mix(in oklab, var(--pvs-ink) 12%, transparent)
);
--pvs-tint-pad: 3.5%;
}
/* ── Soft lift ───────────────────────────────────────────────
@@ -71,9 +40,6 @@
0 3px 7px rgb(0 0 0 / 0.14);
--pvs-shadow-control: 0 1px 2px rgb(0 0 0 / 0.24);
--pvs-shadow-surface: 0 2px 5px rgb(0 0 0 / 0.14);
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* ── Floating ────────────────────────────────────────────────
@@ -87,19 +53,6 @@
0 9px 18px rgb(0 0 0 / 0.24);
--pvs-shadow-control: 0 3px 6px rgb(0 0 0 / 0.32);
--pvs-shadow-surface: 0 6px 14px rgb(0 0 0 / 0.22);
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* Tonal steps paint as a ground, over whatever ground the boundary answer
already chose. */
[data-carry] .ps-gallery-item {
background-image: var(--pvs-tint-card, none);
}
[data-carry] :is(.ps-nav, .ps-footer, .ps-phone-top, .ps-phone-footer) {
background-image: var(--pvs-tint-chrome, none);
}
/* ── Where a cast shadow is allowed to land ──────────────────
+19 -119
View File
@@ -3,11 +3,11 @@
Every other structural screen changes something you can see on its own: a
divider is there or it is not, a corner is round or it is square. This one
changes proportion, and a proportion is invisible without a ruler. Four
pages with the columns nudged a few points apart are four identical pages.
changes proportion, and a proportion is invisible without a ruler. Three
pages with the columns nudged a few points apart are three identical pages.
So the ruler is drawn. The measure below is a real twelve-column grid, and
the three disciplined answers place every block on an exact column count:
the two disciplined answers place every block on an exact column count:
what changes between them is which spans the page spends its columns on.
Freeform is then the one answer that crosses the lines, and it reads as a
decision because the thing it is breaking is on screen next to it.
@@ -46,11 +46,12 @@
--pvs-w10: 83.0667%;
}
/* The page's own margins are asymmetric under two of the answers, and a
drawn grid makes it obvious when only some rows honor them. Chrome and the
editorial band are brought onto the same measure as the hero and the proof
band. Scoped to the structural artboards: screens 03 and 04 are judging
color and glyphs, and their page is not under a ruler. */
/* The nav, the section band, and the footer inset both sides by the page
gutter, where the hero and the proof band read the end gutter on the right.
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) {
padding-right: var(--pvs-gutter-end);
}
@@ -83,10 +84,14 @@
/* The field is nailed to six and six and carries its own twelve columns
rather than reading the answer's margins and the ladder above. A measure
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 four pages each
checked against a ruler cut to fit would be four pages checked against
nothing. The instrument is the same one under all four. What moves is the
page standing on it, which is the whole of what this screen is asking. */
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.
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
slide the ruler onto the very lean the ruler is there to show. */
.picker-preview-layout .ps-desktop {
position: relative;
isolation: isolate;
@@ -272,103 +277,6 @@
width: 38%;
}
/* Editorial, on this screen's own preview. The option's own definition is
the spec: uneven columns, with one of them clearly leading. Every edge
still lands on the drawn lines; what changes is that the spans are ranked
instead of evened. The hero gives the copy seven columns against the
picture's five, and the headline takes the scale that span earns. The
section band drops from four columns to three, ranked four, three, two
left to right: a ladder, not a row. Balanced leans and settles around a
centered axis; this page has a first column, a second, and a third. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout {
--pvs-gutter: 6%;
--pvs-gutter-end: 6%;
/* The leading column: copy at seven against the picture's five. Balanced
leans the other way, so the two answers cannot be mistaken. */
--pvs-hero-cols: var(--pvs-w7) minmax(0, 1fr);
--pvs-hero-gap: var(--pvs-gut);
--pvs-editorial-cols: var(--pvs-w3) minmax(0, 1fr);
--pvs-editorial-gap: var(--pvs-gut);
/* Three ranked columns in the nine-column track: four, three, two. The
tracks are fr in the same proportion as the spans, and the fourth card
is removed below; its columns are what pay for the lead's width. */
--pvs-gallery-cols: 32.2667fr 23.8fr 15.3333fr;
--pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 74.6);
--pvs-section-align: start;
--pvs-rows: 8.5% 48% 9% 26.5% 8%;
--pvs-phone-gutter: 6%;
}
/* The lead column's type claims the width its span gives it: a heavier
headline over a shorter second line, and an eyebrow cut to a label rather
than stretched across the wide measure. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-headline i,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-headline i {
width: 88%;
height: 15px;
}
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-headline i:last-child,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-headline i:last-child {
width: 52%;
}
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-eyebrow,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-eyebrow {
width: 24%;
}
/* The copy holds a reading measure inside the wide column instead of running
to its edge; the width is the lead's, the text is not obliged to fill it. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-copy i,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-copy i {
width: 56%;
}
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-copy i:last-child,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-copy i:last-child {
width: 42%;
}
/* Three ranked columns, not four even ones. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-desktop .ps-gallery-item:nth-child(4),
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-desktop .ps-gallery-item:nth-child(4) {
display: none;
}
/* The handset ranks its two cards the same way: a clear lead and a short. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout .ps-phone-body .ps-gallery,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) .picker-preview-layout .ps-phone-body .ps-gallery {
grid-template-columns: 1.7fr 0.7fr;
}
/* ── Editorial ───────────────────────────────────────────────
The committed ranked page, carried to boundaries, corners, and depth. Same
proportions as the preview above: the copy leading the hero at seven
against five, and the section band's three columns ranked four, three,
two, with the fourth card removed below. The headline scale-up is the
preview's own instrument and stays there; what travels is the ranking. */
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) [data-carry]:not(.picker-preview-layout) {
--pvs-gutter: 6%;
--pvs-gutter-end: 6%;
--pvs-hero-cols: var(--pvs-w7) minmax(0, 1fr);
--pvs-hero-gap: var(--pvs-gut);
--pvs-editorial-cols: var(--pvs-w3) minmax(0, 1fr);
--pvs-editorial-gap: var(--pvs-gut);
--pvs-gallery-cols: 32.2667fr 23.8fr 15.3333fr;
--pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 74.6);
--pvs-section-align: start;
--pvs-rows: 8.5% 48% 9% 26.5% 8%;
--pvs-phone-gutter: 6%;
--pvs-phone-gallery-cols: 1.7fr 0.7fr;
}
/* The ranked band is three columns on every screen that carries it. */
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="editorial"]:checked) [data-carry]:not(.picker-preview-layout) .ps-desktop .ps-gallery-item:nth-child(4) {
display: none;
}
/* Freeform, on this screen's own preview. The option's own definition is the
spec: blocks step out of the grid and overlap where it pays off. That is
not a looser grid; it is the same measure with permission granted. So the
@@ -483,8 +391,8 @@
/* ── The handset ─────────────────────────────────────────────
One column still answers the question: in the margin, in how unevenly the
two cards divide the row, and in whether anything is allowed to cross the
measure. The picture is the block given that permission, both edges under
Editorial and the outer edge alone under Freeform. The rhythm between
measure. Freeform is the answer that takes that permission, and it takes it
on the picture's bottom edge rather than on the measure. The rhythm between
blocks is left alone, because the handset's stack is sized in pixels and a
couple of points of extra gap pushes the card row through the bottom of the
frame at the narrowest window the picker supports. */
@@ -492,14 +400,6 @@
padding-inline: var(--pvs-phone-gutter, 6%);
}
/* The picture carries an explicit width, so a negative margin moves it rather
than widening it. Both are named together or the bleed slides the block
sideways instead of stretching it past the measure. */
[data-carry]:not(.picker-preview-layout) .ps-phone-body > .ps-image {
margin-inline: var(--pvs-phone-bleed, 0);
width: var(--pvs-phone-bleed-w, 100%);
}
[data-carry]:not(.picker-preview-layout) .ps-phone-body .ps-gallery {
grid-template-columns: var(--pvs-phone-gallery-cols, 1.05fr 0.95fr);
}
+23 -4
View File
@@ -371,15 +371,30 @@ Build the composition context from exactly these inputs:
Do **not** read PRODUCT.md wholesale into this task or add any other section to the composition context. The chosen palette does not exist yet; the picker joins it to the pairs later.
### Name the surfaces before composing
A pair that carries a landing page can fail a dashboard outright. The landing page asks the heading face for a six-word line at 40px and up; the dashboard asks the body face for a 12px column label sitting next to a number. Suggest fonts without knowing which of those is on the table and you are guessing at the only question that separates the shortlists.
So decide first what this product is made of, from PRODUCT.md and the codebase, using the four surface kinds the picker's first question offers: `persuade` (landing, marketing, pricing), `operate` (app UI, dashboards, admin, settings), `read` (docs, articles, guides, changelogs), `experience` (portfolios, galleries, showcases). Name every kind the product already implies, not the one it leads with: a tool with a marketing site and a documentation site is `operate, read, persuade`. This is the same set Step 7 writes into `cues.json` as `modes`, so make the judgment once, here, and carry it. No clear signal anywhere leaves the set at `persuade` alone.
What each surface asks of a pair:
- **persuade**: the heading face is the page. It has to hold a short line at display size, where counters, joins, and one badly drawn character are all visible at a glance. The body face sets a paragraph and two button labels, so it is asked for less. This is the surface where a face with a point of view earns its place.
- **operate**: the body face is also the interface face, and it works between 11px and 14px on column headings, form labels, menu items, and numbers in a row. Ask it for a 1, l, and I that stay apart, a 0 that does not read as O, lining figures, and a medium or semibold that the family actually draws rather than one the browser fakes. Headings here are 16px to 24px panel titles set many times per screen, so a face that only comes alive at poster size is the wrong heading for this surface.
- **read**: the body face carries hundreds of words at 16px to 18px across a 60 to 75 character line, which is the hardest job on this list. Ask it for a generous x-height, an italic the family drew rather than sloped, and a bold that still reads inline. The heading face sits inside running text at 1.2 to 1.6 times the body, close enough that a mismatch in proportion shows immediately.
- **experience**: the work is the subject and the type is the room around it. The heading face can be the most expressive of the six pairs. The body face sets captions, credits, and index metadata at 11px to 13px, often tracked out in caps, so it has to stay even when letter-spaced and survive at those sizes.
Rank against the strictest surface in the set, never the loudest. Operate and read set the floor the body face has to clear; persuade and experience set how far the heading is allowed to go. Every pair still has to serve every named surface: the user picks one pair for the whole product, and one type system comes out the other end. A pair that only holds up on one surface belongs at the bottom of the list, or off it. None of this loosens [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces`, which rules all six pairs whatever the surfaces are.
Compose six distinct territories, then resolve each into one heading and body pair:
- Keep at least five pairs inside Q2's chosen direction, varied by classification and voice within it. No two pairs may share a heading family or read as the same voice.
- Let the sixth bend the direction only when Q4, Q5, or asset letterforms argue for it, and name that reason in its `why`.
- Apply [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces` as the canonical denylist and subject-world test. A family the user named in the interview or supplied assets is the only exception.
- Follow [typeset.md](typeset.md)'s workhorse discipline. Give the heading a point of view; give the body a real text face that stays legible at 15px and provides regular and bold weights. A display face in the body slot fails the pair.
- Follow [typeset.md](typeset.md)'s workhorse discipline. Give the heading a point of view; give the body a real text face that stays legible at 15px and provides regular and bold weights. A display face in the body slot fails the pair. Where the surface set names `operate` or `read`, that 15px floor is not the test the body face has to pass: the sizes in those two entries above are.
- Verify every family exists on Google Fonts under the exact current name. Spelling is part of correctness; use `Source Sans 3`, never a retired family name.
- Write `why` as three to five words naming the pair's voice, not a sentence about the brand. The picker sets it in tracked caps under the two family names, so anything longer wraps and stops scanning. `Considered and editorial`, not `Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan`.
- Order the pairs best-first. `pairs[0]` is the recommendation and reaches the picker pre-selected.
- Order the pairs best-first, judged on the strictest surface in the set. `pairs[0]` is the recommendation and reaches the picker pre-selected.
Choose the headline and every wireframe label from the product's own world. Do not invent claims or use placeholder prose that could describe any brand.
@@ -436,9 +451,13 @@ Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every f
## Step 7: Launch the picker
Before launching, add a top-level `modes` array to `cues.json` naming the surface kinds this product already implies, judged from PRODUCT.md and the codebase: any of `persuade`, `operate`, `read`, `experience`. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gives no clear signal; the picker then starts from `persuade` alone.
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.
Color is then answered per surface rather than once for the whole run, because the distribution that suits a marketing page rarely suits the tool it sells. The answers carry `color-strategy` for the leading surface, which is the first chosen tile in tile order and the one every later screen previews, plus one `color-strategy-<mode>` key for each surface chosen. Surfaces the user never opened are included too, holding the default for their kind. When more than one comes back, DESIGN.md's color section says what each surface does with the palette instead of stating one distribution for the product.
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.
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.
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.
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.