@terpjs/react-core 0.7.0 → 0.8.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 (72) hide show
  1. package/package.json +2 -2
  2. package/src/AppShell.test.tsx +33 -12
  3. package/src/AppShell.tsx +69 -249
  4. package/src/Breadcrumbs.test.tsx +24 -0
  5. package/src/Breadcrumbs.tsx +9 -32
  6. package/src/ConfirmDialog.tsx +13 -44
  7. package/src/EmptyState.tsx +8 -36
  8. package/src/ErrorState.tsx +8 -36
  9. package/src/Field.test.tsx +57 -0
  10. package/src/Field.tsx +46 -22
  11. package/src/HubPage.test.tsx +22 -13
  12. package/src/HubPage.tsx +25 -97
  13. package/src/LoadingState.tsx +3 -24
  14. package/src/PageActions.tsx +5 -10
  15. package/src/UserMenu.test.tsx +12 -5
  16. package/src/UserMenu.tsx +33 -62
  17. package/src/dataview/DataView.test.tsx +109 -5
  18. package/src/dataview/DataView.tsx +41 -23
  19. package/src/dataview/DataViewCardList.tsx +14 -60
  20. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  21. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  22. package/src/dataview/DataViewPagination.tsx +2 -32
  23. package/src/dataview/DataViewRowActions.tsx +13 -33
  24. package/src/dataview/DataViewTable.tsx +16 -103
  25. package/src/dataview/DataViewToolbar.tsx +53 -76
  26. package/src/dataview/README.md +6 -0
  27. package/src/dataview/index.ts +1 -0
  28. package/src/dataview/internal.tsx +4 -1
  29. package/src/dataview/types.ts +13 -0
  30. package/src/feedback.test.tsx +26 -0
  31. package/src/files.test.tsx +18 -0
  32. package/src/files.tsx +13 -4
  33. package/src/icons.test.tsx +10 -6
  34. package/src/icons.tsx +9 -37
  35. package/src/index.ts +0 -3
  36. package/src/layout.test.tsx +24 -9
  37. package/src/layout.tsx +24 -21
  38. package/src/layoutContract.test.tsx +95 -0
  39. package/src/locale.tsx +24 -4
  40. package/src/markers.test.ts +242 -19
  41. package/src/router.tsx +6 -9
  42. package/src/ssr.test.tsx +1 -3
  43. package/src/styles.test.ts +823 -6
  44. package/src/styles.ts +2699 -153
  45. package/src/theme.tsx +24 -3
  46. package/src/toast.tsx +35 -71
  47. package/src/ui/Alert.test.tsx +12 -0
  48. package/src/ui/Alert.tsx +15 -43
  49. package/src/ui/Badge.test.tsx +14 -3
  50. package/src/ui/Badge.tsx +9 -28
  51. package/src/ui/Button.test.tsx +17 -4
  52. package/src/ui/Button.tsx +10 -63
  53. package/src/ui/Card.test.tsx +6 -2
  54. package/src/ui/Card.tsx +11 -39
  55. package/src/ui/Checkbox.tsx +2 -19
  56. package/src/ui/Combobox.test.tsx +22 -0
  57. package/src/ui/Combobox.tsx +31 -80
  58. package/src/ui/DatePicker.test.tsx +131 -4
  59. package/src/ui/DatePicker.tsx +158 -106
  60. package/src/ui/Input.tsx +6 -19
  61. package/src/ui/Markdown.test.tsx +26 -0
  62. package/src/ui/Markdown.tsx +28 -2
  63. package/src/ui/Menu.test.tsx +38 -4
  64. package/src/ui/Menu.tsx +50 -52
  65. package/src/ui/Popover.tsx +53 -19
  66. package/src/ui/Radio.tsx +5 -30
  67. package/src/ui/Select.tsx +7 -30
  68. package/src/ui/Switch.tsx +2 -20
  69. package/src/ui/Tabs.tsx +4 -28
  70. package/src/ui/Textarea.tsx +6 -17
  71. package/src/ui/Tooltip.tsx +9 -21
  72. package/src/ui/controlStyles.ts +0 -9
@@ -21,6 +21,9 @@ import {
21
21
  import { OverviewPage } from "./OverviewPage";
22
22
  import { Page } from "./Page";
23
23
  import { DetailList, Stack } from "./layout";
24
+ import { PageActions } from "./PageActions";
25
+ import { ThemeProvider, ThemeToggle } from "./theme";
26
+ import { Button } from "./ui/Button";
24
27
  import { Card } from "./ui/Card";
25
28
 
26
29
  afterEach(cleanup);
@@ -179,3 +182,95 @@ describe("runtime slot enforcement", () => {
179
182
  expect(verifySlotChildren("standard", "Page", [])).toBeNull();
180
183
  });
181
184
  });
185
+
186
+ // The styling migration (ADR 0094) is renaming rendered roots: components that used to emit
187
+ // an unmarked <div>, or to borrow Popover's `popover`, now name themselves. Every one of those
188
+ // names is read by this check, because the check IS the marker join — so the question is not
189
+ // whether the names look sensible but whether any slot's verdict moved.
190
+ //
191
+ // It did not, and the reason is worth stating rather than re-derived: verifySlotChildren
192
+ // refuses a direct body-slot child whose data-terp is not in the slot's allow table, and a
193
+ // missing attribute is refused too (`marker === null`). None of the new names is in any table,
194
+ // and neither was the unmarked element each replaced. So each of these was refused before the
195
+ // migration and is refused after it. What changed is the MESSAGE — describeElement now reports
196
+ // a named component instead of a bare tag, which is strictly more useful to the person reading
197
+ // the refusal, and is also a string a test could have pinned.
198
+ describe("layout contract survives the roots the styling migration renames", () => {
199
+ it("still refuses a chrome menu in a governed body, and now names it", async () => {
200
+ underContract(
201
+ // The provider renders no element of its own, so the slot's direct child is still the
202
+ // toggle. Without it ThemeToggle returns null and the body is empty — which passes for
203
+ // no reason at all.
204
+ <ThemeProvider>
205
+ <OverviewPage title="Records">
206
+ <ThemeToggle variant="stacked" />
207
+ </OverviewPage>
208
+ </ThemeProvider>,
209
+ );
210
+ await waitFor(() => {
211
+ expect(screen.getByTestId("refused").textContent).toBe(
212
+ slotViolationMessage("standard", "OverviewPage", '<div data-terp="theme-toggle">'),
213
+ );
214
+ });
215
+ });
216
+
217
+ it("still refuses an action cluster in a governed body, and now names it", async () => {
218
+ underContract(
219
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
220
+ <PageActions primary={<Button>Publish</Button>} />
221
+ </DetailPage>,
222
+ );
223
+ await waitFor(() => {
224
+ expect(screen.getByTestId("refused").textContent).toBe(
225
+ slotViolationMessage("standard", "DetailPage", '<div data-terp="page-actions">'),
226
+ );
227
+ });
228
+ });
229
+
230
+ it("keeps every marker the allow tables name out of the migration's way", () => {
231
+ // The tables are the audit surface: a rename of one of THESE would widen or close a slot
232
+ // silently, so they are the markers the migration may not touch without changing both
233
+ // halves of the contract. Asserted as a set so an addition has to be deliberate.
234
+ const named = new Set(
235
+ Object.values(LAYOUT_CONTRACTS.standard!.slots).flatMap((slot) =>
236
+ Object.values(slot.components),
237
+ ),
238
+ );
239
+ expect([...named].sort()).toEqual([
240
+ "alert",
241
+ "card",
242
+ "dataview",
243
+ "detail-list",
244
+ "dialog",
245
+ "empty-state",
246
+ "error-state",
247
+ "hubcard",
248
+ "loading-state",
249
+ "module-nav",
250
+ "resource-list",
251
+ "stack",
252
+ "tabs",
253
+ ]);
254
+ });
255
+
256
+ it("passes a governed body whose children are still allowed components", async () => {
257
+ // The other direction: the migration must not have widened anything either. A Stack and a
258
+ // Card are allowed, and they stay allowed with their markers unchanged.
259
+ underContract(
260
+ <ThemeProvider>
261
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
262
+ <Card title="A section">body</Card>
263
+ <Stack>
264
+ <ThemeToggle variant="stacked" />
265
+ </Stack>
266
+ </DetailPage>
267
+ </ThemeProvider>,
268
+ );
269
+ await new Promise((resolve) => setTimeout(resolve, 20));
270
+ // And the toggle really did render, or this asserts nothing.
271
+ expect(document.querySelector('[data-terp="theme-toggle"]')).not.toBeNull();
272
+ // Nested inside an allowed container is sanctioned composition — the check reads direct
273
+ // children only, which is what makes a marked root safe to put anywhere below one.
274
+ expect(screen.queryByTestId("refused")).toBeNull();
275
+ });
276
+ });
package/src/locale.tsx CHANGED
@@ -218,8 +218,28 @@ export function LanguageSwitcher({ variant = "stacked" }: LanguageSwitcherProps)
218
218
  if (context === null || context.locales.length < 2) {
219
219
  return null;
220
220
  }
221
+ // Hoisted out of the attribute rather than inlined as `variant === "inline"`, and not for
222
+ // readability: the marker inventory scanner reads every string literal inside a
223
+ // `data-terp={…}` expression as a marker, so the comparison's own "inline" was picked up as
224
+ // a component marker that nothing styles. Keep marker expressions to marker literals.
225
+ const isInline = variant === "inline";
221
226
  const menu = (
222
- <Menu trigger={<Icon name="globe" size="1.15rem" />} triggerLabel={strings.language}>
227
+ <Menu
228
+ // Claim the root ONLY when this Menu is the root, which is the inline variant. The
229
+ // stacked variant renders its own div and puts the menu inside it, so stamping the
230
+ // same marker unconditionally put it on BOTH elements — and the inner wrapper then
231
+ // matched the stacked grid rule instead of the popover wrapper's geometry. The
232
+ // baselines did not catch that: a one-child grid and a one-child inline-flex box
233
+ // shrink-wrap to the same pixels, so it was wrong and invisible at the same time.
234
+ data-terp={isInline ? "language-switcher" : undefined}
235
+ data-variant={isInline ? "inline" : undefined}
236
+ // Unconditional, unlike the root marker: the panel is the same panel in both variants, so
237
+ // a rule for it must reach both. Deriving the owner from the conditional root marker made
238
+ // this panel "language-switcher" when inline and "popover" when stacked.
239
+ data-owner="language-switcher"
240
+ trigger={<Icon name="globe" size="1.15rem" />}
241
+ triggerLabel={strings.language}
242
+ >
223
243
  {({ close }) => (
224
244
  <>
225
245
  {context.locales.map((code) => (
@@ -237,12 +257,12 @@ export function LanguageSwitcher({ variant = "stacked" }: LanguageSwitcherProps)
237
257
  )}
238
258
  </Menu>
239
259
  );
240
- if (variant === "inline") {
260
+ if (isInline) {
241
261
  return menu;
242
262
  }
243
263
  return (
244
- <div style={{ display: "grid", justifyItems: "start", gap: "var(--space-1)", fontSize: "var(--font-size-sm)" }}>
245
- <span style={{ color: "var(--color-neutral-600)" }}>{strings.language}</span>
264
+ <div data-terp="language-switcher" data-variant="stacked">
265
+ <span data-terp="language-switcher-label">{strings.language}</span>
246
266
  {menu}
247
267
  </div>
248
268
  );
@@ -21,6 +21,8 @@ import { describe, expect, it } from "vitest";
21
21
  //
22
22
  // The ambient `ImportMeta.glob` type lives in raw.d.ts, shared with the other scanning tests.
23
23
 
24
+ import manifest from "../package.json";
25
+
24
26
  const sources = import.meta.glob("./**/*.{ts,tsx}", {
25
27
  query: "?raw",
26
28
  import: "default",
@@ -38,50 +40,164 @@ const STYLESHEET = "./styles.ts";
38
40
  */
39
41
  const MARKERS = [
40
42
  "alert",
43
+ "alert-body",
44
+ "alert-icon",
45
+ "alert-title",
46
+ "appshell",
47
+ "appshell-backdrop",
41
48
  "appshell-brand",
49
+ "appshell-brand-row",
50
+ "appshell-brand-title",
51
+ "appshell-column",
52
+ "appshell-footer",
53
+ "appshell-header",
54
+ "appshell-header-group",
55
+ "appshell-main",
42
56
  "appshell-nav",
57
+ "appshell-nav-label",
58
+ "appshell-nav-list",
59
+ "appshell-sidebar",
43
60
  "badge",
44
61
  "breadcrumbs",
62
+ "breadcrumbs-current",
63
+ "breadcrumbs-separator",
45
64
  "button",
65
+ "button-icon",
66
+ "calendar",
67
+ "calendar-day",
68
+ "calendar-grid",
69
+ "calendar-header",
70
+ "calendar-title",
71
+ "calendar-week",
72
+ "calendar-weekday",
46
73
  "card",
47
74
  "card-actions",
75
+ "card-description",
48
76
  "card-header",
77
+ "card-heading",
78
+ "card-title",
49
79
  "checkbox",
80
+ "combobox",
81
+ "combobox-empty",
82
+ "combobox-field",
83
+ "combobox-list",
84
+ "combobox-option",
85
+ "control-label",
50
86
  "dataview",
87
+ "dataview-actions-cell",
51
88
  "dataview-card",
89
+ "dataview-card-body",
90
+ "dataview-card-expanded",
91
+ "dataview-card-fields",
92
+ "dataview-card-heading",
93
+ "dataview-card-list",
94
+ "dataview-card-main",
95
+ "dataview-card-meta",
96
+ "dataview-card-status",
97
+ "dataview-card-title",
98
+ "dataview-column-option",
99
+ "dataview-column-resizer",
100
+ "dataview-column-settings",
101
+ "dataview-column-settings-title",
102
+ "dataview-column-sort",
103
+ "dataview-error",
104
+ "dataview-expand-cell",
105
+ "dataview-expanded-cell",
106
+ "dataview-inline-action",
107
+ "dataview-pager",
108
+ "dataview-pagination",
52
109
  "dataview-row",
110
+ "dataview-row-actions",
53
111
  "dataview-row-open",
112
+ "dataview-scroll",
113
+ "dataview-select-cell",
114
+ "dataview-skeleton",
54
115
  "dataview-table",
55
116
  "dataview-toolbar",
117
+ "dataview-toolbar-actions",
118
+ "dataview-toolbar-count",
119
+ "dataview-toolbar-layout",
120
+ "dataview-toolbar-search",
121
+ "dataview-toolbar-spacer",
122
+ "dataview-toolbar-status",
56
123
  "detail-list",
124
+ "detail-list-term",
125
+ "detail-list-value",
57
126
  "dialog",
127
+ "dialog-actions",
128
+ "dialog-body",
129
+ "dialog-description",
130
+ "dialog-title",
58
131
  "drawer-focus-end",
59
132
  "drawer-focus-start",
60
133
  "empty-state",
134
+ "empty-state-description",
135
+ "empty-state-icon",
136
+ "empty-state-title",
61
137
  "error-state",
138
+ "error-state-description",
139
+ "error-state-icon",
140
+ "error-state-title",
141
+ "field",
142
+ "field-error",
143
+ "field-hint",
144
+ "field-label",
145
+ "field-label-text",
62
146
  "hubcard",
63
147
  "hubcard-body",
64
148
  "hubcard-description",
149
+ "hubcard-heading",
150
+ "hubcard-icon",
65
151
  "hubcard-link",
66
152
  "hubcard-stat",
67
153
  "hubcard-title",
154
+ "hubpage-grid",
155
+ "icon",
68
156
  "iconbutton",
69
157
  "input",
158
+ "language-switcher",
159
+ "language-switcher-label",
70
160
  "loading-state",
161
+ "loading-state-spinner",
162
+ "markdown",
71
163
  "menu",
72
164
  "menu-item",
165
+ "menu-item-check",
166
+ "menu-item-icon",
167
+ "menu-trigger",
73
168
  "module-nav",
74
169
  "nav-icon",
170
+ "nav-icon-fallback",
171
+ "page-actions",
75
172
  "popover",
76
173
  "popover-panel",
77
174
  "radio",
175
+ "radio-group",
176
+ "radio-group-legend",
177
+ "radio-group-options",
78
178
  "resource-list",
79
179
  "spinner-ring",
80
180
  "stack",
81
181
  "switch",
82
182
  "tab",
183
+ "tab-list",
184
+ "tab-panel",
83
185
  "tabs",
186
+ "theme-toggle",
187
+ "theme-toggle-label",
188
+ "toast",
189
+ "toast-body",
190
+ "toast-icon",
191
+ "toast-title",
192
+ "toast-viewport",
84
193
  "tooltip",
194
+ "tooltip-anchor",
195
+ "user-menu",
196
+ "user-menu-avatar",
197
+ "user-menu-email",
198
+ "user-menu-header",
199
+ "user-menu-identity",
200
+ "user-menu-role",
85
201
  ];
86
202
 
87
203
  /**
@@ -98,35 +214,92 @@ const MARKERS = [
98
214
  * the reason splits into two shapes worth knowing before the styling migration is planned:
99
215
  *
100
216
  * - **Delegates its root.** `theme.tsx` (ThemeToggle) and `locale.tsx` (LanguageSwitcher)
101
- * return a bare `Menu` in their `inline` variant — the variant the app shell header
102
- * actually uses — so their root already carries `data-terp="menu"` and is
103
- * indistinguishable from any other menu. `UserMenu` is the same shape. Marking them
104
- * means either threading a marker through `Menu` or introducing a wrapper element.
105
- * - **Returns a fragment.** `Markdown` emits a sequence of block elements with no root at
106
- * all. A marker requires a wrapper, and a wrapper is a new block box in every consumer's
107
- * layout.
217
+ * returned a bare `Menu` in their `inline` variant — the variant the app shell header
218
+ * actually uses — so their root was `Popover`'s wrapper and indistinguishable from any
219
+ * other popover. Both have graduated, and the answer added no DOM: `Popover` takes the
220
+ * root's marker as a prop named `data-terp`, `Menu` threads it through, and each
221
+ * component names its own root with `data-variant` separating the variants. `UserMenu` was
222
+ * the same shape and has graduated too it was also the last consumer of `Menu`'s
223
+ * `triggerStyle` and `panelStyle` props, which are gone: a marked root makes the trigger
224
+ * reachable by descending from it, and the PANEL, which is portalled to `document.body`
225
+ * and so reachable from nowhere, carries a `data-owner` attribute naming whose panel it
226
+ * is.
227
+ *
228
+ * The prop is named for the attribute deliberately. The scanner below reads `data-terp`
229
+ * sites in component source, so `<Menu data-terp="theme-toggle">` is seen exactly where
230
+ * it looks; a `rootMarker` prop would have put the only mention of the name somewhere the
231
+ * scanner never looks, and a marker rendered by nobody's `data-terp` site is precisely
232
+ * the blind spot this file exists to close.
233
+ * - **Returned a fragment.** `Markdown` emitted a sequence of block elements with no root at
234
+ * all, and the objection to marking it was that a wrapper is a new block box in every
235
+ * consumer's layout. It has graduated, and that objection turned out to be answerable
236
+ * rather than true: the wrapper is `display: contents`, which generates no box, so the
237
+ * blocks stay in-flow siblings and become real flex or grid items of any parent that
238
+ * spaces its children with `gap`. Zero diff by construction — and a prose-rhythm block
239
+ * wrapper remains a later, deliberate change rather than a side effect of marking.
108
240
  *
109
241
  * Both are styling decisions with visible consequences, not bookkeeping, so they belong to
110
- * the migration itself rather than to preparation for it. The archetypes, `Field`,
111
- * `PageActions` and the DataView internals are still unexamined.
242
+ * the migration itself rather than to preparation for it. The archetypes and the DataView
243
+ * internals are still unexamined.
244
+ *
245
+ * `PageActions` has graduated, and it was the easiest of the shapes above: it already rendered
246
+ * a real root of its own, so the marker landed on an element that existed and no DOM moved.
247
+ * Note the one thing it does that a rule cannot — it returns `null` when it has no actions at
248
+ * all, so its presence is conditional rather than styled, and `:empty` is not a substitute.
249
+ *
250
+ * `files.tsx` has left the list without gaining a marker, which is the one exit this ratchet
251
+ * allows that is not a migration: it turned out to have no styled surface. Its single
252
+ * declaration was `display: none` on the file picker's hidden plumbing input — the visible
253
+ * control is a `Button` — and that element is now `hidden`, the attribute HTML provides for
254
+ * exactly this. A marker plus a `display: none` rule would have put a component with no visual
255
+ * design into the sheet and offered an app the chance to un-hide it.
256
+ *
257
+ * Worth knowing about the shape of this list, because it flatters two files: it names files
258
+ * with NO marker at all, so one marker on one element exempts the rest of the file. `toast.tsx`
259
+ * and `ConfirmDialog.tsx` were never on it despite styling five and four unreachable elements
260
+ * respectively, because each rendered one `iconbutton` or one `dialog`. Both have since
261
+ * migrated; the gap in the ratchet has not.
262
+ *
263
+ * `Field` has graduated: it renders a root plus label, label text, hint and error markers,
264
+ * so each part of a form field is addressable from the sheet.
112
265
  */
113
266
  const UNMARKED_STYLED_SURFACES = [
114
267
  "./DetailPage.tsx",
115
- "./Field.tsx",
116
268
  "./LoginView.tsx",
117
269
  "./OverviewPage.tsx",
118
- "./PageActions.tsx",
119
270
  "./ProfileView.tsx",
120
- "./UserMenu.tsx",
121
- "./dataview/DataViewColumnSettings.tsx",
122
- "./dataview/DataViewExpandableRow.tsx",
123
- "./dataview/DataViewRowActions.tsx",
124
- "./files.tsx",
125
- "./locale.tsx",
126
- "./theme.tsx",
127
- "./ui/Markdown.tsx",
128
271
  ];
129
272
 
273
+ /**
274
+ * How many module-scope base style objects each file still declares — the migration's own
275
+ * measurable, as a ratchet.
276
+ *
277
+ * This exists because `UNMARKED_STYLED_SURFACES` above flatters a file: it lists modules with
278
+ * NO `data-terp` at all, so a single marker on a single element exempts everything else in the
279
+ * file. `toast.tsx` and `ConfirmDialog.tsx` were never on that list while styling five and four
280
+ * unreachable elements respectively, because each rendered one `iconbutton` or one `dialog`.
281
+ * Both have migrated, and the gap had not — until this.
282
+ *
283
+ * Counted per file rather than as a set of filenames, so a PARTIAL migration shows: moving half
284
+ * of `AppShell`'s twenty-two objects into the sheet has to update the number here. Asserted as
285
+ * exact equality, which makes it a ratchet in both directions — a new base style object fails,
286
+ * and a removed one fails until the ledger is corrected. That is the same bargain `MARKERS`
287
+ * strikes, and it is the point: the number is meant to be read during review.
288
+ *
289
+ * What is deliberately NOT counted: a measured value passed inline at a call site. `Icon` sizes
290
+ * its box from a prop that takes any CSS length, `Stack` passes `align` / `justify` through, and
291
+ * `Popover` positions its panel from a rect it measured — ADR 0094 §3 puts all three on the
292
+ * inline side of the line permanently, so counting them would make this list unable to reach
293
+ * zero and therefore unable to mean anything.
294
+ */
295
+ const INLINE_BASE_STYLES: Record<string, number> = {
296
+ "./LoginView.tsx": 9,
297
+ "./ModuleNav.tsx": 4,
298
+ "./Page.tsx": 5,
299
+ "./ProfileView.tsx": 3,
300
+ "./ResourceList.tsx": 2,
301
+ };
302
+
130
303
  /**
131
304
  * `text` with comments removed, so prose naming a marker cannot stand in for rendering one.
132
305
  *
@@ -144,6 +317,13 @@ function stripComments(text: string) {
144
317
  *
145
318
  * A regex anchored to `data-terp="…"` alone misses the conditional form — which is how the
146
319
  * DataView row markers are written — so renaming one of those would not fail anything.
320
+ *
321
+ * The cost of reading a whole expression is that EVERY string literal inside one counts as a
322
+ * marker. `data-terp={variant === "inline" ? "theme-toggle" : undefined}` reports both
323
+ * `inline` and `theme-toggle`, and the first is not a marker at all. That is caught rather
324
+ * than tolerated — the inventory assertion fails on the phantom name — and the fix is to keep
325
+ * a marker expression to marker literals by hoisting the comparison out. Worth knowing before
326
+ * writing a conditional marker, because the failure names a marker nobody added.
147
327
  */
148
328
  function markersIn(text: string) {
149
329
  const source = stripComments(text);
@@ -242,4 +422,47 @@ describe("data-terp markers", () => {
242
422
  "the worklist must stay sorted and duplicate-free",
243
423
  ).toEqual([...new Set(UNMARKED_STYLED_SURFACES)].sort());
244
424
  });
425
+ it("keeps the inline base-style ledger honest, file by file", () => {
426
+ // The migration's measurable, gated. A base style object is a module-scope CSSProperties
427
+ // literal or factory — the shape a component uses to style its own root, and the shape the
428
+ // sheet replaces. Comments are stripped first so prose naming the type cannot count.
429
+ const declared: Record<string, number> = {};
430
+ for (const [file, text] of production) {
431
+ const matches = stripComments(text).match(/CSSProperties\s*(?:=\s*\{|=>\s*\()/g);
432
+ if (matches !== null) {
433
+ declared[file] = matches.length;
434
+ }
435
+ }
436
+ expect(declared).toEqual(INLINE_BASE_STYLES);
437
+ });
438
+
439
+ it("injects the sheet from every module that owns a rule, or is reachable from one that does", () => {
440
+ // Twelve marker-rendering modules never call injectTerpStyles and do not need to: the
441
+ // package publishes ONE entry point and declares no `sideEffects`, so importing anything
442
+ // from it loads every module and two dozen of them inject. That guarantee is a packaging
443
+ // property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
444
+ // tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
445
+ // collapsing into one grid item. So the property itself is what this pins.
446
+ expect(Object.keys(manifest.exports)).toEqual(["."]);
447
+ expect(manifest.exports["."]).toBe("./src/index.ts");
448
+ expect(
449
+ "sideEffects" in manifest,
450
+ "declaring sideEffects would let a bundler drop the modules that inject the stylesheet",
451
+ ).toBe(false);
452
+ // And the sheet has many independent injectors reachable from that entry, or the packaging
453
+ // properties above prove nothing on their own. The entry does NOT re-export ./styles — the
454
+ // injection is a module side effect of the components themselves, which is exactly why the
455
+ // sideEffects assertion is the one that matters.
456
+ const injectors = production.filter(([, text]) =>
457
+ stripComments(text).includes("injectTerpStyles()"),
458
+ );
459
+ expect(injectors.length).toBeGreaterThan(20);
460
+ const index = sources["./index.ts"] ?? "";
461
+ for (const [file] of injectors.slice(0, 3)) {
462
+ const module = file.replace(/^\.\//, "./").replace(/\.tsx?$/, "");
463
+ expect(index, `${file} injects the sheet but is not reachable from the entry point`).toContain(
464
+ `from "${module}"`,
465
+ );
466
+ }
467
+ });
245
468
  });
package/src/router.tsx CHANGED
@@ -265,18 +265,15 @@ export function buildAppRouter(
265
265
  logo={options.logo}
266
266
  footer={options.footer}
267
267
  nav={nav}
268
- renderBrandLink={({ to, children, style }) => (
269
- <Link to={to} data-terp="appshell-brand" style={style}>
268
+ renderBrandLink={({ to, children }) => (
269
+ <Link to={to} data-terp="appshell-brand">
270
270
  {children}
271
271
  </Link>
272
272
  )}
273
- renderLink={(item, children, context) => (
274
- <Link
275
- to={item.to}
276
- style={context.style}
277
- activeProps={{ style: { ...context.style, ...context.activeStyle } }}
278
- activeOptions={{ exact: item.to === "/" }}
279
- >
273
+ // No style objects and no activeProps: the shell's stylesheet owns the link
274
+ // geometry and keys the active route on aria-current="page", which Link sets.
275
+ renderLink={(item, children) => (
276
+ <Link to={item.to} activeOptions={{ exact: item.to === "/" }}>
280
277
  {children}
281
278
  </Link>
282
279
  )}
package/src/ssr.test.tsx CHANGED
@@ -24,9 +24,7 @@ describe("server rendering", () => {
24
24
  <AppShell
25
25
  title="Terp"
26
26
  nav={[{ label: "Home", to: "/", icon: "home" }]}
27
- renderLink={(item, children, context) => (
28
- <a href={item.to} style={context.style}>{children}</a>
29
- )}
27
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
30
28
  >
31
29
  <p>content</p>
32
30
  </AppShell>,