jacquardSnapshot

← 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&apos;s commit, a blocked promotion, a human&apos;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&apos;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&apos;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&apos;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&apos;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&apos;s synthesis and the human&apos;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&apos;t exist, a hash that includes a byte, a
          default that says no. One punch and the loom is warped.
        </TeachingAside>
      );
  }
}