This commit is contained in:
Abdul Wahab
2026-09-01 10:09:03 +05:00
parent f775f9e585
commit 82b03772e4
11 changed files with 469 additions and 47 deletions
+16
View File
@@ -111,6 +111,21 @@ import { dcxAsset } from './assets.js';
section.querySelectorAll(":scope .dcx-fan-note").forEach((note) => note.remove());
};
/* A "default" chip marks a surface that kept its preset instead of being
configured, and the sentence that said so was a provenance note, which the
pass above removes. Restate it in a class this pass keeps, so a value the
run never chose is never read as one it did. The "leading" chip shares the
class and means something else, hence the text test. */
const notePresetDefaults = (body) => {
const chips = [...body.querySelectorAll(".dcx-default-mark")];
if (!chips.some((chip) => chip.textContent.trim() === "default")) return;
if (body.querySelector(":scope > .dcx-default-note")) return;
const note = document.createElement("p");
note.className = "dcx-default-note";
note.textContent = "Entries marked default kept their surface's preset; this run never set them. Run /impeccable design-context edit to answer them.";
body.appendChild(note);
};
const compactColorArticle = (article) => {
if (article.dataset.dcxColorCompacted === "true") return;
@@ -268,6 +283,7 @@ import { dcxAsset } from './assets.js';
wrapPrincipleContent(section, category);
placeProductPlatform(section, category);
removeProvenanceNotes(section);
notePresetDefaults(body);
};
const enhanceArticle = (article, category) => {
+53 -23
View File
@@ -315,7 +315,37 @@ import { dcxAsset } from './assets.js';
if (lede) lede.textContent = "Identity, voice, references, taste boundaries, and available assets.";
};
const componentCardMedia = () => dcxAsset("/assets/components/hanazono-ikebana-card.jpg");
const FALLBACK_CARD_IMAGE = "/assets/components/hanazono-ikebana-card.jpg";
const escapeSpecimen = (value) => String(value).replace(/[&<>"]/g, (character) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[character]
));
/* The run's own product name, published by the data layer (design-context.js
renderDocument). The specimen fields show a real value rather than a label,
so a run with no name on record keeps a neutral stand-in. */
const productName = () => {
const name = typeof window.dcxProductName === "string" ? window.dcxProductName.trim() : "";
return escapeSpecimen(name || "Untitled project");
};
const productDomain = () => {
const name = typeof window.dcxProductName === "string" ? window.dcxProductName : "";
/* The strip leaves only [a-z0-9], so this needs no escaping of its own. */
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "");
return slug || "example";
};
/* Cue-first card media: the data layer publishes the run's chosen cue on
window.dcxCueImageSrc, and the vendored photo is the fallback, swapped in
by the delegated error listener when the cue cannot load. A run whose
palette named no cue gets the vendored photo directly. */
const componentCardImage = (fallbackAlt, cueAlt) => {
const cue = typeof window.dcxCueImageSrc === "string" ? window.dcxCueImageSrc : "";
const fallbackSrc = dcxAsset(FALLBACK_CARD_IMAGE);
if (!cue) return `src="${fallbackSrc}" alt="${fallbackAlt}"`;
return `src="${cue}" alt="${cueAlt}" data-dcx-swap-src="${fallbackSrc}" data-dcx-swap-alt="${fallbackAlt}"`;
};
const composeComponentsArticle = (article) => {
article.querySelectorAll(":scope > .dcx-block[data-label]").forEach((block) => block.remove());
@@ -414,7 +444,7 @@ import { dcxAsset } from './assets.js';
</div>
<div class="dcx-demo-field">
<span class="dcx-demo-field-label">Project type</span>
<span class="dcx-demo-input">Ceremony florals</span>
<span class="dcx-demo-input">Quarterly report</span>
<small>Filled</small>
</div>
<div class="dcx-demo-field is-invalid">
@@ -432,15 +462,15 @@ import { dcxAsset } from './assets.js';
<div class="dcx-component-demo-grid dcx-component-demo-grid--three dcx-component-demo-grid--baseline">
<div class="dcx-demo-field dcx-demo-field--small">
<span class="dcx-demo-field-label">Small</span>
<span class="dcx-demo-input">Hana</span>
<span class="dcx-demo-input">Nova</span>
</div>
<div class="dcx-demo-field">
<span class="dcx-demo-field-label">Medium</span>
<span class="dcx-demo-input">Hanazono Atelier</span>
<span class="dcx-demo-input">${productName()}</span>
</div>
<div class="dcx-demo-field dcx-demo-field--large">
<span class="dcx-demo-field-label">Large</span>
<span class="dcx-demo-input">Private celebration</span>
<span class="dcx-demo-input">Quarterly planning</span>
</div>
</div>
</div>
@@ -454,7 +484,7 @@ import { dcxAsset } from './assets.js';
<span class="dcx-demo-field-label">Search</span>
<span class="dcx-demo-input dcx-demo-input--affix">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6"></circle><path d="m16 16 4 4"></path></svg>
Spring arrangements
Recent documents
</span>
</div>
<div class="dcx-demo-field">
@@ -463,7 +493,7 @@ import { dcxAsset } from './assets.js';
</div>
<div class="dcx-demo-field">
<span class="dcx-demo-field-label">Website</span>
<span class="dcx-demo-input dcx-demo-input--affix dcx-demo-input--suffix">hanazono<span>.jp</span></span>
<span class="dcx-demo-input dcx-demo-input--affix dcx-demo-input--suffix">${productDomain()}<span>.com</span></span>
</div>
</div>
</div>
@@ -474,8 +504,8 @@ import { dcxAsset } from './assets.js';
<div class="dcx-component-canvas dcx-component-canvas--fields">
<div class="dcx-component-demo-grid dcx-component-demo-grid--single">
<div class="dcx-demo-field">
<span class="dcx-demo-field-label">Tell us about the setting</span>
<span class="dcx-demo-input dcx-demo-input--textarea">A quiet evening ceremony with seasonal branches, soft candlelight, and room for the architecture to breathe.</span>
<span class="dcx-demo-field-label">Tell us about the project</span>
<span class="dcx-demo-input dcx-demo-input--textarea">A short brief describing the setting, the audience, and the constraints this piece of work has to hold.</span>
<small>240 characters remaining</small>
</div>
</div>
@@ -501,15 +531,15 @@ import { dcxAsset } from './assets.js';
<article class="dcx-showcase-card">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Testimonial</span>
<blockquote>&ldquo;The room felt entirely transformed, but still like us.&rdquo;</blockquote>
<span class="dcx-showcase-card-meta">Mika &amp; Ren</span>
<blockquote>&ldquo;It fits the way we already work, and it still feels like ours.&rdquo;</blockquote>
<span class="dcx-showcase-card-meta">A long-time customer</span>
</div>
</article>
<article class="dcx-showcase-card">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Details</span>
<h5>Autumn ceremony</h5>
<dl><div><dt>Season</dt><dd>Late autumn</dd></div><div><dt>Setting</dt><dd>Kyoto townhouse</dd></div></dl>
<h5>Project details</h5>
<dl><div><dt>Timeline</dt><dd>Six weeks</dd></div><div><dt>Setting</dt><dd>Distributed team</dd></div></dl>
</div>
</article>
</div>
@@ -521,18 +551,18 @@ import { dcxAsset } from './assets.js';
<div class="dcx-component-canvas dcx-component-canvas--cards">
<div class="dcx-component-demo-grid dcx-component-demo-grid--two">
<article class="dcx-showcase-card dcx-showcase-card--media">
<img src="${componentCardMedia()}" width="800" height="1200" alt="Purple flowers arranged in a black ceramic vase" loading="lazy" decoding="async">
<img ${componentCardImage("Purple flowers arranged in a black ceramic vase", "The visual cue this palette was picked from")} width="800" height="1200" loading="lazy" decoding="async">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Garden study</span>
<span class="dcx-component-kind">Case study</span>
<h5>Line, pause, and negative space</h5>
<p>A restrained floral study built around one deliberate gesture.</p>
<p>A calm composition built around one deliberate gesture.</p>
</div>
</article>
<article class="dcx-showcase-card dcx-showcase-card--media dcx-showcase-card--media-close">
<img src="${componentCardMedia()}" width="800" height="1200" alt="Close crop of purple ikebana flowers" loading="lazy" decoding="async">
<img ${componentCardImage("Close crop of purple ikebana flowers", "The chosen visual cue, cropped close")} width="800" height="1200" loading="lazy" decoding="async">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Material note</span>
<h5>Dark ceramic, soft bloom</h5>
<h5>Detail, closely cropped</h5>
<p>The same image can lead with subject or material detail.</p>
</div>
</article>
@@ -544,11 +574,11 @@ import { dcxAsset } from './assets.js';
<figcaption><h4>Horizontal</h4></figcaption>
<div class="dcx-component-canvas dcx-component-canvas--cards dcx-component-canvas--single-card">
<article class="dcx-showcase-card dcx-showcase-card--horizontal">
<img src="${componentCardMedia()}" width="800" height="1200" alt="Purple ikebana in a dark ceramic vase" loading="lazy" decoding="async">
<img ${componentCardImage("Purple ikebana in a dark ceramic vase", "The chosen visual cue as a wide feature image")} width="800" height="1200" loading="lazy" decoding="async">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Featured story</span>
<h5>A study in asymmetry</h5>
<p>How a single stem, a dark vessel, and generous space hold the composition together.</p>
<p>How a single focal point, a quiet ground, and generous space hold the composition together.</p>
<span class="dcx-showcase-card-link">Explore the study <span aria-hidden="true">&#8594;</span></span>
</div>
</article>
@@ -563,15 +593,15 @@ import { dcxAsset } from './assets.js';
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Consultation</span>
<h5>Plan the first conversation</h5>
<p>Share the date and setting so the studio can prepare.</p>
<p>Share the date and setting so the team can prepare.</p>
<span class="dcx-demo-button dcx-demo-button--primary">Book now</span>
</div>
</article>
<article class="dcx-showcase-card dcx-showcase-card--action">
<div class="dcx-showcase-card-body">
<span class="dcx-component-kind">Commission guide</span>
<span class="dcx-component-kind">Process guide</span>
<h5>Understand the process</h5>
<p>Read the stages, timing, and what the studio needs from you.</p>
<p>Read the stages, timing, and what the team needs from you.</p>
<span class="dcx-demo-button dcx-demo-button--outline">View the guide</span>
</div>
</article>
+64 -7
View File
@@ -68,12 +68,16 @@ const contextReady = Promise.all([getJson('/context.json'), cuesReady])
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`);
/* The chosen cue, wherever this page can still reach it. A live doc session
outlives every server the page booted from, so it is the first choice, and
the store's copy is made during submit before that session is forked. A
document opened later without a session still has the picker server serving
the store copy; a submit run before either exists reads the workspace image
the questionnaire already displayed, which is in the browser's cache. */
const cueImageSrc = (slug) => {
if (docSession) return `${docSession.base}/cue.png?token=${encodeURIComponent(docSession.token)}`;
return docMode ? '/cue.png' : `/cues/${encodeURIComponent(slug)}.png`;
};
const seedHexFor = (source, role) => {
const slot = seedPalettes?.[source]?.[role];
@@ -88,7 +92,10 @@ const seedHexFor = (source, role) => {
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const SURFACE_ORDER = ['persuade', 'operate', 'read', 'experience'];
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style'];
/* The five questions asked per surface, matching portability.mjs. The bare key
is the leading surface's answer, which is what DESIGN.md records as the rule
for the whole product. */
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
const fieldValue = (name) => {
const field = form.elements[name];
@@ -290,6 +297,19 @@ document.addEventListener('load', (event) => {
document.addEventListener('error', (event) => {
const image = event.target;
if (!(image instanceof HTMLImageElement)) return;
/* A card that asked for the cue swaps to its vendored photo rather than
hiding: the entry is real either way, only the imagery moved. One swap
only, so a fallback that also fails lands at the hide rule below. */
if (image.dataset.dcxSwapSrc) {
const fallback = image.dataset.dcxSwapSrc;
delete image.dataset.dcxSwapSrc;
if (image.dataset.dcxSwapAlt) {
image.alt = image.dataset.dcxSwapAlt;
delete image.dataset.dcxSwapAlt;
}
image.src = fallback;
return;
}
const casualty = image.closest('[data-dcx-hide-on-error]');
if (casualty) casualty.hidden = true;
}, true);
@@ -323,6 +343,7 @@ function paintCommitted(node) {
set('n-ink', contrastInk(colors.neutral));
set('p-ink', contrastInk(colors.primary));
set('t-ink', contrastInk(colors.tertiary));
set('s-on-n', readableOn(colors.secondary, colors.neutral));
set('p-on-n', readableOn(colors.primary, colors.neutral));
set('t-on-n', readableOn(colors.tertiary, colors.neutral));
set('t-on-p', readableOn(colors.tertiary, colors.primary));
@@ -337,6 +358,7 @@ function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {})
if (!source) return '';
const clone = source.cloneNode(true);
clone.hidden = false;
const sourceSurface = source.getAttribute('data-surface') || '';
clone.removeAttribute('data-surface');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
for (const node of $$('button, input', clone)) {
@@ -356,6 +378,13 @@ function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {})
if (value) wrap.setAttribute(`data-dcx-${key}`, value);
}
}
/* The surface the drawing came from, restated on the frame: the clone loses
its own data-surface above, and the stylesheet's quiet-base rule has to
tell a persuade board from a derived one. Marks that already named a
surface win, since those carry the committed answer. */
if (sourceSurface && !wrap.hasAttribute('data-dcx-surface')) {
wrap.setAttribute('data-dcx-surface', sourceSurface);
}
paintCommitted(wrap);
wrap.appendChild(clone);
return wrap.outerHTML;
@@ -964,6 +993,24 @@ function renderDocument() {
description: MODE_DEFS[surface.mode] || surface.goal || '',
}));
const name = snapshot.context?.product?.name || '';
/* Bridges for the document engine, whose modules read globals rather than
importing this file. The cue URL is empty when the palette came from a
seed deck or a custom pick, which keeps the vendored card photo; the
product name fills the specimen fields that would otherwise show another
studio's. */
const dealtCues = Array.isArray(snapshot.cueSlugs) ? snapshot.cueSlugs : [];
const chosenCue = snapshot.paletteSource && (docMode || dealtCues.includes(snapshot.paletteSource))
? snapshot.paletteSource
: '';
window.dcxCueImageSrc = chosenCue ? cueImageSrc(chosenCue) : '';
window.dcxProductName = name;
/* The components inventory reads the committed palette through --pkc-* on
<body>: its article is rebuilt on every remount, so the paint lives on the
one node that survives. A run that never committed a full palette leaves
the gate off and keeps the vendored demo colors. */
const paletteLive = ROLES.every((role) => fieldValue(`palette-${role}`));
document.body.classList.toggle('dcx-palette-live', paletteLive);
if (paletteLive) paintCommitted(document.body);
for (const [id, build] of Object.entries(BUILDERS)) {
const template = document.getElementById(`dcx-detail-${id}`);
template.innerHTML = `<article class="dcx-article">${build(snapshot, name)}</article>`;
@@ -983,6 +1030,16 @@ function collectAnswers() {
if (!(name in answers)) answers[name] = value;
else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value];
}
/* The visible radio follows whichever surface tab was shown last, so the bare
key can leave carrying a trailing surface's answer. The leading surface owns
it: restate it from that surface's own field before this goes to disk. */
const chosen = [].concat(answers['surface-modes'] || []);
const leading = SURFACE_ORDER.filter((mode) => chosen.includes(mode));
for (const key of PER_SURFACE) {
if (!(key in answers)) continue;
const owner = leading.find((mode) => answers[`${key}-${mode}`]);
if (owner) answers[key] = answers[`${key}-${owner}`];
}
return answers;
}
+104
View File
@@ -499,3 +499,107 @@
font-size: 0.86rem;
line-height: 1.55;
}
/* ============================================================
Specimens in the committed palette: when the run committed one,
the inventory renders in it. design-context.js sets
.dcx-palette-live on <body> and paints --pkc-* there (the body
survives document remounts; the article does not). Without the
gate the vendored kinpaku colors above stand. Danger stays
--ks-vermilion: semantic, not brand. Geometry untouched.
============================================================ */
.dcx-palette-live .dcx-detail-article--components .dcx-component-canvas {
background: var(--pkc-neutral);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--primary {
border-color: var(--pkc-primary);
background: var(--pkc-primary);
color: var(--pkc-p-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--secondary {
border-color: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
background: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
color: var(--pkc-n-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--tertiary {
border-color: color-mix(in oklab, var(--pkc-tertiary) 55%, var(--pkc-neutral));
background: color-mix(in oklab, var(--pkc-tertiary) 13%, transparent);
color: var(--pkc-t-on-n);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--outline {
border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
color: var(--pkc-n-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--ghost {
color: color-mix(in oklab, var(--pkc-n-ink) 92%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-icon-button--secondary {
background: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
color: var(--pkc-n-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-icon-button--outline {
border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
color: var(--pkc-s-on-n);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-spinner {
border-color: color-mix(in oklab, var(--pkc-p-ink) 35%, transparent);
border-top-color: var(--pkc-p-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-field-label {
color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-input {
border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
background: color-mix(in oklab, var(--pkc-n-ink) 7%, var(--pkc-neutral));
color: var(--pkc-n-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-input.is-placeholder {
color: color-mix(in oklab, var(--pkc-n-ink) 55%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-field:not(.is-invalid) small {
color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-demo-input--affix > span {
color: var(--pkc-s-on-n);
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card {
border-color: color-mix(in oklab, var(--pkc-n-ink) 18%, transparent);
background: color-mix(in oklab, var(--pkc-n-ink) 5%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card h5,
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card blockquote {
color: var(--pkc-n-ink);
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card p,
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card dd {
color: color-mix(in oklab, var(--pkc-n-ink) 92%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card-meta {
color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card dt,
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card .dcx-component-kind {
color: color-mix(in oklab, var(--pkc-n-ink) 64%, var(--pkc-neutral));
}
.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card-link {
color: var(--pkc-s-on-n);
}
+2 -1
View File
@@ -623,7 +623,8 @@
overflow-wrap: anywhere;
}
.dcx-detail-article .dcx-fan-note {
.dcx-detail-article .dcx-fan-note,
.dcx-detail-article .dcx-default-note {
max-width: 72ch;
margin-top: 12px;
color: var(--ks-text-faint);
+8 -5
View File
@@ -968,7 +968,8 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l
opacity: 0.82;
}
.dcx-fan-note {
.dcx-fan-note,
.dcx-default-note {
margin: 10px 0 0;
font-size: 0.8rem;
color: var(--ks-text-faint);
@@ -2249,10 +2250,12 @@ body.dcx-live .dcx-request { display: inline-flex; }
--derived-panel-edge: color-mix(in oklab, var(--pv-strong) 20%, var(--pv-neutral));
}
/* Restrained lock for the non-persuade clones, mirrored from the strategy
remap so the derived boards hold the gallery's base regardless of the
run's strategy answer. */
.dcx-proof--board > .picker-preview:not([data-surface="persuade"]) {
/* Quiet base for derived-surface clones that carry no strategy answer of their
own (the Product surface cards). A frame that restates a strategy renders
that strategy instead, and a persuade frame keeps the full palette. The
clone's own data-surface is stripped by proofHtml, so this keys on the
frame's data-dcx-surface restatement. Body mirrored from picker.css:3404. */
.dcx-proof--board:not([data-dcx-strategy]):not([data-dcx-surface="persuade"]) > .picker-preview {
--pv-secondary: color-mix(in oklab, var(--pkc-neutral) 78%, var(--pkc-n-ink));
--pv-tertiary: color-mix(in oklab, var(--pkc-neutral) 86%, var(--pkc-n-ink));
--pv-t-ink: var(--pkc-n-ink);
+9 -8
View File
@@ -137,10 +137,11 @@
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview .pg-cap::before { box-shadow: var(--derived-shadow-card) !important; }
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview :is(.po-chart i, .po-side, .po-panel) { box-shadow: var(--derived-shadow-surface) !important; }
/* Depth persuade rides the drenched base, the gallery's baseCellId lock.
The role swap has to land before the persuade-specific secondary locks,
or the ground stays light and every derived depth cell misses by a mile. */
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] {
/* Depth persuade rode the gallery's drenched base cell unconditionally, which
painted the board in the primary for every run. The swap now applies only
when the run actually chose the drenched strategy; otherwise the committed
base from the shared 08/09/10 map (boundaries.css) carries the board. */
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] {
--pv-neutral: var(--pkc-primary);
--pv-n-ink: var(--pkc-p-ink);
--pv-primary: var(--pkc-p-ink);
@@ -155,18 +156,18 @@
--pv-secondary-wash: color-mix(in oklab, var(--pkc-p-ink) 16%, var(--pkc-primary));
}
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image {
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image {
background: linear-gradient(135deg, color-mix(in oklab, var(--pkc-p-ink) 16%, var(--pkc-primary)), color-mix(in oklab, var(--pkc-secondary) 34%, var(--pkc-primary))) !important;
border-color: color-mix(in oklab, var(--pkc-secondary) 52%, var(--pkc-primary)) !important;
}
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image::after { background: var(--pkc-tertiary) !important; }
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image::after { background: var(--pkc-tertiary) !important; }
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-actions i:last-child {
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-actions i:last-child {
border-color: color-mix(in oklab, var(--pkc-secondary) 68%, var(--pkc-p-ink)) !important;
background: color-mix(in oklab, var(--pkc-secondary) 18%, var(--pkc-primary)) !important;
}
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-nav-bars i:nth-child(2) {
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-nav-bars i:nth-child(2) {
background: color-mix(in oklab, var(--pkc-secondary) 72%, var(--pkc-p-ink)) !important;
}
+3 -3
View File
@@ -451,14 +451,14 @@ Mark the file as a seed with this comment as the first line of the markdown body
This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet.
**Questionnaire seed** (`.impeccable/design-context/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
**Questionnaire seed** (`.impeccable/design-context/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. **`_chosen` names the fields they actually set**: it holds a JSON-encoded array of per-surface keys, and a `<key>-<mode>` field missing from that array is a **preset** the picker minted when the surface was switched on, not an answer. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Derive the two text inks and record them under `colors` too: one near-black and one near-white, the pair the picker's previews already set their text in over these exact surfaces, each holding 4.5:1 against the grounds it will carry copy on, so a builder needing body-text contrast finds ink in the system instead of inventing a fifth color. Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
- **Overview**: Creative North Star and philosophy phrased from the questionnaire's color-strategy and motion answers plus the chat references; reference the user's anti-reference directly. Name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
- `motion-energy-<mode>` keys present, all agreeing: one philosophy sentence for the product, as before.
- Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
- **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the register; this path's chat interview never asked about motion, so there is nothing to borrow. The next Scan-mode run reads the real transitions out of the code.
- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug and name its kept image at `.impeccable/design-context/cue.png`, so a later build opens the picture the palette came from instead of imagining it; note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. Then open the kept image and describe it into the same section, three or four sentences under a **Cue, in words:** lead: the physical material each palette role lives on in the picture (cloth, glass, enamel, paper), the light and its temperature, the surface finish and grain, and the one material move that makes the image itself. Name each color as it appears on its material; `#C92823` as soft matte wrapping cloth instructs an image model where the bare hex only tints. Generation prompts on this world restate this passage (new-work.md and visualize.md say where), so a seed that records only the cue's file path leaves the material world to the model's imagination. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-<mode>` keys), state each surface's strategy and which surface leads (the bare key's owner).
- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug and name its kept image at `.impeccable/design-context/cue.png`, so a later build opens the picture the palette came from instead of imagining it; note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. Then open the kept image and describe it into the same section, three or four sentences under a **Cue, in words:** lead: the physical material each palette role lives on in the picture (cloth, glass, enamel, paper), the light and its temperature, the surface finish and grain, and the one material move that makes the image itself. Name each color as it appears on its material; `#C92823` as soft matte wrapping cloth instructs an image model where the bare hex only tints. Generation prompts on this world restate this passage (new-work.md and visualize.md say where), so a seed that records only the cue's file path leaves the material world to the model's imagination. The **chosen** strategy becomes the Named Rule. When surfaces differ (`color-strategy-<mode>` keys), state each surface's strategy and which surface leads (the bare key's owner).
- **Typography**: the real pair by name, the pairing's character, and the type scale as a rule: `type-scale` names it, `type-scale-ratio` is the ratio (e.g. *"Major third: each heading step is 1.25x the last"*). Base size and exact steps stay `[resolved at implementation]`. A `font-heading-source` / `font-body-source` value means a user-provided font file; record where it lives.
- **Layout**: `boundary-style` (how sections separate) per surface when the `-<mode>` keys differ, plus `layout-structure` (how pages are composed), which the questionnaire asks of a landing page and a portfolio only. No invented grids beyond what the answers state.
- `layout-structure` present: one bare key and no `-<mode>` keys, so state it as a rule for the whole product rather than per surface.
@@ -468,7 +468,7 @@ This seed writes a minimal frontmatter with `name` and `description` only; no co
- **Components**: still omit; nothing exists yet.
- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets. When the interview staged brand files (`context.assets` object entries in `.impeccable/design-context/context.json`), add one Do per file naming its path under `.impeccable/design-context/assets/`, its kind, and its note; a staged logo is the product's real mark and the build uses the file itself.
Per-surface answers come back for every chosen surface, defaults included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface). Where all surfaces agree, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
Per-surface answers come back for every chosen surface, presets included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface, so a pick one surface allows can be unavailable on another and that surface falls to its preset). **Write a preset as provisional**, on the surface's own line: name the value, say it is that surface's default because the surface was never configured, and keep it out of the Named Rules and out of every product-wide sentence. Naming an untouched preset as a rule invents a law the user never chose. Where all surfaces agree **and `_chosen` shows the agreement was picked**, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
+72
View File
@@ -41,6 +41,7 @@
* with IMPECCABLE_DOC_TOKEN in the environment.
*/
import { execFileSync } from 'node:child_process';
import http from 'node:http';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
@@ -59,6 +60,21 @@ const brandAssetsDir = store.assetsDir;
the submit-flow picker server has exited. Read-only, image types only. */
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
/* The Hooks page reads and writes hook config through hook-admin.mjs, the one
writer whose shapes stay validated; this server only ferries JSON either
way. Sync is fine: the admin runs in milliseconds, holds no sockets, and a
moment of backpressure on the doc port costs nothing. */
const hookAdminScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-admin.mjs');
function runHookAdmin(args, input) {
const stdout = execFileSync(process.execPath, [hookAdminScript, ...args], {
cwd: process.cwd(),
input: input ?? '',
encoding: 'utf-8',
timeout: 15_000,
});
return JSON.parse(stdout);
}
const MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const BRAND_ASSET_MIME = new Map([
@@ -291,6 +307,29 @@ async function handleRequest(request, response) {
return;
}
/* The chosen cue, copied into the store at submit (picker-server.mjs
copyChosenCue). The components cards and the Color article render it after
the picker server has exited, so the tab fetches it here: token-gated, one
fixed file, PNG only, the trust model of the routes beside it. A run whose
palette named no cue has no file, which is the 404. */
if (request.method === 'GET' && requestPath === '/cue.png') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
let body;
try {
body = await readFile(store.cuePng);
} catch {
throw httpError(404, 'Not found');
}
response.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': body.length,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=86400',
});
response.end(body);
return;
}
/* The document's own static images, for the tab that outlives the picker
server: the submit flow exits on /submit, and article images only load when
a view opens, which is always after that. Same trust model as the route
@@ -344,6 +383,21 @@ async function handleRequest(request, response) {
return;
}
/* The Hooks page's live state: the shared config's hook switch and the
detector ignore lists, read through hook-admin so this server never
parses config shapes itself. */
if (request.method === 'GET' && requestPath === '/doc/hooks') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
let state;
try {
state = runHookAdmin(['state']);
} catch (error) {
throw httpError(500, String(error.stderr || error.message || error).trim().split('\n')[0]);
}
sendJson(response, 200, { ok: true, state });
return;
}
if (request.method === 'GET' && requestPath === '/doc/answers') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
@@ -387,6 +441,24 @@ async function handleRequest(request, response) {
return;
}
/* The Hooks page's Apply: the full desired state arrives at once and is
written by hook-admin.mjs apply, with no model in the loop. Same
deterministic contract as /doc/save: complete or refused, never
approximately done. */
if (requestPath === '/doc/hooks') {
if (!body.state || typeof body.state !== 'object' || Array.isArray(body.state)) {
throw httpError(400, 'state must be a JSON object');
}
let state;
try {
state = runHookAdmin(['apply'], JSON.stringify(body.state));
} catch (error) {
throw httpError(400, String(error.stderr || error.message || error).trim().split('\n')[0]);
}
sendJson(response, 200, { ok: true, state });
return;
}
if (requestPath === '/doc/request') {
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
const prompt = String(body.prompt || '').trim();
+6
View File
@@ -262,6 +262,12 @@ async function handleRequest(request, response) {
where to reach it from this response; the agent learns from
runtime/session.json, which the sibling writes at boot. */
const doc = await spawnDocSession();
/* The tab fires its first asset requests the moment this response lands,
and an img that reaches a forked session still booting fails once and
never retries. The session writes its record only after listen succeeds,
so the record on disk is readiness itself; no HTTP probe, which would
also mark the session adopted before any tab has seen it. */
if (doc) await waitForSessionRecord(5000);
response.once('finish', () => {
console.log(`ANSWERS ${answersPath}`);
server.close(() => process.exit(0));
+132
View File
@@ -165,6 +165,12 @@ async function waitForExit(processHandle) {
async function cleanup(t, fixture, server) {
t.after(async () => {
if (server?.processHandle.exitCode === null) server.processHandle.kill('SIGTERM');
/* A submit spawns a detached doc session that outlives the picker; reap it
by its own record or it squats a port a later test needs. */
try {
const record = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json'), 'utf8'));
if (record?.pid) process.kill(record.pid, 'SIGTERM');
} catch { /* no session was spawned, or it is already gone */ }
await rm(fixture.cwd, { recursive: true, force: true });
});
}
@@ -704,3 +710,129 @@ test('questionnaire refuses to start without cues.json', async () => {
await rm(fixture.cwd, { recursive: true, force: true });
}
});
/* The chosen cue joins the doc session's image routes: one fixed store file,
token-gated, PNG only. The 404 is the run whose palette named no cue, so
nothing was ever copied in. */
test('doc session serves the stored cue, token-gated', async () => {
const fixture = await createFixture();
const sessionScript = path.join(root, 'skill/scripts/picker-doc-session.mjs');
const child = spawn(process.execPath, [sessionScript, '--port', String(portBase + 70)], {
cwd: fixture.cwd,
env: { ...process.env, IMPECCABLE_DOC_TOKEN: 't-cue' },
stdio: ['ignore', 'pipe', 'pipe'],
});
try {
const sessionFile = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json');
let session = null;
for (let attempt = 0; attempt < 100 && !session; attempt += 1) {
if (existsSync(sessionFile)) session = JSON.parse(await readFile(sessionFile, 'utf8'));
else await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.ok(session?.port, 'the session never recorded its port');
const base = `http://127.0.0.1:${session.port}`;
// No submit has copied a cue in yet.
assert.equal((await fetch(`${base}/cue.png?token=t-cue`)).status, 404);
await mkdir(path.join(fixture.cwd, '.impeccable/design-context'), { recursive: true });
await writeFile(path.join(fixture.cwd, '.impeccable/design-context/cue.png'), Buffer.from('fake-cue-png'));
const ok = await fetch(`${base}/cue.png?token=t-cue`);
assert.equal(ok.status, 200);
assert.equal(ok.headers.get('content-type'), 'image/png');
assert.match(ok.headers.get('cache-control') || '', /max-age/);
assert.equal(await ok.text(), 'fake-cue-png');
assert.equal((await fetch(`${base}/cue.png?token=wrong`)).status, 403);
assert.equal((await fetch(`${base}/cue.png`)).status, 403);
} finally {
child.kill('SIGTERM');
await rm(fixture.cwd, { recursive: true, force: true });
}
});
/* The Hooks page's live channel: GET /doc/hooks reads the project's hook
state and POST /doc/hooks applies a full desired state, both through
hook-admin.mjs with no model in the loop. Exact-set semantics: an entry
missing from a later payload is a removal, which the union-merging CLI
verbs cannot express. */
test('doc session reads and applies hook state, token-gated', async () => {
const fixture = await createFixture();
const sessionScript = path.join(root, 'skill/scripts/picker-doc-session.mjs');
const child = spawn(process.execPath, [sessionScript, '--port', String(portBase + 80)], {
cwd: fixture.cwd,
env: { ...process.env, IMPECCABLE_DOC_TOKEN: 't-hooks' },
stdio: ['ignore', 'pipe', 'pipe'],
});
try {
const sessionFile = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json');
let session = null;
for (let attempt = 0; attempt < 100 && !session; attempt += 1) {
if (existsSync(sessionFile)) session = JSON.parse(await readFile(sessionFile, 'utf8'));
else await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.ok(session?.port, 'the session never recorded its port');
const base = `http://127.0.0.1:${session.port}`;
// Fresh project: defaults, nothing ignored.
const fresh = await fetch(`${base}/doc/hooks?token=t-hooks`);
assert.equal(fresh.status, 200);
assert.deepEqual((await fresh.json()).state, {
enabled: true, ignoreRules: [], ignoreFiles: [], ignoreValues: [],
});
// Apply a full state and read it back from the response and the disk.
const applied = await fetch(`${base}/doc/hooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 't-hooks',
state: {
enabled: false,
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [{ rule: 'overused-font', value: 'Inter', reason: 'user confirmed' }],
},
}),
});
assert.equal(applied.status, 200);
const appliedState = (await applied.json()).state;
assert.equal(appliedState.enabled, false);
assert.deepEqual(appliedState.ignoreRules, ['side-tab']);
const config = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/config.json'), 'utf8'));
assert.equal(config.hook.enabled, false);
assert.deepEqual(config.detector.ignoreRules, ['side-tab']);
assert.deepEqual(config.detector.ignoreFiles, ['src/legacy/**']);
assert.equal(config.detector.ignoreValues.length, 1);
// Removals stick: a payload without the entries clears them on disk.
const cleared = await fetch(`${base}/doc/hooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 't-hooks',
state: { enabled: false, ignoreRules: [], ignoreFiles: [], ignoreValues: [] },
}),
});
assert.equal(cleared.status, 200);
const clearedConfig = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/config.json'), 'utf8'));
assert.deepEqual(clearedConfig.detector.ignoreRules, []);
assert.deepEqual(clearedConfig.detector.ignoreValues, []);
// The gate and the validation hold.
assert.equal((await fetch(`${base}/doc/hooks`)).status, 403);
assert.equal((await fetch(`${base}/doc/hooks?token=wrong`)).status, 403);
const rejected = await fetch(`${base}/doc/hooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 't-hooks', state: { ignoreValues: [{ rule: 'x', value: '*' }] } }),
});
assert.equal(rejected.status, 400);
} finally {
child.kill('SIGTERM');
await rm(fixture.cwd, { recursive: true, force: true });
}
});