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:
@@ -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) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user