docs(product): commit design references under docs/product/reference/

Copy the founder's design handoff bundle (.proto/design/) into
docs/product/reference/ byte-for-byte so dispatch agents running in git
worktrees can read the HTML prototypes, veect-codebase/ prototype,
upload PNGs, and remaining bundle files (ADR-029: reference only, never
vendored into packages/). Ignore-list entries so whole-codebase auditors
and formatters skip reference material: .prettierignore (byte
preservation through lint-staged), .fallowrc.json ignorePatterns, root
ESLint ignores, and the coverage:diff allowlist in
scripts/coverage/diff.mjs (+ unit test). .DS_Store files skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 14:09:30 +02:00
parent c80e09e732
commit 1483a45406
97 changed files with 15685 additions and 1 deletions

View File

@@ -0,0 +1,115 @@
import { useMemo, useState } from 'react';
import { complete, composeSystemPrompt, editSystemPrompt, parseAiJson, sanitize, UNMAPPABLE } from '@/engine/ai';
import { countNodes, findNode, findParent } from '@/engine/tree';
import { useActiveFrame, useVeect } from '@/store/veect';
import type { VeectNode } from '@/types';
/**
* Hook — the constrained-AI orchestration: compose into the active frame,
* or (when a target is scoped) stage an edit for one subtree. Proposals
* are ghosts until accepted; anything off-registry is a refusal.
*/
export function useAiCompose() {
const pushChat = useVeect((s) => s.pushChat);
const proposal = useVeect((s) => s.proposal);
const setProposal = useVeect((s) => s.setProposal);
const model = useVeect((s) => s.model);
const mutate = useVeect((s) => s.mutate);
const board = useVeect((s) => s.board);
const aiTarget = useVeect((s) => s.aiTarget);
const setAiTarget = useVeect((s) => s.setAiTarget);
const activeFrame = useActiveFrame();
const [thinking, setThinking] = useState(false);
const targetNode: VeectNode | null = useMemo(
() => (aiTarget ? findNode(board, aiTarget) : null),
[aiTarget, board],
);
const submit = async (text: string) => {
if (!text.trim() || thinking) return;
pushChat({ role: 'user', text });
const blocked = text.match(UNMAPPABLE);
if (blocked) {
pushChat({
role: 'assistant',
kind: 'refusal',
text: `You don't have a component for this yet. Your registry maps 10 Solstice components — nothing renders a ${blocked[0]}. Veect never falls back to generic markup.`,
});
return;
}
setThinking(true);
try {
if (targetNode) {
const raw = await complete({ system: editSystemPrompt(targetNode), prompt: text, model });
const json = parseAiJson(raw);
if (json.action === 'refuse') {
pushChat({ role: 'assistant', kind: 'refusal', text: String(json.reason ?? 'Outside the registry.') });
return;
}
const one = sanitize([json.node].filter(Boolean) as unknown[]);
if ('bad' in one || one.nodes.length === 0) {
pushChat({ role: 'assistant', kind: 'refusal', text: 'That edit left the registry — refused rather than faked.' });
return;
}
setProposal({ kind: 'edit', targetId: targetNode.id, nodes: one.nodes, summary: text });
pushChat({ role: 'assistant', kind: 'proposal', text: `Staged an edit to this ${targetNode.type} — accept to swap it in.` });
return;
}
const raw = await complete({ system: composeSystemPrompt(), prompt: text, model });
const json = parseAiJson(raw);
if (json.action === 'refuse') {
pushChat({ role: 'assistant', kind: 'refusal', text: String(json.reason ?? 'Outside the registry.') });
return;
}
const result = sanitize((json.nodes as unknown[]) ?? []);
if ('bad' in result) {
pushChat({ role: 'assistant', kind: 'refusal', text: `${result.bad}” is not in your registry — refused rather than faked.` });
return;
}
const frameId = activeFrame?.id ?? 'frame';
setProposal({ kind: 'compose', targetId: frameId, nodes: result.nodes, summary: text });
pushChat({
role: 'assistant',
kind: 'proposal',
text: `Composed from your system — ${countNodes(result.nodes)} components staged on ${activeFrame?.name ?? 'the board'}. The ghost is on the canvas.`,
});
} catch {
pushChat({ role: 'assistant', text: 'Model unavailable — wire /api/compose or retry.' });
} finally {
setThinking(false);
}
};
const accept = () => {
if (!proposal) return;
if (proposal.kind === 'edit') {
mutate((draft) => {
const parent = findParent(draft, proposal.targetId);
if (!parent) return;
const i = parent.children.findIndex((c) => c.id === proposal.targetId);
if (i >= 0) parent.children.splice(i, 1, ...proposal.nodes);
}, 'AI edit — accepted');
setProposal(null);
setAiTarget(proposal.nodes[0]?.id ?? null);
pushChat({ role: 'assistant', text: 'Applied — still 0 unregistered elements.' });
return;
}
mutate((draft) => {
const target = findNode(draft, proposal.targetId);
if (target) target.children.push(...proposal.nodes);
}, 'AI compose — accepted');
setProposal(null);
pushChat({ role: 'assistant', text: `Accepted — ${countNodes(proposal.nodes)} components, 0 unregistered elements.` });
};
const discard = () => {
setProposal(null);
pushChat({ role: 'assistant', text: 'Discarded — nothing was applied.' });
};
return { submit, accept, discard, thinking, proposal, targetNode, clearTarget: () => setAiTarget(null) };
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useState } from 'react';
import { clamp } from '@/lib/utils';
/**
* Drag-to-resize for docked panels (chat, rails, code) — the same
* affordance shadcn's Resizable provides, kept dependency-free.
*/
export function usePanelResize(initial: number, min: number, max: number, invert = false) {
const [width, setWidth] = useState(initial);
const onHandleDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const start = width;
const onMove = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) * (invert ? -1 : 1);
setWidth(clamp(start + dx, min, max));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[invert, max, min, width],
);
return { width, onHandleDown };
}