@terpjs/react-core 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.
Files changed (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
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",
@@ -37,51 +39,193 @@ const STYLESHEET = "./styles.ts";
37
39
  * stylesheet and the layout contract, so it belongs in a release note.
38
40
  */
39
41
  const MARKERS = [
42
+ "admin-form",
43
+ "admin-payload",
44
+ "admin-section-title",
40
45
  "alert",
46
+ "alert-body",
47
+ "alert-icon",
48
+ "alert-title",
49
+ "appshell",
50
+ "appshell-backdrop",
41
51
  "appshell-brand",
52
+ "appshell-brand-row",
53
+ "appshell-brand-title",
54
+ "appshell-column",
55
+ "appshell-footer",
56
+ "appshell-header",
57
+ "appshell-header-group",
58
+ "appshell-main",
42
59
  "appshell-nav",
60
+ "appshell-nav-label",
61
+ "appshell-nav-list",
62
+ "appshell-sidebar",
43
63
  "badge",
44
64
  "breadcrumbs",
65
+ "breadcrumbs-current",
66
+ "breadcrumbs-separator",
45
67
  "button",
68
+ "button-icon",
69
+ "calendar",
70
+ "calendar-day",
71
+ "calendar-grid",
72
+ "calendar-header",
73
+ "calendar-title",
74
+ "calendar-week",
75
+ "calendar-weekday",
46
76
  "card",
47
77
  "card-actions",
78
+ "card-description",
48
79
  "card-header",
80
+ "card-heading",
81
+ "card-title",
49
82
  "checkbox",
83
+ "combobox",
84
+ "combobox-empty",
85
+ "combobox-field",
86
+ "combobox-list",
87
+ "combobox-option",
88
+ "control-label",
50
89
  "dataview",
90
+ "dataview-actions-cell",
51
91
  "dataview-card",
92
+ "dataview-card-body",
93
+ "dataview-card-expanded",
94
+ "dataview-card-fields",
95
+ "dataview-card-heading",
96
+ "dataview-card-list",
97
+ "dataview-card-main",
98
+ "dataview-card-meta",
99
+ "dataview-card-status",
100
+ "dataview-card-title",
101
+ "dataview-column-option",
102
+ "dataview-column-resizer",
103
+ "dataview-column-settings",
104
+ "dataview-column-settings-title",
105
+ "dataview-column-sort",
106
+ "dataview-error",
107
+ "dataview-expand-cell",
108
+ "dataview-expanded-cell",
109
+ "dataview-inline-action",
110
+ "dataview-pager",
111
+ "dataview-pagination",
52
112
  "dataview-row",
113
+ "dataview-row-actions",
53
114
  "dataview-row-open",
115
+ "dataview-scroll",
116
+ "dataview-select-cell",
117
+ "dataview-skeleton",
54
118
  "dataview-table",
55
119
  "dataview-toolbar",
120
+ "dataview-toolbar-actions",
121
+ "dataview-toolbar-count",
122
+ "dataview-toolbar-layout",
123
+ "dataview-toolbar-search",
124
+ "dataview-toolbar-spacer",
125
+ "dataview-toolbar-status",
56
126
  "detail-list",
127
+ "detail-list-term",
128
+ "detail-list-value",
57
129
  "dialog",
130
+ "dialog-actions",
131
+ "dialog-body",
132
+ "dialog-description",
133
+ "dialog-title",
58
134
  "drawer-focus-end",
59
135
  "drawer-focus-start",
60
136
  "empty-state",
137
+ "empty-state-description",
138
+ "empty-state-icon",
139
+ "empty-state-title",
61
140
  "error-state",
141
+ "error-state-description",
142
+ "error-state-icon",
143
+ "error-state-title",
144
+ "field",
145
+ "field-error",
146
+ "field-hint",
147
+ "field-label",
148
+ "field-label-text",
62
149
  "hubcard",
63
150
  "hubcard-body",
64
151
  "hubcard-description",
152
+ "hubcard-heading",
153
+ "hubcard-icon",
65
154
  "hubcard-link",
66
155
  "hubcard-stat",
67
156
  "hubcard-title",
157
+ "hubpage-grid",
158
+ "icon",
68
159
  "iconbutton",
69
160
  "input",
161
+ "language-switcher",
162
+ "language-switcher-label",
70
163
  "loading-state",
164
+ "loading-state-spinner",
165
+ "login-brand",
166
+ "login-card",
167
+ "login-error",
168
+ "login-form",
169
+ "login-separator",
170
+ "login-separator-rule",
171
+ "login-sso",
172
+ "login-title",
173
+ "login-view",
174
+ "markdown",
71
175
  "menu",
72
176
  "menu-item",
177
+ "menu-item-check",
178
+ "menu-item-icon",
179
+ "menu-trigger",
73
180
  "module-nav",
181
+ "module-nav-link",
182
+ "module-nav-list",
74
183
  "nav-icon",
184
+ "nav-icon-fallback",
185
+ "page",
186
+ "page-actions",
187
+ "page-breadcrumbs",
188
+ "page-header",
189
+ "page-heading",
190
+ "page-title",
75
191
  "popover",
76
192
  "popover-panel",
193
+ "profile-avatar",
194
+ "profile-card",
195
+ "profile-email",
196
+ "profile-role",
77
197
  "radio",
198
+ "radio-group",
199
+ "radio-group-legend",
200
+ "radio-group-options",
78
201
  "resource-list",
202
+ "resource-list-create",
203
+ "resource-list-empty",
204
+ "resource-list-error",
205
+ "resource-list-items",
206
+ "resource-list-row",
79
207
  "spinner-ring",
80
208
  "stack",
81
209
  "switch",
82
210
  "tab",
211
+ "tab-list",
212
+ "tab-panel",
83
213
  "tabs",
214
+ "theme-toggle",
215
+ "theme-toggle-label",
216
+ "toast",
217
+ "toast-body",
218
+ "toast-icon",
219
+ "toast-title",
220
+ "toast-viewport",
84
221
  "tooltip",
222
+ "tooltip-anchor",
223
+ "user-menu",
224
+ "user-menu-avatar",
225
+ "user-menu-email",
226
+ "user-menu-header",
227
+ "user-menu-identity",
228
+ "user-menu-role",
85
229
  ];
86
230
 
87
231
  /**
@@ -98,34 +242,154 @@ const MARKERS = [
98
242
  * the reason splits into two shapes worth knowing before the styling migration is planned:
99
243
  *
100
244
  * - **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.
245
+ * returned a bare `Menu` in their `inline` variant — the variant the app shell header
246
+ * actually uses — so their root was `Popover`'s wrapper and indistinguishable from any
247
+ * other popover. Both have graduated, and the answer added no DOM: `Popover` takes the
248
+ * root's marker as a prop named `data-terp`, `Menu` threads it through, and each
249
+ * component names its own root with `data-variant` separating the variants. `UserMenu` was
250
+ * the same shape and has graduated too it was also the last consumer of `Menu`'s
251
+ * `triggerStyle` and `panelStyle` props, which are gone: a marked root makes the trigger
252
+ * reachable by descending from it, and the PANEL, which is portalled to `document.body`
253
+ * and so reachable from nowhere, carries a `data-owner` attribute naming whose panel it
254
+ * is.
255
+ *
256
+ * The prop is named for the attribute deliberately. The scanner below reads `data-terp`
257
+ * sites in component source, so `<Menu data-terp="theme-toggle">` is seen exactly where
258
+ * it looks; a `rootMarker` prop would have put the only mention of the name somewhere the
259
+ * scanner never looks, and a marker rendered by nobody's `data-terp` site is precisely
260
+ * the blind spot this file exists to close.
261
+ * - **Returned a fragment.** `Markdown` emitted a sequence of block elements with no root at
262
+ * all, and the objection to marking it was that a wrapper is a new block box in every
263
+ * consumer's layout. It has graduated, and that objection turned out to be answerable
264
+ * rather than true: the wrapper is `display: contents`, which generates no box, so the
265
+ * blocks stay in-flow siblings and become real flex or grid items of any parent that
266
+ * spaces its children with `gap`. Zero diff by construction — and a prose-rhythm block
267
+ * wrapper remains a later, deliberate change rather than a side effect of marking.
108
268
  *
109
269
  * 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.
270
+ * the migration itself rather than to preparation for it. The archetypes and the DataView
271
+ * internals are still unexamined.
272
+ *
273
+ * `PageActions` has graduated, and it was the easiest of the shapes above: it already rendered
274
+ * a real root of its own, so the marker landed on an element that existed and no DOM moved.
275
+ * Note the one thing it does that a rule cannot — it returns `null` when it has no actions at
276
+ * all, so its presence is conditional rather than styled, and `:empty` is not a substitute.
277
+ *
278
+ * `files.tsx` has left the list without gaining a marker, which is the one exit this ratchet
279
+ * allows that is not a migration: it turned out to have no styled surface. Its single
280
+ * declaration was `display: none` on the file picker's hidden plumbing input — the visible
281
+ * control is a `Button` — and that element is now `hidden`, the attribute HTML provides for
282
+ * exactly this. A marker plus a `display: none` rule would have put a component with no visual
283
+ * design into the sheet and offered an app the chance to un-hide it.
284
+ *
285
+ * `DetailPage` and `OverviewPage` have left the same way, and they were on this list by
286
+ * mistake rather than by migration: each is a `LayoutSlotContext.Provider` wrapped around
287
+ * `Page` and renders no element of its own at all, so they are view compositions — which the
288
+ * paragraph above already excludes. There is nothing to mark without inventing a box, and the
289
+ * box is the one thing that must not exist here: `Page`'s slot check reads `article.children`,
290
+ * so a wrapper around the body — `display: contents` included, since that check is a DOM
291
+ * traversal and the node is still in the collection — becomes the sole body-slot child, is in
292
+ * no allow table, and fails every governed page closed. Their archetype identity is a context
293
+ * value, which is the right place for it: `Page` renders the only box either of them has, and
294
+ * it is marked.
295
+ *
296
+ * Worth knowing about the shape of this list, because it flatters two files: it names files
297
+ * with NO marker at all, so one marker on one element exempts the rest of the file. `toast.tsx`
298
+ * and `ConfirmDialog.tsx` were never on it despite styling five and four unreachable elements
299
+ * respectively, because each rendered one `iconbutton` or one `dialog`. Both have since
300
+ * migrated; the gap in the ratchet has not.
301
+ *
302
+ * `Field` has graduated: it renders a root plus label, label text, hint and error markers,
303
+ * so each part of a form field is addressable from the sheet.
304
+ *
305
+ * THE LIST IS EMPTY. `LoginView` was the last entry, and it is kept rather than deleted for
306
+ * the same reason as the ledger below: this is where a new unmarked styled surface has to
307
+ * argue for itself. Note what an empty list does NOT say, because the shape above already
308
+ * flatters two files — it names modules with NO marker at all, so one marker anywhere in a
309
+ * file exempts the rest of it.
112
310
  */
113
- const UNMARKED_STYLED_SURFACES = [
114
- "./DetailPage.tsx",
115
- "./Field.tsx",
116
- "./LoginView.tsx",
117
- "./OverviewPage.tsx",
118
- "./PageActions.tsx",
119
- "./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
- ];
311
+ const UNMARKED_STYLED_SURFACES: string[] = [];
312
+
313
+ /**
314
+ * How many module-scope base style objects each file still declares — the migration's own
315
+ * measurable, as a ratchet.
316
+ *
317
+ * This exists because `UNMARKED_STYLED_SURFACES` above flatters a file: it lists modules with
318
+ * NO `data-terp` at all, so a single marker on a single element exempts everything else in the
319
+ * file. `toast.tsx` and `ConfirmDialog.tsx` were never on that list while styling five and four
320
+ * unreachable elements respectively, because each rendered one `iconbutton` or one `dialog`.
321
+ * Both have migrated, and the gap had not — until this.
322
+ *
323
+ * Counted per file rather than as a set of filenames, so a PARTIAL migration shows: moving half
324
+ * of `AppShell`'s twenty-two objects into the sheet has to update the number here. Asserted as
325
+ * exact equality, which makes it a ratchet in both directions — a new base style object fails,
326
+ * and a removed one fails until the ledger is corrected. That is the same bargain `MARKERS`
327
+ * strikes, and it is the point: the number is meant to be read during review.
328
+ *
329
+ * What is deliberately NOT counted: a measured value passed inline at a call site. `Icon` sizes
330
+ * its box from a prop that takes any CSS length, `Stack` passes `align` / `justify` through, and
331
+ * `Popover` positions its panel from a rect it measured — ADR 0094 §3 puts all three on the
332
+ * inline side of the line permanently, so counting them would make this list unable to reach
333
+ * zero and therefore unable to mean anything.
334
+ *
335
+ * IT HAS REACHED ZERO, and the honest reading of that is narrow. The detector matches a
336
+ * module-scope declaration annotated `CSSProperties`, which means a call-site literal and an
337
+ * unannotated module-scope object are both invisible to it. Four of the built-in admin views
338
+ * carried five base styles through this entire migration for exactly that reason — four as
339
+ * call-site object literals (two of them the same `<h2>` declaration, two the same form
340
+ * measure) and one as an unannotated `payloadStyle` — while both ratchets read clean.
341
+ * The next commit widens the detector and migrates them, so that zero means what it looks
342
+ * like it means.
343
+ */
344
+ const INLINE_BASE_STYLES: Record<string, number> = {};
345
+
346
+ /**
347
+ * Every module that renders an inline `style={}` at all, and how many times.
348
+ *
349
+ * This closes the gap the ledger above leaves, and the gap was not theoretical. That one
350
+ * matches a module-scope declaration annotated `CSSProperties`, so a call-site literal and
351
+ * an unannotated module-scope object are both invisible to it — and four of the built-in
352
+ * admin views carried five base styles through the entire styling migration for exactly
353
+ * that reason, with both ratchets reading clean the whole time. Widening the annotation
354
+ * pattern would not have found them either. What finds them is counting the SITES.
355
+ *
356
+ * So this list is the complete one, and every entry has to be a value ADR 0094 §3 puts on
357
+ * the inline side permanently. There are only two such kinds, and a new entry is a claim
358
+ * that there is a third:
359
+ *
360
+ * - A measured value the sheet has no business owning. `icons.tsx` and `LoadingState.tsx`
361
+ * size a box from a prop taking any CSS length; `layout.tsx` passes Stack's `align` /
362
+ * `justify`, an open vocabulary CSS already has; `ui/Popover.tsx` positions its panel
363
+ * from a rect measured at runtime; `dataview/DataViewTable.tsx` carries a column's width
364
+ * while it is being dragged.
365
+ * - The caller's own `style` prop, forwarded to a root. `ui/Checkbox.tsx`, `ui/Radio.tsx`,
366
+ * `ui/Switch.tsx` and `ui/Combobox.tsx` do this and nothing else — it is not the
367
+ * component styling itself, it is the escape a framework component owes its caller, and
368
+ * `ui/Button.test.tsx` asserts it keeps working.
369
+ *
370
+ * Exact equality in both directions, like the ledger above: a new site fails, and a removed
371
+ * one fails until this record is corrected. Comments are stripped first, because this file's
372
+ * own prose says `style={}` repeatedly and the measure is otherwise a grep of its own
373
+ * documentation.
374
+ *
375
+ * Counted with a regex rather than by splitting on the literal `style={`, and that is not
376
+ * tidying. JSX permits whitespace around the `=`, so `style = {{ … }}` and a value on the
377
+ * next line are both valid and both invisible to a literal split — either would have let a
378
+ * base style back in past a green gate, which is the exact failure this list exists to
379
+ * prevent. Verified: the two forms count 0 under the split and 1 under the regex, while
380
+ * every file above counts identically either way.
381
+ */
382
+ const INLINE_STYLE_SITES: Record<string, number> = {
383
+ "./LoadingState.tsx": 1,
384
+ "./dataview/DataViewTable.tsx": 1,
385
+ "./icons.tsx": 1,
386
+ "./layout.tsx": 1,
387
+ "./ui/Checkbox.tsx": 1,
388
+ "./ui/Combobox.tsx": 1,
389
+ "./ui/Popover.tsx": 1,
390
+ "./ui/Radio.tsx": 1,
391
+ "./ui/Switch.tsx": 1,
392
+ };
129
393
 
130
394
  /**
131
395
  * `text` with comments removed, so prose naming a marker cannot stand in for rendering one.
@@ -144,6 +408,13 @@ function stripComments(text: string) {
144
408
  *
145
409
  * A regex anchored to `data-terp="…"` alone misses the conditional form — which is how the
146
410
  * DataView row markers are written — so renaming one of those would not fail anything.
411
+ *
412
+ * The cost of reading a whole expression is that EVERY string literal inside one counts as a
413
+ * marker. `data-terp={variant === "inline" ? "theme-toggle" : undefined}` reports both
414
+ * `inline` and `theme-toggle`, and the first is not a marker at all. That is caught rather
415
+ * than tolerated — the inventory assertion fails on the phantom name — and the fix is to keep
416
+ * a marker expression to marker literals by hoisting the comparison out. Worth knowing before
417
+ * writing a conditional marker, because the failure names a marker nobody added.
147
418
  */
148
419
  function markersIn(text: string) {
149
420
  const source = stripComments(text);
@@ -242,4 +513,62 @@ describe("data-terp markers", () => {
242
513
  "the worklist must stay sorted and duplicate-free",
243
514
  ).toEqual([...new Set(UNMARKED_STYLED_SURFACES)].sort());
244
515
  });
516
+ it("keeps the inline base-style ledger honest, file by file", () => {
517
+ // The migration's measurable, gated. A base style object is a module-scope CSSProperties
518
+ // literal or factory — the shape a component uses to style its own root, and the shape the
519
+ // sheet replaces. Comments are stripped first so prose naming the type cannot count.
520
+ const declared: Record<string, number> = {};
521
+ for (const [file, text] of production) {
522
+ const matches = stripComments(text).match(/CSSProperties\s*(?:=\s*\{|=>\s*\()/g);
523
+ if (matches !== null) {
524
+ declared[file] = matches.length;
525
+ }
526
+ }
527
+ expect(declared).toEqual(INLINE_BASE_STYLES);
528
+ });
529
+
530
+ it("accounts for every inline style site, not only the annotated ones", () => {
531
+ // The measure the admin views escaped. A base style declared as a call-site literal, or
532
+ // as a module-scope object nobody annotated, is a base style either way — and the ledger
533
+ // above cannot see either. This one counts sites, so the only way to stay out of it is to
534
+ // render no inline style at all.
535
+ const sites: Record<string, number> = {};
536
+ for (const [file, text] of production) {
537
+ const count = stripComments(text).match(/style\s*=\s*\{/g)?.length ?? 0;
538
+ if (count > 0) {
539
+ sites[file] = count;
540
+ }
541
+ }
542
+ expect(sites).toEqual(INLINE_STYLE_SITES);
543
+ });
544
+
545
+ it("injects the sheet from every module that owns a rule, or is reachable from one that does", () => {
546
+ // Twelve marker-rendering modules never call injectTerpStyles and do not need to: the
547
+ // package publishes ONE entry point and declares no `sideEffects`, so importing anything
548
+ // from it loads every module and two dozen of them inject. That guarantee is a packaging
549
+ // property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
550
+ // tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
551
+ // collapsing into one grid item. So the property itself is what this pins.
552
+ expect(Object.keys(manifest.exports)).toEqual(["."]);
553
+ expect(manifest.exports["."]).toBe("./src/index.ts");
554
+ expect(
555
+ "sideEffects" in manifest,
556
+ "declaring sideEffects would let a bundler drop the modules that inject the stylesheet",
557
+ ).toBe(false);
558
+ // And the sheet has many independent injectors reachable from that entry, or the packaging
559
+ // properties above prove nothing on their own. The entry does NOT re-export ./styles — the
560
+ // injection is a module side effect of the components themselves, which is exactly why the
561
+ // sideEffects assertion is the one that matters.
562
+ const injectors = production.filter(([, text]) =>
563
+ stripComments(text).includes("injectTerpStyles()"),
564
+ );
565
+ expect(injectors.length).toBeGreaterThan(20);
566
+ const index = sources["./index.ts"] ?? "";
567
+ for (const [file] of injectors.slice(0, 3)) {
568
+ const module = file.replace(/^\.\//, "./").replace(/\.tsx?$/, "");
569
+ expect(index, `${file} injects the sheet but is not reachable from the entry point`).toContain(
570
+ `from "${module}"`,
571
+ );
572
+ }
573
+ });
245
574
  });
@@ -0,0 +1,73 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ import type { ModuleManifest } from "@terpjs/contract";
4
+
5
+ /**
6
+ * The runtime half of a route's declared query-string keys (ADR 0096).
7
+ *
8
+ * The generated `routes.gen.d.ts` is types only — it vanishes at runtime — so
9
+ * `useRouteSearch` needs the same declarations as data. They come from the manifests the
10
+ * router was built from, published through a context rather than a module-level table:
11
+ * a table would be shared by every router composed in one process, so an app embedding
12
+ * another (or a test file composing two) would read declarations it never mounted.
13
+ */
14
+ export const RouteSearchContext = createContext<ReadonlyMap<string, readonly string[]> | null>(
15
+ null,
16
+ );
17
+
18
+ /** The manifest spelling (`:id`), so a `$id` manifest and a `:id` read agree on one key. */
19
+ export function canonicalRoutePath(path: string): string {
20
+ return path.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
21
+ }
22
+
23
+ /**
24
+ * Index every manifest route's declared search keys by its canonical path.
25
+ *
26
+ * Two manifests mounting one path union their keys, matching what the generator emits into
27
+ * the type table — so the runtime read and the compile-time check agree.
28
+ */
29
+ export function indexSearchKeys(
30
+ manifests: readonly ModuleManifest[],
31
+ ): ReadonlyMap<string, readonly string[]> {
32
+ const index = new Map<string, readonly string[]>();
33
+ for (const manifest of manifests) {
34
+ for (const route of manifest.routes) {
35
+ const key = canonicalRoutePath(route.path);
36
+ index.set(key, [...new Set([...(index.get(key) ?? []), ...(route.search ?? [])])]);
37
+ }
38
+ }
39
+ return index;
40
+ }
41
+
42
+ /**
43
+ * The search keys declared for *path*, or a refusal naming what is mounted.
44
+ *
45
+ * A path the router never mounted is a programming error, not an empty result: returning
46
+ * `{}` would hand a screen `undefined` for every key it asked for, which reads as "no
47
+ * filters applied" — the silent-wrong-answer this refusal replaces.
48
+ */
49
+ export function declaredSearchKeys(
50
+ index: ReadonlyMap<string, readonly string[]> | null,
51
+ path: string,
52
+ ): readonly string[] {
53
+ if (index === null) {
54
+ throw new Error(
55
+ `useRouteSearch("${path}") was called outside a Terp router. It reads the declarations ` +
56
+ "the router was built from, so it only works under a view mounted by buildAppRouter.",
57
+ );
58
+ }
59
+ const declared = index.get(canonicalRoutePath(path));
60
+ if (declared === undefined) {
61
+ throw new Error(
62
+ `Route "${path}" is not a mounted route, so its search keys are unknown (mounted: ` +
63
+ `${[...index.keys()].join(", ") || "none"}). useRouteSearch takes the path as written ` +
64
+ "in the module manifest — check it there, and run `terp routes` if the manifest changed.",
65
+ );
66
+ }
67
+ return declared;
68
+ }
69
+
70
+ /** The mounted routes' search-key index, or null outside a Terp router. */
71
+ export function useRouteSearchIndex(): ReadonlyMap<string, readonly string[]> | null {
72
+ return useContext(RouteSearchContext);
73
+ }
package/src/routeTypes.ts CHANGED
@@ -26,6 +26,24 @@
26
26
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
27
27
  export interface TerpRouteTable {}
28
28
 
29
+ /**
30
+ * The query-string half of the same table (ADR 0096): path -> its declared search keys.
31
+ *
32
+ * Params were checked and search keys were not, which sounds like a gap and behaves like
33
+ * a hole: a list screen's filters, its sort and its page cursor all live in the query
34
+ * string, so *every* screen with a filter had to leave the typed seam and reach for the
35
+ * router's own `useNavigate` / `useSearch` — losing path checking too, on the majority of
36
+ * screens. Declaring the keys in the manifest (`search: ["status", "page"]`) is what lets
37
+ * navigation and reads stay inside the checked seam.
38
+ *
39
+ * Keyed only for routes that declare keys, so a route with none refuses `search` outright
40
+ * rather than accepting anything. Values are `string | undefined`: a query parameter is
41
+ * text and is absent until set — parsing is the screen's business, and the declaration is
42
+ * what stops the *key* being a typo.
43
+ */
44
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
45
+ export interface TerpRouteSearchTable {}
46
+
29
47
  /** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
30
48
  type Ungenerated = keyof TerpRouteTable extends never ? true : false;
31
49
 
@@ -50,14 +68,40 @@ export type TerpRouteParamName = Ungenerated extends true
50
68
  : { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
51
69
 
52
70
  /**
53
- * A navigation target: a declared path, plus that path's params when it takes any.
54
- * A paramless route refuses a `params` object; a parameterised one requires it, with
55
- * the names the manifest declared. Before generating, this is the loose shape.
71
+ * One route's declared query-string keys, all optional.
72
+ *
73
+ * Three cases, and the middle one is the point. Before `terp routes` has generated, the
74
+ * shape is loose (like {@link TerpRouteParams}) so an app that has not adopted keeps
75
+ * today's behavior. Once generated, a route that declared keys gets exactly those; a
76
+ * route that declared **none** gets `Record<never, never>`, so passing `search` to it is a
77
+ * typecheck error rather than a value silently dropped into the URL.
78
+ */
79
+ export type TerpRouteSearch<P extends TerpRoutePath> = P extends keyof TerpRouteSearchTable
80
+ ? TerpRouteSearchTable[P]
81
+ : Ungenerated extends true
82
+ ? Record<string, string | undefined>
83
+ : Record<never, never>;
84
+
85
+ /** Every search key any route declares — or `string` before generating. */
86
+ export type TerpRouteSearchKey = Ungenerated extends true
87
+ ? string
88
+ : { [P in keyof TerpRouteSearchTable]: keyof TerpRouteSearchTable[P] }[keyof TerpRouteSearchTable] &
89
+ string;
90
+
91
+ /**
92
+ * A navigation target: a declared path, that path's params when it takes any, and that
93
+ * path's declared search keys when it reads any.
94
+ *
95
+ * A paramless route refuses a `params` object; a parameterised one requires it, with the
96
+ * names the manifest declared. `search` is always optional (a route may be visited with
97
+ * no filters applied) but its *keys* are the declared ones, so a typo is a typecheck
98
+ * error and a route that declares no search keys refuses the property. Before generating,
99
+ * this is the loose shape.
56
100
  */
57
101
  export type TerpNavigateTarget = Ungenerated extends true
58
- ? { to: string; params?: Record<string, string> }
102
+ ? { to: string; params?: Record<string, string>; search?: Record<string, string | undefined> }
59
103
  : {
60
- [P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
104
+ [P in keyof TerpRouteTable & string]: (keyof TerpRouteTable[P] extends never
61
105
  ? { to: P; params?: undefined }
62
- : { to: P; params: TerpRouteTable[P] };
106
+ : { to: P; params: TerpRouteTable[P] }) & { search?: TerpRouteSearch<P> };
63
107
  }[keyof TerpRouteTable & string];