@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.
- package/dist/server.js +904 -0
- package/package.json +14 -14
- package/src/index.ts +0 -41
- package/src/server/app.ts +0 -84
- package/src/server/routes.test.ts +0 -164
- package/src/server/routes.ts +0 -87
- package/src/server/schemas.ts +0 -92
- package/src/server/service.test.ts +0 -305
- package/src/server/service.ts +0 -426
- package/src/server/start.ts +0 -80
- package/src/server/storage/local-adapter.test.ts +0 -52
- package/src/server/storage/local-adapter.ts +0 -67
- package/src/server/storage/memory-adapter.ts +0 -49
- package/src/server/storage/spec-storage.ts +0 -22
- package/src/ui/app.css +0 -3
- package/src/ui/features/manifest-view/ManifestView.test.tsx +0 -67
- package/src/ui/features/manifest-view/ManifestView.tsx +0 -80
- package/src/ui/features/spec-board/SpecBoard.test.tsx +0 -92
- package/src/ui/features/spec-board/SpecBoard.tsx +0 -198
- package/src/ui/features/spec-editor/MarkdownPreview.tsx +0 -49
- package/src/ui/features/spec-editor/SpecEditor.test.tsx +0 -79
- package/src/ui/features/spec-editor/SpecEditor.tsx +0 -237
- package/src/ui/features/spec-editor/frontmatter-fields.test.ts +0 -54
- package/src/ui/features/spec-editor/frontmatter-fields.ts +0 -85
- package/src/ui/features/spec-editor/markdown-blocks.test.ts +0 -37
- package/src/ui/features/spec-editor/markdown-blocks.ts +0 -82
- package/src/ui/index.html +0 -12
- package/src/ui/lib/fetchContract.ts +0 -40
- package/src/ui/lib/queries.ts +0 -69
- package/src/ui/main.tsx +0 -27
- package/src/ui/patterns/EmptyState.tsx +0 -24
- package/src/ui/patterns/ErrorBanner.tsx +0 -51
- package/src/ui/patterns/Modal.tsx +0 -38
- package/src/ui/router.tsx +0 -76
- package/src/ui/test-utils.tsx +0 -56
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent The SpecStorage contract (injected dependency — declared-effects
|
|
3
|
-
* principle): everything the studio reads or writes goes through this
|
|
4
|
-
* interface, with paths RELATIVE to the repo root. v1 ships the local
|
|
5
|
-
* filesystem adapter; a v2 github-adapter becomes an addition, not a rework.
|
|
6
|
-
* `message` is the future commit message — the local adapter ignores it
|
|
7
|
-
* (the dev commits by hand in v1).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export interface FileEntry {
|
|
11
|
-
/** File name inside the listed directory (no path separator). */
|
|
12
|
-
name: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface SpecStorage {
|
|
16
|
-
/** Lists the FILES of a directory (relative path); [] when it is absent. */
|
|
17
|
-
list(dir: string): Promise<FileEntry[]>;
|
|
18
|
-
/** Reads a file (relative path) as UTF-8. Rejects when it does not exist. */
|
|
19
|
-
read(path: string): Promise<string>;
|
|
20
|
-
/** Writes a file (relative path); `message` documents the intent (v2: commit). */
|
|
21
|
-
write(path: string, content: string, message: string): Promise<void>;
|
|
22
|
-
}
|
package/src/ui/app.css
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* @intent Proves the manifest view behavior from specs/ogygie-studio.spec.md:
|
|
4
|
-
* features, routes and dependencies displayed READ-ONLY — no write control of
|
|
5
|
-
* any kind exists on this screen.
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
-
import { cleanup, screen, within } from "@testing-library/react";
|
|
9
|
-
import { renderStudio, stubFetch } from "../../test-utils";
|
|
10
|
-
|
|
11
|
-
const MANIFEST_PAYLOAD = {
|
|
12
|
-
manifest: {
|
|
13
|
-
version: 1,
|
|
14
|
-
product: { name: "starter", description: "Squelette de produit Ogygie pour les tests." },
|
|
15
|
-
apps: [{ name: "api", path: "apps/api", type: "api", entryPoint: "apps/api/src/server.ts" }],
|
|
16
|
-
features: [
|
|
17
|
-
{
|
|
18
|
-
name: "notes",
|
|
19
|
-
intent: "Feature d'exemple prouvant chaque rail du framework.",
|
|
20
|
-
folder: "src/features/notes",
|
|
21
|
-
spec: "specs/notes.spec.md",
|
|
22
|
-
routes: [
|
|
23
|
-
{
|
|
24
|
-
method: "POST",
|
|
25
|
-
path: "/api/notes",
|
|
26
|
-
description: "Crée une note dans l'espace actif.",
|
|
27
|
-
schemaInput: "CreateNoteInputSchema",
|
|
28
|
-
schemaOutput: "CreateNoteOutputSchema",
|
|
29
|
-
},
|
|
30
|
-
],
|
|
31
|
-
dbTables: ["notes"],
|
|
32
|
-
dependsOn: ["espaces-et-comptes"],
|
|
33
|
-
status: "delivered",
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: "espaces-et-comptes",
|
|
37
|
-
intent: "Socle SaaS multi-tenant fournissant le WorkspaceContext.",
|
|
38
|
-
folder: "src/features/espaces-et-comptes",
|
|
39
|
-
spec: "specs/espaces-et-comptes.spec.md",
|
|
40
|
-
routes: [],
|
|
41
|
-
dbTables: [],
|
|
42
|
-
dependsOn: [],
|
|
43
|
-
status: "delivered",
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
conventions: { agentsMd: "AGENTS.md", failuresMd: "FAILURES.md", specsFolder: "specs/" },
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
describe("ogygie-studio — vue manifeste (état UI)", () => {
|
|
51
|
-
afterEach(cleanup);
|
|
52
|
-
|
|
53
|
-
it("ÉTANT DONNÉ le manifeste QUAND on ouvre la vue ALORS features, routes et dépendances s'affichent et AUCUNE action d'écriture n'existe", async () => {
|
|
54
|
-
stubFetch([{ path: "/api/manifest", payload: MANIFEST_PAYLOAD }]);
|
|
55
|
-
renderStudio("/manifest");
|
|
56
|
-
|
|
57
|
-
expect(await screen.findByText("notes")).toBeDefined();
|
|
58
|
-
expect(screen.getByText("/api/notes")).toBeDefined();
|
|
59
|
-
// The dependency appears twice: as its own feature card AND as "Dépend de".
|
|
60
|
-
expect(screen.getAllByText("espaces-et-comptes", { exact: false }).length).toBeGreaterThanOrEqual(2);
|
|
61
|
-
|
|
62
|
-
// Read-only: not a single button/input/form on the whole screen.
|
|
63
|
-
const main = screen.getByRole("main");
|
|
64
|
-
expect(within(main).queryAllByRole("button")).toHaveLength(0);
|
|
65
|
-
expect(within(main).queryAllByRole("textbox")).toHaveLength(0);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Read-only projection of ogygie.manifest.json: product, apps,
|
|
3
|
-
* features with routes and dependencies. NO write action exists on this
|
|
4
|
-
* screen — the manifest belongs to feature commits, the studio only reads it.
|
|
5
|
-
*/
|
|
6
|
-
import { useManifest } from "../../lib/queries";
|
|
7
|
-
import { ErrorBanner } from "../../patterns/ErrorBanner";
|
|
8
|
-
|
|
9
|
-
const STATUS_BADGES: Record<string, string> = {
|
|
10
|
-
skeleton: "bg-gray-100 text-gray-600",
|
|
11
|
-
"in-progress": "bg-amber-100 text-amber-800",
|
|
12
|
-
delivered: "bg-emerald-100 text-emerald-800",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export function ManifestView() {
|
|
16
|
-
const { data, isPending, error } = useManifest();
|
|
17
|
-
if (error) return <ErrorBanner error={error} />;
|
|
18
|
-
if (isPending) {
|
|
19
|
-
return <div aria-label="Chargement du manifeste" className="h-96 animate-pulse rounded-lg bg-gray-100" />;
|
|
20
|
-
}
|
|
21
|
-
const { manifest } = data;
|
|
22
|
-
return (
|
|
23
|
-
<div className="space-y-4">
|
|
24
|
-
<header>
|
|
25
|
-
<h1 className="text-lg font-semibold text-gray-900">
|
|
26
|
-
{manifest.product.name}
|
|
27
|
-
<span className="ml-2 align-middle text-xs font-normal text-gray-400">
|
|
28
|
-
manifeste en lecture seule
|
|
29
|
-
</span>
|
|
30
|
-
</h1>
|
|
31
|
-
<p className="mt-1 text-sm text-gray-600">{manifest.product.description}</p>
|
|
32
|
-
</header>
|
|
33
|
-
|
|
34
|
-
<section aria-label="Apps" className="rounded-lg border border-gray-200 bg-white p-4">
|
|
35
|
-
<h2 className="mb-2 text-sm font-semibold text-gray-700">Apps</h2>
|
|
36
|
-
<ul className="space-y-1 text-sm text-gray-700">
|
|
37
|
-
{manifest.apps.map((app) => (
|
|
38
|
-
<li key={app.name} className="flex gap-2">
|
|
39
|
-
<span className="font-mono font-medium">{app.name}</span>
|
|
40
|
-
<span className="text-gray-400">({app.type})</span>
|
|
41
|
-
<span className="font-mono text-xs text-gray-500">{app.path}</span>
|
|
42
|
-
</li>
|
|
43
|
-
))}
|
|
44
|
-
</ul>
|
|
45
|
-
</section>
|
|
46
|
-
|
|
47
|
-
<section aria-label="Features" className="space-y-3">
|
|
48
|
-
{manifest.features.map((feature) => (
|
|
49
|
-
<article key={feature.name} className="rounded-lg border border-gray-200 bg-white p-4">
|
|
50
|
-
<div className="flex items-center justify-between gap-3">
|
|
51
|
-
<h3 className="font-mono text-sm font-semibold text-gray-900">{feature.name}</h3>
|
|
52
|
-
<span
|
|
53
|
-
className={`rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGES[feature.status] ?? ""}`}
|
|
54
|
-
>
|
|
55
|
-
{feature.status}
|
|
56
|
-
</span>
|
|
57
|
-
</div>
|
|
58
|
-
<p className="mt-1 text-sm text-gray-600">{feature.intent}</p>
|
|
59
|
-
{feature.dependsOn.length > 0 && (
|
|
60
|
-
<p className="mt-2 text-xs text-gray-500">
|
|
61
|
-
Dépend de :{" "}
|
|
62
|
-
<span className="font-mono">{feature.dependsOn.join(", ")}</span>
|
|
63
|
-
</p>
|
|
64
|
-
)}
|
|
65
|
-
{feature.routes.length > 0 && (
|
|
66
|
-
<ul className="mt-2 space-y-1 text-xs text-gray-600">
|
|
67
|
-
{feature.routes.map((route) => (
|
|
68
|
-
<li key={`${route.method} ${route.path}`} className="flex gap-2">
|
|
69
|
-
<span className="w-14 shrink-0 font-mono font-semibold">{route.method}</span>
|
|
70
|
-
<span className="font-mono">{route.path}</span>
|
|
71
|
-
</li>
|
|
72
|
-
))}
|
|
73
|
-
</ul>
|
|
74
|
-
)}
|
|
75
|
-
</article>
|
|
76
|
-
))}
|
|
77
|
-
</section>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* @intent Proves the board's UI states from specs/ogygie-studio.spec.md: empty
|
|
4
|
-
* (EmptyState + creation), loading (skeleton), nominal (3 status columns),
|
|
5
|
-
* error (full OgygieError card per unreadable spec).
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
-
import { cleanup, screen, within } from "@testing-library/react";
|
|
9
|
-
import { renderStudio, stubFetch, stubFetchPending } from "../../test-utils";
|
|
10
|
-
|
|
11
|
-
const READABLE = (id: string, status: string) => ({
|
|
12
|
-
id,
|
|
13
|
-
status,
|
|
14
|
-
priority: "P1",
|
|
15
|
-
depends_on: [],
|
|
16
|
-
filePath: `specs/${id}.spec.md`,
|
|
17
|
-
presentSections: ["# @intent"],
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("ogygie-studio — board (états UI)", () => {
|
|
21
|
-
afterEach(cleanup);
|
|
22
|
-
|
|
23
|
-
it("ÉTANT DONNÉ aucune spec QUAND le board s'affiche ALORS EmptyState propose la création depuis le template", async () => {
|
|
24
|
-
stubFetch([{ path: "/api/specs", payload: { specs: [] } }]);
|
|
25
|
-
renderStudio("/");
|
|
26
|
-
expect(await screen.findByText("Aucune spec dans ce repo")).toBeDefined();
|
|
27
|
-
expect(screen.getByRole("button", { name: "Nouvelle spec" })).toBeDefined();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("ÉTANT DONNÉ un chargement en cours QUAND le board s'affiche ALORS le squelette est visible", async () => {
|
|
31
|
-
stubFetchPending();
|
|
32
|
-
renderStudio("/");
|
|
33
|
-
expect(await screen.findByLabelText("Chargement du board")).toBeDefined();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("ÉTANT DONNÉ des specs dans les trois statuts QUAND le board s'affiche ALORS chaque spec est dans sa colonne de statut", async () => {
|
|
37
|
-
stubFetch([
|
|
38
|
-
{
|
|
39
|
-
path: "/api/specs",
|
|
40
|
-
payload: {
|
|
41
|
-
specs: [
|
|
42
|
-
READABLE("alpha", "draft"),
|
|
43
|
-
READABLE("beta", "validated"),
|
|
44
|
-
READABLE("gamma", "implemented"),
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
]);
|
|
49
|
-
renderStudio("/");
|
|
50
|
-
|
|
51
|
-
const draftColumn = await screen.findByLabelText("Colonne Draft");
|
|
52
|
-
expect(within(draftColumn).getByText("alpha")).toBeDefined();
|
|
53
|
-
const validatedColumn = screen.getByLabelText("Colonne Validated");
|
|
54
|
-
expect(within(validatedColumn).getByText("beta")).toBeDefined();
|
|
55
|
-
const implementedColumn = screen.getByLabelText("Colonne Implemented");
|
|
56
|
-
expect(within(implementedColumn).getByText("gamma")).toBeDefined();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("ÉTANT DONNÉ une spec au frontmatter invalide QUAND le board s'affiche ALORS sa carte d'erreur porte l'OgygieError en entier", async () => {
|
|
60
|
-
stubFetch([
|
|
61
|
-
{
|
|
62
|
-
path: "/api/specs",
|
|
63
|
-
payload: {
|
|
64
|
-
specs: [
|
|
65
|
-
READABLE("alpha", "draft"),
|
|
66
|
-
{
|
|
67
|
-
id: "broken",
|
|
68
|
-
filePath: "specs/broken.spec.md",
|
|
69
|
-
error: {
|
|
70
|
-
ogygie: true,
|
|
71
|
-
code: "SPEC_FRONTMATTER_INVALID",
|
|
72
|
-
message: "Spec specs/broken.spec.md has an invalid frontmatter.",
|
|
73
|
-
file: "specs/broken.spec.md",
|
|
74
|
-
cause: "status: nonsense is not a valid status",
|
|
75
|
-
suggestedFix: "Fix the frontmatter in your editor.",
|
|
76
|
-
details: {},
|
|
77
|
-
timestamp: "2026-08-23T00:00:00.000Z",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
]);
|
|
84
|
-
renderStudio("/");
|
|
85
|
-
|
|
86
|
-
expect(await screen.findByText("SPEC_FRONTMATTER_INVALID")).toBeDefined();
|
|
87
|
-
expect(screen.getByText("status: nonsense is not a valid status")).toBeDefined();
|
|
88
|
-
expect(screen.getByText("Fix the frontmatter in your editor.")).toBeDefined();
|
|
89
|
-
// The readable spec still sits in its column next to the error card.
|
|
90
|
-
expect(screen.getByText("alpha")).toBeDefined();
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent The spec board: one kanban column per status (draft / validated /
|
|
3
|
-
* implemented), unreadable specs surfaced as FULL error cards above the board
|
|
4
|
-
* (never repaired, never hidden), and spec creation from the template. Pure
|
|
5
|
-
* composition of patterns over useSpecs — zero business logic.
|
|
6
|
-
*/
|
|
7
|
-
import { useState } from "react";
|
|
8
|
-
import { Link, useNavigate } from "@tanstack/react-router";
|
|
9
|
-
import { StudioApiError } from "../../lib/fetchContract";
|
|
10
|
-
import { useCreateSpec, useSpecs } from "../../lib/queries";
|
|
11
|
-
import { EmptyState } from "../../patterns/EmptyState";
|
|
12
|
-
import { ErrorBanner, OgygieErrorCard } from "../../patterns/ErrorBanner";
|
|
13
|
-
import { Modal } from "../../patterns/Modal";
|
|
14
|
-
import type {
|
|
15
|
-
ReadableSpecSummary,
|
|
16
|
-
SpecStatus,
|
|
17
|
-
SpecSummary,
|
|
18
|
-
UnreadableSpecSummary,
|
|
19
|
-
} from "../../../server/schemas";
|
|
20
|
-
|
|
21
|
-
const COLUMNS: { status: SpecStatus; title: string; accent: string }[] = [
|
|
22
|
-
{ status: "draft", title: "Draft", accent: "border-t-amber-400" },
|
|
23
|
-
{ status: "validated", title: "Validated", accent: "border-t-emerald-500" },
|
|
24
|
-
{ status: "implemented", title: "Implemented", accent: "border-t-sky-500" },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
|
-
function isReadable(summary: SpecSummary): summary is ReadableSpecSummary {
|
|
28
|
-
return !("error" in summary);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function SpecBoard() {
|
|
32
|
-
const { data, isPending, error } = useSpecs();
|
|
33
|
-
if (error) return <ErrorBanner error={error} />;
|
|
34
|
-
if (isPending) return <BoardSkeleton />;
|
|
35
|
-
|
|
36
|
-
const readable = data.specs.filter(isReadable);
|
|
37
|
-
const unreadable = data.specs.filter(
|
|
38
|
-
(summary): summary is UnreadableSpecSummary => !isReadable(summary),
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
if (data.specs.length === 0) {
|
|
42
|
-
return (
|
|
43
|
-
<EmptyState
|
|
44
|
-
title="Aucune spec dans ce repo"
|
|
45
|
-
hint="Créez la première depuis le template canonique — elle naîtra en draft."
|
|
46
|
-
action={<NewSpecButton />}
|
|
47
|
-
/>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div className="space-y-4">
|
|
53
|
-
<div className="flex items-center justify-between">
|
|
54
|
-
<h1 className="text-lg font-semibold text-gray-900">Specs</h1>
|
|
55
|
-
<NewSpecButton />
|
|
56
|
-
</div>
|
|
57
|
-
{unreadable.length > 0 && (
|
|
58
|
-
<section aria-label="Specs illisibles" className="space-y-2">
|
|
59
|
-
{unreadable.map((summary) => (
|
|
60
|
-
<OgygieErrorCard key={summary.filePath} error={summary.error} />
|
|
61
|
-
))}
|
|
62
|
-
</section>
|
|
63
|
-
)}
|
|
64
|
-
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
65
|
-
{COLUMNS.map((column) => (
|
|
66
|
-
<BoardColumn
|
|
67
|
-
key={column.status}
|
|
68
|
-
title={column.title}
|
|
69
|
-
accent={column.accent}
|
|
70
|
-
specs={readable.filter((summary) => summary.status === column.status)}
|
|
71
|
-
/>
|
|
72
|
-
))}
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function BoardColumn({
|
|
79
|
-
title,
|
|
80
|
-
accent,
|
|
81
|
-
specs,
|
|
82
|
-
}: {
|
|
83
|
-
title: string;
|
|
84
|
-
accent: string;
|
|
85
|
-
specs: ReadableSpecSummary[];
|
|
86
|
-
}) {
|
|
87
|
-
return (
|
|
88
|
-
<section
|
|
89
|
-
aria-label={`Colonne ${title}`}
|
|
90
|
-
className={`rounded-lg border border-gray-200 border-t-4 bg-gray-50 p-3 ${accent}`}
|
|
91
|
-
>
|
|
92
|
-
<h2 className="mb-3 flex items-baseline justify-between text-sm font-semibold text-gray-700">
|
|
93
|
-
{title}
|
|
94
|
-
<span className="text-xs font-normal text-gray-400">{specs.length}</span>
|
|
95
|
-
</h2>
|
|
96
|
-
<ul className="space-y-2">
|
|
97
|
-
{specs.map((summary) => (
|
|
98
|
-
<li key={summary.id}>
|
|
99
|
-
<Link
|
|
100
|
-
to="/specs/$specId"
|
|
101
|
-
params={{ specId: summary.id }}
|
|
102
|
-
className="block rounded-md border border-gray-200 bg-white p-3 shadow-sm hover:border-gray-400"
|
|
103
|
-
>
|
|
104
|
-
<p className="font-mono text-sm font-medium text-gray-900">{summary.id}</p>
|
|
105
|
-
<p className="mt-1 text-xs text-gray-500">
|
|
106
|
-
{summary.priority} · {summary.presentSections.length}/6 sections
|
|
107
|
-
{summary.depends_on.length > 0 && ` · dépend de ${summary.depends_on.join(", ")}`}
|
|
108
|
-
</p>
|
|
109
|
-
</Link>
|
|
110
|
-
</li>
|
|
111
|
-
))}
|
|
112
|
-
</ul>
|
|
113
|
-
</section>
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function BoardSkeleton() {
|
|
118
|
-
return (
|
|
119
|
-
<div aria-label="Chargement du board" className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
120
|
-
{[0, 1, 2].map((column) => (
|
|
121
|
-
<div key={column} className="h-64 animate-pulse rounded-lg border border-gray-200 bg-gray-100" />
|
|
122
|
-
))}
|
|
123
|
-
</div>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function NewSpecButton() {
|
|
128
|
-
const [open, setOpen] = useState(false);
|
|
129
|
-
const [specId, setSpecId] = useState("");
|
|
130
|
-
const navigate = useNavigate();
|
|
131
|
-
const creation = useCreateSpec();
|
|
132
|
-
|
|
133
|
-
const submit = () => {
|
|
134
|
-
creation.mutate(
|
|
135
|
-
{ id: specId },
|
|
136
|
-
{
|
|
137
|
-
onSuccess: async (created) => {
|
|
138
|
-
setOpen(false);
|
|
139
|
-
await navigate({ to: "/specs/$specId", params: { specId: created.spec.id } });
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
return (
|
|
146
|
-
<>
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
onClick={() => {
|
|
150
|
-
creation.reset();
|
|
151
|
-
setSpecId("");
|
|
152
|
-
setOpen(true);
|
|
153
|
-
}}
|
|
154
|
-
className="rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
|
155
|
-
>
|
|
156
|
-
Nouvelle spec
|
|
157
|
-
</button>
|
|
158
|
-
<Modal open={open} title="Nouvelle spec depuis le template" onClose={() => setOpen(false)}>
|
|
159
|
-
<form
|
|
160
|
-
onSubmit={(event) => {
|
|
161
|
-
event.preventDefault();
|
|
162
|
-
submit();
|
|
163
|
-
}}
|
|
164
|
-
className="space-y-3"
|
|
165
|
-
>
|
|
166
|
-
<label className="block text-sm text-gray-700">
|
|
167
|
-
Identifiant (kebab-case, il nomme le fichier specs/<id>.spec.md)
|
|
168
|
-
<input
|
|
169
|
-
value={specId}
|
|
170
|
-
onChange={(event) => setSpecId(event.target.value)}
|
|
171
|
-
placeholder="ma-nouvelle-feature"
|
|
172
|
-
className="mt-1 w-full rounded-md border border-gray-300 px-2 py-1.5 font-mono text-sm"
|
|
173
|
-
/>
|
|
174
|
-
</label>
|
|
175
|
-
{creation.error instanceof StudioApiError && (
|
|
176
|
-
<ErrorBanner error={creation.error} />
|
|
177
|
-
)}
|
|
178
|
-
<div className="flex justify-end gap-2">
|
|
179
|
-
<button
|
|
180
|
-
type="button"
|
|
181
|
-
onClick={() => setOpen(false)}
|
|
182
|
-
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700"
|
|
183
|
-
>
|
|
184
|
-
Annuler
|
|
185
|
-
</button>
|
|
186
|
-
<button
|
|
187
|
-
type="submit"
|
|
188
|
-
disabled={creation.isPending || specId === ""}
|
|
189
|
-
className="rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50"
|
|
190
|
-
>
|
|
191
|
-
Créer en draft
|
|
192
|
-
</button>
|
|
193
|
-
</div>
|
|
194
|
-
</form>
|
|
195
|
-
</Modal>
|
|
196
|
-
</>
|
|
197
|
-
);
|
|
198
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @intent Renders the parsed markdown blocks as React elements — never an HTML
|
|
3
|
-
* string (dangerouslySetInnerHTML is banned repo-wide). Minimal on purpose:
|
|
4
|
-
* the preview reassures the writer, the file stays the source of truth.
|
|
5
|
-
*/
|
|
6
|
-
import { parseMarkdownBlocks } from "./markdown-blocks";
|
|
7
|
-
|
|
8
|
-
const HEADING_CLASSES: Record<number, string> = {
|
|
9
|
-
1: "text-base font-bold text-gray-900 mt-4 first:mt-0",
|
|
10
|
-
2: "text-sm font-bold text-gray-900 mt-3",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function MarkdownPreview({ markdown }: { markdown: string }) {
|
|
14
|
-
const blocks = parseMarkdownBlocks(markdown);
|
|
15
|
-
return (
|
|
16
|
-
<div className="space-y-2">
|
|
17
|
-
{blocks.map((block, index) => {
|
|
18
|
-
if (block.kind === "heading") {
|
|
19
|
-
return (
|
|
20
|
-
<p key={index} className={HEADING_CLASSES[Math.min(block.level, 2)]}>
|
|
21
|
-
{block.text}
|
|
22
|
-
</p>
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
if (block.kind === "bullet") {
|
|
26
|
-
return (
|
|
27
|
-
<ul key={index} className="list-disc space-y-1 pl-5 text-sm text-gray-700">
|
|
28
|
-
{block.items.map((item, itemIndex) => (
|
|
29
|
-
<li key={itemIndex}>{item}</li>
|
|
30
|
-
))}
|
|
31
|
-
</ul>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
if (block.kind === "code") {
|
|
35
|
-
return (
|
|
36
|
-
<pre key={index} className="overflow-x-auto rounded bg-gray-100 p-2 font-mono text-xs text-gray-800">
|
|
37
|
-
{block.text}
|
|
38
|
-
</pre>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
return (
|
|
42
|
-
<p key={index} className="text-sm text-gray-700">
|
|
43
|
-
{block.text}
|
|
44
|
-
</p>
|
|
45
|
-
);
|
|
46
|
-
})}
|
|
47
|
-
</div>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* @intent Proves the editor's UI state from specs/ogygie-studio.spec.md:
|
|
4
|
-
* schema-derived frontmatter form + side-by-side markdown preview, and the
|
|
5
|
-
* validation refusal (SPEC_SECTION_MISSING) displayed as the full OgygieError
|
|
6
|
-
* without any status change.
|
|
7
|
-
*/
|
|
8
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
9
|
-
import { cleanup, fireEvent, screen } from "@testing-library/react";
|
|
10
|
-
import { renderStudio, stubFetch } from "../../test-utils";
|
|
11
|
-
|
|
12
|
-
const SPEC_PAYLOAD = {
|
|
13
|
-
frontmatter: {
|
|
14
|
-
id: "alpha",
|
|
15
|
-
status: "draft",
|
|
16
|
-
depends_on: [],
|
|
17
|
-
priority: "P1",
|
|
18
|
-
refs: [],
|
|
19
|
-
deferred: [],
|
|
20
|
-
},
|
|
21
|
-
body: "\n# @intent\nLe pourquoi.\n\n# Comportements\n- ÉTANT DONNÉ x\n",
|
|
22
|
-
presentSections: ["# @intent", "# Comportements"],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
describe("ogygie-studio — éditeur (état UI)", () => {
|
|
26
|
-
afterEach(cleanup);
|
|
27
|
-
|
|
28
|
-
it("ÉTANT DONNÉ une spec en draft QUAND l'éditeur s'ouvre ALORS le formulaire est dérivé du schéma et la preview rend le markdown à côté", async () => {
|
|
29
|
-
stubFetch([{ path: "/api/specs/alpha", payload: SPEC_PAYLOAD }]);
|
|
30
|
-
renderStudio("/specs/alpha");
|
|
31
|
-
|
|
32
|
-
// Schema-derived form: the status enum options come from the schema.
|
|
33
|
-
const statusSelect = await screen.findByRole("combobox", { name: /status/ });
|
|
34
|
-
const options = Array.from(statusSelect.querySelectorAll("option")).map(
|
|
35
|
-
(option) => option.value,
|
|
36
|
-
);
|
|
37
|
-
expect(options).toEqual(["draft", "validated", "implemented"]);
|
|
38
|
-
|
|
39
|
-
// Side-by-side: the textarea holds the raw markdown, the preview renders it.
|
|
40
|
-
const bodyArea = screen.getByRole("textbox", { name: /sections \(markdown\)/ });
|
|
41
|
-
expect(bodyArea).toHaveProperty("value", SPEC_PAYLOAD.body);
|
|
42
|
-
expect(screen.getByText("@intent")).toBeDefined(); // preview heading
|
|
43
|
-
expect(screen.getByText("Le pourquoi.")).toBeDefined();
|
|
44
|
-
expect(screen.getByRole("button", { name: "Valider" })).toBeDefined();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("ÉTANT DONNÉ une section obligatoire manquante QUAND on confirme « Valider » ALORS l'OgygieError SPEC_SECTION_MISSING s'affiche en entier", async () => {
|
|
48
|
-
stubFetch([
|
|
49
|
-
{ path: "/api/specs/alpha", payload: SPEC_PAYLOAD },
|
|
50
|
-
{
|
|
51
|
-
method: "PUT",
|
|
52
|
-
path: "/api/specs/alpha",
|
|
53
|
-
status: 409,
|
|
54
|
-
payload: {
|
|
55
|
-
ogygie: true,
|
|
56
|
-
code: "SPEC_SECTION_MISSING",
|
|
57
|
-
message: "Spec alpha cannot be validated: 4 mandatory section(s) missing.",
|
|
58
|
-
file: "specs/alpha.spec.md",
|
|
59
|
-
cause: "missing or out-of-order: # Contrats de données ; # États UI ; # Non-objectifs ; # Budget",
|
|
60
|
-
suggestedFix: "Write the missing sections (in canonical order) before validating.",
|
|
61
|
-
details: { missingSections: ["# Contrats de données", "# États UI", "# Non-objectifs", "# Budget"] },
|
|
62
|
-
timestamp: "2026-08-23T00:00:00.000Z",
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
]);
|
|
66
|
-
renderStudio("/specs/alpha");
|
|
67
|
-
|
|
68
|
-
fireEvent.click(await screen.findByRole("button", { name: "Valider" }));
|
|
69
|
-
fireEvent.click(await screen.findByRole("button", { name: "Confirmer la validation" }));
|
|
70
|
-
|
|
71
|
-
expect(await screen.findByText("SPEC_SECTION_MISSING")).toBeDefined();
|
|
72
|
-
expect(
|
|
73
|
-
screen.getByText(/# Contrats de données ; # États UI ; # Non-objectifs ; # Budget/),
|
|
74
|
-
).toBeDefined();
|
|
75
|
-
expect(
|
|
76
|
-
screen.getByText("Write the missing sections (in canonical order) before validating."),
|
|
77
|
-
).toBeDefined();
|
|
78
|
-
});
|
|
79
|
-
});
|