+
{option.title}
@@ -53,6 +61,20 @@ const artboardClass = phone ? previewClass : `${previewClass} picker-artboard--s
))}
+
+ {/* 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) => (
+
+ ))}
+ >
+ {perSurface && (
+
+ )}
+
diff --git a/picker/data/surfaces.js b/picker/data/surfaces.js
new file mode 100644
index 000000000..740685f8d
--- /dev/null
+++ b/picker/data/surfaces.js
@@ -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,
+});
diff --git a/picker/pages/index.astro b/picker/pages/index.astro
index b979021aa..76a88bc88 100644
--- a/picker/pages/index.astro
+++ b/picker/pages/index.astro
@@ -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 = [
Surfaces
-
+
@@ -332,7 +300,7 @@ const questions = [
-
+
@@ -359,21 +327,21 @@ const questions = [
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
@@ -421,7 +389,7 @@ const questions = [
-
+
@@ -476,7 +444,7 @@ const questions = [
-
+
@@ -505,10 +473,10 @@ const questions = [
-
-
-
-
+
+
+
+
@@ -747,7 +715,10 @@ const questions = [
-
+ {/* 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. */}
+
Full palette
@@ -755,7 +726,7 @@ const questions = [
-
+
Drenched
@@ -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 }) => (
-
+ {SURFACE_MODES.map((mode) => (
+
))}
@@ -795,25 +766,27 @@ const questions = [
- {/* The stage the chosen surfaces are drawn on. Its contents are
- lifted from the tiles on screen 01b, so it is filled by script;
- the ratio is the artboard's so the CTA sits where it sits on
- every other screen. */}
-
-
+
+ {/* 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. */}
+
-
-
- Select this strategy
-
-
-
-
-
-
-
Back
+
+
+ Select this strategy
+
+
+
+
+
+
+
Back
+
+
@@ -847,20 +820,37 @@ const questions = [
-
+ {/* 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. */}
+
- headings
+ headings
body text
-
+
+ {/* 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) => (
+
+ ))}
+
@@ -926,22 +916,90 @@ const questions = [
+
+
+
+
+ {scaleRows.map(([tag, step]) => (
+
+ ))}
+
+
+
Values at a 16px base, specimen scaled to fit
-
- {scaleRows.map(([tag, step]) => (
-
- ))}
+ {/* 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. */}
+
+
+
A generic display heading
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.
+
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+ consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
+ fugiat nulla pariatur, excepteur sint occaecat cupidatat non proident.
+
+
+
A section heading
+
+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
+ totam rem aperiam eaque ipsa quae ab illo inventore veritatis.
+
+
+ Lorem ipsum dolor sit amet, consectetur
+ Adipiscing elit, sed do eiusmod tempor incididunt ut labore
+ Magna aliqua ut enim ad minim veniam
+
+
+
+ Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae.
+ Attribution line
+
+
+
A subsection heading
+
+ At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum
+ deleniti atque, held together by a run of inline code mid sentence.
+
+
+ Nam libero tempore, cum soluta nobis
+ Eligendi optio cumque nihil impedit
+ Quo minus id quod maxime placeat
+
+
function step(base, ratio, n) {
+ return base * ratio ** n;
+}
+
+
A minor heading
+
+ Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et
+ voluptates repudiandae sint et molestiae non recusandae.
+
+
+
+
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js
index 637d23afc..e267725ab 100644
--- a/picker/scripts/palette-picker.js
+++ b/picker/scripts/palette-picker.js
@@ -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:
+
+
+
+ inside the box that draws the frame, plus one disabled hidden field per
+ surface marked data-surface-field="-". That field is where
+ the surface's answer is kept; whether it also carries a name, and so whether
+ the run records a key per surface or only the leading surface's choice, is
+ the question's own business. What each surface may take and where it
+ lands untouched are already on the tiles as data-allow- and
+ data-default-; why an option is out is on the option itself as
+ data-blocked-reason. All of that comes from data/surfaces.js.
+
+ data-surface-stage on the frame additionally mounts one drawing per chosen
+ surface, lifted from that surface's tile and painted with the committed
+ palette. Screen 03 is the only screen that wants that today. A screen that
+ draws its own per-surface variants instead marks each of them
+ data-surface="" in its own markup and leaves the attribute off; the
+ showing and hiding is the same work either way.
+ ============================================================ */
const chosenSurfaces = () => modeInputs.filter((input) => input.checked);
const surfaceInput = (value) => modeInputs.find((input) => input.value === value);
-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 = ' ';
+ 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 = ' ';
- 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', () => {
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
index 737c9f992..4b7be2d51 100644
--- a/picker/styles/picker.css
+++ b/picker/styles/picker.css
@@ -1178,16 +1178,38 @@ body.picker-page {
aspect-ratio: 16 / 7;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
- gap: 16px;
+ /* A share of the frame rather than a fixed channel: on screen 03 the two
+ cards are drawn near twice the size they are on 01b, and a gutter that
+ stays at 16px there reads as a seam between them. */
+ gap: 3.229163%;
}
/* Two artboards floating on the page itself: a desktop card and a phone card,
both in the same shell of shared chrome. What fills their bodies is the
variant the chosen surface brings, so the palette is judged on the kind of
page it is going to dress. The phone's fixed portrait ratio keeps the wide
- frame from stretching a single page mock. */
+ frame from stretching a single page mock.
+
+ Both cards are inline-size containers, so the chrome each one carries is cut
+ from the card it sits on. A nav band is a drawn object rather than page
+ length, which is why its own vertical padding is counted in `--pv-x` with
+ the rest: a taller frame is a longer page behind the same window, not a
+ thicker window. Every count below is a canonical pixel over the card's own
+ height, 214.78 on both, counted the way the four bodies are counted against
+ theirs. The two corner radii are the exception and stay in pixels: a
+ container measures for what is inside it, so a card cannot spend its own
+ `--pv-x` on itself. */
+/* A line of type is drawn as a bar, and how heavy that bar reads is not settled
+ by its authored count: the phone spends nearly four times the share of its
+ card on the same number, which is why the two cards side by side can be cut
+ from one drawing and still disagree about how present the text is. The factor
+ is the room to say that, per card and without moving anything else in the
+ drawing. It stays 1 wherever a card was signed off, so a card only carries a
+ different weight where something turned it on by name. */
.pv-desktop,
.pv-phone {
+ --pv-text: 1;
+ container-type: inline-size;
min-width: 0;
min-height: 0;
overflow: hidden;
@@ -1196,12 +1218,18 @@ body.picker-page {
}
.pv-desktop {
+ /* 1 / 1.745162, this card's width over its height at the component's authored
+ 16 / 7, measured on the built picker. */
+ --pv-x: 0.573013cqw;
display: grid;
grid-template-rows: auto 1fr;
border-radius: 4px;
}
.pv-phone {
+ /* 1 / 0.468718: the portrait card is narrower than it is tall, so the same
+ count spends a larger share of this card's width than of the desktop's. */
+ --pv-x: 2.133479cqw;
height: 100%;
aspect-ratio: 9 / 19;
display: grid;
@@ -1212,13 +1240,13 @@ body.picker-page {
.pv-nav {
display: grid;
grid-template-columns: auto 1fr auto;
- gap: 10px;
+ gap: calc(4.6559 * var(--pv-x));
align-items: center;
- padding: 10px 12px;
+ padding: calc(4.6559 * var(--pv-x)) calc(5.58708 * var(--pv-x));
}
.pv-logo {
- width: 8px;
+ width: calc(3.72472 * var(--pv-x));
aspect-ratio: 1;
background: var(--pv-primary);
border-radius: 50%;
@@ -1227,41 +1255,51 @@ body.picker-page {
.pv-nav-bars {
display: flex;
justify-content: flex-end;
- gap: 8px;
+ gap: calc(3.72472 * var(--pv-x));
}
.pv-nav-bars i {
display: block;
- width: 18px;
- height: 3px;
+ width: calc(8.38062 * var(--pv-x));
+ height: calc(1.39677 * var(--pv-x));
background: var(--pv-bone);
border-radius: 2px;
}
.pv-pill {
- width: 32px;
- height: 12px;
+ width: calc(14.89888 * var(--pv-x));
+ height: calc(5.58708 * var(--pv-x));
background: var(--pv-primary);
border-radius: 2px;
}
+/* The hero is a size container so the page it draws scales with the card rather
+ than sitting at one drawn size inside it. Its counts are canonical pixels
+ divided by the box's own height, 164.78. Down the page they are spent in
+ `cqh`, so a taller card is a longer hero rather than a magnified one; across
+ it they are spent in `--pv-x` against the width, as the ops variant below
+ sets out, so a bar keeps its thickness when the frame changes shape.
+ Its own padding is a percentage, since a container cannot measure itself. */
.pv-hero {
+ container-type: size;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 8%;
align-items: center;
- padding: 4px 8% 14px;
+ padding: 1.06729% 8% 3.735179%;
+ /* 1 / 1.910772, this box's width over its height. */
+ --pv-x: 0.523349cqw;
}
.pv-hero-copy {
display: grid;
- gap: 8px;
+ gap: 4.85493cqh;
}
.pv-eyebrow {
width: 22%;
- height: 4px;
+ height: calc(2.42746 * var(--pv-text) * var(--pv-x));
background: var(--pv-tertiary);
border-radius: 2px;
}
@@ -1270,14 +1308,14 @@ body.picker-page {
ink: it must visibly react when the primary changes. */
.pv-headline {
display: grid;
- gap: 7px;
- margin-block: 2px 3px;
+ gap: 4.24806cqh;
+ margin-block: 1.21374cqh 1.8206cqh;
}
.pv-headline i {
display: block;
width: 88%;
- height: 8px;
+ height: calc(4.85492 * var(--pv-text) * var(--pv-x));
background: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
border-radius: 2px;
}
@@ -1288,20 +1326,22 @@ body.picker-page {
.pv-copy-bar {
width: 64%;
- height: 5px;
+ height: calc(3.03433 * var(--pv-text) * var(--pv-x));
background: var(--pv-bone);
border-radius: 2px;
}
.pv-actions {
display: flex;
- gap: 7px;
- margin-top: 10px;
+ gap: calc(4.24806 * var(--pv-x));
+ margin-top: 6.06866cqh;
}
+/* Buttons are objects on the page rather than page rhythm, so they keep their
+ drawn size and the room above them takes the extra height instead. */
.pv-actions i {
- width: 38px;
- height: 13px;
+ width: calc(23.06088 * var(--pv-x));
+ height: calc(7.88925 * var(--pv-x));
background: var(--pv-primary);
border-radius: 2px;
}
@@ -1323,16 +1363,16 @@ body.picker-page {
.pv-image::after {
content: "";
position: absolute;
- top: 9px;
- right: 9px;
- width: 7px;
+ top: calc(5.46179 * var(--pv-x));
+ right: calc(5.46179 * var(--pv-x));
+ width: calc(4.24806 * var(--pv-x));
aspect-ratio: 1;
background: var(--pv-tertiary);
border-radius: 50%;
}
.pv-avatar {
- width: 9px;
+ width: calc(4.19031 * var(--pv-x));
aspect-ratio: 1;
border: 1px solid var(--pv-bone);
border-radius: 50%;
@@ -1346,20 +1386,39 @@ body.picker-page {
display: flex;
justify-content: space-between;
align-items: center;
- padding: 9px 10px;
+ padding: calc(4.19031 * var(--pv-x)) calc(4.6559 * var(--pv-x));
background: var(--pv-surface-2);
border-bottom: 1px solid var(--ks-rule);
}
+/* A size container counted the way the hero is: canonical pixels over this
+ padded box's own height, 145.78, spent in `cqh` down the page and in
+ `--pv-x` across it. The padding itself is a percentage of the phone's width,
+ since a container cannot measure itself. */
.pv-phone-body {
+ container-type: size;
min-height: 0;
display: grid;
/* The plate takes the row that flexes and the two blocks keep their drawn
size, so a narrower window shortens the hero rather than pushing a block
out through the bottom of the phone. */
grid-template-rows: minmax(0, 1fr) auto auto;
- gap: 7px;
- padding: 9px 10px 10px;
+ /* A percentage of this box's own height rather than a count in `cqh`: a
+ container unit on the container's own property is measured against the box
+ outside it, which here is the phone card, and the row gap would be cut from
+ the wrong page. */
+ row-gap: 4.802058%;
+ padding: 8.940432% 9.933758% 9.933758%;
+ /* 1 / 0.553376, this box's width over its height. */
+ --pv-x: 1.807089cqw;
+}
+
+/* The desktop plate's mark is counted against a taller box than this one, so
+ the phone restates it. */
+.pv-phone-body .pv-image::after {
+ top: calc(6.17364 * var(--pv-x));
+ right: calc(6.17364 * var(--pv-x));
+ width: calc(4.80172 * var(--pv-x));
}
/* Its 4 / 3 is honored wherever the row is tall enough to hold it, which is
@@ -1370,10 +1429,14 @@ body.picker-page {
max-height: 100%;
}
+/* A block is a drawn object rather than a share of the page, so everything
+ inside it is counted across the box: the row it sits in is `auto`, and a
+ block that took its rhythm from the card's height would grow into the plate
+ above it every time the frame got taller. */
.pv-mini-card {
display: grid;
- gap: 5px;
- padding: 8px 9px;
+ gap: calc(3.4298 * var(--pv-x));
+ padding: calc(5.48768 * var(--pv-x)) calc(6.17364 * var(--pv-x));
background: var(--pv-surface-2);
border: 1px solid var(--ks-rule);
border-radius: 2px;
@@ -1382,14 +1445,14 @@ body.picker-page {
.pv-mini-card i,
.pv-mini-card span {
display: block;
- height: 3px;
+ height: calc(2.05788 * var(--pv-x));
background: var(--pv-bone);
border-radius: 2px;
}
.pv-mini-card span {
width: 62%;
- height: 6px;
+ height: calc(4.11576 * var(--pv-x));
background: var(--pv-secondary);
}
@@ -1403,12 +1466,12 @@ body.picker-page {
display: flex;
justify-content: space-evenly;
align-items: center;
- padding: 8px 0;
+ padding: calc(3.72472 * var(--pv-x)) 0;
border-top: 1px solid var(--ks-rule);
}
.pv-tabbar i {
- width: 5px;
+ width: calc(2.32795 * var(--pv-x));
aspect-ratio: 1;
background: var(--pv-bone);
border-radius: 50%;
@@ -1621,45 +1684,60 @@ body.picker-page {
}
/* Both bodies are size containers so the screen inside them scales with the
- card rather than spilling out of it. Every `cqh` below is a canonical pixel
- divided by the body height it sits in: 181.78 on the desktop, 133.75 inside
- the phone's padded box. Chrome the exact tiles share (nav, phone top bar, tab
- bar) stays in pixels so all the frames stay identical. */
+ card rather than spilling out of it. Every measure below is a canonical pixel
+ read off the reference illustration, divided by the body height it sits in:
+ 181.78 on the desktop, 133.77 inside the phone's padded box. Vertical ones
+ spend that count in `cqh`, so a taller card is a taller page. Horizontal ones
+ spend it in `--pv-x`, so the same count holds when the frame's proportions
+ move: the width of a column and the thickness of a bar are not the frame's
+ height to give away. The chrome all four variants share (nav, phone top bar,
+ tab bar) is counted on the cards themselves, above. */
.po-body {
container-type: size;
min-height: 0;
display: grid;
- /* Percentages, not `cqh`: a container cannot measure itself, and the three
- panels are horizontal dimensions anyway. */
+ /* Percentages, not a container unit: a container cannot measure itself, and
+ the three panels are horizontal dimensions anyway. */
grid-template-columns: 18.68% 51.84% minmax(0, 1fr);
+ /* One canonical measure counted against this body's width rather than its
+ height: 1 / 2.061974, this box's width over its height at the component's
+ authored 16 / 7, measured on the built picker. Every horizontal measure is
+ then a fixed fraction of the width it has to fit inside, so no column can
+ be squeezed out and no row can reach past the card no matter what the frame
+ does with its height. Each body is a different box, so each restates it.
+
+ The count is what the reference illustration was measured in, so a value
+ stays comparable to its vertical neighbours: `13.23` is the same 13.23 in
+ both axes. */
+ --pv-x: 0.484972cqw;
}
.po-side {
display: grid;
align-content: start;
gap: 5.25cqh;
- padding: 6.38cqh 2.86cqh 0 2.22cqh;
+ padding: 6.38cqh calc(2.86 * var(--pv-x)) 0 calc(2.22 * var(--pv-x));
border-right: 1px solid var(--po-rule);
}
.po-item {
display: flex;
align-items: center;
- gap: 3.39cqh;
- padding-left: 2.84cqh;
+ gap: calc(3.39 * var(--pv-x));
+ padding-left: calc(2.84 * var(--pv-x));
}
.po-dot {
flex: none;
- width: 4.07cqh;
+ width: calc(4.07 * var(--pv-x));
aspect-ratio: 1;
background: var(--po-ink);
border-radius: 50%;
}
.po-line {
- width: 13.23cqh;
- height: 1.53cqh;
+ width: calc(13.23 * var(--pv-x));
+ height: calc(1.53 * var(--pv-text) * var(--pv-x));
background: var(--po-ink);
border-radius: 2px;
}
@@ -1681,7 +1759,7 @@ body.picker-page {
min-width: 0;
display: grid;
align-content: start;
- padding: 8.14cqh 4.68cqh 0 5.23cqh;
+ padding: 8.14cqh calc(4.68 * var(--pv-x)) 0 calc(5.23 * var(--pv-x));
border-right: 1px solid var(--po-rule);
}
@@ -1693,17 +1771,19 @@ body.picker-page {
position: relative;
display: flex;
align-items: flex-end;
- gap: 7.23cqh;
+ gap: calc(7.23 * var(--pv-x));
height: 33.26cqh;
- margin-inline: 9.52cqh 14.63cqh;
- padding: 0 4.29cqh 2.56cqh 4.46cqh;
+ margin-inline: calc(9.52 * var(--pv-x)) calc(14.63 * var(--pv-x));
+ /* The bottom is the tick zone rather than page rhythm, so it is measured with
+ the ticks it holds. */
+ padding: 0 calc(4.29 * var(--pv-x)) calc(2.56 * var(--pv-x)) calc(4.46 * var(--pv-x));
}
.po-chart::before {
content: "";
position: absolute;
right: 0;
- bottom: 2.56cqh;
+ bottom: calc(2.56 * var(--pv-x));
left: 0;
height: 1px;
background: var(--po-ink-soft);
@@ -1711,7 +1791,7 @@ body.picker-page {
.po-chart i {
flex: none;
- width: 6.92cqh;
+ width: calc(6.92 * var(--pv-x));
height: var(--h);
background: var(--po-wash);
}
@@ -1727,8 +1807,8 @@ body.picker-page {
position: absolute;
bottom: 0;
width: 1px;
- height: 1.29cqh;
- margin-left: 3.19cqh;
+ height: calc(1.29 * var(--pv-x));
+ margin-left: calc(3.19 * var(--pv-x));
background: var(--po-ink-soft);
}
@@ -1747,19 +1827,25 @@ body.picker-page {
.po-row {
display: flex;
align-items: center;
- gap: 4.9cqh;
+ gap: calc(4.9 * var(--pv-x));
height: 11.61cqh;
- padding-left: 3.41cqh;
+ padding-left: calc(3.41 * var(--pv-x));
}
.po-row .po-dot {
- width: 3.85cqh;
+ width: calc(3.85 * var(--pv-x));
}
+/* The column widths differ per cell, so they arrive on the element. Only the
+ count does: a container unit reached through a second custom property is
+ dropped when it arrives in an inline style, which left every cell here at
+ auto width and so at nothing, since a cell has no content to be wide for.
+ Spending the unit in the sheet keeps the drawn width and leaves the markup
+ carrying the same counts the rest of the drawing is authored in. */
.po-cell {
flex: none;
- width: var(--w);
- height: 1.45cqh;
+ width: calc(var(--w) * var(--pv-x));
+ height: calc(1.45 * var(--pv-text) * var(--pv-x));
background: var(--po-ink-soft);
border-radius: 2px;
}
@@ -1767,7 +1853,7 @@ body.picker-page {
/* The reference's two column gaps differ by less than half a canonical pixel,
so one step carries both. */
.po-cell + .po-cell {
- margin-left: 12.71cqh;
+ margin-left: calc(12.71 * var(--pv-x));
}
.po-row--on {
@@ -1782,20 +1868,23 @@ body.picker-page {
min-width: 0;
display: grid;
align-content: start;
- padding: 6.82cqh 6.71cqh 0 7.21cqh;
+ padding: 6.82cqh calc(6.71 * var(--pv-x)) 0 calc(7.21 * var(--pv-x));
}
.po-heading {
- width: 17.38cqh;
- height: 1.62cqh;
+ width: calc(17.38 * var(--pv-x));
+ height: calc(1.62 * var(--pv-text) * var(--pv-x));
background: var(--pv-primary);
border-radius: 2px;
}
+/* The controls in this panel are drawn objects rather than page rhythm, so the
+ field, the switches and the toggles keep their measured size while the
+ margins between them are what a taller card spends its extra height on. */
.po-field {
display: flex;
align-items: center;
- height: 8.75cqh;
+ height: calc(8.75 * var(--pv-x));
margin-top: 5.81cqh;
border: 1px solid var(--pv-primary);
border-radius: 2px;
@@ -1805,9 +1894,9 @@ body.picker-page {
drawn chevron, so the margins are placing the tip rather than the box. */
.po-chev {
align-self: start;
- width: 2cqh;
+ width: calc(2 * var(--pv-x));
aspect-ratio: 1;
- margin: 1.86cqh 3.14cqh 0 auto;
+ margin: calc(1.86 * var(--pv-x)) calc(3.14 * var(--pv-x)) 0 auto;
border-right: 1px solid var(--po-ink);
border-bottom: 1px solid var(--po-ink);
transform: rotate(45deg);
@@ -1816,7 +1905,7 @@ body.picker-page {
.po-switch {
display: flex;
align-items: center;
- height: 5.34cqh;
+ height: calc(5.34 * var(--pv-x));
margin-top: 8.39cqh;
}
@@ -1826,8 +1915,8 @@ body.picker-page {
.po-label {
flex: none;
- width: 17.22cqh;
- height: 1.62cqh;
+ width: calc(17.22 * var(--pv-x));
+ height: calc(1.62 * var(--pv-text) * var(--pv-x));
background: var(--po-ink);
border-radius: 2px;
}
@@ -1835,8 +1924,8 @@ body.picker-page {
.po-toggle {
position: relative;
flex: none;
- width: 10.29cqh;
- height: 5.34cqh;
+ width: calc(10.29 * var(--pv-x));
+ height: calc(5.34 * var(--pv-x));
margin-left: auto;
background: var(--po-ink);
border-radius: 999px;
@@ -1849,67 +1938,74 @@ body.picker-page {
.po-toggle::after {
content: "";
position: absolute;
- top: 0.66cqh;
- left: 0.66cqh;
- width: 4.02cqh;
+ top: calc(0.66 * var(--pv-x));
+ left: calc(0.66 * var(--pv-x));
+ width: calc(4.02 * var(--pv-x));
aspect-ratio: 1;
background: var(--pv-neutral);
border-radius: 50%;
}
.po-toggle--on::after {
- right: 0.66cqh;
+ right: calc(0.66 * var(--pv-x));
left: auto;
}
/* The phone is the same screen narrowed: one chart, the same field, two setting
- rows. Its padded box is 133.75 tall, so every shared `cqh` above is restated
- here against that number. The padding itself is a percentage of the phone's
- width, since a container cannot measure itself. */
+ rows. Its padded box is 133.77 tall, so every shared measure above is
+ restated here against that number. The padding itself is a percentage of the
+ phone's width, since a container cannot measure itself.
+
+ The single column is stated rather than left implicit. The chart's five bars
+ and four gaps come to the full width of this box, and an implicit `auto`
+ column takes its floor from its widest item: it opens past the card and
+ carries the field and the setting rows out through the right edge with it. */
.po-phone-body {
container-type: size;
min-height: 0;
display: grid;
+ grid-template-columns: minmax(0, 1fr);
align-content: start;
padding: 16.19% 4.57% 14.63% 4.27%;
+ --pv-x: 1.457438cqw;
}
.po-phone-body .po-chart {
- gap: 6.79cqh;
+ gap: calc(6.79 * var(--pv-x));
height: 36.93cqh;
- margin-inline: 0.67cqh 2.92cqh;
- padding: 0 3.51cqh 3.36cqh 3.36cqh;
+ margin-inline: calc(0.67 * var(--pv-x)) calc(2.92 * var(--pv-x));
+ padding: 0 calc(3.51 * var(--pv-x)) calc(3.36 * var(--pv-x)) calc(3.36 * var(--pv-x));
}
.po-phone-body .po-chart::before {
- bottom: 3.36cqh;
+ bottom: calc(3.36 * var(--pv-x));
}
.po-phone-body .po-chart i {
- width: 6.21cqh;
+ width: calc(6.21 * var(--pv-x));
}
.po-phone-body .po-chart i::after {
- height: 1.94cqh;
- margin-left: 2.73cqh;
+ height: calc(1.94 * var(--pv-x));
+ margin-left: calc(2.73 * var(--pv-x));
}
.po-phone-body .po-field {
- height: 11.81cqh;
+ height: calc(11.81 * var(--pv-x));
margin-top: 10.47cqh;
}
.po-phone-body .po-chev {
- width: 2.77cqh;
- margin: 2.65cqh 5.03cqh 0 auto;
+ width: calc(2.77 * var(--pv-x));
+ margin: calc(2.65 * var(--pv-x)) calc(5.03 * var(--pv-x)) 0 auto;
}
.po-prow {
display: flex;
align-items: center;
- gap: 4.49cqh;
+ gap: calc(4.49 * var(--pv-x));
height: 18.47cqh;
- padding-left: 2.24cqh;
+ padding-left: calc(2.24 * var(--pv-x));
}
.po-field + .po-prow {
@@ -1921,27 +2017,27 @@ body.picker-page {
}
.po-phone-body .po-dot {
- width: 5.76cqh;
+ width: calc(5.76 * var(--pv-x));
}
.po-phone-body .po-label {
- width: 18.62cqh;
- height: 2.17cqh;
+ width: calc(18.62 * var(--pv-x));
+ height: calc(2.17 * var(--pv-x));
}
.po-phone-body .po-toggle {
- width: 12.64cqh;
- height: 7.25cqh;
+ width: calc(12.64 * var(--pv-x));
+ height: calc(7.25 * var(--pv-x));
}
.po-phone-body .po-toggle::after {
- top: 0.9cqh;
- left: 0.9cqh;
- width: 5.46cqh;
+ top: calc(0.9 * var(--pv-x));
+ left: calc(0.9 * var(--pv-x));
+ width: calc(5.46 * var(--pv-x));
}
.po-phone-body .po-toggle--on::after {
- right: 0.9cqh;
+ right: calc(0.9 * var(--pv-x));
left: auto;
}
@@ -1949,8 +2045,8 @@ body.picker-page {
other two borders lit. */
.po-phone-body .po-chev--next {
align-self: center;
- width: 2.99cqh;
- margin: 0 4.86cqh 0 auto;
+ width: calc(2.99 * var(--pv-x));
+ margin: 0 calc(4.86 * var(--pv-x)) 0 auto;
border-top: 1px solid var(--po-ink);
border-bottom: 0;
}
@@ -1973,45 +2069,46 @@ body.picker-page {
}
/* Both bodies are size containers so the page inside them scales with the card
- rather than spilling out of it. The card is a fixed ratio, so measuring
- against the body's own height keeps the drawing self-similar at every window
- width. Every `cqh` below is a canonical pixel divided by the body height it
- sits in: 181.78 on the desktop, 146.5 inside the phone. Chrome the two exact
- tiles share (nav, phone top bar, tab bar) stays in pixels so both tiles keep
- identical frames. */
+ rather than spilling out of it. Every measure below is a canonical pixel
+ divided by the body height it sits in: 181.78 on the desktop, 146.5 inside
+ the phone. Vertical ones spend that count in `cqh`, so a taller card is a
+ longer article rather than a magnified one, and horizontal ones spend it in
+ `--pv-x` against the width, as the ops variant above sets out. The chrome
+ around both bodies is counted on the cards themselves. */
.pd-body {
container-type: size;
min-height: 0;
display: grid;
- /* A percentage, not `cqh`: a container cannot measure itself, and the rail is
- a horizontal dimension anyway. */
+ /* A percentage, not a container unit: a container cannot measure itself, and
+ the rail is a horizontal dimension anyway. */
grid-template-columns: 23.75% minmax(0, 1fr);
+ --pv-x: 0.484972cqw;
}
.pd-side {
display: grid;
align-content: start;
gap: 8.69cqh;
- padding: 9.24cqh 0 0 7.15cqh;
+ padding: 9.24cqh 0 0 calc(7.15 * var(--pv-x));
border-right: 1px solid var(--pd-rule);
}
.pd-item {
display: flex;
align-items: center;
- gap: 3.03cqh;
+ gap: calc(3.03 * var(--pv-x));
}
.pd-item i {
- width: 2.48cqh;
+ width: calc(2.48 * var(--pv-x));
aspect-ratio: 1;
background: var(--pd-ink);
border-radius: 50%;
}
.pd-item span {
- width: 19.36cqh;
- height: 1.27cqh;
+ width: calc(19.36 * var(--pv-x));
+ height: calc(1.27 * var(--pv-text) * var(--pv-x));
background: var(--pd-ink);
border-radius: 2px;
}
@@ -2032,12 +2129,12 @@ body.picker-page {
min-width: 0;
display: grid;
align-content: start;
- padding: 9.74cqh 50.06cqh 0 16.94cqh;
+ padding: 9.74cqh calc(50.06 * var(--pv-x)) 0 calc(16.94 * var(--pv-x));
}
.pd-heading {
width: var(--w, 100%);
- height: 2.53cqh;
+ height: calc(2.53 * var(--pv-text) * var(--pv-x));
background: var(--pd-strong);
border-radius: 2px;
}
@@ -2063,26 +2160,26 @@ body.picker-page {
.pd-para i {
width: var(--w, 100%);
- height: 1.1cqh;
+ height: calc(1.1 * var(--pv-text) * var(--pv-x));
background: var(--pd-ink);
border-radius: 2px;
}
.pd-note {
display: flex;
- gap: 3.41cqh;
+ gap: calc(3.41 * var(--pv-x));
height: 14.03cqh;
margin-top: 7.37cqh;
- padding: 3.52cqh 4.4cqh 0 5.17cqh;
+ padding: 3.52cqh calc(4.4 * var(--pv-x)) 0 calc(5.17 * var(--pv-x));
background: var(--pv-surface-2);
- border-left: 1.27cqh solid var(--pv-primary);
+ border-left: calc(1.27 * var(--pv-x)) solid var(--pv-primary);
border-radius: 0 2px 2px 0;
}
.pd-note-dot {
flex: none;
align-self: start;
- width: 2.64cqh;
+ width: calc(2.64 * var(--pv-x));
aspect-ratio: 1;
background: var(--pv-tertiary);
border-radius: 50%;
@@ -2098,7 +2195,7 @@ body.picker-page {
.pd-note-lines i {
width: var(--w, 100%);
- height: 0.83cqh;
+ height: calc(0.83 * var(--pv-text) * var(--pv-x));
background: var(--pv-tertiary);
border-radius: 2px;
}
@@ -2106,26 +2203,30 @@ body.picker-page {
/* The phone is the same document narrowed: breadcrumb, heading, the same two
passages, the same note. */
/* The phone body's own padding is a percentage of the phone's width, since a
- container cannot measure itself. Everything inside it is `cqh` against the
- padded box. */
+ container cannot measure itself. Everything inside it is measured against the
+ padded box, in `cqh` down the page and in `--pv-x` across it. The single
+ column is stated so a child that asks for more width than the box has cannot
+ open the column and carry its siblings out through the card edge. */
.pd-phone-body {
container-type: size;
min-height: 0;
display: grid;
+ grid-template-columns: minmax(0, 1fr);
align-content: start;
padding: 8.84% 11.42% 9.33% 10.23%;
+ --pv-x: 1.857cqw;
}
.pd-crumb {
- width: 13.11cqh;
- height: 1.77cqh;
+ width: calc(13.11 * var(--pv-x));
+ height: calc(1.77 * var(--pv-x));
margin-inline: auto;
background: var(--pd-ink);
border-radius: 2px;
}
.pd-phone-body .pd-heading {
- height: 3.14cqh;
+ height: calc(3.14 * var(--pv-x));
margin-top: 9.62cqh;
}
@@ -2134,7 +2235,7 @@ body.picker-page {
}
.pd-phone-body .pd-para i {
- height: 1.37cqh;
+ height: calc(1.37 * var(--pv-x));
}
.pd-phone-body .pd-para--lede {
@@ -2152,14 +2253,14 @@ body.picker-page {
.pd-phone-body .pd-note {
height: 14.27cqh;
- gap: 3.34cqh;
+ gap: calc(3.34 * var(--pv-x));
margin-top: 6.76cqh;
- padding: 4.37cqh 1.37cqh 0 3.14cqh;
- border-left-width: 1.23cqh;
+ padding: 4.37cqh calc(1.37 * var(--pv-x)) 0 calc(3.14 * var(--pv-x));
+ border-left-width: calc(1.23 * var(--pv-x));
}
.pd-phone-body .pd-note-dot {
- width: 3.34cqh;
+ width: calc(3.34 * var(--pv-x));
}
.pd-phone-body .pd-note-lines {
@@ -2167,7 +2268,7 @@ body.picker-page {
}
.pd-phone-body .pd-note-lines i {
- height: 1.37cqh;
+ height: calc(1.37 * var(--pv-x));
}
/* ── Gallery variant of the preview ──────────────────────────
@@ -2178,10 +2279,13 @@ body.picker-page {
the same way the docs variant was built.
Both bodies are size containers so the page inside scales with the card
- rather than spilling out of it. Every `cqh` below is a canonical pixel
+ rather than spilling out of it. Every measure below is a canonical pixel
divided by the body height it sits in: 182.78 on the desktop, 148.53 inside
- the phone's padded box. Chrome the tiles share (nav, phone top bar, tab bar)
- stays in pixels so every frame stays identical. */
+ the phone's padded box. Vertical ones spend that count in `cqh`, which is
+ what makes a taller card show taller plates rather than a zoomed index, and
+ horizontal ones spend it in `--pv-x` against the width, as the ops variant
+ above sets out. The chrome around both bodies is counted on the cards
+ themselves. */
.picker-preview--gallery {
--pg-ink: var(--pv-bone);
}
@@ -2194,10 +2298,12 @@ body.picker-page {
/* The illustration marks each shot in the brand color rather than the accent,
which is what ties the two rows back to the nav. */
+/* The mark is a fixed inset from the plate's own corner, so both insets are
+ measured with it rather than with the page. */
.picker-preview--gallery .pv-image::after {
- top: 3.41cqh;
- right: 3.15cqh;
- width: 3.37cqh;
+ top: calc(3.41 * var(--pv-x));
+ right: calc(3.15 * var(--pv-x));
+ width: calc(3.37 * var(--pv-x));
background: var(--pv-primary);
}
@@ -2206,24 +2312,28 @@ body.picker-page {
min-height: 0;
display: grid;
align-content: start;
+ --pv-x: 0.48764cqw;
}
/* The insets differ per row because the illustration staggers the pair rather
- than mirroring it, which is what keeps the index from reading as a grid. */
+ than mirroring it, which is what keeps the index from reading as a grid.
+ The plate is the drawn column and the caption takes what is left, so a frame
+ that changes shape spends it on the plate's height and never on the caption's
+ width. */
.pg-row {
display: grid;
- grid-template-columns: 73.39cqh minmax(0, 1fr);
- column-gap: 10.55cqh;
+ grid-template-columns: calc(73.39 * var(--pv-x)) minmax(0, 1fr);
+ column-gap: calc(10.55 * var(--pv-x));
height: 37.31cqh;
margin-top: 2.31cqh;
- padding-inline: 31.34cqh 32.13cqh;
+ padding-inline: calc(31.34 * var(--pv-x)) calc(32.13 * var(--pv-x));
}
.pg-row--flip {
- grid-template-columns: minmax(0, 1fr) 73.39cqh;
- column-gap: 11.18cqh;
+ grid-template-columns: minmax(0, 1fr) calc(73.39 * var(--pv-x));
+ column-gap: calc(11.18 * var(--pv-x));
margin-top: 7.28cqh;
- padding-inline: 34.97cqh 33.07cqh;
+ padding-inline: calc(34.97 * var(--pv-x)) calc(33.07 * var(--pv-x));
}
.pg-row .pv-image {
@@ -2239,31 +2349,33 @@ body.picker-page {
.pg-cap i {
width: var(--w, 100%);
- height: 1.5cqh;
+ height: calc(1.5 * var(--pv-text) * var(--pv-x));
background: var(--pg-ink);
border-radius: 2px;
}
.pg-cap i.pg-cap-title {
- height: 2.01cqh;
+ height: calc(2.01 * var(--pv-text) * var(--pv-x));
margin-bottom: 0.93cqh;
}
/* The insets are measured to the chevrons' drawn tips, which sit outside their
- layout boxes once the squares below are turned. */
+ layout boxes once the squares below are turned. The rail's own height is the
+ arrows it holds rather than a share of the page, so a taller card gives its
+ room to the plates above and leaves this strip alone. */
.pg-rail {
display: flex;
align-items: center;
- height: 3.25cqh;
+ height: calc(3.25 * var(--pv-x));
margin-top: 4.95cqh;
- padding-inline: 7.78cqh 8.24cqh;
+ padding-inline: calc(7.78 * var(--pv-x)) calc(8.24 * var(--pv-x));
}
/* A square stood on its corner: two borders draw the chevron without pulling
in an inline SVG nothing else in the component uses. */
.pg-arrow {
flex: none;
- width: 2.3cqh;
+ width: calc(2.3 * var(--pv-x));
aspect-ratio: 1;
border-left: 1px solid var(--pg-ink);
border-bottom: 1px solid var(--pg-ink);
@@ -2280,15 +2392,16 @@ body.picker-page {
.pg-track {
display: flex;
align-items: center;
- gap: 3.7cqh;
- margin-left: 7.26cqh;
+ gap: calc(3.7 * var(--pv-x));
+ margin-left: calc(7.26 * var(--pv-x));
margin-right: auto;
}
+/* Segment widths arrive as counts for the reason the table cells above do. */
.pg-track i {
flex: none;
- width: var(--w);
- height: 1.48cqh;
+ width: calc(var(--w) * var(--pv-x));
+ height: calc(1.48 * var(--pv-x));
background: var(--pg-ink);
border-radius: 2px;
}
@@ -2299,13 +2412,17 @@ body.picker-page {
/* The phone is the same index at one column: shot, caption, shot, caption. */
/* Its own padding is a percentage of the phone's width, since a container
- cannot measure itself. Everything inside is `cqh` against the padded box. */
+ cannot measure itself. Everything inside is measured against the padded box,
+ in `cqh` down the page and in `--pv-x` across it, and the single column is
+ stated so no child can open it wider than the phone. */
.pg-phone-body {
container-type: size;
min-height: 0;
display: grid;
+ grid-template-columns: minmax(0, 1fr);
align-content: start;
padding: 5.65% 8.34% 10.49%;
+ --pv-x: 1.770721cqw;
}
.pg-phone-body .pv-image {
@@ -2317,12 +2434,12 @@ body.picker-page {
margin-top: 5.59cqh;
}
-/* The phone container is shorter than the desktop one, so every shared `cqh`
+/* The phone container is shorter than the desktop one, so every shared measure
has to be restated against it. */
.pg-phone-body .pv-image::after {
- top: 3.02cqh;
- right: 3.26cqh;
- width: 3.4cqh;
+ top: calc(3.02 * var(--pv-x));
+ right: calc(3.26 * var(--pv-x));
+ width: calc(3.4 * var(--pv-x));
}
.pg-phone-body .pg-cap {
@@ -2336,12 +2453,12 @@ body.picker-page {
}
.pg-phone-body .pg-cap i {
- height: 1.52cqh;
+ height: calc(1.52 * var(--pv-x));
}
/* At this size the caption's first line is a shorter line, not a heavier one. */
.pg-phone-body .pg-cap i.pg-cap-title {
- height: 1.52cqh;
+ height: calc(1.52 * var(--pv-x));
margin-bottom: 0.28cqh;
}
@@ -2349,7 +2466,7 @@ body.picker-page {
even spread. */
.picker-preview--gallery .pv-tabbar {
justify-content: center;
- gap: 11px;
+ gap: calc(5.12149 * var(--pv-x));
}
.picker-mode-copy {
@@ -2535,7 +2652,11 @@ body.picker-page {
gap: 6px;
}
-.picker-strategy-title {
+/* Scoped to the copy block the two were drawn for. Both classes are also the
+ handles the per-surface machinery reads a row's name and its description
+ through, and the font rail names itself in the pair's own faces, so away from
+ this block they are hooks and carry no design of their own. */
+.picker-strategy-copy > .picker-strategy-title {
color: var(--ks-champagne);
font-family: var(--ks-font-display);
font-size: 1.22rem;
@@ -2545,7 +2666,7 @@ body.picker-page {
transition: color 180ms var(--ks-ease);
}
-.picker-strategy-desc {
+.picker-strategy-copy > .picker-strategy-desc {
color: var(--ks-text-muted);
font-size: 0.88rem;
line-height: 1.5;
@@ -2563,51 +2684,62 @@ body.picker-page {
grid-template-rows: minmax(0, 1fr);
}
-/* Screen 03 carries the palette under its choices, so its rail is two rows and
- the options are measured against what is left of the column rather than
- against all of it. Without the second measure the panel keeps its own 434 and
- overflows the row the band now shares. Scoped to this screen because the cap
- only holds where there are four options to divide. */
-/* What this screen has to spend between the heading and the fold. Both columns
- read it: the rail sizes its rows against it and the frame takes what the rail
- leaves, so whichever of the two is taller is still the one that decides where
- the CTA lands. The shared --pk-column is measured for a row an artboard sets
- and runs about 30px short of what this screen spends around its grid; at a
- 900px viewport that difference is the whole overhang. Taken at 1600x900, the
- tightest of the common sizes. */
+/* Screen 03 carries the palette under its choices and the buttons under its
+ frame, and the two pairs sit on the same two rows: the choices beside the
+ frame, then the palette beside the buttons. Both columns subgrid onto them, so
+ the frame's bottom edge is the options panel's bottom edge by construction
+ rather than by two sizing formulas agreeing. The row gap is the rail's own
+ former 14px, which now also sets the air between the frame and the CTA.
+
+ Everything this screen shows lives inside the grid, so what the grid may
+ spend is the whole distance between the heading and the fold. Set so that
+ 800px of viewport is the last height where the rows still reach their 108
+ ceiling; under that the panel gives up row height and then, past its floor,
+ scrolls. --pk-column, which the later question screens still read, is
+ measured for a grid with the buttons underneath it and leaves this one 90px
+ short of what it now owns. */
.picker-screen[data-screen="03"] .picker-strategy-grid {
- --pk-fit: calc(100svh - 376px);
+ --pk-fit: calc(100svh - 250px);
+ max-height: var(--pk-fit);
+ grid-template-rows: minmax(0, 1fr) auto;
+ row-gap: 14px;
}
.picker-screen[data-screen="03"] .picker-strategy-grid > .picker-type-rail {
- grid-template-rows: minmax(0, 1fr) auto;
+ grid-row: 1 / -1;
+ grid-template-rows: subgrid;
/* The rail is measured against the viewport rather than against the frame
beside it. The frame draws the chosen surface at that drawing's own ratio,
which is wider than the artboard this column used to hold, so matching its
height would spend the whole column on four rows of 78px.
- 546px is the comfortable rail: four rows at 108, the band at 96, the gap,
- and the panel's hairline. Below that the band is the first thing to give,
- down to a floor where the palette is still readable, because a shorter
- reading of four colors costs less than a squeezed sentence. */
- --pk-band: clamp(72px, calc(var(--pk-fit) - 450px), 96px);
+ The band no longer has to give on a short viewport: the row it sits in is
+ shared with the CTA stack, which is 90px whatever the viewport does, so
+ anything the band gave back would be spent on empty space beside the
+ buttons. */
+ --pk-band: 96px;
}
-/* And the rows come in under what the artboard beside them asks for, so the row
- they share keeps the height the artboard sets and the CTA underneath stays
- where it is on every other screen.
+/* These four rows are what the top row of the screen measures, and the frame
+ beside them takes whatever they come to, so this is also where the drawing's
+ height is decided.
Floored at 88px, which is the tallest option's two lines plus the air they
need to read as a paragraph rather than a block. Below that the row has no
padding left to give and the sentence is what gets cut, so past the floor the
- rail is allowed to be taller than the artboard instead: the same trade this
- column made before it carried a band. */
+ panel is allowed to run past the budget and scroll instead. */
.picker-screen[data-screen="03"] .picker-strategy-choices.picker-type-options {
/* The row formula below is the only cap this panel needs. The shared one
measures against the column budget alone, which on a short viewport asks
for less than the floor and clips the fourth option to reach it. */
max-height: none;
- grid-auto-rows: max(88px, min(108px, calc((var(--pk-fit) - var(--pk-band) - 18px) / 4)));
+ /* The row is the panel's own height at every size the budget clears, so
+ filling it costs nothing there and buys the scroll on the sizes where the
+ four rows have stopped fitting. */
+ align-self: stretch;
+ /* The 16 the rows do not get is the row the palette and the buttons share,
+ less the grid's own row gap and the panel's hairline. */
+ grid-auto-rows: max(88px, min(108px, calc((var(--pk-fit) - var(--pk-band) - 16px) / 4)));
}
/* Equal rows, sized by the tallest one's copy rather than by how many there
@@ -2791,25 +2923,69 @@ body.picker-page {
}
/* ============================================================
- The strategy stage. Screen 03 colors the surfaces chosen on screen 01b, so
- the frame holds one drawing per chosen surface and shows one at a time. The
- drawings are lifted whole from the tiles, which is why the frame takes their
- ratio rather than imposing the artboard's: two of the four are drawn in
- pixels and two scale off their own height, and stretching either to a taller
- frame breaks the drawing rather than filling it.
+ The strategy stage and the column it sits in. Screen 03 colors the surfaces
+ chosen on screen 01b, so the frame holds one drawing per chosen surface and
+ shows one at a time. The tabs sit on the frame's top right corner rather than
+ in a row of their own, and the buttons sit under it, so the column is the
+ whole answer to the question rather than a preview with the commitment parked
+ somewhere else on the page.
- The rail beside it is the taller column, so the frame is centred against it
- and the tabs ride in the space above, where they cover none of the page they
- are switching between.
+ The frame takes the height of the row the choices set instead of a ratio of
+ its own, so it is wider than tall here and squarer on a short viewport. Three
+ of the four drawings key their horizontal measures to their own width and so
+ hold either way. Persuade is still drawn in pixels and sits loose in a frame
+ this size.
============================================================ */
+.picker-strategy-subgrid {
+ min-width: 0;
+ grid-row: 1 / -1;
+ display: grid;
+ grid-template-rows: subgrid;
+}
+
+/* Centred on the frame rather than on the page, which is where the question was
+ answered. The row is sized for the palette across from it and the buttons come
+ to a few pixels less, so they take the middle of it. */
+.picker-strategy-subgrid > .picker-actions-stack {
+ align-content: center;
+}
+
+/* The stage is the frame's own box: one row, and the tab strip taken out of the
+ flow so it can sit on the corner. It is the positioned ancestor that strip
+ measures itself against. */
.picker-strategy-stage {
min-width: 0;
+ position: relative;
display: grid;
- grid-template-rows: auto auto;
- /* Level with the top of the rail beside it. Centring it against the taller
- column opened a gap under the heading that read as a missing element. */
- align-content: start;
- gap: 12px;
+ grid-template-rows: minmax(0, 1fr);
+}
+
+.picker-strategy-stage > .picker-preview {
+ /* Every chosen surface is mounted and all but one are display: none, so the
+ row is named rather than left to auto-placement: a frame where two are
+ briefly shown puts them on top of each other instead of stacking the stage
+ into a second row and taking the column past the choices' bottom edge. */
+ grid-row: 1;
+ /* The frame runs down to the bottom of the choices beside it, so it takes the
+ row's height rather than the 16 / 7 it carries everywhere else. */
+ aspect-ratio: auto;
+}
+
+/* The tiles and the palette screen show the drawing at a fraction of this
+ size, and this stage is the one place a visitor reads the desktop card as a
+ page rather than as a thumbnail. At that size the tool, the document and the
+ index draw their body text as hairlines beside their own phone card, which
+ spends nearly four times the share of its card on the same count. The step is
+ on the thickness only, so the page behind the window is unchanged: same
+ measure, same rhythm, same everything the counts were measured for.
+
+ The landing page is left at its authored weight. Its counts are the largest
+ of the four, and at this size they already read as type; a headline bar taken
+ any heavier stops being display type and becomes a slab. The nav is left out
+ for the same kind of reason: it is the chrome all four cards share, and a
+ visitor moving between tabs should not watch it change weight. */
+.picker-strategy-stage > .picker-preview:is(.picker-preview--ops, .picker-preview--docs, .picker-preview--gallery) .pv-desktop {
+ --pv-text: 1.35;
}
/* One drawing is shown and the rest stay mounted, so switching costs an
@@ -2820,9 +2996,34 @@ body.picker-page {
}
/* The group only appears with a second surface to switch to; a single surface
- is already the only thing the frame can be showing. */
+ is already the only thing the frame can be showing.
+
+ Out of the flow, docked on the frame's top right corner. A row of its own cost
+ the frame 65px of the height the choices beside it set, and cost it only when
+ more than one surface was chosen, so the drawing changed size with a decision
+ that has nothing to do with its size.
+
+ On the edge rather than inside it. Every drawing puts its nav across the
+ frame's top and the strip is taller than that band, so sitting inside covers
+ the one place the primary shows up as chrome. Outside it the strip also keeps
+ its own ground: raised lacquer and a hairline are read against the page, not
+ against a drawing repainted in whatever palette and strategy were chosen. The
+ 2px is the join, and the z-index is what keeps it visible, since the drawings
+ raise plates of their own that would otherwise paint over it.
+
+ Screen 03 hangs the strip on its stage, which is already positioned because
+ it stacks a drawing per surface. A question screen has one drawing and hangs
+ it on the artboard, which is that screen's frame. */
+.picker-artboard:has(> .picker-surface-tabs) {
+ position: relative;
+}
+
.picker-surface-tabs {
- justify-self: end;
+ position: absolute;
+ z-index: 1;
+ top: 2px;
+ right: 0;
+ transform: translateY(-100%);
display: flex;
gap: 2px;
padding: 4px;
@@ -2882,7 +3083,7 @@ body.picker-page {
border-color: var(--ks-patina);
}
-/* A strategy the surface in the frame cannot carry. The row keeps its place so
+/* An option the surface in the frame cannot carry. The row keeps its place so
the list does not reshuffle as you move between surfaces, and its sentence
is replaced by the reason it is out. */
.picker-strategy-option.is-blocked {
@@ -3748,6 +3949,54 @@ body.picker-page {
align-items: stretch;
}
+/* The frame's own box on the font screen. Every chosen surface gets a board and
+ they are all mounted, so the row is named rather than left to auto-placement:
+ four boards would otherwise stack into four rows and take the column past the
+ rail beside it. It is also the positioned ancestor the tab strip docks
+ against, and the node the chosen pair's faces are written to, which is how one
+ write sets all four boards. */
+.picker-type-stage {
+ /* The chosen pair is the run's answer rather than one board's, so the two
+ faces are declared here and written here, and every board is set in them
+ by inheriting. Declared on a board instead, these would outrank the write
+ and the boards would all draw in the picker's own faces. */
+ --pt-heading: var(--ks-font-display);
+ --pt-body: var(--ks-font);
+ --pt-heading-weight: 600;
+
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: minmax(0, 1fr);
+}
+
+/* The strip needs a positioned ancestor and the boards would rather not have
+ one: the stacking context shifts the rasterization of their plate edges by a
+ pixel. Nobody could see it, but a run with a single surface shows no strip and
+ has no reason to pay for one, so the position arrives with it. */
+.picker-type-stage:has(> .picker-surface-tabs:not([hidden])) {
+ position: relative;
+}
+
+.picker-type-stage > .picker-artboard {
+ grid-row: 1;
+ /* Both sides are named so the declared ratio has nothing left to decide. It
+ still does the asking: with no height of its own the stage measures the
+ ratio, which is what the row across from the rail is offered, and the taller
+ of the two wins. Leave the height out and the ratio answers instead of the
+ row, and the board comes up short of the rail beside it. */
+ width: 100%;
+ height: 100%;
+}
+
+/* One board is shown and the rest stay mounted, so switching costs an attribute
+ rather than a rebuild. [hidden] is a display: none that the component's own
+ display: grid outranks, so it is restated here. */
+.picker-artboard[hidden] {
+ display: none;
+}
+
/* The rail keeps the row height owned by the preview: the fieldset scrolls
inside it instead of stretching the page when six tall cards stack up. The
control row underneath is what tells you the list runs past its frame. On
@@ -3841,12 +4090,22 @@ body.picker-page {
/* Eight intervals and a lede leave less room than screen 04 has, so the sheet
is capped harder. The subtraction covers the title, the lede, and the CTA
- stack, which is what keeps Select this scale above the fold. */
+ stack, which is what keeps Select this scale above the fold.
+
+ Three columns share 1388px at the container's cap, and the two previews are
+ what pay for the third. The rail goes first: its widest row is the ratio and
+ Augmented Fourth on one line, which needs 300px of column and not the 420 it
+ was taking, and its description was already wrapping. The sheet gives the
+ rest. It can afford to because its sample string shortened to the scale's
+ name in the same pass, and a name is under half the width of the sentence
+ that was there, so the sheet renders its steps larger in 538px than it did in
+ 918. The reading column is last and smallest: 45 characters of lorem at the
+ base step is a measure, and more than that is width the other two need. */
.picker-scale-grid {
max-height: calc(100svh - 420px);
display: grid;
- grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
- gap: 50px;
+ grid-template-columns: minmax(300px, 352px) minmax(0, 1.25fr) minmax(0, 1fr);
+ gap: clamp(22px, 2.2vw, 34px);
align-items: stretch;
}
@@ -3927,14 +4186,18 @@ body.picker-page {
/* Fixed side columns, because each row is its own grid: sized to their content
they would land wherever that row's specimen and numbers happened to end,
and the tag column would step left and right down the sheet and again on
- every font change. Rem holds them still; the sheet's own face does not. */
+ every font change. Rem holds them still; the sheet's own face does not.
+
+ The gutters answer to the row rather than the viewport, because the row is
+ one of three columns now and a viewport-scaled gutter was spending 48px of a
+ 538px sheet on air the numbers did not ask for. */
.ts-row {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 2.4rem 4.4rem;
align-items: center;
- gap: clamp(12px, 2vw, 24px);
+ gap: clamp(12px, 3%, 24px);
border-bottom: 1px dotted var(--ks-rule);
}
@@ -4012,6 +4275,211 @@ body.picker-page {
font-style: normal;
}
+/* The sheet answers what each step measures. This column answers what the steps
+ do to each other once a page is built out of them, which is a different
+ question and the one a scale is actually chosen on. Same panel, same ink, same
+ sizes: it reads the step values syncTypeScale computed for the sheet, so one
+ scale cannot render two ways on one screen.
+
+ What it deliberately has none of is color and no chrome past the panel it
+ sits in. A palette here would ask screen 03's question again inside screen
+ 05's, and a specimen that has to be read past its own decoration is not
+ showing a type scale. */
+.picker-scale-specimen {
+ --pt-heading: var(--ks-font-display);
+ --pt-body: var(--ks-font);
+ --pt-heading-weight: 400;
+ /* One rhythm unit for the column, taken from the step the running text sets
+ at, so the air between components is measured in the scale being judged
+ rather than against it. */
+ --sp-rhythm: calc(var(--ts-step-0) * 1px);
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ align-content: start;
+ gap: calc(var(--sp-rhythm) * 1.6);
+ padding: clamp(14px, 2.4%, 28px) clamp(18px, 3.2%, 36px);
+ background: var(--ks-lacquer-raised);
+ color: var(--ks-text);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ overflow-y: auto;
+ /* Reaching the end of the specimen should not then carry the page with it. */
+ overscroll-behavior: contain;
+ /* Screen 04's list says it scrolls with a thin patina track, and the same
+ track says it here. What that list adds on top, arrows that page by a row,
+ has nothing to page through in a column of prose. */
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--ks-patina-deep) 35%, transparent) transparent;
+}
+
+/* Both preview columns take the rail's shape, a panel with a line under it, so
+ the three panels end on one edge. What goes in that line is what the column
+ owes the reader: the specimen hides more than either list on this screen and
+ an overlay scrollbar says so only once the reader has already guessed, so it
+ gets the rail's arrows; the sheet hides nothing and gets the sentence that
+ explains why its numbers and its rendered sizes disagree, which is also the
+ one place that sentence has ever been next to the numbers. */
+.picker-scale-column {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ gap: 14px;
+}
+
+/* A column of prose is the one item in this row whose content is taller than
+ any viewport, so left alone it takes the whole cap and the rail, which stops
+ at 570, ends 90px short of it. Taking the rail's ceiling keeps the columns
+ landing on one line and keeps the row the height the CTA above the fold was
+ calibrated against. */
+@media (min-width: 1200px) {
+ .picker-scale-column {
+ max-height: 570px;
+ }
+}
+
+/* One sentence is shorter than the 44px buttons the other two lines are made
+ of, and a line that measures less takes height back from its panel, which
+ loses the shared edge the shape exists for. */
+.picker-scale-column .picker-type-controls {
+ min-height: 44px;
+}
+
+/* Headings take the display face and running text the body face, which is the
+ division screen 04 just committed to. */
+.picker-scale-specimen :is(.sp-h1, .sp-h2, .sp-h3, .sp-h4) {
+ margin: 0;
+ color: var(--ks-champagne);
+ font-family: var(--pt-heading);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.14;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+
+.sp-h1 {
+ font-size: calc(var(--ts-step-6) * 1px);
+}
+
+.sp-h2 {
+ font-size: calc(var(--ts-step-5) * 1px);
+}
+
+.sp-h3 {
+ font-size: calc(var(--ts-step-4) * 1px);
+}
+
+.sp-h4 {
+ font-size: calc(var(--ts-step-3) * 1px);
+}
+
+/* A heading belongs to what follows it, so the break in the column goes above
+ it and not below. The column gap alone reads as a list of unrelated blocks. */
+.picker-scale-specimen :is(.sp-h2, .sp-h3, .sp-h4) {
+ margin-block-start: calc(var(--sp-rhythm) * 0.9);
+}
+
+.picker-scale-specimen :is(.sp-lede, .sp-p, .sp-list, .sp-quote) {
+ margin: 0;
+ font-family: var(--pt-body);
+ font-weight: 400;
+}
+
+.sp-lede {
+ color: var(--ks-champagne);
+ font-size: calc(var(--ts-step-1) * 1px);
+ line-height: 1.5;
+}
+
+.sp-p {
+ font-size: calc(var(--ts-step-0) * 1px);
+ line-height: 1.62;
+}
+
+.picker-scale-specimen strong {
+ color: var(--ks-champagne);
+ font-weight: 600;
+}
+
+/* Clear of the descenders the body face will have, whichever face it is. */
+.picker-scale-specimen u {
+ text-decoration-thickness: from-font;
+ text-underline-offset: 0.16em;
+}
+
+.sp-list {
+ padding-inline-start: calc(var(--sp-rhythm) * 1.5);
+ font-size: calc(var(--ts-step-0) * 1px);
+ line-height: 1.58;
+}
+
+.sp-list li + li {
+ margin-block-start: calc(var(--sp-rhythm) * 0.5);
+}
+
+.sp-list-ordered {
+ font-variant-numeric: tabular-nums;
+}
+
+.sp-list::marker,
+.sp-list li::marker {
+ color: var(--ks-text-faint);
+}
+
+/* The rule is the quote's only mark. It is a typographic convention rather
+ than picker chrome, which is why it survives the no-decoration rule that
+ removed everything else from this column. */
+.sp-quote {
+ padding-inline-start: calc(var(--sp-rhythm) * 1.2);
+ border-inline-start: 1px solid var(--ks-rule);
+}
+
+.sp-quote p {
+ margin: 0;
+ color: var(--ks-champagne);
+ font-size: calc(var(--ts-step-2) * 1px);
+ line-height: 1.42;
+}
+
+.sp-cite {
+ display: block;
+ margin-block-start: calc(var(--sp-rhythm) * 0.7);
+ color: var(--ks-text-muted);
+ font-size: calc(var(--ts-step-0) * 1px);
+ font-style: normal;
+ letter-spacing: 0.02em;
+}
+
+/* The mono face is not part of the pair screen 04 deals, so code takes the
+ kit's own stack. Guessing a third face here would put a claim in the
+ specimen that the run never made. */
+.sp-code,
+.sp-pre {
+ /* Mono runs large against a proportional face set at the same size. */
+ font-size: calc(var(--ts-step-0) * 0.94px);
+ font-family: var(--ks-mono);
+}
+
+.sp-code {
+ color: var(--ks-champagne);
+}
+
+/* The block wraps instead of scrolling sideways, and not for taste: an
+ overflowing box is a scroll container, a scroll container's automatic minimum
+ height is zero, and this column always has more content than height. The grid
+ spends that overflow on the one track it is allowed to shrink, so a code block
+ with overflow-x lands two pixels tall and the snippet disappears. */
+.sp-pre {
+ margin: 0;
+ padding: 14px 16px;
+ color: var(--ks-text-muted);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+
/* ============================================================
Screen 11: the icon set. Screen 05's frame again, because the question has
the same shape: more candidates than fit the rail, and one specimen that
@@ -4555,9 +5023,6 @@ body.picker-page {
artboards stay on scale. A future type-scale step replaces the ratio;
until then the preview follows this one everywhere. */
.picker-preview-type {
- --pt-heading: var(--ks-font-display);
- --pt-body: var(--ks-font);
- --pt-heading-weight: 600;
--pt-ratio: 1.618;
--pt-base: clamp(0.72rem, 0.86vw, 0.84rem);
/* Slot colors were mixed for bars, where a pale fill still reads as a
@@ -5055,6 +5520,603 @@ body.picker-page {
color: var(--pvs-title);
}
+/* ============================================================
+ The App UI board.
+
+ Same frame, same nav band, same footer as the landing board; only the two
+ bodies change. The wireframe on the surface tile is what it is drawn from,
+ column for column: a rail, a working column, a settings panel, at the
+ 18.68 / 51.84 / rest the drawing already uses.
+
+ What it drops is what a chart is. A chart carries no glyphs, so a pair
+ cannot be judged on one, and the numbers it stood for can. What it refuses
+ to add is a display line: nothing on a dashboard is set at 35px, and a board
+ that opened with one would be recommending a pair for a page this surface
+ never has. The largest step here is the panel title, and the face doing the
+ most work is the body face between 10 and 12px.
+ ============================================================ */
+.picker-preview-type--ops .ps-desktop {
+ grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
+}
+
+.ps-ops {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 18.68% 51.84% minmax(0, 1fr);
+}
+
+.ps-ops-rail,
+.ps-ops-main {
+ border-right: 1px solid var(--pvs-rule);
+}
+
+.ps-ops-rail,
+.ps-ops-panel {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ gap: var(--ps-gap-sm);
+ padding: var(--ps-band-y) calc(var(--pt-base) * 0.7);
+}
+
+.ps-ops-main {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ gap: var(--ps-gap-lg);
+ padding: var(--ps-band-y) calc(var(--pt-base) * 1.1);
+}
+
+/* Chrome on a tool is one line at the smallest step there is, and a rail item
+ or a table cell that took two would be a different control. Holding them to
+ one means a pair that sets them too wide reports it as an overflow rather
+ than as a second line nobody reads as a fault. */
+.picker-preview-type--ops :is(
+ .ps-ops-item b,
+ .ps-ops-row b,
+ .ps-ops-field b,
+ .ps-ops-switch b
+) {
+ min-width: 0;
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.ps-ops-item {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--pt-base) * 0.5);
+ padding: calc(var(--pt-base) * 0.35) calc(var(--pt-base) * 0.45);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-ops-item > i {
+ flex: none;
+ width: calc(var(--pt-base) * 0.4);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-a);
+ border-radius: 50%;
+}
+
+/* The open section is a filled plate rather than a tinted row, which is the
+ mark that tells a reader this rail belongs to a tool and not to a document.
+ Its label is the one place a rail sets the body face's semibold. */
+.ps-ops-item--on {
+ background-color: var(--pvs-cta);
+}
+
+.picker-preview-type--ops .ps-ops-item--on b {
+ color: var(--pvs-cta-text);
+ font-weight: 600;
+}
+
+.ps-ops-item--on > i {
+ background-color: var(--pvs-cta-text);
+}
+
+.ps-ops-title {
+ color: var(--pvs-title);
+ font-family: var(--pt-heading);
+ font-size: var(--pt-title);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.2;
+ overflow-wrap: break-word;
+}
+
+.ps-ops-metrics {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: var(--ps-gap-sm);
+}
+
+.ps-ops-metric {
+ min-width: 0;
+ display: grid;
+ gap: var(--ps-gap-xs);
+}
+
+/* The figure is set in the body face, because on a tool it is the body face
+ that has to draw numbers: a column of them lines up or it does not, and a
+ pair whose text figures step up and down loses a table the moment there is
+ more than one row of it. */
+.ps-ops-metric b {
+ color: var(--pvs-title);
+ font-family: var(--pt-body);
+ font-size: var(--pt-title);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums lining-nums;
+ line-height: 1.1;
+}
+
+/* Three of these sit side by side in a third of the column each, so the label
+ is the one micro line on the board that has to be allowed to take two. */
+.picker-preview-type--ops .ps-ops-metric span {
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ line-height: 1.35;
+}
+
+.ps-ops-table {
+ display: grid;
+ border-top: 1px solid var(--pvs-rule);
+}
+
+.ps-ops-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: calc(var(--pt-base) * 0.7);
+ align-items: center;
+ padding: calc(var(--pt-base) * 0.55) calc(var(--pt-base) * 0.3);
+}
+
+.ps-ops-row > i {
+ width: calc(var(--pt-base) * 0.4);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-c);
+ border-radius: 50%;
+}
+
+.picker-preview-type--ops .ps-ops-row b:first-of-type {
+ color: var(--pvs-title);
+}
+
+.picker-preview-type--ops .ps-ops-row b:last-of-type {
+ text-align: end;
+ font-variant-numeric: tabular-nums lining-nums;
+}
+
+/* The heading row is the semibold an Operate product leans on hardest, and it
+ sits directly over the cells it names, so any drift between the two weights
+ of the same face shows up here first. */
+.picker-preview-type--ops .ps-ops-row--head b {
+ color: var(--pvs-copy);
+ font-weight: 600;
+}
+
+.ps-ops-row--head > i {
+ background-color: transparent;
+}
+
+.ps-ops-row--on {
+ background-color: var(--pvs-ghost);
+}
+
+/* A settings panel's heading is a UI label rather than a section title, so it
+ takes the body face at reading size and the weight that goes with the job.
+ No other board sets that combination, and it is the one an app spends most
+ of its chrome on. */
+.ps-ops-panel-title {
+ color: var(--pvs-title);
+ font-family: var(--pt-body);
+ font-size: var(--pt-base);
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.ps-ops-field {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--pt-base) * 0.4);
+ padding: calc(var(--pt-base) * 0.4) calc(var(--pt-base) * 0.5);
+ border: 1px solid var(--pvs-ghost-edge);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.ps-ops-chev {
+ flex: none;
+ width: calc(var(--pt-base) * 0.32);
+ aspect-ratio: 1;
+ margin-left: auto;
+ border-right: 1px solid var(--pvs-copy);
+ border-bottom: 1px solid var(--pvs-copy);
+ transform: translateY(-25%) rotate(45deg);
+}
+
+.ps-ops-switch {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--pt-base) * 0.4);
+ padding-block: calc(var(--pt-base) * 0.3);
+}
+
+.ps-ops-toggle {
+ flex: none;
+ position: relative;
+ width: calc(var(--pt-base) * 1.5);
+ height: calc(var(--pt-base) * 0.8);
+ margin-left: auto;
+ background-color: var(--pvs-bars);
+ border-radius: 999px;
+}
+
+.ps-ops-toggle--on {
+ background-color: var(--pvs-cta);
+}
+
+.ps-ops-toggle::after {
+ content: "";
+ position: absolute;
+ top: 12.5%;
+ left: 8%;
+ height: 75%;
+ aspect-ratio: 1;
+ background-color: var(--pvs-ground);
+ border-radius: 50%;
+}
+
+.ps-ops-toggle--on::after {
+ right: 8%;
+ left: auto;
+}
+
+/* The handset takes the same screen narrowed: the title, one figure, the rows
+ cut to what they are and what they come to, and the two switches. The rail
+ and the panel are chrome a phone gives to a drawer, and a drawer is not on
+ screen. */
+.picker-preview-type--ops .ps-phone-body {
+ grid-template-rows: repeat(4, auto);
+ align-content: start;
+ gap: var(--ps-gap-md);
+}
+
+.picker-preview-type--ops .ps-phone-body .ps-ops-row {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+}
+
+.picker-preview-type--ops .ps-phone-body .ps-ops-panel {
+ gap: 0;
+ padding: 0;
+}
+
+/* ============================================================
+ The Docs board.
+
+ Drawn from the wireframe on the surface tile: a section rail at the 23.75%
+ the drawing sets, and a document beside it. Where it departs is the count
+ of headings. The drawing carries one; this board sets a page title and a
+ subsection heading together, because the relationship between two heading
+ levels is the thing a documentation pair is actually chosen on, and a
+ specimen showing one level can never be judged on it.
+
+ The other departure is the measure. The drawing stops its prose at roughly
+ 46 characters, which is a picture of a document rather than a document. A
+ body face is judged on the texture of a block at a reading measure, so the
+ prose is capped in characters instead: the cap is set in the chosen face,
+ so a wide-set family takes the column and a narrow one leaves the drawing's
+ whitespace of its own accord.
+ ============================================================ */
+.picker-preview-type--read .ps-desktop {
+ grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
+}
+
+.ps-docs {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 23.75% minmax(0, 1fr);
+}
+
+.ps-docs-rail {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ gap: var(--ps-gap-sm);
+ padding: var(--ps-band-y) calc(var(--pt-base) * 0.6) 0 var(--ps-gutter);
+ border-right: 1px solid var(--pvs-rule);
+}
+
+.ps-docs-item {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: calc(var(--pt-base) * 0.5);
+}
+
+.ps-docs-item > i {
+ flex: none;
+ width: calc(var(--pt-base) * 0.34);
+ aspect-ratio: 1;
+ background-color: var(--pvs-bars);
+ border-radius: 50%;
+}
+
+.picker-preview-type--read .ps-docs-item b {
+ min-width: 0;
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 500;
+ line-height: 1.4;
+}
+
+/* The open section is a recolor, not a plate. A reader is somewhere in a
+ document rather than in one of its modes, and a filled plate would say the
+ rail is a set of switches. */
+.ps-docs-item--on > i {
+ background-color: var(--pvs-accent-c);
+}
+
+.picker-preview-type--read .ps-docs-item--on b {
+ color: var(--pvs-accent-text);
+ font-weight: 600;
+}
+
+.ps-docs-main {
+ min-width: 0;
+ display: grid;
+ align-content: start;
+ padding: var(--ps-band-y) 6% 0 calc(var(--pt-base) * 1.6);
+}
+
+.picker-preview-type--read .ps-docs-main .pt-headline {
+ max-width: 22ch;
+}
+
+.picker-preview-type--read :is(.ps-docs-lede, .ps-docs-para) {
+ max-width: 70ch;
+ margin: 0;
+ color: var(--pvs-copy);
+ font-family: var(--pt-body);
+ font-size: var(--pt-base);
+ line-height: 1.65;
+}
+
+/* The lede is the one paragraph a documentation page sets apart, and it is set
+ apart by weight rather than by size so the block below it keeps the leading
+ the family drew for it. */
+.picker-preview-type--read .ps-docs-lede {
+ margin-top: calc(var(--pt-base) * 0.9);
+ color: var(--pvs-title);
+ font-weight: 500;
+}
+
+.picker-preview-type--read .ps-docs-para {
+ margin-top: calc(var(--pt-base) * 0.7);
+}
+
+.ps-docs-sub {
+ margin-top: calc(var(--pt-base) * 1.5);
+ color: var(--pvs-title);
+ font-family: var(--pt-heading);
+ font-size: var(--pt-title);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.25;
+ overflow-wrap: break-word;
+}
+
+/* A callout is the one place a documentation page prints body copy on a ground
+ that is not the page, which is where a light body weight stops holding. */
+.ps-docs-note {
+ display: flex;
+ gap: calc(var(--pt-base) * 0.6);
+ margin-top: calc(var(--pt-base) * 1.3);
+ padding: calc(var(--pt-base) * 0.7) calc(var(--pt-base) * 0.9);
+ background-color: var(--pvs-ghost);
+ border-left: calc(var(--pt-base) * 0.2) solid var(--pvs-accent-c);
+}
+
+.ps-docs-note-dot {
+ flex: none;
+ margin-top: calc(var(--pt-base) * 0.42);
+ width: calc(var(--pt-base) * 0.34);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-c);
+ border-radius: 50%;
+}
+
+.ps-docs-note-copy {
+ min-width: 0;
+ display: grid;
+ gap: calc(var(--pt-base) * 0.2);
+}
+
+.picker-preview-type--read .ps-docs-note-copy b {
+ color: var(--pvs-accent-text);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.picker-preview-type--read .ps-docs-note-copy > span {
+ color: var(--pvs-copy);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ line-height: 1.5;
+}
+
+/* The handset is the same document with the rail folded into a crumb, which is
+ how the drawing puts it too. One passage rather than two: past that the note
+ leaves the card. */
+.picker-preview-type--read .ps-phone-body {
+ grid-template-rows: repeat(5, auto);
+ align-content: start;
+ gap: 0;
+}
+
+.ps-docs-crumb {
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 500;
+ text-align: center;
+}
+
+.picker-preview-type--read .ps-phone-body .pt-headline {
+ margin-top: calc(var(--pt-base) * 1);
+ font-size: var(--pt-title);
+}
+
+/* The phone re-bases the type ladder smaller, so the step that separates a
+ passage from the heading over it has to be restated against the new base or
+ the two run together. */
+.picker-preview-type--read .ps-phone-body .ps-docs-sub {
+ margin-top: calc(var(--pt-base) * 2.1);
+}
+
+/* ============================================================
+ The Portfolio board.
+
+ The sparsest of the four drawings, so it is the one that changes most. The
+ drawing staggers two plates and a carousel rail under them. Here the second
+ plate pays for a page title: the staggered pair proves nothing the single
+ row does not, and this is the one surface whose heading face is chosen to be
+ looked at rather than read past.
+
+ The tracked caption meta is deliberate and it is the only tracked label in
+ the picker. An `experience` body face is asked to hold up letter-spaced and
+ uppercased, and an instruction a specimen cannot test is decoration.
+ ============================================================ */
+.picker-preview-type--experience .ps-desktop {
+ grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
+}
+
+.ps-index {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: var(--ps-gap-md);
+ padding: var(--ps-band-y) var(--ps-gutter);
+}
+
+.picker-preview-type--experience .ps-index > .pt-headline {
+ max-width: 20ch;
+}
+
+.ps-index-row {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 42% minmax(0, 1fr);
+ column-gap: 5.14%;
+}
+
+/* The plate keeps its own proportion rather than taking the row's height. A
+ frame that grew taller would otherwise stretch the work into a column, and
+ the room left over reads as the margin an index is hung in. */
+.ps-index-row > .ps-image {
+ height: auto;
+ max-height: 100%;
+ aspect-ratio: 4 / 3;
+ align-self: center;
+}
+
+.ps-index-cap {
+ min-width: 0;
+ display: grid;
+ align-content: center;
+ gap: calc(var(--pt-base) * 0.35);
+}
+
+.ps-index-title {
+ color: var(--pvs-title);
+ font-family: var(--pt-heading);
+ font-size: var(--pt-title);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.2;
+ overflow-wrap: break-word;
+}
+
+.picker-preview-type--experience .ps-index-meta {
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 500;
+ line-height: 1.4;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.picker-preview-type--experience .ps-index-note {
+ max-width: 46ch;
+ margin-top: calc(var(--pt-base) * 0.3);
+ color: var(--pvs-copy);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ line-height: 1.55;
+}
+
+.ps-index-rail {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--pt-base) * 0.9);
+}
+
+/* A square stood on its corner, the same construction the drawing uses, so the
+ board takes no inline SVG for two marks. */
+.ps-index-arrow {
+ flex: none;
+ width: calc(var(--pt-base) * 0.42);
+ aspect-ratio: 1;
+ border-left: 1px solid var(--pvs-bars);
+ border-bottom: 1px solid var(--pvs-bars);
+ transform: rotate(45deg);
+}
+
+.ps-index-arrow--next {
+ margin-left: auto;
+ border-right: 1px solid var(--pvs-bars);
+ border-top: 1px solid var(--pvs-bars);
+ border-bottom: 0;
+ border-left: 0;
+}
+
+.ps-index-track {
+ display: flex;
+ align-items: baseline;
+ gap: calc(var(--pt-base) * 1.1);
+}
+
+.picker-preview-type--experience .ps-index-track b {
+ color: var(--pvs-bars);
+ font-family: var(--pt-body);
+ font-size: var(--pt-micro);
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+.picker-preview-type--experience .ps-index-stop--on {
+ color: var(--pvs-title);
+}
+
+/* The handset drops the title and the rail and shows the work: two plates,
+ each with the caption the desktop row carries. */
+.picker-preview-type--experience .ps-phone-body {
+ grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr) auto;
+ gap: var(--ps-gap-sm);
+}
+
+.picker-preview-type--experience .ps-phone-body .ps-index-cap {
+ padding-top: 0;
+}
+
.picker-palette-hint {
min-height: 3.2em;
padding-inline-end: 48px;
diff --git a/picker/styles/screens/boundaries.css b/picker/styles/screens/boundaries.css
index 1bb0eb772..304523511 100644
--- a/picker/styles/screens/boundaries.css
+++ b/picker/styles/screens/boundaries.css
@@ -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);
}
diff --git a/picker/styles/screens/depth.css b/picker/styles/screens/depth.css
index 5eab68920..d84fe62fb 100644
--- a/picker/styles/screens/depth.css
+++ b/picker/styles/screens/depth.css
@@ -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 ──────────────────
diff --git a/picker/styles/screens/layout.css b/picker/styles/screens/layout.css
index 1cc18a650..ade543d92 100644
--- a/picker/styles/screens/layout.css
+++ b/picker/styles/screens/layout.css
@@ -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);
}
diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md
index dd134c87c..f70c22071 100644
--- a/skill/reference/visual-cues.md
+++ b/skill/reference/visual-cues.md
@@ -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-` 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 for every surface chosen, `` 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.