mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
+115
-2
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user