Files
pbakaus_impeccable/picker/scripts/hydrate.js
T
2026-09-01 10:05:59 +05:00

190 lines
7.3 KiB
JavaScript

/** 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();
}