@terpjs/react-core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/styles.test.ts
CHANGED
|
@@ -6,6 +6,17 @@ import { TERP_STYLES_ID, TERP_STYLES_CSS, injectTerpStyles } from "./styles";
|
|
|
6
6
|
/** The sheet with comments removed — prose must not satisfy a structural assertion. */
|
|
7
7
|
const css = TERP_STYLES_CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* One source file, read as text, so a union declared in TypeScript can be compared against the
|
|
11
|
+
* rules the sheet declares for it. Narrow on purpose: this file needs exactly one module, and a
|
|
12
|
+
* `./**` glob would pull the 180 KB stylesheet in a second time.
|
|
13
|
+
*/
|
|
14
|
+
const sources = import.meta.glob("./dataview/types.ts", {
|
|
15
|
+
query: "?raw",
|
|
16
|
+
import: "default",
|
|
17
|
+
eager: true,
|
|
18
|
+
}) as Record<string, string>;
|
|
19
|
+
|
|
9
20
|
/**
|
|
10
21
|
* The body of one `@layer <name> { … }` block, brace-matched.
|
|
11
22
|
*
|
|
@@ -70,6 +81,11 @@ describe("injectTerpStyles", () => {
|
|
|
70
81
|
expect(sheet).toContain("scrollbar-color: var(--color-neutral-300) transparent");
|
|
71
82
|
expect(sheet).toContain("::-webkit-scrollbar");
|
|
72
83
|
expect(sheet).toContain("::-webkit-scrollbar-thumb");
|
|
84
|
+
// And reserves the gutter, so the content box is the same width on a page that
|
|
85
|
+
// scrolls and a page that fits. The structural half; the workbench's computed lane
|
|
86
|
+
// measures the reserved width itself, which is the half that can tell whether the
|
|
87
|
+
// browser honoured it.
|
|
88
|
+
expect(sheet).toContain("scrollbar-gutter: stable");
|
|
73
89
|
});
|
|
74
90
|
});
|
|
75
91
|
|
|
@@ -204,10 +220,19 @@ describe("cascade structure", () => {
|
|
|
204
220
|
it("keeps the shell declarations no lane can reach", () => {
|
|
205
221
|
// Three groups, and every one of them was established by mutation rather than assumed.
|
|
206
222
|
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
223
|
+
// Two of the three have since GAINED a baseline, and the reason is worth keeping because
|
|
224
|
+
// the earlier version of this comment said flatly that the screenshot lane could not reach
|
|
225
|
+
// them: it could not, while the viewport was pinned at 1280 for every specimen. A
|
|
226
|
+
// per-specimen `viewport` in the workbench is what changed, and what these two assertions
|
|
227
|
+
// are now is belt rather than the sole gate.
|
|
228
|
+
//
|
|
229
|
+
// The sidebar's flex-shrink. At the pinned 1280 the row has room to spare, so deleting it
|
|
230
|
+
// moves no baseline — measured, and still true. It bites above the mobile breakpoint and
|
|
231
|
+
// below wide, and `app-shell-narrow` (820x900, with a DataView supplying the content
|
|
232
|
+
// pressure a short paragraph does not) is that band: removing the declaration now repaints
|
|
233
|
+
// 124,797 pixels there, in both themes, and leaves the other three shell specimens
|
|
234
|
+
// untouched. A row under no pressure never asks a flex item whether it may shrink, which
|
|
235
|
+
// is why the specimen needed the table and not just the narrower window.
|
|
211
236
|
const base = layerBody("terp.base");
|
|
212
237
|
const at = base.indexOf('[data-terp="appshell-sidebar"] {');
|
|
213
238
|
expect(at, "the sidebar should have a base rule").toBeGreaterThan(-1);
|
|
@@ -228,10 +253,65 @@ describe("cascade structure", () => {
|
|
|
228
253
|
`z-index: var(${token})`,
|
|
229
254
|
);
|
|
230
255
|
}
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
256
|
+
// The shell's three geometry literals, same shape and the same reason. `15rem`, `4rem` and
|
|
257
|
+
// `3rem` are now published tokens an app moves from its own unlayered `theme.css` with no
|
|
258
|
+
// prop at all (ADR 0097 §1), and restating any of them as a number here would move no
|
|
259
|
+
// baseline — the values are identical, which is what made the conversion provably
|
|
260
|
+
// zero-diff — while quietly taking the knob away again. `tokens.guard.test.ts` holds the
|
|
261
|
+
// other direction: a shell token nothing reads fails there.
|
|
262
|
+
for (const [declaration, what] of [
|
|
263
|
+
["width: var(--shell-sidebar-width-expanded)", "the expanded sidebar"],
|
|
264
|
+
["width: var(--shell-sidebar-width-collapsed)", "the collapsed icon rail"],
|
|
265
|
+
["min-height: var(--shell-header-height)", "the sticky header's floor"],
|
|
266
|
+
] as const) {
|
|
267
|
+
expect(base, `${what} must read its token rather than restating the length`).toContain(
|
|
268
|
+
declaration,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
// And the content measure, which no structural check can reach through a marker: it is the
|
|
272
|
+
// one shell rule keyed on a descendant of an attribute rather than on a marker of its own,
|
|
273
|
+
// because the mechanism is deliberately NOT a new element (ADR 0097 §2). The `:not(header)`
|
|
274
|
+
// is the whole band: the header keeps the page grid's full track while its siblings take
|
|
275
|
+
// the measure.
|
|
276
|
+
// Selector AND declaration read out of ONE rule body, not as two independent substrings
|
|
277
|
+
// of the layer. Asserted separately, an empty measure rule plus the declaration moved onto
|
|
278
|
+
// some other rule during a consolidation would satisfy both — and the only baseline that
|
|
279
|
+
// moved would read as an intentional layout change.
|
|
280
|
+
const measureRule =
|
|
281
|
+
/\[data-terp="appshell"\]\[data-content-width="measured"\]\s*\n?\s*\[data-terp="page"\] > \*:not\(\[data-terp="page-header"\]\) \{([^}]*)\}/.exec(
|
|
282
|
+
base,
|
|
283
|
+
);
|
|
284
|
+
expect(measureRule, "the content measure must be one rule keyed on the shell's attribute").not
|
|
285
|
+
.toBeNull();
|
|
286
|
+
const measureBody = measureRule![1]!;
|
|
287
|
+
// WIDTH, not max-width, and this assertion is the gate on that. The selector weighs
|
|
288
|
+
// (0,4,0), so as a max-width it outranks every component declaring a narrower one —
|
|
289
|
+
// resource-list, admin-form, dialog and both text measures — and an admin-form inside a
|
|
290
|
+
// measured shell computed 1280px instead of 512px. As a width it composes, because CSS
|
|
291
|
+
// resolves max-width after width. Nothing renders differently for a child with no measure
|
|
292
|
+
// of its own, which is why only a probe found it.
|
|
293
|
+
expect(
|
|
294
|
+
measureBody,
|
|
295
|
+
"the measure must be a width, or it outranks every component's own narrower max-width",
|
|
296
|
+
).toContain("width: min(100%, var(--shell-content-max-width))");
|
|
297
|
+
expect(
|
|
298
|
+
base.includes("max-width: var(--shell-content-max-width)"),
|
|
299
|
+
"as a max-width the measure would widen resource-list, admin-form, dialog and Text",
|
|
300
|
+
).toBe(false);
|
|
301
|
+
// And the mobile half of the shell, where the three selectors below now differ from each
|
|
302
|
+
// other and the difference is the interesting part. The BEHAVIOUR is covered throughout by
|
|
303
|
+
// AppShell.test.tsx (focus containment, inert page, close-on-nav) with a stubbed
|
|
304
|
+
// matchMedia; what varies is the geometry.
|
|
305
|
+
//
|
|
306
|
+
// `appshell-main`'s tightened padding is painted: `app-shell-mobile` renders at 420x900,
|
|
307
|
+
// and moving that padding one step to the desktop value repaints 1,309 pixels there in both
|
|
308
|
+
// themes and nothing else.
|
|
309
|
+
//
|
|
310
|
+
// The other two DID wait for a door, and it arrived: `defaultDrawerOpen` plus the
|
|
311
|
+
// `app-shell-drawer-open` specimen (420x900, overlay) paint the drawer's own geometry and
|
|
312
|
+
// its backdrop for the first time. So this loop is belt for all three now rather than the
|
|
313
|
+
// sole gate for two of them — the message below still says "no baseline can hold it", which
|
|
314
|
+
// was true when it was written and is not any more.
|
|
235
315
|
for (const selector of [
|
|
236
316
|
'[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"]',
|
|
237
317
|
'[data-terp="appshell-backdrop"]',
|
|
@@ -239,7 +319,8 @@ describe("cascade structure", () => {
|
|
|
239
319
|
]) {
|
|
240
320
|
expect(
|
|
241
321
|
declaresRuleFor(base, selector),
|
|
242
|
-
`${selector} is mobile-only
|
|
322
|
+
`${selector} is mobile-only; app-shell-mobile and app-shell-drawer-open are the only ` +
|
|
323
|
+
"pictures of it",
|
|
243
324
|
).toBe(true);
|
|
244
325
|
}
|
|
245
326
|
});
|
|
@@ -285,20 +366,30 @@ describe("cascade structure", () => {
|
|
|
285
366
|
).toContain("height: 100%");
|
|
286
367
|
});
|
|
287
368
|
|
|
288
|
-
it("puts the DataView's surface on the full variant, not on the
|
|
369
|
+
it("puts the DataView's surface on the full variant's TABLE, not on the root or the marker", () => {
|
|
289
370
|
// Both values of data-variant are stamped and only one has a rule, which is what keeps
|
|
290
371
|
// the embedded variant a bare grid. The alternative — surface on the marker, un-declared
|
|
291
372
|
// under [data-variant="embedded"] — needs background: transparent, border: 0 and
|
|
292
373
|
// border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
|
|
293
374
|
//
|
|
294
375
|
// dataview-embedded is the negative evidence this rests on: deleting the full-variant
|
|
295
|
-
// rule must move
|
|
376
|
+
// rule must move the full-variant baselines and leave that one untouched.
|
|
377
|
+
//
|
|
378
|
+
// The OWNER changed and the guarantee did not. The surface used to sit on the root, which
|
|
379
|
+
// made one card of the toolbar, the table and the pagination, divided by internal borders
|
|
380
|
+
// — and left the table's cells flush against the outer frame. It now sits on whatever
|
|
381
|
+
// occupies the table's slot, so the table is the object and the controls above and below
|
|
382
|
+
// it float on the page. Still keyed on the variant, for the reason above.
|
|
296
383
|
const base = layerBody("terp.base");
|
|
297
384
|
expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
|
|
298
385
|
expect(
|
|
299
|
-
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
300
|
-
"the surface belongs to the full variant",
|
|
386
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"]'),
|
|
387
|
+
"the surface belongs to the full variant's table slot",
|
|
301
388
|
).toBe(true);
|
|
389
|
+
expect(
|
|
390
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
391
|
+
"the root must NOT carry a surface — that is the card this change removed",
|
|
392
|
+
).toBe(false);
|
|
302
393
|
expect(
|
|
303
394
|
base,
|
|
304
395
|
"un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
|
|
@@ -321,38 +412,30 @@ describe("cascade structure", () => {
|
|
|
321
412
|
);
|
|
322
413
|
});
|
|
323
414
|
|
|
324
|
-
it("keeps the toolbar
|
|
325
|
-
//
|
|
326
|
-
//
|
|
415
|
+
it("keeps the toolbar FLOATING — no surface, no divider, and still no ink", () => {
|
|
416
|
+
// This assertion reversed, and the reversal is the change: the band used to declare
|
|
417
|
+
// neutral-0 and two top radii because it was the top third of a card, and nothing else
|
|
418
|
+
// kept the selection colour inside the root's rounded frame. There is no card now. A
|
|
419
|
+
// background on a strip that sits on the page canvas paints a rectangle the design does
|
|
420
|
+
// not have, and a border under it draws a line across nothing.
|
|
327
421
|
//
|
|
328
|
-
// The
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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.
|
|
422
|
+
// The radii went with the card for the same reason: there is no rounded frame above the
|
|
423
|
+
// toolbar to stay inside. Selection mode keeps a fill — it marks a MODE and losing that
|
|
424
|
+
// makes selection invisible — but as a surface of its own, with its own padding and
|
|
425
|
+
// radius, asserted below.
|
|
335
426
|
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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.
|
|
427
|
+
// NO colour survives unchanged, and it is the invariant with the sharpest edge: two of
|
|
428
|
+
// this element's direct children are arbitrary caller slots (`children` and `trailing`),
|
|
429
|
+
// so a muted ink here would inherit into app-authored filter controls — a silent restyle
|
|
430
|
+
// of app DOM, which is what the styling migration exists to stop the framework doing.
|
|
344
431
|
const base = layerBody("terp.base");
|
|
345
432
|
const at = base.indexOf('[data-terp="dataview-toolbar"]');
|
|
346
433
|
expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
|
|
347
434
|
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
348
|
-
expect(block, "
|
|
349
|
-
|
|
435
|
+
expect(block, "a floating strip paints no surface of its own").not.toContain("background:");
|
|
436
|
+
expect(block, "a divider under a floating strip is a line across nothing").not.toContain(
|
|
437
|
+
"border-block-end:",
|
|
350
438
|
);
|
|
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
439
|
expect(
|
|
357
440
|
/(^|;)\s*color:/.test(block),
|
|
358
441
|
"a colour here inherits into the caller's filter slot and trailing slot",
|
|
@@ -368,11 +451,27 @@ describe("cascade structure", () => {
|
|
|
368
451
|
layerBody("terp.state"),
|
|
369
452
|
"selection mode is a resting surface, not an interaction state",
|
|
370
453
|
).not.toContain('[data-variant="selection"]');
|
|
371
|
-
// The
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
454
|
+
// The band used to read --density-cell-pad-x for its INLINE padding, so a compact view's
|
|
455
|
+
// band lined up with its first cell. That alignment target is gone with the card: the
|
|
456
|
+
// toolbar now aligns with the table's outer frame, whose position does not move with
|
|
457
|
+
// density, so inline padding here would push the controls off it. Block padding stays on
|
|
458
|
+
// the spacing scale — vertical rhythm was never the density question.
|
|
459
|
+
expect(block, "the band's block padding stays on the spacing scale").toContain(
|
|
460
|
+
"padding-block: var(--space-2)",
|
|
461
|
+
);
|
|
462
|
+
expect(
|
|
463
|
+
/padding-inline|padding:/.test(block),
|
|
464
|
+
"inline padding would push the controls off the table frame they now align to",
|
|
465
|
+
).toBe(false);
|
|
466
|
+
// Selection mode is the exception and declares its own inline padding, because there it
|
|
467
|
+
// IS a surface and its fill needs to clear its text.
|
|
468
|
+
const selectionAt = base.indexOf('[data-terp="dataview-toolbar"][data-variant="selection"]');
|
|
469
|
+
const selectionBlock = base.slice(
|
|
470
|
+
base.indexOf("{", selectionAt) + 1,
|
|
471
|
+
base.indexOf("}", selectionAt),
|
|
472
|
+
);
|
|
473
|
+
expect(selectionBlock, "the selection surface reads density for its own inset").toContain(
|
|
474
|
+
"padding-inline: var(--density-cell-pad-x)",
|
|
376
475
|
);
|
|
377
476
|
});
|
|
378
477
|
|
|
@@ -443,10 +542,28 @@ describe("cascade structure", () => {
|
|
|
443
542
|
}
|
|
444
543
|
// Every transition this sheet declares is either on a marked element or on one of the
|
|
445
544
|
// descendant selectors above. A new descendant rule with a transition has to join them.
|
|
446
|
-
|
|
447
|
-
|
|
545
|
+
// BOTH layers. This read only terp.base, so a transition declared in terp.state — where
|
|
546
|
+
// every hover, focus and selected treatment lives — was audited by nothing at all. The one
|
|
547
|
+
// that exists there today passed only because its selector happens to be in the explicit
|
|
548
|
+
// list above, which is luck rather than coverage.
|
|
549
|
+
//
|
|
550
|
+
// And the "marked" heuristic needs the trailing pseudo stripped before it is applied.
|
|
551
|
+
// `\s[a-z]+$` asks whether the selector ends in a bare element name preceded by whitespace,
|
|
552
|
+
// but `[data-terp="card"] span:hover` ends in `:hover` — the character before the final run
|
|
553
|
+
// is a colon, not a space — so the test scored it as marked and waved it through. Reduced
|
|
554
|
+
// motion cannot reach that rule: terp.motion is the last layer and beats any earlier one on
|
|
555
|
+
// the elements its selectors MATCH, and its list matches marked elements and four named
|
|
556
|
+
// descendants. A transition on any other descendant simply escapes.
|
|
557
|
+
const transitionRules = [layerBody("terp.base"), layerBody("terp.state")].flatMap((body) => [
|
|
558
|
+
...body.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g),
|
|
559
|
+
]);
|
|
560
|
+
expect(transitionRules.length, "no transition rules were scanned at all").toBeGreaterThan(0);
|
|
561
|
+
for (const match of transitionRules) {
|
|
448
562
|
const selector = match[1].trim().replace(/\s+/g, " ");
|
|
449
|
-
|
|
563
|
+
// Strip trailing pseudo-classes / pseudo-elements, so the bare-element test sees the
|
|
564
|
+
// element the selector actually ends on.
|
|
565
|
+
const withoutPseudo = selector.replace(/(::?[a-z-]+(\([^)]*\))?)+$/, "");
|
|
566
|
+
const marked = withoutPseudo.includes("[data-terp") && !/\s[a-z]+$/.test(withoutPseudo);
|
|
450
567
|
expect(
|
|
451
568
|
marked || motion.includes(selector),
|
|
452
569
|
`${selector} declares a transition but reduced motion cannot reach it`,
|
|
@@ -454,18 +571,270 @@ describe("cascade structure", () => {
|
|
|
454
571
|
}
|
|
455
572
|
});
|
|
456
573
|
|
|
574
|
+
it("times every transition off the published motion scale, never a literal", () => {
|
|
575
|
+
// The scale shipped in 2a and was read by nothing: this sheet wrote `150ms ease`
|
|
576
|
+
// 28 times and `100ms ease` once across 16 declarations, and read a motion token
|
|
577
|
+
// zero times — so a Studio editor built from the manifest would have offered four
|
|
578
|
+
// duration controls that moved nothing. Wiring them was inert by construction:
|
|
579
|
+
// --motion-duration-fast IS 150ms, --motion-duration-instant IS 100ms and
|
|
580
|
+
// --motion-easing-standard IS ease, so every literal mapped onto a token pair.
|
|
581
|
+
//
|
|
582
|
+
// This gate has to be structural, and that is the whole reason it exists here
|
|
583
|
+
// rather than in a baseline. The screenshot lane runs with
|
|
584
|
+
// `animations: "disabled"`, so a duration is invisible to it: a wrong value — or
|
|
585
|
+
// literal number 30 — would move no pixel and nothing would say so.
|
|
586
|
+
//
|
|
587
|
+
// Scoped to `transition` on purpose. The spinner's `animation: terp-spin 0.8s` is
|
|
588
|
+
// a rotation period rather than an interaction step and the scale tops out at
|
|
589
|
+
// 400ms, so there is no token for it to name.
|
|
590
|
+
const declarations = [...css.matchAll(/transition:\s*([^;]+);/g)].map((match) => match[1]!);
|
|
591
|
+
expect(declarations.length).toBeGreaterThan(10);
|
|
592
|
+
expect(
|
|
593
|
+
declarations.filter((value) => /\d+m?s\b/.test(value)),
|
|
594
|
+
"a transition's duration belongs on the published motion scale, not in the rule",
|
|
595
|
+
).toEqual([]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("puts the responsive Stack rules after the ones they override", () => {
|
|
599
|
+
// A stack with direction { narrow: "row", wide: "column" } carries BOTH
|
|
600
|
+
// data-direction="row" and data-direction-wide="column", and the two selectors weigh the
|
|
601
|
+
// same (0,2,0) — so nothing but source order decides which applies above the cutover.
|
|
602
|
+
// Backwards, the narrow value renders at every width, which looks exactly like the prop
|
|
603
|
+
// not working rather than like a cascade mistake.
|
|
604
|
+
//
|
|
605
|
+
// The same tie the focus ring and the loading cursor both turned on, and the third time
|
|
606
|
+
// it has mattered in this sheet: equal specificity in one layer is settled by position,
|
|
607
|
+
// and position is the one thing a reader cannot see from the rule.
|
|
608
|
+
const base = layerBody("terp.base");
|
|
609
|
+
const wideAt = base.indexOf("@media not all and (max-width: 768px)");
|
|
610
|
+
expect(wideAt, "the wide half of the responsive props should be in terp.base").toBeGreaterThan(
|
|
611
|
+
-1,
|
|
612
|
+
);
|
|
613
|
+
for (const selector of [
|
|
614
|
+
'[data-terp="stack"][data-direction="row"]',
|
|
615
|
+
'[data-terp="stack"][data-gap="8"]',
|
|
616
|
+
]) {
|
|
617
|
+
const at = base.indexOf(selector);
|
|
618
|
+
expect(at, `${selector} should exist`).toBeGreaterThan(-1);
|
|
619
|
+
expect(at, `${selector} must be declared before the wide block that overrides it`).toBeLessThan(
|
|
620
|
+
wideAt,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("declares a wide-half gap rule for every step SpaceToken allows", () => {
|
|
626
|
+
// The narrow half is covered by the roll-call below; this is its counterpart. A responsive
|
|
627
|
+
// gap whose wide step has no rule silently renders the narrow gap at every width — which
|
|
628
|
+
// is the failure mode of the whole responsive idea, and invisible unless a specimen happens
|
|
629
|
+
// to be recorded at both viewports.
|
|
630
|
+
const base = layerBody("terp.base");
|
|
631
|
+
const wide = base.slice(base.indexOf("@media not all and (max-width: 768px)"));
|
|
632
|
+
for (const token of [0, 1, 2, 3, 4, 6, 8]) {
|
|
633
|
+
expect(
|
|
634
|
+
declaresRuleFor(wide, `[data-terp="stack"][data-gap-wide="${token}"]`),
|
|
635
|
+
`the wide half has no rule for gap ${token}`,
|
|
636
|
+
).toBe(true);
|
|
637
|
+
}
|
|
638
|
+
for (const direction of ["column", "row"]) {
|
|
639
|
+
expect(
|
|
640
|
+
declaresRuleFor(wide, `[data-terp="stack"][data-direction-wide="${direction}"]`),
|
|
641
|
+
`the wide half has no rule for direction ${direction}`,
|
|
642
|
+
).toBe(true);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("pins the four Grid track floors, which no baseline can hold", () => {
|
|
647
|
+
// The grid specimens gate the MECHANISM and not the values, and the gap between those two
|
|
648
|
+
// was measured rather than assumed. An auto-fit grid quantises: a floor only changes the
|
|
649
|
+
// rendered column count when it crosses a threshold for the container's width. At
|
|
650
|
+
// `grid-min-column`'s 66rem, moving the md floor 20rem -> 22rem drops it from three columns
|
|
651
|
+
// to two and repaints 63,524 pixels — while moving it 20rem -> 21rem changes **nothing**,
|
|
652
|
+
// and all ten grid baselines pass. So the pictures prove the attribute selects a different
|
|
653
|
+
// floor; only this proves which floor.
|
|
654
|
+
//
|
|
655
|
+
// The same shape as the shell declarations below, and the reason the values are pinned here
|
|
656
|
+
// rather than promoted to tokens: four published tokens with one consumer is the vocabulary
|
|
657
|
+
// the density cell tokens were deleted for. They become tokens the day an app asks.
|
|
658
|
+
const base = layerBody("terp.base");
|
|
659
|
+
for (const [attribute, floor] of [
|
|
660
|
+
[null, "16rem"],
|
|
661
|
+
["xs", "10rem"],
|
|
662
|
+
["md", "20rem"],
|
|
663
|
+
["lg", "26rem"],
|
|
664
|
+
] as const) {
|
|
665
|
+
const selector =
|
|
666
|
+
attribute === null
|
|
667
|
+
? '[data-terp="grid"]'
|
|
668
|
+
: `[data-terp="grid"][data-min-column="${attribute}"]`;
|
|
669
|
+
const at = base.indexOf(`${selector} {`);
|
|
670
|
+
expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
|
|
671
|
+
expect(
|
|
672
|
+
base.slice(at, base.indexOf("}", at)),
|
|
673
|
+
`${selector} should floor its tracks at ${floor}`,
|
|
674
|
+
).toContain(`minmax(min(${floor}, 100%), 1fr)`);
|
|
675
|
+
}
|
|
676
|
+
// sm is the default and therefore the base rule, so it has no attribute of its own — and
|
|
677
|
+
// its floor is the same 16rem the hub grid uses, which is why the two agree by construction
|
|
678
|
+
// instead of by coincidence.
|
|
679
|
+
expect(
|
|
680
|
+
declaresRuleFor(base, '[data-terp="grid"][data-min-column="sm"]'),
|
|
681
|
+
"sm is the base rule; an attribute rule for it means the default is described twice",
|
|
682
|
+
).toBe(false);
|
|
683
|
+
const hub = base.indexOf('[data-terp="hubpage-grid"] {');
|
|
684
|
+
expect(base.slice(hub, base.indexOf("}", hub))).toContain("minmax(min(16rem, 100%), 1fr)");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("floors every fixed Grid column at zero, not at min-content", () => {
|
|
688
|
+
// `repeat(N, 1fr)` looks equivalent and is not: a bare 1fr floors at the track's
|
|
689
|
+
// min-content size, so one long unbroken word in a cell widens its column and the grid
|
|
690
|
+
// overflows its container. A two-column form of long field labels walks straight into it,
|
|
691
|
+
// which is the case Grid exists for. minmax(0, 1fr) is the fix, and it is invisible to
|
|
692
|
+
// every baseline that does not happen to contain an overflowing word.
|
|
693
|
+
const base = layerBody("terp.base");
|
|
694
|
+
for (const count of [1, 2, 3, 4]) {
|
|
695
|
+
const selector = `[data-terp="grid"][data-columns="${count}"]`;
|
|
696
|
+
const at = base.indexOf(`${selector} {`);
|
|
697
|
+
expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
|
|
698
|
+
expect(
|
|
699
|
+
base.slice(at, base.indexOf("}", at)),
|
|
700
|
+
`${selector} must floor its tracks at 0, or a long word widens the column`,
|
|
701
|
+
).toContain("minmax(0, 1fr)");
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("declares a rule for every size Button names, and none for its default", () => {
|
|
706
|
+
// The same bargain the gap rules strike: the union and the sheet are two lists kept by
|
|
707
|
+
// hand, so widening ButtonSize without adding a rule would silently fall back to the
|
|
708
|
+
// standard geometry rather than fail.
|
|
709
|
+
//
|
|
710
|
+
// And the converse, which is the part worth pinning: md must have NO rule of its own. Its
|
|
711
|
+
// geometry is the base rule, exactly as "comfortable" is the token sheet's :root value, and
|
|
712
|
+
// a data-size="md" rule appearing here would mean the component had started stamping the
|
|
713
|
+
// attribute for its default — leaving two places that describe the standard control.
|
|
714
|
+
const base = layerBody("terp.base");
|
|
715
|
+
for (const size of ["sm", "lg"]) {
|
|
716
|
+
expect(
|
|
717
|
+
declaresRuleFor(base, `[data-terp="button"][data-size="${size}"]`),
|
|
718
|
+
`Button has no rule for size ${size}`,
|
|
719
|
+
).toBe(true);
|
|
720
|
+
}
|
|
721
|
+
expect(
|
|
722
|
+
declaresRuleFor(base, '[data-terp="button"][data-size="md"]'),
|
|
723
|
+
"md is the base rule; a rule of its own means the default is described twice",
|
|
724
|
+
).toBe(false);
|
|
725
|
+
// Sizes read the density token rather than heights of their own, which is what makes the
|
|
726
|
+
// two dimensions compose: the compact re-scoping moves the token these calc() off.
|
|
727
|
+
const at = base.indexOf('[data-terp="button"][data-size="sm"]');
|
|
728
|
+
expect(
|
|
729
|
+
base.slice(at, base.indexOf("}", at)),
|
|
730
|
+
"a size that hardcodes its height stops following density",
|
|
731
|
+
).toContain("var(--density-control-min-height)");
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("declares one avatar rule plus a small variant, and nothing for the default size", () => {
|
|
735
|
+
// The tile used to be two rules of eleven declarations each — profile-avatar and
|
|
736
|
+
// user-menu-avatar — identical but for a width, a height and a font size. Merging them is only
|
|
737
|
+
// zero-pixel if the variant keeps BOTH geometries, so this pins the shape of the pair: a base
|
|
738
|
+
// rule carrying md, and an sm rule carrying exactly the three declarations that differ.
|
|
739
|
+
const base = layerBody("terp.base");
|
|
740
|
+
expect(declaresRuleFor(base, '[data-terp="avatar"]')).toBe(true);
|
|
741
|
+
expect(declaresRuleFor(base, '[data-terp="avatar"][data-size="sm"]')).toBe(true);
|
|
742
|
+
// md is the base rule, as with Button. A rule of its own means the default is described twice.
|
|
743
|
+
expect(
|
|
744
|
+
declaresRuleFor(base, '[data-terp="avatar"][data-size="md"]'),
|
|
745
|
+
"md is the base rule; a rule of its own means the default is described twice",
|
|
746
|
+
).toBe(false);
|
|
747
|
+
const at = base.indexOf('[data-terp="avatar"][data-size="sm"]');
|
|
748
|
+
const body = base.slice(at, base.indexOf("}", at));
|
|
749
|
+
for (const declaration of ["width: 2rem", "height: 2rem", "var(--font-size-sm)"]) {
|
|
750
|
+
expect(body, `the small variant must restate ${declaration}`).toContain(declaration);
|
|
751
|
+
}
|
|
752
|
+
// And nothing else: a declaration here that the base already sets is a second description of
|
|
753
|
+
// the same tile, which is the duplication the merge removed.
|
|
754
|
+
expect(body).not.toContain("border-radius");
|
|
755
|
+
expect(body).not.toContain("background");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("declares a min-inline-size for every step ColumnWidth names, and nothing it does not", () => {
|
|
759
|
+
// The Button gate above notes that its union and its sheet are two lists kept by hand. This
|
|
760
|
+
// one reads the union out of the source instead, because a declared track that silently does
|
|
761
|
+
// nothing is exactly the defect this attribute exists to end: `meta.width` was a px hint that
|
|
762
|
+
// `table-layout: auto` shrank away, so widening ColumnWidth without a rule would restore the
|
|
763
|
+
// old behaviour under a new spelling.
|
|
764
|
+
const source = sources["./dataview/types.ts"];
|
|
765
|
+
const union = /export type ColumnWidth =([^;]+);/.exec(source);
|
|
766
|
+
expect(union, "ColumnWidth is not declared where this test looks for it").not.toBeNull();
|
|
767
|
+
const steps = [...(union?.[1] ?? "").matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
|
|
768
|
+
expect(steps.length, "parsed no steps out of ColumnWidth").toBeGreaterThan(0);
|
|
769
|
+
|
|
770
|
+
const base = layerBody("terp.base");
|
|
771
|
+
for (const step of steps) {
|
|
772
|
+
const selector = `[data-terp="dataview-table"] > thead > tr > th[data-width="${step}"]`;
|
|
773
|
+
expect(declaresRuleFor(base, selector), `no rule for step ${step}`).toBe(true);
|
|
774
|
+
const at = base.indexOf(selector);
|
|
775
|
+
expect(
|
|
776
|
+
base.slice(at, base.indexOf("}", at)),
|
|
777
|
+
`step ${step} must bind as a minimum; a width is a preference auto layout shrinks away`,
|
|
778
|
+
).toContain("min-inline-size");
|
|
779
|
+
}
|
|
780
|
+
// And nothing beyond the union. `lg` and `content` were both drafted and declined for having
|
|
781
|
+
// no consumer, so a rule for either means the sheet grew a step the type cannot express.
|
|
782
|
+
for (const absent of ["lg", "xl", "content", "auto"]) {
|
|
783
|
+
expect(
|
|
784
|
+
declaresRuleFor(
|
|
785
|
+
base,
|
|
786
|
+
`[data-terp="dataview-table"] > thead > tr > th[data-width="${absent}"]`,
|
|
787
|
+
),
|
|
788
|
+
`the sheet declares a step ColumnWidth does not name: ${absent}`,
|
|
789
|
+
).toBe(false);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("keeps the loading cursor where it can beat the disabled cursor", () => {
|
|
794
|
+
// A loading button is also `:disabled` — the component sets both — and the disabled rule
|
|
795
|
+
// lives in terp.state. So a `data-loading` rule in terp.base loses on layer order and the
|
|
796
|
+
// cursor silently stays `not-allowed`, which tells a user "you may not" where the truth is
|
|
797
|
+
// "not yet". It has to be in terp.state, and after the disabled rule, because the two weigh
|
|
798
|
+
// the same (0,2,0) and nothing but source order separates them.
|
|
799
|
+
//
|
|
800
|
+
// This is the focus-ring lesson in miniature, and it is invisible to every other lane:
|
|
801
|
+
// Playwright's screenshots do not paint a pointer, so no baseline has ever held either
|
|
802
|
+
// cursor. The computed lane asserts the resolved values; this asserts the structure that
|
|
803
|
+
// produces them.
|
|
804
|
+
const state = layerBody("terp.state");
|
|
805
|
+
const base = layerBody("terp.base");
|
|
806
|
+
expect(
|
|
807
|
+
declaresRuleFor(base, '[data-terp="button"][data-loading="true"]'),
|
|
808
|
+
"in terp.base this rule loses to the disabled cursor and does nothing",
|
|
809
|
+
).toBe(false);
|
|
810
|
+
const disabledAt = state.indexOf('[data-terp="button"]:disabled');
|
|
811
|
+
const loadingAt = state.indexOf('[data-terp="button"][data-loading="true"]');
|
|
812
|
+
expect(disabledAt, "the disabled rule should be in terp.state").toBeGreaterThan(-1);
|
|
813
|
+
expect(loadingAt, "the loading rule should be in terp.state").toBeGreaterThan(-1);
|
|
814
|
+
expect(loadingAt).toBeGreaterThan(disabledAt);
|
|
815
|
+
});
|
|
816
|
+
|
|
457
817
|
it("declares a gap rule for every step SpaceToken allows", () => {
|
|
458
818
|
// gap moved from a computed inline value to a rule per step, so the union and the sheet
|
|
459
819
|
// are now two lists maintained by hand. Widening SpaceToken without adding rules would
|
|
460
820
|
// silently fall back to the default gap rather than fail.
|
|
461
821
|
const base = layerBody("terp.base");
|
|
462
822
|
for (const token of [0, 1, 2, 3, 4, 6, 8]) {
|
|
463
|
-
for (const marker of ["stack", "card"]) {
|
|
823
|
+
for (const marker of ["stack", "card", "grid"]) {
|
|
464
824
|
expect(
|
|
465
825
|
declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
|
|
466
826
|
`${marker} has no rule for gap ${token}`,
|
|
467
827
|
).toBe(true);
|
|
468
828
|
}
|
|
829
|
+
// Padding is the same bargain on the same scale, and it is the dimension Stack did not
|
|
830
|
+
// have at all — a padded region used to be reachable only through a Card, which brought
|
|
831
|
+
// its border and background whether or not they were wanted.
|
|
832
|
+
for (const marker of ["stack", "grid"]) {
|
|
833
|
+
expect(
|
|
834
|
+
declaresRuleFor(base, `[data-terp="${marker}"][data-padding="${token}"]`),
|
|
835
|
+
`${marker} has no rule for padding ${token}`,
|
|
836
|
+
).toBe(true);
|
|
837
|
+
}
|
|
469
838
|
}
|
|
470
839
|
});
|
|
471
840
|
|
|
@@ -497,13 +866,14 @@ describe("cascade structure", () => {
|
|
|
497
866
|
// DataViewCardList's inline background on the element it matches. The escalation
|
|
498
867
|
// came off when the card migrated, which is the LAST of the two rather than the
|
|
499
868
|
// first — and the card's tone rules now lose to it on layer instead.
|
|
500
|
-
'[data-terp="dataview-card"]:focus-within',
|
|
869
|
+
'[data-terp="dataview-card"][data-clickable="true"]:focus-within',
|
|
501
870
|
// 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
|
|
871
|
+
// migrated" but "can any element this selector matches still beat it". Of the SEVENTEEN
|
|
503
872
|
// sites wearing the iconbutton marker, only six can carry the disabled attribute at all:
|
|
504
873
|
// 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
|
-
//
|
|
874
|
+
// dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle,
|
|
875
|
+
// the toolbar's clear-search button and two layout toggles and the password reveal have no
|
|
876
|
+
// disabled state —
|
|
507
877
|
// and each of the six set cursor inline until it migrated. The day a calendar arrow gains
|
|
508
878
|
// a min/max bound, this answer changes back.
|
|
509
879
|
'[data-terp="iconbutton"]:disabled',
|
|
@@ -706,10 +1076,51 @@ describe("cascade structure", () => {
|
|
|
706
1076
|
// check below only asks that the block is non-empty, which a block of dead declarations
|
|
707
1077
|
// satisfies. Prose rhythm belongs in descendant rules.
|
|
708
1078
|
const base = layerBody("terp.base");
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1079
|
+
// Anchored to the STANDALONE rule, not to the first mention of the marker. A plain indexOf
|
|
1080
|
+
// finds whichever selector merely contains it — and one now does: the measured-width
|
|
1081
|
+
// reach-through that caps this wrapper's blocks is declared earlier in the layer, so the
|
|
1082
|
+
// loose locator sliced that rule's body instead and reported a width where it expected
|
|
1083
|
+
// display: contents. The assertion was right and its aim was not.
|
|
1084
|
+
const rule = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
|
|
1085
|
+
(match) => match[1]!.trim() === '[data-terp="markdown"]',
|
|
1086
|
+
);
|
|
1087
|
+
expect(rule, 'no standalone [data-terp="markdown"] rule in terp.base').toBeDefined();
|
|
1088
|
+
expect(rule![2]!.trim()).toBe("display: contents;");
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it("pins the values of five declarations no lane can reach", () => {
|
|
1092
|
+
// Presence is not enough for these five, and that is the distinction the roll-calls above
|
|
1093
|
+
// already draw for Button sizes and gap steps: a rule can be present and WRONG, and nothing
|
|
1094
|
+
// here would notice. Each of these is reachable through a published prop, and none of them
|
|
1095
|
+
// is painted by any specimen — so today their values could be changed to anything and the
|
|
1096
|
+
// whole suite stays green.
|
|
1097
|
+
//
|
|
1098
|
+
// Two disabled paints, both reachable (Combobox and DatePicker each take `disabled`), and
|
|
1099
|
+
// neither rendered in that state by any specimen. Mutation: change either colour token.
|
|
1100
|
+
const state = layerBody("terp.state");
|
|
1101
|
+
const ruleFor = (selector: string) => {
|
|
1102
|
+
const match = [...state.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
|
|
1103
|
+
(rule) => rule[1]!.trim().replace(/\s+/g, " ") === selector,
|
|
1104
|
+
);
|
|
1105
|
+
expect(match, `${selector} has no rule of its own`).toBeDefined();
|
|
1106
|
+
return match![2]!;
|
|
1107
|
+
};
|
|
1108
|
+
expect(ruleFor('[data-terp="combobox-option"]:disabled')).toContain(
|
|
1109
|
+
"color: var(--color-neutral-400)",
|
|
1110
|
+
);
|
|
1111
|
+
expect(ruleFor('[data-terp="calendar-day"]:disabled')).toContain(
|
|
1112
|
+
"color: var(--color-neutral-300)",
|
|
1113
|
+
);
|
|
1114
|
+
|
|
1115
|
+
// The split archetype's two outer list widths. `md` is painted by the split-page specimen;
|
|
1116
|
+
// `sm` and `lg` are painted by nothing, and they live inside a wide-viewport media block the
|
|
1117
|
+
// pinned 1280 does reach — so the rules apply and simply have no picture. A renamed
|
|
1118
|
+
// attribute value would silently stop matching. Mutation: rename "sm" to "small", or change
|
|
1119
|
+
// either track measure.
|
|
1120
|
+
const wide = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="sm"]'));
|
|
1121
|
+
expect(wide.slice(0, 120)).toContain("minmax(0, 18rem) minmax(0, 1fr)");
|
|
1122
|
+
const lg = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="lg"]'));
|
|
1123
|
+
expect(lg.slice(0, 120)).toContain("minmax(0, 32rem) minmax(0, 1fr)");
|
|
713
1124
|
});
|
|
714
1125
|
|
|
715
1126
|
it("gives every migrated component a base rule in terp.base", () => {
|
|
@@ -720,16 +1131,21 @@ describe("cascade structure", () => {
|
|
|
720
1131
|
const base = layerBody("terp.base");
|
|
721
1132
|
for (const marker of [
|
|
722
1133
|
"button",
|
|
1134
|
+
"avatar",
|
|
723
1135
|
"badge",
|
|
724
1136
|
"alert",
|
|
725
1137
|
"tooltip",
|
|
726
1138
|
"input",
|
|
1139
|
+
"input-password",
|
|
727
1140
|
"field",
|
|
728
1141
|
"control-label",
|
|
729
1142
|
"checkbox",
|
|
730
1143
|
"radio",
|
|
731
1144
|
"switch",
|
|
732
1145
|
"stack",
|
|
1146
|
+
"appshell-skip-link",
|
|
1147
|
+
"splitpage-panes",
|
|
1148
|
+
"splitpane",
|
|
733
1149
|
"detail-list",
|
|
734
1150
|
"combobox",
|
|
735
1151
|
"combobox-list",
|
|
@@ -777,7 +1193,6 @@ describe("cascade structure", () => {
|
|
|
777
1193
|
"dialog-description",
|
|
778
1194
|
"dialog-actions",
|
|
779
1195
|
"user-menu",
|
|
780
|
-
"user-menu-avatar",
|
|
781
1196
|
"user-menu-email",
|
|
782
1197
|
"user-menu-header",
|
|
783
1198
|
"user-menu-identity",
|
|
@@ -854,7 +1269,6 @@ describe("cascade structure", () => {
|
|
|
854
1269
|
"module-nav-list",
|
|
855
1270
|
"module-nav-link",
|
|
856
1271
|
"profile-card",
|
|
857
|
-
"profile-avatar",
|
|
858
1272
|
"profile-email",
|
|
859
1273
|
"profile-role",
|
|
860
1274
|
"login-view",
|
|
@@ -868,7 +1282,70 @@ describe("cascade structure", () => {
|
|
|
868
1282
|
"login-error",
|
|
869
1283
|
"admin-form",
|
|
870
1284
|
"admin-section-title",
|
|
871
|
-
"
|
|
1285
|
+
"grid",
|
|
1286
|
+
"divider",
|
|
1287
|
+
"heading",
|
|
1288
|
+
"text",
|
|
1289
|
+
"code",
|
|
1290
|
+
"code-block",
|
|
1291
|
+
"link",
|
|
1292
|
+
"detail-list-row",
|
|
1293
|
+
// `appshell-nav-group-label` is here; `appshell-nav-group` deliberately is NOT, and the
|
|
1294
|
+
// omission joins `appshell-mark` and `appshell-skip-link` above rather than starting a
|
|
1295
|
+
// new kind of exception. The wrapper declares nothing on its own — in the sidebar it is a
|
|
1296
|
+
// plain block whose whole job is to be a sibling, so its two rules are a `+` pair and a
|
|
1297
|
+
// placement override, and it has no standalone selector to match. `declaresRuleFor` wants
|
|
1298
|
+
// exact selector equality, which is what makes it worth having: adding the family pattern
|
|
1299
|
+
// here to make the list look complete would fail, correctly.
|
|
1300
|
+
"appshell-nav-group-label",
|
|
1301
|
+
// The 32 below close a gap the roll-call carried quietly: it listed barely more than
|
|
1302
|
+
// three quarters of the pinned markers, so a quarter of the sheet's base blocks could be
|
|
1303
|
+
// DELETED and only the screenshot lane would notice — and only for a marker some specimen
|
|
1304
|
+
// happens to paint. (No figures here on purpose: both sides of that ratio have moved twice
|
|
1305
|
+
// since it was written, and a citation keeps where a tally rots.)
|
|
1306
|
+
// markers.test.ts cannot see a deleted rule either: removing a block only shrinks the
|
|
1307
|
+
// styled set, which still satisfies both of its directions.
|
|
1308
|
+
//
|
|
1309
|
+
// Seven pinned markers are deliberately NOT here, because they have no standalone base
|
|
1310
|
+
// rule to assert. appshell-nav-group declares nothing on its own (it is a
|
|
1311
|
+
// sibling-combinator pair plus a placement override); dataview-row, dataview-select-cell,
|
|
1312
|
+
// dataview-expand-cell and dataview-actions-cell are styled through their descendants
|
|
1313
|
+
// and attribute variants; and drawer-focus-start / drawer-focus-end appear only in the
|
|
1314
|
+
// shared visually-hidden selector list. Adding any of them here fails, correctly —
|
|
1315
|
+
// declaresRuleFor wants exact selector equality, which is what makes this list mean
|
|
1316
|
+
// something.
|
|
1317
|
+
"alert-body",
|
|
1318
|
+
"alert-icon",
|
|
1319
|
+
"alert-title",
|
|
1320
|
+
"appshell-mark",
|
|
1321
|
+
"breadcrumbs-separator",
|
|
1322
|
+
"button-icon",
|
|
1323
|
+
"calendar-header",
|
|
1324
|
+
"calendar-title",
|
|
1325
|
+
"calendar-weekday",
|
|
1326
|
+
"card-actions",
|
|
1327
|
+
"card-description",
|
|
1328
|
+
"card-heading",
|
|
1329
|
+
"combobox-empty",
|
|
1330
|
+
"combobox-field",
|
|
1331
|
+
"detail-list-term",
|
|
1332
|
+
"detail-list-value",
|
|
1333
|
+
"empty-state-description",
|
|
1334
|
+
"empty-state-icon",
|
|
1335
|
+
"empty-state-title",
|
|
1336
|
+
"error-state-description",
|
|
1337
|
+
"error-state-icon",
|
|
1338
|
+
"error-state-title",
|
|
1339
|
+
"field-error",
|
|
1340
|
+
"field-hint",
|
|
1341
|
+
"field-label",
|
|
1342
|
+
"field-label-text",
|
|
1343
|
+
"iconbutton",
|
|
1344
|
+
"loading-state-spinner",
|
|
1345
|
+
"radio-group",
|
|
1346
|
+
"radio-group-legend",
|
|
1347
|
+
"radio-group-options",
|
|
1348
|
+
"tooltip-anchor",
|
|
872
1349
|
]) {
|
|
873
1350
|
expect(
|
|
874
1351
|
declaresRuleFor(base, `[data-terp="${marker}"]`),
|