@terpjs/react-core 0.6.0 → 0.7.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/README.md +12 -2
- package/package.json +2 -2
- package/src/AppShell.tsx +1 -1
- package/src/HubPage.tsx +1 -1
- package/src/LoadingState.tsx +1 -1
- package/src/ModuleNav.tsx +1 -1
- package/src/icons.tsx +24 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +245 -0
- package/src/raw.d.ts +15 -1
- package/src/styles.ts +4 -4
- package/src/theme.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +38 -11
- package/src/tokens.guard.test.ts +3 -12
- package/src/ui/Badge.tsx +8 -1
- package/src/ui/Checkbox.tsx +1 -1
- package/src/ui/Combobox.tsx +1 -1
- package/src/ui/Radio.tsx +1 -1
- package/src/ui/Switch.tsx +1 -1
- package/src/ui/Tabs.tsx +2 -2
- package/src/ui/Tooltip.tsx +1 -1
- package/src/uiText.tsx +9 -0
package/README.md
CHANGED
|
@@ -20,7 +20,17 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
20
20
|
or `useRealtimeChannel()` for typed SSE/WebSocket subscriptions. The hook mints a
|
|
21
21
|
one-use connection ticket through the generated client; bearer tokens never enter URLs.
|
|
22
22
|
- **Design tokens, not inline colours** — style with the CSS variables from
|
|
23
|
-
`@terpjs/contract` (`var(--color-*)`, `var(--space-*)`, `var(--font-*)`).
|
|
23
|
+
`@terpjs/contract` (`var(--color-*)`, `var(--space-*)`, `var(--font-*)`). The full list,
|
|
24
|
+
with each token's per-theme values and the foreground/background pairings the contrast gate
|
|
25
|
+
enforces, is published as `@terpjs/contract/tokens.manifest.json` — read it rather than
|
|
26
|
+
inferring names from the compiled sheet.
|
|
27
|
+
- **The accent is two tokens, and mixing them up is a contrast bug.**
|
|
28
|
+
`--color-brand-primary` is the accent as a *filled surface*, and the only thing that may sit
|
|
29
|
+
on it is `--color-brand-primary-contrast`. `--color-fg-accent` is the accent as *ink or a
|
|
30
|
+
boundary* against one of the app's own surfaces — accent text, a selected-tab underline, a
|
|
31
|
+
focus ring, a checkbox's `accent-color`. One token cannot do both: in a dark theme the
|
|
32
|
+
surface use needs a value dark enough to hold a white label and the ink use needs one light
|
|
33
|
+
enough to read on a dark canvas, and there is no value satisfying both.
|
|
24
34
|
- **User-facing text is `UiText`** — every text prop accepts a plain string or an
|
|
25
35
|
`{id, message}` descriptor, so apps can localize via `UiTextProvider` without
|
|
26
36
|
react-core taking an i18n dependency.
|
|
@@ -43,7 +53,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
43
53
|
| `LoginView` | The standard sign-in screen: username/password, plus optional SSO provider buttons via `ssoProviders` and a dev-only credential-fill button via `devCredentials` (gate it on `import.meta.env.DEV`). |
|
|
44
54
|
| `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
|
|
45
55
|
| `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
|
|
46
|
-
| `ThemeProvider`, `ThemeToggle`, `useTheme` |
|
|
56
|
+
| `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the five shipped palettes — `light`, `dark`, `midnight`, `twilight`, `contrast` — plus `system` to follow the OS preference. Applies `data-theme` on `<html>` (the token stylesheet carries every palette) and persists the choice. `defaultTheme` is how an app ships on a named theme. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
|
|
47
57
|
| `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: per-locale string catalogs, a persisted active locale, and an icon-only, token-themed menu in the shell header once an app declares a second locale. English and Dutch catalogs ship complete; `renderTerpApp({ locales })` wires them. |
|
|
48
58
|
| `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
|
|
49
59
|
| `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@tanstack/react-router": "^1.170.16",
|
|
16
|
-
"@terpjs/contract": "^0.
|
|
16
|
+
"@terpjs/contract": "^0.7.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/AppShell.tsx
CHANGED
|
@@ -93,7 +93,7 @@ export const NAV_LINK_COLLAPSED_STYLE: CSSProperties = {
|
|
|
93
93
|
/** Merged over {@link NAV_LINK_STYLE} on the active route's link. */
|
|
94
94
|
export const NAV_LINK_ACTIVE_STYLE: CSSProperties = {
|
|
95
95
|
background: "var(--color-brand-primary-soft)",
|
|
96
|
-
color: "var(--color-
|
|
96
|
+
color: "var(--color-fg-accent)",
|
|
97
97
|
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
98
98
|
};
|
|
99
99
|
|
package/src/HubPage.tsx
CHANGED
|
@@ -124,7 +124,7 @@ const iconTileStyle: CSSProperties = {
|
|
|
124
124
|
flexShrink: 0,
|
|
125
125
|
borderRadius: "var(--radius-md)",
|
|
126
126
|
background: "var(--color-brand-primary-soft)",
|
|
127
|
-
color: "var(--color-
|
|
127
|
+
color: "var(--color-fg-accent)",
|
|
128
128
|
};
|
|
129
129
|
|
|
130
130
|
const titleTextStyle: CSSProperties = {
|
package/src/LoadingState.tsx
CHANGED
|
@@ -73,7 +73,7 @@ const wrapStyle: CSSProperties = {
|
|
|
73
73
|
fontSize: "var(--font-size-sm)",
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
const spinnerColorStyle: CSSProperties = { color: "var(--color-
|
|
76
|
+
const spinnerColorStyle: CSSProperties = { color: "var(--color-fg-accent)" };
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* Standard inline loading indicator: a spinner plus a short label, announced
|
package/src/ModuleNav.tsx
CHANGED
package/src/icons.tsx
CHANGED
|
@@ -77,6 +77,30 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
|
|
|
77
77
|
<path d="M8 21h8M12 17v4" />
|
|
78
78
|
</svg>
|
|
79
79
|
),
|
|
80
|
+
// The three glyphs below exist for the named themes. `moon` and `sun` were already
|
|
81
|
+
// taken by dark and light, and a theme picker where two entries share a glyph is a
|
|
82
|
+
// picker whose icons carry no information.
|
|
83
|
+
"moon-stars": (
|
|
84
|
+
<svg {...svgProps}>
|
|
85
|
+
<path d="M20.5 15.6A8 8 0 0 1 9.4 4.5 8.4 8.4 0 1 0 20.5 15.6Z" />
|
|
86
|
+
<path d="M17 3v3M15.5 4.5h3M20.5 8v2M19.5 9h2" />
|
|
87
|
+
</svg>
|
|
88
|
+
),
|
|
89
|
+
sunset: (
|
|
90
|
+
<svg {...svgProps}>
|
|
91
|
+
<path d="M12 4v2M5.6 7.6l1.4 1.4M17 9l1.4-1.4M3 15h1.5M19.5 15H21" />
|
|
92
|
+
<path d="M8 15a4 4 0 0 1 8 0" />
|
|
93
|
+
<path d="M3 19h18" />
|
|
94
|
+
</svg>
|
|
95
|
+
),
|
|
96
|
+
contrast: (
|
|
97
|
+
<svg {...svgProps}>
|
|
98
|
+
<circle cx="12" cy="12" r="9" />
|
|
99
|
+
{/* Filled half, so the glyph reads as contrast rather than as a plain circle.
|
|
100
|
+
Stroked shapes elsewhere use `currentColor`; so does this fill. */}
|
|
101
|
+
<path d="M12 3a9 9 0 0 0 0 18Z" fill="currentColor" stroke="none" />
|
|
102
|
+
</svg>
|
|
103
|
+
),
|
|
80
104
|
document: (
|
|
81
105
|
<svg {...svgProps}>
|
|
82
106
|
<path d="M6 3h8l4 4v14H6Z" />
|
package/src/locale.tsx
CHANGED
|
@@ -67,6 +67,9 @@ export const LOCALE_NL: LocaleCatalog = {
|
|
|
67
67
|
theme: "Thema",
|
|
68
68
|
themeLight: "Licht",
|
|
69
69
|
themeDark: "Donker",
|
|
70
|
+
themeMidnight: "Middernacht",
|
|
71
|
+
themeTwilight: "Schemering",
|
|
72
|
+
themeContrast: "Hoog contrast",
|
|
70
73
|
themeSystem: "Systeem",
|
|
71
74
|
language: "Taal",
|
|
72
75
|
admin: "Beheer",
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The `data-terp` marker inventory, pinned.
|
|
4
|
+
//
|
|
5
|
+
// A marker names a sanctioned component's rendered root. Two things already read them: the
|
|
6
|
+
// layout contract's runtime slot check (ADR 0079) verifies the identity of the components a
|
|
7
|
+
// body slot contains, and `TERP_STYLES_CSS` hangs every hover/active/disabled/selected
|
|
8
|
+
// declaration off them. So a marker is not decoration — it is the join between a component
|
|
9
|
+
// and both its enforcement and its styling.
|
|
10
|
+
//
|
|
11
|
+
// Nothing held that join in place. A renamed marker silently unstyles a component (the
|
|
12
|
+
// stylesheet rule stops matching, and no test asserts the rule matched anything) and
|
|
13
|
+
// silently widens a layout slot. Renaming is also exactly what a refactor does casually,
|
|
14
|
+
// because the string looks like a debug hook.
|
|
15
|
+
//
|
|
16
|
+
// Two sides are scanned separately and that separation is the whole point of this file:
|
|
17
|
+
// `styles.ts` *consumes* markers as CSS selectors, every component *produces* them as DOM
|
|
18
|
+
// attributes. Scanning them together is worse than not testing at all — the selectors in
|
|
19
|
+
// the sheet re-supply any name a component stopped rendering, so deleting a marker from a
|
|
20
|
+
// component leaves the inventory intact and the suite green while the app loses its styling.
|
|
21
|
+
//
|
|
22
|
+
// The ambient `ImportMeta.glob` type lives in raw.d.ts, shared with the other scanning tests.
|
|
23
|
+
|
|
24
|
+
const sources = import.meta.glob("./**/*.{ts,tsx}", {
|
|
25
|
+
query: "?raw",
|
|
26
|
+
import: "default",
|
|
27
|
+
eager: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The single module holding the framework stylesheet: markers appear there as selectors. */
|
|
31
|
+
const STYLESHEET = "./styles.ts";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every marker a component renders today.
|
|
35
|
+
*
|
|
36
|
+
* Adding a component adds a name here. Renaming one is a breaking change to both the
|
|
37
|
+
* stylesheet and the layout contract, so it belongs in a release note.
|
|
38
|
+
*/
|
|
39
|
+
const MARKERS = [
|
|
40
|
+
"alert",
|
|
41
|
+
"appshell-brand",
|
|
42
|
+
"appshell-nav",
|
|
43
|
+
"badge",
|
|
44
|
+
"breadcrumbs",
|
|
45
|
+
"button",
|
|
46
|
+
"card",
|
|
47
|
+
"card-actions",
|
|
48
|
+
"card-header",
|
|
49
|
+
"checkbox",
|
|
50
|
+
"dataview",
|
|
51
|
+
"dataview-card",
|
|
52
|
+
"dataview-row",
|
|
53
|
+
"dataview-row-open",
|
|
54
|
+
"dataview-table",
|
|
55
|
+
"dataview-toolbar",
|
|
56
|
+
"detail-list",
|
|
57
|
+
"dialog",
|
|
58
|
+
"drawer-focus-end",
|
|
59
|
+
"drawer-focus-start",
|
|
60
|
+
"empty-state",
|
|
61
|
+
"error-state",
|
|
62
|
+
"hubcard",
|
|
63
|
+
"hubcard-body",
|
|
64
|
+
"hubcard-description",
|
|
65
|
+
"hubcard-link",
|
|
66
|
+
"hubcard-stat",
|
|
67
|
+
"hubcard-title",
|
|
68
|
+
"iconbutton",
|
|
69
|
+
"input",
|
|
70
|
+
"loading-state",
|
|
71
|
+
"menu",
|
|
72
|
+
"menu-item",
|
|
73
|
+
"module-nav",
|
|
74
|
+
"nav-icon",
|
|
75
|
+
"popover",
|
|
76
|
+
"popover-panel",
|
|
77
|
+
"radio",
|
|
78
|
+
"resource-list",
|
|
79
|
+
"spinner-ring",
|
|
80
|
+
"stack",
|
|
81
|
+
"switch",
|
|
82
|
+
"tab",
|
|
83
|
+
"tabs",
|
|
84
|
+
"tooltip",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Styled surfaces that render no marker, and therefore cannot be reached by an attribute
|
|
89
|
+
* selector or verified by a slot check.
|
|
90
|
+
*
|
|
91
|
+
* A ratchet that shrinks only: this list is the worklist for moving component styling out
|
|
92
|
+
* of inline `style={}` and into the sheet, because a surface with no marker has nothing for
|
|
93
|
+
* a rule to match. Providers, hooks, module manifests and view compositions are deliberately
|
|
94
|
+
* absent — they render no styled root of their own.
|
|
95
|
+
*
|
|
96
|
+
* `Badge` and `Tooltip` have graduated: both own a single styled element, so marking them
|
|
97
|
+
* changed no DOM and no pixels. What is left does *not* reduce to adding an attribute, and
|
|
98
|
+
* the reason splits into two shapes worth knowing before the styling migration is planned:
|
|
99
|
+
*
|
|
100
|
+
* - **Delegates its root.** `theme.tsx` (ThemeToggle) and `locale.tsx` (LanguageSwitcher)
|
|
101
|
+
* return a bare `Menu` in their `inline` variant — the variant the app shell header
|
|
102
|
+
* actually uses — so their root already carries `data-terp="menu"` and is
|
|
103
|
+
* indistinguishable from any other menu. `UserMenu` is the same shape. Marking them
|
|
104
|
+
* means either threading a marker through `Menu` or introducing a wrapper element.
|
|
105
|
+
* - **Returns a fragment.** `Markdown` emits a sequence of block elements with no root at
|
|
106
|
+
* all. A marker requires a wrapper, and a wrapper is a new block box in every consumer's
|
|
107
|
+
* layout.
|
|
108
|
+
*
|
|
109
|
+
* Both are styling decisions with visible consequences, not bookkeeping, so they belong to
|
|
110
|
+
* the migration itself rather than to preparation for it. The archetypes, `Field`,
|
|
111
|
+
* `PageActions` and the DataView internals are still unexamined.
|
|
112
|
+
*/
|
|
113
|
+
const UNMARKED_STYLED_SURFACES = [
|
|
114
|
+
"./DetailPage.tsx",
|
|
115
|
+
"./Field.tsx",
|
|
116
|
+
"./LoginView.tsx",
|
|
117
|
+
"./OverviewPage.tsx",
|
|
118
|
+
"./PageActions.tsx",
|
|
119
|
+
"./ProfileView.tsx",
|
|
120
|
+
"./UserMenu.tsx",
|
|
121
|
+
"./dataview/DataViewColumnSettings.tsx",
|
|
122
|
+
"./dataview/DataViewExpandableRow.tsx",
|
|
123
|
+
"./dataview/DataViewRowActions.tsx",
|
|
124
|
+
"./files.tsx",
|
|
125
|
+
"./locale.tsx",
|
|
126
|
+
"./theme.tsx",
|
|
127
|
+
"./ui/Markdown.tsx",
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `text` with comments removed, so prose naming a marker cannot stand in for rendering one.
|
|
132
|
+
*
|
|
133
|
+
* Line comments are only stripped from a `//` that does not follow a colon, which keeps
|
|
134
|
+
* `https://` inside a string intact. Approximate by design: the result is fed to a marker
|
|
135
|
+
* regex, never compiled.
|
|
136
|
+
*/
|
|
137
|
+
function stripComments(text: string) {
|
|
138
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Marker values authored in `text`, in any of the forms the package actually uses:
|
|
143
|
+
* `data-terp="x"`, `data-terp={"x"}`, `data-terp={open ? "x" : "y"}`, and `"data-terp": "x"`.
|
|
144
|
+
*
|
|
145
|
+
* A regex anchored to `data-terp="…"` alone misses the conditional form — which is how the
|
|
146
|
+
* DataView row markers are written — so renaming one of those would not fail anything.
|
|
147
|
+
*/
|
|
148
|
+
function markersIn(text: string) {
|
|
149
|
+
const source = stripComments(text);
|
|
150
|
+
const found = new Set<string>();
|
|
151
|
+
const attribute = /data-terp["']?\s*[=:]\s*/g;
|
|
152
|
+
for (let match = attribute.exec(source); match; match = attribute.exec(source)) {
|
|
153
|
+
const rest = source.slice(match.index + match[0].length);
|
|
154
|
+
// An expression container may hold several literals (a ternary); a bare literal holds
|
|
155
|
+
// one. Read only as far as the value actually extends, so the next attribute's string
|
|
156
|
+
// is never absorbed.
|
|
157
|
+
const scope = rest.startsWith("{") ? expressionAt(rest) : rest.slice(0, firstLiteralEnd(rest));
|
|
158
|
+
for (const literal of scope.matchAll(/["']([a-z0-9-]+)["']/g)) {
|
|
159
|
+
found.add(literal[1]!);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The `{ … }` expression starting at `text[0]`, brace-matched. */
|
|
166
|
+
function expressionAt(text: string) {
|
|
167
|
+
let depth = 0;
|
|
168
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
169
|
+
if (text[index] === "{") depth += 1;
|
|
170
|
+
else if (text[index] === "}") {
|
|
171
|
+
depth -= 1;
|
|
172
|
+
if (depth === 0) return text.slice(0, index + 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return text;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The end of the single quoted literal starting at `text[0]`, or 0 when there is none. */
|
|
179
|
+
function firstLiteralEnd(text: string) {
|
|
180
|
+
const quote = text[0];
|
|
181
|
+
if (quote !== '"' && quote !== "'") return 0;
|
|
182
|
+
const close = text.indexOf(quote, 1);
|
|
183
|
+
return close === -1 ? 0 : close + 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const production = Object.entries(sources).filter(([file]) => !file.includes(".test."));
|
|
187
|
+
const components = production.filter(([file]) => file !== STYLESHEET);
|
|
188
|
+
|
|
189
|
+
/** Markers a component renders as a DOM attribute. */
|
|
190
|
+
const rendered = new Set(components.flatMap(([, text]) => [...markersIn(text)]));
|
|
191
|
+
|
|
192
|
+
/** Markers the framework stylesheet targets as `[data-terp="…"]` selectors. */
|
|
193
|
+
const styled = new Set(
|
|
194
|
+
[...(sources[STYLESHEET] ?? "").matchAll(/\[data-terp=["']([a-z0-9-]+)["']\]/g)].map(
|
|
195
|
+
(match) => match[1]!,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
describe("data-terp markers", () => {
|
|
200
|
+
it("reads both sides of the join it is asserting about", () => {
|
|
201
|
+
expect(components.length).toBeGreaterThan(0);
|
|
202
|
+
expect(sources[STYLESHEET], `${STYLESHEET} is not in the scanned source`).toBeDefined();
|
|
203
|
+
expect(rendered.size).toBeGreaterThan(0);
|
|
204
|
+
expect(styled.size).toBeGreaterThan(0);
|
|
205
|
+
// The two sets must be gathered from different files, or the separation is cosmetic.
|
|
206
|
+
expect(components.some(([file]) => file === STYLESHEET)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("renders exactly the pinned inventory", () => {
|
|
210
|
+
// Sorted both sides so the diff on failure names the added or removed marker rather
|
|
211
|
+
// than showing two long reordered lists.
|
|
212
|
+
expect([...rendered].sort()).toEqual([...MARKERS].sort());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("pins the inventory in sorted order with no duplicates", () => {
|
|
216
|
+
// The list is read by humans during review; an unsorted or duplicated entry makes an
|
|
217
|
+
// addition look like a rename.
|
|
218
|
+
expect(MARKERS).toEqual([...new Set(MARKERS)].sort());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("styles no marker that no component renders", () => {
|
|
222
|
+
// A selector matching nothing is dead styling that reads as live: the rule is right
|
|
223
|
+
// there in the sheet, so the state it describes looks handled when it is not.
|
|
224
|
+
expect([...styled].filter((marker) => !rendered.has(marker)).sort()).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps the unmarked-surface worklist accurate", () => {
|
|
228
|
+
// One direction only: a file that gained a marker must leave the list, or the ratchet
|
|
229
|
+
// stops meaning anything. It deliberately does not assert the converse — every
|
|
230
|
+
// provider, hook and manifest in the package renders no marker legitimately, so
|
|
231
|
+
// requiring the list to name every unmarked file would make it a list of everything.
|
|
232
|
+
// A *new* unmarked primitive is therefore caught at review, not here.
|
|
233
|
+
const stillUnmarked = components
|
|
234
|
+
.filter(([, text]) => markersIn(text).size === 0)
|
|
235
|
+
.map(([file]) => file);
|
|
236
|
+
expect(
|
|
237
|
+
UNMARKED_STYLED_SURFACES.filter((file) => !stillUnmarked.includes(file)),
|
|
238
|
+
"these now render a marker — remove them from the worklist",
|
|
239
|
+
).toEqual([]);
|
|
240
|
+
expect(
|
|
241
|
+
UNMARKED_STYLED_SURFACES,
|
|
242
|
+
"the worklist must stay sorted and duplicate-free",
|
|
243
|
+
).toEqual([...new Set(UNMARKED_STYLED_SURFACES)].sort());
|
|
244
|
+
});
|
|
245
|
+
});
|
package/src/raw.d.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal
|
|
2
|
+
* Minimal ambient declarations for the source-scanning tests only — the package keeps
|
|
3
3
|
* `"types": []` so component source never sees ambient Node globals.
|
|
4
|
+
*
|
|
5
|
+
* Both live here rather than inline in the tests that use them: a global augmentation
|
|
6
|
+
* repeated in two files is a TS2717 the moment the copies disagree, and they disagree
|
|
7
|
+
* silently until someone widens one of them.
|
|
4
8
|
*/
|
|
5
9
|
declare module "node:fs" {
|
|
6
10
|
export function readFileSync(path: URL | string, encoding: "utf-8"): string;
|
|
7
11
|
}
|
|
12
|
+
|
|
13
|
+
// Declared at top level, not inside `declare global`: this file is a global script (it has
|
|
14
|
+
// no imports or exports), so the interface merges with the ambient `ImportMeta` directly —
|
|
15
|
+
// `declare global` is only meaningful from inside a module.
|
|
16
|
+
interface ImportMeta {
|
|
17
|
+
glob: (
|
|
18
|
+
pattern: string,
|
|
19
|
+
options: { query: "?raw"; import: "default"; eager: true },
|
|
20
|
+
) => Record<string, string>;
|
|
21
|
+
}
|
package/src/styles.ts
CHANGED
|
@@ -141,7 +141,7 @@ html {
|
|
|
141
141
|
[data-terp="input"]:focus,
|
|
142
142
|
[data-terp="input"]:focus-visible {
|
|
143
143
|
outline: none;
|
|
144
|
-
border-color: var(--color-
|
|
144
|
+
border-color: var(--color-fg-accent) !important;
|
|
145
145
|
box-shadow: 0 0 0 3px var(--color-focus-ring) !important;
|
|
146
146
|
}
|
|
147
147
|
[data-terp="input"]::placeholder {
|
|
@@ -241,12 +241,12 @@ label:has([data-terp="switch"]:disabled) {
|
|
|
241
241
|
transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
|
|
242
242
|
}
|
|
243
243
|
[data-terp="hubcard"]:hover {
|
|
244
|
-
border-color: var(--color-
|
|
244
|
+
border-color: var(--color-fg-accent) !important;
|
|
245
245
|
box-shadow: var(--shadow-sm);
|
|
246
246
|
transform: translateY(-1px);
|
|
247
247
|
}
|
|
248
248
|
[data-terp="hubcard"]:hover [data-terp="hubcard-title"] {
|
|
249
|
-
color: var(--color-
|
|
249
|
+
color: var(--color-fg-accent) !important;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
/* Breadcrumb links -------------------------------------------------------- */
|
|
@@ -283,7 +283,7 @@ label:has([data-terp="switch"]:disabled) {
|
|
|
283
283
|
}
|
|
284
284
|
[data-terp="menu-item"][data-selected="true"] {
|
|
285
285
|
background: var(--color-brand-primary-soft) !important;
|
|
286
|
-
color: var(--color-
|
|
286
|
+
color: var(--color-fg-accent) !important;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
/* Dialogs: ::backdrop cannot be set inline, so the dim layer lives here and
|
package/src/theme.test.tsx
CHANGED
|
@@ -34,6 +34,45 @@ describe("ThemeProvider + ThemeToggle", () => {
|
|
|
34
34
|
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it("applies a named theme beyond light and dark", () => {
|
|
38
|
+
// The named themes are the reason the semantic token layer exists. They are compiled,
|
|
39
|
+
// contrast-gated and published, and none of that reaches a user unless the control can
|
|
40
|
+
// actually pin one — so the whole path is exercised for one of them.
|
|
41
|
+
render(
|
|
42
|
+
<ThemeProvider>
|
|
43
|
+
<ThemeToggle />
|
|
44
|
+
</ThemeProvider>,
|
|
45
|
+
);
|
|
46
|
+
fireEvent.click(screen.getByRole("button", { name: "Theme" }));
|
|
47
|
+
fireEvent.click(screen.getByRole("menuitemradio", { name: "Midnight" }));
|
|
48
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("midnight");
|
|
49
|
+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("midnight");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("offers every shipped theme, not only light and dark", () => {
|
|
53
|
+
// `theme.themes.test.ts` proves the list matches the contract's; this proves the list
|
|
54
|
+
// reaches the menu, which is a different failure — a name in `THEMES` that never renders.
|
|
55
|
+
render(
|
|
56
|
+
<ThemeProvider>
|
|
57
|
+
<ThemeToggle />
|
|
58
|
+
</ThemeProvider>,
|
|
59
|
+
);
|
|
60
|
+
fireEvent.click(screen.getByRole("button", { name: "Theme" }));
|
|
61
|
+
for (const label of ["Light", "Dark", "Midnight", "Twilight", "High contrast", "System"]) {
|
|
62
|
+
expect(screen.getByRole("menuitemradio", { name: label })).toBeInTheDocument();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("accepts a named theme as the app default", () => {
|
|
67
|
+
// How an app ships on a named theme: one prop, no other change anywhere.
|
|
68
|
+
render(
|
|
69
|
+
<ThemeProvider defaultTheme="contrast">
|
|
70
|
+
<ThemeToggle />
|
|
71
|
+
</ThemeProvider>,
|
|
72
|
+
);
|
|
73
|
+
expect(document.documentElement.getAttribute("data-theme")).toBe("contrast");
|
|
74
|
+
});
|
|
75
|
+
|
|
37
76
|
it("restores a persisted choice over the app default", () => {
|
|
38
77
|
window.localStorage.setItem(THEME_STORAGE_KEY, "light");
|
|
39
78
|
render(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_STRINGS } from "./uiText";
|
|
6
|
+
|
|
7
|
+
// react-core's theme list against the contract's published one.
|
|
8
|
+
//
|
|
9
|
+
// `Theme` in `theme.tsx` is a hand-written union of the stylesheet's theme names, and
|
|
10
|
+
// `THEME_ICONS` and the label map are records over it. That is a restatement of a published
|
|
11
|
+
// contract, so it can drift from it — and both directions fail quietly:
|
|
12
|
+
//
|
|
13
|
+
// * A theme the sheet ships that this union omits is a palette no app can ever select. It
|
|
14
|
+
// is compiled, gated for contrast and completeness, published in the manifest, and
|
|
15
|
+
// unreachable.
|
|
16
|
+
// * A theme this union offers that the sheet has no block for is a menu entry that sets
|
|
17
|
+
// `data-theme` to a value nothing matches, so the app silently renders the base theme
|
|
18
|
+
// while the control reports the choice took.
|
|
19
|
+
//
|
|
20
|
+
// The union is not derived from the manifest at runtime on purpose: react-core publishes
|
|
21
|
+
// unbuilt TypeScript and imports nothing but React, so resolving a sibling package's JSON
|
|
22
|
+
// module would add a bundler and tsconfig requirement to every consumer. A test reading the
|
|
23
|
+
// file from disk is the same discipline the other mirrored tables in this repo use — the copy
|
|
24
|
+
// stays a copy, and the copy is checked.
|
|
25
|
+
|
|
26
|
+
const manifest: {
|
|
27
|
+
base: string;
|
|
28
|
+
systemDark: string;
|
|
29
|
+
themes: { name: string; label: string; appearance: "light" | "dark" }[];
|
|
30
|
+
} = JSON.parse(
|
|
31
|
+
readFileSync(new URL("../../contract/src/tokens.manifest.json", import.meta.url), "utf-8"),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Read as source rather than imported, because the union is a type: it does not survive to
|
|
35
|
+
// runtime, and `THEMES` alone would not prove the type and the array agree.
|
|
36
|
+
const themeSource = readFileSync(new URL("./theme.tsx", import.meta.url), "utf-8");
|
|
37
|
+
|
|
38
|
+
/** The names in `export type Theme = "a" | "b" | …`. */
|
|
39
|
+
function unionMembers(): string[] {
|
|
40
|
+
const match = /export type Theme =([^;]+);/.exec(themeSource);
|
|
41
|
+
if (!match) throw new Error("theme.tsx: could not find `export type Theme`");
|
|
42
|
+
return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The names in the `THEMES` array literal. */
|
|
46
|
+
function themesArray(): string[] {
|
|
47
|
+
const match = /const THEMES: readonly Theme\[\] = \[([^\]]+)\]/.exec(themeSource);
|
|
48
|
+
if (!match) throw new Error("theme.tsx: could not find the `THEMES` array");
|
|
49
|
+
return [...match[1]!.matchAll(/"([a-z-]+)"/g)].map((entry) => entry[1]!);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The keys of a `Record<Theme, …>` literal assigned to `name`. */
|
|
53
|
+
function recordKeys(name: string): string[] {
|
|
54
|
+
const match = new RegExp(`const ${name}: Record<Theme, [^>]+> = \\{([^}]+)\\}`).exec(
|
|
55
|
+
themeSource,
|
|
56
|
+
);
|
|
57
|
+
if (!match) throw new Error(`theme.tsx: could not find \`${name}\``);
|
|
58
|
+
return [...match[1]!.matchAll(/^\s*([a-z-]+):/gm)].map((entry) => entry[1]!);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every theme the stylesheet ships, in registry order. */
|
|
62
|
+
const shipped = manifest.themes.map((theme) => theme.name);
|
|
63
|
+
|
|
64
|
+
/** What the control offers: every shipped theme, plus the OS-preference entry. */
|
|
65
|
+
const OS_PREFERENCE = "system";
|
|
66
|
+
const expected = [...shipped, OS_PREFERENCE];
|
|
67
|
+
|
|
68
|
+
describe("react-core's theme list", () => {
|
|
69
|
+
it("reads the sources it is asserting about", () => {
|
|
70
|
+
// Every assertion below is a comparison against a regex match. A regex that stopped
|
|
71
|
+
// matching — a reformatted union, a renamed constant — would compare two empty lists and
|
|
72
|
+
// report green, which is the one failure mode a parity test cannot afford.
|
|
73
|
+
expect(shipped.length).toBeGreaterThanOrEqual(3);
|
|
74
|
+
expect(unionMembers().length).toBe(expected.length);
|
|
75
|
+
expect(themesArray().length).toBe(expected.length);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("offers exactly the themes the contract ships, plus system", () => {
|
|
79
|
+
// The union is the app-facing type: a theme missing here cannot be passed to
|
|
80
|
+
// `ThemeProvider` at all, however completely the stylesheet defines it.
|
|
81
|
+
expect(unionMembers()).toEqual(expected);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists them in the same order in the THEMES array", () => {
|
|
85
|
+
// `THEMES` is what `ThemeToggle` maps over and what `readStoredTheme` validates against,
|
|
86
|
+
// so a name in the type but not the array is selectable in code and absent from the menu.
|
|
87
|
+
expect(themesArray()).toEqual(expected);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("gives every theme an icon, and no two the same", () => {
|
|
91
|
+
// A picker whose entries share a glyph is a picker whose icons carry no information, and
|
|
92
|
+
// the trigger renders the active theme's icon — so a duplicate makes two states identical.
|
|
93
|
+
expect(recordKeys("THEME_ICONS")).toEqual(expected);
|
|
94
|
+
const glyphs = [...themeSource.matchAll(/^\s{2}([a-z-]+): "([a-z-]+)",$/gm)]
|
|
95
|
+
.filter((entry) => expected.includes(entry[1]!))
|
|
96
|
+
.map((entry) => entry[2]!);
|
|
97
|
+
expect(glyphs).toHaveLength(expected.length);
|
|
98
|
+
expect(new Set(glyphs).size).toBe(glyphs.length);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("has a translated label for every theme", () => {
|
|
102
|
+
// The label map is `Record<Theme, string>` so the compiler catches a missing entry, but
|
|
103
|
+
// not one pointing at a string key that does not exist in the catalog.
|
|
104
|
+
for (const name of expected) {
|
|
105
|
+
const key = `theme${name[0]!.toUpperCase()}${name.slice(1)}`;
|
|
106
|
+
expect(
|
|
107
|
+
Object.hasOwn(DEFAULT_STRINGS, key),
|
|
108
|
+
`${name} needs a ${key} string (LOCALE_NL's completeness gate then forces the Dutch one)`,
|
|
109
|
+
).toBe(true);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps the OS-preference entry from colliding with a theme name", () => {
|
|
114
|
+
// "system" is a sentinel, not a theme: it means *remove* `data-theme` and let the sheet's
|
|
115
|
+
// `prefers-color-scheme` block decide. A shipped theme actually named `system` would make
|
|
116
|
+
// the sentinel ambiguous — selecting it would clear the attribute rather than pin the
|
|
117
|
+
// theme, and the two would be indistinguishable in storage.
|
|
118
|
+
expect(shipped).not.toContain(OS_PREFERENCE);
|
|
119
|
+
// Both ends of what the sentinel resolves to have to be themes the control also offers,
|
|
120
|
+
// or "System" would render a palette the user cannot pick deliberately.
|
|
121
|
+
expect(shipped).toContain(manifest.systemDark);
|
|
122
|
+
expect(shipped).toContain(manifest.base);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/theme.tsx
CHANGED
|
@@ -7,16 +7,37 @@ import { useStrings } from "./uiText";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The visual theme: an explicit choice, or "system" to follow the OS preference.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* The token stylesheet (`@terpjs/contract/tokens.css`) carries every palette: it applies
|
|
12
|
+
* each named theme's colours under `<html data-theme="<name>">` and — with no attribute —
|
|
13
|
+
* applies the dark palette under `@media (prefers-color-scheme: dark)`, so "system" simply
|
|
14
|
+
* removes the attribute.
|
|
15
|
+
*
|
|
16
|
+
* The names are the stylesheet's, so this union is a restatement of a published contract
|
|
17
|
+
* and could drift from it silently — a theme the sheet ships that no app can select, or one
|
|
18
|
+
* this offers that resolves to nothing. `theme.themes.test.ts` holds it against the token
|
|
19
|
+
* manifest. The union is written out rather than derived from the manifest at runtime because
|
|
20
|
+
* react-core publishes unbuilt source and imports nothing but React: resolving a JSON module
|
|
21
|
+
* from a sibling package would add a requirement to every consumer's bundler and tsconfig,
|
|
22
|
+
* which is the consumption-model change the framework spends real effort avoiding.
|
|
13
23
|
*/
|
|
14
|
-
export type Theme = "light" | "dark" | "system";
|
|
24
|
+
export type Theme = "light" | "dark" | "midnight" | "twilight" | "contrast" | "system";
|
|
25
|
+
|
|
26
|
+
const THEMES: readonly Theme[] = [
|
|
27
|
+
"light",
|
|
28
|
+
"dark",
|
|
29
|
+
"midnight",
|
|
30
|
+
"twilight",
|
|
31
|
+
"contrast",
|
|
32
|
+
"system",
|
|
33
|
+
];
|
|
15
34
|
|
|
16
|
-
const THEMES: readonly Theme[] = ["light", "dark", "system"];
|
|
17
35
|
const THEME_ICONS: Record<Theme, string> = {
|
|
18
36
|
light: "sun",
|
|
19
37
|
dark: "moon",
|
|
38
|
+
midnight: "moon-stars",
|
|
39
|
+
twilight: "sunset",
|
|
40
|
+
contrast: "contrast",
|
|
20
41
|
system: "monitor",
|
|
21
42
|
};
|
|
22
43
|
|
|
@@ -46,10 +67,13 @@ export interface ThemeProviderProps {
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
/**
|
|
49
|
-
* Owns the
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
70
|
+
* Owns the theme choice: applies it as `data-theme` on `<html>` (the token stylesheet does
|
|
71
|
+
* the rest — no component changes anywhere) and persists it in `localStorage`.
|
|
72
|
+
* `renderTerpApp` mounts one for every app; pair with {@link ThemeToggle} (the default
|
|
73
|
+
* {@link UserMenu} already includes it).
|
|
74
|
+
*
|
|
75
|
+
* `defaultTheme` is how an app ships on a named theme — `defaultTheme="midnight"` and
|
|
76
|
+
* nothing else — since it applies until the user chooses otherwise.
|
|
53
77
|
*/
|
|
54
78
|
export function ThemeProvider({ defaultTheme = "system", children }: ThemeProviderProps) {
|
|
55
79
|
const [theme, setThemeState] = useState<Theme>(() => {
|
|
@@ -93,8 +117,8 @@ export interface ThemeToggleProps {
|
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
/**
|
|
96
|
-
* The standard theme control: a token-themed
|
|
97
|
-
* outside a {@link ThemeProvider}, so shared chrome (the shell header) can
|
|
120
|
+
* The standard theme control: a token-themed menu over every shipped theme plus "system".
|
|
121
|
+
* Renders nothing outside a {@link ThemeProvider}, so shared chrome (the shell header) can
|
|
98
122
|
* include it unconditionally.
|
|
99
123
|
*/
|
|
100
124
|
export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
@@ -106,6 +130,9 @@ export function ThemeToggle({ variant = "stacked" }: ThemeToggleProps) {
|
|
|
106
130
|
const labels: Record<Theme, string> = {
|
|
107
131
|
light: strings.themeLight,
|
|
108
132
|
dark: strings.themeDark,
|
|
133
|
+
midnight: strings.themeMidnight,
|
|
134
|
+
twilight: strings.themeTwilight,
|
|
135
|
+
contrast: strings.themeContrast,
|
|
109
136
|
system: strings.themeSystem,
|
|
110
137
|
};
|
|
111
138
|
const menu = (
|
package/src/tokens.guard.test.ts
CHANGED
|
@@ -7,18 +7,9 @@ const tokensCss = readFileSync(
|
|
|
7
7
|
"utf-8",
|
|
8
8
|
);
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
declare global {
|
|
14
|
-
interface ImportMeta {
|
|
15
|
-
glob: (
|
|
16
|
-
pattern: string,
|
|
17
|
-
options: { query: "?raw"; import: "default"; eager: true },
|
|
18
|
-
) => Record<string, string>;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
10
|
+
// The package keeps its deliberate `"types": []` isolation: react-core source must never
|
|
11
|
+
// see ambient Node globals. The source scan uses Vite's raw glob and the sheet needs fs;
|
|
12
|
+
// both are declared minimally in raw.d.ts, shared with the other scanning tests.
|
|
22
13
|
const sources = import.meta.glob("./**/*.{ts,tsx}", {
|
|
23
14
|
query: "?raw",
|
|
24
15
|
import: "default",
|
package/src/ui/Badge.tsx
CHANGED
|
@@ -55,6 +55,13 @@ const badgeStyle = (tone: BadgeTone): CSSProperties => ({
|
|
|
55
55
|
/** Small token-styled status pill — flat soft tint with a matching text colour. */
|
|
56
56
|
export function Badge({ label, children, tone = "neutral" }: BadgeProps) {
|
|
57
57
|
const resolve = useUiText();
|
|
58
|
-
|
|
58
|
+
// `data-tone` alongside the marker: the tone is currently readable only from the inline
|
|
59
|
+
// colour values, so a stylesheet rule cannot address "the warning pill" at all. Naming it
|
|
60
|
+
// as an attribute is what lets tone styling move out of `style={}` later.
|
|
61
|
+
return (
|
|
62
|
+
<span data-terp="badge" data-tone={tone} style={badgeStyle(tone)}>
|
|
63
|
+
{resolve((label ?? children) as UiText)}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
59
66
|
}
|
|
60
67
|
|
package/src/ui/Checkbox.tsx
CHANGED
package/src/ui/Combobox.tsx
CHANGED
|
@@ -80,7 +80,7 @@ const optionStyle = (active: boolean, selected: boolean, disabled: boolean): CSS
|
|
|
80
80
|
color: disabled
|
|
81
81
|
? "var(--color-neutral-400)"
|
|
82
82
|
: selected
|
|
83
|
-
? "var(--color-
|
|
83
|
+
? "var(--color-fg-accent)"
|
|
84
84
|
: "var(--color-neutral-900)",
|
|
85
85
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
86
86
|
fontWeight: selected ? "var(--font-weight-semibold)" as never : "var(--font-weight-normal)" as never,
|
package/src/ui/Radio.tsx
CHANGED
package/src/ui/Switch.tsx
CHANGED
|
@@ -18,7 +18,7 @@ const labelStyle: CSSProperties = {
|
|
|
18
18
|
const inputStyle: CSSProperties = {
|
|
19
19
|
inlineSize: "2.25rem",
|
|
20
20
|
blockSize: "1.25rem",
|
|
21
|
-
accentColor: "var(--color-
|
|
21
|
+
accentColor: "var(--color-fg-accent)",
|
|
22
22
|
cursor: "pointer",
|
|
23
23
|
transition: "background-color 150ms ease",
|
|
24
24
|
};
|
package/src/ui/Tabs.tsx
CHANGED
|
@@ -22,8 +22,8 @@ const tabStyle = (selected: boolean): CSSProperties => ({
|
|
|
22
22
|
: "var(--font-weight-medium)") as never,
|
|
23
23
|
padding: "var(--space-2) var(--space-3)",
|
|
24
24
|
border: 0,
|
|
25
|
-
borderBlockEnd: selected ? "2px solid var(--color-
|
|
26
|
-
color: selected ? "var(--color-
|
|
25
|
+
borderBlockEnd: selected ? "2px solid var(--color-fg-accent)" : "2px solid transparent",
|
|
26
|
+
color: selected ? "var(--color-fg-accent)" : "var(--color-neutral-600)",
|
|
27
27
|
background: "transparent",
|
|
28
28
|
cursor: "pointer",
|
|
29
29
|
marginBlockEnd: "-1px",
|
package/src/ui/Tooltip.tsx
CHANGED
|
@@ -59,7 +59,7 @@ export function Tooltip({ content, children }: TooltipProps) {
|
|
|
59
59
|
setOpen(false);
|
|
60
60
|
},
|
|
61
61
|
})}
|
|
62
|
-
<span id={id} role="tooltip" hidden={!open} style={tooltipStyle}>
|
|
62
|
+
<span id={id} role="tooltip" data-terp="tooltip" hidden={!open} style={tooltipStyle}>
|
|
63
63
|
{resolve(content)}
|
|
64
64
|
</span>
|
|
65
65
|
</span>
|
package/src/uiText.tsx
CHANGED
|
@@ -100,6 +100,12 @@ export interface TerpStrings {
|
|
|
100
100
|
themeLight: string;
|
|
101
101
|
/** {@link ThemeToggle} option: the dark theme. */
|
|
102
102
|
themeDark: string;
|
|
103
|
+
/** {@link ThemeToggle} option: the near-black dark theme. */
|
|
104
|
+
themeMidnight: string;
|
|
105
|
+
/** {@link ThemeToggle} option: the violet-tinted dark theme. */
|
|
106
|
+
themeTwilight: string;
|
|
107
|
+
/** {@link ThemeToggle} option: the high-contrast theme. */
|
|
108
|
+
themeContrast: string;
|
|
103
109
|
/** {@link ThemeToggle} option: follow the OS preference. */
|
|
104
110
|
themeSystem: string;
|
|
105
111
|
/** Label of the {@link LanguageSwitcher} select. */
|
|
@@ -239,6 +245,9 @@ export const DEFAULT_STRINGS: TerpStrings = {
|
|
|
239
245
|
theme: "Theme",
|
|
240
246
|
themeLight: "Light",
|
|
241
247
|
themeDark: "Dark",
|
|
248
|
+
themeMidnight: "Midnight",
|
|
249
|
+
themeTwilight: "Twilight",
|
|
250
|
+
themeContrast: "High contrast",
|
|
242
251
|
themeSystem: "System",
|
|
243
252
|
language: "Language",
|
|
244
253
|
admin: "Admin",
|