@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
@@ -1,7 +1,51 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
3
 
4
- import { TERP_STYLES_ID, injectTerpStyles } from "./styles";
4
+ import { TERP_STYLES_ID, TERP_STYLES_CSS, injectTerpStyles } from "./styles";
5
+
6
+ /** The sheet with comments removed — prose must not satisfy a structural assertion. */
7
+ const css = TERP_STYLES_CSS.replace(/\/\*[\s\S]*?\*\//g, "");
8
+
9
+ /**
10
+ * The body of one `@layer <name> { … }` block, brace-matched.
11
+ *
12
+ * Throws rather than returning "" for a name that is not in the sheet: several assertions
13
+ * below are `.not.toContain(...)`, which an empty string satisfies, so a typo'd or renamed
14
+ * layer would silently disable them instead of failing.
15
+ */
16
+ function layerBody(name: string): string {
17
+ const open = css.indexOf(`@layer ${name} {`);
18
+ if (open === -1) throw new Error(`styles.ts declares no @layer ${name}`);
19
+ let depth = 0;
20
+ for (let i = css.indexOf("{", open); i < css.length; i += 1) {
21
+ if (css[i] === "{") depth += 1;
22
+ else if (css[i] === "}") {
23
+ depth -= 1;
24
+ if (depth === 0) return css.slice(css.indexOf("{", open) + 1, i);
25
+ }
26
+ }
27
+ throw new Error(`@layer ${name} is not brace-balanced`);
28
+ }
29
+
30
+ /**
31
+ * Whether `body` declares a rule whose selector list contains EXACTLY `selector` — not
32
+ * merely a selector containing it as a substring.
33
+ *
34
+ * The distinction is the whole point: `[data-terp="button"]` is a substring of
35
+ * `[data-terp="button"][data-variant="primary"]`, so a substring check keeps passing after
36
+ * the component's actual base block is deleted and only its variants remain.
37
+ */
38
+ function declaresRuleFor(body: string, selector: string): boolean {
39
+ // The selector group excludes braces, so each match starts naturally after the previous
40
+ // rule's closing brace. Anchoring on that brace instead would consume it and match only
41
+ // every other rule — which is how this helper first read the sheet, and what made it
42
+ // report a missing base rule for `input` and a missing gap-0 rule for `stack`.
43
+ for (const match of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
44
+ const selectors = match[1].split(",").map((part) => part.trim().replace(/\s+/g, " "));
45
+ if (selectors.includes(selector) && match[2].trim().length > 0) return true;
46
+ }
47
+ return false;
48
+ }
5
49
 
6
50
  afterEach(() => {
7
51
  document.querySelectorAll(`style#${TERP_STYLES_ID}`).forEach((node) => node.remove());
@@ -21,10 +65,815 @@ describe("injectTerpStyles", () => {
21
65
 
22
66
  it("themes scrollbars against the token palette (thin, not the OS default)", () => {
23
67
  injectTerpStyles();
24
- const css = document.querySelector(`style#${TERP_STYLES_ID}`)?.textContent ?? "";
25
- expect(css).toContain("scrollbar-width: thin");
26
- expect(css).toContain("scrollbar-color: var(--color-neutral-300) transparent");
27
- expect(css).toContain("::-webkit-scrollbar");
28
- expect(css).toContain("::-webkit-scrollbar-thumb");
68
+ const sheet = document.querySelector(`style#${TERP_STYLES_ID}`)?.textContent ?? "";
69
+ expect(sheet).toContain("scrollbar-width: thin");
70
+ expect(sheet).toContain("scrollbar-color: var(--color-neutral-300) transparent");
71
+ expect(sheet).toContain("::-webkit-scrollbar");
72
+ expect(sheet).toContain("::-webkit-scrollbar-thumb");
73
+ });
74
+ });
75
+
76
+ // The cascade structure the migration rests on (ADR 0094). None of it was pinned by
77
+ // anything, and it is invisible to every other lane in the repo: jsdom does not compute the
78
+ // cascade, and the visual baselines only capture resting state, so a broken focus ring, a
79
+ // broken disabled treatment or an ignored reduced-motion preference all render as a passing
80
+ // suite. These assertions are cheap precisely because they read the sheet as text.
81
+ describe("cascade structure", () => {
82
+ it("declares the layer order the rules depend on, before any rule", () => {
83
+ // Without this statement the layers would be ordered by first appearance, which is the
84
+ // same order today and would silently stop being so the moment a rule is added above.
85
+ expect(css).toContain("@layer terp.reset, terp.base, terp.state, terp.motion;");
86
+ expect(css.indexOf("@layer terp.reset, terp.base")).toBeLessThan(css.indexOf("@layer terp.base {"));
87
+ });
88
+
89
+ it("gives the focus ring an opaque colour, not a transparent outline", () => {
90
+ // SC 1.4.11 asks 3:1 of a focus indicator. The ring used to declare a TRANSPARENT
91
+ // outline, which left a translucent box-shadow as the whole visible indicator — and a
92
+ // translucent shadow's effective colour is its alpha blend over the surface behind it.
93
+ // Blended, it measured 1.67 in light and never better than 2.73 in any theme but
94
+ // contrast, so four of five shipped palettes failed on every focusable component.
95
+ //
96
+ // A text assertion because nothing else can hold it: the baselines capture the resting
97
+ // state, axe does not evaluate focus indicators, and the keyboard lane is about where
98
+ // focus goes. Mutating the colour back to transparent must fail here.
99
+ const state = layerBody("terp.state");
100
+ const at = state.indexOf("[data-terp]:focus-visible");
101
+ expect(at).toBeGreaterThan(-1);
102
+ const block = state.slice(state.indexOf("{", at) + 1, state.indexOf("}", at));
103
+ expect(block, "the focus ring's outline must carry a colour").toContain(
104
+ "outline: 2px solid var(--color-fg-accent)",
105
+ );
106
+ expect(block, "a transparent outline leaves only a translucent shadow").not.toContain(
107
+ "solid transparent",
108
+ );
109
+ // And the halo stays, because it is what makes the indicator legible against a busy
110
+ // surface rather than a hairline on it.
111
+ expect(block).toContain("box-shadow: 0 0 0 3px var(--color-focus-ring)");
112
+ });
113
+
114
+ it("keeps the shared focus ring in terp.state, not terp.base", () => {
115
+ // The ring and [data-terp="button"][data-variant="primary"] both weigh (0,2,0), so in a
116
+ // single layer the later rule wins — and the ring is declared first. In terp.base the
117
+ // primary button's resting box-shadow suppresses it entirely (measured: the focused
118
+ // button computes its resting shadow instead of the ring).
119
+ expect(layerBody("terp.state")).toContain("[data-terp]:focus-visible");
120
+ expect(layerBody("terp.base")).not.toContain(":focus-visible");
121
+ });
122
+
123
+ it("declares no !important in terp.base — a base rule never needs to shout", () => {
124
+ // !important in this sheet means exactly one thing: a rule that must beat an inline base
125
+ // style on a component that has not migrated yet. That is always a state rule, so an
126
+ // !important appearing in terp.base is a sign someone escalated a resting style.
127
+ expect(layerBody("terp.base")).not.toContain("!important");
128
+ });
129
+
130
+ it("has no rule left that needs an escalation, and says which file was the last", () => {
131
+ // The tax comes off per CONSUMER, not per rule, and the condition is the LAST element a
132
+ // selector matches rather than the first. Every entry below names the inline declaration it
133
+ // has to out-shout, so the next reader can re-derive when it may retire instead of trusting
134
+ // this list — the previous version of this test named ConfirmDialog and Popover as reasons
135
+ // for the focus ring, and both had migrated a stage earlier.
136
+ //
137
+ // This direction matters more than the converse and used to be pinned six times worse: the
138
+ // regression that shipped — a disabled Combobox painted exactly like an enabled one — came
139
+ // from dropping an escalation early, and only three of twelve were asserted. Every rule here
140
+ // is a hover, disabled or focus state, which is to say invisible to both lanes: the
141
+ // baselines capture the resting state and axe does not evaluate hover.
142
+ // This list is empty, and keeping the test rather than deleting it is the point: the
143
+ // direction it guards is the one that shipped a regression (a disabled Combobox painted
144
+ // exactly like an enabled one, from dropping an escalation while an inline consumer
145
+ // remained). It stays as the place a new escalation has to justify itself.
146
+ //
147
+ // AppShell was the last file, and it blocked five of the seven on its own: the shared
148
+ // icon-button hover's background and colour (toggleStyle), the nav link's background and
149
+ // colour (NAV_LINK_STYLE), and the reduced-motion override (three inline transitions, one
150
+ // of them on an <aside> carrying no marker at all).
151
+ const state = layerBody("terp.state");
152
+ const stillInline: [string, string][] = [];
153
+ for (const [rule, consumer] of stillInline) {
154
+ const at = state.indexOf(rule);
155
+ expect(at, `${rule} should still be declared`).toBeGreaterThan(-1);
156
+ expect(
157
+ state.slice(at, state.indexOf("}", at)),
158
+ `${rule} must keep its escalation while ${consumer} is inline`,
159
+ ).toContain("!important");
160
+ }
161
+ // The reduced-motion override no longer shouts either, and that one is a decision rather
162
+ // than bookkeeping. Its three inline consumers are gone, so layer order is enough —
163
+ // terp.motion sits above terp.base and terp.state. Retiring it also has a merit of its own:
164
+ // the block sets `transition: none` for EVERY transition, including pure colour fades that
165
+ // are not motion at all, so leaving it unoverridable was the over-broad choice. An app can
166
+ // now narrow it from theme.css, which is exactly the power this phase exists to hand over.
167
+ const motion = layerBody("terp.motion");
168
+ expect(motion, "the reduced-motion override should still be declared").toContain(
169
+ "transition: none",
170
+ );
171
+ expect(motion, "its last inline consumer migrated with AppShell").not.toContain("!important");
172
+ });
173
+
174
+ it("keeps the shared icon-button hover from impersonating a pressed toggle", () => {
175
+ // This rule shouts, because AppShell's toggleStyle declares background and colour inline
176
+ // on the shell's two header toggles and nothing but !important reaches a style attribute.
177
+ // Its two values are byte-identical to the PRESSED look of the DataView toolbar's layout
178
+ // toggles, which wear the same marker — so unguarded, hovering an inactive toggle paints it
179
+ // exactly like the active one, and the toggle group's only job is to say which layout is
180
+ // current.
181
+ //
182
+ // What makes that unrecoverable rather than merely wrong is the escalation. For important
183
+ // declarations the layer order reverses and unlayered styles sort last, so no author rule
184
+ // at any specificity — including an app's theme.css — can put the inactive colour back. The
185
+ // fix is therefore a guard on the rule and not an override anywhere else.
186
+ //
187
+ // Invisible to all three lanes: the baselines capture the resting state, axe reads a static
188
+ // tree, and the keyboard lane asserts where focus goes rather than what it paints.
189
+ const state = layerBody("terp.state");
190
+ const at = state.indexOf('[data-terp="iconbutton"]:hover');
191
+ expect(at, "the shared icon-button hover should still be declared").toBeGreaterThan(-1);
192
+ expect(
193
+ state.slice(at, state.indexOf("{", at)),
194
+ "an !important hover must not reach a pressed toggle",
195
+ ).toContain(':not([aria-pressed="true"])');
196
+ // And the precedent it copies, verbatim, so deleting either guard is one failure and not
197
+ // two independent ones nobody connects.
198
+ expect(
199
+ state,
200
+ "the tab precedent this guard is modelled on must stay in the sheet",
201
+ ).toContain('[data-terp="tab"]:hover:not(:disabled):not([aria-selected="true"])');
202
+ });
203
+
204
+ it("keeps the shell declarations no lane can reach", () => {
205
+ // Three groups, and every one of them was established by mutation rather than assumed.
206
+ //
207
+ // The sidebar's flex-shrink. It is a flex item with an explicit width in a container that
208
+ // has room to spare at the pinned 1280px viewport, so deleting it moves no baseline —
209
+ // measured. It bites at narrow DESKTOP widths, above the mobile breakpoint where the drawer
210
+ // takes over, which the screenshot lane cannot reach because the viewport is pinned.
211
+ const base = layerBody("terp.base");
212
+ const at = base.indexOf('[data-terp="appshell-sidebar"] {');
213
+ expect(at, "the sidebar should have a base rule").toBeGreaterThan(-1);
214
+ expect(
215
+ base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at)),
216
+ "without this the rail squeezes instead of the content, between the breakpoint and wide",
217
+ ).toContain("flex-shrink: 0");
218
+ // The three z-index tokens the shell is the first reader of. Before this the family shipped
219
+ // with --z-index-drawer read by nothing at all, while the one element that wanted it — this
220
+ // drawer — hardcoded 50, and the token's only binding anywhere pointed at the popover level
221
+ // instead. A hardcoded number here would move no baseline and read as correct.
222
+ for (const [token, selector] of [
223
+ ["--z-index-drawer", "the mobile drawer"],
224
+ ["--z-index-backdrop", "the drawer's backdrop"],
225
+ ["--z-index-sticky", "the sticky header"],
226
+ ] as const) {
227
+ expect(base, `${selector} must read ${token} rather than hardcoding its number`).toContain(
228
+ `z-index: var(${token})`,
229
+ );
230
+ }
231
+ // And the mobile half of the shell, which the screenshot lane cannot render at all: the
232
+ // viewport is pinned at 1280 and the drawer needs 768 or less. The BEHAVIOUR is covered by
233
+ // AppShell.test.tsx (focus containment, inert page, close-on-nav) with a stubbed matchMedia;
234
+ // the geometry is covered by nothing, so these three exist as text or not at all.
235
+ for (const selector of [
236
+ '[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"]',
237
+ '[data-terp="appshell-backdrop"]',
238
+ '[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-main"]',
239
+ ]) {
240
+ expect(
241
+ declaresRuleFor(base, selector),
242
+ `${selector} is mobile-only, so no baseline can hold it`,
243
+ ).toBe(true);
244
+ }
245
+ });
246
+
247
+ it("keeps the hub-card declarations the lanes cannot explain", () => {
248
+ // Both of these were found by mutation: deleting either moves no baseline, and neither is
249
+ // dead. This is their only gate.
250
+ //
251
+ // The placeholder rows. A non-breaking space paints nothing, so a hidden placeholder and a
252
+ // visible one are pixel-identical — measured at width 557 and height 89 either way. The
253
+ // declaration exists for the accessibility tree: visibility: hidden removes the text, and
254
+ // without it a screen reader announces a blank row on every card missing a description or a
255
+ // stat. It must therefore stay `visibility` and never become `opacity`, which hides the text
256
+ // from sight while leaving it announced.
257
+ const base = layerBody("terp.base");
258
+ const at = base.indexOf('[data-terp="hubcard-description"][data-empty="true"]');
259
+ expect(at, "the placeholder rule should be declared").toBeGreaterThan(-1);
260
+ const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
261
+ expect(block, "a placeholder row must leave the accessibility tree, not just the page").toContain(
262
+ "visibility: hidden",
263
+ );
264
+ expect(block, "opacity would hide the text from sight and leave it announced").not.toContain(
265
+ "opacity",
266
+ );
267
+ // The card's height chain, and this assertion had to be corrected once: the link's
268
+ // display: block and height: 100% look like the middle rung and are not. Measured in a row
269
+ // stretched past the body's 10rem floor, forcing that anchor inline changes nothing —
270
+ // percentage heights resolve against the nearest block container and skip inline boxes, so
271
+ // the body reaches past the anchor to the <li> the grid stretched. Nor does removing the
272
+ // grid's align-items: stretch (grid's default anyway), its grid-auto-rows: 1fr, or the
273
+ // card's own height: 100%.
274
+ //
275
+ // Exactly one declaration equalises two cards, and this is it. Removing it in that same
276
+ // stretched row drops the bare card to 160 while its neighbour stays at 189 — which
277
+ // hub-card-bare now catches, and catches only because its full card is deliberately long
278
+ // enough to set the row height. Kept here as well as in the baseline, because the baseline
279
+ // cannot say WHY the two cards match.
280
+ const body = base.indexOf('[data-terp="hubcard-body"]');
281
+ expect(body, "the hub-card body rule should be declared").toBeGreaterThan(-1);
282
+ expect(
283
+ base.slice(base.indexOf("{", body) + 1, base.indexOf("}", body)),
284
+ "the body's percentage height is the only thing equalising two cards in a row",
285
+ ).toContain("height: 100%");
286
+ });
287
+
288
+ it("puts the DataView's surface on the full variant, not on the bare marker", () => {
289
+ // Both values of data-variant are stamped and only one has a rule, which is what keeps
290
+ // the embedded variant a bare grid. The alternative — surface on the marker, un-declared
291
+ // under [data-variant="embedded"] — needs background: transparent, border: 0 and
292
+ // border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
293
+ //
294
+ // dataview-embedded is the negative evidence this rests on: deleting the full-variant
295
+ // rule must move five baselines and leave that one untouched.
296
+ const base = layerBody("terp.base");
297
+ expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
298
+ expect(
299
+ declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
300
+ "the surface belongs to the full variant",
301
+ ).toBe(true);
302
+ expect(
303
+ base,
304
+ "un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
305
+ ).not.toContain('[data-variant="embedded"]');
306
+ // display: grid is asserted HERE because nothing else can: forcing the root to block
307
+ // leaves every measured dimension identical on dataview-wide, dataview-full and
308
+ // dataview-embedded — the three children are full-width block boxes with no margins, which
309
+ // a single-column grid and a block container stack the same way — so deleting it moves no
310
+ // baseline. This assertion is the only thing standing between that declaration and a
311
+ // silent removal.
312
+ //
313
+ // It matters in one direction: under grid, a wide table without the scroll wrapper's
314
+ // overflow-x widens the whole DataView, because a grid item's automatic minimum size is
315
+ // content-based unless its overflow is not visible. The two are coupled, and the second
316
+ // half is what dataview-wide catches.
317
+ const at = base.indexOf('[data-terp="dataview"]');
318
+ expect(base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at))).toContain("display: grid");
319
+ expect(declaresRuleFor(base, '[data-terp="dataview-scroll"]'), "the pair's other half").toBe(
320
+ true,
321
+ );
322
+ });
323
+
324
+ it("keeps the toolbar band declaring its own surface, and no ink", () => {
325
+ // Three separate invariants about one element, and each has a way of going wrong that
326
+ // nothing else in the suite can see.
327
+ //
328
+ // The background. The inline style this replaced read `selectionMode ? neutral-50 :
329
+ // neutral-0` — an explicit value in BOTH branches, so the resting rule has to carry
330
+ // neutral-0 rather than leaving it to the host. Against every composed DataView specimen
331
+ // dropping it moves nothing, because the full variant's root and the workbench's specimen
332
+ // card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
333
+ // declares nothing but a display, and the band would show the page canvas through it.
334
+ // `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
335
+ //
336
+ // The two top radii, which are load-bearing rather than decorative: the DataView root
337
+ // rounds its border with no overflow: hidden, so nothing else keeps the selection band's
338
+ // neutral-50 inside the rounded frame.
339
+ //
340
+ // And NO colour. Two of this element's direct children are arbitrary caller slots
341
+ // (`children` and `trailing`), so a muted ink here would inherit into app-authored filter
342
+ // controls — a silent restyle of app DOM, which is exactly what this migration exists to
343
+ // stop the framework doing.
344
+ const base = layerBody("terp.base");
345
+ const at = base.indexOf('[data-terp="dataview-toolbar"]');
346
+ expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
347
+ const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
348
+ expect(block, "the band must declare its own surface, not inherit the host's").toContain(
349
+ "background: var(--color-neutral-0)",
350
+ );
351
+ for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
352
+ expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
353
+ `${radius}: var(--radius-lg)`,
354
+ );
355
+ }
356
+ expect(
357
+ /(^|;)\s*color:/.test(block),
358
+ "a colour here inherits into the caller's filter slot and trailing slot",
359
+ ).toBe(false);
360
+ // Selection mode is a resting surface, so it belongs in terp.base and wins at (0,2,0)
361
+ // against the band's (0,1,0) — on specificity, with no :not() and no source-order
362
+ // dependency. In terp.state it would beat the focus ring's layer for no reason.
363
+ expect(
364
+ declaresRuleFor(base, '[data-terp="dataview-toolbar"][data-variant="selection"]'),
365
+ "selection mode must be a base rule of its own",
366
+ ).toBe(true);
367
+ expect(
368
+ layerBody("terp.state"),
369
+ "selection mode is a resting surface, not an interaction state",
370
+ ).not.toContain('[data-variant="selection"]');
371
+ // The bar reads the density token rather than a literal --space-3, which is what makes a
372
+ // compact view's band line up with its first cell. Zero-diff at comfortable, because
373
+ // comfortable --density-cell-pad-x IS --space-3 — so only dataview-compact can catch it.
374
+ expect(block, "the band's inline padding must follow density").toContain(
375
+ "padding: var(--space-2) var(--density-cell-pad-x)",
376
+ );
377
+ });
378
+
379
+ it("signals the active layout with more than a colour", () => {
380
+ // Two icon-only controls whose entire job is to say which layout is current, so which one
381
+ // is pressed cannot be carried by ink alone (SC 1.4.1) and its indicator has to clear 3:1
382
+ // (SC 1.4.11). Measured over the manifest's per-theme values: the neutral-100 fill against
383
+ // the band's transparent is 1.09-1.16 across the five themes, nowhere near it; the accent
384
+ // border is 4.95 at worst against its own fill and 5.75 at worst against the band.
385
+ //
386
+ // A text assertion because nothing else can hold it. The baselines DO see the border, but
387
+ // they cannot say why it is there — re-recording them is one keystroke, and the reason this
388
+ // rule carries a border rather than only a wash is exactly what a re-record erases.
389
+ //
390
+ // Keyed on aria-pressed rather than a data attribute duplicating it — the
391
+ // [data-terp="tab"][aria-selected="true"] precedent — and the reuse is safe in the strong
392
+ // sense that case defines: DataViewToolbar is the sole author of that attribute on these
393
+ // two elements, setting it from its own layout prop, and no router, wrapper or caller can
394
+ // reach them.
395
+ //
396
+ // Deliberately NOT a box-shadow: [data-terp]:focus-visible sets box-shadow in this same
397
+ // layer at (0,2,0) against this rule's (0,3,0), so an inset shadow here would win and a
398
+ // focused active toggle would lose its focus ring.
399
+ const state = layerBody("terp.state");
400
+ const selector =
401
+ '[data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"][aria-pressed="true"]';
402
+ expect(declaresRuleFor(state, selector), `${selector} must be declared`).toBe(true);
403
+ const at = state.indexOf(selector);
404
+ const block = state.slice(state.indexOf("{", at) + 1, state.indexOf("}", at));
405
+ expect(block, "pressed must carry a non-colour signal, not a wash alone").toContain(
406
+ "border-color: var(--color-fg-accent)",
407
+ );
408
+ expect(
409
+ block,
410
+ "an inset box-shadow here outranks the shared focus ring in the same layer",
411
+ ).not.toContain("box-shadow");
412
+ });
413
+
414
+ it("keeps the focus-within tint scoped to rows something will actually open", () => {
415
+ // The row marker is unconditional, so this selector reaches every row unless it says
416
+ // otherwise. Unguarded it paints a brand wash on any row holding focus — a selection
417
+ // checkbox in a view with no onRowClick — over that row's status tone, for a row nothing
418
+ // will open. Invisible to all three lanes: resting baselines, a static tree, and a
419
+ // keyboard lane that asserts where focus goes rather than what it paints.
420
+ const state = layerBody("terp.state");
421
+ expect(
422
+ declaresRuleFor(state, '[data-terp="dataview-row"][data-clickable="true"]:focus-within td'),
423
+ "the row's focus-within tint must be guarded on data-clickable",
424
+ ).toBe(true);
425
+ expect(
426
+ state,
427
+ "an unguarded row focus-within selector would reach every row with a checkbox in it",
428
+ ).not.toContain('[data-terp="dataview-row"]:focus-within');
429
+ });
430
+
431
+ it("names every element the reduced-motion block has to reach", () => {
432
+ // The layer wins the cascade, but only over elements a selector actually matches. A
433
+ // transition on an element with no data-terp of its own escapes unless it is listed
434
+ // here by name — which is how the breadcrumb link kept animating.
435
+ const motion = layerBody("terp.motion");
436
+ for (const selector of [
437
+ "[data-terp]",
438
+ '[data-terp="appshell-nav"] a',
439
+ '[data-terp="breadcrumbs"] a',
440
+ '[data-terp="dataview-table"] tbody tr',
441
+ ]) {
442
+ expect(motion, `${selector} must be in the reduced-motion selector list`).toContain(selector);
443
+ }
444
+ // Every transition this sheet declares is either on a marked element or on one of the
445
+ // descendant selectors above. A new descendant rule with a transition has to join them.
446
+ const base = layerBody("terp.base");
447
+ for (const match of base.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g)) {
448
+ const selector = match[1].trim().replace(/\s+/g, " ");
449
+ const marked = selector.includes("[data-terp") && !/\s[a-z]+$/.test(selector);
450
+ expect(
451
+ marked || motion.includes(selector),
452
+ `${selector} declares a transition but reduced motion cannot reach it`,
453
+ ).toBe(true);
454
+ }
455
+ });
456
+
457
+ it("declares a gap rule for every step SpaceToken allows", () => {
458
+ // gap moved from a computed inline value to a rule per step, so the union and the sheet
459
+ // are now two lists maintained by hand. Widening SpaceToken without adding rules would
460
+ // silently fall back to the default gap rather than fail.
461
+ const base = layerBody("terp.base");
462
+ for (const token of [0, 1, 2, 3, 4, 6, 8]) {
463
+ for (const marker of ["stack", "card"]) {
464
+ expect(
465
+ declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
466
+ `${marker} has no rule for gap ${token}`,
467
+ ).toBe(true);
468
+ }
469
+ }
470
+ });
471
+
472
+ it("carries no !important on a marker whose every consumer has migrated", () => {
473
+ // The converse, so the escalation cannot outlive its reason. A rule left shouting after
474
+ // its last inline consumer is gone is invisible: it works, and it silently outranks the
475
+ // app theme.css that ADR 0094 exists to empower.
476
+ const state = layerBody("terp.state");
477
+ for (const rule of [
478
+ '[data-terp="input"]:hover',
479
+ '[data-terp="input"]:focus',
480
+ '[data-terp="input"]:disabled',
481
+ '[data-terp="input"][aria-invalid="true"]',
482
+ '[data-terp="tab"]:hover',
483
+ '[data-terp="tab"]:disabled',
484
+ // MenuItem is the only element in the package wearing this marker, so its escalation
485
+ // retired the moment the component stopped carrying inline base styles — unlike
486
+ // `input`, which six elements wear and which had to wait for the last of them.
487
+ '[data-terp="menu-item"]:hover',
488
+ '[data-terp="menu-item"][data-selected="true"]',
489
+ '[data-terp="menu-item"]:disabled',
490
+ '[data-terp="menu-trigger"]:hover',
491
+ // The brand link declares no inline background — brandLinkStyle sets colour, radius and
492
+ // box-sizing and nothing else — so this rule never had anything to out-shout.
493
+ '[data-terp="appshell-brand"]:hover',
494
+ // The textbook per-consumer case, in both directions. This selector has two halves
495
+ // and they were never in the same state: the row half had nothing to out-shout,
496
+ // because a body cell declares no background, while the card half faced
497
+ // DataViewCardList's inline background on the element it matches. The escalation
498
+ // came off when the card migrated, which is the LAST of the two rather than the
499
+ // first — and the card's tone rules now lose to it on layer instead.
500
+ '[data-terp="dataview-card"]:focus-within',
501
+ // Re-derived rather than assumed, because the condition is not "has something
502
+ // migrated" but "can any element this selector matches still beat it". Of the SIXTEEN
503
+ // sites wearing the iconbutton marker, only six can carry the disabled attribute at all:
504
+ // the four pagination arrows and the two reorder arrows. The shell's toggles, the toast
505
+ // dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle
506
+ // and the toolbar's clear-search button and two layout toggles have no disabled state —
507
+ // and each of the six set cursor inline until it migrated. The day a calendar arrow gains
508
+ // a min/max bound, this answer changes back.
509
+ '[data-terp="iconbutton"]:disabled',
510
+ // HubPage was the condition for both of these, and it was the condition in the strongest
511
+ // form: hubcard-body's border and hubcard-title's colour were declared inline on the very
512
+ // elements these selectors match, so no layered rule could reach them at any specificity.
513
+ // Both surfaces come from terp.base now.
514
+ '[data-terp="hubcard"]:hover [data-terp="hubcard-body"]',
515
+ '[data-terp="hubcard"]:hover [data-terp="hubcard-title"]',
516
+ // AppShell's, and it blocked these two on its own: toggleStyle declared background and
517
+ // colour inline on the shell's two toggles, the last elements wearing the icon-button
518
+ // marker able to out-rank a layered rule. Their resting look is a scoped base rule now,
519
+ // so this wins on LAYER — terp.state over terp.base — whatever its specificity.
520
+ '[data-terp="iconbutton"]:hover',
521
+ // And the nav link's, whose consumer was NAV_LINK_STYLE: a CSSProperties object exported
522
+ // for every router's link renderer to spread onto its own element, so colour and
523
+ // background were inline on the very elements this selector matches.
524
+ '[data-terp="appshell-nav"] a:hover',
525
+ ]) {
526
+ const at = state.indexOf(rule);
527
+ expect(at, `${rule} should still be declared`).toBeGreaterThan(-1);
528
+ const block = state.slice(at, state.indexOf("}", at));
529
+ expect(block, `${rule}: every element wearing that marker is migrated`)
530
+ .not.toContain("!important");
531
+ }
532
+
533
+ // The two widest escalations in the sheet retired in stage 4, and they are worth their own
534
+ // assertions because both had a real consumer until it migrated.
535
+ //
536
+ // The focus ring's !important could only ever beat an INLINE box-shadow on an element the
537
+ // selector matches, and there is now none anywhere in the package. ConfirmDialog's dialog
538
+ // and Popover's panel were the two that mattered and took their surface from the sheet in
539
+ // stage 4; the three that remained — AppShell's drawer, DataViewCardList's card and
540
+ // LoginView's card — went with those three components' own migrations, the last of them
541
+ // with LoginView. The DataView card is worth remembering anyway: it is a div with onClick
542
+ // and no tabIndex, so it cannot match :focus-visible at all, which is why the sheet reaches
543
+ // it with :focus-within.
544
+ // Measured after removing it: a keyboard-focused primary button and a focused menu item both
545
+ // still compute rgba(37,99,235,0.35) 0 0 0 3px, because the LAYER carries the ring, not the
546
+ // escalation. That was always the claim in this file's header; the escalation was belt.
547
+ const at = state.indexOf("[data-terp]:focus-visible");
548
+ expect(at, "the shared focus ring should still be declared").toBeGreaterThan(-1);
549
+ expect(
550
+ state.slice(at, state.indexOf("}", at)),
551
+ "the focus ring's last inline box-shadow consumer migrated in stage 4",
552
+ ).not.toContain("!important");
553
+
554
+ // And the spinner's reduced-motion override never had a consumer: the rotation is declared by
555
+ // this sheet in terp.state, terp.motion sits above it, and no component has ever set
556
+ // `animation` in a style object. Measured under prefers-reduced-motion: animation-name
557
+ // computes `none` either way.
558
+ const motion = layerBody("terp.motion");
559
+ const spin = motion.indexOf('[data-terp="spinner-ring"]');
560
+ expect(spin, "the spinner's reduced-motion rule should still be declared").toBeGreaterThan(-1);
561
+ expect(
562
+ motion.slice(spin, motion.indexOf("}", spin)),
563
+ "the spinner's animation is a sheet rule in a lower layer, so layer order already wins",
564
+ ).not.toContain("!important");
565
+ });
566
+
567
+ it("states the real cost of an escalation: a layered !important is a wall, not a hurdle", () => {
568
+ // The reason both directions of this ledger are gated, recorded where the rules are.
569
+ //
570
+ // For NORMAL declarations an unlayered author rule beats a layered one whatever its
571
+ // specificity, which is what lets an app's theme.css override this sheet. For IMPORTANT
572
+ // declarations the layer order REVERSES, and unlayered styles sort last — so a layered
573
+ // !important beats an unlayered !important. Measured in a browser against the real sheet:
574
+ // with the ring escalated, an unlayered rule lost both with and without !important; with it
575
+ // retired, an unlayered rule wins either way.
576
+ //
577
+ // So an escalation does not merely outrank theme.css. It makes that declaration
578
+ // UNTHEMEABLE — there is no author-side override at all. That is why the count is the
579
+ // phase's measurable and why retiring one is a feature rather than tidying.
580
+ expect(css).toContain("@layer terp.reset, terp.base, terp.state, terp.motion;");
581
+ // ZERO. Every rule in this sheet is now beatable by an app's unlayered theme.css without
582
+ // !important and without out-specifying anything, which is what the phase was for: an
583
+ // escalation does not merely outrank an app's stylesheet, it makes that one declaration
584
+ // unthemeable, because for important declarations the layer order reverses and unlayered
585
+ // styles sort last.
586
+ //
587
+ // Asserted as exact equality in both directions. A new escalation fails here and has to
588
+ // name its inline consumer in the ledger above, which is now empty.
589
+ const declarations = css.split("!important").length - 1;
590
+ expect(declarations).toBe(0);
591
+ });
592
+
593
+ it("declares a rule for every DataView surface reached structurally", () => {
594
+ // The check below asks for an EXACT `[data-terp="x"]` selector, and several DataView
595
+ // surfaces deliberately have none: a table has hundreds of cells, so stamping an
596
+ // attribute on each of them buys nothing a descendant selector does not already do.
597
+ // Those rules are therefore invisible to that check — deleting one leaves the marker
598
+ // inventory intact and the whole table unpadded. So they are named here as the
599
+ // selectors they actually are.
600
+ //
601
+ // Note which ancestor each descends from. Cells hang off the ROW rather than off the
602
+ // table, because a "tbody td" selector also matches the expanded row's cell — which
603
+ // carries a padding and a border of its own and would then have to out-specify this
604
+ // rule instead of simply not matching it. And the two visually-hidden surfaces are
605
+ // qualified by element: the actions BODY cell's only child is the row-actions cluster,
606
+ // so an unqualified `> span` would clip a live control out of the layout.
607
+ const base = layerBody("terp.base");
608
+ for (const selector of [
609
+ // The toolbar's three descendant rules, and the fourth on the layout group. The search
610
+ // wrapper's `> span` is its glyph, unqualified because the clear button is a <button> and
611
+ // the field is an <input>, so a span there can only be the glyph. The field rule has to
612
+ // out-rank input[data-terp="input"] { padding } and does so at (0,2,0) against (0,1,1) —
613
+ // deleting it puts the glyph on top of the text with the marker inventory intact.
614
+ // The skeleton's five bars, structural because repeated identical boxes in a place
615
+ // where only bars sit is the card-main / actions-cell precedent. Deleting it collapses
616
+ // dataview-loading and leaves the marker inventory intact.
617
+ '[data-terp="dataview-skeleton"] > div',
618
+ '[data-terp="dataview-toolbar-search"] > span',
619
+ '[data-terp="dataview-toolbar-search"] > [data-terp="input"]',
620
+ '[data-terp="dataview-toolbar-search"] > [data-terp="iconbutton"]',
621
+ '[data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"]',
622
+ '[data-terp="dataview-table"] > thead > tr > th',
623
+ '[data-terp="dataview-row"] > td',
624
+ '[data-terp="dataview-row"][data-clickable="true"]',
625
+ 'th[data-terp="dataview-expand-cell"]',
626
+ 'th[data-terp="dataview-select-cell"]',
627
+ 'th[data-terp="dataview-actions-cell"]',
628
+ 'td[data-terp="dataview-actions-cell"]',
629
+ 'th[data-terp="dataview-actions-cell"] > span',
630
+ '[data-terp="dataview-card-main"] > span',
631
+ // The expand toggle, in both places it renders. It wears the shared iconbutton
632
+ // marker, so its geometry is reachable only through the ancestor it sits in — and
633
+ // losing either selector leaves the toggle unstyled in one of the two layouts,
634
+ // which the marker inventory cannot see because the marker is still rendered.
635
+ '[data-terp="dataview-expand-cell"] > [data-terp="iconbutton"]',
636
+ '[data-terp="dataview-card-main"] > [data-terp="iconbutton"]',
637
+ '[data-terp="dataview-pager"] > [data-terp="iconbutton"]',
638
+ // Two structural span rules in the row-action cluster, and they mean different
639
+ // things: the first reaches the custom-control wrappers (the only span that is a
640
+ // direct child of the cluster — the inline actions are buttons and the overflow
641
+ // menu's root is a div), the second reaches an action's leading icon.
642
+ '[data-terp="dataview-row-actions"] > span',
643
+ '[data-terp="dataview-inline-action"] > span',
644
+ '[data-terp="dataview-column-option"] > label',
645
+ '[data-terp="dataview-column-option"] > [data-terp="iconbutton"]',
646
+ ]) {
647
+ expect(
648
+ declaresRuleFor(base, selector),
649
+ `${selector} must be declared as a rule of its own`,
650
+ ).toBe(true);
651
+ }
652
+ // Every row tone, or a tone silently renders untinted — and the row-tones baseline only
653
+ // covers the three the specimen happens to use.
654
+ for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
655
+ expect(
656
+ declaresRuleFor(base, `[data-terp="dataview-row"][data-tone="${tone}"]`),
657
+ `row tone ${tone} has no rule`,
658
+ ).toBe(true);
659
+ expect(
660
+ declaresRuleFor(base, `[data-terp="dataview-card"][data-tone="${tone}"]`),
661
+ `card tone ${tone} has no rule`,
662
+ ).toBe(true);
663
+ }
664
+ // The selection tint must stay guarded against a toned row. Both weigh (0,2,0) without
665
+ // the :not(), which would leave source order deciding whether a failed row reads as
666
+ // failed or merely as selected.
667
+ expect(
668
+ declaresRuleFor(base, '[data-terp="dataview-row"][data-selected="true"]:not([data-tone])'),
669
+ "the selection tint must exclude toned rows explicitly, not by source order",
670
+ ).toBe(true);
671
+ });
672
+
673
+ it("reads the density tokens from rules, and re-scopes them unlayered", () => {
674
+ // The four cell-padding tokens were published a stage before anything read them and
675
+ // were deleted for exactly that. These are the readers; without them the tokens are
676
+ // back to being decoration, and the compact attribute silently does nothing.
677
+ for (const token of ["--density-cell-pad-y", "--density-cell-pad-x"]) {
678
+ expect(css, `${token} is published but nothing reads it`).toContain(`var(${token})`);
679
+ }
680
+ // And the re-scoping stays OUTSIDE every layer. Inside one it would lose to the
681
+ // contract's own unlayered :root values whenever the attribute sits on the same element
682
+ // those target — the app-wide case, data-density on <html> — and compact would do
683
+ // nothing at all. Asserted by subtracting the layer bodies from the sheet.
684
+ const density = '[data-density="compact"]';
685
+ expect(css).toContain(density);
686
+ for (const layer of ["terp.reset", "terp.base", "terp.state", "terp.motion"]) {
687
+ expect(layerBody(layer), `${density} must not be inside @layer ${layer}`).not.toContain(
688
+ density,
689
+ );
690
+ }
691
+ const at = css.indexOf(density);
692
+ const block = css.slice(at, css.indexOf("}", at));
693
+ for (const token of [
694
+ "--density-control-min-height",
695
+ "--density-cell-pad-y",
696
+ "--density-cell-pad-x",
697
+ ]) {
698
+ expect(block, `${token} is not re-scoped under compact`).toContain(`${token}: var(`);
699
+ }
700
+ });
701
+
702
+ it("keeps the markdown wrapper boxless", () => {
703
+ // display: contents is the entire rule, and it has to stay the entire rule. Every
704
+ // non-inherited property on such an element is silently dropped, so a padding or a border
705
+ // added here would do nothing and nothing else in the suite would say so — the base-rule
706
+ // check below only asks that the block is non-empty, which a block of dead declarations
707
+ // satisfies. Prose rhythm belongs in descendant rules.
708
+ const base = layerBody("terp.base");
709
+ const at = base.indexOf('[data-terp="markdown"]');
710
+ expect(at).toBeGreaterThan(-1);
711
+ const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
712
+ expect(block.trim()).toBe("display: contents;");
713
+ });
714
+
715
+ it("gives every migrated component a base rule in terp.base", () => {
716
+ // markers.test.ts pins the marker join in both directions but cannot see a *deleted*
717
+ // rule: removing a whole block only shrinks the styled set, which still passes. These
718
+ // components render no inline base styles at all, so a missing rule here means the
719
+ // component renders unstyled.
720
+ const base = layerBody("terp.base");
721
+ for (const marker of [
722
+ "button",
723
+ "badge",
724
+ "alert",
725
+ "tooltip",
726
+ "input",
727
+ "field",
728
+ "control-label",
729
+ "checkbox",
730
+ "radio",
731
+ "switch",
732
+ "stack",
733
+ "detail-list",
734
+ "combobox",
735
+ "combobox-list",
736
+ "combobox-option",
737
+ "calendar",
738
+ "calendar-day",
739
+ "calendar-grid",
740
+ "calendar-week",
741
+ "card",
742
+ "card-header",
743
+ "card-title",
744
+ "tabs",
745
+ "tab",
746
+ "tab-list",
747
+ "tab-panel",
748
+ "breadcrumbs",
749
+ "empty-state",
750
+ "error-state",
751
+ "loading-state",
752
+ "spinner-ring",
753
+ "icon",
754
+ "nav-icon",
755
+ "nav-icon-fallback",
756
+ "language-switcher",
757
+ "theme-toggle",
758
+ "theme-toggle-label",
759
+ "language-switcher-label",
760
+ "markdown",
761
+ "menu",
762
+ "menu-item",
763
+ "menu-trigger",
764
+ "menu-item-icon",
765
+ "menu-item-check",
766
+ "page-actions",
767
+ "popover",
768
+ "popover-panel",
769
+ "toast-viewport",
770
+ "toast",
771
+ "toast-icon",
772
+ "toast-body",
773
+ "toast-title",
774
+ "dialog",
775
+ "dialog-body",
776
+ "dialog-title",
777
+ "dialog-description",
778
+ "dialog-actions",
779
+ "user-menu",
780
+ "user-menu-avatar",
781
+ "user-menu-email",
782
+ "user-menu-header",
783
+ "user-menu-identity",
784
+ "user-menu-role",
785
+ "breadcrumbs-current",
786
+ "dataview-table",
787
+ "dataview-column-sort",
788
+ "dataview-column-resizer",
789
+ "dataview-row-open",
790
+ "dataview-card",
791
+ "dataview-card-list",
792
+ "dataview-card-main",
793
+ "dataview-card-body",
794
+ "dataview-card-expanded",
795
+ "dataview-card-fields",
796
+ "dataview-card-heading",
797
+ "dataview-card-title",
798
+ "dataview-card-status",
799
+ "dataview-card-meta",
800
+ "dataview-expanded-cell",
801
+ "dataview-pagination",
802
+ "dataview-pager",
803
+ "dataview-row-actions",
804
+ "dataview-inline-action",
805
+ "dataview",
806
+ "dataview-error",
807
+ "dataview-scroll",
808
+ "dataview-skeleton",
809
+ "dataview-toolbar",
810
+ "dataview-toolbar-actions",
811
+ "dataview-toolbar-count",
812
+ "dataview-toolbar-layout",
813
+ "dataview-toolbar-search",
814
+ "dataview-toolbar-spacer",
815
+ "dataview-toolbar-status",
816
+ "dataview-column-settings",
817
+ "dataview-column-settings-title",
818
+ "dataview-column-option",
819
+ "hubpage-grid",
820
+ "hubcard",
821
+ "hubcard-link",
822
+ "hubcard-body",
823
+ "hubcard-heading",
824
+ "hubcard-icon",
825
+ "hubcard-title",
826
+ "hubcard-description",
827
+ "hubcard-stat",
828
+ "appshell",
829
+ "appshell-sidebar",
830
+ "appshell-backdrop",
831
+ "appshell-brand",
832
+ "appshell-brand-row",
833
+ "appshell-brand-title",
834
+ "appshell-nav",
835
+ "appshell-nav-list",
836
+ "appshell-nav-label",
837
+ "appshell-column",
838
+ "appshell-header",
839
+ "appshell-header-group",
840
+ "appshell-main",
841
+ "appshell-footer",
842
+ "page",
843
+ "page-header",
844
+ "page-breadcrumbs",
845
+ "page-heading",
846
+ "page-title",
847
+ "resource-list",
848
+ "resource-list-create",
849
+ "resource-list-error",
850
+ "resource-list-empty",
851
+ "resource-list-items",
852
+ "resource-list-row",
853
+ "module-nav",
854
+ "module-nav-list",
855
+ "module-nav-link",
856
+ "profile-card",
857
+ "profile-avatar",
858
+ "profile-email",
859
+ "profile-role",
860
+ "login-view",
861
+ "login-card",
862
+ "login-brand",
863
+ "login-title",
864
+ "login-form",
865
+ "login-sso",
866
+ "login-separator",
867
+ "login-separator-rule",
868
+ "login-error",
869
+ "admin-form",
870
+ "admin-section-title",
871
+ "admin-payload",
872
+ ]) {
873
+ expect(
874
+ declaresRuleFor(base, `[data-terp="${marker}"]`),
875
+ `[data-terp="${marker}"] must have a base rule of its own, not merely appear in one`,
876
+ ).toBe(true);
877
+ }
29
878
  });
30
879
  });