@terpjs/contract 0.7.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 +8 -0
- package/package.json +1 -1
- package/scripts/build-tokens.mjs +5 -1
- package/src/manifest.ts +14 -0
- package/src/routes-codegen.js +100 -32
- package/src/routes-codegen.test.js +92 -15
- package/src/schema.d.ts +5 -0
- package/src/tokens.contrast.test.js +164 -13
- package/src/tokens.css +6 -6
- package/src/tokens.manifest.json +176 -12
- package/src/tokens.manifest.test.js +18 -1
- package/token-pairs.json +129 -1
- package/tokens.contrast.json +1 -2
- package/tokens.dark.json +1 -2
- package/tokens.json +19 -2
- package/tokens.midnight.json +1 -2
- package/tokens.twilight.json +1 -2
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.
|
|
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/scripts/build-tokens.mjs
CHANGED
|
@@ -205,6 +205,9 @@ const manifest = {
|
|
|
205
205
|
themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
|
|
206
206
|
})),
|
|
207
207
|
textPairs: pairs.textPairs,
|
|
208
|
+
// Both sections, because a consumer that can only see the text pairings would read the
|
|
209
|
+
// absence of a boundary pairing as "no requirement" rather than "held elsewhere".
|
|
210
|
+
nonTextPairs: pairs.nonTextPairs,
|
|
208
211
|
};
|
|
209
212
|
|
|
210
213
|
writeFileSync(
|
|
@@ -213,5 +216,6 @@ writeFileSync(
|
|
|
213
216
|
);
|
|
214
217
|
console.log(
|
|
215
218
|
`wrote src/tokens.manifest.json (${manifest.tokens.length} tokens, ` +
|
|
216
|
-
`${manifest.themes.length} themes, ${manifest.textPairs.length} pairs
|
|
219
|
+
`${manifest.themes.length} themes, ${manifest.textPairs.length} text pairs, ` +
|
|
220
|
+
`${manifest.nonTextPairs.length} non-text pairs)`,
|
|
217
221
|
);
|
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 {
|
package/src/routes-codegen.js
CHANGED
|
@@ -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
|
|
39
|
-
"// useRouteParams / useRouteParam /
|
|
40
|
-
"//
|
|
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
|
|
72
|
+
* Every route a source file's `defineModuleManifest(...)` calls declare.
|
|
62
73
|
*
|
|
63
|
-
* Returns `{
|
|
64
|
-
* `problems` (with file:line) rather than being
|
|
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
|
|
81
|
+
export function extractRoutes(sourceText, label) {
|
|
67
82
|
const source = ts.createSourceFile(label, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
68
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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(
|
|
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 {
|
|
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 *
|
|
178
|
-
export function renderRouteTable(
|
|
179
|
-
|
|
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
|
|
273
|
+
const routes = [];
|
|
206
274
|
const problems = [];
|
|
207
275
|
for (const file of files) {
|
|
208
|
-
const result =
|
|
209
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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 {
|
|
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(
|
|
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 {
|
|
93
|
+
const { routes, problems } = extractRoutes(
|
|
94
94
|
manifest("{ path: `/records/${base}`, view: \"List\" }"),
|
|
95
95
|
"src/modules/records/module.tsx",
|
|
96
96
|
);
|
|
97
|
-
expect(
|
|
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 =
|
|
104
|
+
const spread = extractRoutes(manifest("...shared"), "module.tsx");
|
|
105
105
|
expect(spread.problems[0]).toContain("not an object literal");
|
|
106
106
|
|
|
107
|
-
const dynamic =
|
|
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 } =
|
|
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 } =
|
|
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
|
@@ -29,6 +29,15 @@ const AA_NORMAL_TEXT = 4.5;
|
|
|
29
29
|
/** WCAG 2.1 AAA, normal-size text — the bar a theme named for contrast has to clear. */
|
|
30
30
|
const AAA_NORMAL_TEXT = 7;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* WCAG 2.1 SC 1.4.11, non-text contrast: the bar for a control's visual boundary and for a
|
|
34
|
+
* state or focus indicator. Flat across every theme, including the one that raises its text
|
|
35
|
+
* floor to AAA, because WCAG defines no AAA tier for non-text contrast — `minimumContrast` in
|
|
36
|
+
* themes.json is a promise about reading, and inventing a stricter non-text bar from it would
|
|
37
|
+
* be this file asserting a standard nobody wrote.
|
|
38
|
+
*/
|
|
39
|
+
const UI_COMPONENT = 3;
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* Pairings the framework renders as text, read from the shared data file.
|
|
34
43
|
*
|
|
@@ -41,7 +50,30 @@ const AAA_NORMAL_TEXT = 7;
|
|
|
41
50
|
* `id` is the stable key — labels intentionally repeat across the primitive and semantic
|
|
42
51
|
* layers ("body text on the canvas" describes both), so only the id can identify a pairing.
|
|
43
52
|
*/
|
|
44
|
-
const
|
|
53
|
+
const PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8"));
|
|
54
|
+
const TEXT_PAIRS = PAIRS.textPairs;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pairings the framework renders as a boundary or an indicator rather than as text, from the
|
|
58
|
+
* same file, held to {@link UI_COMPONENT} instead of AA.
|
|
59
|
+
*
|
|
60
|
+
* The section exists because three measured ratios had nowhere to live and so were recorded as
|
|
61
|
+
* prose in `styles.ts` — the shared focus ring, the border that says which layout toggle is
|
|
62
|
+
* active, and the neutral-300 control outline. A number in a comment is not a gate: the focus
|
|
63
|
+
* ring shipped at 1.67:1 for exactly as long as its value was only ever read by a person.
|
|
64
|
+
*
|
|
65
|
+
* What is deliberately NOT here is as load-bearing as what is. The focus ring's translucent
|
|
66
|
+
* box-shadow halo is excluded: the opaque outline is the indicator SC 1.4.11 measures, and the
|
|
67
|
+
* halo is reinforcement around it — declaring the halo would assert a ratio WCAG does not ask
|
|
68
|
+
* for, which is the same reason dividers are absent from `textPairs`. The active toggle's
|
|
69
|
+
* neutral-100 fill is excluded for the same reason, at 1.10, and it is why that rule carries a
|
|
70
|
+
* border at all rather than a wash. And neither the toggle's border against the toolbar band
|
|
71
|
+
* nor the focus ring on a card is an entry, because both name the same two tokens as the TEXT
|
|
72
|
+
* pairing `accent-on-surface` — measuring one pairing twice under two names would make the
|
|
73
|
+
* ratchet lie about how much is covered, and here the other name is held to a stricter bar.
|
|
74
|
+
* `declares no pairing the text section already holds to a stricter bar` enforces that.
|
|
75
|
+
*/
|
|
76
|
+
const NON_TEXT_PAIRS = PAIRS.nonTextPairs;
|
|
45
77
|
|
|
46
78
|
|
|
47
79
|
/**
|
|
@@ -69,6 +101,41 @@ const TEXT_PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8
|
|
|
69
101
|
*/
|
|
70
102
|
const BELOW_AA = new Map([]);
|
|
71
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Non-text pairings that do not reach 3:1 today, with the ratio measured when they were
|
|
106
|
+
* recorded. Same ratchet contract as {@link BELOW_AA}: a floor may only rise, and a pairing
|
|
107
|
+
* that reaches the bar must leave the table.
|
|
108
|
+
*
|
|
109
|
+
* Every entry is the same defect. `--color-neutral-300` is the control outline — the border on
|
|
110
|
+
* an input, a secondary button, a card, a combobox, a menu, the layout toggles — and against
|
|
111
|
+
* the surfaces those controls sit on it measures 1.42 to 2.36, so a bordered control's edge is
|
|
112
|
+
* effectively invisible to anyone who needs the boundary in order to see the control. That is a
|
|
113
|
+
* genuine SC 1.4.11 failure in four of the five themes, deliberately recorded rather than
|
|
114
|
+
* fixed: the fix is the token value, and moving it repaints every bordered control in the
|
|
115
|
+
* package, which is a decision about how the framework looks and not a side effect of adding a
|
|
116
|
+
* gate. The contrast theme already clears it at 10.37, which is what shows the fix is a value
|
|
117
|
+
* and not a structure.
|
|
118
|
+
*
|
|
119
|
+
* Unlike {@link BELOW_AA} the entries are not confined to the themes that predate the gate, and
|
|
120
|
+
* pretending otherwise would be the dishonest option — every palette inherited the same
|
|
121
|
+
* 300-step boundary, so the defect is one token's value seen five times rather than five
|
|
122
|
+
* independent mistakes. The guard below is therefore different in kind: the allowance may name
|
|
123
|
+
* only the control-boundary pairings. A new pairing cannot be added to it at all.
|
|
124
|
+
*/
|
|
125
|
+
const BELOW_UI = new Map([
|
|
126
|
+
["dark/control-boundary-on-canvas", 2.3559],
|
|
127
|
+
["dark/control-boundary-on-surface", 1.9305],
|
|
128
|
+
["light/control-boundary-on-canvas", 1.419],
|
|
129
|
+
["light/control-boundary-on-surface", 1.4847],
|
|
130
|
+
["midnight/control-boundary-on-canvas", 1.6826],
|
|
131
|
+
["midnight/control-boundary-on-surface", 1.5506],
|
|
132
|
+
["twilight/control-boundary-on-canvas", 1.982],
|
|
133
|
+
["twilight/control-boundary-on-surface", 1.7807],
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/** The only pairings {@link BELOW_UI} is allowed to name. */
|
|
137
|
+
const CONTROL_BOUNDARY_IDS = ["control-boundary-on-canvas", "control-boundary-on-surface"];
|
|
138
|
+
|
|
72
139
|
/** The declarations of the one rule whose selector is exactly `selector`. */
|
|
73
140
|
function declarationsFor(selector) {
|
|
74
141
|
const matches = parseRules(tokensCss).filter((rule) => rule.selector === selector);
|
|
@@ -126,16 +193,26 @@ function contrastRatio(a, b) {
|
|
|
126
193
|
const floorFor = (name) =>
|
|
127
194
|
registry.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
|
|
128
195
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
)
|
|
196
|
+
/**
|
|
197
|
+
* Every pairing in *list*, in every registered theme, tagged with its ratchet key and the
|
|
198
|
+
* ratio it has to reach.
|
|
199
|
+
*
|
|
200
|
+
* Shared by both suites because they differ in exactly one thing — the bar — and writing the
|
|
201
|
+
* fan-out twice is how the two would drift into measuring different theme sets.
|
|
202
|
+
*/
|
|
203
|
+
const casesFor = (list, floorOf) =>
|
|
204
|
+
Object.entries(THEMES).flatMap(([theme, declarations]) =>
|
|
205
|
+
list.map((pair) => ({
|
|
206
|
+
...pair,
|
|
207
|
+
theme,
|
|
208
|
+
declarations,
|
|
209
|
+
key: `${theme}/${pair.id}`,
|
|
210
|
+
floor: floorOf(theme),
|
|
211
|
+
})),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
/** Every text pairing, in every registered theme, tagged with its `BELOW_AA` key. */
|
|
215
|
+
const cases = casesFor(TEXT_PAIRS, floorFor);
|
|
139
216
|
|
|
140
217
|
/** The measured ratio for one case, with the painted values for the failure message. */
|
|
141
218
|
function measure({ fg, bg, declarations }) {
|
|
@@ -152,6 +229,11 @@ function measure({ fg, bg, declarations }) {
|
|
|
152
229
|
const meetsAa = cases.filter(({ key }) => !BELOW_AA.has(key));
|
|
153
230
|
const knownGaps = cases.filter(({ key }) => BELOW_AA.has(key));
|
|
154
231
|
|
|
232
|
+
/** The same three lists for the non-text section. Its bar is flat, so every floor is the same. */
|
|
233
|
+
const uiCases = casesFor(NON_TEXT_PAIRS, () => UI_COMPONENT);
|
|
234
|
+
const meetsUi = uiCases.filter(({ key }) => !BELOW_UI.has(key));
|
|
235
|
+
const uiGaps = uiCases.filter(({ key }) => BELOW_UI.has(key));
|
|
236
|
+
|
|
155
237
|
describe("token sheet text contrast", () => {
|
|
156
238
|
it("measures a known ratio correctly", () => {
|
|
157
239
|
// The calculator itself needs a fixture, or a subtly wrong exponent would move every
|
|
@@ -162,11 +244,14 @@ describe("token sheet text contrast", () => {
|
|
|
162
244
|
expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.5422, 4);
|
|
163
245
|
});
|
|
164
246
|
|
|
165
|
-
it("gives every pairing a unique id", () => {
|
|
247
|
+
it("gives every pairing a unique id, across both sections", () => {
|
|
166
248
|
// The id is the ratchet key and the manifest's handle. A duplicate would silently make
|
|
167
249
|
// one pairing's allowance apply to another, and labels cannot substitute — they repeat
|
|
168
250
|
// across the primitive and semantic layers on purpose.
|
|
169
|
-
|
|
251
|
+
//
|
|
252
|
+
// Both sections at once, because the two ratchets key the same way: `light/x` has to name
|
|
253
|
+
// one pairing whichever table it appears in, or an allowance would apply the wrong bar.
|
|
254
|
+
const ids = [...TEXT_PAIRS, ...NON_TEXT_PAIRS].map((pair) => pair.id);
|
|
170
255
|
expect(ids.filter((id) => !id)).toEqual([]);
|
|
171
256
|
expect(new Set(ids).size).toBe(ids.length);
|
|
172
257
|
});
|
|
@@ -242,3 +327,69 @@ describe("token sheet text contrast", () => {
|
|
|
242
327
|
},
|
|
243
328
|
);
|
|
244
329
|
});
|
|
330
|
+
|
|
331
|
+
describe("token sheet non-text contrast", () => {
|
|
332
|
+
it("declares no pairing the text section already holds to a stricter bar", () => {
|
|
333
|
+
// The guard this section was one review away from needing. `focus-ring-on-surface` shipped
|
|
334
|
+
// here naming --color-fg-accent on --color-bg-surface, which is exactly what the text
|
|
335
|
+
// pairing `accent-on-surface` already holds to 4.5 — so the non-text case could never fail
|
|
336
|
+
// unless the stricter one had failed first, and its only effect was to make the section
|
|
337
|
+
// look like it covered one surface more than it did. The ring on a card is still measured;
|
|
338
|
+
// it is measured by the entry that would go red first.
|
|
339
|
+
//
|
|
340
|
+
// Compared on token NAMES rather than values on purpose: `body-on-card` and
|
|
341
|
+
// `body-on-surface` resolve to identical values in every theme and are both declared,
|
|
342
|
+
// because a theme author retargeting the semantic alias needs the alias measured too. That
|
|
343
|
+
// is the file working as intended; two names for one pair inside one bar is not.
|
|
344
|
+
const textPairKeys = new Set(TEXT_PAIRS.map((pair) => `${pair.fg} on ${pair.bg}`));
|
|
345
|
+
const restated = NON_TEXT_PAIRS.filter((pair) =>
|
|
346
|
+
textPairKeys.has(`${pair.fg} on ${pair.bg}`),
|
|
347
|
+
).map((pair) => pair.id);
|
|
348
|
+
expect(restated).toEqual([]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("covers every registered theme once per pairing", () => {
|
|
352
|
+
// Narrower than its namesake in the text suite on purpose: that one also proves each theme
|
|
353
|
+
// RESOLVED, which is the failure mode that looks like coverage, and it proves it for the
|
|
354
|
+
// shared THEMES map this suite reads. Re-asserting it here would be a second copy of one
|
|
355
|
+
// fact. What is not covered there is the empty-list case — with no pairings the count check
|
|
356
|
+
// would read 0 === 0 and pass — so that is the assertion this one adds.
|
|
357
|
+
expect(NON_TEXT_PAIRS.length).toBeGreaterThan(0);
|
|
358
|
+
expect(uiCases).toHaveLength(registry.themes.length * NON_TEXT_PAIRS.length);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("holds every pairing in exactly one of the two sets", () => {
|
|
362
|
+
expect(meetsUi.length + uiGaps.length).toBe(uiCases.length);
|
|
363
|
+
expect(uiGaps).toHaveLength(BELOW_UI.size);
|
|
364
|
+
const known = new Set(uiCases.map(({ key }) => key));
|
|
365
|
+
expect([...BELOW_UI.keys()].filter((key) => !known.has(key))).toEqual([]);
|
|
366
|
+
expect([...BELOW_UI.keys()]).toEqual([...BELOW_UI.keys()].sort());
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("lets the allowance name the control boundary and nothing else", () => {
|
|
370
|
+
// The one guard that keeps this from becoming a general amnesty. BELOW_AA restricts its
|
|
371
|
+
// allowance by THEME, which works there because a new theme has no excuse to ship below AA.
|
|
372
|
+
// That reasoning does not transfer: this defect is one token value that every palette
|
|
373
|
+
// inherited, so it shows up in themes that postdate the gate through no fault of their own.
|
|
374
|
+
// Restricting by PAIRING instead says the same thing the theme rule says — no new debt —
|
|
375
|
+
// without pretending the existing debt is older than it is.
|
|
376
|
+
const idOf = (key) => key.slice(key.indexOf("/") + 1);
|
|
377
|
+
expect([...BELOW_UI.keys()].filter((key) => !CONTROL_BOUNDARY_IDS.includes(idOf(key)))).toEqual(
|
|
378
|
+
[],
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it.each(meetsUi)("$theme: $id ($label) reaches $floor:1 as a non-text pairing", (testCase) => {
|
|
383
|
+
const { ratio, painted } = measure(testCase);
|
|
384
|
+
expect(ratio, painted).toBeGreaterThanOrEqual(testCase.floor);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it.each(uiGaps)("$theme: $id ($label) is a known non-text gap, held at its floor", (testCase) => {
|
|
388
|
+
const { ratio, painted } = measure(testCase);
|
|
389
|
+
const floor = BELOW_UI.get(testCase.key);
|
|
390
|
+
expect(ratio, `${painted} regressed below its recorded floor`).toBeGreaterThanOrEqual(floor);
|
|
391
|
+
expect(ratio, `${painted} now reaches 3:1 — remove it from BELOW_UI`).toBeLessThan(
|
|
392
|
+
UI_COMPONENT,
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
});
|
package/src/tokens.css
CHANGED
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
--color-fg-muted: #475569;
|
|
40
40
|
--color-fg-subtle: #64748b;
|
|
41
41
|
--color-fg-accent: #1d4ed8;
|
|
42
|
-
--color-fg-on-brand: #ffffff;
|
|
43
42
|
--color-border-default: #e2e8f0;
|
|
44
43
|
--color-border-strong: #cbd5e1;
|
|
45
44
|
--color-border-subtle: #f1f5f9;
|
|
@@ -72,6 +71,12 @@
|
|
|
72
71
|
--space-12: 3rem;
|
|
73
72
|
--space-16: 4rem;
|
|
74
73
|
--space-20: 5rem;
|
|
74
|
+
--density-control-min-height: 2.25rem;
|
|
75
|
+
--density-cell-pad-y: 0.75rem;
|
|
76
|
+
--density-cell-pad-x: 0.75rem;
|
|
77
|
+
--density-compact-control-min-height: 2rem;
|
|
78
|
+
--density-compact-cell-pad-y: 0.5rem;
|
|
79
|
+
--density-compact-cell-pad-x: 0.5rem;
|
|
75
80
|
--radius-sm: 0.25rem;
|
|
76
81
|
--radius-md: 0.5rem;
|
|
77
82
|
--radius-lg: 0.75rem;
|
|
@@ -157,7 +162,6 @@
|
|
|
157
162
|
--color-fg-muted: #b4c0d0;
|
|
158
163
|
--color-fg-subtle: #94a3b8;
|
|
159
164
|
--color-fg-accent: #60a5fa;
|
|
160
|
-
--color-fg-on-brand: #ffffff;
|
|
161
165
|
--color-border-default: #334155;
|
|
162
166
|
--color-border-strong: #475569;
|
|
163
167
|
--color-border-subtle: #263449;
|
|
@@ -212,7 +216,6 @@
|
|
|
212
216
|
--color-fg-muted: #9aa4b2;
|
|
213
217
|
--color-fg-subtle: #8b949e;
|
|
214
218
|
--color-fg-accent: #58a6ff;
|
|
215
|
-
--color-fg-on-brand: #ffffff;
|
|
216
219
|
--color-border-default: #30363d;
|
|
217
220
|
--color-border-strong: #484f58;
|
|
218
221
|
--color-border-subtle: #21262d;
|
|
@@ -267,7 +270,6 @@
|
|
|
267
270
|
--color-fg-muted: #b9aecd;
|
|
268
271
|
--color-fg-subtle: #a294bd;
|
|
269
272
|
--color-fg-accent: #a78bfa;
|
|
270
|
-
--color-fg-on-brand: #ffffff;
|
|
271
273
|
--color-border-default: #3a3350;
|
|
272
274
|
--color-border-strong: #4d4468;
|
|
273
275
|
--color-border-subtle: #2d2740;
|
|
@@ -322,7 +324,6 @@
|
|
|
322
324
|
--color-fg-muted: #141414;
|
|
323
325
|
--color-fg-subtle: #1f1f1f;
|
|
324
326
|
--color-fg-accent: #0842a0;
|
|
325
|
-
--color-fg-on-brand: #ffffff;
|
|
326
327
|
--color-border-default: #595959;
|
|
327
328
|
--color-border-strong: #000000;
|
|
328
329
|
--color-border-subtle: #767676;
|
|
@@ -377,7 +378,6 @@
|
|
|
377
378
|
--color-fg-muted: #b4c0d0;
|
|
378
379
|
--color-fg-subtle: #94a3b8;
|
|
379
380
|
--color-fg-accent: #60a5fa;
|
|
380
|
-
--color-fg-on-brand: #ffffff;
|
|
381
381
|
--color-border-default: #334155;
|
|
382
382
|
--color-border-strong: #475569;
|
|
383
383
|
--color-border-subtle: #263449;
|
package/src/tokens.manifest.json
CHANGED
|
@@ -419,18 +419,6 @@
|
|
|
419
419
|
},
|
|
420
420
|
"themeable": true
|
|
421
421
|
},
|
|
422
|
-
{
|
|
423
|
-
"name": "--color-fg-on-brand",
|
|
424
|
-
"category": "color",
|
|
425
|
-
"values": {
|
|
426
|
-
"light": "#ffffff",
|
|
427
|
-
"dark": "#ffffff",
|
|
428
|
-
"midnight": "#ffffff",
|
|
429
|
-
"twilight": "#ffffff",
|
|
430
|
-
"contrast": "#ffffff"
|
|
431
|
-
},
|
|
432
|
-
"themeable": true
|
|
433
|
-
},
|
|
434
422
|
{
|
|
435
423
|
"name": "--color-border-default",
|
|
436
424
|
"category": "color",
|
|
@@ -751,6 +739,54 @@
|
|
|
751
739
|
},
|
|
752
740
|
"themeable": false
|
|
753
741
|
},
|
|
742
|
+
{
|
|
743
|
+
"name": "--density-control-min-height",
|
|
744
|
+
"category": "density",
|
|
745
|
+
"values": {
|
|
746
|
+
"light": "2.25rem"
|
|
747
|
+
},
|
|
748
|
+
"themeable": false
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
"name": "--density-cell-pad-y",
|
|
752
|
+
"category": "density",
|
|
753
|
+
"values": {
|
|
754
|
+
"light": "0.75rem"
|
|
755
|
+
},
|
|
756
|
+
"themeable": false
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
"name": "--density-cell-pad-x",
|
|
760
|
+
"category": "density",
|
|
761
|
+
"values": {
|
|
762
|
+
"light": "0.75rem"
|
|
763
|
+
},
|
|
764
|
+
"themeable": false
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
"name": "--density-compact-control-min-height",
|
|
768
|
+
"category": "density",
|
|
769
|
+
"values": {
|
|
770
|
+
"light": "2rem"
|
|
771
|
+
},
|
|
772
|
+
"themeable": false
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
"name": "--density-compact-cell-pad-y",
|
|
776
|
+
"category": "density",
|
|
777
|
+
"values": {
|
|
778
|
+
"light": "0.5rem"
|
|
779
|
+
},
|
|
780
|
+
"themeable": false
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
"name": "--density-compact-cell-pad-x",
|
|
784
|
+
"category": "density",
|
|
785
|
+
"values": {
|
|
786
|
+
"light": "0.5rem"
|
|
787
|
+
},
|
|
788
|
+
"themeable": false
|
|
789
|
+
},
|
|
754
790
|
{
|
|
755
791
|
"name": "--radius-sm",
|
|
756
792
|
"category": "radius",
|
|
@@ -1157,6 +1193,20 @@
|
|
|
1157
1193
|
"bg": "--color-neutral-50",
|
|
1158
1194
|
"layer": "primitive"
|
|
1159
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
|
+
},
|
|
1160
1210
|
{
|
|
1161
1211
|
"id": "primary-button-label",
|
|
1162
1212
|
"label": "primary button label",
|
|
@@ -1248,6 +1298,13 @@
|
|
|
1248
1298
|
"bg": "--color-brand-primary-soft",
|
|
1249
1299
|
"layer": "semantic"
|
|
1250
1300
|
},
|
|
1301
|
+
{
|
|
1302
|
+
"id": "muted-on-soft",
|
|
1303
|
+
"label": "muted text on the accent wash",
|
|
1304
|
+
"fg": "--color-fg-muted",
|
|
1305
|
+
"bg": "--color-brand-primary-soft",
|
|
1306
|
+
"layer": "semantic"
|
|
1307
|
+
},
|
|
1251
1308
|
{
|
|
1252
1309
|
"id": "sidebar-text",
|
|
1253
1310
|
"label": "sidebar text",
|
|
@@ -1261,6 +1318,113 @@
|
|
|
1261
1318
|
"fg": "--color-sidebar-muted",
|
|
1262
1319
|
"bg": "--color-sidebar-bg",
|
|
1263
1320
|
"layer": "semantic"
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
"id": "muted-on-tone-neutral",
|
|
1324
|
+
"label": "muted text on a neutral-toned row or card",
|
|
1325
|
+
"fg": "--color-fg-muted",
|
|
1326
|
+
"bg": "--color-neutral-100",
|
|
1327
|
+
"layer": "semantic"
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
"id": "muted-on-tone-info",
|
|
1331
|
+
"label": "muted text on an info-toned row or card",
|
|
1332
|
+
"fg": "--color-fg-muted",
|
|
1333
|
+
"bg": "--color-status-info-soft",
|
|
1334
|
+
"layer": "semantic"
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
"id": "muted-on-tone-success",
|
|
1338
|
+
"label": "muted text on a success-toned row or card",
|
|
1339
|
+
"fg": "--color-fg-muted",
|
|
1340
|
+
"bg": "--color-status-success-soft",
|
|
1341
|
+
"layer": "semantic"
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
"id": "muted-on-tone-warning",
|
|
1345
|
+
"label": "muted text on a warning-toned row or card",
|
|
1346
|
+
"fg": "--color-fg-muted",
|
|
1347
|
+
"bg": "--color-status-warning-soft",
|
|
1348
|
+
"layer": "semantic"
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
"id": "muted-on-tone-danger",
|
|
1352
|
+
"label": "muted text on a danger-toned row or card",
|
|
1353
|
+
"fg": "--color-fg-muted",
|
|
1354
|
+
"bg": "--color-status-danger-soft",
|
|
1355
|
+
"layer": "semantic"
|
|
1356
|
+
}
|
|
1357
|
+
],
|
|
1358
|
+
"nonTextPairs": [
|
|
1359
|
+
{
|
|
1360
|
+
"id": "focus-ring-on-canvas",
|
|
1361
|
+
"label": "the focus indicator on the canvas",
|
|
1362
|
+
"fg": "--color-fg-accent",
|
|
1363
|
+
"bg": "--color-bg-canvas",
|
|
1364
|
+
"layer": "semantic"
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
"id": "active-toggle-border",
|
|
1368
|
+
"label": "the border marking which layout toggle is active",
|
|
1369
|
+
"fg": "--color-fg-accent",
|
|
1370
|
+
"bg": "--color-neutral-100",
|
|
1371
|
+
"layer": "semantic"
|
|
1372
|
+
},
|
|
1373
|
+
{
|
|
1374
|
+
"id": "subtle-glyph-on-tone-neutral",
|
|
1375
|
+
"label": "an icon button's glyph on a neutral wash",
|
|
1376
|
+
"fg": "--color-fg-subtle",
|
|
1377
|
+
"bg": "--color-neutral-100",
|
|
1378
|
+
"layer": "semantic"
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
"id": "subtle-glyph-on-tone-info",
|
|
1382
|
+
"label": "an icon button's glyph on an info row",
|
|
1383
|
+
"fg": "--color-fg-subtle",
|
|
1384
|
+
"bg": "--color-status-info-soft",
|
|
1385
|
+
"layer": "semantic"
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
"id": "subtle-glyph-on-tone-success",
|
|
1389
|
+
"label": "an icon button's glyph on a success row",
|
|
1390
|
+
"fg": "--color-fg-subtle",
|
|
1391
|
+
"bg": "--color-status-success-soft",
|
|
1392
|
+
"layer": "semantic"
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
"id": "subtle-glyph-on-tone-warning",
|
|
1396
|
+
"label": "an icon button's glyph on a warning row",
|
|
1397
|
+
"fg": "--color-fg-subtle",
|
|
1398
|
+
"bg": "--color-status-warning-soft",
|
|
1399
|
+
"layer": "semantic"
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
"id": "subtle-glyph-on-tone-danger",
|
|
1403
|
+
"label": "an icon button's glyph on a danger row",
|
|
1404
|
+
"fg": "--color-fg-subtle",
|
|
1405
|
+
"bg": "--color-status-danger-soft",
|
|
1406
|
+
"layer": "semantic"
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
"id": "subtle-glyph-on-focus-wash",
|
|
1410
|
+
"label": "an icon button's glyph on a focused clickable row",
|
|
1411
|
+
"fg": "--color-fg-subtle",
|
|
1412
|
+
"bg": "--color-brand-primary-soft",
|
|
1413
|
+
"layer": "semantic"
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
"id": "control-boundary-on-surface",
|
|
1417
|
+
"label": "a control's outline on a card",
|
|
1418
|
+
"fg": "--color-neutral-300",
|
|
1419
|
+
"bg": "--color-neutral-0",
|
|
1420
|
+
"layer": "primitive"
|
|
1421
|
+
},
|
|
1422
|
+
{
|
|
1423
|
+
"id": "control-boundary-on-canvas",
|
|
1424
|
+
"label": "a control's outline on the canvas",
|
|
1425
|
+
"fg": "--color-neutral-300",
|
|
1426
|
+
"bg": "--color-neutral-50",
|
|
1427
|
+
"layer": "primitive"
|
|
1264
1428
|
}
|
|
1265
1429
|
]
|
|
1266
1430
|
}
|
|
@@ -132,10 +132,27 @@ describe("token manifest", () => {
|
|
|
132
132
|
// The manifest is a claim about what is guaranteed; the gate is what guarantees it. If
|
|
133
133
|
// the two lists could differ, the published claim would be unverified.
|
|
134
134
|
expect(manifest.textPairs).toEqual(pairsSource.textPairs);
|
|
135
|
+
expect(manifest.nonTextPairs).toEqual(pairsSource.nonTextPairs);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("publishes both sections, so a missing one cannot read as no requirement", () => {
|
|
139
|
+
// `nonTextPairs` reached the manifest by being added to the builder's literal, which is a
|
|
140
|
+
// line that can be deleted without any other test noticing: a consumer would then see only
|
|
141
|
+
// the text pairings and read the absence of a boundary pairing as "nothing is required
|
|
142
|
+
// here" rather than "held in a section you were not given". Both sections are named
|
|
143
|
+
// explicitly rather than derived, because deriving them from the source file is what the
|
|
144
|
+
// assertion above already does — this one is about the shape the package publishes.
|
|
145
|
+
expect(Array.isArray(manifest.textPairs)).toBe(true);
|
|
146
|
+
expect(Array.isArray(manifest.nonTextPairs)).toBe(true);
|
|
147
|
+
expect(manifest.nonTextPairs.length).toBeGreaterThan(0);
|
|
135
148
|
});
|
|
136
149
|
|
|
137
150
|
it("references only tokens that exist, in both directions of every pairing", () => {
|
|
138
|
-
|
|
151
|
+
// Both sections. A typo in a token name is the failure this catches, and it is the only
|
|
152
|
+
// check that catches it for a pairing naming a token the sheet declares nowhere — the
|
|
153
|
+
// contrast gate would report it as an undefined declaration, which reads as a sheet
|
|
154
|
+
// problem rather than as a pairing problem.
|
|
155
|
+
for (const pair of [...manifest.textPairs, ...manifest.nonTextPairs]) {
|
|
139
156
|
expect(tokenByName.has(pair.fg), `${pair.id} fg ${pair.fg}`).toBe(true);
|
|
140
157
|
expect(tokenByName.has(pair.bg), `${pair.id} bg ${pair.bg}`).toBe(true);
|
|
141
158
|
}
|
package/token-pairs.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "
|
|
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",
|
|
@@ -120,6 +134,13 @@
|
|
|
120
134
|
"bg": "--color-brand-primary-soft",
|
|
121
135
|
"layer": "semantic"
|
|
122
136
|
},
|
|
137
|
+
{
|
|
138
|
+
"id": "muted-on-soft",
|
|
139
|
+
"label": "muted text on the accent wash",
|
|
140
|
+
"fg": "--color-fg-muted",
|
|
141
|
+
"bg": "--color-brand-primary-soft",
|
|
142
|
+
"layer": "semantic"
|
|
143
|
+
},
|
|
123
144
|
{
|
|
124
145
|
"id": "sidebar-text",
|
|
125
146
|
"label": "sidebar text",
|
|
@@ -133,6 +154,113 @@
|
|
|
133
154
|
"fg": "--color-sidebar-muted",
|
|
134
155
|
"bg": "--color-sidebar-bg",
|
|
135
156
|
"layer": "semantic"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"id": "muted-on-tone-neutral",
|
|
160
|
+
"label": "muted text on a neutral-toned row or card",
|
|
161
|
+
"fg": "--color-fg-muted",
|
|
162
|
+
"bg": "--color-neutral-100",
|
|
163
|
+
"layer": "semantic"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"id": "muted-on-tone-info",
|
|
167
|
+
"label": "muted text on an info-toned row or card",
|
|
168
|
+
"fg": "--color-fg-muted",
|
|
169
|
+
"bg": "--color-status-info-soft",
|
|
170
|
+
"layer": "semantic"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "muted-on-tone-success",
|
|
174
|
+
"label": "muted text on a success-toned row or card",
|
|
175
|
+
"fg": "--color-fg-muted",
|
|
176
|
+
"bg": "--color-status-success-soft",
|
|
177
|
+
"layer": "semantic"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"id": "muted-on-tone-warning",
|
|
181
|
+
"label": "muted text on a warning-toned row or card",
|
|
182
|
+
"fg": "--color-fg-muted",
|
|
183
|
+
"bg": "--color-status-warning-soft",
|
|
184
|
+
"layer": "semantic"
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"id": "muted-on-tone-danger",
|
|
188
|
+
"label": "muted text on a danger-toned row or card",
|
|
189
|
+
"fg": "--color-fg-muted",
|
|
190
|
+
"bg": "--color-status-danger-soft",
|
|
191
|
+
"layer": "semantic"
|
|
192
|
+
}
|
|
193
|
+
],
|
|
194
|
+
"nonTextPairs": [
|
|
195
|
+
{
|
|
196
|
+
"id": "focus-ring-on-canvas",
|
|
197
|
+
"label": "the focus indicator on the canvas",
|
|
198
|
+
"fg": "--color-fg-accent",
|
|
199
|
+
"bg": "--color-bg-canvas",
|
|
200
|
+
"layer": "semantic"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"id": "active-toggle-border",
|
|
204
|
+
"label": "the border marking which layout toggle is active",
|
|
205
|
+
"fg": "--color-fg-accent",
|
|
206
|
+
"bg": "--color-neutral-100",
|
|
207
|
+
"layer": "semantic"
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"id": "subtle-glyph-on-tone-neutral",
|
|
211
|
+
"label": "an icon button's glyph on a neutral wash",
|
|
212
|
+
"fg": "--color-fg-subtle",
|
|
213
|
+
"bg": "--color-neutral-100",
|
|
214
|
+
"layer": "semantic"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"id": "subtle-glyph-on-tone-info",
|
|
218
|
+
"label": "an icon button's glyph on an info row",
|
|
219
|
+
"fg": "--color-fg-subtle",
|
|
220
|
+
"bg": "--color-status-info-soft",
|
|
221
|
+
"layer": "semantic"
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
"id": "subtle-glyph-on-tone-success",
|
|
225
|
+
"label": "an icon button's glyph on a success row",
|
|
226
|
+
"fg": "--color-fg-subtle",
|
|
227
|
+
"bg": "--color-status-success-soft",
|
|
228
|
+
"layer": "semantic"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"id": "subtle-glyph-on-tone-warning",
|
|
232
|
+
"label": "an icon button's glyph on a warning row",
|
|
233
|
+
"fg": "--color-fg-subtle",
|
|
234
|
+
"bg": "--color-status-warning-soft",
|
|
235
|
+
"layer": "semantic"
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"id": "subtle-glyph-on-tone-danger",
|
|
239
|
+
"label": "an icon button's glyph on a danger row",
|
|
240
|
+
"fg": "--color-fg-subtle",
|
|
241
|
+
"bg": "--color-status-danger-soft",
|
|
242
|
+
"layer": "semantic"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"id": "subtle-glyph-on-focus-wash",
|
|
246
|
+
"label": "an icon button's glyph on a focused clickable row",
|
|
247
|
+
"fg": "--color-fg-subtle",
|
|
248
|
+
"bg": "--color-brand-primary-soft",
|
|
249
|
+
"layer": "semantic"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"id": "control-boundary-on-surface",
|
|
253
|
+
"label": "a control's outline on a card",
|
|
254
|
+
"fg": "--color-neutral-300",
|
|
255
|
+
"bg": "--color-neutral-0",
|
|
256
|
+
"layer": "primitive"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
"id": "control-boundary-on-canvas",
|
|
260
|
+
"label": "a control's outline on the canvas",
|
|
261
|
+
"fg": "--color-neutral-300",
|
|
262
|
+
"bg": "--color-neutral-50",
|
|
263
|
+
"layer": "primitive"
|
|
136
264
|
}
|
|
137
265
|
]
|
|
138
266
|
}
|
package/tokens.contrast.json
CHANGED
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"default": { "value": "#000000" },
|
|
41
41
|
"muted": { "value": "#141414" },
|
|
42
42
|
"subtle": { "value": "#1f1f1f" },
|
|
43
|
-
"accent": { "value": "#0842a0" }
|
|
44
|
-
"onBrand": { "value": "#ffffff" }
|
|
43
|
+
"accent": { "value": "#0842a0" }
|
|
45
44
|
},
|
|
46
45
|
"border": {
|
|
47
46
|
"default": { "value": "#595959" },
|
package/tokens.dark.json
CHANGED
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"default": { "value": "#f1f5f9" },
|
|
41
41
|
"muted": { "value": "#b4c0d0" },
|
|
42
42
|
"subtle": { "value": "#94a3b8" },
|
|
43
|
-
"accent": { "value": "#60a5fa" }
|
|
44
|
-
"onBrand": { "value": "#ffffff" }
|
|
43
|
+
"accent": { "value": "#60a5fa" }
|
|
45
44
|
},
|
|
46
45
|
"border": {
|
|
47
46
|
"default": { "value": "#334155" },
|
package/tokens.json
CHANGED
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"default": { "value": "#0f172a" },
|
|
41
41
|
"muted": { "value": "#475569" },
|
|
42
42
|
"subtle": { "value": "#64748b" },
|
|
43
|
-
"accent": { "value": "#1d4ed8" }
|
|
44
|
-
"onBrand": { "value": "#ffffff" }
|
|
43
|
+
"accent": { "value": "#1d4ed8" }
|
|
45
44
|
},
|
|
46
45
|
"border": {
|
|
47
46
|
"default": { "value": "#e2e8f0" },
|
|
@@ -88,6 +87,24 @@
|
|
|
88
87
|
"16": { "value": "4rem" },
|
|
89
88
|
"20": { "value": "5rem" }
|
|
90
89
|
},
|
|
90
|
+
"density": {
|
|
91
|
+
"control": {
|
|
92
|
+
"minHeight": { "value": "2.25rem" }
|
|
93
|
+
},
|
|
94
|
+
"cell": {
|
|
95
|
+
"padY": { "value": "0.75rem" },
|
|
96
|
+
"padX": { "value": "0.75rem" }
|
|
97
|
+
},
|
|
98
|
+
"compact": {
|
|
99
|
+
"control": {
|
|
100
|
+
"minHeight": { "value": "2rem" }
|
|
101
|
+
},
|
|
102
|
+
"cell": {
|
|
103
|
+
"padY": { "value": "0.5rem" },
|
|
104
|
+
"padX": { "value": "0.5rem" }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
91
108
|
"radius": {
|
|
92
109
|
"sm": { "value": "0.25rem" },
|
|
93
110
|
"md": { "value": "0.5rem" },
|
package/tokens.midnight.json
CHANGED
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"default": { "value": "#f0f6fc" },
|
|
41
41
|
"muted": { "value": "#9aa4b2" },
|
|
42
42
|
"subtle": { "value": "#8b949e" },
|
|
43
|
-
"accent": { "value": "#58a6ff" }
|
|
44
|
-
"onBrand": { "value": "#ffffff" }
|
|
43
|
+
"accent": { "value": "#58a6ff" }
|
|
45
44
|
},
|
|
46
45
|
"border": {
|
|
47
46
|
"default": { "value": "#30363d" },
|
package/tokens.twilight.json
CHANGED
|
@@ -40,8 +40,7 @@
|
|
|
40
40
|
"default": { "value": "#f6f3fa" },
|
|
41
41
|
"muted": { "value": "#b9aecd" },
|
|
42
42
|
"subtle": { "value": "#a294bd" },
|
|
43
|
-
"accent": { "value": "#a78bfa" }
|
|
44
|
-
"onBrand": { "value": "#ffffff" }
|
|
43
|
+
"accent": { "value": "#a78bfa" }
|
|
45
44
|
},
|
|
46
45
|
"border": {
|
|
47
46
|
"default": { "value": "#3a3350" },
|