← snapshot
26242 bytes
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useReducer, useState } from "react";
import { ProvenanceBadge } from "@/components/provenance-badge";
import { SeedDemoButton } from "@/components/seed-demo-button";
import { VoiceStatementInput } from "@/components/voice-statement-input";
import { TeachingAside } from "@/components/teaching-aside";
import { ApiError, api } from "@/lib/api";
import {
type InitRepoRequest,
type ProvenanceLabel,
RATIONALE_FAMILIES,
} from "@/lib/types";
import {
isValidRefName,
isValidRepoPath,
slugify,
statementStatus,
} from "@/lib/validate";
const STORAGE_KEY = "jac-wizard-v1";
const STEP_COUNT = 7;
interface WizardState {
name: string;
agents: string[];
founder: string;
defaultRef: string;
commitEnabled: boolean;
filePath: string;
fileContent: string;
commitMessage: string;
provenance: ProvenanceLabel;
decisionEnabled: boolean;
title: string;
rationale: string;
families: string[];
scope: string;
signNow: boolean;
statement: string;
}
const INITIAL: WizardState = {
name: "",
agents: ["loom-bot"],
founder: "",
defaultRef: "main",
commitEnabled: true,
filePath: "README.md",
fileContent: "# a new loom\n\nWhat changed, whose hands made it, and why.\n",
commitMessage: "the first cloth",
provenance: "human",
decisionEnabled: false,
title: "",
rationale: "",
families: [],
scope: "",
signNow: false,
statement: "",
};
type Action = { type: "set"; patch: Partial<WizardState> } | { type: "load"; state: WizardState };
function reducer(state: WizardState, action: Action): WizardState {
switch (action.type) {
case "set":
return { ...state, ...action.patch };
case "load":
return action.state;
}
}
function stepValid(state: WizardState, step: number): boolean {
switch (step) {
case 1:
return slugify(state.name).length > 0;
case 2:
return state.agents.every((a) => a.trim().length > 0);
case 3:
return state.founder.trim().length > 0;
case 4:
return isValidRefName(state.defaultRef);
case 5:
return (
!state.commitEnabled ||
(isValidRepoPath(state.filePath) && state.commitMessage.trim().length > 0)
);
case 6:
if (!state.decisionEnabled) return true;
if (state.title.trim().length === 0 || state.rationale.trim().length === 0)
return false;
if (!parseScope(state.scope).every(isValidRepoPath)) return false;
return !state.signNow || statementStatus(state.statement).valid;
default:
return true;
}
}
function parseScope(scope: string): string[] {
return scope
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
function buildRequest(state: WizardState): InitRepoRequest {
const req: InitRepoRequest = {
name: state.name.trim(),
founder: { display_name: state.founder.trim() },
default_ref: state.defaultRef,
agents: state.agents.filter((a) => a.trim().length > 0).map((a) => ({ model: a.trim() })),
};
if (state.commitEnabled) {
req.initial_commit = {
message: state.commitMessage.trim(),
files: [{ path: state.filePath.trim(), content: state.fileContent }],
provenance: state.provenance,
};
}
if (state.decisionEnabled) {
req.founding_decision = {
title: state.title.trim(),
rationale: state.rationale.trim(),
families: state.families,
scope: parseScope(state.scope),
...(state.signNow ? { attestation: { statement: state.statement } } : {}),
};
}
return req;
}
export function InitWizard() {
const router = useRouter();
const searchParams = useSearchParams();
const step = Math.min(STEP_COUNT, Math.max(1, Number(searchParams.get("step") ?? "1") || 1));
const [state, dispatch] = useReducer(reducer, INITIAL);
const [hydrated, setHydrated] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) dispatch({ type: "load", state: { ...INITIAL, ...JSON.parse(saved) } });
} catch {
// corrupt or unavailable — start fresh
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// non-fatal
}
}, [state, hydrated]);
const set = (patch: Partial<WizardState>) => dispatch({ type: "set", patch });
const go = (n: number) => router.push(`/new?step=${n}`);
const valid = stepValid(state, step);
async function warp() {
setPending(true);
setError(null);
try {
const result = await api.initRepo(buildRequest(state));
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// non-fatal
}
router.push(`/repos/${result.slug}?warped=1`);
} catch (e) {
setError(e instanceof ApiError ? `${e.code}: ${e.message}` : String(e));
setPending(false);
}
}
return (
<div>
<div className="jac-steps" aria-label={`Step ${step} of ${STEP_COUNT}`}>
{Array.from({ length: STEP_COUNT }, (_, i) => i + 1).map((n) => (
<button
key={n}
type="button"
className="jac-step-hole"
data-state={n < step ? "done" : n === step ? "current" : "ahead"}
onClick={() => n < step && go(n)}
disabled={n >= step}
aria-label={`Step ${n}${n < step ? " (done)" : n === step ? " (current)" : ""}`}
/>
))}
<span className="jac-steps-rule" />
<span className="jac-small jac-mono">
{step} / {STEP_COUNT}
</span>
</div>
<div className="jac-wizard" style={{ marginTop: 30 }}>
<div>
{step === 1 && <StepName state={state} set={set} />}
{step === 2 && <StepWorkshop state={state} set={set} />}
{step === 3 && <StepWeaver state={state} set={set} />}
{step === 4 && <StepThread state={state} set={set} />}
{step === 5 && <StepCloth state={state} set={set} />}
{step === 6 && <StepCard state={state} set={set} />}
{step === 7 && <StepLedger state={state} />}
{error ? <div className="jac-error-box">{error}</div> : null}
<div className="jac-cta-row">
{step > 1 ? (
<button type="button" className="jac-btn" onClick={() => go(step - 1)}>
← Back
</button>
) : null}
{step < STEP_COUNT ? (
<button
type="button"
className="jac-btn jac-btn--primary"
disabled={!valid}
onClick={() => go(step + 1)}
>
{step === 5 && !state.commitEnabled
? "Skip — no first cloth"
: step === 6 && !state.decisionEnabled
? "Skip — no founding card"
: "Continue"}
</button>
) : (
<button
type="button"
className="jac-btn jac-btn--primary"
disabled={pending}
onClick={warp}
>
{pending ? "Warping…" : "Warp the loom"}
</button>
)}
<span className="jac-small">
Nothing is created until the final punch — abandoning this leaves
no trace.
</span>
</div>
</div>
<StepAside step={step} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
type StepProps = { state: WizardState; set: (patch: Partial<WizardState>) => void };
function StepName({ state, set }: StepProps) {
const slug = slugify(state.name);
return (
<div>
<h2 className="jac-h2">1 · Name the loom</h2>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-name">
Organisation / repository name
</label>
<input
id="wiz-name"
className="jac-input"
value={state.name}
onChange={(e) => set({ name: e.target.value })}
placeholder="Meridian Systems"
autoFocus
/>
<p className="jac-hint">
{slug ? (
<>
lives at <span className="jac-mono">/repos/{slug}</span>
</>
) : (
"at least one alphanumeric character"
)}
</p>
</div>
<hr className="jac-divider" />
<p className="jac-small">
In a hurry? <SeedDemoButton /> — the full eleven-scene arc, instantly:
an agent's commit, a blocked promotion, a human's attestation,
a rendezvous.
</p>
</div>
);
}
function StepWorkshop({ state, set }: StepProps) {
return (
<div>
<h2 className="jac-h2">2 · The workshop</h2>
<p className="jac-prose" style={{ marginTop: 12 }}>
Your workshop gets an organisation id (<span className="jac-mono">org-…</span>)
— the only name the rendezvous registry will ever hold. Register the
agents that will weave alongside you; each gets an{" "}
<span className="jac-mono">agt-…</span> id, and everything they touch
says so.
</p>
{state.agents.map((agent, i) => (
<div className="jac-field" key={`agent-${String(i)}`}>
<label className="jac-label" htmlFor={`wiz-agent-${i}`}>
Agent {i + 1} — model or tool name
</label>
<div style={{ display: "flex", gap: 10 }}>
<input
id={`wiz-agent-${i}`}
className="jac-input"
value={agent}
onChange={(e) => {
const agents = [...state.agents];
agents[i] = e.target.value;
set({ agents });
}}
placeholder="loom-bot"
/>
<button
type="button"
className="jac-btn"
onClick={() => set({ agents: state.agents.filter((_, j) => j !== i) })}
>
Remove
</button>
</div>
</div>
))}
<div className="jac-cta-row" style={{ marginTop: 14 }}>
<button
type="button"
className="jac-btn"
onClick={() => set({ agents: [...state.agents, ""] })}
>
+ Add an agent
</button>
<span className="jac-small">or none — agents can be registered later</span>
</div>
</div>
);
}
function StepWeaver({ state, set }: StepProps) {
return (
<div>
<h2 className="jac-h2">3 · The founding weaver</h2>
<p className="jac-prose" style={{ marginTop: 12 }}>
Every repo needs at least one person, because only a person can attest
a decision. Without you, nothing here could ever settle.
</p>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-founder">
Your name
</label>
<input
id="wiz-founder"
className="jac-input"
value={state.founder}
onChange={(e) => set({ founder: e.target.value })}
placeholder="Ada"
/>
<p className="jac-hint">
mints <span className="jac-mono">hum-…</span> — the only kind of id an
attestation constructor accepts
</p>
</div>
</div>
);
}
function StepThread({ state, set }: StepProps) {
const touched = state.defaultRef.length > 0;
const ok = isValidRefName(state.defaultRef);
return (
<div>
<h2 className="jac-h2">4 · The first thread</h2>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-ref">
Default ref
</label>
<input
id="wiz-ref"
className="jac-input"
value={state.defaultRef}
onChange={(e) => set({ defaultRef: e.target.value })}
aria-invalid={touched && !ok}
/>
<p className={`jac-hint${touched && !ok ? " jac-hint--error" : ""}`}>
{touched && !ok
? "non-empty segments joined by /, no leading or trailing /, no . or .. segments"
: "promotions into this ref pass through the gate"}
</p>
</div>
</div>
);
}
function StepCloth({ state, set }: StepProps) {
return (
<div>
<h2 className="jac-h2">5 · First cloth <span className="jac-small">(optional)</span></h2>
<label className="jac-cta-row" style={{ marginTop: 12, cursor: "pointer" }}>
<input
type="checkbox"
checked={state.commitEnabled}
onChange={(e) => set({ commitEnabled: e.target.checked })}
/>
<span className="jac-prose">Lay down a first commit</span>
</label>
{state.commitEnabled ? (
<>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-path">
File path
</label>
<input
id="wiz-path"
className="jac-input"
value={state.filePath}
onChange={(e) => set({ filePath: e.target.value })}
aria-invalid={state.filePath.length > 0 && !isValidRepoPath(state.filePath)}
/>
</div>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-content">
Content
</label>
<textarea
id="wiz-content"
className="jac-textarea"
value={state.fileContent}
onChange={(e) => set({ fileContent: e.target.value })}
rows={5}
/>
</div>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-message">
Commit message
</label>
<input
id="wiz-message"
className="jac-input"
value={state.commitMessage}
onChange={(e) => set({ commitMessage: e.target.value })}
/>
</div>
<div className="jac-field">
<span className="jac-label">Whose hands made it?</span>
<div className="jac-prov-cards">
{(
[
["human", "Every hunk typed by a person."],
["agent", "Every hunk produced by an agent."],
["mixed", "A person and an agent both had their hands in it."],
] as [ProvenanceLabel, string][]
).map(([label, blurb]) => (
<button
key={label}
type="button"
className="jac-prov-card"
data-selected={state.provenance === label}
onClick={() => set({ provenance: label })}
>
<ProvenanceBadge provenance={label} />
<p style={{ margin: "10px 0 0" }}>{blurb}</p>
</button>
))}
</div>
<div className="jac-exhibit">
The label is hashed into the snapshot's identity — an
illustration with a one-file tree:
<br />
as committed (<em>agent</em>): <em>snap:9c01f2ab84d3</em>
<br />
relabelled (<em>human</em>): <em>snap:4e77b90c15fa</em>
<br />
The same tree under different hands is a different object
entirely.
</div>
</div>
</>
) : null}
</div>
);
}
function StepCard({ state, set }: StepProps) {
const scopeList = parseScope(state.scope);
const scopeInvalid = !scopeList.every(isValidRepoPath);
return (
<div>
<h2 className="jac-h2">6 · The first card <span className="jac-small">(optional)</span></h2>
<label className="jac-cta-row" style={{ marginTop: 12, cursor: "pointer" }}>
<input
type="checkbox"
checked={state.decisionEnabled}
onChange={(e) => set({ decisionEnabled: e.target.checked })}
/>
<span className="jac-prose">Punch a founding decision</span>
</label>
{state.decisionEnabled ? (
<>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-title">
Title — short and imperative, ADR-style
</label>
<input
id="wiz-title"
className="jac-input"
value={state.title}
onChange={(e) => set({ title: e.target.value })}
placeholder="auth retry fails closed on 401/403"
/>
</div>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-rationale">
Rationale
</label>
<textarea
id="wiz-rationale"
className="jac-textarea"
value={state.rationale}
onChange={(e) => set({ rationale: e.target.value })}
rows={4}
placeholder="What forced this shape, what was considered and rejected…"
/>
</div>
<div className="jac-field">
<span className="jac-label">Families of design knowledge covered</span>
{RATIONALE_FAMILIES.map((f) => (
<label
key={f.label}
className="jac-cta-row"
style={{ marginTop: 6, cursor: "pointer" }}
title={f.hint}
>
<input
type="checkbox"
checked={state.families.includes(f.label)}
onChange={(e) =>
set({
families: e.target.checked
? [...state.families, f.label]
: state.families.filter((x) => x !== f.label),
})
}
/>
<span className="jac-mono" style={{ fontSize: 13 }}>
{f.label}
</span>
<span className="jac-small">{f.hint}</span>
</label>
))}
</div>
<div className="jac-field">
<label className="jac-label" htmlFor="wiz-scope">
Scope — path prefixes it governs, comma-separated
</label>
<input
id="wiz-scope"
className="jac-input"
value={state.scope}
onChange={(e) => set({ scope: e.target.value })}
placeholder="src/auth, src/session"
aria-invalid={scopeInvalid}
/>
<p className={`jac-hint${scopeInvalid ? " jac-hint--error" : ""}`}>
{scopeInvalid
? "each prefix must be a valid repo path"
: scopeList.length === 0
? "empty scope governs nothing — the gate will never block on it, honestly"
: `governs everything at or under: ${scopeList.join(", ")} (segment-wise — src/auth-x is not under src/auth)`}
</p>
</div>
<div className="jac-field">
<label className="jac-cta-row" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={state.signNow}
onChange={(e) => set({ signNow: e.target.checked })}
/>
<span className="jac-prose">Sign it now?</span>
</label>
{state.signNow ? (
<div style={{ marginTop: 10 }}>
<VoiceStatementInput
value={state.statement}
onChange={(statement) => set({ statement })}
/>
</div>
) : (
<p className="jac-hint">
Unsigned, it lands <em>unsettled</em> — proposing is not
attesting, even for the proposer.
</p>
)}
</div>
</>
) : null}
</div>
);
}
function StepLedger({ state }: { state: WizardState }) {
const slug = slugify(state.name);
const agents = state.agents.filter((a) => a.trim().length > 0);
return (
<div>
<h2 className="jac-h2">7 · The ledger</h2>
<p className="jac-prose" style={{ marginTop: 12 }}>
What will be created, and which mechanism it exercises:
</p>
<ul className="jac-ledger">
<li>
<span>
org <strong>{state.name.trim() || "—"}</strong>{" "}
<span className="jac-mono jac-small">/repos/{slug}</span>
</span>
<span className="jac-small">the only name the registry can ever hold</span>
</li>
<li>
<span>
founding weaver <strong>{state.founder.trim() || "—"}</strong>
</span>
<span className="jac-small">
the one identity an attestation constructor accepts
</span>
</li>
{agents.length > 0 ? (
<li>
<span>
{agents.length} agent{agents.length === 1 ? "" : "s"}:{" "}
{agents.join(", ")}
</span>
<span className="jac-small">no code path turns these into an attestation</span>
</li>
) : null}
<li>
<span>
default ref <span className="jac-mono">{state.defaultRef}</span>
</span>
<span className="jac-small">the gate stands at promotion into it</span>
</li>
{state.commitEnabled ? (
<li>
<span>
first commit — <span className="jac-mono">{state.filePath}</span>,
provenance <strong>{state.provenance}</strong>
</span>
<span className="jac-small">
whose hands made it, hashed into the content address
</span>
</li>
) : null}
{state.decisionEnabled ? (
<li>
<span>
founding decision — <strong>{state.title.trim() || "—"}</strong>,{" "}
{state.signNow ? "signed at birth" : "unsettled"}
</span>
<span className="jac-small">
{state.signNow
? "your verbatim words become a separate object"
: "no human has put their name to it — yet"}
</span>
</li>
) : null}
</ul>
<p className="jac-small" style={{ marginTop: 18 }}>
And what is <em>not</em> proven, said plainly: identity here is
declared, not authenticated; nothing persists past the process. Both
are named unblock conditions, not oversights.
</p>
</div>
);
}
function StepAside({ step }: { step: number }) {
switch (step) {
case 1:
return (
<TeachingAside title="Version control is having its Jacquard moment.">
Agents write more of the code every month, and the systems we record
it in capture <em>what</em> changed and <em>who</em> pushed — almost
none of <em>why</em>, and no trace of whose hands actually made it.
This repo will record all three.
</TeachingAside>
);
case 2:
return (
<TeachingAside title="Rendezvous instead of rework.">
When this repo publishes to the shared registry, what leaves is your
org's id and 64-integer sketches — nothing else <em>can</em>{" "}
leave. The registry crate cannot name content types (the dependency
graph never reaches them), and its API only accepts values marked
digest-safe. Both facts are checked by the build.
</TeachingAside>
);
case 3:
return (
<TeachingAside
title="Only a person can attest."
honest="identity here is a type, not authentication. Signing at creation is named future work — the substrate says so rather than pretending."
>
An agent may draft and propose a decision, but a proposed decision is
only <em>unsettled</em>. Settlement takes a few sentences in your own
words — and in the code, no attestation constructor accepts an agent.
A compile-fail test proves it stays that way.
</TeachingAside>
);
case 4:
return (
<TeachingAside title="Unproven is not passed.">
Promotion into this ref is gated, fail-closed: if the blast radius
touches an unsettled decision, the work parks and a human is routed
to it. The gate's <em>default</em> verdict is blocked — a verdict
nobody computed admits nothing.
</TeachingAside>
);
case 5:
return (
<TeachingAside title="Provenance is identity.">
Whose hands made a snapshot — human, agent, or mixed — is hashed{" "}
<em>inside its content address</em>. Relabelling agent work as human
doesn't edit a field; it mints a different object, visible to
everything holding the old id.
</TeachingAside>
);
case 6:
return (
<TeachingAside title="Decisions are objects in the version graph.">
Not markdown by convention — content-addressed objects with scopes
naming what they govern. Broadening a scope later mints a{" "}
<em>new</em> decision: people attested to the narrow one. If you sign
now, your words become a separate object from the rationale — the
machine's synthesis and the human's verbatim account are
never conflated.
</TeachingAside>
);
default:
return (
<TeachingAside title="The books balance.">
Every line in this ledger is a claim bound to a mechanism — a
constructor that doesn't exist, a hash that includes a byte, a
default that says no. One punch and the loom is warped.
</TeachingAside>
);
}
}