Add configure hub after the font pair screen.

Let questionnaire runs finish early or jump into the seven remaining questions from a summary card grid, with hub navigation and per-answer summaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:40 +05:00
co-authored by Cursor
parent 5dffc569ff
commit 80211f3364
3 changed files with 381 additions and 2 deletions
+115 -2
View File
@@ -907,6 +907,99 @@ const questions = [
</div>
</section>
{/* Screen 04b: the configure hub. Every remaining question already
holds an answer (defaults are pre-filled the moment a surface tile
is checked), so the run can end here. The cards are skeletons;
summaries and edited marks are written by renderHub() in
scripts/palette-picker.js on every arrival. Placement is the
approved masonry: tall per-surface cards anchor the outer columns. */}
<section class="picker-screen" data-screen="04b" data-step="Configure" aria-hidden="true" aria-labelledby="picker-hub-title">
<div class="picker-container">
<div class="picker-hub">
<h2 id="picker-hub-title" class="picker-question-title">What else would you like to configure?</h2>
<div class="picker-hub-grid">
<div class="picker-hub-col">
<button class="picker-hub-card" type="button" data-hub-target="06" data-hub-category="material">
<span class="picker-hub-eyebrow">Material</span>
<span class="picker-hub-title">Motion
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
<button class="picker-hub-card" type="button" data-hub-target="09" data-hub-category="material">
<span class="picker-hub-eyebrow">Material</span>
<span class="picker-hub-title">Corners
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
</div>
<div class="picker-hub-col">
<button class="picker-hub-card" type="button" data-hub-target="05" data-hub-category="typography">
<span class="picker-hub-eyebrow">Typography</span>
<span class="picker-hub-title">Type scale
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
<button class="picker-hub-card" type="button" data-hub-target="11" data-hub-category="iconography">
<span class="picker-hub-eyebrow">Iconography</span>
<span class="picker-hub-title">Icon set
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
<button class="picker-hub-card" type="button" data-hub-target="08" data-hub-category="material">
<span class="picker-hub-eyebrow">Material</span>
<span class="picker-hub-title">Boundaries
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
</div>
<div class="picker-hub-col">
<button class="picker-hub-card" type="button" data-hub-target="07" data-hub-category="material">
<span class="picker-hub-eyebrow">Material</span>
<span class="picker-hub-title">Layout
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
<button class="picker-hub-card" type="button" data-hub-target="10" data-hub-category="material">
<span class="picker-hub-eyebrow">Material</span>
<span class="picker-hub-title">Depth
<span class="picker-hub-mark" data-hub-mark hidden>Edited</span>
<svg class="picker-hub-chevron" viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</span>
<span class="picker-hub-summary" data-hub-summary></span>
</button>
</div>
</div>
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-hub-finish data-hub-target="12">
Create my design context
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="05" data-step="Type scale" aria-hidden="true" aria-labelledby="picker-scale-title">
<div class="picker-container">
<div class="picker-scale">
@@ -1164,10 +1257,10 @@ const questions = [
<div class="picker-progress" aria-hidden="true">
<div class="picker-progress-track">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
<i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<p class="picker-progress-label">
<span class="picker-progress-index"><b data-progress-index>1</b> / <span data-progress-total>11</span></span>
<span class="picker-progress-index"><b data-progress-index>1</b> / <span data-progress-total>12</span></span>
<span class="picker-progress-name" data-progress-name></span>
</p>
</div>
@@ -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;
+125
View File
@@ -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();
+141
View File
@@ -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);
}
}