@ogygie/studio 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/server.js +904 -0
  2. package/package.json +14 -14
  3. package/src/index.ts +0 -41
  4. package/src/server/app.ts +0 -84
  5. package/src/server/routes.test.ts +0 -164
  6. package/src/server/routes.ts +0 -87
  7. package/src/server/schemas.ts +0 -92
  8. package/src/server/service.test.ts +0 -305
  9. package/src/server/service.ts +0 -426
  10. package/src/server/start.ts +0 -80
  11. package/src/server/storage/local-adapter.test.ts +0 -52
  12. package/src/server/storage/local-adapter.ts +0 -67
  13. package/src/server/storage/memory-adapter.ts +0 -49
  14. package/src/server/storage/spec-storage.ts +0 -22
  15. package/src/ui/app.css +0 -3
  16. package/src/ui/features/manifest-view/ManifestView.test.tsx +0 -67
  17. package/src/ui/features/manifest-view/ManifestView.tsx +0 -80
  18. package/src/ui/features/spec-board/SpecBoard.test.tsx +0 -92
  19. package/src/ui/features/spec-board/SpecBoard.tsx +0 -198
  20. package/src/ui/features/spec-editor/MarkdownPreview.tsx +0 -49
  21. package/src/ui/features/spec-editor/SpecEditor.test.tsx +0 -79
  22. package/src/ui/features/spec-editor/SpecEditor.tsx +0 -237
  23. package/src/ui/features/spec-editor/frontmatter-fields.test.ts +0 -54
  24. package/src/ui/features/spec-editor/frontmatter-fields.ts +0 -85
  25. package/src/ui/features/spec-editor/markdown-blocks.test.ts +0 -37
  26. package/src/ui/features/spec-editor/markdown-blocks.ts +0 -82
  27. package/src/ui/index.html +0 -12
  28. package/src/ui/lib/fetchContract.ts +0 -40
  29. package/src/ui/lib/queries.ts +0 -69
  30. package/src/ui/main.tsx +0 -27
  31. package/src/ui/patterns/EmptyState.tsx +0 -24
  32. package/src/ui/patterns/ErrorBanner.tsx +0 -51
  33. package/src/ui/patterns/Modal.tsx +0 -38
  34. package/src/ui/router.tsx +0 -76
  35. package/src/ui/test-utils.tsx +0 -56
@@ -1,237 +0,0 @@
1
- /**
2
- * @intent The spec editor: frontmatter form DERIVED from SpecFrontmatterSchema
3
- * (never hand-described), markdown body as textarea with a side-by-side
4
- * preview (v1 decision recorded in the spec's non-objectifs), explicit
5
- * validation with confirmation. Every server refusal (missing section,
6
- * forbidden transition) is displayed as the full OgygieError.
7
- */
8
- import { useState } from "react";
9
- import { Link } from "@tanstack/react-router";
10
- import { SpecFrontmatterSchema, type SpecFrontmatter } from "@ogygie/core/spec";
11
- import { useSaveSpec, useSpec } from "../../lib/queries";
12
- import { ErrorBanner } from "../../patterns/ErrorBanner";
13
- import { Modal } from "../../patterns/Modal";
14
- import {
15
- deriveFrontmatterFields,
16
- fromFormState,
17
- toFormState,
18
- type FrontmatterFormState,
19
- } from "./frontmatter-fields";
20
- import { MarkdownPreview } from "./MarkdownPreview";
21
-
22
- export function SpecEditor({ specId }: { specId: string }) {
23
- const { data, isPending, error } = useSpec(specId);
24
- if (error) {
25
- return (
26
- <div className="space-y-3">
27
- <BackLink />
28
- <ErrorBanner error={error} />
29
- </div>
30
- );
31
- }
32
- if (isPending) {
33
- return <div aria-label="Chargement de la spec" className="h-96 animate-pulse rounded-lg bg-gray-100" />;
34
- }
35
- return (
36
- <EditorForm
37
- key={specId}
38
- specId={specId}
39
- initialFrontmatter={data.frontmatter}
40
- initialBody={data.body}
41
- />
42
- );
43
- }
44
-
45
- function BackLink() {
46
- return (
47
- <Link to="/" className="text-sm text-gray-500 hover:text-gray-900">
48
- ← Retour au board
49
- </Link>
50
- );
51
- }
52
-
53
- function EditorForm({
54
- specId,
55
- initialFrontmatter,
56
- initialBody,
57
- }: {
58
- specId: string;
59
- initialFrontmatter: SpecFrontmatter;
60
- initialBody: string;
61
- }) {
62
- const fields = deriveFrontmatterFields();
63
- const [formState, setFormState] = useState<FrontmatterFormState>(() =>
64
- toFormState(initialFrontmatter),
65
- );
66
- const [body, setBody] = useState(initialBody);
67
- const [localError, setLocalError] = useState<string | null>(null);
68
- const [confirming, setConfirming] = useState(false);
69
- const save = useSaveSpec(specId);
70
-
71
- const setField = (name: keyof FrontmatterFormState, value: string) => {
72
- setFormState((state) => ({ ...state, [name]: value }));
73
- };
74
-
75
- /** Rebuilds and validates the frontmatter with the SHARED schema. */
76
- const candidateFrontmatter = (): SpecFrontmatter | null => {
77
- const parsed = SpecFrontmatterSchema.safeParse(fromFormState(formState));
78
- if (!parsed.success) {
79
- setLocalError(
80
- parsed.error.issues
81
- .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
82
- .join(" ; "),
83
- );
84
- return null;
85
- }
86
- setLocalError(null);
87
- return parsed.data;
88
- };
89
-
90
- const saveDraft = () => {
91
- const frontmatter = candidateFrontmatter();
92
- if (frontmatter === null) return;
93
- save.mutate({ frontmatter, body });
94
- };
95
-
96
- const confirmValidation = () => {
97
- const frontmatter = candidateFrontmatter();
98
- if (frontmatter === null) {
99
- setConfirming(false);
100
- return;
101
- }
102
- save.mutate(
103
- { frontmatter: { ...frontmatter, status: "validated" }, body },
104
- { onSettled: () => setConfirming(false) },
105
- );
106
- };
107
-
108
- return (
109
- <div className="space-y-4">
110
- <div className="flex items-center justify-between">
111
- <div>
112
- <BackLink />
113
- <h1 className="font-mono text-lg font-semibold text-gray-900">{specId}</h1>
114
- </div>
115
- <div className="flex gap-2">
116
- <button
117
- type="button"
118
- onClick={saveDraft}
119
- disabled={save.isPending}
120
- className="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
121
- >
122
- Enregistrer
123
- </button>
124
- {formState.status === "draft" && (
125
- <button
126
- type="button"
127
- onClick={() => setConfirming(true)}
128
- disabled={save.isPending}
129
- className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
130
- >
131
- Valider
132
- </button>
133
- )}
134
- </div>
135
- </div>
136
-
137
- {localError !== null && (
138
- <div role="alert" className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
139
- Frontmatter invalide : {localError}
140
- </div>
141
- )}
142
- {save.error != null && <ErrorBanner error={save.error} />}
143
- {save.isSuccess && save.error == null && (
144
- <p role="status" className="rounded-md border border-emerald-200 bg-emerald-50 p-2 text-sm text-emerald-800">
145
- Spec enregistrée — statut : {save.data.spec.status}.
146
- </p>
147
- )}
148
-
149
- <section aria-label="Frontmatter" className="rounded-lg border border-gray-200 bg-white p-4">
150
- <h2 className="mb-3 text-sm font-semibold text-gray-700">
151
- Frontmatter (formulaire dérivé de SpecFrontmatterSchema)
152
- </h2>
153
- <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
154
- {fields.map((field) => (
155
- <label key={field.name} className="block text-sm text-gray-700">
156
- <span className="font-mono text-xs text-gray-500">{field.name}</span>
157
- {field.kind === "enum" ? (
158
- <select
159
- value={formState[field.name]}
160
- onChange={(event) => setField(field.name, event.target.value)}
161
- className="mt-1 w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"
162
- >
163
- {(field.options ?? []).map((option) => (
164
- <option key={option} value={option}>
165
- {option}
166
- </option>
167
- ))}
168
- </select>
169
- ) : field.kind === "string-array" ? (
170
- <textarea
171
- value={formState[field.name]}
172
- onChange={(event) => setField(field.name, event.target.value)}
173
- rows={2}
174
- placeholder="un élément par ligne"
175
- className="mt-1 w-full rounded-md border border-gray-300 px-2 py-1.5 font-mono text-xs"
176
- />
177
- ) : (
178
- <input
179
- value={formState[field.name]}
180
- readOnly={field.readOnly}
181
- onChange={(event) => setField(field.name, event.target.value)}
182
- className={`mt-1 w-full rounded-md border border-gray-300 px-2 py-1.5 font-mono text-sm ${field.readOnly ? "bg-gray-50 text-gray-500" : ""}`}
183
- />
184
- )}
185
- </label>
186
- ))}
187
- </div>
188
- </section>
189
-
190
- <section aria-label="Sections markdown" className="grid grid-cols-1 gap-4 lg:grid-cols-2">
191
- <label className="block text-sm text-gray-700">
192
- <span className="font-mono text-xs text-gray-500">sections (markdown)</span>
193
- <textarea
194
- value={body}
195
- onChange={(event) => setBody(event.target.value)}
196
- rows={24}
197
- className="mt-1 w-full rounded-md border border-gray-300 p-3 font-mono text-xs leading-relaxed"
198
- />
199
- </label>
200
- <div className="text-sm">
201
- <span className="font-mono text-xs text-gray-500">preview</span>
202
- <div className="mt-1 h-full rounded-md border border-gray-200 bg-white p-4">
203
- <MarkdownPreview markdown={body} />
204
- </div>
205
- </div>
206
- </section>
207
-
208
- <Modal
209
- open={confirming}
210
- title={`Valider ${specId} ?`}
211
- onClose={() => setConfirming(false)}
212
- >
213
- <p className="text-sm text-gray-600">
214
- Une spec validée est un contrat d'implémentation : status passera à
215
- « validated » et le fichier sera réécrit (message « spec: validate {specId} »).
216
- </p>
217
- <div className="mt-4 flex justify-end gap-2">
218
- <button
219
- type="button"
220
- onClick={() => setConfirming(false)}
221
- className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700"
222
- >
223
- Annuler
224
- </button>
225
- <button
226
- type="button"
227
- onClick={confirmValidation}
228
- disabled={save.isPending}
229
- className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
230
- >
231
- Confirmer la validation
232
- </button>
233
- </div>
234
- </Modal>
235
- </div>
236
- );
237
- }
@@ -1,54 +0,0 @@
1
- /**
2
- * @intent Proves that the frontmatter form is really DERIVED from
3
- * SpecFrontmatterSchema (never hand-described): field kinds and enum options
4
- * come from the schema, and the form-state round-trip re-validates against the
5
- * SAME schema — no duplicated rule can drift.
6
- */
7
- import { describe, expect, it } from "vitest";
8
- import { SpecFrontmatterSchema } from "@ogygie/core/spec";
9
- import {
10
- deriveFrontmatterFields,
11
- fromFormState,
12
- toFormState,
13
- } from "./frontmatter-fields";
14
-
15
- describe("ogygie-studio — dérivation du formulaire de frontmatter", () => {
16
- it("ÉTANT DONNÉ SpecFrontmatterSchema QUAND on dérive les champs ALORS chaque clé du schéma a son champ, avec les options d'enum lues du schéma", () => {
17
- const fields = deriveFrontmatterFields();
18
- expect(fields.map((field) => field.name)).toEqual([
19
- "id",
20
- "status",
21
- "depends_on",
22
- "priority",
23
- "refs",
24
- "deferred",
25
- ]);
26
- const status = fields.find((field) => field.name === "status");
27
- expect(status).toMatchObject({
28
- kind: "enum",
29
- options: ["draft", "validated", "implemented"],
30
- });
31
- const priority = fields.find((field) => field.name === "priority");
32
- expect(priority).toMatchObject({ kind: "enum", options: ["P0", "P1", "P2"] });
33
- const id = fields.find((field) => field.name === "id");
34
- expect(id).toMatchObject({ kind: "text", readOnly: true });
35
- for (const arrayField of ["depends_on", "refs", "deferred"]) {
36
- expect(fields.find((field) => field.name === arrayField)).toMatchObject({
37
- kind: "string-array",
38
- });
39
- }
40
- });
41
-
42
- it("ÉTANT DONNÉ un frontmatter valide QUAND on passe par l'état de formulaire puis on revalide ALORS le round-trip est identique", () => {
43
- const frontmatter = SpecFrontmatterSchema.parse({
44
- id: "alpha",
45
- status: "draft",
46
- depends_on: ["beta"],
47
- priority: "P2",
48
- refs: ["specs/OGYGIE-STUDIO-ARCHI.md"],
49
- deferred: [],
50
- });
51
- const roundTrip = SpecFrontmatterSchema.parse(fromFormState(toFormState(frontmatter)));
52
- expect(roundTrip).toEqual(frontmatter);
53
- });
54
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * @intent DERIVES the frontmatter form from SpecFrontmatterSchema — never
3
- * described by hand (same rule as the ContractForm pattern): a field added to
4
- * the schema appears in the studio without touching the UI. Only the flat
5
- * shapes the spec format allows are supported (scalar string, enum, array of
6
- * strings); anything else fails loudly instead of rendering a wrong field.
7
- */
8
- import { z } from "zod";
9
- import { SpecFrontmatterSchema, type SpecFrontmatter } from "@ogygie/core/spec";
10
-
11
- export type FrontmatterFieldKind = "text" | "enum" | "string-array";
12
-
13
- export interface FrontmatterField {
14
- name: keyof SpecFrontmatter;
15
- kind: FrontmatterFieldKind;
16
- /** Enum options, present only when kind === "enum". */
17
- options?: readonly string[] | undefined;
18
- /** The id names the file: shown, never editable from the studio. */
19
- readOnly: boolean;
20
- }
21
-
22
- function unwrap(schema: z.ZodType): z.ZodType {
23
- if (schema instanceof z.ZodDefault) {
24
- return unwrap(schema.def.innerType as z.ZodType);
25
- }
26
- return schema;
27
- }
28
-
29
- export function deriveFrontmatterFields(): FrontmatterField[] {
30
- const shape = SpecFrontmatterSchema.shape;
31
- return (Object.keys(shape) as (keyof SpecFrontmatter)[]).map((name) => {
32
- const fieldSchema = unwrap(shape[name]);
33
- if (fieldSchema instanceof z.ZodEnum) {
34
- return {
35
- name,
36
- kind: "enum",
37
- options: fieldSchema.options.map((option) => String(option)),
38
- readOnly: false,
39
- };
40
- }
41
- if (fieldSchema instanceof z.ZodArray) {
42
- return { name, kind: "string-array", readOnly: false };
43
- }
44
- if (fieldSchema instanceof z.ZodString) {
45
- return { name, kind: "text", readOnly: name === "id" };
46
- }
47
- throw new Error(
48
- `deriveFrontmatterFields: unsupported schema for "${String(name)}" — extend the derivation, never hand-describe the form`,
49
- );
50
- });
51
- }
52
-
53
- /** Form state: every field edited as text (arrays = one element per line). */
54
- export type FrontmatterFormState = Record<keyof SpecFrontmatter, string>;
55
-
56
- export function toFormState(frontmatter: SpecFrontmatter): FrontmatterFormState {
57
- return {
58
- id: frontmatter.id,
59
- status: frontmatter.status,
60
- depends_on: frontmatter.depends_on.join("\n"),
61
- priority: frontmatter.priority,
62
- refs: frontmatter.refs.join("\n"),
63
- deferred: frontmatter.deferred.join("\n"),
64
- };
65
- }
66
-
67
- function linesOf(text: string): string[] {
68
- return text
69
- .split("\n")
70
- .map((line) => line.trim())
71
- .filter((line) => line !== "");
72
- }
73
-
74
- /** Rebuilds a candidate frontmatter from the form; the caller validates it
75
- * with SpecFrontmatterSchema (single source of truth — no duplicated rules). */
76
- export function fromFormState(state: FrontmatterFormState): Record<string, unknown> {
77
- return {
78
- id: state.id,
79
- status: state.status,
80
- depends_on: linesOf(state.depends_on),
81
- priority: state.priority,
82
- refs: linesOf(state.refs),
83
- deferred: linesOf(state.deferred),
84
- };
85
- }
@@ -1,37 +0,0 @@
1
- /**
2
- * @intent Proves the preview parser: headings, bullets, fenced code and
3
- * paragraphs become typed blocks (the preview never renders an HTML string).
4
- */
5
- import { describe, expect, it } from "vitest";
6
- import { parseMarkdownBlocks } from "./markdown-blocks";
7
-
8
- describe("ogygie-studio — parseur de preview markdown", () => {
9
- it("ÉTANT DONNÉ un corps de spec canonique QUAND on le parse ALORS titres, listes, code et paragraphes deviennent des blocs typés", () => {
10
- const markdown = [
11
- "# @intent",
12
- "Le pourquoi de la feature.",
13
- "",
14
- "# Comportements",
15
- "- ÉTANT DONNÉ x",
16
- " QUAND y",
17
- "- ALORS z",
18
- "",
19
- "```",
20
- "const a = 1;",
21
- "```",
22
- ].join("\n");
23
-
24
- expect(parseMarkdownBlocks(markdown)).toEqual([
25
- { kind: "heading", level: 1, text: "@intent" },
26
- { kind: "paragraph", text: "Le pourquoi de la feature." },
27
- { kind: "heading", level: 1, text: "Comportements" },
28
- { kind: "bullet", items: ["ÉTANT DONNÉ x QUAND y", "ALORS z"] },
29
- { kind: "code", text: "const a = 1;" },
30
- ]);
31
- });
32
-
33
- it("ÉTANT DONNÉ un texte vide QUAND on le parse ALORS aucun bloc", () => {
34
- expect(parseMarkdownBlocks("")).toEqual([]);
35
- expect(parseMarkdownBlocks("\n\n")).toEqual([]);
36
- });
37
- });
@@ -1,82 +0,0 @@
1
- /**
2
- * @intent Tiny markdown block parser for the side-by-side preview (v1 spec:
3
- * textarea + preview). Pure function returning typed blocks that the preview
4
- * renders as React elements — never an HTML string (dangerouslySetInnerHTML is
5
- * banned repo-wide). Deliberately minimal: headings, bullets, fenced code,
6
- * paragraphs — a spec is prose, not a rendering benchmark.
7
- */
8
-
9
- export type MarkdownBlock =
10
- | { kind: "heading"; level: number; text: string }
11
- | { kind: "bullet"; items: string[] }
12
- | { kind: "code"; text: string }
13
- | { kind: "paragraph"; text: string };
14
-
15
- export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
16
- const blocks: MarkdownBlock[] = [];
17
- const lines = markdown.split("\n");
18
- let index = 0;
19
- while (index < lines.length) {
20
- const line = lines[index] ?? "";
21
- const trimmed = line.trim();
22
- if (trimmed === "") {
23
- index += 1;
24
- continue;
25
- }
26
- const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed);
27
- if (heading !== null) {
28
- blocks.push({
29
- kind: "heading",
30
- level: (heading[1] ?? "#").length,
31
- text: heading[2] ?? "",
32
- });
33
- index += 1;
34
- continue;
35
- }
36
- if (trimmed.startsWith("```")) {
37
- const codeLines: string[] = [];
38
- index += 1;
39
- while (index < lines.length && !(lines[index] ?? "").trim().startsWith("```")) {
40
- codeLines.push(lines[index] ?? "");
41
- index += 1;
42
- }
43
- index += 1; // closing fence (or end of text)
44
- blocks.push({ kind: "code", text: codeLines.join("\n") });
45
- continue;
46
- }
47
- if (trimmed.startsWith("- ")) {
48
- const items: string[] = [];
49
- while (index < lines.length) {
50
- const itemLine = (lines[index] ?? "").trim();
51
- if (itemLine.startsWith("- ")) {
52
- items.push(itemLine.slice(2));
53
- index += 1;
54
- } else if (itemLine !== "" && !itemLine.startsWith("#")) {
55
- // continuation of the previous item (indented prose)
56
- items[items.length - 1] = `${items[items.length - 1] ?? ""} ${itemLine}`;
57
- index += 1;
58
- } else {
59
- break;
60
- }
61
- }
62
- blocks.push({ kind: "bullet", items });
63
- continue;
64
- }
65
- const paragraphLines: string[] = [];
66
- while (index < lines.length) {
67
- const paragraphLine = (lines[index] ?? "").trim();
68
- if (
69
- paragraphLine === "" ||
70
- paragraphLine.startsWith("#") ||
71
- paragraphLine.startsWith("- ") ||
72
- paragraphLine.startsWith("```")
73
- ) {
74
- break;
75
- }
76
- paragraphLines.push(paragraphLine);
77
- index += 1;
78
- }
79
- blocks.push({ kind: "paragraph", text: paragraphLines.join(" ") });
80
- }
81
- return blocks;
82
- }
package/src/ui/index.html DELETED
@@ -1,12 +0,0 @@
1
- <!doctype html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Ogygie Studio</title>
7
- </head>
8
- <body>
9
- <div id="root"></div>
10
- <script type="module" src="./main.tsx"></script>
11
- </body>
12
- </html>
@@ -1,40 +0,0 @@
1
- /**
2
- * @intent Single fetch path of the studio UI: relative /api/... calls, output
3
- * parsed against the shared Zod contract (zero drift with the server), and any
4
- * non-2xx response surfaced as the full OgygieError payload — the UI displays
5
- * errors entirely, it never summarizes them away.
6
- */
7
- import type { z } from "zod";
8
- import { OgygieErrorSchema, type OgygieError } from "@ogygie/core/errors";
9
-
10
- export class StudioApiError extends Error {
11
- public readonly payload: OgygieError;
12
-
13
- constructor(payload: OgygieError) {
14
- super(payload.message);
15
- this.name = "StudioApiError";
16
- this.payload = payload;
17
- }
18
- }
19
-
20
- export async function fetchContract<Output>(
21
- path: string,
22
- schema: z.ZodType<Output>,
23
- init?: RequestInit,
24
- ): Promise<Output> {
25
- const response = await fetch(path, init);
26
- const json: unknown = await response.json();
27
- if (!response.ok) {
28
- const parsed = OgygieErrorSchema.safeParse(json);
29
- if (parsed.success) throw new StudioApiError(parsed.data);
30
- throw new Error(`HTTP ${response.status} on ${path} (non-OgygieError payload)`);
31
- }
32
- return schema.parse(json);
33
- }
34
-
35
- export function jsonBody(body: unknown): RequestInit {
36
- return {
37
- headers: { "content-type": "application/json" },
38
- body: JSON.stringify(body),
39
- };
40
- }
@@ -1,69 +0,0 @@
1
- /**
2
- * @intent Data access of the studio UI: TanStack Query hooks over the shared
3
- * contracts. Every mutation invalidates the specs root — the board and the
4
- * editor always re-read the repo, which is the only state.
5
- */
6
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
7
- import type { SpecFrontmatter } from "@ogygie/core/spec";
8
- import {
9
- CreateSpecOutputSchema,
10
- GetManifestOutputSchema,
11
- GetSpecOutputSchema,
12
- ListSpecsOutputSchema,
13
- UpdateSpecOutputSchema,
14
- } from "../../server/schemas";
15
- import { fetchContract, jsonBody } from "./fetchContract";
16
-
17
- export function useSpecs() {
18
- return useQuery({
19
- queryKey: ["specs"],
20
- queryFn: () => fetchContract("/api/specs", ListSpecsOutputSchema),
21
- });
22
- }
23
-
24
- export function useSpec(specId: string) {
25
- return useQuery({
26
- queryKey: ["specs", specId],
27
- queryFn: () => fetchContract(`/api/specs/${specId}`, GetSpecOutputSchema),
28
- });
29
- }
30
-
31
- export function useManifest() {
32
- return useQuery({
33
- queryKey: ["manifest"],
34
- queryFn: () => fetchContract("/api/manifest", GetManifestOutputSchema),
35
- });
36
- }
37
-
38
- export interface SaveSpecVariables {
39
- frontmatter: SpecFrontmatter;
40
- body: string;
41
- }
42
-
43
- export function useSaveSpec(specId: string) {
44
- const queryClient = useQueryClient();
45
- return useMutation({
46
- mutationFn: (variables: SaveSpecVariables) =>
47
- fetchContract(`/api/specs/${specId}`, UpdateSpecOutputSchema, {
48
- method: "PUT",
49
- ...jsonBody(variables),
50
- }),
51
- onSuccess: async () => {
52
- await queryClient.invalidateQueries({ queryKey: ["specs"] });
53
- },
54
- });
55
- }
56
-
57
- export function useCreateSpec() {
58
- const queryClient = useQueryClient();
59
- return useMutation({
60
- mutationFn: (variables: { id: string }) =>
61
- fetchContract("/api/specs", CreateSpecOutputSchema, {
62
- method: "POST",
63
- ...jsonBody(variables),
64
- }),
65
- onSuccess: async () => {
66
- await queryClient.invalidateQueries({ queryKey: ["specs"] });
67
- },
68
- });
69
- }
package/src/ui/main.tsx DELETED
@@ -1,27 +0,0 @@
1
- /**
2
- * @intent Browser entry point of the studio UI: TanStack Query client +
3
- * router mounted on #root. The repo stays the only state — the client cache
4
- * is a projection, invalidated after every write.
5
- */
6
- import { StrictMode } from "react";
7
- import { createRoot } from "react-dom/client";
8
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
9
- import { RouterProvider } from "@tanstack/react-router";
10
- import { createStudioRouter } from "./router";
11
- import "./app.css";
12
-
13
- const queryClient = new QueryClient();
14
- const router = createStudioRouter();
15
-
16
- const rootElement = document.getElementById("root");
17
- if (rootElement === null) {
18
- throw new Error("Studio UI: #root element missing from index.html");
19
- }
20
-
21
- createRoot(rootElement).render(
22
- <StrictMode>
23
- <QueryClientProvider client={queryClient}>
24
- <RouterProvider router={router} />
25
- </QueryClientProvider>
26
- </StrictMode>,
27
- );
@@ -1,24 +0,0 @@
1
- /**
2
- * @intent Canonical empty state of the studio: a title, an optional hint, and
3
- * the action that fills the void (spec: the empty board offers creation from
4
- * the template).
5
- */
6
- import type { ReactNode } from "react";
7
-
8
- export function EmptyState({
9
- title,
10
- hint,
11
- action,
12
- }: {
13
- title: string;
14
- hint?: string;
15
- action?: ReactNode;
16
- }) {
17
- return (
18
- <div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center">
19
- <p className="text-base font-medium text-gray-900">{title}</p>
20
- {hint !== undefined && <p className="text-sm text-gray-500">{hint}</p>}
21
- {action}
22
- </div>
23
- );
24
- }