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
+82
View File
@@ -0,0 +1,82 @@
/** What the design context document lets a person edit, and where it lands.
*
* Every editable field has an id the browser sends and this file resolves into
* a file and a path inside it. That is what makes applying a change a
* deterministic write rather than a search: the document names the field, not
* the text it happens to hold.
*
* `file` is the store file the value lives in. For `context`, the path is
* relative to the top-level `context` object, so `product.purpose` addresses
* `context.product.purpose` inside context.json.
*
* `downstream` names the document the agent reconciles afterwards. The value
* itself is already applied by the time the agent hears about it; what needs a
* reader is the prose around it.
*/
export const BINDINGS = {
'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' },
'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' },
'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' },
'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' },
'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' },
'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' },
'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' },
'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' },
};
const DEFAULT_MAX_LEN = 2000;
const HEX = /^#[0-9a-fA-F]{6}$/;
export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null);
/**
* Turn what a contenteditable produced into something safe to write.
*
* Everything arriving here was typed into a browser, so it is treated as text
* and nothing else: control characters go, newlines collapse (every bound field
* is a single line in the document), and the length is capped where the field
* says so. A value that survives is a string; a value that cannot be one throws.
*/
export function sanitizeValue(binding, raw) {
if (binding.kind === 'color') {
const value = String(raw ?? '').trim().toUpperCase();
if (!HEX.test(value)) throw new Error('Expected a #rrggbb color');
return value;
}
const text = String(raw ?? '')
/* Newlines first, because they are the one control character with a
meaning here: a pasted paragraph becomes one line rather than nothing. */
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
if (!text) throw new Error('Expected some text');
return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN);
}
/** Read a dotted path out of a plain object, without creating anything. */
export function readPath(root, dotted) {
return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root);
}
/** Write a dotted path into a plain object, creating the objects on the way. */
export function writePath(root, dotted, value) {
const keys = dotted.split('.');
const last = keys.pop();
let node = root;
for (const key of keys) {
if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
node = node[key];
}
node[last] = value;
return root;
}
@@ -0,0 +1,339 @@
/** Taking a design context out of a project, and putting one into another.
*
* Two shapes, because they answer different questions. `design-context.md` is
* for a reader, human or otherwise: one document that says what was decided
* and why, which can be handed to another tool as the rules to follow. The
* bundle is for this toolchain: everything needed to rebuild the store
* somewhere else, including the bytes of the files the user supplied.
*
* The bundle carries the schema version, not the store. A store file's era is
* readable from its own keys, and stamping the browser's submission would mean
* rewriting what it sent.
*/
import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
paths,
readAnswers,
readContext,
readJsonSoft,
writeAnswers,
writeContext,
writeJsonAtomic,
SCHEMA_VERSION,
} from './store.mjs';
export const BUNDLE_KIND = 'impeccable-design-context';
export const BUNDLE_SCHEMA = 1;
const MAX_FILE_BYTES = 1024 * 1024;
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
const MIME = new Map([
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'],
['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
]);
/* Exactly the three places an export puts bytes, and so exactly the three an
import will write them back to. Anything else in a bundle is not ours. */
const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/;
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
/* ============================================================
Export
============================================================ */
async function collectFiles(cwd, { includeAssets = true } = {}) {
const target = paths(cwd);
const files = [];
const skipped = [];
let total = 0;
const take = async (absolute, relative) => {
let bytes;
try {
bytes = await readFile(absolute);
} catch {
return;
}
if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) {
skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' });
return;
}
total += bytes.length;
files.push({
path: relative,
mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream',
base64: bytes.toString('base64'),
});
};
if (!includeAssets) return { files, skipped };
for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) {
let names = [];
try {
names = await readdir(dir);
} catch {
continue;
}
for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`);
}
await take(target.cuePng, 'cue.png');
return { files, skipped };
}
export async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) {
const target = paths(cwd);
const answers = await readAnswers(cwd);
if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.');
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
const cues = await readJsonSoft(target.cuesJson);
const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
/* A seed or custom palette names no cue, so there is no image and no dealt
entry to carry. The hexes in the answers are the palette of record. */
const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null;
const { files, skipped } = await collectFiles(cwd, { includeAssets });
let designMd = null;
try {
designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8');
} catch {
/* Not written yet, which an import is told about rather than guessing. */
}
return {
schemaVersion: BUNDLE_SCHEMA,
kind: BUNDLE_KIND,
exportedAt: now.toISOString(),
product: { name: stored.context?.product?.name || '' },
context: stored,
answers,
/* Whole, never trimmed: the questionnaire validates the manifest by its
pair count and quietly falls back to its own set at any other number. */
fonts: await readJsonSoft(target.fontsManifestJson),
chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null,
designMd,
files,
...(skipped.length ? { skipped } : {}),
};
}
/* ============================================================
The readable compilation
============================================================ */
const line = (label, value) => (value ? `- **${label}:** ${value}\n` : '');
function paletteTable(answers) {
const rows = ROLES
.map((role) => [role, String(answers[`palette-${role}`] || '')])
.filter(([, hex]) => hex);
if (!rows.length) return '';
return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`;
}
function perSurfaceTable(answers, surfaces) {
const rows = [];
for (const key of PER_SURFACE) {
for (const mode of surfaces) {
const value = answers[`${key}-${mode}`];
if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]);
}
}
if (!rows.length) return '';
return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows
.map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`)
.join('\n')}\n\n`;
}
/** One document a reader, or another tool, can follow without this toolchain. */
export function renderMarkdown(bundle) {
const context = bundle.context?.context || {};
const answers = bundle.answers || {};
const name = bundle.product?.name || 'This product';
const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean);
const out = [];
out.push(`# Design context: ${name}\n\n`);
out.push('The decisions this product\'s design follows, and the reasoning behind them. ');
out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n');
const audience = context.audience || {};
if (Object.keys(audience).length) {
out.push('## Audience\n\n');
out.push(line('Primary', audience.primary));
out.push(line('Secondary', audience.secondary));
out.push(line('On arrival', audience.emotion));
out.push(line('Leaving with', audience.leaving));
if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`);
if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`);
if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`);
out.push('\n');
}
const product = context.product || {};
if (Object.keys(product).length) {
out.push('## Product\n\n');
out.push(line('Purpose', product.purpose));
out.push(line('Success', product.success));
out.push(line('Platform', product.platform));
out.push(line('Primary conversion', product.conversion));
if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`);
if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`);
if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`);
out.push('\n');
}
const brand = context.brand || {};
if (Object.keys(brand).length) {
out.push('## Brand\n\n');
if (brand.words?.length) out.push(line('Words', brand.words.join(', ')));
out.push(line('Personality', brand.personality));
if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`);
if (brand.voice?.length) {
out.push('\nVoice, as wording rather than adjectives:\n\n');
for (const pair of brand.voice) {
if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`);
}
}
out.push('\n');
}
const interview = context.interview || {};
if (interview.references?.length || interview.antiReference) {
out.push('## References\n\n');
for (const reference of interview.references || []) {
if (typeof reference === 'string') out.push(`- ${reference}\n`);
else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`);
}
const anti = interview.antiReference;
if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`);
else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`);
out.push('\n');
}
out.push('## Decisions\n\n');
if (surfaces.length) {
out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `);
out.push('The first of these owns any answer stated once for the whole product.\n\n');
}
out.push('### Palette\n\n');
out.push(paletteTable(answers));
if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`);
out.push('### Typography\n\n');
out.push(line('Heading', answers['font-heading']));
out.push(line('Body', answers['font-body']));
out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`));
out.push('\n');
if (answers['icon-pack-name']) {
out.push('### Icons\n\n');
out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`);
if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`);
out.push('\nEvery icon comes from this pack; do not mix sets.\n\n');
}
const perSurface = perSurfaceTable(answers, surfaces);
if (perSurface) {
out.push('### Per surface\n\n');
out.push(perSurface);
}
if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`);
if (bundle.designMd) {
out.push('## DESIGN.md\n\n');
out.push('The design document this context produced, verbatim.\n\n');
out.push('<!-- begin DESIGN.md -->\n\n');
out.push(bundle.designMd.trim());
out.push('\n\n<!-- end DESIGN.md -->\n');
}
return out.join('');
}
export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) {
const bundle = await buildBundle(cwd, { includeAssets, now });
const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir;
await mkdir(destination, { recursive: true });
const markdownPath = path.join(destination, 'design-context.md');
const bundlePath = path.join(destination, 'design-context.bundle.json');
await writeFile(markdownPath, renderMarkdown(bundle));
await writeJsonAtomic(bundlePath, bundle);
return { markdownPath, bundlePath, skipped: bundle.skipped || [] };
}
/* ============================================================
Import
============================================================ */
export function validateBundle(bundle) {
if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle');
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`);
if (bundle.schemaVersion !== BUNDLE_SCHEMA) {
throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`);
}
if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers');
return bundle;
}
export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) {
validateBundle(bundle);
const target = paths(cwd);
await writeAnswers(bundle.answers, cwd);
const context = bundle.context && typeof bundle.context === 'object'
? bundle.context
: { schemaVersion: SCHEMA_VERSION };
await writeContext(context, cwd);
let written = 0;
for (const file of Array.isArray(bundle.files) ? bundle.files : []) {
const relative = String(file?.path || '');
/* Containment is not enough on its own: a bundle could otherwise name a
store file and overwrite what was just written. Only the three places an
export puts bytes are accepted. */
if (!ALLOWED_FILE.test(relative)) {
process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`);
continue;
}
const absolute = path.resolve(target.storeDir, relative);
if (path.relative(target.storeDir, absolute).startsWith('..')) continue;
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64'));
written += 1;
}
/* The questionnaire cannot run without a cue manifest: its palette screen
loads the deck and the built-in seeds together, and neither arrives if the
file is missing. An imported project gets a valid one either way, carrying
the chosen cue's dealt values when the bundle brought them. */
if (!(await readJsonSoft(target.cuesJson))) {
await writeJsonAtomic(target.cuesJson, {
cues: [],
...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }),
});
}
if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) {
await writeJsonAtomic(target.fontsManifestJson, bundle.fonts);
}
let designWritten = false;
if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) {
const designPath = path.resolve(cwd, 'DESIGN.md');
if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) {
await writeFile(designPath, bundle.designMd);
designWritten = true;
}
}
return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) };
}
@@ -0,0 +1,236 @@
/** The save flow behind the design context document.
*
* A person edits fields in the document; the edits stage in the browser and
* arrive here as one batch when they press Apply. Applying is deterministic:
* every change names a binding, the binding names a file and a path, and the
* value is written through the store. Nothing is searched for and no model is
* involved, which is what makes a save either complete or refused rather than
* approximately done.
*
* What the agent gets afterwards is the reconciliation, not the write. The
* values are already on disk by the time the batch reaches a poll; DESIGN.md
* and PRODUCT.md are the agent's to bring in line with them.
*
* browser --POST /doc/save-------> applied here, journaled, batch queued
* agent --GET /doc/poll-------> save_batch (leased)
* agent --POST /doc/reply------> acknowledged, version bumped
* browser --GET /doc/state------> version moved, so re-read and re-render
*
* The batch is journaled before it is offered and cleared only on an
* acknowledgement, so a session that dies mid-flight re-offers it on the next
* boot rather than losing the work.
*/
import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs';
import {
appendJournal,
readAnswers,
readContext,
replayJournal,
writeAnswers,
writeContext,
SCHEMA_VERSION,
} from './store.mjs';
const MAX_CHANGES = 100;
/* Long enough that an agent doing real prose work is never raced, short enough
that an agent that died does not hold the batch for the session's lifetime. */
const LEASE_MS = 10 * 60_000;
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) {
/* Recovered from the journal at boot: a batch the agent never acknowledged
is still owed, whoever was running when it was made. */
const replayed = replayJournal(cwd);
let pending = replayed.pendingBatch
? { ...replayed.pendingBatch, leaseUntil: 0 }
: null;
let counter = Number(replayed.lastSeq) || 0;
const summary = () => (pending
? { id: pending.id, status: pending.status, count: pending.changes.length }
: null);
function validate(body) {
const changes = Array.isArray(body?.changes) ? body.changes : null;
if (!changes?.length) throw httpError(400, 'changes must be a non-empty array');
if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`);
return changes.map((change) => {
const binding = bindingFor(String(change?.bindingId ?? ''));
if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`);
let value;
try {
value = sanitizeValue(binding, change.to);
} catch (error) {
throw httpError(400, `${change.bindingId}: ${error.message}`);
}
return {
bindingId: String(change.bindingId),
binding,
from: typeof change.from === 'string' ? change.from : '',
to: value,
};
});
}
/** One read and one write per file, so a save lands whole or not at all. */
async function applyToStore(changes) {
const files = new Map();
const load = async (file) => {
if (!files.has(file)) {
files.set(file, file === 'answers'
? (await readAnswers(cwd)) || {}
: (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION });
}
return files.get(file);
};
for (const change of changes) {
const document = await load(change.binding.file);
/* context.json wraps its payload, so a binding path addresses the
context object rather than the file's own root. */
const root = change.binding.file === 'context'
? (document.context ??= {})
: document;
change.previous = String(readPath(root, change.binding.path) ?? '');
writePath(root, change.binding.path, change.to);
}
if (files.has('answers')) await writeAnswers(files.get('answers'), cwd);
if (files.has('context')) await writeContext(files.get('context'), cwd);
}
return {
summary,
hasPending: () => Boolean(pending),
/** POST /doc/save */
async save(body) {
if (pending) throw httpError(409, 'A save is already applying');
const changes = validate(body);
await applyToStore(changes);
for (const change of changes) {
appendJournal({
type: 'change',
bindingId: change.bindingId,
from: change.previous,
to: change.to,
}, cwd);
}
counter += 1;
const id = `batch-${String(counter).padStart(3, '0')}`;
const recorded = changes.map(({ bindingId, previous, to, binding }) => ({
bindingId,
from: previous,
to,
downstream: binding.downstream,
}));
appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd);
pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 };
onChange();
return { id, count: recorded.length };
},
/** The event a polling agent is handed, or nothing when none is due. */
takeBatchEvent(replyCommandFor) {
if (!pending || pending.leaseUntil > Date.now()) return null;
/* Stamped before anything awaits, so a second poll arriving in the same
tick cannot be handed the same batch. */
pending.leaseUntil = Date.now() + LEASE_MS;
return {
type: 'save_batch',
id: pending.id,
changes: pending.changes,
downstream: pending.changes.filter((change) => change.downstream !== 'none'),
replyCommand: replyCommandFor(pending.id),
};
},
/**
* POST /doc/reply for a batch.
*
* An unknown id keeps the lease and says which batch is actually owed, so
* an agent that replied to the wrong thing can correct itself rather than
* leaving the work stranded.
*/
async reply(body) {
if (!pending) throw httpError(404, 'No save is waiting for a reply');
if (body.id !== pending.id) {
throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`);
}
if (!['done', 'error', 'retry'].includes(body.status)) {
throw httpError(400, 'status must be done, error, or retry');
}
if (body.status === 'retry') {
pending.leaseUntil = 0;
onChange();
return { ok: true, status: 'pending' };
}
/* The agent's own follow-on writes ride here rather than going to the
store directly, so this process stays the only writer while it runs. */
const applied = await applyAgentUpdates(body, cwd);
appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd);
pending = null;
onChange();
return { ok: true, status: body.status, applied };
},
/** Journaled so the tab re-reads on a font or freeform request too. */
noteRequest(id, status) {
appendJournal({ type: 'request', id, status }, cwd);
},
};
}
/**
* Key-value updates an agent attaches to its reply.
*
* Answers keys are written as given, since the questionnaire's own vocabulary
* is wider than the bound fields; context values go through their binding when
* one exists, so the same rules apply to both writers.
*/
async function applyAgentUpdates(body, cwd) {
const applied = { answers: 0, context: 0 };
if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) {
const answers = (await readAnswers(cwd)) || {};
for (const [key, value] of Object.entries(body.answers)) {
if (typeof value !== 'string' && !Array.isArray(value)) continue;
answers[key] = value;
applied.answers += 1;
}
if (applied.answers) await writeAnswers(answers, cwd);
}
if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) {
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
const root = (stored.context ??= {});
for (const [dotted, value] of Object.entries(body.context)) {
if (typeof value !== 'string') continue;
const binding = bindingFor(dotted);
let next = value;
if (binding) {
try {
next = sanitizeValue(binding, value);
} catch {
continue;
}
}
writePath(root, binding ? binding.path : dotted, next);
applied.context += 1;
}
if (applied.context) await writeContext(stored, cwd);
}
return applied;
}
+241
View File
@@ -0,0 +1,241 @@
/** The design-context store: the one place that knows where design context lives.
*
* Layout, under the project root:
*
* .impeccable/design-context/
* context.json { schemaVersion, modes, context } the chat half of the interview
* answers.json the questionnaire submission, flat FormData shape
* assets/ brand files the user supplied
* fonts/ font faces the user uploaded
* cue.png the chosen hero, copied at submit so the document stands alone
* runtime/ session.json, journal.jsonl, draft.json (gitignored)
* exports/ design-context.md, design-context.bundle.json (gitignored)
*
* Two rules hold this together. Every write goes through writeJsonAtomic, so a
* reader never sees a torn file. Every read comes off disk, so no process ever
* answers from a copy the file has moved past.
*
* Zero dependencies beyond node: builtins, like every other picker script.
*/
import fs from 'node:fs';
import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const STORE_DIR = '.impeccable/design-context';
export const WORKSPACE_DIR = '.impeccable/visual-cues';
/* The shape of context.json. Bump only when the shape changes, never for a release. */
export const SCHEMA_VERSION = 1;
const LEGACY_DIR = '.impeccable/design-interview';
const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`;
export function paths(cwd = process.cwd()) {
const store = path.resolve(cwd, STORE_DIR);
const runtime = path.join(store, 'runtime');
return {
storeDir: store,
contextJson: path.join(store, 'context.json'),
answersJson: path.join(store, 'answers.json'),
assetsDir: path.join(store, 'assets'),
fontsDir: path.join(store, 'fonts'),
cuePng: path.join(store, 'cue.png'),
runtimeDir: runtime,
sessionJson: path.join(runtime, 'session.json'),
journalJsonl: path.join(runtime, 'journal.jsonl'),
draftJson: path.join(runtime, 'draft.json'),
exportsDir: path.join(store, 'exports'),
workspaceDir: path.resolve(cwd, WORKSPACE_DIR),
cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'),
fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'),
};
}
/** The project-relative path an uploaded font is reported by, and stored under. */
export function fontRelativePath(name) {
return path.join(STORE_DIR, 'fonts', name);
}
export async function writeJsonAtomic(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
await rename(temporary, filePath);
}
export async function readJsonSoft(filePath) {
try {
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson);
export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value);
export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson);
export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value);
export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson);
export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value);
export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {});
/* ============================================================
The journal: append-only, replayed on every read.
============================================================ */
/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */
export function appendJournal(event, cwd = process.cwd()) {
const { runtimeDir, journalJsonl } = paths(cwd);
const seq = replayJournal(cwd).lastSeq + 1;
fs.mkdirSync(runtimeDir, { recursive: true });
fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`);
return seq;
}
/**
* Fold the journal into the state a booting session needs.
*
* Lines the fold cannot use are collected rather than thrown: a legacy
* doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn
* final line is possible after a hard kill. Neither can move lastSeq or
* resurrect a batch, so both are diagnostics, not failures.
*/
export function replayJournal(cwd = process.cwd()) {
const { journalJsonl } = paths(cwd);
const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] };
let raw;
try {
raw = fs.readFileSync(journalJsonl, 'utf8');
} catch {
return state;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) });
continue;
}
if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) {
state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null });
continue;
}
state.entries.push(entry);
if (entry.seq > state.lastSeq) state.lastSeq = entry.seq;
if (entry.type === 'batch') {
state.pendingBatch = entry.status === 'pending' ? entry : null;
}
}
return state;
}
/* ============================================================
Migration from the pre-store layout.
============================================================ */
export function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
/* EPERM means the process exists and is not ours to signal. */
return error.code === 'EPERM';
}
}
async function moveFile(from, to) {
if (fs.existsSync(to) || !fs.existsSync(from)) return false;
await mkdir(path.dirname(to), { recursive: true });
await rename(from, to);
return true;
}
/* Directories move child by child: renaming onto an existing directory fails,
and a run interrupted halfway leaves a destination that already exists. */
async function moveDirContents(fromDir, toDir) {
if (!fs.existsSync(fromDir)) return;
await mkdir(toDir, { recursive: true });
for (const name of fs.readdirSync(fromDir)) {
await moveFile(path.join(fromDir, name), path.join(toDir, name));
}
try {
if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir);
} catch {
/* Something arrived between the read and the remove; leaving it is safe. */
}
}
/** Uploaded-face paths were recorded as strings inside the answers themselves. */
function rewriteFontSources(answers) {
if (!answers || typeof answers !== 'object') return null;
let touched = false;
for (const [key, value] of Object.entries(answers)) {
if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue;
answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`);
touched = true;
}
return touched ? answers : null;
}
/**
* Bring a pre-store project onto the current layout. Idempotent and silent:
* a project that is already current, or was never interviewed, does nothing.
*
* A live session of the old shape holds the old paths in its own constants, so
* migrating under it would strand its writes. That case defers to the next boot.
*/
export async function migrate(cwd = process.cwd()) {
const legacyDir = path.resolve(cwd, LEGACY_DIR);
if (!fs.existsSync(legacyDir)) {
await migrateContextFromCues(cwd);
return { migrated: false, deferred: false };
}
const legacySession = path.join(legacyDir, 'doc-session.json');
const session = await readJsonSoft(legacySession);
if (session && pidAlive(session.pid)) return { migrated: false, deferred: true };
const target = paths(cwd);
await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson);
await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl);
await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir);
await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir);
const answers = await readJsonSoft(target.answersJson);
const rewritten = rewriteFontSources(answers);
if (rewritten) await writeJsonAtomic(target.answersJson, rewritten);
await rm(legacySession, { force: true }).catch(() => {});
try {
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
} catch {
/* Files the migration does not own stay where they are. */
}
await migrateContextFromCues(cwd);
return { migrated: true, deferred: false };
}
/* The chat half of the interview used to ride inside the cue manifest. It is
not a generation artifact, so it moves to the store; cues.json keeps its
cues and palette and is left untouched. */
async function migrateContextFromCues(cwd) {
const target = paths(cwd);
if (fs.existsSync(target.contextJson)) return;
const cues = await readJsonSoft(target.cuesJson);
if (!cues) return;
const hasModes = Array.isArray(cues.modes);
const hasContext = cues.context && typeof cues.context === 'object';
if (!hasModes && !hasContext) return;
await writeJsonAtomic(target.contextJson, {
schemaVersion: SCHEMA_VERSION,
...(hasModes ? { modes: cues.modes } : {}),
...(hasContext ? { context: cues.context } : {}),
});
}