@terpjs/react-core 0.1.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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
package/src/sso.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { TerpClient, TerpClientFor } from "@terpjs/contract";
2
+
3
+ import { unwrap } from "./unwrap";
4
+
5
+ /**
6
+ * The SSO seam over the OIDC capability's SPA-shaped endpoints (ADR 0058):
7
+ *
8
+ * - `GET /api/v1/oidc/{provider}/authorize` opens a flow and returns the IdP
9
+ * authorize URL the client navigates to; and
10
+ * - `POST /api/v1/oidc/{provider}/callback` relays what the IdP appended to the
11
+ * redirect URI and mints a normal Terp session (`access_token` + the same
12
+ * httpOnly refresh cookie a password login sets).
13
+ *
14
+ * The endpoints are app-mounted (the OIDC capability is opt-in), so they are not
15
+ * part of the baked base-profile contract; the wire shapes below mirror the
16
+ * capability's DTOs, which the shipped capability keeps stable.
17
+ */
18
+
19
+ /** Response of `GET /oidc/{provider}/authorize` — the URL to navigate to. */
20
+ interface AuthorizationRequest {
21
+ provider: string;
22
+ authorization_url: string;
23
+ }
24
+
25
+ /** Response of `POST /oidc/{provider}/callback` — a normal Terp access token. */
26
+ interface SsoAccessToken {
27
+ access_token: string;
28
+ token_type: string;
29
+ }
30
+
31
+ /** The OIDC capability's SPA endpoints, typed for the shared contract client. */
32
+ interface OidcPaths {
33
+ "/api/v1/oidc/{provider}/authorize": {
34
+ get: {
35
+ parameters: { path: { provider: string }; query?: never; header?: never; cookie?: never };
36
+ requestBody?: never;
37
+ responses: {
38
+ 200: {
39
+ headers: { [name: string]: unknown };
40
+ content: { "application/json": AuthorizationRequest };
41
+ };
42
+ };
43
+ };
44
+ };
45
+ "/api/v1/oidc/{provider}/callback": {
46
+ post: {
47
+ parameters: { path: { provider: string }; query?: never; header?: never; cookie?: never };
48
+ requestBody: { content: { "application/json": { code: string; state: string } } };
49
+ responses: {
50
+ 200: {
51
+ headers: { [name: string]: unknown };
52
+ content: { "application/json": SsoAccessToken };
53
+ };
54
+ };
55
+ };
56
+ };
57
+ }
58
+
59
+ /** A configured SSO provider, as the login screen presents it. */
60
+ export interface SsoProvider {
61
+ /** Provider name as mounted on the backend (the `{provider}` path segment). */
62
+ name: string;
63
+ /** Display label for the provider button (defaults to the name). */
64
+ label?: string;
65
+ }
66
+
67
+ /** Default SPA path prefix the IdP redirects back to: `/auth/callback/{provider}`. */
68
+ export const DEFAULT_SSO_CALLBACK_PATH = "/auth/callback";
69
+
70
+ function oidcClient(client: TerpClient): TerpClientFor<OidcPaths> {
71
+ return client as unknown as TerpClientFor<OidcPaths>;
72
+ }
73
+
74
+ /** Open an SSO flow: fetch the IdP authorize URL for `provider`. */
75
+ export async function fetchSsoAuthorizationUrl(
76
+ client: TerpClient,
77
+ provider: string,
78
+ ): Promise<string> {
79
+ const data = unwrap(
80
+ await oidcClient(client).GET("/api/v1/oidc/{provider}/authorize", {
81
+ params: { path: { provider } },
82
+ }),
83
+ );
84
+ return data.authorization_url;
85
+ }
86
+
87
+ /** Finish an SSO flow: relay the IdP's `code`/`state` and receive a Terp access token. */
88
+ export async function completeSsoCallback(
89
+ client: TerpClient,
90
+ provider: string,
91
+ payload: { code: string; state: string },
92
+ ): Promise<string> {
93
+ const data = unwrap(
94
+ await oidcClient(client).POST("/api/v1/oidc/{provider}/callback", {
95
+ params: { path: { provider } },
96
+ body: { code: payload.code, state: payload.state },
97
+ }),
98
+ );
99
+ return data.access_token;
100
+ }
101
+
102
+ /** What the IdP appended to the redirect URI, parsed from the current location. */
103
+ export interface SsoCallbackParams {
104
+ provider: string;
105
+ code: string;
106
+ state: string;
107
+ }
108
+
109
+ /** True when `pathname` sits under the SSO callback prefix (an IdP redirect landed here). */
110
+ export function isSsoCallbackLocation(
111
+ location: { pathname: string },
112
+ callbackPath: string = DEFAULT_SSO_CALLBACK_PATH,
113
+ ): boolean {
114
+ const prefix = callbackPath.endsWith("/") ? callbackPath : `${callbackPath}/`;
115
+ return location.pathname.startsWith(prefix);
116
+ }
117
+
118
+ /**
119
+ * Parse an in-flight SSO redirect out of a location. Returns the provider + code/state
120
+ * when `pathname` is `{callbackPath}/{provider}` and both query params are present,
121
+ * else null (a normal boot, or an IdP error redirect carrying no code).
122
+ */
123
+ export function parseSsoCallback(
124
+ location: { pathname: string; search: string },
125
+ callbackPath: string = DEFAULT_SSO_CALLBACK_PATH,
126
+ ): SsoCallbackParams | null {
127
+ const prefix = callbackPath.endsWith("/") ? callbackPath : `${callbackPath}/`;
128
+ if (!location.pathname.startsWith(prefix)) {
129
+ return null;
130
+ }
131
+ const provider = decodeURIComponent(location.pathname.slice(prefix.length).replace(/\/+$/, ""));
132
+ if (provider.length === 0 || provider.includes("/")) {
133
+ return null;
134
+ }
135
+ const query = new URLSearchParams(location.search);
136
+ const code = query.get("code");
137
+ const state = query.get("state");
138
+ if (!code || !state) {
139
+ return null;
140
+ }
141
+ return { provider, code, state };
142
+ }
@@ -0,0 +1,45 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { AppShell } from "./AppShell";
5
+ import { DataView } from "./dataview/DataView";
6
+ import type { DataViewRepository } from "./dataview/types";
7
+
8
+ interface Row {
9
+ id: string;
10
+ name: string;
11
+ }
12
+
13
+ const repository: DataViewRepository<Row> = {
14
+ query: async () => ({ rows: [], totalCount: 0 }),
15
+ getRowId: (row) => row.id,
16
+ capabilities: { serverSide: false, search: false, searchScope: false },
17
+ };
18
+
19
+ describe("server rendering", () => {
20
+ it("renders responsive shell and DataView defaults without browser globals", () => {
21
+ expect(typeof window).toBe("undefined");
22
+
23
+ const shell = renderToString(
24
+ <AppShell
25
+ title="Terp"
26
+ nav={[{ label: "Home", to: "/", icon: "home" }]}
27
+ renderLink={(item, children, context) => (
28
+ <a href={item.to} style={context.style}>{children}</a>
29
+ )}
30
+ >
31
+ <p>content</p>
32
+ </AppShell>,
33
+ );
34
+ const view = renderToString(
35
+ <DataView
36
+ repository={repository}
37
+ columns={[{ id: "name", header: "Name", accessor: (row) => row.name }]}
38
+ />,
39
+ );
40
+
41
+ expect(shell).toContain("content");
42
+ expect(shell).toContain("Home");
43
+ expect(view).toContain('data-terp="dataview"');
44
+ });
45
+ });
@@ -0,0 +1,21 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import { TERP_STYLES_ID, injectTerpStyles } from "./styles";
5
+
6
+ afterEach(() => {
7
+ document.querySelectorAll(`style#${TERP_STYLES_ID}`).forEach((node) => node.remove());
8
+ });
9
+
10
+ describe("injectTerpStyles", () => {
11
+ it("appends the stylesheet once and is idempotent on re-invocation", () => {
12
+ injectTerpStyles();
13
+ injectTerpStyles();
14
+ injectTerpStyles();
15
+ const nodes = document.querySelectorAll(`style#${TERP_STYLES_ID}`);
16
+ expect(nodes.length).toBe(1);
17
+ expect(nodes[0]?.textContent ?? "").toContain("data-terp");
18
+ expect(nodes[0]?.textContent ?? "").toContain('[data-terp="input"][type="number"]');
19
+ expect(nodes[0]?.textContent ?? "").toContain("::-webkit-inner-spin-button");
20
+ });
21
+ });
package/src/styles.ts ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * The one interaction-state stylesheet for react-core.
3
+ *
4
+ * Components keep their inline token styles as the visible base (so tests can
5
+ * still assert `element.style.background` etc.); this sheet layers hover,
6
+ * active, focus-visible and small animation polish on top, keyed by
7
+ * `data-terp` / `data-variant` attributes the components already set. That
8
+ * keeps the boundary lint's inline-style rule intact (react-core itself is
9
+ * allowed to inject a stylesheet — the rule targets *app modules*), while
10
+ * giving every button, link, tab, row and menu real interaction states.
11
+ *
12
+ * Because the base styles are inline (`style={}` wins the cascade over author
13
+ * stylesheets, even for `:hover` / `:focus` / `:disabled` rules), every
14
+ * interaction-state declaration that overrides an inline base property is
15
+ * marked `!important`. The rules are state-scoped (hover / focus / disabled
16
+ * only), so the escalation cannot leak into resting styles.
17
+ *
18
+ * The injector is idempotent, SSR-safe (guarded on `document`), and appends
19
+ * the rules through `textContent` — never `innerHTML` — so no HTML sink is
20
+ * touched.
21
+ */
22
+
23
+ /** The `<style>` element id used to detect a prior injection. */
24
+ export const TERP_STYLES_ID = "terp-core-styles";
25
+
26
+ /** The interaction-state rules layered over the component's inline base styles. */
27
+ export const TERP_STYLES_CSS = `
28
+ /* Document reset: the app shell owns the full canvas. Without this the
29
+ browser's default 8px body margin leaves the document's own (white)
30
+ canvas visible as a ring around the shell — most obvious in Studio's
31
+ preview iframe and in dark mode. The body carries the same canvas token
32
+ as the shell so overscroll never flashes white. */
33
+ html, body {
34
+ margin: 0;
35
+ }
36
+ body {
37
+ background: var(--color-neutral-50);
38
+ }
39
+ /* Border-box baseline: react-core sizes components as padding-inclusive
40
+ (e.g. LoginView's 100vh page with padding, inputs at width:100% with
41
+ padding) and app modules cannot ship a stylesheet of their own, so the
42
+ framework owns this. Under the browser default (content-box) a
43
+ min-height:100vh + padding screen overflows the viewport by exactly its
44
+ padding — phantom scrollbars on pages that fit. */
45
+ *,
46
+ *::before,
47
+ *::after {
48
+ box-sizing: border-box;
49
+ }
50
+
51
+ /* Shared focus-visible ring: every interactive element that opts in via
52
+ [data-terp] shows a soft outline ring. !important lets the ring beat
53
+ inline base box-shadows (e.g. the primary button's resting shadow) so
54
+ keyboard focus is always visible. */
55
+ [data-terp]:focus-visible {
56
+ outline: 2px solid transparent;
57
+ outline-offset: 1px;
58
+ box-shadow: 0 0 0 3px var(--color-focus-ring) !important;
59
+ }
60
+
61
+ /* Buttons ------------------------------------------------------------------ */
62
+ [data-terp="button"] {
63
+ transition: background-color 150ms ease, color 150ms ease,
64
+ border-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
65
+ }
66
+ [data-terp="button"][data-variant="primary"]:hover:not(:disabled) {
67
+ background: var(--color-brand-primary-hover) !important;
68
+ }
69
+ [data-terp="button"][data-variant="secondary"]:hover:not(:disabled) {
70
+ background: var(--color-neutral-100) !important;
71
+ border-color: var(--color-neutral-300) !important;
72
+ }
73
+ [data-terp="button"][data-variant="ghost"]:hover:not(:disabled) {
74
+ background: var(--color-neutral-100) !important;
75
+ color: var(--color-neutral-900) !important;
76
+ }
77
+ [data-terp="button"][data-variant="danger"]:hover:not(:disabled) {
78
+ filter: brightness(0.94);
79
+ }
80
+ [data-terp="button"]:active:not(:disabled) {
81
+ transform: translateY(1px);
82
+ }
83
+ [data-terp="button"]:disabled {
84
+ opacity: 0.55;
85
+ cursor: not-allowed !important;
86
+ }
87
+
88
+ /* Icon-only buttons (header toggle, dismissers, pagination). */
89
+ [data-terp="iconbutton"] {
90
+ transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
91
+ }
92
+ [data-terp="iconbutton"]:hover:not(:disabled) {
93
+ background: var(--color-neutral-100) !important;
94
+ color: var(--color-neutral-900) !important;
95
+ }
96
+ [data-terp="iconbutton"]:disabled {
97
+ opacity: 0.4;
98
+ cursor: not-allowed !important;
99
+ }
100
+
101
+ /* Inputs / selects / textareas -------------------------------------------- */
102
+ [data-terp="input"] {
103
+ transition: border-color 150ms ease, box-shadow 150ms ease;
104
+ }
105
+ [data-terp="input"]:hover:not(:disabled):not(:focus) {
106
+ border-color: var(--color-neutral-400, var(--color-neutral-500)) !important;
107
+ }
108
+ [data-terp="input"]:focus,
109
+ [data-terp="input"]:focus-visible {
110
+ outline: none;
111
+ border-color: var(--color-brand-primary) !important;
112
+ box-shadow: 0 0 0 3px var(--color-focus-ring) !important;
113
+ }
114
+ [data-terp="input"]::placeholder {
115
+ color: var(--color-neutral-500);
116
+ opacity: 1;
117
+ }
118
+ [data-terp="input"] option {
119
+ color: var(--color-neutral-900);
120
+ background: var(--color-neutral-0);
121
+ }
122
+ [data-terp="input"]:disabled {
123
+ /* background-color (not the background shorthand) so the Select's chevron,
124
+ drawn as a background-image, survives the disabled state. */
125
+ background-color: var(--color-neutral-50) !important;
126
+ color: var(--color-neutral-500) !important;
127
+ cursor: not-allowed !important;
128
+ }
129
+ [data-terp="input"][aria-invalid="true"] {
130
+ border-color: var(--color-status-danger) !important;
131
+ }
132
+ /* Number steppers are browser chrome and cannot be token-themed consistently.
133
+ Keep keyboard/wheel/manual numeric input while removing the mismatched arrows. */
134
+ [data-terp="input"][type="number"] {
135
+ appearance: textfield;
136
+ -moz-appearance: textfield;
137
+ }
138
+ [data-terp="input"][type="number"]::-webkit-inner-spin-button,
139
+ [data-terp="input"][type="number"]::-webkit-outer-spin-button {
140
+ appearance: none;
141
+ -webkit-appearance: none;
142
+ margin: 0;
143
+ }
144
+
145
+ /* Checkboxes / radios / switches ------------------------------------------- */
146
+ [data-terp="checkbox"]:disabled,
147
+ [data-terp="radio"]:disabled,
148
+ [data-terp="switch"]:disabled {
149
+ cursor: not-allowed !important;
150
+ }
151
+ label:has([data-terp="checkbox"]:disabled),
152
+ label:has([data-terp="radio"]:disabled),
153
+ label:has([data-terp="switch"]:disabled) {
154
+ cursor: not-allowed !important;
155
+ color: var(--color-neutral-500) !important;
156
+ }
157
+
158
+ /* Sidebar navigation links (from the shell or any app-provided <a>). The hover
159
+ rule skips the active route's link (aria-current="page") so the brand-soft
160
+ active highlight is not washed out on hover. */
161
+ [data-terp="appshell-nav"] a {
162
+ transition: background-color 150ms ease, color 150ms ease;
163
+ }
164
+ [data-terp="appshell-nav"] a:hover:not([aria-current="page"]) {
165
+ background: var(--color-neutral-100) !important;
166
+ color: var(--color-neutral-900) !important;
167
+ }
168
+ [data-terp="appshell-nav"][data-collapsed="true"] {
169
+ overflow-x: hidden;
170
+ scrollbar-width: none;
171
+ }
172
+ [data-terp="appshell-nav"][data-collapsed="true"]::-webkit-scrollbar {
173
+ width: 0;
174
+ height: 0;
175
+ }
176
+ [data-terp="appshell-brand"] {
177
+ transition: background-color 150ms ease;
178
+ }
179
+ [data-terp="appshell-brand"]:hover {
180
+ background: var(--color-neutral-100) !important;
181
+ }
182
+
183
+ /* Tabs -------------------------------------------------------------------- */
184
+ [data-terp="tab"] {
185
+ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
186
+ border-top-left-radius: var(--radius-sm);
187
+ border-top-right-radius: var(--radius-sm);
188
+ }
189
+ [data-terp="tab"]:hover:not(:disabled):not([aria-selected="true"]) {
190
+ color: var(--color-neutral-900) !important;
191
+ background: var(--color-neutral-100) !important;
192
+ }
193
+ [data-terp="tab"]:disabled {
194
+ opacity: 0.5;
195
+ cursor: not-allowed !important;
196
+ }
197
+
198
+ /* Hub cards --------------------------------------------------------------- */
199
+ [data-terp="hubcard-link"],
200
+ [data-terp="hubcard"] a {
201
+ text-decoration: none;
202
+ color: inherit;
203
+ display: block;
204
+ height: 100%;
205
+ min-height: 0;
206
+ }
207
+ [data-terp="hubcard"] {
208
+ transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
209
+ }
210
+ [data-terp="hubcard"]:hover {
211
+ border-color: var(--color-brand-primary) !important;
212
+ box-shadow: var(--shadow-sm);
213
+ transform: translateY(-1px);
214
+ }
215
+ [data-terp="hubcard"]:hover [data-terp="hubcard-title"] {
216
+ color: var(--color-brand-primary) !important;
217
+ }
218
+
219
+ /* Breadcrumb links -------------------------------------------------------- */
220
+ [data-terp="breadcrumbs"] a {
221
+ color: var(--color-neutral-600);
222
+ text-decoration: none;
223
+ transition: color 150ms ease;
224
+ }
225
+ [data-terp="breadcrumbs"] a:hover {
226
+ color: var(--color-neutral-900);
227
+ text-decoration: underline;
228
+ }
229
+
230
+ /* DataView table row hover ------------------------------------------------- */
231
+ [data-terp="dataview-table"] tbody tr {
232
+ transition: background-color 150ms ease;
233
+ }
234
+ [data-terp="dataview-table"] tbody tr:hover td {
235
+ background: var(--color-neutral-50);
236
+ }
237
+ [data-terp="dataview-row"]:focus-within td,
238
+ [data-terp="dataview-card"]:focus-within {
239
+ background: var(--color-brand-primary-soft) !important;
240
+ }
241
+
242
+ /* Menu items (UserMenu, DataView row-actions / column settings). */
243
+ [data-terp="menu-item"] {
244
+ transition: background-color 150ms ease, color 150ms ease;
245
+ border-radius: var(--radius-sm);
246
+ }
247
+ [data-terp="menu-item"]:hover:not(:disabled) {
248
+ background: var(--color-neutral-100) !important;
249
+ color: var(--color-neutral-900) !important;
250
+ }
251
+ [data-terp="menu-item"][data-selected="true"] {
252
+ background: var(--color-brand-primary-soft) !important;
253
+ color: var(--color-brand-primary) !important;
254
+ }
255
+
256
+ /* Dialogs: ::backdrop cannot be set inline, so the dim layer lives here and
257
+ matches the mobile drawer backdrop for one consistent overlay darkness. */
258
+ [data-terp="dialog"]::backdrop {
259
+ background: rgb(0 0 0 / 0.4);
260
+ }
261
+
262
+ /* Spinner keyframes for the LoadingState ring. */
263
+ @keyframes terp-spin {
264
+ from { transform: rotate(0deg); }
265
+ to { transform: rotate(360deg); }
266
+ }
267
+ [data-terp="spinner-ring"] {
268
+ animation: terp-spin 0.8s linear infinite;
269
+ }
270
+
271
+ /* Respect the user's reduced-motion preference — kill transitions and the
272
+ spinner animation everywhere the sheet applies them. */
273
+ @media (prefers-reduced-motion: reduce) {
274
+ [data-terp],
275
+ [data-terp="appshell-nav"] a,
276
+ [data-terp="dataview-table"] tbody tr {
277
+ transition: none !important;
278
+ }
279
+ [data-terp="spinner-ring"] { animation: none !important; }
280
+ }
281
+ `;
282
+
283
+ /**
284
+ * Inject the react-core interaction-state stylesheet once per document.
285
+ *
286
+ * SSR-safe: no-op when `document` is undefined. Idempotent: the sheet element
287
+ * is keyed by {@link TERP_STYLES_ID}, so repeated calls (from any component's
288
+ * module scope) attach the rules exactly once. Content is set via
289
+ * `textContent` — never `innerHTML` — so no HTML-injection sink is touched.
290
+ */
291
+ export function injectTerpStyles(): void {
292
+ if (typeof document === "undefined") {
293
+ return;
294
+ }
295
+ if (document.getElementById(TERP_STYLES_ID) !== null) {
296
+ return;
297
+ }
298
+ const el = document.createElement("style");
299
+ el.id = TERP_STYLES_ID;
300
+ el.textContent = TERP_STYLES_CSS;
301
+ document.head.appendChild(el);
302
+ }
@@ -0,0 +1,74 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { THEME_STORAGE_KEY, ThemeProvider, ThemeToggle } from "./theme";
6
+
7
+ afterEach(() => {
8
+ cleanup();
9
+ window.localStorage.clear();
10
+ document.documentElement.removeAttribute("data-theme");
11
+ });
12
+
13
+ describe("ThemeProvider + ThemeToggle", () => {
14
+ it("defaults to the system theme (no data-theme attribute)", () => {
15
+ render(
16
+ <ThemeProvider>
17
+ <ThemeToggle />
18
+ </ThemeProvider>,
19
+ );
20
+ expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
21
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
22
+ expect(screen.getByRole("menuitemradio", { name: "System" })).toHaveAttribute("aria-checked", "true");
23
+ });
24
+
25
+ it("applies an explicit choice to <html data-theme> and persists it", () => {
26
+ render(
27
+ <ThemeProvider>
28
+ <ThemeToggle />
29
+ </ThemeProvider>,
30
+ );
31
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
32
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Dark" }));
33
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
34
+ expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
35
+ });
36
+
37
+ it("restores a persisted choice over the app default", () => {
38
+ window.localStorage.setItem(THEME_STORAGE_KEY, "light");
39
+ render(
40
+ <ThemeProvider defaultTheme="dark">
41
+ <ThemeToggle />
42
+ </ThemeProvider>,
43
+ );
44
+ expect(document.documentElement.getAttribute("data-theme")).toBe("light");
45
+ });
46
+
47
+ it("switching back to system removes the attribute (OS preference wins)", () => {
48
+ render(
49
+ <ThemeProvider defaultTheme="dark">
50
+ <ThemeToggle />
51
+ </ThemeProvider>,
52
+ );
53
+ expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
54
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
55
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "System" }));
56
+ expect(document.documentElement.hasAttribute("data-theme")).toBe(false);
57
+ });
58
+
59
+ it("ThemeToggle renders nothing outside a ThemeProvider", () => {
60
+ render(<ThemeToggle />);
61
+ expect(screen.queryByLabelText("Theme")).not.toBeInTheDocument();
62
+ });
63
+
64
+ it("ignores a corrupt persisted value", () => {
65
+ window.localStorage.setItem(THEME_STORAGE_KEY, "neon");
66
+ render(
67
+ <ThemeProvider>
68
+ <ThemeToggle />
69
+ </ThemeProvider>,
70
+ );
71
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
72
+ expect(screen.getByRole("menuitemradio", { name: "System" })).toHaveAttribute("aria-checked", "true");
73
+ });
74
+ });