@@ -1164,10 +1257,10 @@ const questions = [
@@ -1544,13 +1637,33 @@ const questions = [
controls()[0]?.focus();
};
+ /* Screens 05 through 11 are reachable only from the configure hub, so
+ both their CTA and their Back return there rather than to the DOM
+ neighbor. The hub itself and the review screen keep the ordinary
+ rule: Back on the hub is the font pair, Back on the review's error
+ box is the icons screen. */
+ const HUB_SCREEN = '04b';
+ const HUB_RETURNS = new Set(['05', '06', '07', '08', '09', '10', '11']);
+ const screenIndex = (id) => screens.findIndex((screen) => screen.dataset.screen === id);
+
form.onclick = (e) => {
const control = e.target.closest('[data-advance]');
if (!control) return;
+ if (HUB_RETURNS.has(screens[current].dataset.screen)) {
+ goTo(screenIndex(HUB_SCREEN));
+ return;
+ }
const step = control.dataset.advance === 'prev' ? -1 : 1;
goTo(current + step, step);
};
+ /* The hub's cards and its finish CTA jump by screen id. goTo() keeps
+ its skip rule, so a jump aimed at a skipped screen walks off it. */
+ document.addEventListener('picker:goto', (e) => {
+ const index = screenIndex(e.detail.screen);
+ if (index !== -1) goTo(index);
+ });
+
document.addEventListener('keydown', (e) => {
if (e.defaultPrevented) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [role="slider"]')) return;
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js
index e6cc9ae19..89611b459 100644
--- a/picker/scripts/palette-picker.js
+++ b/picker/scripts/palette-picker.js
@@ -1808,6 +1808,9 @@ scroller.addEventListener('scroll', () => {
}, { passive: true });
document.addEventListener('picker:screenchange', (event) => {
activate(event.detail.screen === '02');
+ // The hub re-reads every answer on arrival, so an edit made on a
+ // question screen is on its card by the time the return lands.
+ if (event.detail.screen === '04b') renderHub();
// Every artboard on the screen being shown, whatever question it belongs to,
// gets the committed palette. The scale sheet is deliberately not one: it is
// picker chrome in the picker's own theme, not a page in the user's palette.
@@ -2218,6 +2221,128 @@ const paintStage = () => {
for (const question of surfaceQuestions) question.paint();
};
+/* ============================================================
+ Screen 04b: the configure hub.
+
+ Seven questions remain after the font pair, and every one of
+ them already holds an answer: sync() pre-fills each per-surface
+ field with its surface's default the moment the tile is checked,
+ and the flat radios ship with a default checked. The hub reads
+ those answers back out of the DOM (hidden fields for per-surface
+ questions, the checked radio for flat ones), maps each value to
+ the title on its own option row, and marks the cards whose
+ answer a person actually picked. Nothing here writes an answer;
+ the cards are a reading of the form.
+ ============================================================ */
+const hubScreen = document.querySelector('.picker-screen[data-screen="04b"]');
+/* Card target screen mapped to the radio group that screen answers. */
+const HUB_GROUPS = {
+ '05': 'type-scale',
+ '06': 'motion-energy',
+ '07': 'layout-structure',
+ '08': 'boundary-style',
+ '09': 'corner-style',
+ '10': 'depth-style',
+ '11': 'icon-pack',
+};
+/* Type scale and icon set render no per-surface fields, so they have
+ no dataset.chosen. A change event on their group is the one signal
+ that a person picked rather than the markup default: programmatic
+ checks never fire it. */
+const hubEdited = new Set();
+document.addEventListener('change', ({ target }) => {
+ if (target?.name === 'type-scale' || target?.name === 'icon-pack') hubEdited.add(target.name);
+});
+
+const hubSurfaceLabel = (mode) => modeInputs.find((input) => input.value === mode)?.dataset.surfaceLabel ?? mode;
+
+/* The display title lives on the option row of the question's own
+ screen, so the hub never restates copy. The two dealt-data groups
+ carry their names as data attributes instead of a row label. */
+function hubOptionTitle(group, value) {
+ const input = document.querySelector(`input[name="${group}"][value="${value}"]`);
+ if (!input) return value;
+ if (input.dataset.scaleName) return `${input.dataset.scaleName} · ${input.dataset.ratio}`;
+ if (input.dataset.packName) return input.dataset.packName;
+ return input.closest('.picker-strategy-option')?.querySelector('.picker-strategy-title')?.textContent.trim() ?? value;
+}
+
+/* The enabled fields are exactly the chosen surfaces this question
+ was put to; sync() disables the rest and empties their values. */
+function hubSurfaceRows(group) {
+ return [...document.querySelectorAll(`input[type="hidden"][data-surface-field^="${group}-"]`)]
+ .filter((field) => !field.disabled && field.value)
+ .map((field) => ({
+ mode: field.dataset.surfaceField.slice(group.length + 1),
+ value: field.value,
+ chosen: field.dataset.chosen === 'yes',
+ }));
+}
+
+function hubLine(text, surface) {
+ const line = document.createElement('span');
+ line.className = 'picker-hub-line';
+ if (surface) {
+ const name = document.createElement('b');
+ name.textContent = surface;
+ line.append(name);
+ }
+ line.append(text);
+ return line;
+}
+
+function renderHub() {
+ if (!hubScreen) return;
+ for (const cardNode of hubScreen.querySelectorAll('.picker-hub-card')) {
+ const group = HUB_GROUPS[cardNode.dataset.hubTarget];
+ if (!group) continue;
+ const summary = cardNode.querySelector('[data-hub-summary]');
+ const mark = cardNode.querySelector('[data-hub-mark]');
+ const target = document.querySelector(`.picker-screen[data-screen="${cardNode.dataset.hubTarget}"]`);
+ const skipped = Boolean(target?.hasAttribute('data-skip'));
+ cardNode.classList.toggle('is-skipped', skipped);
+ cardNode.disabled = skipped;
+ cardNode.setAttribute('aria-disabled', skipped ? 'true' : 'false');
+ if (skipped) {
+ summary.replaceChildren(hubLine('Not asked of these surfaces'));
+ cardNode.classList.remove('is-edited');
+ mark.hidden = true;
+ continue;
+ }
+ const rows = hubSurfaceRows(group);
+ let edited;
+ let lines;
+ if (rows.length === 0) {
+ const checked = document.querySelector(`input[name="${group}"]:checked`);
+ edited = hubEdited.has(group);
+ lines = [hubLine(checked ? hubOptionTitle(group, checked.value) : '')];
+ } else {
+ edited = rows.some((row) => row.chosen);
+ if (rows.length > 1 && rows.every((row) => row.value === rows[0].value)) {
+ lines = [hubLine(`${hubOptionTitle(group, rows[0].value)} · all`)];
+ } else if (rows.length === 1) {
+ lines = [hubLine(hubOptionTitle(group, rows[0].value))];
+ } else {
+ lines = rows.map((row) => hubLine(hubOptionTitle(group, row.value), hubSurfaceLabel(row.mode)));
+ }
+ }
+ summary.replaceChildren(...lines);
+ cardNode.classList.toggle('is-edited', edited);
+ mark.hidden = !edited;
+ }
+}
+
+/* Cards and the finish CTA jump by screen id. The inline nav script
+ owns goTo(); it listens for this event, so no swap logic is
+ duplicated here. A disabled card never reaches this handler. */
+hubScreen?.addEventListener('click', (event) => {
+ const cardNode = event.target.closest('[data-hub-target]');
+ if (!cardNode || cardNode.disabled) return;
+ document.dispatchEvent(new CustomEvent('picker:goto', {
+ detail: { screen: cardNode.dataset.hubTarget },
+ }));
+});
+
for (const input of modeInputs) {
input.addEventListener('change', () => {
syncModesNext();
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
index 4268affa8..20c243ce3 100644
--- a/picker/styles/picker.css
+++ b/picker/styles/picker.css
@@ -7552,3 +7552,144 @@ body.picker-page {
}
}
}
+
+/* ============================================================
+ Screen 04b: the configure hub. Three packed columns of cards;
+ the tall per-surface cards (motion, layout) anchor the outer
+ tracks. Sharp corners and hairline rules, the same vocabulary
+ as the option rows. Summaries are written by renderHub().
+ ============================================================ */
+.picker-hub {
+ display: grid;
+ gap: clamp(28px, 4.5svh, 44px);
+ justify-items: center;
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+.picker-hub-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ align-items: start;
+ width: min(100%, 1080px);
+}
+
+.picker-hub-col {
+ display: grid;
+ gap: 14px;
+ align-content: start;
+}
+
+.picker-hub-card {
+ display: grid;
+ gap: 10px;
+ width: 100%;
+ padding: 20px 24px;
+ text-align: left;
+ background: var(--ks-lacquer-raised);
+ border: 1px solid var(--ks-rule);
+ border-radius: 0;
+ cursor: pointer;
+ transition:
+ background-color 180ms var(--ks-ease),
+ border-color 180ms var(--ks-ease);
+}
+
+.picker-hub-card:hover {
+ background: var(--ks-graphite);
+ border-color: var(--ks-kinpaku-deep);
+}
+
+.picker-hub-card:focus-visible {
+ outline: 1px solid var(--ks-kinpaku);
+ outline-offset: 3px;
+}
+
+.picker-hub-eyebrow {
+ color: var(--ks-text-faint);
+ font-size: var(--ks-type-eyebrow-size);
+ font-weight: 500;
+ letter-spacing: var(--ks-type-eyebrow-track);
+ text-transform: uppercase;
+}
+
+.picker-hub-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--ks-champagne);
+ font-size: var(--ks-type-title-size);
+ font-weight: var(--ks-type-title-weight);
+ line-height: var(--ks-type-title-line);
+}
+
+.picker-hub-chevron {
+ width: 16px;
+ height: 16px;
+ margin-left: auto;
+ flex: 0 0 auto;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ color: var(--ks-text-faint);
+ transition: color 180ms var(--ks-ease), transform 180ms var(--ks-ease);
+}
+
+.picker-hub-card:hover .picker-hub-chevron {
+ color: var(--ks-kinpaku);
+ transform: translateX(2px);
+}
+
+.picker-hub-mark {
+ padding: 2px 8px;
+ border: 1px solid var(--ks-kinpaku-deep);
+ color: var(--ks-kinpaku);
+ font-size: var(--ks-type-eyebrow-size);
+ font-weight: 500;
+ letter-spacing: var(--ks-type-eyebrow-track);
+ text-transform: uppercase;
+}
+
+.picker-hub-summary {
+ display: grid;
+ gap: 6px;
+ color: var(--ks-text-muted);
+ font-size: 0.86rem;
+ line-height: 1.5;
+}
+
+.picker-hub-line {
+ display: grid;
+ gap: 2px;
+}
+
+.picker-hub-line b {
+ color: var(--ks-text-faint);
+ font-weight: 500;
+}
+
+/* A question no chosen surface takes: present so the menu keeps its
+ shape, muted so nobody is invited into a screen the run skips. */
+.picker-hub-card.is-skipped,
+.picker-hub-card.is-skipped:hover {
+ background: transparent;
+ border-color: var(--ks-rule);
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+.picker-hub-card.is-skipped .picker-hub-title,
+.picker-hub-card.is-skipped .picker-hub-eyebrow,
+.picker-hub-card.is-skipped .picker-hub-summary,
+.picker-hub-card.is-skipped .picker-hub-chevron {
+ color: var(--ks-text-mute-deep);
+}
+
+/* The app's one narrow breakpoint. Today the width gate overlays the
+ form below 1200px, so this is the collapse the surface owes if that
+ gate ever loosens; it costs nothing while it holds. */
+@media (max-width: 1199px) {
+ .picker-hub-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}