@terpjs/contract 0.8.0 → 0.9.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.
package/openapi.json CHANGED
@@ -104,6 +104,14 @@
104
104
  "title": "Id",
105
105
  "type": "string"
106
106
  },
107
+ "permissions": {
108
+ "default": [],
109
+ "items": {
110
+ "type": "string"
111
+ },
112
+ "title": "Permissions",
113
+ "type": "array"
114
+ },
107
115
  "role_name": {
108
116
  "title": "Role Name",
109
117
  "type": "string"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/contract",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
6
6
  "exports": {
package/src/manifest.ts CHANGED
@@ -26,6 +26,20 @@ export interface ModuleRoute {
26
26
  view: string;
27
27
  /** Minimum role required to see the route; omitted = any authenticated user. */
28
28
  role?: RoleName;
29
+ /**
30
+ * Query-string keys this route reads, e.g. `["status", "page"]`.
31
+ *
32
+ * Declared for the same reason params are: the router is realised at runtime, so
33
+ * nothing checks a search key either — and a list screen's filters live in the query
34
+ * string, which is why *most* screens were the ones bypassing the typed navigation
35
+ * seam entirely. `terp routes` emits these into the generated table, so navigating
36
+ * with an undeclared key (or reading one) is a typecheck error.
37
+ *
38
+ * Values are `string | undefined` and nothing more: a query parameter is text, and
39
+ * every key is absent until someone sets it. Parsing `page` into a number is the
40
+ * screen's business — declaring the key is what stops it being a typo.
41
+ */
42
+ search?: string[];
29
43
  }
30
44
 
31
45
  export interface NavItem {
@@ -35,9 +35,10 @@ import ts from "typescript";
35
35
  const HEADER = [
36
36
  "// Generated by `terp routes` from this app's module manifests. Do not edit.",
37
37
  "//",
38
- "// Maps every route path the manifests declare to that route's params, so",
39
- "// useRouteParams / useRouteParam / useTerpNavigate check paths and param names at",
40
- "// compile time (ADR 0092). Regenerate after changing a manifest route — `terp verify`",
38
+ "// Maps every route path the manifests declare to that route's params and its declared",
39
+ "// query-string keys, so useRouteParams / useRouteParam / useRouteSearch /",
40
+ "// useTerpNavigate check paths, param names and search keys at compile time",
41
+ "// (ADR 0092, ADR 0096). Regenerate after changing a manifest route — `terp verify`",
41
42
  "// fails on a stale copy. Routes mounted by a packaged area (the admin area) are not",
42
43
  "// keyed here: this file is a pure function of this app's own manifests.",
43
44
  "",
@@ -57,15 +58,29 @@ export function canonicalPath(routePath) {
57
58
  return routePath.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
58
59
  }
59
60
 
61
+ /** Find a property assignment by name on an object literal, or undefined. */
62
+ function propertyNamed(element, name) {
63
+ return element.properties.find(
64
+ (property) =>
65
+ ts.isPropertyAssignment(property) &&
66
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
67
+ property.name.text === name,
68
+ );
69
+ }
70
+
60
71
  /**
61
- * Every route path a source file's `defineModuleManifest(...)` calls declare.
72
+ * Every route a source file's `defineModuleManifest(...)` calls declare.
62
73
  *
63
- * Returns `{ paths, problems }`. A shape that cannot be read statically lands in
64
- * `problems` (with file:line) rather than being skipped — the caller refuses on any.
74
+ * Returns `{ routes, problems }`, where a route is `{ path, search }`. A shape that
75
+ * cannot be read statically lands in `problems` (with file:line) rather than being
76
+ * skipped — the caller refuses on any. `search` is held to the same standard as `path`:
77
+ * a computed key list is refused rather than silently dropped, because a missing search
78
+ * key turns a real navigation into a type error, which is the failure mode that teaches
79
+ * authors to distrust the check.
65
80
  */
66
- export function extractRoutePaths(sourceText, label) {
81
+ export function extractRoutes(sourceText, label) {
67
82
  const source = ts.createSourceFile(label, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
68
- const paths = [];
83
+ const routes = [];
69
84
  const problems = [];
70
85
  let manifests = 0;
71
86
 
@@ -90,12 +105,7 @@ export function extractRoutePaths(sourceText, label) {
90
105
  );
91
106
  continue;
92
107
  }
93
- const pathProperty = element.properties.find(
94
- (property) =>
95
- ts.isPropertyAssignment(property) &&
96
- (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
97
- property.name.text === "path",
98
- );
108
+ const pathProperty = propertyNamed(element, "path");
99
109
  if (pathProperty === undefined) {
100
110
  problems.push(`${at(element)}: a route declares no \`path\`.`);
101
111
  continue;
@@ -108,8 +118,48 @@ export function extractRoutePaths(sourceText, label) {
108
118
  );
109
119
  continue;
110
120
  }
111
- paths.push(value.text);
121
+ const search = readSearchKeys(element);
122
+ if (search === null) {
123
+ continue;
124
+ }
125
+ routes.push({ path: value.text, search });
126
+ }
127
+ };
128
+
129
+ /** A route's declared query-string keys, `[]` when it declares none, `null` on refusal. */
130
+ const readSearchKeys = (element) => {
131
+ const searchProperty = propertyNamed(element, "search");
132
+ if (searchProperty === undefined) {
133
+ return [];
134
+ }
135
+ const value = searchProperty.initializer;
136
+ if (!ts.isArrayLiteralExpression(value)) {
137
+ problems.push(
138
+ `${at(value)}: a route \`search\` is not an array literal, so its keys cannot be read ` +
139
+ 'statically. Write them inline (e.g. search: ["status", "page"]).',
140
+ );
141
+ return null;
142
+ }
143
+ const keys = [];
144
+ for (const key of value.elements) {
145
+ if (!ts.isStringLiteral(key)) {
146
+ problems.push(
147
+ `${at(key)}: a route \`search\` key is not a plain string literal, so it cannot be ` +
148
+ 'read statically. Write each key inline (e.g. search: ["status", "page"]).',
149
+ );
150
+ return null;
151
+ }
152
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key.text)) {
153
+ problems.push(
154
+ `${at(key)}: search key ${JSON.stringify(key.text)} is not a plain identifier, so it ` +
155
+ "cannot be emitted as a typed property. Rename it to letters, digits and " +
156
+ "underscores.",
157
+ );
158
+ return null;
159
+ }
160
+ keys.push(key.text);
112
161
  }
162
+ return keys;
113
163
  };
114
164
 
115
165
  const visit = (node) => {
@@ -127,16 +177,11 @@ export function extractRoutePaths(sourceText, label) {
127
177
  "cannot be read statically.",
128
178
  );
129
179
  } else {
130
- const routes = argument.properties.find(
131
- (property) =>
132
- ts.isPropertyAssignment(property) &&
133
- (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
134
- property.name.text === "routes",
135
- );
136
- if (routes === undefined) {
180
+ const declared = propertyNamed(argument, "routes");
181
+ if (declared === undefined) {
137
182
  problems.push(`${at(argument)}: the manifest declares no \`routes\`.`);
138
183
  } else {
139
- readRoutesArray(routes.initializer);
184
+ readRoutesArray(declared.initializer);
140
185
  }
141
186
  }
142
187
  }
@@ -150,7 +195,7 @@ export function extractRoutePaths(sourceText, label) {
150
195
  "a module whose manifest is built elsewhere cannot be extracted.",
151
196
  );
152
197
  }
153
- return { paths, problems };
198
+ return { routes, problems };
154
199
  }
155
200
 
156
201
  /** The module files to scan: `<modulesDir>/<name>/module.tsx` (or `.ts`), sorted. */
@@ -174,9 +219,18 @@ export function moduleFiles(modulesDir) {
174
219
  return found;
175
220
  }
176
221
 
177
- /** Render the declaration file for *paths* — deterministic: deduped, sorted, LF. */
178
- export function renderRouteTable(paths) {
179
- const unique = [...new Set(paths.map(canonicalPath))].sort();
222
+ /** Render the declaration file for *routes* — deterministic: deduped, sorted, LF. */
223
+ export function renderRouteTable(routes) {
224
+ // Two manifests may mount the same path; their declared search keys are unioned, which
225
+ // is the honest reading of "this route reads these keys".
226
+ const searchByPath = new Map();
227
+ for (const route of routes) {
228
+ const key = canonicalPath(route.path);
229
+ const merged = searchByPath.get(key) ?? [];
230
+ searchByPath.set(key, [...merged, ...(route.search ?? [])]);
231
+ }
232
+ const unique = [...searchByPath.keys()].sort();
233
+
180
234
  const lines = [...HEADER];
181
235
  for (const routePath of unique) {
182
236
  const params = paramNamesOf(routePath);
@@ -186,7 +240,21 @@ export function renderRouteTable(paths) {
186
240
  : `{ ${params.map((name) => `${name}: string`).join("; ")} }`;
187
241
  lines.push(` ${JSON.stringify(routePath)}: ${shape};`);
188
242
  }
189
- lines.push(" }", "}", "");
243
+ lines.push(" }");
244
+
245
+ // Only emitted when some route declares keys: an app that declares none gets exactly
246
+ // the file it had before, and no empty interface for its own linter to complain about.
247
+ const withSearch = unique.filter((routePath) => searchByPath.get(routePath).length > 0);
248
+ if (withSearch.length > 0) {
249
+ lines.push(" interface TerpRouteSearchTable {");
250
+ for (const routePath of withSearch) {
251
+ const keys = [...new Set(searchByPath.get(routePath))].sort();
252
+ const shape = keys.map((name) => `${name}?: string`).join("; ");
253
+ lines.push(` ${JSON.stringify(routePath)}: { ${shape} };`);
254
+ }
255
+ lines.push(" }");
256
+ }
257
+ lines.push("}", "");
190
258
  return lines.join("\n");
191
259
  }
192
260
 
@@ -202,11 +270,11 @@ export function generateRouteTable(modulesDir) {
202
270
  "src/modules/<name>/module.tsx; pass --modules-dir if this app keeps them elsewhere.",
203
271
  );
204
272
  }
205
- const paths = [];
273
+ const routes = [];
206
274
  const problems = [];
207
275
  for (const file of files) {
208
- const result = extractRoutePaths(fs.readFileSync(file, "utf8"), path.relative(process.cwd(), file).split(path.sep).join("/"));
209
- paths.push(...result.paths);
276
+ const result = extractRoutes(fs.readFileSync(file, "utf8"), path.relative(process.cwd(), file).split(path.sep).join("/"));
277
+ routes.push(...result.routes);
210
278
  problems.push(...result.problems);
211
279
  }
212
280
  if (problems.length > 0) {
@@ -216,7 +284,7 @@ export function generateRouteTable(modulesDir) {
216
284
  problems.map((problem) => ` - ${problem}`).join("\n"),
217
285
  );
218
286
  }
219
- return renderRouteTable(paths);
287
+ return renderRouteTable(routes);
220
288
  }
221
289
 
222
290
  /** Parse `--flag value` / `--flag` argv into a plain object. */
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
8
8
 
9
9
  import {
10
10
  canonicalPath,
11
- extractRoutePaths,
11
+ extractRoutes,
12
12
  generateRouteTable,
13
13
  moduleFiles,
14
14
  paramNamesOf,
@@ -66,17 +66,17 @@ describe("param extraction from a path", () => {
66
66
 
67
67
  describe("extraction from a module manifest", () => {
68
68
  it("reads every route path a manifest declares", () => {
69
- const { paths, problems } = extractRoutePaths(
69
+ const { routes, problems } = extractRoutes(
70
70
  manifest('{ path: "/records", view: "List" }, { path: "/records/:recordId", view: "Detail" }'),
71
71
  "module.tsx",
72
72
  );
73
73
  expect(problems).toEqual([]);
74
- expect(paths).toEqual(["/records", "/records/:recordId"]);
74
+ expect(routes.map((route) => route.path)).toEqual(["/records", "/records/:recordId"]);
75
75
  });
76
76
 
77
77
  it("reads a manifest declared as a property value, not only `export const manifest`", () => {
78
78
  // The packaged admin area's shape: defineModuleManifest(...) inside an object literal.
79
- const { paths, problems } = extractRoutePaths(
79
+ const { routes, problems } = extractRoutes(
80
80
  [
81
81
  'import { defineModuleManifest } from "@terpjs/contract";',
82
82
  "export const adminModule = {",
@@ -86,25 +86,25 @@ describe("extraction from a module manifest", () => {
86
86
  "module.tsx",
87
87
  );
88
88
  expect(problems).toEqual([]);
89
- expect(paths).toEqual(["/admin"]);
89
+ expect(routes.map((route) => route.path)).toEqual(["/admin"]);
90
90
  });
91
91
 
92
92
  it("refuses a path that is not a plain string literal, naming file and line", () => {
93
- const { paths, problems } = extractRoutePaths(
93
+ const { routes, problems } = extractRoutes(
94
94
  manifest("{ path: `/records/${base}`, view: \"List\" }"),
95
95
  "src/modules/records/module.tsx",
96
96
  );
97
- expect(paths).toEqual([]);
97
+ expect(routes.map((route) => route.path)).toEqual([]);
98
98
  expect(problems).toHaveLength(1);
99
99
  expect(problems[0]).toContain("src/modules/records/module.tsx:4");
100
100
  expect(problems[0]).toContain("not a plain string literal");
101
101
  });
102
102
 
103
103
  it("refuses a spread route and a non-literal routes array", () => {
104
- const spread = extractRoutePaths(manifest("...shared"), "module.tsx");
104
+ const spread = extractRoutes(manifest("...shared"), "module.tsx");
105
105
  expect(spread.problems[0]).toContain("not an object literal");
106
106
 
107
- const dynamic = extractRoutePaths(
107
+ const dynamic = extractRoutes(
108
108
  [
109
109
  'import { defineModuleManifest } from "@terpjs/contract";',
110
110
  "export const manifest = defineModuleManifest({ name: \"x\", routes: buildRoutes() });",
@@ -115,20 +115,20 @@ describe("extraction from a module manifest", () => {
115
115
  });
116
116
 
117
117
  it("refuses a module slot with no manifest call at all", () => {
118
- const { problems } = extractRoutePaths("export const views = {};", "module.tsx");
118
+ const { problems } = extractRoutes("export const views = {};", "module.tsx");
119
119
  expect(problems[0]).toContain("no defineModuleManifest");
120
120
  });
121
121
 
122
122
  it("refuses a route with no path", () => {
123
- const { problems } = extractRoutePaths(manifest('{ view: "List" }'), "module.tsx");
123
+ const { problems } = extractRoutes(manifest('{ view: "List" }'), "module.tsx");
124
124
  expect(problems[0]).toContain("declares no `path`");
125
125
  });
126
126
  });
127
127
 
128
128
  describe("rendering the declaration file", () => {
129
129
  it("is deterministic: deduped, sorted, LF-only, one trailing newline", () => {
130
- const first = renderRouteTable(["/b", "/a", "/b"]);
131
- const second = renderRouteTable(["/b", "/b", "/a"]);
130
+ const first = renderRouteTable([{ path: "/b", search: [] }, { path: "/a", search: [] }, { path: "/b", search: [] }]);
131
+ const second = renderRouteTable([{ path: "/b", search: [] }, { path: "/b", search: [] }, { path: "/a", search: [] }]);
132
132
  expect(first).toBe(second);
133
133
  expect(first).not.toContain("\r");
134
134
  expect(first.endsWith("}\n")).toBe(true);
@@ -136,14 +136,14 @@ describe("rendering the declaration file", () => {
136
136
  });
137
137
 
138
138
  it("types a parameterised route's params and leaves a paramless route empty", () => {
139
- const rendered = renderRouteTable(["/records/:recordId", "/records", "/s/:a/i/:b"]);
139
+ const rendered = renderRouteTable([{ path: "/records/:recordId", search: [] }, { path: "/records", search: [] }, { path: "/s/:a/i/:b", search: [] }]);
140
140
  expect(rendered).toContain('"/records": Record<never, never>;');
141
141
  expect(rendered).toContain('"/records/:recordId": { recordId: string };');
142
142
  expect(rendered).toContain('"/s/:a/i/:b": { a: string; b: string };');
143
143
  });
144
144
 
145
145
  it("augments the react-core table so the app's own types pick it up", () => {
146
- const rendered = renderRouteTable(["/"]);
146
+ const rendered = renderRouteTable([{ path: "/", search: [] }]);
147
147
  expect(rendered).toContain('declare module "@terpjs/react-core"');
148
148
  expect(rendered).toContain("interface TerpRouteTable");
149
149
  // A module augmentation only applies from a file that IS a module.
@@ -151,6 +151,83 @@ describe("rendering the declaration file", () => {
151
151
  });
152
152
  });
153
153
 
154
+ describe("declared query-string keys", () => {
155
+ it("reads a route's search keys, and defaults to none", () => {
156
+ const { routes, problems } = extractRoutes(
157
+ manifest(
158
+ '{ path: "/records", view: "List", search: ["status", "page"] }, ' +
159
+ '{ path: "/records/:recordId", view: "Detail" }',
160
+ ),
161
+ "module.tsx",
162
+ );
163
+ expect(problems).toEqual([]);
164
+ expect(routes).toEqual([
165
+ { path: "/records", search: ["status", "page"] },
166
+ { path: "/records/:recordId", search: [] },
167
+ ]);
168
+ });
169
+
170
+ it("refuses keys that cannot be read statically, naming file and line", () => {
171
+ // Held to the same standard as `path`: a key list assembled at runtime would emit a
172
+ // table missing a key, which turns a real navigation into a type error.
173
+ const spread = extractRoutes(
174
+ manifest('{ path: "/r", view: "L", search: [...FILTERS] }'),
175
+ "module.tsx",
176
+ );
177
+ expect(spread.problems.join("\n")).toMatch(/search` key is not a plain string literal/);
178
+
179
+ const dynamic = extractRoutes(
180
+ manifest('{ path: "/r", view: "L", search: FILTERS }'),
181
+ "module.tsx",
182
+ );
183
+ expect(dynamic.problems.join("\n")).toMatch(/search` is not an array literal/);
184
+ expect(dynamic.problems[0]).toMatch(/^module\.tsx:4:/);
185
+ });
186
+
187
+ it("refuses a key that is not a plain identifier, because it cannot be a typed property", () => {
188
+ const { problems } = extractRoutes(
189
+ manifest('{ path: "/r", view: "L", search: ["not-an-identifier"] }'),
190
+ "module.tsx",
191
+ );
192
+ expect(problems.join("\n")).toMatch(/is not a plain identifier/);
193
+ });
194
+
195
+ it("emits a second table keyed only for routes that declare keys", () => {
196
+ const rendered = renderRouteTable([
197
+ { path: "/records", search: ["status"] },
198
+ { path: "/records/:recordId", search: [] },
199
+ ]);
200
+ expect(rendered).toContain(' "/records": Record<never, never>;');
201
+ expect(rendered).toContain(" interface TerpRouteSearchTable {");
202
+ expect(rendered).toContain(' "/records": { status?: string };');
203
+ // The route that declares no keys is absent from the SEARCH table (it is of course
204
+ // present in the params table above), so passing `search` to it stays a typecheck
205
+ // error rather than accepting anything.
206
+ const searchTable = rendered.slice(rendered.indexOf("interface TerpRouteSearchTable"));
207
+ expect(searchTable).not.toContain("/records/:recordId");
208
+ });
209
+
210
+ it("omits the search table entirely when no route declares a key", () => {
211
+ // An app that declares none gets exactly the file it had before route search existed
212
+ // — and no empty interface for its own linter to complain about.
213
+ const rendered = renderRouteTable([{ path: "/records", search: [] }]);
214
+ expect(rendered).not.toContain("TerpRouteSearchTable");
215
+ });
216
+
217
+ it("unions the keys when two manifests mount one path", () => {
218
+ const rendered = renderRouteTable([
219
+ { path: "/shared", search: ["b"] },
220
+ { path: "/shared", search: ["a", "b"] },
221
+ ]);
222
+ expect(rendered).toContain(' "/shared": { a?: string; b?: string };');
223
+ });
224
+
225
+ it("keys the search table by the canonical spelling, like the params table", () => {
226
+ const rendered = renderRouteTable([{ path: "/admin/users/$userId", search: ["q"] }]);
227
+ expect(rendered).toContain(' "/admin/users/:userId": { q?: string };');
228
+ });
229
+ });
230
+
154
231
  describe("generating across an app's module slots", () => {
155
232
  it("merges every slot and dedupes a path two modules claim", () => {
156
233
  const root = appWithModules({
package/src/schema.d.ts CHANGED
@@ -382,6 +382,11 @@ export interface components {
382
382
  * Format: uuid
383
383
  */
384
384
  id: string;
385
+ /**
386
+ * Permissions
387
+ * @default []
388
+ */
389
+ permissions: string[];
385
390
  /** Role Name */
386
391
  role_name: string;
387
392
  /** Role Rank */
@@ -1193,6 +1193,20 @@
1193
1193
  "bg": "--color-neutral-50",
1194
1194
  "layer": "primitive"
1195
1195
  },
1196
+ {
1197
+ "id": "danger-on-card",
1198
+ "label": "danger text on a card",
1199
+ "fg": "--color-status-danger",
1200
+ "bg": "--color-neutral-0",
1201
+ "layer": "primitive"
1202
+ },
1203
+ {
1204
+ "id": "danger-on-canvas",
1205
+ "label": "danger text on the canvas",
1206
+ "fg": "--color-status-danger",
1207
+ "bg": "--color-neutral-50",
1208
+ "layer": "primitive"
1209
+ },
1196
1210
  {
1197
1211
  "id": "primary-button-label",
1198
1212
  "label": "primary button label",
package/token-pairs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Token pairings the framework renders, as data, in two sections held to two different bars. `textPairs` are foreground/background pairs painted as TEXT and held to WCAG 2.1 AA for normal text; `nonTextPairs` are the visual boundaries and state indicators SC 1.4.11 asks 3:1 of — a focus indicator, the border that says which control is active, the outline of a control against its surface. Two consumers read both: tokens.contrast.test.js measures each pairing at its own bar, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. A pair of token names appears in one section only: the text bar is the stricter of the two, so restating a text pairing under a non-text name would add a case that cannot fail unless the stricter one already has, and would overstate how much the ratchets cover. Purely decorative marks stay absent from both sections — WCAG sets no ratio for a divider or for an aria-hidden ornament, and asserting one would teach the next reader to ignore this file. Names are CSS custom properties because that is the vocabulary a theme author writes and a manifest consumer reads.",
2
+ "$comment": "Token pairings the framework renders, as data, in two sections held to two different bars. `textPairs` are foreground/background pairs painted as TEXT and held to WCAG 2.1 AA for normal text; `nonTextPairs` are the visual boundaries and state indicators SC 1.4.11 asks 3:1 of — a focus indicator, the border that says which control is active, the outline of a control against its surface. Two consumers read both: tokens.contrast.test.js measures each pairing at its own bar, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. A pair of token names appears in one section only: the text bar is the stricter of the two, so restating a text pairing under a non-text name would add a case that cannot fail unless the stricter one already has, and would overstate how much the ratchets cover. Purely decorative marks stay absent from both sections — WCAG sets no ratio for a divider or for an aria-hidden ornament, and asserting one would teach the next reader to ignore this file. Names are CSS custom properties because that is the vocabulary a theme author writes and a manifest consumer reads. A pairing earns its place here even when no specimen paints it, and two of them are exactly that: axe measures the pixels a lane renders, so a surface that only appears on a state no specimen can reach - a failed sign-in, a failed create - has no lane coverage at all, and the declared pairing is the only gate it will ever have.",
3
3
  "textPairs": [
4
4
  {
5
5
  "id": "body-on-card",
@@ -29,6 +29,20 @@
29
29
  "bg": "--color-neutral-50",
30
30
  "layer": "primitive"
31
31
  },
32
+ {
33
+ "id": "danger-on-card",
34
+ "label": "danger text on a card",
35
+ "fg": "--color-status-danger",
36
+ "bg": "--color-neutral-0",
37
+ "layer": "primitive"
38
+ },
39
+ {
40
+ "id": "danger-on-canvas",
41
+ "label": "danger text on the canvas",
42
+ "fg": "--color-status-danger",
43
+ "bg": "--color-neutral-50",
44
+ "layer": "primitive"
45
+ },
32
46
  {
33
47
  "id": "primary-button-label",
34
48
  "label": "primary button label",