basis-design 0.0.0-stage → 0.1.0

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 (147) hide show
  1. package/LICENSE +177 -0
  2. package/NOTICE +7 -0
  3. package/README.md +135 -3
  4. package/bin/basis.js +6 -0
  5. package/index.html +16 -0
  6. package/package.json +66 -4
  7. package/preview.html +12 -0
  8. package/server/agent.ts +845 -0
  9. package/server/brief.ts +355 -0
  10. package/server/chatlog.ts +57 -0
  11. package/server/exec.ts +77 -0
  12. package/server/export.ts +180 -0
  13. package/server/guidelines.md +379 -0
  14. package/server/history.ts +107 -0
  15. package/server/index.ts +414 -0
  16. package/server/paths.ts +57 -0
  17. package/server/previews.ts +71 -0
  18. package/server/project.ts +151 -0
  19. package/server/projects.ts +213 -0
  20. package/server/reach.ts +192 -0
  21. package/server/rebase.ts +46 -0
  22. package/server/reviews.ts +156 -0
  23. package/server/setup.ts +219 -0
  24. package/server/snapshots.ts +67 -0
  25. package/server/workspace.ts +423 -0
  26. package/shared/annotations.ts +43 -0
  27. package/shared/contracts.ts +190 -0
  28. package/shared/contrast.ts +77 -0
  29. package/shared/design.ts +556 -0
  30. package/shared/diff.ts +26 -0
  31. package/shared/fixed-colors.ts +91 -0
  32. package/shared/frame.ts +223 -0
  33. package/shared/pattern-contracts.ts +229 -0
  34. package/shared/protocol.ts +226 -0
  35. package/shared/review.ts +145 -0
  36. package/shared/tokens.ts +102 -0
  37. package/src/App.tsx +22 -0
  38. package/src/Workbench.tsx +341 -0
  39. package/src/app.css +103 -0
  40. package/src/bridge.ts +163 -0
  41. package/src/chat/ChatPanel.module.css +759 -0
  42. package/src/chat/ChatPanel.tsx +326 -0
  43. package/src/chat/Composer.tsx +290 -0
  44. package/src/chat/Markdown.tsx +67 -0
  45. package/src/chat/images.ts +46 -0
  46. package/src/icon.svg +1 -0
  47. package/src/main.tsx +10 -0
  48. package/src/preview/Foundations.module.css +346 -0
  49. package/src/preview/Foundations.tsx +389 -0
  50. package/src/preview/FrameBar.tsx +74 -0
  51. package/src/preview/Gallery.module.css +232 -0
  52. package/src/preview/Gallery.tsx +163 -0
  53. package/src/preview/Impact.tsx +217 -0
  54. package/src/preview/Preview.module.css +103 -0
  55. package/src/preview/Preview.tsx +167 -0
  56. package/src/preview/Review.module.css +192 -0
  57. package/src/preview/Review.tsx +456 -0
  58. package/src/preview/SectionBoundary.tsx +70 -0
  59. package/src/preview/a11y.ts +123 -0
  60. package/src/preview/color.ts +53 -0
  61. package/src/preview/ds-module.d.ts +2 -0
  62. package/src/preview/ds.ts +33 -0
  63. package/src/preview/errors.ts +83 -0
  64. package/src/preview/main.tsx +283 -0
  65. package/src/preview/narrow.ts +95 -0
  66. package/src/preview/picker/PickLayer.module.css +90 -0
  67. package/src/preview/picker/PickLayer.tsx +151 -0
  68. package/src/preview/picker/annotate.ts +93 -0
  69. package/src/preview/preview.css +102 -0
  70. package/src/preview/progressive.ts +62 -0
  71. package/src/preview/specimens.tsx +379 -0
  72. package/src/shell/ChatResizer.tsx +69 -0
  73. package/src/shell/CreateDialog.tsx +132 -0
  74. package/src/shell/ExportDialog.tsx +232 -0
  75. package/src/shell/HistoryMenu.tsx +95 -0
  76. package/src/shell/InspectBanner.tsx +147 -0
  77. package/src/shell/PreviewFrame.tsx +101 -0
  78. package/src/shell/ProjectMenu.tsx +364 -0
  79. package/src/shell/ReviewBar.tsx +224 -0
  80. package/src/shell/Shell.module.css +1067 -0
  81. package/src/shell/Sidebar.module.css +211 -0
  82. package/src/shell/Sidebar.tsx +80 -0
  83. package/src/shell/TopBar.tsx +83 -0
  84. package/src/shell/history.ts +64 -0
  85. package/src/shell/navigation.tsx +150 -0
  86. package/src/shell/prefs.ts +81 -0
  87. package/src/shell/useReview.ts +114 -0
  88. package/template/LICENSE +16 -0
  89. package/template/components/Button.module.css +151 -0
  90. package/template/components/Button.specimen.tsx +74 -0
  91. package/template/components/Button.tsx +41 -0
  92. package/template/components/Controls.module.css +174 -0
  93. package/template/components/Controls.specimen.tsx +54 -0
  94. package/template/components/Controls.tsx +58 -0
  95. package/template/components/Display.module.css +251 -0
  96. package/template/components/Display.specimen.tsx +261 -0
  97. package/template/components/Display.tsx +126 -0
  98. package/template/components/Field.module.css +109 -0
  99. package/template/components/Field.specimen.tsx +138 -0
  100. package/template/components/Field.tsx +94 -0
  101. package/template/components/Overlay.module.css +237 -0
  102. package/template/components/Overlay.specimen.tsx +60 -0
  103. package/template/components/Overlay.tsx +69 -0
  104. package/template/components/Tabs.module.css +140 -0
  105. package/template/components/Tabs.specimen.tsx +51 -0
  106. package/template/components/Tabs.tsx +117 -0
  107. package/template/components/contracts.ts +24 -0
  108. package/template/components/index.ts +7 -0
  109. package/template/components/specimen.ts +88 -0
  110. package/template/components/util.ts +6 -0
  111. package/template/contract-check.ts +22 -0
  112. package/template/patterns/AppShell.module.css +36 -0
  113. package/template/patterns/AppShell.specimen.tsx +114 -0
  114. package/template/patterns/AppShell.tsx +25 -0
  115. package/template/patterns/Chat.module.css +437 -0
  116. package/template/patterns/Chat.specimen.tsx +298 -0
  117. package/template/patterns/Chat.tsx +236 -0
  118. package/template/patterns/Composer.module.css +184 -0
  119. package/template/patterns/Composer.specimen.tsx +106 -0
  120. package/template/patterns/Composer.tsx +183 -0
  121. package/template/patterns/DataTable.module.css +190 -0
  122. package/template/patterns/DataTable.specimen.tsx +189 -0
  123. package/template/patterns/DataTable.tsx +241 -0
  124. package/template/patterns/EmptyState.module.css +57 -0
  125. package/template/patterns/EmptyState.specimen.tsx +71 -0
  126. package/template/patterns/EmptyState.tsx +36 -0
  127. package/template/patterns/FormSection.module.css +69 -0
  128. package/template/patterns/FormSection.specimen.tsx +75 -0
  129. package/template/patterns/FormSection.tsx +40 -0
  130. package/template/patterns/Navbar.module.css +265 -0
  131. package/template/patterns/Navbar.specimen.tsx +118 -0
  132. package/template/patterns/Navbar.tsx +124 -0
  133. package/template/patterns/PageHeader.module.css +98 -0
  134. package/template/patterns/PageHeader.specimen.tsx +75 -0
  135. package/template/patterns/PageHeader.tsx +63 -0
  136. package/template/patterns/SidebarNav.module.css +186 -0
  137. package/template/patterns/SidebarNav.specimen.tsx +98 -0
  138. package/template/patterns/SidebarNav.tsx +81 -0
  139. package/template/patterns/StatCard.module.css +81 -0
  140. package/template/patterns/StatCard.specimen.tsx +61 -0
  141. package/template/patterns/StatCard.tsx +83 -0
  142. package/template/patterns/contracts.ts +25 -0
  143. package/template/patterns/icons.tsx +68 -0
  144. package/template/patterns/index.ts +12 -0
  145. package/template/patterns/specimen-data.ts +112 -0
  146. package/template/views/TeamMembers.specimen.tsx +147 -0
  147. package/vite.config.ts +9 -0
@@ -0,0 +1,156 @@
1
+ import { readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ parseReviewRequest,
5
+ REVIEW_FILE,
6
+ type Alternatives,
7
+ type ReviewRequest,
8
+ type ReviewState,
9
+ } from "../shared/review.ts";
10
+ import { git, isCommit, lines } from "./exec.ts";
11
+ import { SOURCE_DIRS } from "./paths.ts";
12
+
13
+ /** Where an open review keeps its iterations: a ref outside branches, so history (main) never shows them. */
14
+ const REVIEW_REF = "refs/basis/review";
15
+ /** The open review's state, kept in .basis/ so it survives restarts. */
16
+ const STATE_FILE = "review.json";
17
+
18
+ /**
19
+ * The open review of a workspace: the request the agent wrote, the commit it
20
+ * started from and its iterations. The methods run git and expect the caller
21
+ * to serialise them with its other git work (see Workspace.serial).
22
+ */
23
+ export class Reviews {
24
+ /** The open review, or null. */
25
+ state: ReviewState | null = null;
26
+ /** Problems with review.json, or null when it is valid or absent. */
27
+ errors: string[] | null = null;
28
+ private readonly stateFile: string;
29
+ /**
30
+ * What the next iteration builds on when it is not the latest one: the
31
+ * starting point of a set of alternatives, after the workspace went back to it.
32
+ */
33
+ private nextParent: string | undefined;
34
+
35
+ constructor(private readonly dir: string) {
36
+ this.stateFile = path.join(dir, ".basis", STATE_FILE);
37
+ }
38
+
39
+ /** Restores the review that was open when Basis last stopped, if its commits still exist. */
40
+ async load(): Promise<void> {
41
+ try {
42
+ const raw = JSON.parse(await readFile(this.stateFile, "utf8")) as ReviewState;
43
+ const valid =
44
+ typeof raw.title === "string" &&
45
+ Array.isArray(raw.show) &&
46
+ Array.isArray(raw.steps) &&
47
+ (await isCommit(this.dir, raw.baseline)) &&
48
+ (await Promise.all(raw.steps.map((step) => isCommit(this.dir, step.id)))).every(Boolean);
49
+ this.state = valid ? { ...raw, files: Array.isArray(raw.files) ? raw.files : [] } : null;
50
+ // Alternatives still being made when Basis stopped are not coming; finished ones still wait for a pick.
51
+ if (this.state?.alternatives && !this.state.alternatives.done)
52
+ this.state = { ...this.state, alternatives: undefined };
53
+ } catch {
54
+ this.state = null;
55
+ }
56
+ }
57
+
58
+ /** Reads the agent's review.json; sets `errors` and returns the request when it is valid. */
59
+ async readRequest(): Promise<ReviewRequest | null> {
60
+ let text: string;
61
+ try {
62
+ text = await readFile(path.join(this.dir, REVIEW_FILE), "utf8");
63
+ } catch {
64
+ // No request (or the agent removed it): an open review stays open until approved or discarded.
65
+ this.errors = null;
66
+ return null;
67
+ }
68
+ const result = parseReviewRequest(text);
69
+ this.errors = result.ok ? null : result.errors;
70
+ return result.ok ? result.request : null;
71
+ }
72
+
73
+ /**
74
+ * Opens a review for the request, or updates what the open one shows.
75
+ * A new review starts from HEAD: during a turn, the state before the turn.
76
+ * Returns whether anything changed.
77
+ */
78
+ async apply(request: ReviewRequest): Promise<boolean> {
79
+ const current = this.state;
80
+ if (current && current.title === request.title && JSON.stringify(current.show) === JSON.stringify(request.show))
81
+ return false;
82
+ const baseline = current?.baseline ?? (await git(this.dir, ["rev-parse", "HEAD"])).trim();
83
+ this.state = current
84
+ ? { ...current, title: request.title, show: request.show }
85
+ : { ...request, baseline, startedAt: Date.now(), steps: [], files: [] };
86
+ await this.save();
87
+ return true;
88
+ }
89
+
90
+ /** The commit the working tree is recorded against: the latest iteration, or what the workspace went back to. */
91
+ get tip(): string | undefined {
92
+ const review = this.state;
93
+ return review ? (this.nextParent ?? review.steps.at(-1)?.id ?? review.baseline) : undefined;
94
+ }
95
+
96
+ /**
97
+ * Records the working tree as the next iteration, if it changed. Returns
98
+ * whether it did. A new iteration after a finished set of alternatives means
99
+ * the user moved on, so the set is put away (its iterations stay).
100
+ */
101
+ async recordStep(label: string): Promise<boolean> {
102
+ const review = this.state;
103
+ const parent = this.tip;
104
+ if (!review || !parent) return false;
105
+ await git(this.dir, ["add", "-A"]);
106
+ const tree = (await git(this.dir, ["write-tree"])).trim();
107
+ if (tree === (await git(this.dir, ["rev-parse", `${parent}^{tree}`])).trim()) return false;
108
+ const id = (await git(this.dir, ["commit-tree", tree, "-p", parent, "-m", `review: ${label.slice(0, 72)}`])).trim();
109
+ this.nextParent = undefined;
110
+ await git(this.dir, ["update-ref", REVIEW_REF, id]);
111
+ const files = lines(await git(this.dir, ["diff", "--name-only", review.baseline, id, "--", ...SOURCE_DIRS]));
112
+ const alternatives = review.alternatives?.done ? undefined : review.alternatives;
113
+ this.state = { ...review, steps: [...review.steps, { id, at: Date.now(), label }], files, alternatives };
114
+ await this.save();
115
+ return true;
116
+ }
117
+
118
+ /** Sets or clears the review's alternatives. */
119
+ async setAlternatives(alternatives: Alternatives | undefined): Promise<void> {
120
+ if (!this.state) return;
121
+ this.state = { ...this.state, alternatives };
122
+ await this.save();
123
+ }
124
+
125
+ /** Records which specimens the review's changes can reach (null: all of them). */
126
+ async setReach(reach: string[] | null): Promise<void> {
127
+ if (!this.state) return;
128
+ this.state = { ...this.state, reach };
129
+ await this.save();
130
+ }
131
+
132
+ /**
133
+ * Puts the working tree back to a commit: the starting point, or one of the
134
+ * iterations. With `asBase`, the next iteration builds on that commit rather
135
+ * than on the latest one, and going back is not an iteration of its own.
136
+ */
137
+ async restore(commit: string, asBase = false): Promise<void> {
138
+ await git(this.dir, ["add", "-A"]);
139
+ await git(this.dir, ["restore", `--source=${commit}`, "--staged", "--worktree", "--", "."]);
140
+ if (asBase) this.nextParent = commit;
141
+ }
142
+
143
+ /** Forgets the open review: its iterations ref, its saved state and the agent's request. */
144
+ async end(): Promise<void> {
145
+ this.state = null;
146
+ this.nextParent = undefined;
147
+ this.errors = null;
148
+ await git(this.dir, ["update-ref", "-d", REVIEW_REF]).catch(() => undefined);
149
+ await rm(this.stateFile, { force: true });
150
+ await rm(path.join(this.dir, REVIEW_FILE), { force: true });
151
+ }
152
+
153
+ private async save(): Promise<void> {
154
+ await writeFile(this.stateFile, `${JSON.stringify(this.state, null, 2)}\n`);
155
+ }
156
+ }
@@ -0,0 +1,219 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { REVIEW_FILE } from "../shared/review.ts";
7
+ import { AGENT_BRIEF, CLAUDE_MD } from "./brief.ts";
8
+ import { COMPONENTS_DIR, PATTERNS_DIR, repoRoot, SOURCE_DIRS, VIEWS_DIR } from "./paths.ts";
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ /*
13
+ * Getting a workspace ready on every start: starter source for new
14
+ * workspaces, what Basis added since for older ones, and the files Basis owns.
15
+ */
16
+
17
+ /**
18
+ * Seeds a new workspace from template/, or brings an older one up to date.
19
+ * Returns a note for the history entry ("Basis added component contracts"),
20
+ * or "" when nothing an existing workspace would notice was added.
21
+ */
22
+ export async function seedWorkspace(dir: string): Promise<string> {
23
+ const components = path.join(dir, COMPONENTS_DIR);
24
+ const notes: string[] = [];
25
+ if (!existsSync(components)) await seed(dir, COMPONENTS_DIR);
26
+ else if (!existsSync(path.join(components, "specimen.ts"))) {
27
+ await seed(dir, COMPONENTS_DIR, (name) => name.includes("specimen"));
28
+ notes.push("gallery specimens");
29
+ }
30
+ if (!existsSync(path.join(components, "contracts.ts"))) {
31
+ await seed(dir, COMPONENTS_DIR, (name) => name === "contracts.ts");
32
+ notes.push("component contracts");
33
+ }
34
+ if (!existsSync(path.join(dir, PATTERNS_DIR))) {
35
+ const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
36
+ await seed(dir, PATTERNS_DIR);
37
+ if (!fresh) notes.push("starter patterns");
38
+ } else {
39
+ if (!existsSync(path.join(dir, PATTERNS_DIR, "contracts.ts"))) {
40
+ await seed(dir, PATTERNS_DIR, (name) => name === "contracts.ts");
41
+ notes.push("pattern contracts");
42
+ }
43
+ const added = await addLaterPatterns(dir);
44
+ if (added.length) notes.push(`the ${added.join(" and ")} pattern${added.length > 1 ? "s" : ""}`);
45
+ }
46
+ if (!existsSync(path.join(dir, VIEWS_DIR))) {
47
+ const fresh = notes.length === 0 && !existsSync(path.join(dir, ".git"));
48
+ await seed(dir, VIEWS_DIR);
49
+ if (!fresh) notes.push("a starter view");
50
+ }
51
+ if (await updateSpecimenFormat(dir)) notes.push("the newest specimen format");
52
+ return notes.length ? `Basis added ${notes.join(" and ")}` : "";
53
+ }
54
+
55
+ /**
56
+ * Starter patterns added to the baseline after workspaces already had patterns/.
57
+ * Older workspaces get their files and an export in patterns/index.ts, since the
58
+ * baseline contract requires them.
59
+ */
60
+ const LATER_PATTERNS = ["Composer", "Chat"];
61
+
62
+ async function addLaterPatterns(dir: string): Promise<string[]> {
63
+ const index = path.join(dir, PATTERNS_DIR, "index.ts");
64
+ const added: string[] = [];
65
+ for (const name of LATER_PATTERNS) {
66
+ if (existsSync(path.join(dir, PATTERNS_DIR, `${name}.tsx`))) continue;
67
+ await seed(dir, PATTERNS_DIR, (file) => file.startsWith(`${name}.`));
68
+ const exports = await readFile(index, "utf8").catch(() => "");
69
+ if (!exports.includes(`"./${name}.tsx"`)) {
70
+ const separator = exports === "" || exports.endsWith("\n") ? "" : "\n";
71
+ await writeFile(index, `${exports}${separator}export * from "./${name}.tsx";\n`);
72
+ }
73
+ added.push(name);
74
+ }
75
+ return added;
76
+ }
77
+
78
+ /**
79
+ * components/specimen.ts is Basis' specimen format, seeded once. When it is an
80
+ * untouched copy of an earlier version, it is replaced with the current one;
81
+ * a copy someone changed is left alone. Add the hash of the current template
82
+ * here whenever the template changes (sha256 of its text with LF line endings).
83
+ */
84
+ const EARLIER_SPECIMEN_FORMATS = new Set([
85
+ "e04c6fc2a3e85090b7672d229d95ca8c5a42009f6397332230d324357b993169",
86
+ "03370daf9bba1c4329d3dac7b76199970cab9c11a6ea2d23e1a484ead80d36d6",
87
+ "1f826f8c8f183e311fd20cca0e5e3c138ac2775c4e156862600c24e9dad7cf7d",
88
+ ]);
89
+
90
+ async function updateSpecimenFormat(dir: string): Promise<boolean> {
91
+ const file = path.join(dir, COMPONENTS_DIR, "specimen.ts");
92
+ const current = (await readFile(file, "utf8").catch(() => "")).replaceAll("\r\n", "\n");
93
+ if (!EARLIER_SPECIMEN_FORMATS.has(createHash("sha256").update(current).digest("hex"))) return false;
94
+ const template = await readFile(path.join(repoRoot, "template", COMPONENTS_DIR, "specimen.ts"), "utf8");
95
+ await writeFile(file, template.replaceAll("\r\n", "\n"));
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * Copies starter files from template/<folder> that are missing, normalising
101
+ * line endings (a Windows checkout of Basis may have CRLF).
102
+ */
103
+ async function seed(dir: string, folder: string, only: (name: string) => boolean = () => true): Promise<void> {
104
+ const source = path.join(repoRoot, "template", folder);
105
+ const destination = path.join(dir, folder);
106
+ await mkdir(destination, { recursive: true });
107
+ for (const name of await readdir(source)) {
108
+ if (!only(name) || existsSync(path.join(destination, name))) continue;
109
+ const text = await readFile(path.join(source, name), "utf8");
110
+ await writeFile(path.join(destination, name), text.replaceAll("\r\n", "\n"));
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The workspace format this Basis writes. Raise it when a change means an older Basis would misread or damage a
116
+ * workspace this one has opened; an older Basis then refuses to open it instead.
117
+ */
118
+ export const WORKSPACE_FORMAT = 1;
119
+
120
+ /** Refuses a workspace a newer Basis has opened, and marks it with this Basis' format otherwise. */
121
+ export async function claimWorkspaceFormat(dir: string): Promise<void> {
122
+ const file = path.join(dir, ".basis", "format.json");
123
+ const saved = existsSync(file) ? Number(JSON.parse(await readFile(file, "utf8")).format) || 0 : 0;
124
+ if (saved > WORKSPACE_FORMAT) {
125
+ throw new Error("a newer version of Basis has opened this project. Update Basis to open it again.");
126
+ }
127
+ await mkdir(path.dirname(file), { recursive: true });
128
+ await writeFile(file, `${JSON.stringify({ format: WORKSPACE_FORMAT })}\n`);
129
+ }
130
+
131
+ /** Files Basis owns in the workspace, rewritten on every start and kept out of history. */
132
+ export async function writeGeneratedFiles(dir: string): Promise<void> {
133
+ const basisDir = path.join(dir, ".basis");
134
+ await mkdir(basisDir, { recursive: true });
135
+ // Resolved, not joined onto Basis' folder: an npm install may hoist them to a node_modules above it.
136
+ const types = (pkg: string) =>
137
+ path
138
+ .dirname(require.resolve(`${pkg}/package.json`))
139
+ .split(path.sep)
140
+ .join("/");
141
+ const tsconfig = {
142
+ compilerOptions: {
143
+ target: "ES2022",
144
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
145
+ module: "ESNext",
146
+ moduleResolution: "bundler",
147
+ jsx: "react-jsx",
148
+ strict: true,
149
+ noEmit: true,
150
+ skipLibCheck: true,
151
+ isolatedModules: true,
152
+ allowImportingTsExtensions: true,
153
+ types: [],
154
+ // Resolve React's types from the Basis install, wherever this workspace lives.
155
+ paths: {
156
+ react: [`${types("@types/react")}/index.d.ts`],
157
+ "react/*": [`${types("@types/react")}/*`],
158
+ "react-dom": [`${types("@types/react-dom")}/index.d.ts`],
159
+ "react-dom/*": [`${types("@types/react-dom")}/*`],
160
+ },
161
+ },
162
+ include: [...SOURCE_DIRS.map((folder) => `${folder}/**/*`), ".basis/*.ts"],
163
+ };
164
+ await writeFile(path.join(dir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
165
+ await writeFile(
166
+ path.join(basisDir, "contracts.ts"),
167
+ await readFile(path.join(repoRoot, "shared", "contracts.ts"), "utf8"),
168
+ );
169
+ await writeFile(
170
+ path.join(basisDir, "pattern-contracts.ts"),
171
+ await readFile(path.join(repoRoot, "shared", "pattern-contracts.ts"), "utf8"),
172
+ );
173
+ await writeFile(
174
+ path.join(basisDir, "guidelines.md"),
175
+ await readFile(path.join(repoRoot, "server", "guidelines.md"), "utf8"),
176
+ );
177
+ await writeFile(
178
+ path.join(basisDir, "env.d.ts"),
179
+ 'declare module "*.module.css" {\n const classes: Record<string, string>;\n export default classes;\n}\n',
180
+ );
181
+ await writeFile(
182
+ path.join(basisDir, "check.ts"),
183
+ [
184
+ "// Fails to type-check when components/ or patterns/ stop satisfying their contracts: the Basis",
185
+ "// baselines (./contracts.ts, ./pattern-contracts.ts) and the contracts this design system added",
186
+ "// (components/contracts.ts, patterns/contracts.ts).",
187
+ 'import type { Contracts } from "./contracts.ts";',
188
+ 'import type { AddedContracts } from "../components/contracts.ts";',
189
+ 'import * as components from "../components/index.ts";',
190
+ 'import type { PatternContracts } from "./pattern-contracts.ts";',
191
+ 'import type { AddedPatternContracts } from "../patterns/contracts.ts";',
192
+ 'import type { IconName } from "../patterns/icons.tsx";',
193
+ 'import * as patterns from "../patterns/index.ts";',
194
+ "",
195
+ "export const satisfiesContracts: Contracts & AddedContracts = components;",
196
+ "export const satisfiesPatternContracts: PatternContracts<IconName> & AddedPatternContracts = patterns;",
197
+ "",
198
+ "// Every export of components/index.ts needs a contract; add missing ones to components/contracts.ts.",
199
+ "type Uncovered = Exclude<keyof typeof components, keyof Contracts | keyof AddedContracts>;",
200
+ "export const everyExportHasAContract: [Uncovered] extends [never] ? true : { missingContractFor: Uncovered } = true;",
201
+ "",
202
+ "// Every export of patterns/index.ts needs a contract; add missing ones to patterns/contracts.ts.",
203
+ "type UncoveredPattern = Exclude<",
204
+ " keyof typeof patterns,",
205
+ " keyof PatternContracts<IconName> | keyof AddedPatternContracts",
206
+ ">;",
207
+ "export const everyPatternHasAContract: [UncoveredPattern] extends [never]",
208
+ " ? true",
209
+ " : { missingContractFor: UncoveredPattern } = true;",
210
+ "",
211
+ ].join("\n"),
212
+ );
213
+ await writeFile(path.join(dir, "AGENTS.md"), AGENT_BRIEF);
214
+ await writeFile(path.join(dir, "CLAUDE.md"), CLAUDE_MD);
215
+ await writeFile(
216
+ path.join(dir, ".gitignore"),
217
+ `# Generated by Basis on every start\n.basis/\nAGENTS.md\nCLAUDE.md\ntsconfig.json\nnode_modules/\n${REVIEW_FILE}\n`,
218
+ );
219
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseDesignText, type Design } from "../shared/design.ts";
5
+ import { git, isCommit } from "./exec.ts";
6
+ import { DESIGN_FILE, SOURCE_DIRS } from "./paths.ts";
7
+
8
+ /**
9
+ * Read-only copies of past versions under .basis/snapshots/<commit>/, which
10
+ * the preview imports so a review's "before" side runs that version's own
11
+ * component code next to today's.
12
+ */
13
+ export class Snapshots {
14
+ private readonly root: string;
15
+ /** Copies being written, so concurrent requests for one version share the work. */
16
+ private pending = new Map<string, Promise<string>>();
17
+
18
+ constructor(private readonly dir: string) {
19
+ this.root = path.join(dir, ".basis", "snapshots");
20
+ }
21
+
22
+ /** A past version: its tokens, and the folder holding a copy of its source. */
23
+ async get(id: string): Promise<{ id: string; design: Design; dir: string } | null> {
24
+ if (!(await isCommit(this.dir, id))) return null;
25
+ try {
26
+ const result = parseDesignText(await git(this.dir, ["show", `${id}:${DESIGN_FILE}`]));
27
+ if (!result.ok) return null;
28
+ return { id, design: result.design, dir: await this.materialise(id) };
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Removes every copy except `keep`'s. */
35
+ async prune(keep: string | undefined): Promise<void> {
36
+ if (!existsSync(this.root)) return;
37
+ for (const name of await readdir(this.root)) {
38
+ if (name !== keep) await rm(path.join(this.root, name), { recursive: true, force: true });
39
+ }
40
+ }
41
+
42
+ /** Writes the source folders of a commit once; later calls reuse the copy. */
43
+ private materialise(id: string): Promise<string> {
44
+ const running = this.pending.get(id);
45
+ if (running) return running;
46
+ const target = path.join(this.root, id);
47
+ const done = path.join(target, ".complete");
48
+ const work = (async () => {
49
+ if (existsSync(done)) return target;
50
+ await rm(target, { recursive: true, force: true });
51
+ const names = (await git(this.dir, ["ls-tree", "-r", "-z", "--name-only", id, "--", ...SOURCE_DIRS]))
52
+ .split("\0")
53
+ .filter(Boolean);
54
+ await Promise.all(
55
+ names.map(async (name) => {
56
+ const file = path.join(target, ...name.split("/"));
57
+ await mkdir(path.dirname(file), { recursive: true });
58
+ await writeFile(file, await git(this.dir, ["show", `${id}:${name}`]));
59
+ }),
60
+ );
61
+ await writeFile(done, "");
62
+ return target;
63
+ })().finally(() => this.pending.delete(id));
64
+ this.pending.set(id, work);
65
+ return work;
66
+ }
67
+ }