This commit is contained in:
Abdul Wahab
2026-09-01 10:05:59 +05:00
parent 33ea98ae3e
commit bba73a2283
34 changed files with 2571 additions and 171 deletions
+35
View File
@@ -0,0 +1,35 @@
/** The boot contract: one fetch that tells the page how to start.
*
* Both scripts on the page read it, so it is fetched once and shared. A server
* that predates the contract answers 404, and the page starts the way it always
* did: a blank questionnaire.
*/
let bootPromise = null;
export function getBoot() {
bootPromise ??= fetch('/boot.json')
.then((response) => (response.ok ? response.json() : null))
.catch(() => null)
.then((data) => ({
mode: data?.mode === 'doc' ? 'doc' : 'questionnaire',
prior: data?.prior && typeof data.prior === 'object' && !Array.isArray(data.prior) ? data.prior : null,
priorSource: data?.priorSource || null,
doc: data?.doc || null,
}));
return bootPromise;
}
/* Restoring a previous run happens inside the questionnaire, which owns the
state being restored. The document waits on this before it renders, so it
never reads a form that is still half filled. It resolves either way: a run
with nothing to restore is ready immediately. */
let settle;
export const hydrationReady = new Promise((resolve) => { settle = resolve; });
/* The attribute is the observable half: it drives the note on the start screen
and gives anything watching the page one thing to wait for. */
export function markHydrated(source) {
document.body.dataset.hydrated = source || 'none';
settle(source || null);
}
+272 -39
View File
@@ -1,16 +1,22 @@
/* Design context document the questionnaire's final act.
/* Design context document, the questionnaire's final act and its own surface.
*
* When the run reaches the review screen this module saves the answers, then
* When a run reaches the review screen this module saves the answers, then
* swaps the picker for the eight-category design context document. The mosaic
* landing, tile-to-fullscreen morph, sidebar shell, and article vocabulary are
* ported unchanged from docs/design-context-categorization/design-context.html;
* what changed is the content: the prototype rendered one example project, this
* renders the interview that just ended. Everything is assembled client-side
* before the POST resolves, because the server's exit on /submit is the
* completion signal the agent waits on after it there is nothing to fetch.
* completion signal the agent waits on, and after it there is nothing to fetch.
*
* The document is also openable on its own, long after that run. The boot
* contract says which of the two this page is, and document mode renders from
* the design-context store with no submit involved.
*/
import { contrastInk, contrastInkHex, formatOklch, readableOn } from './color.js';
import { getBoot, hydrationReady } from './boot.js';
import { loadIconPacks } from './palette-picker.js';
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
@@ -18,23 +24,37 @@ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const form = $('#picker-form');
const shell = $('[data-dcx-shell]');
/* Seed context (the chat half of the interview) and the dealt palettes ride
in on cues.json. Fetched at load, before the server can exit: the context
block feeds the chat-sourced pages, and the palette map is what the
provenance tags compare committed values against. */
/* Seed context (the chat half of the interview) lives in the design-context
store; the dealt palettes stay with the cues that generated them. Both are
fetched at load, before the server can exit: the context block feeds the
chat-sourced pages, and the palette map is what the provenance tags compare
committed values against. Both promises are kept rather than discarded,
because a document opened directly renders from them instead of waiting on
a submit that never comes. */
let seedContext = null;
let seedModes = null;
let seedPalettes = null;
let seedCues = null;
fetch('/cues.json')
const getJson = (url) => fetch(url)
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
seedContext = data?.context || null;
seedModes = Array.isArray(data?.modes) ? data.modes : null;
seedPalettes = data?.palette || null;
seedCues = Array.isArray(data?.cues) ? data.cues : null;
})
.catch(() => {});
.catch(() => null);
const cuesReady = getJson('/cues.json').then((data) => {
seedPalettes = data?.palette || null;
seedCues = Array.isArray(data?.cues) ? data.cues : null;
return data;
});
/* Field by field, not file by file: a store written before a field existed,
or one carrying only half a run, still falls back to whatever the cue
manifest kept from the release that wrote it. */
const contextReady = Promise.all([getJson('/context.json'), cuesReady])
.then(([stored, cues]) => {
seedContext = stored?.context ?? cues?.context ?? null;
const modes = Array.isArray(stored?.modes) ? stored.modes : cues?.modes;
seedModes = Array.isArray(modes) ? modes : null;
});
/* The winning cue's dealt value for one role, read the way the deck's own
createState reads it in palette-picker.js: the pixel-snapped value when
@@ -42,6 +62,17 @@ fetch('/cues.json')
source that is not a cue in cues.json (a seed-deck card, a custom
palette) has no entry here and returns nothing, which is what turns the
provenance tag off. */
/* Which of the two surfaces this page is. Set before the document renders in
document mode, read by the parts of it that differ. */
let docMode = false;
/* The submit flow renders before the server has exited but reveals after, and
the store's copy of the cue is made during that submit: a URL first requested
after the exit would find nothing serving it. The cue the questionnaire
already displayed is in the browser's cache, so that run keeps reading it
from the workspace, and only a document opened later reads the store copy. */
const cueImageSrc = (slug) => (docMode ? '/cue.png' : `/cues/${encodeURIComponent(slug)}.png`);
const seedHexFor = (source, role) => {
const slot = seedPalettes?.[source]?.[role];
if (!slot) return '';
@@ -221,6 +252,16 @@ const empty = (title, body) => `
const note = (text) => `<p class="dcx-fan-note">${text}</p>`;
/* A value the document lets a person change in place.
The binding id is the whole address: the session resolves it to a file and
a path, so nothing on this side has to know where the text lives. The
original travels with it because an edit reports what it replaced, and a
field edited twice still has to report the value the store started from.
Editing itself is switched on after render, and only where a session can
accept it. */
const editable = (bindingId, text) => `<span class="dcx-editable" data-dcx-binding="${escapeHtml(bindingId)}" data-dcx-original="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
/* Chat-round material renders when the agent passed it along, and says where
it lives when it did not — an interview that skipped a question is a fact
the document reports, not a gap it papers over. */
@@ -362,8 +403,8 @@ function buildAudience(s, name) {
const audience = s.context?.audience || {};
const parts = [heading(1, 'Audience', 'Who it is for, emotional state, needs, trust triggers.', name)];
const who = [
audience.primary && { dt: 'Primary', dd: escapeHtml(audience.primary) },
audience.secondary && { dt: 'Secondary', dd: escapeHtml(audience.secondary) },
audience.primary && { dt: 'Primary', dd: editable('audience.primary', audience.primary) },
audience.secondary && { dt: 'Secondary', dd: editable('audience.secondary', audience.secondary) },
].filter(Boolean);
parts.push(block('Who they are', who.length
? defs(who)
@@ -371,8 +412,8 @@ function buildAudience(s, name) {
/* Arrival-only context keeps the old single-callout block; a leaving line
widens it into the two-beat journey, side by side. */
if (audience.emotion || audience.leaving) {
const arrival = audience.emotion ? callout('On arrival', escapeHtml(audience.emotion), true) : '';
const leaving = audience.leaving ? callout('Leaving with', escapeHtml(audience.leaving), true) : '';
const arrival = audience.emotion ? callout('On arrival', editable('audience.emotion', audience.emotion), true) : '';
const leaving = audience.leaving ? callout('Leaving with', editable('audience.leaving', audience.leaving), true) : '';
if (arrival && leaving) {
parts.push(block('Emotional journey', `<div class="dcx-callout-pair">${arrival}${leaving}</div>`));
} else {
@@ -437,7 +478,7 @@ function buildProduct(s, name) {
const product = s.context?.product || {};
const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)];
const purposeCallout = product.purpose
? callout(product.name || name || 'This product', escapeHtml(product.purpose), false,
? callout(product.name || name || 'This product', editable('product.purpose', product.purpose), false,
product.success ? `\n <p class="dcx-callout-success">${escapeHtml(product.success)}</p>` : '')
: fromChat('The purpose and success definition were confirmed', '<code>PRODUCT.md &middot; Product Purpose</code>');
const platform = typeof product.platform === 'string' && product.platform.trim()
@@ -448,8 +489,8 @@ function buildProduct(s, name) {
: purposeCallout));
if (product.positioning && (product.positioning.not || product.positioning.this)) {
const cells = [
product.positioning.not && callout('Not this', escapeHtml(product.positioning.not)),
product.positioning.this && callout('This', escapeHtml(product.positioning.this), true),
product.positioning.not && callout('Not this', editable('product.positioning.not', product.positioning.not)),
product.positioning.this && callout('This', editable('product.positioning.this', product.positioning.this), true),
].filter(Boolean).join('');
parts.push(block('Positioning', `<div class="dcx-callout-pair">${cells}</div>`));
}
@@ -485,7 +526,7 @@ function buildBrand(s, name) {
words alone over the pointer to the durable copy when only they arrived;
the plain pointer otherwise. */
parts.push(block('Personality', brand.personality
? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true)
? callout(brand.words?.join(' · ') || 'Voice', editable('brand.personality', brand.personality), true)
: (Array.isArray(brand.words) && brand.words.length
? callout(brand.words.join(' · '), 'Three words, voice, and tone were confirmed in chat, before the browser questionnaire. <code>PRODUCT.md &middot; Brand Personality</code> is the durable copy.', true)
: fromChat('Three words, voice, and tone were confirmed', '<code>PRODUCT.md &middot; Brand Personality</code>'))));
@@ -536,7 +577,7 @@ function buildBrand(s, name) {
+ note('Q5 of the seed interview. A hard constraint on every palette and pair that followed.')));
}
/* Assets: an object entry carries a staged file under
.impeccable/design-interview/assets/ and renders as an image; a plain
.impeccable/design-context/assets/ and renders as an image; a plain
string keeps the text line it always had. A logo is proofed on two
chips, the committed primary and the committed neutral, so a colored
and a quiet ground are judged at once; boards and references get a
@@ -564,7 +605,7 @@ function buildBrand(s, name) {
</div>
${assetCaption(entry)}
</figure>`).join('')}</div>`
+ note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in <code>.impeccable/design-interview/assets/</code>.')));
+ note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in <code>.impeccable/design-context/assets/</code>.')));
}
if (boards.length) {
parts.push(block('Boards and references', `<div class="dcx-boards">${boards.map((entry) => `
@@ -572,7 +613,7 @@ function buildBrand(s, name) {
<span class="dcx-board-frame"><img src="${brandAssetSrc(entry.file)}" alt="${escapeHtml(entry.file)}" loading="lazy" /></span>
${assetCaption(entry)}
</figure>`).join('')}</div>`
+ note('Boards and reference images provided in chat, staged in <code>.impeccable/design-interview/assets/</code>.')));
+ note('Boards and reference images provided in chat, staged in <code>.impeccable/design-context/assets/</code>.')));
}
if (textAssets.length) {
parts.push(block('Assets provided', list(textAssets.map((entry) => escapeHtml(
@@ -599,9 +640,20 @@ function buildColor(s, name) {
points marked at the cues.json coordinates in each role's dealt color,
and the rest of the generated set dimmed below. Skipped without ceremony
when the palette came from a seed deck or a custom pick rather than a
cue, or when the run had no cues at all. */
cue, or when the run had no cues at all.
The image itself comes from the store, where the submit put a copy of the
one that was picked, so a document reopened after the generation workspace
was cleaned still has its cue. The workspace only has to still be there
for the sample dots and the directions not taken. */
const cueSlugs = Array.isArray(s.cueSlugs) ? s.cueSlugs : [];
const chosenCue = cueSlugs.includes(s.paletteSource) ? s.paletteSource : '';
/* A document opened on its own reads the cue out of the store, so the
generation workspace no longer has to still list it. A palette that never
came from a cue has no copy there either, and the whole block hides itself
when the image fails, which is the same answer arrived at later. */
const chosenCue = s.paletteSource && (docMode || cueSlugs.includes(s.paletteSource))
? s.paletteSource
: '';
if (chosenCue && s.palette.length) {
const cuePalette = seedPalettes?.[chosenCue] || {};
const dots = ROLES.map((role) => {
@@ -619,7 +671,7 @@ function buildColor(s, name) {
</div>`).join('');
parts.push(block('The cue', `<div class="dcx-cue" data-dcx-hide-on-error>
<figure class="dcx-cue-frame">
<img data-dcx-cue-img src="/cues/${encodeURIComponent(chosenCue)}.png" alt="The chosen visual cue, ${escapeHtml(chosenCue)}" />
<img data-dcx-cue-img src="${cueImageSrc(chosenCue)}" alt="The chosen visual cue, ${escapeHtml(chosenCue)}" />
${dots}
</figure>
<div class="dcx-cue-card">
@@ -973,6 +1025,46 @@ document.addEventListener('picker:screenchange', ({ detail }) => {
finishSequence();
});
/* Document mode: the run already happened, so the document renders from the
store instead of waiting on a submit that will never come.
Everything it reads has to be in hand before the first render, because
nothing re-renders it afterwards on its own: the version the tab compares
against and the version a fresh session starts at are both 1, so a render
that raced its own data would stay wrong until an edit moved the number.
That means the context and cue fetches, the restored form, and the icon
sheet, which the questionnaire otherwise fetches only when its screen is
reached and whose absence quietly drops a block from the document. */
getBoot().then(async (boot) => {
if (boot.mode !== 'doc') return;
docMode = true;
// Nothing here submits, and there is no half-finished run to save.
finished = true;
await Promise.all([cuesReady, contextReady, hydrationReady, loadIconPacks().catch(() => {})]);
if (boot.doc?.base && boot.doc?.token) startDocSession(boot.doc);
renderDocument();
revealDocument();
});
/* A run walked away from is a run that can be resumed: the whole form goes to
the server after every screen, so closing the tab costs the visitor nothing
but the trip. Debounced because arrow keys can walk several screens faster
than a request completes, and dropped silently on failure, since a draft the
server never took is only the resume that will not happen. */
let draftTimer;
document.addEventListener('picker:screenchange', () => {
if (finished) return;
clearTimeout(draftTimer);
draftTimer = setTimeout(() => {
if (finished) return;
fetch('/autosave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectAnswers()),
}).catch(() => {});
}, 500);
});
$('[data-doc-retry]')?.addEventListener('click', finishSequence);
/* ============================================================
@@ -1320,8 +1412,16 @@ async function pollDocState() {
renderTray();
if (state.version !== docVersion) {
docVersion = state.version;
await adoptAnswers();
/* Something moved on disk: a save of this tab's own, a request the
agent finished, or a value it settled while doing either. Re-read
both halves of the store and rebuild, including the article that is
open, since the templates alone are not what anyone is looking at. */
await adoptStoreState();
const openScroll = current?.expander?.querySelector('.dcx-main')?.scrollTop ?? 0;
refreshDocument();
const main = current?.expander?.querySelector('.dcx-main');
if (main) main.scrollTop = openScroll;
markEditables();
}
schedulePoll(2000);
} catch {
@@ -1367,12 +1467,102 @@ function ensureFace(family) {
document.head.appendChild(link);
}
/* The chat half moves too: an agent reconciling a batch can rewrite a purpose
line, and the document is where that has to show up. Assigned onto the same
variables the boot fetch fills, so every builder reads the new values. */
async function adoptContext() {
const response = await fetch(`${docSession.base}/doc/context?token=${encodeURIComponent(docSession.token)}`);
if (!response.ok) return;
const payload = await response.json();
if (payload.context && typeof payload.context === 'object') seedContext = payload.context;
if (Array.isArray(payload.modes)) seedModes = payload.modes;
}
const adoptStoreState = () => Promise.all([adoptAnswers(), adoptContext()]);
function refreshDocument() {
renderDocument();
if (current) renderCategory(current.id, current.expander, false);
markEditables();
}
/* ---------- Simple edits: palette colors ---------- */
/* ---------- Staged edits: the pending ledger and the save bar ----------
Edits land in the page immediately and on disk deliberately. That split is
what lets a person try three changes and keep two: until Apply, nothing has
been written, and the document is only showing what it would look like.
The ledger keys on the binding id and keeps the FIRST original it saw, so a
field edited three times still reports the value the store actually holds.
------------------------------------------------------------------------ */
const staged = new Map();
/* One-off cards in the tray, for outcomes that are not a queued request. */
const trayNotes = [];
const saveBar = $('[data-dcx-savebar]');
let applying = false;
let wasShowing = false;
function stage(bindingId, from, to) {
if (!bindingId) return;
const existing = staged.get(bindingId);
if (to === (existing ? existing.from : from)) staged.delete(bindingId);
else staged.set(bindingId, { from: existing ? existing.from : from, to });
renderSaveBar();
}
function renderSaveBar() {
if (!saveBar) return;
const count = staged.size;
saveBar.hidden = !count || !docLive();
saveBar.toggleAttribute('data-applying', applying);
if (saveBar.hidden) return;
const label = $('[data-dcx-apply-label]', saveBar);
const counter = $('[data-dcx-apply-count]', saveBar);
label.textContent = applying ? 'Applying' : `Apply ${count === 1 ? 'change' : 'changes'}`;
counter.textContent = String(count);
counter.hidden = applying;
$('[data-dcx-apply]', saveBar).disabled = applying;
$('[data-dcx-discard]', saveBar).disabled = applying;
$('[data-dcx-apply]', saveBar).setAttribute(
'aria-label',
`Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context`,
);
if (!wasShowing) {
saveBar.setAttribute('data-just-appeared', '');
setTimeout(() => saveBar.removeAttribute('data-just-appeared'), 700);
}
wasShowing = true;
}
/* Editing is offered only where it can be accepted, and re-armed after every
render because the article is rebuilt rather than patched. */
function markEditables() {
const live = docLive() && !applying;
for (const node of $$('[data-dcx-binding]')) {
node.contentEditable = live ? 'plaintext-only' : 'false';
const id = node.dataset.dcxBinding;
const pendingValue = staged.get(id)?.to;
// A re-render rebuilt this element from the store, so anything staged
// against it has to be written back on: the bar still counts it.
if (pendingValue !== undefined && node.textContent !== pendingValue) {
node.textContent = pendingValue;
}
node.toggleAttribute('data-dcx-dirty', pendingValue !== undefined);
}
renderSaveBar();
}
/* plaintext-only keeps pasted markup out; this is the second half of that,
because a browser without the mode still allows rich text. */
document.addEventListener('input', (event) => {
const node = event.target.closest?.('[data-dcx-binding]');
if (!node) return;
stage(node.dataset.dcxBinding, node.dataset.dcxOriginal ?? '', node.textContent.trim());
node.toggleAttribute('data-dcx-dirty', staged.has(node.dataset.dcxBinding));
});
/* ---------- Palette swatches stage like everything else ---------- */
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-edit-color]');
@@ -1384,22 +1574,59 @@ document.addEventListener('click', (event) => {
document.addEventListener('change', (event) => {
const input = event.target.closest?.('[data-color-input-for]');
if (!input) return;
applyColorEdit(input.dataset.colorInputFor, input.value.toUpperCase());
stageColorEdit(input.dataset.colorInputFor, input.value.toUpperCase());
});
async function applyColorEdit(role, hex) {
function stageColorEdit(role, hex) {
const field = form.elements[`palette-${role}`];
if (!field || field.value.toUpperCase() === hex) return;
const previous = field.value.toUpperCase();
field.value = hex;
refreshDocument();
if (!docLive()) return;
stage(`palette.${role}`, previous, hex);
}
/* ---------- Apply and discard ---------- */
$('[data-dcx-apply]')?.addEventListener('click', async () => {
if (!staged.size || applying || !docLive()) return;
const count = staged.size;
if (!window.confirm(`Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context?`)) return;
const changes = [...staged].map(([bindingId, { from, to }]) => ({ bindingId, from, to }));
applying = true;
markEditables();
try {
const result = await docPost('/doc/edit', { kind: 'color', role, value: hex });
const result = await docPost('/doc/save', { changes });
docVersion = result.version;
} catch {
setDocOnline(false);
renderTray();
staged.clear();
trayNote(`Applied ${count} ${count === 1 ? 'change' : 'changes'}`, 'done');
} catch (error) {
trayNote('Those changes could not be saved. They are still here.', 'error');
} finally {
applying = false;
markEditables();
}
});
$('[data-dcx-discard]')?.addEventListener('click', async () => {
if (!staged.size || applying) return;
const count = staged.size;
if (!window.confirm(`Discard ${count} ${count === 1 ? 'change' : 'changes'}?`)) return;
staged.clear();
// The store is the rollback: re-reading it puts every field back.
await adoptStoreState();
refreshDocument();
markEditables();
});
function trayNote(message, status) {
trayNotes.push({ id: `note-${trayNotes.length}`, status, message });
renderTray();
setTimeout(() => {
trayNotes.shift();
renderTray();
}, 6000);
}
/* ---------- Complex edits: the request modal ---------- */
@@ -1465,12 +1692,18 @@ const TRAY_LABELS = {
};
function renderTray() {
/* Notes are transient outcomes of a save; requests are work the agent owes. */
if (!tray) return;
const items = trayRequests.slice(-4);
const offline = docSession && !docOnline;
tray.hidden = !offline && items.length === 0;
tray.hidden = !offline && items.length === 0 && trayNotes.length === 0;
tray.innerHTML = [
offline ? '<div class="dcx-tray-item" data-status="offline"><span class="dcx-tray-dot"></span><div><p class="dcx-tray-prompt">Edit session offline</p><p class="dcx-tray-note">Changes stay in this tab; reconnecting&hellip;</p></div></div>' : '',
...trayNotes.map((entry) => `
<div class="dcx-tray-item" data-status="${escapeHtml(entry.status)}">
<span class="dcx-tray-dot"></span>
<div><p class="dcx-tray-prompt">${escapeHtml(entry.message)}</p></div>
</div>`),
...items.map((entry) => `
<div class="dcx-tray-item" data-status="${escapeHtml(entry.status)}">
<span class="dcx-tray-dot"></span>
+189
View File
@@ -0,0 +1,189 @@
/** Restoring a previous run into the questionnaire.
*
* The one rule this file is built around: setting `.checked` or `.value` from
* script fires no event, and every committer on this page runs off change
* events. A bare write therefore leaves the radio saying one thing and the
* hidden field the rest of the run reads saying another. So every answer is
* restored by clicking its control, or by calling the function the control
* would have called.
*
* The order matters too. Clicking a radio runs record(), which stamps
* data-chosen on every field it writes, so which answers a person actually
* visited is restored last, after every click has had its say.
*
* The questionnaire owns the state being restored, so palette-picker.js passes
* the handles this needs rather than this file reaching into it.
*/
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
/* Groups whose answer is one radio the whole run reads. Absent keys are left
alone: a run that was never asked about movement must come back without a
movement key, not with a default standing in for a decision. */
const RADIO_GROUPS = [
'color-strategy',
'motion-energy',
'layout-structure',
'boundary-style',
'corner-style',
'depth-style',
'type-scale',
'icon-pack',
];
const asText = (value) => (typeof value === 'string' ? value : '');
/* Clicking is the whole point: activation runs the change handlers that commit
the answer, which a bare `checked = true` would skip. */
function clickOption(name, value) {
if (!value) return false;
const input = document.querySelector(`input[name="${name}"][value="${CSS.escape(value)}"]`);
if (!input || input.disabled) return false;
input.click();
return true;
}
function surfaceFields() {
return [...document.querySelectorAll('input[type="hidden"][data-surface-field]')];
}
export function hydrateAnswers(prior, ctx) {
if (!prior) return;
restoreSurfaces(prior, ctx);
for (const group of RADIO_GROUPS) {
if (group in prior) clickOption(group, asText(prior[group]));
}
restoreSurfaceFields(prior);
restorePalette(prior, ctx);
restoreFonts(prior, ctx);
restoreIconMeta(prior);
restoreChosen(prior, ctx);
}
/* Which surfaces the run covers, first, because every per-surface field is
filled with its default the moment a tile is checked. */
function restoreSurfaces(prior, ctx) {
const raw = prior['surface-modes'];
const wanted = new Set(Array.isArray(raw) ? raw : (asText(raw) ? [raw] : []));
if (!wanted.size) return;
// The same guard the agent's own hint carries: a set naming no real tile
// would otherwise clear the run's only required answer.
if (!ctx.modeInputs.some((input) => wanted.has(input.value))) return;
for (const input of ctx.modeInputs) input.checked = wanted.has(input.value);
ctx.syncModes();
}
function restoreSurfaceFields(prior) {
for (const field of surfaceFields()) {
const key = field.dataset.surfaceField;
if (field.disabled || !(key in prior)) continue;
const value = asText(prior[key]);
if (value) field.value = value;
}
}
/* The deck has no programmatic selection path: the committing click reads the
card the scroller is parked on. So the card's own state is written first,
which is also what stops a later reorder or reset from reverting the fields
to the colors the cue was dealt with. */
function restorePalette(prior, ctx) {
const source = asText(prior['palette-source']);
const colors = {};
for (const role of ROLES) {
const hex = asText(prior[`palette-${role}`]);
if (hex) colors[role] = hex;
}
if (!source && !Object.keys(colors).length) return;
/* The fields come first, and unconditionally: the colors are the answer, the
source is what the document names them by, and neither depends on the deck
still being able to show the card they came from. A document reopened after
the generation workspace was cleaned has no deck at all, and it still has a
palette. */
const sourceField = document.querySelector('[name="palette-source"]');
if (sourceField && source) sourceField.value = source;
for (const role of ROLES) {
const field = document.querySelector(`[name="palette-${role}"]`);
if (field && colors[role]) field.value = colors[role];
}
// Nothing was dealt, so there is no card to park on and nothing to repaint.
if (!ctx.cards.length) return;
const index = ctx.cards.findIndex((item) => item.id === source);
const target = index === -1 ? 0 : index;
/* Writing the card's own state is also what stops a later reorder or reset
from reverting the fields to the colors the cue was dealt with. */
const state = ctx.states.get(ctx.cards[target]?.id);
if (state?.colors) Object.assign(state.colors, colors);
ctx.setCurrent(target);
ctx.render();
ctx.syncDeckScroll();
}
/* A pair still on the rail is chosen by clicking it, which runs syncFontPair.
A pair that is not, an upload or a set of faces this run was not dealt, is
rebuilt as the custom pair and registered before its card is added: the rail
resolves a click through the manifest, so a card the manifest does not know
cannot be chosen a second time. */
function restoreFonts(prior, ctx) {
const wanted = asText(prior['font-pair']);
const heading = asText(prior['font-heading']);
const body = asText(prior['font-body']);
const manifest = ctx.fontManifest();
if (wanted && wanted !== 'custom' && manifest.pairs.some(({ id }) => id === wanted)) {
if (clickOption('font-pair', wanted)) return;
}
if (!heading || !body) return;
const pair = {
id: 'custom',
name: 'Custom',
heading: { family: heading, weight: 600, source: asText(prior['font-heading-source']) },
body: { family: body, weight: 400, source: asText(prior['font-body-source']) },
why: 'From your last run',
};
manifest.pairs = [pair, ...manifest.pairs.filter(({ id }) => id !== 'custom')];
for (const node of ctx.pairNodes()) {
if (node.querySelector('input')?.value === 'custom') ctx.removePairCard(node);
}
ctx.addPairCard(pair, { checked: true, first: true });
ctx.loadCustomFace(pair);
ctx.syncFontPair(pair);
ctx.applyHoist({ force: true });
}
/* The pack's own radio carries the license and URL when it is still on offer;
these are the record of one that is not. */
function restoreIconMeta(prior) {
for (const key of ['icon-pack-name', 'icon-pack-license', 'icon-pack-url']) {
const field = document.querySelector(`[name="${key}"]`);
const value = asText(prior[key]);
if (field && !field.value && value) field.value = value;
}
}
/* Last, because every click above stamped its own. A default nobody opened and
a default someone confirmed hold the same value, so only this list tells them
apart, and the document says which is which. */
function restoreChosen(prior, ctx) {
let chosen = null;
try {
const parsed = JSON.parse(asText(prior._chosen) || 'null');
if (Array.isArray(parsed)) chosen = new Set(parsed);
} catch {
/* Written by a run that did not keep the distinction. */
}
for (const field of surfaceFields()) {
const key = field.dataset.surfaceField;
if (field.disabled) continue;
// A run that kept no list was confirmed wholesale at submit, so every
// answer it carries counts as visited.
const visited = chosen ? chosen.has(key) : key in prior;
if (visited) field.dataset.chosen = 'yes';
else delete field.dataset.chosen;
}
ctx.syncChosenField();
}
+80 -7
View File
@@ -1,4 +1,6 @@
import { contrastInk, contrastInkHex, formatOklch, hexToOklch, neutralContrastIssue, oklchToHex, readableOn, seedToRoles } from './color.js';
import { getBoot, markHydrated } from './boot.js';
import { hydrateAnswers } from './hydrate.js';
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const screen = document.querySelector('[data-screen="02"]');
@@ -901,7 +903,10 @@ function commitIconPack(input) {
paintIconPack(input.value);
}
function loadIconPacks() {
/* Exported because a document opened on its own never visits the icon screen,
and the sheet it clones is drawn by this fetch. Memoized, so the two callers
cost one request. */
export function loadIconPacks() {
iconRequest ??= fetch('/icon-packs.json')
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => {
@@ -4806,6 +4811,20 @@ $('[data-select-palette]').addEventListener('click', panel.onclick);
$('[data-deck-prev]').onclick = () => browse(current - 1);
$('[data-deck-next]').onclick = () => browse(current + 1);
/* The deck's position IS the scroll offset: the listener below reads the card
back out of it on every scroll. A card selected while the screen was hidden
therefore has to leave the scroller parked on its own snap point, or the
visitor's first scroll would compute its way back to the first card. Hidden
elements measure zero, so this reports whether it could do its job and runs
again when the screen is first shown. */
function syncDeckScroll() {
const height = points.firstElementChild?.offsetHeight || 0;
if (!height) return false;
const wanted = current * height;
if (Math.abs(scroller.scrollTop - wanted) > 1) scroller.scrollTop = wanted;
return true;
}
scroller.addEventListener('scroll', () => {
const height = points.firstElementChild?.offsetHeight || 1;
const next = Math.min(cards.length - 1, Math.round(scroller.scrollTop / height));
@@ -4820,6 +4839,8 @@ scroller.addEventListener('scroll', () => {
}, { passive: true });
document.addEventListener('picker:screenchange', (event) => {
activate(event.detail.screen === '02');
// A restored card could not be scrolled to while the screen had no size.
if (event.detail.screen === '02') syncDeckScroll();
// 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();
@@ -4983,6 +5004,21 @@ function syncModePreview() {
const chosenSurfaces = () => modeInputs.filter((input) => input.checked);
const surfaceInput = (value) => modeInputs.find((input) => input.value === value);
/* Which per-surface answers a person actually opened, carried in the form so a
later run can restore the distinction. Every chosen surface leaves an answer
whether or not it was ever looked at, so the value alone cannot say whether
a default was confirmed or merely inherited, and the document says which.
Derived from the fields on every write rather than tracked alongside them,
which is the only version that cannot drift. */
const chosenField = document.querySelector('input[name="_chosen"]');
function syncChosenField() {
if (!chosenField) return;
const keys = [...document.querySelectorAll('input[type="hidden"][data-surface-field][data-chosen="yes"]')]
.filter((field) => !field.disabled)
.map((field) => field.dataset.surfaceField);
chosenField.value = JSON.stringify(keys);
}
function buildSurfaceQuestion(tabs) {
const name = tabs.dataset.surfaceTabs;
// The stage the strip sits on is also the box a per-surface drawing has to
@@ -5188,6 +5224,7 @@ function buildSurfaceQuestion(tabs) {
field.dataset.chosen = 'yes';
}
markTabs();
syncChosenField();
}
/* Arrow keys walk the group, which is the one thing a row of buttons owes a
@@ -5230,6 +5267,7 @@ function alignSurfaces(leader) {
}
const syncSurfaces = () => {
for (const question of surfaceQuestions) question.sync();
syncChosenField();
};
const paintStage = () => {
for (const question of surfaceQuestions) question.paint();
@@ -5558,12 +5596,19 @@ syncModePreview();
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]);
// The agent's reading of PRODUCT.md arrives as cues.modes and pre-checks
// the surface tiles. Applied only when it names at least one real tile, so
// a bad hint cannot uncheck everything.
if (Array.isArray(cueData.modes)) {
const wanted = new Set(cueData.modes);
const [cueData, seedData, storedContext] = await Promise.all([
get('/cues.json'),
get('/palettes.json'),
// Absent on a run that predates the store, so this one may not reject.
fetch('/context.json').then((response) => (response.ok ? response.json() : null)).catch(() => null),
]);
// The agent's reading of PRODUCT.md arrives as modes and pre-checks the
// surface tiles: from the design-context store when it carries them, from
// the cue manifest otherwise. Applied only when it names at least one real
// tile, so a bad hint cannot uncheck everything.
const hintedModes = Array.isArray(storedContext?.modes) ? storedContext.modes : cueData.modes;
if (Array.isArray(hintedModes)) {
const wanted = new Set(hintedModes);
if (modeInputs.some((input) => wanted.has(input.value))) {
for (const input of modeInputs) input.checked = wanted.has(input.value);
syncModesNext();
@@ -5597,3 +5642,31 @@ try {
// The built-in pairs keep older and incomplete runs moving.
}
renderFontPairs(manifest, usingFallback);
/* Everything the run can be restored into now exists: the deck is built, the
pairs are dealt, and every per-surface field holds its default. A previous
run, or one walked away from, is written over that. */
const boot = await getBoot();
try {
hydrateAnswers(boot.prior, {
modeInputs,
syncModes: () => { syncModesNext(); syncModePreview(); },
states,
cards,
setCurrent: (index) => { current = index; },
render,
syncDeckScroll,
fontManifest: () => fontManifest,
pairNodes: () => [...pairOrder],
addPairCard,
removePairCard,
loadCustomFace,
syncFontPair,
applyHoist,
syncChosenField,
});
} catch {
/* A restore that cannot complete must not cost the visitor the run; the
questionnaire's own defaults are still standing behind it. */
}
markHydrated(boot.prior ? boot.priorSource : null);