@terpjs/react-core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sso.test.tsx CHANGED
@@ -120,9 +120,12 @@ describe("SSO login (ADR 0058)", () => {
120
120
  </TerpProvider>,
121
121
  );
122
122
 
123
- await waitFor(() =>
124
- expect(screen.getByText("Single sign-on failed. Try again.")).toBeInTheDocument(),
125
- );
123
+ // Announced, not merely displayed. This is the one screen whose user is not signed in
124
+ // yet, so a failure they cannot see leaves them with no signal at all that the sign-in
125
+ // did not happen — the form simply sits there. ResourceList's error has carried
126
+ // role="alert" since it existed; this one carried nothing.
127
+ const failure = await screen.findByRole("alert");
128
+ expect(failure).toHaveTextContent("Single sign-on failed. Try again.");
126
129
  expect(window.location.pathname).toBe("/");
127
130
  });
128
131
  });
@@ -534,11 +534,13 @@ describe("cascade structure", () => {
534
534
  // assertions because both had a real consumer until it migrated.
535
535
  //
536
536
  // The focus ring's !important could only ever beat an INLINE box-shadow on an element the
537
- // selector matches, and there is none left: ConfirmDialog's dialog and Popover's panel were
538
- // the two, and both took their surface from the sheet in stage 4. Of the three inline
539
- // box-shadows remaining, AppShell's drawer and LoginView's panel sit on elements with no
540
- // marker at all, and DataViewCardList's card is a div with onClick and no tabIndex, so it
541
- // cannot match :focus-visible which is why the sheet reaches it with :focus-within.
537
+ // selector matches, and there is now none anywhere in the package. ConfirmDialog's dialog
538
+ // and Popover's panel were the two that mattered and took their surface from the sheet in
539
+ // stage 4; the three that remained — AppShell's drawer, DataViewCardList's card and
540
+ // LoginView's card went with those three components' own migrations, the last of them
541
+ // with LoginView. The DataView card is worth remembering anyway: it is a div with onClick
542
+ // and no tabIndex, so it cannot match :focus-visible at all, which is why the sheet reaches
543
+ // it with :focus-within.
542
544
  // Measured after removing it: a keyboard-focused primary button and a focused menu item both
543
545
  // still compute rgba(37,99,235,0.35) 0 0 0 3px, because the LAYER carries the ring, not the
544
546
  // escalation. That was always the claim in this file's header; the escalation was belt.
@@ -837,6 +839,36 @@ describe("cascade structure", () => {
837
839
  "appshell-header-group",
838
840
  "appshell-main",
839
841
  "appshell-footer",
842
+ "page",
843
+ "page-header",
844
+ "page-breadcrumbs",
845
+ "page-heading",
846
+ "page-title",
847
+ "resource-list",
848
+ "resource-list-create",
849
+ "resource-list-error",
850
+ "resource-list-empty",
851
+ "resource-list-items",
852
+ "resource-list-row",
853
+ "module-nav",
854
+ "module-nav-list",
855
+ "module-nav-link",
856
+ "profile-card",
857
+ "profile-avatar",
858
+ "profile-email",
859
+ "profile-role",
860
+ "login-view",
861
+ "login-card",
862
+ "login-brand",
863
+ "login-title",
864
+ "login-form",
865
+ "login-sso",
866
+ "login-separator",
867
+ "login-separator-rule",
868
+ "login-error",
869
+ "admin-form",
870
+ "admin-section-title",
871
+ "admin-payload",
840
872
  ]) {
841
873
  expect(
842
874
  declaresRuleFor(base, `[data-terp="${marker}"]`),
package/src/styles.ts CHANGED
@@ -13,12 +13,18 @@
13
13
  * anything. A new escalation is therefore a claim that some element still styles
14
14
  * itself inline on the same property — state it, with the file, or do not add it.
15
15
  *
16
- * The migration itself is not finished: five modules still declare module-scope
17
- * base style objects (23 between them, gated in markers.test.ts). They are just no
18
- * longer in anyone's way of what they render, only `module-nav` and
19
- * `resource-list` carry a marker, and no rule in `terp.state` targets either, so
20
- * retiring the last escalations left nothing inert. Checked by scanning the layer
21
- * against those files rather than by spot-checking a hover.
16
+ * THE LEDGER IS EMPTY. No module declares a module-scope base style object any more,
17
+ * and the unmarked-surface worklist is empty with it — both gated in markers.test.ts,
18
+ * both kept rather than deleted, for the reason the escalation ledger below is kept:
19
+ * an empty gate is where the next entry has to justify itself.
20
+ *
21
+ * And the measure is now the whole surface rather than the annotated part of it. The
22
+ * ledger counts module-scope `CSSProperties` declarations, which a call-site literal and
23
+ * an unannotated style object both slip past — that is how the built-in admin views kept
24
+ * five base styles through the entire migration with both ratchets reading clean. A third
25
+ * gate counts inline style SITES per file, so the only way out of it is to render none;
26
+ * the nine that remain are ADR 0094 §3's permanent inline side and nothing else, named
27
+ * one by one in markers.test.ts.
22
28
  *
23
29
  * A migrated component gets its base here and renders no `style={}` for it —
24
30
  * though it may still pass an inline value the sheet has no business owning, which
@@ -519,6 +525,35 @@ textarea[data-terp="input"] {
519
525
  color: var(--color-neutral-900);
520
526
  }
521
527
 
528
+ /* Module navigation -------------------------------------------------------- */
529
+ /* Secondary tabs for a module's sub-pages, and very nearly the same object as tab
530
+ above: a transparent 2px edge the active item colours, muted ink the active item
531
+ darkens. The difference is that these are router links rather than buttons, and
532
+ that difference decides where the active state is keyed — see terp.state.
533
+
534
+ The edges are logical (border-block-end) to match tab and appshell-footer rather
535
+ than the physical borderBottom the component declared. Identical in every writing
536
+ mode this framework ships, and the sheet already had one convention. */
537
+ [data-terp="module-nav"] {
538
+ border-block-end: 1px solid var(--color-neutral-200);
539
+ }
540
+ [data-terp="module-nav-list"] {
541
+ list-style: none;
542
+ margin: 0;
543
+ padding: 0;
544
+ display: flex;
545
+ flex-wrap: wrap;
546
+ gap: var(--space-3);
547
+ }
548
+ [data-terp="module-nav-link"] {
549
+ display: inline-flex;
550
+ align-items: center;
551
+ padding: var(--space-2) 0;
552
+ color: var(--color-neutral-600);
553
+ text-decoration: none;
554
+ border-block-end: 2px solid transparent;
555
+ }
556
+
522
557
  /* Breadcrumbs -------------------------------------------------------------- */
523
558
  /* The trail owns its whole subtree, so the list and its items are addressed
524
559
  structurally and the current crumb by the aria-current it already carries —
@@ -806,6 +841,240 @@ textarea[data-terp="input"] {
806
841
  font-size: var(--font-size-xs);
807
842
  }
808
843
 
844
+ /* The page frame ----------------------------------------------------------- */
845
+ /* Every routed view is this shape: one header carrying the breadcrumb trail (when
846
+ there is a path back up) and the title row, then the body. HubPage, OverviewPage
847
+ and DetailPage are all this frame with a different trail.
848
+
849
+ The header is a <header> ELEMENT and Page has to keep it one, which is a
850
+ constraint this sheet cannot express and the component records at the site: the
851
+ layout contract's runtime slot check reads article.children and drops the header
852
+ by TAG NAME, so re-rendering it as a marked <div> would put it back into the body
853
+ set and fail every governed OverviewPage and DetailPage closed. Marking it is
854
+ additive; retagging it is not. The body likewise takes no wrapper — not even a
855
+ display: contents one, since that check is a DOM traversal and would see the node
856
+ whether or not it generates a box.
857
+
858
+ align-content: start is what keeps the rows at the top of a page taller than its
859
+ content — the loading and error frames, where the body is one small block. With
860
+ the default the two rows would spread to fill the height. It needs something to
861
+ stretch the article before it is observable at all, which is why page-loading and
862
+ page-error render inside a grid box rather than a plain tall div. */
863
+ [data-terp="page"] {
864
+ display: grid;
865
+ grid-template-columns: minmax(0, 1fr);
866
+ gap: var(--space-4);
867
+ align-content: start;
868
+ min-width: 0;
869
+ }
870
+ [data-terp="page-header"] {
871
+ display: grid;
872
+ gap: var(--space-2);
873
+ }
874
+ /* The crumb row keeps a 2rem floor, and it is doing work rather than reserving
875
+ space for its own sake: the trail is shorter than 2rem at font-size-sm, so
876
+ dropping the floor closes the gap under the trail on every page that has one.
877
+ Measured — removing it moves all six baselines with a trail and nothing else. */
878
+ [data-terp="page-breadcrumbs"] {
879
+ display: flex;
880
+ align-items: center;
881
+ min-height: 2rem;
882
+ }
883
+ /* Title left, the actions slot right, wrapping rather than overflowing when a long
884
+ title meets a wide action cluster. */
885
+ [data-terp="page-heading"] {
886
+ display: flex;
887
+ align-items: center;
888
+ justify-content: space-between;
889
+ gap: var(--space-3);
890
+ flex-wrap: wrap;
891
+ }
892
+ /* The single h1 of the view. margin: 0 is load-bearing — the browser default h1
893
+ margin would otherwise fight the header's own gap. */
894
+ [data-terp="page-title"] {
895
+ margin: 0;
896
+ font-size: var(--font-size-lg);
897
+ font-weight: var(--font-weight-semibold);
898
+ letter-spacing: 0;
899
+ color: var(--color-neutral-900);
900
+ line-height: 1.3;
901
+ }
902
+
903
+ /* The sign-in screen ------------------------------------------------------- */
904
+ /* The one screen an unauthenticated user sees, and the only full-viewport page in
905
+ the package: a 100vh grid centring one card. The reset layer's box-sizing note
906
+ already names this page as the reason it exists — content-box plus 100vh plus
907
+ padding overflows the viewport by exactly the padding, which is a phantom
908
+ scrollbar on a page that fits.
909
+
910
+ The buttons fill their group, and that is a rule on the GROUP rather than a prop
911
+ on Button, because it has exactly one consumer in the package. Button declares
912
+ width: fit-content, which is a definite width — so grid stretch does NOT do this
913
+ for free, and dropping the declaration shrinks all four buttons to their labels.
914
+ Two selectors at (0,2,0) against the button's own (0,1,0), same layer, so
915
+ specificity settles it and source order never enters into it. A block prop on
916
+ Button would be a new public API minted for one internal caller; the sheet can
917
+ already reach the thing, which is the test stage 4 set when it deleted Menu's
918
+ style props.
919
+
920
+ The separator's ink moves from --color-neutral-500 to --color-fg-subtle, which is
921
+ the rest of the migration ec36a2b started rather than a new decision: the two
922
+ tokens are byte-identical in light and dark, and only fg-subtle has a declared
923
+ pairing (subtle-on-surface) for the gate to measure. So the screenshot themes do
924
+ not move and midnight and twilight get the value the gate has been measuring all
925
+ along. The "or" is aria-hidden but it is visible text, so it is held to AA rather
926
+ than treated as an ornament.
927
+
928
+ The error line has no specimen and cannot have one: the error is internal state set
929
+ only in a catch, and sso.error needs a real failed callback, which needs a URL the
930
+ lane owns. Its ink is gated statically instead — danger-on-card, declared for this
931
+ surface and measured in all five themes. */
932
+ [data-terp="login-view"] {
933
+ min-height: 100vh;
934
+ display: grid;
935
+ place-items: center;
936
+ padding: var(--space-6);
937
+ background: var(--color-neutral-50);
938
+ font-family: var(--font-family-sans);
939
+ color: var(--color-neutral-900);
940
+ }
941
+ [data-terp="login-card"] {
942
+ width: 100%;
943
+ max-width: 24rem;
944
+ display: grid;
945
+ gap: var(--space-4);
946
+ padding: var(--space-6);
947
+ background: var(--color-neutral-0);
948
+ border: 1px solid var(--color-neutral-200);
949
+ border-radius: var(--radius-lg);
950
+ box-shadow: var(--shadow-md);
951
+ }
952
+ [data-terp="login-brand"] {
953
+ display: flex;
954
+ align-items: center;
955
+ gap: var(--space-2);
956
+ color: var(--color-neutral-900);
957
+ }
958
+ [data-terp="login-title"] {
959
+ margin: 0;
960
+ font-size: var(--font-size-xl);
961
+ font-weight: var(--font-weight-bold);
962
+ letter-spacing: 0;
963
+ }
964
+ [data-terp="login-form"],
965
+ [data-terp="login-sso"] {
966
+ display: grid;
967
+ gap: var(--space-3);
968
+ }
969
+ [data-terp="login-form"] > [data-terp="button"],
970
+ [data-terp="login-sso"] > [data-terp="button"] {
971
+ width: 100%;
972
+ }
973
+ [data-terp="login-separator"] {
974
+ display: flex;
975
+ align-items: center;
976
+ gap: var(--space-2);
977
+ color: var(--color-fg-subtle);
978
+ font-size: var(--font-size-xs);
979
+ text-transform: uppercase;
980
+ letter-spacing: 0.06em;
981
+ }
982
+ [data-terp="login-separator-rule"] {
983
+ flex: 1;
984
+ border-block-start: 1px solid var(--color-neutral-200);
985
+ }
986
+ [data-terp="login-error"] {
987
+ margin: 0;
988
+ color: var(--color-status-danger);
989
+ font-size: var(--font-size-sm);
990
+ }
991
+
992
+ /* The profile screen ------------------------------------------------------- */
993
+ /* The built-in /profile view: two cards, an avatar tile, and the identity lines.
994
+ Its cards are its own rather than the Card component's — the same declarations on
995
+ a different element — and folding the two together is a component decision, not a
996
+ styling one, so it stays a follow-up rather than riding in on a migration whose
997
+ whole contract is zero pixel movement.
998
+
999
+ overflow-wrap on the address is real and unobservable: the workbench session is a
1000
+ fixed user whose address is short, so no specimen can paint the case it exists for
1001
+ (an address has no spaces to break at, so a long one widens the card past its own
1002
+ max-width instead of wrapping). Asserted in the unit test as the marker it keys on;
1003
+ there is no picture of it and cannot be until the mock session is variable. */
1004
+ [data-terp="profile-card"] {
1005
+ display: grid;
1006
+ gap: var(--space-4);
1007
+ padding: var(--space-4);
1008
+ max-width: 32rem;
1009
+ background: var(--color-neutral-0);
1010
+ border: 1px solid var(--color-neutral-200);
1011
+ border-radius: var(--radius-lg);
1012
+ }
1013
+ /* The initials tile. It is aria-hidden, so axe skips it by design and the declared
1014
+ pairing is the only thing measuring its ink: brand-primary-contrast on
1015
+ brand-primary is primary-button-label, which the contrast gate holds at AA in all
1016
+ five themes. Exactly the shape of NavIcon's fallback tile, which failed at 1.60
1017
+ for as long as nothing declared it. */
1018
+ [data-terp="profile-avatar"] {
1019
+ display: inline-flex;
1020
+ align-items: center;
1021
+ justify-content: center;
1022
+ width: 3.5rem;
1023
+ height: 3.5rem;
1024
+ flex-shrink: 0;
1025
+ border-radius: var(--radius-full);
1026
+ background: var(--color-brand-primary);
1027
+ color: var(--color-brand-primary-contrast);
1028
+ font-size: var(--font-size-lg);
1029
+ font-weight: var(--font-weight-medium);
1030
+ }
1031
+ [data-terp="profile-email"] {
1032
+ overflow-wrap: anywhere;
1033
+ }
1034
+ [data-terp="profile-role"] {
1035
+ margin: 0;
1036
+ color: var(--color-neutral-600);
1037
+ }
1038
+
1039
+ /* The built-in admin screens ----------------------------------------------- */
1040
+ /* The packaged /admin views. Three surfaces, and they were invisible to both
1041
+ ratchets for the entire migration — which is why they are here rather than in
1042
+ 0.8.0. The worklist names files with NO marker at all, and every admin view
1043
+ rendered none, so it read as a view composition and was excluded on purpose. The
1044
+ ledger counts module-scope CSSProperties declarations, and these were four
1045
+ call-site literals plus one unannotated object. Neither gate was wrong; both were
1046
+ narrower than they looked, and the same commit widens the measure.
1047
+
1048
+ The form box is ONE marker across two files, because UserCreate and GroupCreate
1049
+ constrain their form to the same measure — the same surface twice, not two
1050
+ surfaces that happen to agree today. */
1051
+ [data-terp="admin-form"] {
1052
+ max-width: 32rem;
1053
+ }
1054
+ /* A section heading inside a detail screen: the members list, the permission
1055
+ grants. font-size-base rather than the UA default, which for an h2 is LARGER than
1056
+ the page's own h1 at font-size-lg — so without this a section outranks the view
1057
+ it sits in. */
1058
+ [data-terp="admin-section-title"] {
1059
+ margin: 0;
1060
+ font-size: var(--font-size-base);
1061
+ }
1062
+ /* The audit event's JSON payload. No font-family: it is a <pre>, so the UA
1063
+ stylesheet's monospace already applies and the inline object set none either.
1064
+
1065
+ font-size-sm loses the inline fallback the object carried (0.875rem) and no other
1066
+ rule in this sheet has one. The fallback could never fire — tokens.guard.test.ts
1067
+ refuses any var() in react-core naming a property the contract does not publish,
1068
+ so the token is always there. It recorded an author's doubt, not an option. */
1069
+ [data-terp="admin-payload"] {
1070
+ margin: 0;
1071
+ padding: var(--space-3);
1072
+ background: var(--color-neutral-100);
1073
+ border-radius: var(--radius-md);
1074
+ font-size: var(--font-size-sm);
1075
+ overflow-x: auto;
1076
+ }
1077
+
809
1078
  /* Hub cards --------------------------------------------------------------- */
810
1079
  /* This whole family was in terp.state, resting declarations and all, for the same
811
1080
  reason the icon button's transition was: that is where the hover rules needing it
@@ -1613,6 +1882,58 @@ th[data-terp="dataview-actions-cell"] > span {
1613
1882
  color: var(--color-neutral-700);
1614
1883
  }
1615
1884
 
1885
+ /* Resource list ------------------------------------------------------------ */
1886
+ /* The plain listing screen: a write-gated create row, then the rows. The DataView is
1887
+ the same job at scale, and this one stays deliberately simple, so its rules are
1888
+ geometry plus two inks.
1889
+
1890
+ The create row keeps its flex layout and the input keeps flex: 1 as a RULE rather
1891
+ than becoming a 1fr auto grid, and that is a move rather than a decision. With
1892
+ flex: 1 the input's basis is 0 so it may shrink below its intrinsic width; a 1fr
1893
+ track floors at min-content instead. The two agree at this list's 40rem cap, so
1894
+ swapping them would have changed nothing anybody could see until some narrower
1895
+ container found the difference — which is the kind of diff this migration exists
1896
+ not to introduce.
1897
+
1898
+ Neither paragraph resets its margin, and that is verbatim rather than an
1899
+ oversight: both are <p> elements whose default margin is what separates them from
1900
+ the form above and the rows below. Adding margin: 0 here would move the rows. */
1901
+ [data-terp="resource-list"] {
1902
+ display: grid;
1903
+ gap: var(--space-4);
1904
+ max-width: 40rem;
1905
+ }
1906
+ [data-terp="resource-list-create"] {
1907
+ display: flex;
1908
+ gap: var(--space-2);
1909
+ }
1910
+ [data-terp="resource-list-create"] > [data-terp="input"] {
1911
+ flex: 1;
1912
+ }
1913
+ [data-terp="resource-list-error"] {
1914
+ color: var(--color-status-danger);
1915
+ }
1916
+ [data-terp="resource-list-empty"] {
1917
+ color: var(--color-neutral-600);
1918
+ }
1919
+ [data-terp="resource-list-items"] {
1920
+ list-style: none;
1921
+ margin: 0;
1922
+ padding: 0;
1923
+ display: grid;
1924
+ gap: var(--space-2);
1925
+ }
1926
+ [data-terp="resource-list-row"] {
1927
+ display: flex;
1928
+ align-items: center;
1929
+ justify-content: space-between;
1930
+ gap: var(--space-3);
1931
+ padding: var(--space-3);
1932
+ border: 1px solid var(--color-neutral-200);
1933
+ border-radius: var(--radius-md);
1934
+ background: var(--color-neutral-0);
1935
+ }
1936
+
1616
1937
  /* Empty / error / loading states ------------------------------------------- */
1617
1938
  /* Same centred block, opposite messages: empty is a dashed outline on the page
1618
1939
  surface because nothing is wrong, error is a filled danger wash because
@@ -2578,6 +2899,35 @@ button[data-terp="input"][data-placeholder="true"] {
2578
2899
  cursor: not-allowed;
2579
2900
  }
2580
2901
 
2902
+ /* Module navigation ------------------------------------------------------- */
2903
+ /* Which sub-page you are on, here for the same reason the selected tab is: the
2904
+ accent edge and the darker ink have to beat the resting pair.
2905
+
2906
+ Keyed on data-active, which ModuleNav writes, and deliberately NOT on the
2907
+ aria-current the same element also carries. That attribute has a second author:
2908
+ TanStack's link props spread the router's own active props LAST, after the
2909
+ caller's, so on a Link the router has the final word on aria-current. This is
2910
+ the breadcrumb lesson in its exact form — reuse a semantic only where the
2911
+ component is its sole author.
2912
+
2913
+ The two notions of "active" are not the same predicate, and they diverge in BOTH
2914
+ directions, which is worth knowing before touching either. activeOptions
2915
+ .includeSearch defaults to true, so the router additionally demands an exact
2916
+ query-string match that ModuleNav does not — the router is narrower there. And
2917
+ with exact matching the router compares through exactPathTest, which is
2918
+ removeTrailingSlash(a) === removeTrailingSlash(b), while ModuleNav compares
2919
+ pathname === item.to raw — so on a path with a trailing slash the ROUTER is
2920
+ active and ModuleNav is not, and this rule withholds the accent edge from a tab
2921
+ the router considers current. That second case is a real defect and it is older
2922
+ than this rule: the inline styling it replaced read the same isActive, so the
2923
+ behaviour is unchanged and only the reasoning was wrong. Fixing the predicate
2924
+ belongs with the navigation model, because that is what decides what "active"
2925
+ should mean. */
2926
+ [data-terp="module-nav-link"][data-active="true"] {
2927
+ color: var(--color-neutral-900);
2928
+ border-block-end-color: var(--color-fg-accent);
2929
+ }
2930
+
2581
2931
  /* Hub cards --------------------------------------------------------------- */
2582
2932
  /* The hover edge recolours hubcard-BODY, not the card.
2583
2933
 
@@ -39,9 +39,11 @@ describe("Button", () => {
39
39
  });
40
40
 
41
41
  it("still forwards an explicit style, so framework callers keep their escape", () => {
42
- // The sheet owns the base; a one-off geometry override (LoginView's full-width submit)
43
- // is inline and therefore still wins, which is the boundary ADR 0094 draws between
44
- // styling policy and a measured value.
42
+ // The sheet owns the base, and the escape still has to work: a caller may pass a measured
43
+ // value the sheet has no business owning, and it wins because a style attribute outranks
44
+ // any author rule (ADR 0094 §3). It is no longer LoginView's full-width submit, which is
45
+ // what this comment used to cite — a fixed 100% is layout policy rather than a measured
46
+ // value, and the sheet could reach it, so it is a rule on the login form now.
45
47
  render(<Button style={{ width: "100%" }}>Wide</Button>);
46
48
  expect(screen.getByRole("button", { name: "Wide" }).style.width).toBe("100%");
47
49
  });