@terpjs/react-core 0.13.1 → 0.15.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.
@@ -347,7 +347,6 @@ describe("cascade structure", () => {
347
347
  for (const selector of [
348
348
  '[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"]',
349
349
  '[data-terp="appshell-backdrop"]',
350
- '[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-main"]',
351
350
  ]) {
352
351
  expect(
353
352
  declaresRuleFor(base, selector),
@@ -355,6 +354,163 @@ describe("cascade structure", () => {
355
354
  "pictures of it",
356
355
  ).toBe(true);
357
356
  }
357
+ // The gutter's phone value is ONE rule where the header and main used to carry a mobile
358
+ // override each, and it is asserted against the UNLAYERED prelude rather than terp.base
359
+ // because that is where it has to live: tokens.css declares --shell-gutter on :root and
360
+ // ships as an extracted <link> ahead of this sheet, so a layered copy would lose to it
361
+ // in a production build while winning in dev — the exposure the density remap beside it
362
+ // records. app-shell-mobile is still its only picture.
363
+ const prelude = css.slice(0, css.indexOf("@layer terp.reset {"));
364
+ expect(
365
+ declaresRuleFor(prelude, '[data-terp="appshell"][data-variant="mobile"]'),
366
+ "the phone gutter must be remapped in the unlayered prelude, or tokens.css outranks it",
367
+ ).toBe(true);
368
+ expect(
369
+ declaresRuleFor(layerBody("terp.base"), '[data-terp="appshell"][data-variant="mobile"]'),
370
+ "a layered copy of the remap loses to tokens.css in a production build",
371
+ ).toBe(false);
372
+ // That the remap actually TIGHTENS is asserted in tokens.guard.test.ts, not here: it
373
+ // compares the remapped value against the one tokens.css publishes, and this file runs
374
+ // in jsdom where import.meta.url is not a file URL. The guard file already reads the
375
+ // token sheet for exactly this kind of claim.
376
+ const remap = /\[data-terp="appshell"\]\[data-variant="mobile"\] \{([^}]*)\}/.exec(prelude);
377
+ expect(remap, "the phone remap should be one rule").not.toBeNull();
378
+ expect(
379
+ remap![1]!,
380
+ "the remap must move --shell-gutter, not something else",
381
+ ).toContain("--shell-gutter:");
382
+ });
383
+
384
+ it("keeps the content column's gutter one measure", () => {
385
+ // The bug this exists for: appshell-header carried padding-inline var(--space-4) while
386
+ // appshell-main and appshell-footer carried var(--space-6), so the breadcrumb trail —
387
+ // the first thing inside main on every routed view — sat 0.5rem right of the header's
388
+ // own toggle. Boxes stack in that column and the topmost content in each starts at its
389
+ // inline padding edge, so their gutters are ONE measure and any two disagreeing is the
390
+ // defect.
391
+ //
392
+ // No baseline caught it, and could not have: every app-shell specimen recorded the
393
+ // misalignment as its expected picture from the first run. A screenshot says "this is
394
+ // what it looks like", never "these two edges are meant to be the same edge".
395
+ //
396
+ // It is now a published token with one remap rather than a literal repeated per box,
397
+ // which stopped being a preference when the page band arrived: the band reads the same
398
+ // measure as a NEGATIVE margin to reach the column's edge, so a literal would have had
399
+ // to agree in sign across rules three hundred lines apart.
400
+ const base = layerBody("terp.base");
401
+ const bodyFor = (selector: string): string => {
402
+ for (const match of base.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
403
+ const selectors = match[1].split(",").map((part) => part.trim().replace(/\s+/g, " "));
404
+ if (selectors.includes(selector)) return match[2];
405
+ }
406
+ throw new Error(`the sheet declares no rule for exactly ${selector}`);
407
+ };
408
+ /**
409
+ * The inline padding a rule sets, from either the shorthand's second value or an
410
+ * explicit `padding-inline`. Both spellings are in play on purpose: the header and the
411
+ * footer set block and inline together, main sets one value for all four sides.
412
+ */
413
+ const gutterOf = (selector: string): string => {
414
+ const declarations = bodyFor(selector);
415
+ const inline = /padding-inline:\s*([^;]+);/.exec(declarations);
416
+ if (inline) return inline[1]!.trim();
417
+ const shorthand = /padding:\s*([^;]+);/.exec(declarations);
418
+ if (!shorthand) throw new Error(`${selector} declares no padding at all`);
419
+ const parts = shorthand[1]!.trim().split(/\s+(?![^(]*\))/);
420
+ // One value pads every side, two are block-then-inline. Nothing in this column writes
421
+ // the three- or four-value form, and a rule that started to would land here rather
422
+ // than being read wrong.
423
+ if (parts.length === 1) return parts[0]!;
424
+ if (parts.length === 2) return parts[1]!;
425
+ throw new Error(
426
+ `${selector} writes a ${parts.length}-value padding this reader cannot split`,
427
+ );
428
+ };
429
+
430
+ const column = [
431
+ '[data-terp="appshell-header"]',
432
+ '[data-terp="appshell-main"]',
433
+ '[data-terp="appshell-footer"]',
434
+ ].map((selector) => [selector, gutterOf(selector)] as const);
435
+ expect(
436
+ new Set(column.map(([, gutter]) => gutter)).size,
437
+ `the column's boxes must share one inline gutter, got ${column
438
+ .map(([selector, gutter]) => `${selector} = ${gutter}`)
439
+ .join(", ")}`,
440
+ ).toBe(1);
441
+ // And it is the published token, not a spacing step restated three times. A literal here
442
+ // is the exact state the defect was in, and a literal passes the check above.
443
+ expect(column[0]![1], "the gutter must read the shell's own token").toBe(
444
+ "var(--shell-gutter)",
445
+ );
446
+
447
+ // The band splits in two, and which half is gated on the shell is the whole point.
448
+ //
449
+ // CHROME is ungated: a bordered row at the app header's height is correct wherever Page
450
+ // renders, the workbench's specimen cards and these tests included. It used to sit in the
451
+ // shell-gated rule while the comment beside it claimed the opposite, so the three
452
+ // page-header specimens pictured a plain flex row that the prose called chrome.
453
+ const chrome = bodyFor(
454
+ '[data-terp="page"]:not([data-measure="narrow"]) > [data-terp="page-header"]',
455
+ );
456
+ // Same height as the app header, by reading the header's own token rather than restating
457
+ // 3rem — and border-box, or the two are a padding apart and the claim is off by 1rem.
458
+ expect(chrome, "the band takes the app header's height").toContain(
459
+ "min-height: var(--shell-header-height)",
460
+ );
461
+ expect(chrome, "without border-box the floor excludes the padding").toContain(
462
+ "box-sizing: border-box",
463
+ );
464
+ expect(chrome, "the band is what separates chrome from content").toContain(
465
+ "border-block-end: 1px solid var(--color-neutral-200)",
466
+ );
467
+ expect(
468
+ /padding-inline|padding:/.test(chrome),
469
+ "an inline pad with no bleed would inset the band from the body beneath it",
470
+ ).toBe(false);
471
+
472
+ // THE BLEED is gated, because the negative-margin idiom is only correct against a box
473
+ // that pads by exactly this token. ADR 0097 section 2 kept "it works with no shell above
474
+ // it at all" as a property of the mechanism, and this keying is what keeps it. The sign
475
+ // agreement is a fact about two declarations in ONE rule rather than between rules.
476
+ const bleed = bodyFor(
477
+ '[data-terp="appshell-main"] > [data-terp="page"]:not([data-measure="narrow"])'
478
+ + ' > [data-terp="page-header"]',
479
+ );
480
+ expect(bleed, "the band must bleed by negating the gutter it pads by").toContain(
481
+ "margin: calc(-1 * var(--shell-gutter)) calc(-1 * var(--shell-gutter)) 0",
482
+ );
483
+ expect(bleed, "the band must restore the gutter as its own padding").toContain(
484
+ "padding-inline: var(--shell-gutter)",
485
+ );
486
+ expect(
487
+ bleed.includes("margin") && !/margin:[^;]*calc\(-1/.test(bleed),
488
+ "a bleed whose margin is not negative escapes nothing",
489
+ ).toBe(false);
490
+ });
491
+
492
+ it("keeps a row's own colour out of the hover wash's reach", () => {
493
+ // A row's tone and its selection tint paint on the tr; the table's hover wash paints on
494
+ // the td, and a cell background paints ABOVE its row's. So an unguarded wash repaints a
495
+ // selected row over its tint and a danger row over its tone: the affordance overwrites
496
+ // the data. The selection half only became visible when --color-interactive-selected
497
+ // split away from --color-neutral-50 — while the two spelled one value the collision was
498
+ // pixel-identical — and the tone half was live before that. Neither is picturable: no
499
+ // specimen renders a hovered row, so this assertion is the only gate.
500
+ // terp.state, not terp.base: a hover is a state, and that is where the sheet keeps it.
501
+ const state = layerBody("terp.state");
502
+ const hover = /\[data-terp="dataview-table"\][^{]*:hover td \{/.exec(state);
503
+ expect(hover, "the table should declare a hover wash").not.toBeNull();
504
+ const selector = state.slice(
505
+ state.lastIndexOf("}", hover!.index) + 1,
506
+ hover!.index + hover![0].length,
507
+ );
508
+ for (const guard of ['[data-tone]', '[data-selected="true"]']) {
509
+ expect(
510
+ selector,
511
+ `the hover wash must exclude :not(${guard}), or it repaints what the row is telling you`,
512
+ ).toContain(`:not(${guard})`);
513
+ }
358
514
  });
359
515
 
360
516
  it("keeps the hub-card declarations the lanes cannot explain", () => {
@@ -654,6 +810,191 @@ describe("cascade structure", () => {
654
810
  }
655
811
  });
656
812
 
813
+ it("puts the DetailList gap rules after the layout rules they override", () => {
814
+ // The same tie as the responsive Stack rules above, on a different component.
815
+ // [data-terp="detail-list"][data-gap="3"] and [data-terp="detail-list"][data-layout="aligned"]
816
+ // both weigh (0,2,0), so nothing but source order decides which row-gap a list carrying both
817
+ // attributes renders. Backwards, the layout default wins and the prop silently does nothing
818
+ // — which reads as a broken prop rather than as a misplaced rule, and no baseline can say
819
+ // which of the two it is looking at.
820
+ const base = layerBody("terp.base");
821
+ const layoutAt = base.indexOf('[data-terp="detail-list"][data-layout="stacked"] {');
822
+ expect(layoutAt, "the layouts' default row gap should have a rule").toBeGreaterThan(-1);
823
+ for (const token of [0, 1, 2, 3, 4, 6, 8]) {
824
+ const at = base.indexOf(`[data-terp="detail-list"][data-gap="${token}"]`);
825
+ expect(at, `detail-list has no rule for gap ${token}`).toBeGreaterThan(-1);
826
+ expect(at, `gap ${token} must be declared after the layout default it overrides`).toBeGreaterThan(
827
+ layoutAt,
828
+ );
829
+ }
830
+ });
831
+
832
+ it("wins DetailList's wide rules on specificity, not on where they sit", () => {
833
+ // The mirror of the test above, and the opposite hazard. The sheet has exactly ONE
834
+ // wide-viewport block and DetailList's base rules are declared roughly a hundred lines
835
+ // BELOW it, so source order settles these the wrong way round and cannot be relied on.
836
+ // Specificity is what makes them apply, which is precisely how splitpage-panes already
837
+ // works from this same block — and it is invisible to a reader of either rule.
838
+ //
839
+ // Asserted as a property rather than as a list of pixel values: every selector in here
840
+ // carries the marker plus at least one attribute, so it out-weighs the (0,1,0) base rule
841
+ // it overrides. These selectors are attribute-only, so counting `[` counts specificity's
842
+ // b-component exactly.
843
+ const base = layerBody("terp.base");
844
+ const wideAt = base.indexOf("@media not all and (max-width: 768px)");
845
+ expect(wideAt, "the wide block should be in terp.base").toBeGreaterThan(-1);
846
+ let depth = 0;
847
+ let end = base.length;
848
+ for (let i = base.indexOf("{", wideAt); i < base.length; i += 1) {
849
+ if (base[i] === "{") depth += 1;
850
+ else if (base[i] === "}") {
851
+ depth -= 1;
852
+ if (depth === 0) {
853
+ end = i;
854
+ break;
855
+ }
856
+ }
857
+ }
858
+ const wide = base.slice(wideAt, end);
859
+ for (const selector of [
860
+ '[data-terp="detail-list"][data-columns="2"]',
861
+ '[data-terp="detail-list"][data-layout="aligned"]',
862
+ '[data-terp="detail-list"][data-layout="aligned"][data-columns="2"]',
863
+ '[data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-row"]',
864
+ ]) {
865
+ expect(
866
+ declaresRuleFor(wide, selector),
867
+ `${selector} belongs inside the wide block — narrow is one column`,
868
+ ).toBe(true);
869
+ expect(
870
+ (selector.match(/\[/g) ?? []).length,
871
+ `${selector} must out-specify the base rule it overrides, which is declared later`,
872
+ ).toBeGreaterThan(1);
873
+ }
874
+ // And the base rules really are declared after it, or none of the above is load-bearing.
875
+ for (const selector of ['[data-terp="detail-list"] {', '[data-terp="detail-list-row"] {']) {
876
+ expect(base.indexOf(selector), `${selector} should exist`).toBeGreaterThan(-1);
877
+ expect(
878
+ base.indexOf(selector),
879
+ `${selector} is declared before the wide block, so order — not specificity — would settle it`,
880
+ ).toBeGreaterThan(wideAt);
881
+ }
882
+ // The one property the wide block must NOT declare: row-gap belongs to the gap prop, whose
883
+ // roll-call weighs the same (0,2,0) and is declared later on purpose. A row-gap in here
884
+ // would out-order it and the prop would stop working above the cutover only.
885
+ expect(wide, "row-gap in the wide block would silently disable the gap prop").not.toContain(
886
+ "row-gap",
887
+ );
888
+ });
889
+
890
+ it("mutes the DetailList label in both non-inline layouts, and in neither sentence", () => {
891
+ // The defect this closes was a divergence: `stacked` muted its term and `aligned` never
892
+ // got the rule, so an aligned label rendered at the VALUE's size, weight and ink — 16px,
893
+ // 500, near-black — and a card of five labelled values read as a wall of bold text with
894
+ // nothing saying which half of a pair to read first.
895
+ //
896
+ // `inline` is deliberately excluded, and that is the half worth pinning: there the term is
897
+ // part of a sentence (the colon comes from a ::after) and muting half a sentence is a
898
+ // different defect. So this asserts the selector list exactly, in both directions.
899
+ // Anchored on the RULES rather than on the section comment, because `css` above has its
900
+ // comments stripped — prose must not satisfy a structural assertion, so prose cannot
901
+ // delimit one either.
902
+ const base = layerBody("terp.base");
903
+ const muting = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
904
+ .map((match) => ({
905
+ selectors: match[1].split(",").map((part) => part.trim().replace(/\s+/g, " ")),
906
+ body: match[2],
907
+ }))
908
+ .filter(
909
+ (rule) =>
910
+ rule.selectors.some((selector) => selector.includes('[data-terp="detail-list-term"]')) &&
911
+ rule.body.includes("--color-fg-muted"),
912
+ );
913
+ expect(muting, "one shared rule mutes the label, not one per layout").toHaveLength(1);
914
+ expect(muting[0]!.selectors).toEqual([
915
+ '[data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-term"]',
916
+ '[data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-term"]',
917
+ ]);
918
+ expect(muting[0]!.body).toContain("var(--font-size-xs)");
919
+ expect(muting[0]!.body).toContain("var(--font-weight-normal)");
920
+ // And the base term rule — the one the inline layout renders — carries no colour of its own.
921
+ const termAt = base.indexOf('[data-terp="detail-list-term"] {');
922
+ expect(termAt, "the base term rule should exist").toBeGreaterThan(-1);
923
+ expect(base.slice(termAt, base.indexOf("}", termAt))).not.toContain("color:");
924
+ });
925
+
926
+ it("keeps a Card's actions slot on the title's line, description or not", () => {
927
+ // The measured inconsistency: `actions` is documented as a header-row slot and delivered
928
+ // one only while `description` was unset. The heading declared min-width: 0 alone, so it
929
+ // computed flex: 0 1 auto and its hypothetical main size was the max-content width of a
930
+ // block holding a title AND a sentence — and flex breaks lines on hypothetical main sizes
931
+ // BEFORE it shrinks anything, so with the header's flex-wrap the heading took the line and
932
+ // the control wrapped underneath. 103px against 48px for the same component, one prop apart.
933
+ //
934
+ // A base size of 0 is what makes both fit by construction. min-width: 0 stays for the other
935
+ // half: a flex item's automatic minimum is its content's, so a long unbreakable title would
936
+ // otherwise refuse to shrink past it. Both are asserted, because dropping either brings a
937
+ // different half of the bug back.
938
+ const base = layerBody("terp.base");
939
+ const headingAt = base.indexOf('[data-terp="card-heading"] {');
940
+ expect(headingAt, "card-heading should have a base rule").toBeGreaterThan(-1);
941
+ const heading = base.slice(headingAt, base.indexOf("}", headingAt));
942
+ expect(heading, "a content-sized heading wraps the actions slot onto its own line").toContain(
943
+ "flex: 1 1 0",
944
+ );
945
+ expect(heading, "min-width: 0 is what lets an unbreakable title shrink").toContain(
946
+ "min-width: 0",
947
+ );
948
+ // The conditional half. `center` is right for a title alone — the slot is a control, so its
949
+ // box is taller than one line box — and wrong the moment a description makes the heading a
950
+ // block, where it floats the control in the middle instead of beside the title.
951
+ expect(
952
+ declaresRuleFor(base, '[data-terp="card-header"]:has([data-terp="card-description"])'),
953
+ "the header's alignment must depend on whether there is a description",
954
+ ).toBe(true);
955
+ const conditionalAt = base.indexOf(
956
+ '[data-terp="card-header"]:has([data-terp="card-description"])',
957
+ );
958
+ expect(base.slice(conditionalAt, base.indexOf("}", conditionalAt))).toContain(
959
+ "align-items: start",
960
+ );
961
+ // And the base rule still centres, or the title rides above the control in the common case.
962
+ const headerAt = base.indexOf('[data-terp="card-header"] {');
963
+ expect(base.slice(headerAt, base.indexOf("}", headerAt))).toContain("align-items: center");
964
+ });
965
+
966
+ it("gives the page title, a card title and body copy three different steps", () => {
967
+ // The scale was flat: the single h1 of a view rendered at lg (18px) against a card title at
968
+ // base (16px) against 16px prose — one step from the h1 to a section heading, and a section
969
+ // heading the same size as the text under it. --font-size-xl was published with exactly one
970
+ // reader, so the top of the scale existed and the page that most needs it was not using it.
971
+ //
972
+ // Pinned here rather than left to the baselines because a screenshot cannot say WHICH step a
973
+ // rendered size came from, and the point of the change is that these four are four steps of
974
+ // one published scale rather than four sizes that happen to differ.
975
+ const base = layerBody("terp.base");
976
+ for (const [selector, step] of [
977
+ // page-title left the TOP step when the header became a band, but it still has a step
978
+ // and this is it: sm, the trail's own size, because the title IS the trail's leaf and a
979
+ // 24px leaf on 14px ancestors reads as small-small-BIG rather than as one trail. Pinned
980
+ // rather than dropped — removing it from this loop, which is what the band change first
981
+ // did, left the one marker whose size the change was about free to drift.
982
+ ['[data-terp="page-title"]', "sm"],
983
+ // The top step keeps two readers, which is what stops the token going unread;
984
+ // tokens.guard.test.ts holds that end.
985
+ ['[data-terp="heading"][data-size="xl"]', "xl"],
986
+ ['[data-terp="card-title"]', "lg"],
987
+ ['[data-terp="card-description"]', "sm"],
988
+ ] as const) {
989
+ const at = base.indexOf(`${selector} {`);
990
+ expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
991
+ expect(
992
+ base.slice(at, base.indexOf("}", at)),
993
+ `${selector} should render the ${step} step of the published scale`,
994
+ ).toContain(`font-size: var(--font-size-${step})`);
995
+ }
996
+ });
997
+
657
998
  it("declares a wide-half gap rule for every step SpaceToken allows", () => {
658
999
  // The narrow half is covered by the roll-call below; this is its counterpart. A responsive
659
1000
  // gap whose wide step has no rule silently renders the narrow gap at every width — which
@@ -852,7 +1193,12 @@ describe("cascade structure", () => {
852
1193
  // silently fall back to the default gap rather than fail.
853
1194
  const base = layerBody("terp.base");
854
1195
  for (const token of [0, 1, 2, 3, 4, 6, 8]) {
855
- for (const marker of ["stack", "card", "grid"]) {
1196
+ // `detail-list` joined the three late, and its absence is why the value it now takes was
1197
+ // unreachable: every other layout primitive published a gap on this scale, so a detail
1198
+ // list wanting looser rows had nowhere to say so — and app modules may write neither
1199
+ // `style` nor `className`. Its rules set `row-gap` rather than the shorthand, for the
1200
+ // reason the sheet gives there: the column gap is the layout's, not the caller's.
1201
+ for (const marker of ["stack", "card", "grid", "detail-list"]) {
856
1202
  expect(
857
1203
  declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
858
1204
  `${marker} has no rule for gap ${token}`,
@@ -1288,8 +1634,9 @@ describe("cascade structure", () => {
1288
1634
  "appshell-footer",
1289
1635
  "page",
1290
1636
  "page-header",
1291
- "page-breadcrumbs",
1292
1637
  "page-heading",
1638
+ "page-badges",
1639
+ "page-description",
1293
1640
  "page-title",
1294
1641
  "resource-list",
1295
1642
  "resource-list-create",