@terpjs/react-core 0.9.0 → 0.10.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 (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
@@ -83,6 +83,35 @@ describe("ThemeProvider + ThemeToggle", () => {
83
83
  expect(document.documentElement.getAttribute("data-theme")).toBe("light");
84
84
  });
85
85
 
86
+ it("restores a persisted SYSTEM choice over the app default", () => {
87
+ // The one stored choice a declared default used to beat. `"system"` is a menu entry a
88
+ // person can pick, and it is stored like any other — so reading it as "nothing stored"
89
+ // put them back on the app's palette on the next load, while the menu reported that
90
+ // palette as active. An app declaring a palette must not be able to overrule a person
91
+ // who asked to follow their own platform.
92
+ window.localStorage.setItem(THEME_STORAGE_KEY, "system");
93
+ render(
94
+ <ThemeProvider defaultTheme="midnight">
95
+ <ThemeToggle />
96
+ </ThemeProvider>,
97
+ );
98
+ expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
99
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
100
+ expect(screen.getByRole("menuitemradio", { name: "System" })).toBeChecked();
101
+ });
102
+
103
+ it("treats a stored theme this build does not ship as no choice at all", () => {
104
+ // A theme removed by an upgrade must not leave a viewer pinned to a `data-theme` value
105
+ // nothing styles — which is what the app's default is for.
106
+ window.localStorage.setItem(THEME_STORAGE_KEY, "sepia");
107
+ render(
108
+ <ThemeProvider defaultTheme="contrast">
109
+ <ThemeToggle />
110
+ </ThemeProvider>,
111
+ );
112
+ expect(document.documentElement.getAttribute("data-theme")).toBe("contrast");
113
+ });
114
+
86
115
  it("switching back to system removes the attribute (OS preference wins)", () => {
87
116
  render(
88
117
  <ThemeProvider defaultTheme="dark">
@@ -6,9 +6,9 @@ import { DEFAULT_STRINGS } from "./uiText";
6
6
 
7
7
  // react-core's theme list against the contract's published one.
8
8
  //
9
- // `Theme` in `theme.tsx` is a hand-written union of the stylesheet's theme names, and
10
- // `THEME_ICONS` and the label map are records over it. That is a restatement of a published
11
- // contract, so it can drift from it — and both directions fail quietly:
9
+ // `Theme` in `themes.ts` is a hand-written union of the stylesheet's theme names, and
10
+ // `THEME_ICONS` and the label map in `theme.tsx` are records over it. That is a restatement of a
11
+ // published contract, so it can drift from it — and both directions fail quietly:
12
12
  //
13
13
  // * A theme the sheet ships that this union omits is a palette no app can ever select. It
14
14
  // is compiled, gated for contrast and completeness, published in the manifest, and
@@ -33,19 +33,25 @@ const manifest: {
33
33
 
34
34
  // Read as source rather than imported, because the union is a type: it does not survive to
35
35
  // runtime, and `THEMES` alone would not prove the type and the array agree.
36
+ //
37
+ // Two files, because the names and the records over them are two files: the names are a leaf
38
+ // module the layout-declaration resolver also reads (it must refuse a palette a checked-in
39
+ // declaration asks for), and the icon and label records belong with the control that renders
40
+ // them. Each regex below names the file it is asserting about.
41
+ const namesSource = readFileSync(new URL("./themes.ts", import.meta.url), "utf-8");
36
42
  const themeSource = readFileSync(new URL("./theme.tsx", import.meta.url), "utf-8");
37
43
 
38
44
  /** The names in `export type Theme = "a" | "b" | …`. */
39
45
  function unionMembers(): string[] {
40
- const match = /export type Theme =([^;]+);/.exec(themeSource);
41
- if (!match) throw new Error("theme.tsx: could not find `export type Theme`");
46
+ const match = /export type Theme =([^;]+);/.exec(namesSource);
47
+ if (!match) throw new Error("themes.ts: could not find `export type Theme`");
42
48
  return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
43
49
  }
44
50
 
45
51
  /** The names in the `THEMES` array literal. */
46
52
  function themesArray(): string[] {
47
- const match = /const THEMES: readonly Theme\[\] = \[([^\]]+)\]/.exec(themeSource);
48
- if (!match) throw new Error("theme.tsx: could not find the `THEMES` array");
53
+ const match = /const THEMES: readonly Theme\[\] = \[([^\]]+)\]/.exec(namesSource);
54
+ if (!match) throw new Error("themes.ts: could not find the `THEMES` array");
49
55
  return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
50
56
  }
51
57
 
package/src/theme.tsx CHANGED
@@ -1,38 +1,22 @@
1
+ import type { IconName } from "@terpjs/contract";
1
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
3
  import type { ReactNode } from "react";
3
4
 
4
5
  import { Icon } from "./icons";
6
+ import { THEMES } from "./themes";
7
+ import type { Theme } from "./themes";
5
8
  import { Menu, MenuItem } from "./ui/Menu";
6
9
  import { useStrings } from "./uiText";
7
10
 
8
11
  /**
9
- * The visual theme: an explicit choice, or "system" to follow the OS preference.
10
- *
11
- * The token stylesheet (`@terpjs/contract/tokens.css`) carries every palette: it applies
12
- * each named theme's colours under `<html data-theme="<name>">` and with no attribute —
13
- * applies the dark palette under `@media (prefers-color-scheme: dark)`, so "system" simply
14
- * removes the attribute.
15
- *
16
- * The names are the stylesheet's, so this union is a restatement of a published contract
17
- * and could drift from it silently — a theme the sheet ships that no app can select, or one
18
- * this offers that resolves to nothing. `theme.themes.test.ts` holds it against the token
19
- * manifest. The union is written out rather than derived from the manifest at runtime because
20
- * react-core publishes unbuilt source and imports nothing but React: resolving a JSON module
21
- * from a sibling package would add a requirement to every consumer's bundler and tsconfig,
22
- * which is the consumption-model change the framework spends real effort avoiding.
12
+ * The theme names and the runtime list of them live in `themes.ts`, a leaf module with no
13
+ * React and no DOM, because the layout-declaration resolver needs the same list to refuse a
14
+ * palette an app's checked-in file names and it must not import this file to get it.
15
+ * Re-exported here so `ThemeProvider`'s own type stays where its props are documented.
23
16
  */
24
- export type Theme = "light" | "dark" | "midnight" | "twilight" | "contrast" | "system";
17
+ export type { Theme } from "./themes";
25
18
 
26
- const THEMES: readonly Theme[] = [
27
- "light",
28
- "dark",
29
- "midnight",
30
- "twilight",
31
- "contrast",
32
- "system",
33
- ];
34
-
35
- const THEME_ICONS: Record<Theme, string> = {
19
+ const THEME_ICONS: Record<Theme, IconName> = {
36
20
  light: "sun",
37
21
  dark: "moon",
38
22
  midnight: "moon-stars",
@@ -51,12 +35,26 @@ interface ThemeContextValue {
51
35
 
52
36
  const ThemeContext = createContext<ThemeContextValue | null>(null);
53
37
 
54
- function readStoredTheme(): Theme {
38
+ /**
39
+ * The theme this viewer chose, or `null` if they have not chosen one.
40
+ *
41
+ * `null` rather than `"system"` for the absent case, and the distinction is load-bearing.
42
+ * `"system"` is a choice a person can make from the menu — it means "follow my platform" —
43
+ * and it is stored like any other. Collapsing it into the absent case made a declared
44
+ * `defaultTheme` beat it on every reload: the person picked System, the session honoured it,
45
+ * and the next load silently put them back on the app's palette while the menu reported that
46
+ * palette as active. The key documents itself as applying "until a person chooses another",
47
+ * and this was the one choice it could not survive.
48
+ *
49
+ * A stored value this build does not ship also reads as no choice — a theme removed by an
50
+ * upgrade must not leave a viewer pinned to a `data-theme` nothing styles.
51
+ */
52
+ function readStoredTheme(): Theme | null {
55
53
  try {
56
54
  const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
57
- return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
55
+ return THEMES.includes(stored as Theme) ? (stored as Theme) : null;
58
56
  } catch {
59
- return "system";
57
+ return null;
60
58
  }
61
59
  }
62
60
 
@@ -73,13 +71,12 @@ export interface ThemeProviderProps {
73
71
  * {@link UserMenu} already includes it).
74
72
  *
75
73
  * `defaultTheme` is how an app ships on a named theme — `defaultTheme="midnight"` and
76
- * nothing else — since it applies until the user chooses otherwise.
74
+ * nothing else — since it applies until the user chooses otherwise, INCLUDING when what they
75
+ * choose is "system". Prefer declaring it in the app's own `frontend/layout-contract.json`,
76
+ * which is the form a tool can read and rewrite.
77
77
  */
78
78
  export function ThemeProvider({ defaultTheme = "system", children }: ThemeProviderProps) {
79
- const [theme, setThemeState] = useState<Theme>(() => {
80
- const stored = readStoredTheme();
81
- return stored === "system" ? defaultTheme : stored;
82
- });
79
+ const [theme, setThemeState] = useState<Theme>(() => readStoredTheme() ?? defaultTheme);
83
80
 
84
81
  useEffect(() => {
85
82
  const root = document.documentElement;
package/src/themes.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The theme names, as a leaf module: no React, no DOM, two literals and nothing else.
3
+ *
4
+ * They live here rather than beside `ThemeProvider` because two consumers need them and only
5
+ * one of them is a component. The provider needs the list to decide whether a stored choice is
6
+ * still a theme this build ships; {@link ./layoutDeclaration.resolveLayoutDeclaration} needs it
7
+ * to refuse a palette an app's checked-in declaration names and this release cannot honour.
8
+ * Importing `theme.tsx` from that resolver would have pulled React, the icon set and the
9
+ * component stylesheet's module-scope injection into a module whose entire job is to validate a
10
+ * JSON file — and into the node-environment test that covers it, where the stylesheet's
11
+ * `document` guard is the only thing standing between it and a crash.
12
+ *
13
+ * The union is a restatement of a published contract — `@terpjs/contract`'s compiled stylesheet
14
+ * and its token manifest — so it can drift from it, in both directions and quietly:
15
+ *
16
+ * * A theme the sheet ships that this union omits is a palette no app can ever select. It is
17
+ * compiled, gated for contrast and completeness, published in the manifest, unreachable.
18
+ * * A theme this union offers that the sheet has no block for sets `data-theme` to a value
19
+ * nothing matches, so the app renders the base palette while the control reports the choice
20
+ * took.
21
+ *
22
+ * `theme.themes.test.ts` holds this file against the manifest for exactly that. The names are
23
+ * written out rather than derived from the manifest at runtime because react-core publishes
24
+ * unbuilt source and imports nothing but React: resolving a sibling package's JSON module would
25
+ * add a bundler and tsconfig requirement to every consumer, which is the consumption-model
26
+ * change the framework spends real effort avoiding. The copy stays a copy, and the copy is
27
+ * checked.
28
+ */
29
+
30
+ /**
31
+ * The visual theme: an explicit choice, or `"system"` to follow the OS preference.
32
+ *
33
+ * The token stylesheet (`@terpjs/contract/tokens.css`) carries every palette: it applies each
34
+ * named theme's colours under `<html data-theme="<name>">` and — with no attribute — applies the
35
+ * dark palette under `@media (prefers-color-scheme: dark)`, so `"system"` simply removes the
36
+ * attribute.
37
+ */
38
+ export type Theme = "light" | "dark" | "midnight" | "twilight" | "contrast" | "system";
39
+
40
+ /**
41
+ * Every value {@link Theme} admits, in the order the theme control offers them: the shipped
42
+ * palettes in registry order, then the OS-preference sentinel last.
43
+ *
44
+ * This is the runtime half of the union — the type does not survive to runtime, and a stored
45
+ * string, a JSON file and a bootstrap option are all `string` until something checks them.
46
+ */
47
+ export const THEMES: readonly Theme[] = [
48
+ "light",
49
+ "dark",
50
+ "midnight",
51
+ "twilight",
52
+ "contrast",
53
+ "system",
54
+ ];
package/src/toast.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import type { IconName } from "@terpjs/contract";
1
2
  import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
2
3
  import type { ReactNode } from "react";
3
4
 
@@ -39,7 +40,7 @@ const ToastContext = createContext<ToastApi | null>(null);
39
40
 
40
41
  const DEFAULT_DURATION_MS = 5000;
41
42
 
42
- const iconName: Record<ToastVariant, string> = {
43
+ const iconName: Record<ToastVariant, IconName> = {
43
44
  success: "check",
44
45
  error: "x",
45
46
  warning: "bell",
@@ -1,6 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
+ import { NARROW_VIEWPORT, WIDE_VIEWPORT_QUERY } from "./breakpoints";
5
+ import { TERP_STYLES_CSS } from "./styles";
6
+
4
7
  // Vitest stubs .css imports to empty modules, so the sheet is read from disk.
5
8
  const tokensCss = readFileSync(
6
9
  new URL("../../contract/src/tokens.css", import.meta.url),
@@ -21,6 +24,119 @@ const declared = new Set(
21
24
  [...tokensCss.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((match) => match[1]!),
22
25
  );
23
26
 
27
+ /** The framework sheet with its comments removed — prose naming a token is not a reader. */
28
+ const sheet = TERP_STYLES_CSS.replace(/\/\*[\s\S]*?\*\//g, "");
29
+
30
+ /**
31
+ * Published motion tokens no rule in the sheet reads, as an exact list.
32
+ *
33
+ * The other three are wired: every `transition` in the sheet names
34
+ * `--motion-duration-fast` / `--motion-duration-instant` with
35
+ * `--motion-easing-standard`, which was inert by construction because those tokens'
36
+ * values ARE the `150ms` / `100ms` / `ease` literals they replaced.
37
+ *
38
+ * These four are not, and leaving them that way is a position rather than an omission.
39
+ * They map onto no literal this sheet contains, so there is nothing to convert. The two
40
+ * ways out are both worse than naming them here: deleting them is a **contract change**,
41
+ * since `tokens.manifest.json` publishes them and a consumer may already read one; and
42
+ * giving them readers means inventing overlay entrance/exit animations, which is a
43
+ * behaviour change wearing a token wiring's clothes — and one the screenshot lane cannot
44
+ * see either way, because it runs with `animations: "disabled"`.
45
+ *
46
+ * Exact equality in both directions, which is what makes it a decision instead of drift:
47
+ * wiring one has to shrink this list, and publishing an eighth motion token has to
48
+ * either name a reader or land here with a reason.
49
+ */
50
+ const UNREAD_TOKENS: Record<string, string[]> = {
51
+ "--motion-": [
52
+ "--motion-duration-base",
53
+ "--motion-duration-slow",
54
+ "--motion-easing-entrance",
55
+ "--motion-easing-exit",
56
+ ],
57
+ // The published type scale, wired in 4b by the prose components — and the reason it took a
58
+ // new component rather than a conversion is the interesting part. Every motion literal
59
+ // mapped exactly onto a token, so wiring those was inert. Here the sheet writes line heights
60
+ // of 1.2, 1.25, 1.3, 1.4 and 1.5 while the scale offers 1.2, 1.35, 1.5 and 1.7: only 8 of 32
61
+ // literals map. Converting those eight and leaving thirteen is a half-migration, and
62
+ // converting the rest CHANGES rendered line heights across a dozen components — a typography
63
+ // pass with its own baselines, not a token wiring done in passing. New components have
64
+ // nothing depending on their metrics, so they take the scale as published.
65
+ "--font-line-height-": [],
66
+ // EMPTY, and `wide` is what emptied it. It sat here reading "for the uppercase-label
67
+ // treatment nothing in the package uses" — a token waiting for a component. The navigation
68
+ // group label is that treatment, and it takes 0.08em from the scale rather than adding a
69
+ // third bare literal beside the sheet's 0.04em and 0.06em. Which is the whole point of
70
+ // keeping this list: it named the reader before the reader existed, so the new rule had one
71
+ // obvious right answer instead of a plausible wrong one.
72
+ "--font-letter-spacing-": [],
73
+ // The shell's published geometry, tracked from the day it shipped rather than after
74
+ // something rots. All four have readers — two sidebar widths, the header's floor and the
75
+ // content measure — so the list is empty, and empty is the assertion: a fifth shell token
76
+ // added without a rule to read it lands here and has to justify itself. That is the exact
77
+ // offence `--color-fg-on-brand` was deleted for, and the reason it went unnoticed for a
78
+ // release is that only three families were tracked at all. (Still only four: `--color-`,
79
+ // `--space-`, `--radius-` and the rest publish unread tokens with nothing to say so. The
80
+ // sidebar colour family WAS in that state for four releases — five tokens declared in every
81
+ // theme and read by nothing — which is the offence `--color-fg-on-brand` was deleted for. The
82
+ // difference there is that the vocabulary was wrong; here it was right and the readers were
83
+ // missing, so the fix was to wire them. Tracked from now on.)
84
+ "--shell-": [],
85
+ "--color-sidebar-": [],
86
+ // The whole semantic colour layer, which the comment above admitted was untracked and then
87
+ // left untracked. Seventeen of the forty-eight published `--color-` tokens have no `var()`
88
+ // reader anywhere in the sheet, and until now nothing said so — the exact `--color-fg-on-brand`
89
+ // shape that comment describes, seventeen times over.
90
+ //
91
+ // Booked as one family rather than four narrower ones on purpose. `--color-bg-`,
92
+ // `--color-border-`, `--color-interactive-` and `--color-chart-` are each read by NOTHING, and
93
+ // the assertion below requires a tracked family to publish more than it books — a deliberate
94
+ // "is the prefix right?" check that a wholly-unread family would trip. The broader prefix
95
+ // subsumes them and still catches the eighteenth. `--color-sidebar-` stays above because it
96
+ // makes a narrower claim worth keeping: that family is read in full.
97
+ //
98
+ // None of these seventeen is a defect on its own. They are a published vocabulary that shipped
99
+ // ahead of its consumers — four surface tokens, three borders, three interactive states, a
100
+ // five-step chart ramp and two neutrals — and the point of booking them is that the list can
101
+ // only shrink from here, so wiring one is visible and adding an eighteenth has to argue.
102
+ "--color-": [
103
+ "--color-bg-canvas",
104
+ "--color-bg-inset",
105
+ "--color-bg-raised",
106
+ "--color-bg-surface",
107
+ "--color-border-default",
108
+ "--color-border-strong",
109
+ "--color-border-subtle",
110
+ "--color-chart-1",
111
+ "--color-chart-2",
112
+ "--color-chart-3",
113
+ "--color-chart-4",
114
+ "--color-chart-5",
115
+ "--color-interactive-active",
116
+ "--color-interactive-hover",
117
+ "--color-interactive-selected",
118
+ "--color-neutral-500",
119
+ "--color-neutral-800",
120
+ ],
121
+ };
122
+
123
+ /**
124
+ * Bare `line-height` / `letter-spacing` values still in the sheet, as an exact multiset.
125
+ *
126
+ * The reconciliation debt, made a number so it cannot grow quietly. The scale above is now
127
+ * read, but these 32 declarations predate it and most map onto nothing in it — so a new rule
128
+ * that adds a 27th bare line height has to either use a token or come here and say why, and
129
+ * whoever does the typography pass has a target rather than a grep.
130
+ *
131
+ * Two entries are permanent rather than pending: `line-height: 0` and `line-height: 1` are
132
+ * icon and avatar boxes, where the line box is being removed rather than set to a step on a
133
+ * prose scale.
134
+ */
135
+ const BARE_TYPE_LITERALS: Record<string, Record<string, number>> = {
136
+ "line-height": { "0": 2, "1": 4, "1.2": 4, "1.25": 7, "1.3": 2, "1.4": 3, "1.5": 4 },
137
+ "letter-spacing": { "0": 4, "0.04em": 1, "0.06em": 1 },
138
+ };
139
+
24
140
  describe("design tokens", () => {
25
141
  it("only references custom properties the contract token sheet declares", () => {
26
142
  // A fallback-less var() against an undeclared token silently computes to the
@@ -49,4 +165,80 @@ describe("design tokens", () => {
49
165
  expect(tokensCss).toContain("color-scheme: light");
50
166
  expect(tokensCss.match(/color-scheme: dark/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
51
167
  });
168
+
169
+ it("spells the one viewport cutover from the published token, in one place", () => {
170
+ // The breakpoint is the other token family nothing can `var()`: CSS forbids a custom
171
+ // property in a media-query condition, and `matchMedia` takes a string, so neither
172
+ // consumer can read `--breakpoint-md` the way a colour is read. The literal is therefore
173
+ // unavoidable — and unguarded it was already written twice, verbatim, in `AppShell` and
174
+ // `DataView`, which is the duplication the diagnosis named.
175
+ //
176
+ // So this holds three things together that CANNOT agree by construction: the token the
177
+ // contract publishes, the string the components hand to `matchMedia`, and the query the
178
+ // stylesheet uses. The third one at least is the complement of the second by construction
179
+ // rather than by a second literal.
180
+ const declaredMd = /--breakpoint-md:\s*([^;]+);/.exec(tokensCss)?.[1]?.trim();
181
+ expect(declaredMd, "the contract should publish --breakpoint-md").toBe("768px");
182
+ expect(NARROW_VIEWPORT).toBe(`(max-width: ${declaredMd})`);
183
+ expect(WIDE_VIEWPORT_QUERY).toBe(`not all and ${NARROW_VIEWPORT}`);
184
+ expect(sheet).toContain(`@media ${WIDE_VIEWPORT_QUERY}`);
185
+
186
+ // And nobody re-spells it. The two components import the constant now; a third copy is
187
+ // exactly how the first two came to disagree with nothing noticing.
188
+ const offenders = Object.entries(sources)
189
+ .filter(([file]) => !file.includes(".test.") && file !== "./breakpoints.ts")
190
+ .filter(([, text]) => text.includes("max-width: 768px"))
191
+ .map(([file]) => file);
192
+ expect(offenders, "the breakpoint belongs in ./breakpoints.ts and nowhere else").toEqual([]);
193
+ });
194
+
195
+ it("names every published token in a tracked family that the sheet does not read", () => {
196
+ // The other direction of this file's join. The test above catches a `var()` naming a
197
+ // token the contract never declared; this one catches a token the contract publishes
198
+ // that nothing consumes — the `--color-fg-on-brand` shape, which was deleted for
199
+ // being declared in five themes and read by none. A Studio editor built from the
200
+ // manifest offers a control per published token, so an unread one is a knob that
201
+ // does nothing.
202
+ for (const [family, unread] of Object.entries(UNREAD_TOKENS)) {
203
+ const published = [
204
+ ...new Set(
205
+ // `[a-z0-9-]`, not `[a-z-]`. The narrower class could not match a token name with a
206
+ // DIGIT in it, which is every step of every scale the contract publishes — all nine
207
+ // neutrals, the five chart colours, every `--space-N` and `--font-size-N`. So a family
208
+ // containing them reported only its wordy members as published, and an unread numbered
209
+ // token was invisible to a test whose entire job is to name unread tokens. Found when
210
+ // the semantic colour layer was booked and the guard could see ten of its seventeen.
211
+ [...tokensCss.matchAll(new RegExp(`(${family}[a-z0-9-]+)\\s*:`, "g"))].map(
212
+ (match) => match[1]!,
213
+ ),
214
+ ),
215
+ ];
216
+ expect(published.length, `${family} publishes nothing — is the prefix right?`).toBeGreaterThan(
217
+ unread.length,
218
+ );
219
+ expect(
220
+ published.filter((token) => !sheet.includes(`var(${token})`)).sort(),
221
+ `a published ${family} token gained or lost a reader — wire it, or record it with a reason`,
222
+ ).toEqual(unread);
223
+ }
224
+ });
225
+
226
+ it("holds the bare type literals at their recorded count", () => {
227
+ // The debt the type scale's arrival did not clear, as a ratchet. See BARE_TYPE_LITERALS:
228
+ // most of these map onto no step in the published scale, so converting them changes
229
+ // rendered line heights and belongs in a typography pass. What this stops is the count
230
+ // growing in the meantime.
231
+ for (const [property, expected] of Object.entries(BARE_TYPE_LITERALS)) {
232
+ const counts: Record<string, number> = {};
233
+ for (const match of sheet.matchAll(new RegExp(`\\b${property}:\\s*([^;]+);`, "g"))) {
234
+ const value = match[1]!.trim();
235
+ if (value.includes("var(")) continue;
236
+ counts[value] = (counts[value] ?? 0) + 1;
237
+ }
238
+ expect(
239
+ counts,
240
+ `${property}: use the published scale, or record the new literal here`,
241
+ ).toEqual(expected);
242
+ }
243
+ });
52
244
  });