@terpjs/react-core 0.13.0 → 0.14.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/src/styles.ts CHANGED
@@ -181,6 +181,20 @@ export const TERP_STYLES_CSS = `
181
181
  --density-cell-pad-y: var(--density-compact-cell-pad-y);
182
182
  --density-cell-pad-x: var(--density-compact-cell-pad-x);
183
183
  }
184
+ /* The content column's gutter, tightened for a phone. One remap, and it replaces the two
185
+ base-plus-variant pairs the header and main each used to carry: everything that has to
186
+ start on the same vertical line now reads --shell-gutter and the variant moves the value
187
+ once. The band the page header renders also reads it as a NEGATIVE margin to bleed to the
188
+ column's edge, so a fourth and fifth copy of the same measure would have arrived with it —
189
+ which is what turned this from a nice-to-have into the mechanism.
190
+
191
+ UNLAYERED, beside the density remap, for the reason recorded there and not by analogy:
192
+ tokens.css declares --shell-gutter on :root and ships as an extracted <link> that PRECEDES
193
+ the injected sheet, so a layered copy of this rule would lose to it in a production build
194
+ while winning in dev. (0,2,0) against :root's (0,1,0) settles it here in either. */
195
+ [data-terp="appshell"][data-variant="mobile"] {
196
+ --shell-gutter: var(--space-4);
197
+ }
184
198
 
185
199
  @layer terp.reset {
186
200
  /* Document reset: the app shell owns the full canvas. Without this the
@@ -391,9 +405,12 @@ html {
391
405
  border: 1px solid;
392
406
  border-radius: var(--radius-md);
393
407
  }
408
+ /* The neutral tone's wash is --color-neutral-100, the value Badge and a neutral-toned
409
+ row already spell for the same tone. It was --color-neutral-50, which is the canvas: on
410
+ a card with no fill the alert kept its border and lost its wash entirely. */
394
411
  [data-terp="alert"][data-tone="neutral"] {
395
412
  color: var(--color-neutral-600);
396
- background: var(--color-neutral-50);
413
+ background: var(--color-neutral-100);
397
414
  }
398
415
  [data-terp="alert"][data-tone="info"] {
399
416
  color: var(--color-status-info);
@@ -546,6 +563,56 @@ textarea[data-terp="input"] {
546
563
  [data-terp="splitpage-panes"][data-list-width="lg"] {
547
564
  grid-template-columns: minmax(0, 32rem) minmax(0, 1fr);
548
565
  }
566
+ /* DetailList's multi-column half. Mobile-first, like SplitPage's panes above: the narrow
567
+ shape is one column with the term as a block above its value, which is the BASE rule and
568
+ therefore needs no query at all — this block is only the widths at which a shared label
569
+ column, or two pairs per row, fit.
570
+
571
+ They ran at every width before, and 430px is where that showed: four tracks in ~370px, and
572
+ detail-list-value's overflow-wrap: anywhere then broke words mid-token rather than letting
573
+ a value have its own line. The anywhere is right — it is what stops an unbreakable digest
574
+ overflowing its column — so the fix is to stop asking a phone to hold four tracks.
575
+
576
+ Why a viewport query and not a container query, which would be the more nearly right
577
+ instrument: container queries appear nowhere in this sheet, so introducing one for a single
578
+ component is a mechanism change that wants its own record rather than a line in a defect
579
+ fix, and the cutover the framework already has covers the width the failure was measured
580
+ at. It is also why DetailList reflows itself while Grid deliberately refuses to for a fixed
581
+ column count — Grid publishes columns="auto" as its responsive answer, and DetailList's
582
+ closed one-or-two has no such escape, so the reflow has to be the component's own.
583
+
584
+ PLACEMENT: the detail-list base rules are declared ~100 lines BELOW this block, so source
585
+ order cannot settle these. Specificity does, and deliberately: every selector here carries
586
+ the marker plus at least one attribute (0,2,0) against the base rule's (0,1,0). That is
587
+ exactly how splitpage-panes above already wins over its own base rule further down, and
588
+ styles.test.ts pins the property, because a reader cannot see it from the rule. The one
589
+ thing this block must NOT declare is row-gap: that belongs to the gap prop, whose roll-call
590
+ weighs the same (0,2,0) and is therefore declared later on purpose. */
591
+ [data-terp="detail-list"][data-columns="2"] {
592
+ grid-template-columns: repeat(2, minmax(0, 1fr));
593
+ column-gap: var(--space-4);
594
+ }
595
+ /* minmax(0, max-content) rather than a bare auto for the label track. An auto track floors
596
+ at min-content, which makes it the one track in this component that was never floored at
597
+ zero — so a label with nothing to break on widened the column and pushed the list past its
598
+ container, the same failure the value track's minmax(0, 1fr) was written to stop. Capping at
599
+ max-content also stops the label column claiming width no label is using: measured in a
600
+ 565px card, two independently-sized auto label tracks left a value track of ~400px holding
601
+ ~60px of text. */
602
+ [data-terp="detail-list"][data-layout="aligned"] {
603
+ grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
604
+ column-gap: var(--space-3);
605
+ }
606
+ [data-terp="detail-list"][data-layout="aligned"][data-columns="2"] {
607
+ grid-template-columns: repeat(2, minmax(0, max-content) minmax(0, 1fr));
608
+ }
609
+ /* display: contents is what makes the dt and dd grid items of the dl itself, so labels align
610
+ ACROSS rows without a DOM change — and it belongs in here rather than in the base rules
611
+ because it is the mechanism of the shared column, which exists only above the cutover.
612
+ Narrow, the row wrapper stays a block and each pair reads as two lines. */
613
+ [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-row"] {
614
+ display: contents;
615
+ }
549
616
  }
550
617
 
551
618
  /* Grids -------------------------------------------------------------------- */
@@ -645,26 +712,22 @@ textarea[data-terp="input"] {
645
712
  the mechanism anywhere in the suite. */
646
713
  overflow-wrap: anywhere;
647
714
  }
648
- /* Two pairs per row. minmax(0, 1fr) rather than 1fr for the reason Grid's fixed counts need
649
- it: a bare 1fr floors at min-content, and a 64-character digest with nothing to break on
650
- then widens its column and pushes the list past its container — which is the defect the
651
- diagnosis was describing, rather than the missing alignment it named. */
652
- [data-terp="detail-list"][data-columns="2"] {
653
- grid-template-columns: repeat(2, minmax(0, 1fr));
654
- column-gap: var(--space-4);
655
- }
656
- /* Aligned: every label in a shared column. The row wrapper becomes display: contents so the
657
- dt and dd are grid items of the dl itself — the only way to align across rows without
658
- changing the DOM, and the reason the wrapper needed a marker at all. */
659
- [data-terp="detail-list"][data-layout="aligned"] {
660
- grid-template-columns: auto minmax(0, 1fr);
661
- column-gap: var(--space-3);
662
- }
663
- [data-terp="detail-list"][data-layout="aligned"][data-columns="2"] {
664
- grid-template-columns: repeat(2, auto minmax(0, 1fr));
665
- }
666
- [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-row"] {
667
- display: contents;
715
+ /* The tracks for two pairs per row, and for aligned, live in the wide-viewport block above
716
+ rather than here: one column is the narrow shape and therefore the base. What stays here is
717
+ everything that holds at every width.
718
+
719
+ Both non-inline layouts open their rows to --space-3. At the base --space-1 the distance
720
+ WITHIN a pair and the distance BETWEEN pairs were the same 4px, so a card of five labelled
721
+ values had nothing grouping it — five pairs read as ten equally-spaced lines. inline keeps
722
+ --space-1, because there a pair IS one line of a paragraph and 4px is the leading between
723
+ lines of one block; opening it would space out a run of sentences.
724
+
725
+ row-gap rather than gap, and the distinction is load-bearing: the column gap in aligned is
726
+ the label-to-value distance, and at two pairs per row it is the space between pair groups —
727
+ both owned by the rules in the wide block. A shorthand here would silently reset them. */
728
+ [data-terp="detail-list"][data-layout="aligned"],
729
+ [data-terp="detail-list"][data-layout="stacked"] {
730
+ row-gap: var(--space-3);
668
731
  }
669
732
  [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-term"],
670
733
  [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-value"],
@@ -672,12 +735,37 @@ textarea[data-terp="input"] {
672
735
  [data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-value"] {
673
736
  display: block;
674
737
  }
675
- /* Stacked: label above value. The label takes the muted step so the pair reads as one unit
676
- rather than two lines of equal weight. */
738
+ /* The label takes the muted, smaller, regular step so a pair reads as one unit rather than two
739
+ lines of equal weight — and aligned shares the rule rather than getting a second treatment
740
+ of its own. It did not, and that was the defect: an aligned term measured 16px / weight 500 /
741
+ near-black, which is the value's own typography, so a card of five labelled values rendered
742
+ as a wall of bold text with nothing telling a reader which half to read first.
743
+
744
+ inline is deliberately NOT here. There the term is half a sentence — the colon comes from
745
+ the ::after above — and muting half a sentence is a different defect from the one this fixes.
746
+ Two layouts diverging was the bug; three converging would be another. */
747
+ [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-term"],
677
748
  [data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-term"] {
678
749
  font-size: var(--font-size-xs);
750
+ font-weight: var(--font-weight-normal);
679
751
  color: var(--color-fg-muted);
680
752
  }
753
+ /* The gap prop, and this block must stay AFTER the layout rules above. Both
754
+ [data-terp="detail-list"][data-gap="3"] and [data-terp="detail-list"][data-layout="aligned"]
755
+ weigh (0,2,0), so nothing but source order decides which row-gap a list carrying both
756
+ attributes renders — backwards, the prop silently does nothing, which looks like the prop not
757
+ working rather than like a cascade mistake. The same tie the responsive Stack rules turn on,
758
+ pinned the same way in styles.test.ts.
759
+
760
+ row-gap only, for the reason the layout rules above give. The prop is documented as the
761
+ distance BETWEEN pairs, and the column gap stays the layout's. */
762
+ [data-terp="detail-list"][data-gap="0"] { row-gap: var(--space-0); }
763
+ [data-terp="detail-list"][data-gap="1"] { row-gap: var(--space-1); }
764
+ [data-terp="detail-list"][data-gap="2"] { row-gap: var(--space-2); }
765
+ [data-terp="detail-list"][data-gap="3"] { row-gap: var(--space-3); }
766
+ [data-terp="detail-list"][data-gap="4"] { row-gap: var(--space-4); }
767
+ [data-terp="detail-list"][data-gap="6"] { row-gap: var(--space-6); }
768
+ [data-terp="detail-list"][data-gap="8"] { row-gap: var(--space-8); }
681
769
 
682
770
  /* Checkboxes / radios / switches ------------------------------------------- */
683
771
  /* One label shape for all three, so the marker is shared: the control differs,
@@ -727,11 +815,42 @@ textarea[data-terp="input"] {
727
815
  }
728
816
 
729
817
  /* Cards -------------------------------------------------------------------- */
818
+ /* The surface model, and this is the rule that states it: an in-flow block declares a
819
+ FRAME and no fill. Border, radius and padding are the whole of a card's chrome, and
820
+ what shows through it is the canvas the page already paints.
821
+
822
+ It used to paint --color-neutral-0 over a --color-neutral-50 canvas — a second colour
823
+ saying what the border already says, and it cost twice. A card dropped onto anything
824
+ that is itself a surface repainted it rather than sitting on it, which is the
825
+ frame-inside-a-frame the plain variant exists to escape; and an app that themed the
826
+ canvas found that its cards did not follow, because the fill named the OTHER end of
827
+ the ramp. Both go away with the declaration: the plain variant is now the base rule
828
+ minus a border and a padding, and a themed canvas reaches every block on the page.
829
+
830
+ Six rules follow the same line, and they are the framework's other in-flow blocks:
831
+ hubcard-body, profile-card, resource-list-row, empty-state, dataview-card and the full
832
+ DataView's table frame — whose header cells carried the same fill, invisibly, since a
833
+ filled frame sat directly behind them. Three kinds of element deliberately do NOT. An
834
+ overlay has to be opaque over whatever it covers (dialog, popover-panel, toast, the
835
+ combobox list, and appshell-header, which the page scrolls under); a control needs a
836
+ surface of its own to read as a control against the page (input, select, the secondary
837
+ button); and login-card is the one object on an otherwise empty canvas, where the fill
838
+ IS the object.
839
+
840
+ The washes moved with it, because a wash that painted --color-neutral-50 was painting
841
+ the canvas and on a card with no fill it painted nothing: the table's hover and
842
+ selection, the expanded row's panel, a code block, a neutral alert and a disabled
843
+ control each step one place along the ramp to --color-neutral-100 — which is already
844
+ this sheet's hover wash for every control — or to the --color-interactive-* token that
845
+ names the state. One step along the ramp rather than a value per rule, because the dark
846
+ themes invert it: the same step is darker than the canvas in light and lighter in dark,
847
+ midnight and twilight, which is the direction a recess and a wash want in each.
848
+ --color-bg-inset cannot do that job — in those three themes it is declared AS the
849
+ canvas value, so an inset named from it would be the one thing that disappears. */
730
850
  [data-terp="card"] {
731
851
  display: flex;
732
852
  flex-direction: column;
733
853
  gap: var(--space-3);
734
- background: var(--color-neutral-0);
735
854
  border: 1px solid var(--color-neutral-200);
736
855
  border-radius: var(--radius-lg);
737
856
  padding: var(--space-4);
@@ -751,19 +870,48 @@ textarea[data-terp="input"] {
751
870
  flex-wrap: wrap;
752
871
  gap: var(--space-3);
753
872
  }
873
+ /* start rather than center once there is a description, and the condition is the whole point.
874
+ With a title alone, center is right: the actions slot is a control, so its box is
875
+ --density-control-min-height tall against a single line box, and start would leave the title
876
+ riding above it. With two or three lines of description the same declaration floats the
877
+ control in the middle of the block instead of beside the title it belongs to.
878
+
879
+ :has() rather than an attribute Card could stamp, for the reason control-label's disabled
880
+ states use it (see the note there): the header has no idea what its heading holds, and the
881
+ alternative is a prop describing the DOM back to the sheet. Specificity (0,2,0) beats the
882
+ base rule above, so this does not depend on its position. */
883
+ [data-terp="card-header"]:has([data-terp="card-description"]) {
884
+ align-items: start;
885
+ }
886
+ /* flex: 1 1 0, and the base size is the load-bearing half. Left at the initial 0 1 auto, the
887
+ heading's hypothetical main size is the max-content width of a block holding a title AND a
888
+ sentence of description — and flex breaks lines on hypothetical main sizes BEFORE it shrinks
889
+ anything, so with flex-wrap above the heading claimed the whole line and the actions slot
890
+ wrapped underneath it. Measured: a 103px header with the button below the description, where
891
+ the same component with no description rendered inline at 48px. Same prop, two results,
892
+ depending on whether a sibling prop was set.
893
+
894
+ A base size of 0 means both items fit on one line by construction, and the heading then grows
895
+ into whatever the actions slot does not use. min-width: 0 stays for the other half of that
896
+ story: a flex item's automatic minimum size is its content's, so a long unbreakable word in a
897
+ title would otherwise refuse to shrink past it. */
754
898
  [data-terp="card-heading"] {
899
+ flex: 1 1 0;
755
900
  min-width: 0;
756
901
  }
757
- /* Chrome off, heading kept. Three declarations removed rather than a second component
902
+ /* Chrome off, heading kept. Two declarations removed rather than a second component
758
903
  with six markers of its own describing the same DOM: a titled region inside something
759
904
  that is already a surface wants no second border, and the commonest instance is a
760
905
  section whose body is a DataView — boxed, the table gets a border inside a border and
761
906
  loses the full width its own scroll container gives it.
762
907
 
908
+ Two, and it was three: background: none went with the base rule's fill. Un-declaring
909
+ what nothing declares is the shape ADR 0094 exists to avoid, and here it would also
910
+ have been the only line in the sheet still implying a card has a colour of its own.
911
+
763
912
  padding: 0 rather than dropping the declaration, because the base rule sets it and an
764
913
  absent value inherits nothing useful. */
765
914
  [data-terp="card"][data-variant="plain"] {
766
- background: none;
767
915
  border-color: transparent;
768
916
  padding: 0;
769
917
  }
@@ -837,12 +985,16 @@ textarea[data-terp="input"] {
837
985
  background: var(--color-neutral-100);
838
986
  color: var(--color-fg-default);
839
987
  }
988
+ /* The same wash as inline code, and that is new: the block was --color-neutral-50 while
989
+ the chip above it was --color-neutral-100, so two renderings of one thing disagreed —
990
+ and the block's half was the canvas, which a card with no fill would have shown
991
+ through. One value now, for both. */
840
992
  [data-terp="code-block"] {
841
993
  margin: 0;
842
994
  padding: var(--space-3);
843
995
  border: 1px solid var(--color-neutral-200);
844
996
  border-radius: var(--radius-md);
845
- background: var(--color-neutral-50);
997
+ background: var(--color-neutral-100);
846
998
  overflow-x: auto;
847
999
  font-size: var(--font-size-sm);
848
1000
  line-height: var(--font-line-height-base);
@@ -892,9 +1044,15 @@ textarea[data-terp="input"] {
892
1044
  [data-terp="card-actions"] {
893
1045
  flex-shrink: 0;
894
1046
  }
1047
+ /* lg, and it is no longer "the step below the page title" — page-title moved DOWN to sm in
1048
+ 0.14.0 when the header became a band, so a section heading is now two steps above the view's
1049
+ own h1. That inversion is deliberate and belongs to the band: a page title there is chrome
1050
+ naming where you are, not the largest thing on the page, while a card title is the heading
1051
+ of content you are reading. The original reasoning for lg is unchanged: at base a section
1052
+ heading was typographically indistinguishable from the prose underneath it. */
895
1053
  [data-terp="card-title"] {
896
1054
  margin: 0;
897
- font-size: var(--font-size-base);
1055
+ font-size: var(--font-size-lg);
898
1056
  font-weight: var(--font-weight-semibold);
899
1057
  line-height: 1.3;
900
1058
  }
@@ -1296,6 +1454,30 @@ textarea[data-terp="input"] {
1296
1454
  flex-grow: 1;
1297
1455
  min-width: 0;
1298
1456
  }
1457
+ /* The header's INLINE padding is the content column's gutter, and it is a shared measure
1458
+ rather than a value this rule is free to pick. Four boxes have to start on the same
1459
+ vertical line — this header, appshell-main, appshell-footer, and the band the page header
1460
+ renders, which reaches the column's edge by reading the same value as a negative margin.
1461
+
1462
+ It was var(--space-4) here against var(--space-6) on main and on the footer, which put
1463
+ the breadcrumb trail 0.5rem right of the header's own toggle on every desktop shell and
1464
+ lined it up with nothing: two of the three boxes already agreed and the header was the
1465
+ outlier. Not a clean indent either, which is why it read as broken rather than deliberate
1466
+ — the toggle is a 2.25rem box centring a 1em glyph at font-size-sm, so its BOX sat 8px
1467
+ left of the trail while its GLYPH sat 3px right of it. On mobile it happened to be
1468
+ correct, because main stepped down to var(--space-4) and met the header's fixed value by
1469
+ accident.
1470
+
1471
+ Every reader now names --shell-gutter and the variant moves the value once, at the remap
1472
+ above. That is a published contract token rather than a private property because
1473
+ tokens.guard.test.ts refuses a fallback-less var() against anything tokens.css does not
1474
+ declare — and a shell measure an app may want to move is what ADR 0097 §1 says a contract
1475
+ token is for. The block padding is untouched and stays var(--space-2) under the
1476
+ min-height floor.
1477
+
1478
+ No backticks anywhere above, and that is not a style preference: one here terminates
1479
+ TERP_STYLES_CSS and the parse then fails somewhere else entirely with "try inserting a
1480
+ semicolon". This comment cost that mistake once too, which is twice in one sheet. */
1299
1481
  [data-terp="appshell-header"] {
1300
1482
  position: sticky;
1301
1483
  top: 0;
@@ -1305,7 +1487,7 @@ textarea[data-terp="input"] {
1305
1487
  align-items: center;
1306
1488
  justify-content: space-between;
1307
1489
  gap: var(--space-3);
1308
- padding: var(--space-2) var(--space-4);
1490
+ padding: var(--space-2) var(--shell-gutter);
1309
1491
  min-height: var(--shell-header-height);
1310
1492
  box-sizing: border-box;
1311
1493
  background: var(--color-neutral-0);
@@ -1358,16 +1540,26 @@ textarea[data-terp="input"] {
1358
1540
  [data-terp="appshell-brand-row"] > [data-terp="iconbutton"] {
1359
1541
  color: var(--color-sidebar-muted);
1360
1542
  }
1543
+ /* Main, the footer and the page band all read the same gutter as the header — see that rule
1544
+ for why they are one measure. The mobile override this pair used to carry is gone into the
1545
+ remap: --shell-gutter is 1.5rem on desktop and 1rem on a phone, which is exactly what main's
1546
+ two rules computed, so main does not move and it has one owner instead of two.
1547
+
1548
+ THE FOOTER DOES MOVE, and it is the one thing here that is a change rather than a
1549
+ refactor. It carried var(--space-6) with no mobile override, so its inline padding stayed
1550
+ 1.5rem at every width while main stepped down to 1rem beside it; reading the gutter now
1551
+ tightens it to 1rem on a phone, 8px left of where it was. That is the point of the measure
1552
+ rather than a casualty of it — a footer indented past the content above it was the same
1553
+ disagreement this token exists to end — but no baseline covers it, because the 420x900
1554
+ phone specimens put the footer below the fold. Stated here because it is invisible to the
1555
+ lanes and would otherwise read as an accident to whoever finds it. */
1361
1556
  [data-terp="appshell-main"] {
1362
1557
  flex-grow: 1;
1363
- padding: var(--space-6);
1558
+ padding: var(--shell-gutter);
1364
1559
  min-width: 0;
1365
1560
  }
1366
- [data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-main"] {
1367
- padding: var(--space-4);
1368
- }
1369
1561
  [data-terp="appshell-footer"] {
1370
- padding: var(--space-3) var(--space-6);
1562
+ padding: var(--space-3) var(--shell-gutter);
1371
1563
  border-block-start: 1px solid var(--color-neutral-200);
1372
1564
  color: var(--color-fg-subtle);
1373
1565
  font-size: var(--font-size-xs);
@@ -1479,38 +1671,140 @@ textarea[data-terp="input"] {
1479
1671
  [data-terp="page"][data-measure="narrow"] {
1480
1672
  max-width: 32rem;
1481
1673
  }
1674
+ /* The page band (ADR 0097 §2, amended by building it).
1675
+
1676
+ ONE row, and the row IS the title. The frame used to spend two: a crumb row with a 2rem
1677
+ floor, then an h1 row at font-size-xl. Those two rows said the same thing twice, and the
1678
+ duplication was in the component rather than in the eye of a reviewer — Page built its
1679
+ trail as [...breadcrumbs, { label: title }] and then rendered <h1>{title}</h1>, so every
1680
+ DetailPage in every app printed its own name as the leaf crumb and again 24px below it.
1681
+ Removing one of the two was therefore the whole design question, and the answer is that
1682
+ the TRAIL survives: it carries the path back up, which an h1 cannot, and its leaf is a
1683
+ heading, which a crumb need not be. So the leaf is the h1 (see page-title below) and the
1684
+ band is the trail with the page's badges, its lead line and its actions on the same line.
1685
+
1686
+ xl retires from page-title with it, and that is a reversal of 4b's type-scale decision
1687
+ rather than a side effect. That decision was right for the shape it was made in: when the
1688
+ title is the page's masthead, 24 / 18 / 16 / 14 is a scale and 18 / 16 / 16 / 14 is a
1689
+ list. In a band the title is CHROME — it names where you are, above content that starts
1690
+ immediately under a border — and a 24px leaf on a trail of 14px ancestors reads as
1691
+ small-small-BIG rather than as one trail. It also does not fit: the band is
1692
+ shell-header-height with space-2 of block padding, which leaves 2rem, and xl at
1693
+ line-height 1.3 is 1.95rem of it before a badge or a lead line asks for room.
1694
+ --font-size-xl keeps two readers (heading[data-size="xl"], login-title), so the top of the
1695
+ scale is still wired; an app that wants the masthead back redefines the marker from its own
1696
+ unlayered theme.css, exactly as the retired comment here said.
1697
+
1698
+ align-items: center rather than baseline, because the row mixes a heading, pill badges and
1699
+ a button cluster: baseline alignment lines up the text and leaves the pills sitting low.
1700
+ Wrapping is kept from the old heading row for the same reason it was there — a long title
1701
+ meeting a wide action cluster takes a second line instead of overflowing — and the band
1702
+ grows past its floor when it does. */
1482
1703
  [data-terp="page-header"] {
1483
- display: grid;
1484
- gap: var(--space-2);
1485
- }
1486
- /* The crumb row keeps a 2rem floor, and it is doing work rather than reserving
1487
- space for its own sake: the trail is shorter than 2rem at font-size-sm, so
1488
- dropping the floor closes the gap under the trail on every page that has one.
1489
- Measured — removing it moves all six baselines with a trail and nothing else. */
1490
- [data-terp="page-breadcrumbs"] {
1491
1704
  display: flex;
1705
+ flex-wrap: wrap;
1492
1706
  align-items: center;
1493
- min-height: 2rem;
1707
+ justify-content: space-between;
1708
+ gap: var(--space-2) var(--space-4);
1709
+ min-width: 0;
1494
1710
  }
1495
- /* Title left, the actions slot right, wrapping rather than overflowing when a long
1496
- title meets a wide action cluster. */
1711
+ /* The band's left group: the trail (whose leaf is the h1), then badges, then the lead line.
1712
+ flex: 1 1 auto with min-width: 0 is what lets the lead line truncate instead of pushing
1713
+ the action cluster off the row. */
1497
1714
  [data-terp="page-heading"] {
1498
1715
  display: flex;
1499
- align-items: center;
1500
- justify-content: space-between;
1501
- gap: var(--space-3);
1502
1716
  flex-wrap: wrap;
1717
+ align-items: center;
1718
+ gap: var(--space-2) var(--space-3);
1719
+ flex: 1 1 auto;
1720
+ min-width: 0;
1503
1721
  }
1504
- /* The single h1 of the view. margin: 0 is load-bearing the browser default h1
1505
- margin would otherwise fight the header's own gap. */
1722
+ /* The single h1 of the view, and now also the trail's current crumb: one node instead of two
1723
+ copies of one string. margin: 0 is load-bearing twice over — the browser default h1 margin
1724
+ would fight the band's own gap, and an h1 inside the trail's <li> would otherwise break the
1725
+ row. font-size is declared rather than inherited because the UA sheet sets h1 { font-size:
1726
+ 2em } ON the element, and a declaration on the element beats an inherited value however
1727
+ specific the ancestor rule is: without this the leaf renders at twice the trail.
1728
+
1729
+ Semibold at the trail's own size, which is the whole "the trail is the title" idea in one
1730
+ declaration — heavier than its ancestors, not larger. */
1506
1731
  [data-terp="page-title"] {
1507
1732
  margin: 0;
1508
- font-size: var(--font-size-lg);
1733
+ font-size: var(--font-size-sm);
1509
1734
  font-weight: var(--font-weight-semibold);
1510
1735
  letter-spacing: 0;
1511
1736
  color: var(--color-neutral-900);
1512
1737
  line-height: 1.3;
1513
1738
  }
1739
+ /* Status pills next to the title. A row of its own so a page passing several keeps them
1740
+ together when the band wraps, rather than letting one pill orphan onto the second line. */
1741
+ [data-terp="page-badges"] {
1742
+ display: flex;
1743
+ flex-wrap: wrap;
1744
+ align-items: center;
1745
+ gap: var(--space-1);
1746
+ min-width: 0;
1747
+ }
1748
+ /* The lead line: one short sentence about the page, truncated rather than wrapped, because
1749
+ the band has a height and prose that wraps would set it. A page whose explanation does not
1750
+ fit on one line is describing its body, and that belongs in the body. */
1751
+ [data-terp="page-description"] {
1752
+ margin: 0;
1753
+ flex: 1 1 auto;
1754
+ min-width: 0;
1755
+ color: var(--color-fg-subtle);
1756
+ font-size: var(--font-size-sm);
1757
+ overflow: hidden;
1758
+ white-space: nowrap;
1759
+ text-overflow: ellipsis;
1760
+ }
1761
+ /* The band's CHROME, which needs no shell above it and so is not gated on one. Its height
1762
+ and its border are what make the row a band rather than a title line, and they are correct
1763
+ wherever Page renders — the workbench's specimen cards and the unit tests included. That
1764
+ split is the repair of a real contradiction: these declarations used to sit inside the
1765
+ shell-gated rule below while the comment claimed "standalone, the band is still a bordered
1766
+ row at the header's height", which the rule made false and a test fifteen lines away in
1767
+ styles.test.ts pinned as false. The three page-header specimens were pictures of a plain
1768
+ flex row that the comment described as chrome.
1769
+
1770
+ box-sizing: border-box so the floor counts the padding, matching appshell-header, whose
1771
+ height this is reading. Without it the two are a padding apart and the "same height as the
1772
+ header above it" claim is off by 1rem.
1773
+
1774
+ :not([data-measure="narrow"]) because a form is capped WITH its header (ADR 0098 §3) — a
1775
+ Save button a screen-width from its field is worse than one over it — so a form gets the
1776
+ one-row band with no chrome at all: a title row, which is what it wants.
1777
+
1778
+ padding-BLOCK only. The inline gutter belongs to the bleed below, because standalone there
1779
+ is nothing to bleed into and an inline pad with no negative margin would inset the band's
1780
+ content from the body beneath it for no reason. */
1781
+ [data-terp="page"]:not([data-measure="narrow"]) > [data-terp="page-header"] {
1782
+ padding-block: var(--space-2);
1783
+ min-height: var(--shell-header-height);
1784
+ box-sizing: border-box;
1785
+ border-block-end: 1px solid var(--color-neutral-200);
1786
+ }
1787
+ /* The BLEED, which does need a shell, because the negative-margin idiom is only correct when
1788
+ the box being escaped is appshell-main and that box pads by exactly --shell-gutter. ADR
1789
+ 0097 §2 kept "it works with no shell above it at all" as a property of the mechanism, and
1790
+ keying this half on the shell is what keeps it: standalone the band is the same bordered
1791
+ row at the same height, it simply does not reach past its container.
1792
+
1793
+ The surface comes with the bleed rather than with the chrome, and that is deliberate under
1794
+ 0.14.0's surface model: a block paints no fill of its own, and this one is only chrome —
1795
+ the app header's companion — once there is an app header above it to pair with.
1796
+
1797
+ ONE rule for both variants, which is the payoff of the token rather than a tidy-up. As two
1798
+ literals this needed a mobile twin, and a negative margin whose sign has to agree with a
1799
+ padding declared three rules away is exactly the drift that put the trail 8px off the
1800
+ header in the first place. */
1801
+ [data-terp="appshell-main"]
1802
+ > [data-terp="page"]:not([data-measure="narrow"])
1803
+ > [data-terp="page-header"] {
1804
+ margin: calc(-1 * var(--shell-gutter)) calc(-1 * var(--shell-gutter)) 0;
1805
+ padding-inline: var(--shell-gutter);
1806
+ background: var(--color-neutral-0);
1807
+ }
1514
1808
  /* The content measure, and the subheader band, which are ONE declaration rather than two
1515
1809
  features (ADR 0097 §2). A full-width band only means anything once the column beside it is
1516
1810
  constrained, and constraining the column is what leaves the header spanning the full track.
@@ -1702,7 +1996,6 @@ textarea[data-terp="input"] {
1702
1996
  gap: var(--space-4);
1703
1997
  padding: var(--space-4);
1704
1998
  max-width: 32rem;
1705
- background: var(--color-neutral-0);
1706
1999
  border: 1px solid var(--color-neutral-200);
1707
2000
  border-radius: var(--radius-lg);
1708
2001
  }
@@ -1848,7 +2141,6 @@ textarea[data-terp="input"] {
1848
2141
  padding: var(--space-4);
1849
2142
  border: 1px solid var(--color-neutral-200);
1850
2143
  border-radius: var(--radius-lg);
1851
- background: var(--color-neutral-0);
1852
2144
  color: var(--color-neutral-900);
1853
2145
  box-sizing: border-box;
1854
2146
  transition: border-color var(--motion-duration-fast) var(--motion-easing-standard);
@@ -1958,6 +2250,11 @@ textarea[data-terp="input"] {
1958
2250
  leaves the toolbar inside a surface, and leaves two nested frames whenever the view
1959
2251
  is empty (the empty state's dashed frame inside the card's solid one).
1960
2252
 
2253
+ What the slot carries is now a FRAME rather than a fill — border and radius, with the
2254
+ page showing through, per the card rule's note on the surface model. That changes the
2255
+ sentence above by one word and not the ownership it settles: the frame is the table's,
2256
+ not the view's.
2257
+
1961
2258
  Keyed on [data-variant="full"] rather than the bare marker for the reason the
1962
2259
  previous rule gave and which still holds: [data-variant="embedded"] must declare
1963
2260
  nothing, and un-declaring a surface with background: transparent / border: 0 is the
@@ -1965,7 +2262,6 @@ textarea[data-terp="input"] {
1965
2262
  [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"],
1966
2263
  [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-error"],
1967
2264
  [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-skeleton"] {
1968
- background: var(--color-neutral-0);
1969
2265
  border: 1px solid var(--color-neutral-200);
1970
2266
  border-radius: var(--radius-lg);
1971
2267
  }
@@ -2072,10 +2368,15 @@ textarea[data-terp="input"] {
2072
2368
  [data-terp="dataview-toolbar"][data-variant="selection"] {
2073
2369
  /* Still a filled surface, because it marks a MODE and losing that would make
2074
2370
  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. */
2371
+ card, so it takes the padding and radius that make it read as one.
2372
+
2373
+ And the fill is the SELECTION token now, not --color-neutral-50: that value is the
2374
+ canvas, so the strip that floats on the canvas was painting a rectangle nobody could
2375
+ see. --color-interactive-selected is what the selected rows below it carry, which is
2376
+ the pairing this band wanted anyway — one mode, one colour, top to bottom. */
2076
2377
  padding-inline: var(--density-cell-pad-x);
2077
2378
  border-radius: var(--radius-md);
2078
- background: var(--color-neutral-50);
2379
+ background: var(--color-interactive-selected);
2079
2380
  }
2080
2381
  [data-terp="dataview-toolbar-count"] {
2081
2382
  font-weight: var(--font-weight-medium);
@@ -2253,7 +2554,6 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2253
2554
  letter-spacing: 0.04em;
2254
2555
  border-bottom: 1px solid var(--color-neutral-200);
2255
2556
  white-space: nowrap;
2256
- background: var(--color-neutral-0);
2257
2557
  }
2258
2558
  /* A column's declared track. A MINIMUM rather than a width, because a specified width is only a
2259
2559
  preference under table-layout: auto and the algorithm shrinks it to fit — which is why the pixel
@@ -2311,9 +2611,16 @@ input[data-terp="input"][type="password"]::-ms-reveal {
2311
2611
  A row's own state outranks the selection tint, and the :not() is what says so
2312
2612
  without depending on source order — both selectors weigh (0,2,0) otherwise,
2313
2613
  which is precisely the trap the layer comment at the top of this file
2314
- describes. Selection stays legible through the checkbox and data-selected. */
2614
+ describes. Selection stays legible through the checkbox and data-selected.
2615
+
2616
+ The tint is --color-interactive-selected, the token's first reader. It was
2617
+ --color-neutral-50 — the canvas — which read as a tint only while the table sat on a
2618
+ filled card, and equalled the hover wash exactly, so a selected row and a pointed-at
2619
+ row looked the same. A brand-tinted row now says selected in every theme, and holds
2620
+ muted ink at AA there (5.19 at its narrowest, in midnight; declared as
2621
+ muted-on-selection so the gate measures it). */
2315
2622
  [data-terp="dataview-row"][data-selected="true"]:not([data-tone]) {
2316
- background: var(--color-neutral-50);
2623
+ background: var(--color-interactive-selected);
2317
2624
  }
2318
2625
  [data-terp="dataview-row"][data-tone="neutral"] {
2319
2626
  background: var(--color-neutral-100);
@@ -2427,7 +2734,6 @@ th[data-terp="dataview-actions-cell"] > span {
2427
2734
  display: grid;
2428
2735
  gap: var(--space-2);
2429
2736
  padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
2430
- background: var(--color-neutral-0);
2431
2737
  border: 1px solid var(--color-neutral-200);
2432
2738
  border-radius: var(--radius-lg);
2433
2739
  box-shadow: var(--shadow-sm);
@@ -2559,7 +2865,7 @@ th[data-terp="dataview-actions-cell"] > span {
2559
2865
  "this belongs to the row above" rather than as another row. */
2560
2866
  [data-terp="dataview-expanded-cell"] {
2561
2867
  padding: var(--density-cell-pad-y) var(--space-4);
2562
- background: var(--color-neutral-50);
2868
+ background: var(--color-neutral-100);
2563
2869
  border-block-end: 1px solid var(--color-neutral-200);
2564
2870
  }
2565
2871
 
@@ -2743,13 +3049,13 @@ th[data-terp="dataview-actions-cell"] > span {
2743
3049
  padding: var(--space-3);
2744
3050
  border: 1px solid var(--color-neutral-200);
2745
3051
  border-radius: var(--radius-md);
2746
- background: var(--color-neutral-0);
2747
3052
  }
2748
3053
 
2749
3054
  /* Empty / error / loading states ------------------------------------------- */
2750
3055
  /* Same centred block, opposite messages: empty is a dashed outline on the page
2751
3056
  surface because nothing is wrong, error is a filled danger wash because
2752
- something is. */
3057
+ something is. Which is now literal — the outline is all of it, and the surface
3058
+ underneath is whatever the block was dropped on. */
2753
3059
  [data-terp="empty-state"] {
2754
3060
  display: grid;
2755
3061
  justify-items: center;
@@ -2759,7 +3065,6 @@ th[data-terp="dataview-actions-cell"] > span {
2759
3065
  color: var(--color-neutral-600);
2760
3066
  border: 1px dashed var(--color-neutral-300);
2761
3067
  border-radius: var(--radius-lg);
2762
- background: var(--color-neutral-0);
2763
3068
  }
2764
3069
  /* Compact: a section's emptiness rather than the page's. Same frame and same words,
2765
3070
  laid out as a row — the glyph beside the text instead of above it — so two of
@@ -3751,8 +4056,12 @@ button[data-terp="input"][data-placeholder="true"] {
3751
4056
  }
3752
4057
  [data-terp="input"]:disabled {
3753
4058
  /* background-color (not the background shorthand) so the Select's chevron,
3754
- drawn as a background-image, survives the disabled state. */
3755
- background-color: var(--color-neutral-50);
4059
+ drawn as a background-image, survives the disabled state.
4060
+
4061
+ --color-neutral-100 rather than the canvas value it used to carry: a disabled field
4062
+ on a card with no fill would have been a hole in the page rather than a control with
4063
+ a wash, and the enabled field beside it paints the other end of the ramp. */
4064
+ background-color: var(--color-neutral-100);
3756
4065
  color: var(--color-fg-subtle);
3757
4066
  cursor: not-allowed;
3758
4067
  }
@@ -4050,8 +4359,29 @@ button[data-terp="input"][data-placeholder="true"] {
4050
4359
  [data-terp="dataview-table"] tbody tr {
4051
4360
  transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
4052
4361
  }
4053
- [data-terp="dataview-table"] tbody tr:hover td {
4054
- background: var(--color-neutral-50);
4362
+ /* --color-interactive-hover, which is the semantic name for the value every control's
4363
+ hover in this sheet already spells, and the token's first reader. The row was one step
4364
+ lighter, on the canvas value, so once the table frame stopped painting a fill there was
4365
+ nothing for a hover to differ from.
4366
+
4367
+ THE ROW'S OWN COLOUR WINS, and the two :not() clauses are the whole of that rather than
4368
+ defensive noise. A row's tone and its selection tint are painted on the tr; this wash is
4369
+ painted on the td, and a cell background paints ABOVE its row's. So without the guards,
4370
+ pointing at a selected row repaints it #f1f5f9 over #eff6ff and it reads as unselected,
4371
+ and pointing at a danger-toned row erases the tone entirely. Both are data; the wash is
4372
+ an affordance, and an affordance may not overwrite what the row is telling you.
4373
+
4374
+ The selection half only became visible when --color-interactive-selected split away from
4375
+ --color-neutral-50: while both spelled the same value the collision painted identically
4376
+ and no baseline could have shown it. The tone half was live before that and equally
4377
+ unpicturable, because no specimen renders a hovered row at all.
4378
+
4379
+ A guarded row therefore shows no wash change and the cursor is what says it is hoverable
4380
+ there — which is exactly what this comment already claimed for the neutral tone, whose
4381
+ --color-neutral-100 happens to equal this token in every theme. That was true by
4382
+ coincidence for one tone; it is true by construction for all of them now. */
4383
+ [data-terp="dataview-table"] tbody tr:not([data-tone]):not([data-selected="true"]):hover td {
4384
+ background: var(--color-interactive-hover);
4055
4385
  }
4056
4386
  /* The row containing keyboard focus, highlighted because it is the one Enter would
4057
4387
  open. Guarded on data-clickable, and the guard is not decoration: the row marker