@terpjs/react-core 0.10.0 → 0.12.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.
@@ -63,6 +63,38 @@ afterEach(() => {
63
63
  });
64
64
 
65
65
  describe("injectTerpStyles", () => {
66
+ it("adopts a constructable stylesheet when the document supports one", () => {
67
+ // jsdom exposes the CSSStyleSheet constructor but not document.adoptedStyleSheets,
68
+ // so the property is defined here to reach the branch a real browser takes. That
69
+ // branch is the whole point of the injector: an adopted sheet is not governed by
70
+ // CSP's style-src, so a generated app needs no 'unsafe-inline' — measured in
71
+ // Chromium, where a <style> element is reported as style-src-elem and dropped.
72
+ const sheets: CSSStyleSheet[] = [];
73
+ Object.defineProperty(document, "adoptedStyleSheets", {
74
+ configurable: true,
75
+ get: () => sheets,
76
+ set: (next: CSSStyleSheet[]) => {
77
+ sheets.length = 0;
78
+ sheets.push(...next);
79
+ },
80
+ });
81
+ try {
82
+ injectTerpStyles();
83
+ expect(sheets.length).toBe(1);
84
+ // No element: taking the adopted route must not also append a <style>, or the
85
+ // page would carry the rules twice and still need the CSP keyword.
86
+ expect(document.getElementById(TERP_STYLES_ID)).toBeNull();
87
+ expect(sheets[0]?.cssRules.length ?? 0).toBeGreaterThan(0);
88
+
89
+ // Idempotent by the sheet's own mark rather than by an element id.
90
+ injectTerpStyles();
91
+ injectTerpStyles();
92
+ expect(sheets.length).toBe(1);
93
+ } finally {
94
+ delete (document as unknown as { adoptedStyleSheets?: unknown }).adoptedStyleSheets;
95
+ }
96
+ });
97
+
66
98
  it("appends the stylesheet once and is idempotent on re-invocation", () => {
67
99
  injectTerpStyles();
68
100
  injectTerpStyles();
@@ -366,20 +398,30 @@ describe("cascade structure", () => {
366
398
  ).toContain("height: 100%");
367
399
  });
368
400
 
369
- it("puts the DataView's surface on the full variant, not on the bare marker", () => {
401
+ it("puts the DataView's surface on the full variant's TABLE, not on the root or the marker", () => {
370
402
  // Both values of data-variant are stamped and only one has a rule, which is what keeps
371
403
  // the embedded variant a bare grid. The alternative — surface on the marker, un-declared
372
404
  // under [data-variant="embedded"] — needs background: transparent, border: 0 and
373
405
  // border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
374
406
  //
375
407
  // dataview-embedded is the negative evidence this rests on: deleting the full-variant
376
- // rule must move five baselines and leave that one untouched.
408
+ // rule must move the full-variant baselines and leave that one untouched.
409
+ //
410
+ // The OWNER changed and the guarantee did not. The surface used to sit on the root, which
411
+ // made one card of the toolbar, the table and the pagination, divided by internal borders
412
+ // — and left the table's cells flush against the outer frame. It now sits on whatever
413
+ // occupies the table's slot, so the table is the object and the controls above and below
414
+ // it float on the page. Still keyed on the variant, for the reason above.
377
415
  const base = layerBody("terp.base");
378
416
  expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
379
417
  expect(
380
- declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
381
- "the surface belongs to the full variant",
418
+ declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"]'),
419
+ "the surface belongs to the full variant's table slot",
382
420
  ).toBe(true);
421
+ expect(
422
+ declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
423
+ "the root must NOT carry a surface — that is the card this change removed",
424
+ ).toBe(false);
383
425
  expect(
384
426
  base,
385
427
  "un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
@@ -402,38 +444,30 @@ describe("cascade structure", () => {
402
444
  );
403
445
  });
404
446
 
405
- it("keeps the toolbar band declaring its own surface, and no ink", () => {
406
- // Three separate invariants about one element, and each has a way of going wrong that
407
- // nothing else in the suite can see.
408
- //
409
- // The background. The inline style this replaced read `selectionMode ? neutral-50 :
410
- // neutral-0` an explicit value in BOTH branches, so the resting rule has to carry
411
- // neutral-0 rather than leaving it to the host. Against every composed DataView specimen
412
- // dropping it moves nothing, because the full variant's root and the workbench's specimen
413
- // card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
414
- // declares nothing but a display, and the band would show the page canvas through it.
415
- // `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
447
+ it("keeps the toolbar FLOATING no surface, no divider, and still no ink", () => {
448
+ // This assertion reversed, and the reversal is the change: the band used to declare
449
+ // neutral-0 and two top radii because it was the top third of a card, and nothing else
450
+ // kept the selection colour inside the root's rounded frame. There is no card now. A
451
+ // background on a strip that sits on the page canvas paints a rectangle the design does
452
+ // not have, and a border under it draws a line across nothing.
416
453
  //
417
- // The two top radii, which are load-bearing rather than decorative: the DataView root
418
- // rounds its border with no overflow: hidden, so nothing else keeps the selection band's
419
- // neutral-50 inside the rounded frame.
454
+ // The radii went with the card for the same reason: there is no rounded frame above the
455
+ // toolbar to stay inside. Selection mode keeps a fill it marks a MODE and losing that
456
+ // makes selection invisible but as a surface of its own, with its own padding and
457
+ // radius, asserted below.
420
458
  //
421
- // And NO colour. Two of this element's direct children are arbitrary caller slots
422
- // (`children` and `trailing`), so a muted ink here would inherit into app-authored filter
423
- // controls a silent restyle of app DOM, which is exactly what this migration exists to
424
- // stop the framework doing.
459
+ // NO colour survives unchanged, and it is the invariant with the sharpest edge: two of
460
+ // this element's direct children are arbitrary caller slots (`children` and `trailing`),
461
+ // so a muted ink here would inherit into app-authored filter controls a silent restyle
462
+ // of app DOM, which is what the styling migration exists to stop the framework doing.
425
463
  const base = layerBody("terp.base");
426
464
  const at = base.indexOf('[data-terp="dataview-toolbar"]');
427
465
  expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
428
466
  const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
429
- expect(block, "the band must declare its own surface, not inherit the host's").toContain(
430
- "background: var(--color-neutral-0)",
467
+ expect(block, "a floating strip paints no surface of its own").not.toContain("background:");
468
+ expect(block, "a divider under a floating strip is a line across nothing").not.toContain(
469
+ "border-block-end:",
431
470
  );
432
- for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
433
- expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
434
- `${radius}: var(--radius-lg)`,
435
- );
436
- }
437
471
  expect(
438
472
  /(^|;)\s*color:/.test(block),
439
473
  "a colour here inherits into the caller's filter slot and trailing slot",
@@ -449,11 +483,27 @@ describe("cascade structure", () => {
449
483
  layerBody("terp.state"),
450
484
  "selection mode is a resting surface, not an interaction state",
451
485
  ).not.toContain('[data-variant="selection"]');
452
- // The bar reads the density token rather than a literal --space-3, which is what makes a
453
- // compact view's band line up with its first cell. Zero-diff at comfortable, because
454
- // comfortable --density-cell-pad-x IS --space-3 so only dataview-compact can catch it.
455
- expect(block, "the band's inline padding must follow density").toContain(
456
- "padding: var(--space-2) var(--density-cell-pad-x)",
486
+ // The band used to read --density-cell-pad-x for its INLINE padding, so a compact view's
487
+ // band lined up with its first cell. That alignment target is gone with the card: the
488
+ // toolbar now aligns with the table's outer frame, whose position does not move with
489
+ // density, so inline padding here would push the controls off it. Block padding stays on
490
+ // the spacing scale — vertical rhythm was never the density question.
491
+ expect(block, "the band's block padding stays on the spacing scale").toContain(
492
+ "padding-block: var(--space-2)",
493
+ );
494
+ expect(
495
+ /padding-inline|padding:/.test(block),
496
+ "inline padding would push the controls off the table frame they now align to",
497
+ ).toBe(false);
498
+ // Selection mode is the exception and declares its own inline padding, because there it
499
+ // IS a surface and its fill needs to clear its text.
500
+ const selectionAt = base.indexOf('[data-terp="dataview-toolbar"][data-variant="selection"]');
501
+ const selectionBlock = base.slice(
502
+ base.indexOf("{", selectionAt) + 1,
503
+ base.indexOf("}", selectionAt),
504
+ );
505
+ expect(selectionBlock, "the selection surface reads density for its own inset").toContain(
506
+ "padding-inline: var(--density-cell-pad-x)",
457
507
  );
458
508
  });
459
509
 
package/src/styles.ts CHANGED
@@ -1939,20 +1939,32 @@ textarea[data-terp="input"] {
1939
1939
  [data-terp="dataview"] {
1940
1940
  display: grid;
1941
1941
  }
1942
- /* The full variant's surface. (0,2,0) against the bare marker's (0,1,0), so it wins
1943
- on specificity no tie, no :not(), no source order. BOTH values of data-variant
1944
- are stamped and only this one has a rule, which is the theme-toggle idiom where
1945
- inline is stamped and takes the shared base while only stacked declares anything.
1946
- Rejected: putting the surface on the bare marker and un-declaring it under
1947
- [data-variant="embedded"], which needs background: transparent, border: 0 and
1948
- border-radius: 0 the shape ADR 0094 exists to avoid. Also rejected: stamping
1949
- nothing for the default on the density precedent, which holds only because
1950
- comfortable IS the :root value and so has nothing to declare.
1951
- Byte-identical to [data-terp="card"]'s trio; written flat anyway, the Badge /
1952
- Alert / row-tone precedent. No overflow: hidden, and none should be added here
1953
- the last row's border crossing the rounded bottom corners is pre-existing and
1954
- belongs to its own commit. */
1955
- [data-terp="dataview"][data-variant="full"] {
1942
+ /* The full variant's surface belongs to the TABLE, not to the whole view.
1943
+ It used to wrap everything: one card holding the toolbar, the table and the
1944
+ pagination, divided internally by a border under the toolbar and over the
1945
+ pagination. That reads as three bands of one object, and it costs twice — the
1946
+ table's cells are then flush against the outer frame (nothing between the first
1947
+ column's text and the card edge but cell padding), and the toolbar's controls sit
1948
+ inside a surface they do not belong to.
1949
+
1950
+ So the surface moves down one level, onto whatever occupies the table's slot, and
1951
+ the toolbar and the pagination float on the page background instead. The table
1952
+ becomes the object; the controls above and below it become controls. That also
1953
+ dissolves the flush-to-the-edge problem rather than padding around it: the table's
1954
+ own frame is the edge now, and --density-cell-pad-x is already the inset from it.
1955
+
1956
+ The alternative considered was keeping the outer card and adding inline padding to
1957
+ the table inside it. Rejected: it fixes the symptom by making the card thicker,
1958
+ leaves the toolbar inside a surface, and leaves two nested frames whenever the view
1959
+ is empty (the empty state's dashed frame inside the card's solid one).
1960
+
1961
+ Keyed on [data-variant="full"] rather than the bare marker for the reason the
1962
+ previous rule gave and which still holds: [data-variant="embedded"] must declare
1963
+ nothing, and un-declaring a surface with background: transparent / border: 0 is the
1964
+ shape ADR 0094 exists to avoid. */
1965
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"],
1966
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-error"],
1967
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-skeleton"] {
1956
1968
  background: var(--color-neutral-0);
1957
1969
  border: 1px solid var(--color-neutral-200);
1958
1970
  border-radius: var(--radius-lg);
@@ -2031,22 +2043,38 @@ textarea[data-terp="input"] {
2031
2043
  No colour declaration here on purpose. Two of this element's direct children
2032
2044
  are arbitrary caller slots, and inheriting a muted ink onto app-authored filter
2033
2045
  controls would be a silent restyle of app DOM. */
2046
+ /* Floating: no background, no divider, and no inline padding. The divider was the
2047
+ seam between two bands of one card and there is no card now — a border under a strip
2048
+ that sits on the page background is a line drawn across nothing. Dropping the inline
2049
+ padding aligns the controls with the table's OUTER edge (its frame) rather than with
2050
+ its cell text, which is the alignment a floating control row wants: the eye follows
2051
+ the frame, and a control indented to meet the first column's text reads as belonging
2052
+ inside the table.
2053
+
2054
+ The background's previous justification is worth answering rather than deleting: it
2055
+ was there for the EMBEDDED variant, whose root declares nothing but a display, so that
2056
+ the band would not show the page canvas through it. That is now the intent in both
2057
+ variants. A floating strip shows whatever is behind it — the page in the full variant,
2058
+ the app's own card in the embedded one — and in neither case is a neutral-0 rectangle
2059
+ under the controls something the design asks for. dataview-toolbar-bare still renders
2060
+ on a neutral-50 host, so the difference is visible in a baseline either way. */
2034
2061
  [data-terp="dataview-toolbar"] {
2035
2062
  display: flex;
2036
2063
  align-items: center;
2037
2064
  gap: var(--space-2);
2038
2065
  flex-wrap: wrap;
2039
- padding: var(--space-2) var(--density-cell-pad-x);
2040
- border-block-end: 1px solid var(--color-neutral-200);
2041
- background: var(--color-neutral-0);
2042
- border-top-left-radius: var(--radius-lg);
2043
- border-top-right-radius: var(--radius-lg);
2066
+ padding-block: var(--space-2);
2044
2067
  min-height: 3rem;
2045
2068
  }
2046
2069
  /* Selection mode. A resting surface rather than an interaction state, so
2047
2070
  terp.base — and (0,2,0) against the base's (0,1,0) means it wins on
2048
2071
  specificity alone, needing no :not() and no source-order dependency. */
2049
2072
  [data-terp="dataview-toolbar"][data-variant="selection"] {
2073
+ /* Still a filled surface, because it marks a MODE and losing that would make
2074
+ selection invisible — but now it is a surface of its own rather than a band of the
2075
+ card, so it takes the padding and radius that make it read as one. */
2076
+ padding-inline: var(--density-cell-pad-x);
2077
+ border-radius: var(--radius-md);
2050
2078
  background: var(--color-neutral-50);
2051
2079
  }
2052
2080
  [data-terp="dataview-toolbar-count"] {
@@ -2263,6 +2291,17 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2263
2291
  data-tone on an element no selector could reach, and the row-tones baseline
2264
2292
  would have lost its tints the moment the tone moved out of a style object.
2265
2293
  It is unconditional now, and clickability is an attribute of its own. */
2294
+ /* The last row draws no bottom border, which fixes two things the full variant's own
2295
+ comment had already named and deferred. The container carries a 1px border and a
2296
+ radius, so the last row's border sat a pixel inside it as a DOUBLE line, and with no
2297
+ overflow: hidden it also ran straight across the rounded bottom corners. Dropping the
2298
+ border is the fix rather than clipping the container: overflow: hidden here would trap
2299
+ the horizontal scroll container and any overlay a cell renders, which is why that
2300
+ comment refused it. tbody's last row, not the table's — a footer row would want its
2301
+ own rule. */
2302
+ [data-terp="dataview-row"]:last-child > td {
2303
+ border-bottom: none;
2304
+ }
2266
2305
  [data-terp="dataview-row"][data-clickable="true"] {
2267
2306
  cursor: pointer;
2268
2307
  }
@@ -2296,15 +2335,31 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2296
2335
  sorted, so the unsorted glyph's dimming keys off its ABSENCE rather than off
2297
2336
  an attribute minted for it — this component owns aria-sort, unlike the
2298
2337
  breadcrumb's aria-current, which a router stamps on every ancestor link. */
2338
+ /* font: inherit is not enough, and the gap it leaves split the header row in two.
2339
+ The font shorthand carries no text-transform and no letter-spacing, and the UA
2340
+ stylesheet resets both on form controls — so in any table mixing sortable and
2341
+ non-sortable columns the plain th rendered uppercase with 0.04em tracking while
2342
+ the sortable one rendered sentence case with none, side by side in one row. Both
2343
+ are named explicitly because inheritance is what the UA overrode.
2344
+
2345
+ The block padding mirrors the th's so the button fills the cell it sits in,
2346
+ pulled back out by the negative margin. The control was a 17px-tall target inside
2347
+ a 34px cell — half the cell unused, and the most-used control in a data app
2348
+ clearing WCAG 2.5.8 only through the spacing exception. Filling the cell costs
2349
+ nothing and changes no layout: the button's box grows into padding the th
2350
+ already reserved. */
2299
2351
  [data-terp="dataview-column-sort"] {
2300
2352
  display: inline-flex;
2301
2353
  align-items: center;
2302
2354
  gap: var(--space-1);
2303
2355
  font: inherit;
2356
+ text-transform: inherit;
2357
+ letter-spacing: inherit;
2304
2358
  color: inherit;
2305
2359
  background: transparent;
2306
2360
  border: none;
2307
- padding: 0;
2361
+ padding: var(--space-2) 0;
2362
+ margin-block: calc(-1 * var(--space-2));
2308
2363
  cursor: pointer;
2309
2364
  }
2310
2365
  [data-terp="dataview-table"] > thead > tr > th:not([aria-sort]) > [data-terp="dataview-column-sort"] > svg {
@@ -2518,8 +2573,7 @@ th[data-terp="dataview-actions-cell"] > span {
2518
2573
  justify-content: space-between;
2519
2574
  gap: var(--space-3);
2520
2575
  flex-wrap: wrap;
2521
- padding: var(--space-2) var(--density-cell-pad-x);
2522
- border-block-start: 1px solid var(--color-neutral-200);
2576
+ padding-block: var(--space-2);
2523
2577
  font-size: var(--font-size-sm);
2524
2578
  color: var(--color-fg-subtle);
2525
2579
  }
@@ -2707,6 +2761,24 @@ th[data-terp="dataview-actions-cell"] > span {
2707
2761
  border-radius: var(--radius-lg);
2708
2762
  background: var(--color-neutral-0);
2709
2763
  }
2764
+ /* Compact: a section's emptiness rather than the page's. Same frame and same words,
2765
+ laid out as a row — the glyph beside the text instead of above it — so two of
2766
+ these stacked read as two quiet sections rather than 480px of repeated poster. */
2767
+ [data-terp="empty-state"][data-size="compact"] {
2768
+ grid-template-columns: auto 1fr;
2769
+ justify-items: start;
2770
+ align-items: center;
2771
+ gap: var(--space-2) var(--space-3);
2772
+ padding: var(--space-3) var(--space-4);
2773
+ text-align: start;
2774
+ }
2775
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-title"] {
2776
+ font-size: var(--font-size-sm);
2777
+ }
2778
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-description"],
2779
+ [data-terp="empty-state"][data-size="compact"] > :not([data-terp="empty-state-icon"]):not([data-terp="empty-state-title"]) {
2780
+ grid-column: 2;
2781
+ }
2710
2782
  [data-terp="empty-state-icon"] {
2711
2783
  color: var(--color-neutral-400);
2712
2784
  display: inline-flex;
@@ -2822,6 +2894,71 @@ input[data-terp="input"][role="combobox"] {
2822
2894
  position: relative;
2823
2895
  display: grid;
2824
2896
  }
2897
+ /* Multiple: the tokens share the field's box with the input, wrapping onto as many rows
2898
+ as the selection needs. A fixed-height field would either clip the third token or
2899
+ reserve room for tokens nobody has chosen — and a set-valued field whose height never
2900
+ changes is lying about how much is in it.
2901
+
2902
+ The input keeps a minimum inline size so a filter is still typeable when the tokens have
2903
+ taken most of a row, and flex-basis 0 so it yields to them rather than pushing the last
2904
+ token out of the box. */
2905
+ [data-terp="combobox-field"][data-multiple="true"] {
2906
+ display: flex;
2907
+ flex-wrap: wrap;
2908
+ align-items: center;
2909
+ gap: var(--space-1);
2910
+ padding: var(--space-1);
2911
+ border: 1px solid var(--color-neutral-300);
2912
+ border-radius: var(--radius-md);
2913
+ background: var(--color-neutral-0);
2914
+ }
2915
+ [data-terp="combobox-field"][data-multiple="true"] > [data-terp="input"] {
2916
+ flex: 1 1 0;
2917
+ min-inline-size: 6rem;
2918
+ border: none;
2919
+ background: transparent;
2920
+ padding-inline: var(--space-1);
2921
+ }
2922
+ [data-terp="combobox-token"] {
2923
+ display: inline-flex;
2924
+ align-items: center;
2925
+ gap: var(--space-1);
2926
+ padding-block: 0;
2927
+ padding-inline: var(--space-2);
2928
+ font-size: var(--font-size-sm);
2929
+ color: var(--color-neutral-900);
2930
+ background: var(--color-neutral-100);
2931
+ border: 1px solid var(--color-neutral-200);
2932
+ border-radius: var(--radius-sm);
2933
+ /* Matches the control height a token sits beside, so a row of tokens and the input
2934
+ share one baseline instead of the tokens riding high. */
2935
+ min-block-size: calc(var(--density-control-min-height) - var(--space-2));
2936
+ }
2937
+ /* The remove control is a real button and a real tab stop, which is the accessible half of
2938
+ the Backspace shortcut rather than a duplicate of it: the shortcut is discoverable only if
2939
+ you already know it, and a token nobody can reach by keyboard cannot be removed by one. */
2940
+ [data-terp="combobox-token-remove"] {
2941
+ display: inline-flex;
2942
+ align-items: center;
2943
+ justify-content: center;
2944
+ min-inline-size: var(--space-4);
2945
+ min-block-size: var(--space-4);
2946
+ padding: 0;
2947
+ color: var(--color-neutral-600);
2948
+ background: transparent;
2949
+ border: none;
2950
+ border-radius: var(--radius-sm);
2951
+ cursor: pointer;
2952
+ line-height: 1;
2953
+ }
2954
+ [data-terp="combobox-token-remove"]:hover:not(:disabled) {
2955
+ color: var(--color-neutral-900);
2956
+ background: var(--color-neutral-200);
2957
+ }
2958
+ [data-terp="combobox-token-remove"]:disabled {
2959
+ cursor: not-allowed;
2960
+ opacity: 0.5;
2961
+ }
2825
2962
  /* Addressed structurally rather than by a marker of its own: it is an
2826
2963
  iconbutton, and the only thing distinguishing it is where it sits. */
2827
2964
  [data-terp="combobox-field"] > [data-terp="iconbutton"] {
@@ -3006,7 +3143,7 @@ button[data-terp="input"][data-placeholder="true"] {
3006
3143
  }
3007
3144
  [data-terp="tooltip"] {
3008
3145
  position: absolute;
3009
- z-index: 1;
3146
+ z-index: var(--z-index-tooltip);
3010
3147
  inset-block-end: calc(100% + var(--space-1));
3011
3148
  inset-inline-start: 0;
3012
3149
  max-inline-size: min(18rem, calc(100vw - 2 * var(--space-4)));
@@ -3393,10 +3530,21 @@ button[data-terp="input"][data-placeholder="true"] {
3393
3530
  viewport reads --z-index-toast. (This paragraph said "AppShell still writes
3394
3531
  50/40/30 ... and comes right with its own migration" for a release after that
3395
3532
  migration landed, which is the shape of stale comment worth naming: it read as
3396
- a known gap rather than as a finished one.) Tooltip's z-index:
3397
- 1 above is deliberately NOT a token the tooltip is absolutely positioned
3398
- inside its own anchor, so 1 is a local lift within a stacking context rather
3399
- than a place in the app-wide order. */
3533
+ a known gap rather than as a finished one.) Tooltip's z-index WAS 1 on the
3534
+ reasoning that the tooltip is absolutely positioned inside its own anchor, so 1
3535
+ is a local lift within a stacking context rather than a place in the app-wide
3536
+ order. That is sound about the anchor and wrong about the page:
3537
+ [data-terp="tooltip-anchor"] is only position: relative, which does NOT create a
3538
+ stacking context, so the 1 competed in the ROOT context — against a sticky header
3539
+ at 30 and an open popover at 60 — and lost. Reported as a tooltip that renders
3540
+ below content "sometimes, not always", which is exactly what a level that depends
3541
+ on whatever ancestor happens to establish a context looks like. It reads
3542
+ --z-index-tooltip (70) now, the level published for it.
3543
+
3544
+ Still true, and NOT fixed by this: an ancestor with overflow: hidden clips an
3545
+ absolutely positioned tooltip whatever its level. The fix for that is the one
3546
+ [data-terp="popover-panel"] already uses — position: fixed with measured
3547
+ coordinates — and it is a change to the component rather than to this sheet. */
3400
3548
  [data-terp="popover-panel"] {
3401
3549
  position: fixed;
3402
3550
  z-index: var(--z-index-popover);
@@ -4080,13 +4228,50 @@ button[data-terp="input"][data-placeholder="true"] {
4080
4228
  }
4081
4229
  `;
4082
4230
 
4231
+ /**
4232
+ * The property stamped on the constructed sheet so a second call recognises it.
4233
+ *
4234
+ * On the sheet object rather than on an element or a `data-` attribute: the sheet
4235
+ * lives on the `document`, so the mark is document-scoped exactly like the old
4236
+ * element-id check was, and it cannot collide with the `data-terp*` selectors
4237
+ * this very stylesheet declares.
4238
+ */
4239
+ const ADOPTED_MARKER = "__terpStylesId";
4240
+
4241
+ /** A constructed sheet plus the mark identifying it as ours. */
4242
+ type MarkedSheet = CSSStyleSheet & { [ADOPTED_MARKER]?: string };
4243
+
4244
+ /**
4245
+ * `document.adoptedStyleSheets` is absent in jsdom, so the property is read
4246
+ * through a type that admits that. Cast via `unknown` rather than intersected
4247
+ * with `Document`, because the DOM lib declares the property as always present
4248
+ * and an intersection would keep that stricter declaration.
4249
+ */
4250
+ type AdoptableDocument = { adoptedStyleSheets?: MarkedSheet[] };
4251
+
4083
4252
  /**
4084
4253
  * Inject the react-core interaction-state stylesheet once per document.
4085
4254
  *
4086
- * SSR-safe: no-op when `document` is undefined. Idempotent: the sheet element
4087
- * is keyed by {@link TERP_STYLES_ID}, so repeated calls (from any component's
4088
- * module scope) attach the rules exactly once. Content is set via
4089
- * `textContent` never `innerHTML` so no HTML-injection sink is touched.
4255
+ * Prefers a **constructable stylesheet** (`new CSSStyleSheet()` +
4256
+ * `document.adoptedStyleSheets`), because that is the only injection route a
4257
+ * Content-Security-Policy does not have to widen for. A `<style>` element's
4258
+ * rules are inline styles as far as CSP is concerned, so shipping them obliged
4259
+ * every generated app to serve `style-src 'unsafe-inline'` — a keyword that,
4260
+ * once present, also permits every *other* inline stylesheet on the page,
4261
+ * including one an injection managed to introduce. Measured in Chromium: an
4262
+ * adopted sheet applies cleanly under `style-src 'self'` while a `<style>`
4263
+ * element is reported as a `style-src-elem` violation and its rules dropped.
4264
+ *
4265
+ * The `<style>` element remains the fallback, because a browser without
4266
+ * constructable stylesheets would otherwise render the chrome unstyled. Under a
4267
+ * strict policy those browsers get no styling either way, so the fallback only
4268
+ * ever helps.
4269
+ *
4270
+ * SSR-safe: no-op when `document` is undefined. Idempotent by either route — the
4271
+ * adopted sheet carries {@link ADOPTED_MARKER}, the element is keyed by
4272
+ * {@link TERP_STYLES_ID} — so repeated calls from any component's module scope
4273
+ * attach the rules exactly once. Neither route touches an HTML sink: the element
4274
+ * path sets `textContent`, never `innerHTML`, and `replaceSync` parses CSS only.
4090
4275
  */
4091
4276
  export function injectTerpStyles(): void {
4092
4277
  if (typeof document === "undefined") {
@@ -4095,6 +4280,23 @@ export function injectTerpStyles(): void {
4095
4280
  if (document.getElementById(TERP_STYLES_ID) !== null) {
4096
4281
  return;
4097
4282
  }
4283
+
4284
+ const adopted = (document as unknown as AdoptableDocument).adoptedStyleSheets;
4285
+ if (adopted !== undefined && typeof CSSStyleSheet === "function") {
4286
+ if (adopted.some((sheet) => sheet[ADOPTED_MARKER] === TERP_STYLES_ID)) {
4287
+ return;
4288
+ }
4289
+ try {
4290
+ const sheet: MarkedSheet = new CSSStyleSheet();
4291
+ sheet.replaceSync(TERP_STYLES_CSS);
4292
+ sheet[ADOPTED_MARKER] = TERP_STYLES_ID;
4293
+ (document as unknown as AdoptableDocument).adoptedStyleSheets = [...adopted, sheet];
4294
+ return;
4295
+ } catch {
4296
+ // A browser that exposes the API but refuses this sheet still gets styling.
4297
+ }
4298
+ }
4299
+
4098
4300
  const el = document.createElement("style");
4099
4301
  el.id = TERP_STYLES_ID;
4100
4302
  el.textContent = TERP_STYLES_CSS;
@@ -133,8 +133,13 @@ const UNREAD_TOKENS: Record<string, string[]> = {
133
133
  * prose scale.
134
134
  */
135
135
  const BARE_TYPE_LITERALS: Record<string, Record<string, number>> = {
136
- "line-height": { "0": 2, "1": 4, "1.2": 4, "1.25": 7, "1.3": 2, "1.4": 3, "1.5": 4 },
137
- "letter-spacing": { "0": 4, "0.04em": 1, "0.06em": 1 },
136
+ "line-height": { "0": 2, "1": 5, "1.2": 4, "1.25": 7, "1.3": 2, "1.4": 3, "1.5": 4 },
137
+ // `inherit` is recorded rather than tokenised, and it is the one value here that is not
138
+ // debt: the sort button in a table header must render the tracking its own `th` sets, and
139
+ // naming a token would pin it to whatever that header uses TODAY. The UA stylesheet resets
140
+ // letter-spacing on form controls, so inheritance has to be asked for explicitly — the
141
+ // header and its button were rendering two different treatments side by side until it was.
142
+ "letter-spacing": { "0": 4, "0.04em": 1, "0.06em": 1, inherit: 1 },
138
143
  };
139
144
 
140
145
  describe("design tokens", () => {
@@ -242,3 +247,45 @@ describe("design tokens", () => {
242
247
  }
243
248
  });
244
249
  });
250
+
251
+ /**
252
+ * Bare `z-index` values the sheet is allowed to write, and why each one is not the app-wide
253
+ * order. This is a whitelist rather than a count, because unlike the type scale there is no
254
+ * migration debt here: a full `--z-index-*` family is published and read by seven rules, so
255
+ * any NEW bare value is a component opting out of the order rather than waiting for a pass.
256
+ *
257
+ * It exists because the absence of it cost a real defect. Tooltip wrote `z-index: 1` with a
258
+ * comment arguing it was "a local lift within a stacking context rather than a place in the
259
+ * app-wide order" — sound about its anchor and wrong about the page, since
260
+ * `[data-terp="tooltip-anchor"]` is only `position: relative` and so establishes no stacking
261
+ * context. The 1 competed in the root context against a sticky header at 30 and an open
262
+ * popover at 60, and lost: a tooltip that rendered below content "sometimes, not always".
263
+ * Nothing caught it, because a published scale with no guard against skipping it is a
264
+ * convention, not a contract.
265
+ */
266
+ const LOCAL_STACKING_LIFTS: Record<string, string> = {
267
+ 'dataview-column-resizer': "a lift inside the cell's own stacking context, above the cell's content and nothing else",
268
+ };
269
+
270
+ describe("stacking order", () => {
271
+ it("every z-index reads the published scale, or is a recorded local lift", () => {
272
+ const bare = [...sheet.matchAll(/([^{}]+)\{([^}]*)\}/g)].flatMap(([, selector, body]) => {
273
+ const match = /\bz-index:\s*([^;]+);/.exec(body ?? "");
274
+ if (match === null || match[1]!.includes("var(")) {
275
+ return [];
276
+ }
277
+ const marker = /data-terp="([^"]+)"/.exec(selector ?? "");
278
+ return [{ marker: marker?.[1] ?? (selector ?? "").trim(), value: match[1]!.trim() }];
279
+ });
280
+
281
+ const unrecorded = bare.filter(({ marker }) => !(marker in LOCAL_STACKING_LIFTS));
282
+ expect(
283
+ unrecorded,
284
+ "a bare z-index is a component opting out of the app-wide --z-index-* order. Read the " +
285
+ "token published for its level, or record it in LOCAL_STACKING_LIFTS with the reason " +
286
+ "its stacking context is genuinely local — and be sure it IS local: an anchor that is " +
287
+ "only position: relative establishes no stacking context, which is how the tooltip's " +
288
+ "own lift ended up competing with the page and losing.",
289
+ ).toEqual([]);
290
+ });
291
+ });