@godxjp/ui 28.6.0 → 28.8.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 (99) hide show
  1. package/dist/components/data-display/data-table.d.ts +10 -2
  2. package/dist/components/data-display/data-table.js +12 -5
  3. package/dist/components/data-display/index.d.ts +2 -0
  4. package/dist/components/data-display/index.js +2 -0
  5. package/dist/components/data-display/marquee.d.ts +16 -0
  6. package/dist/components/data-display/marquee.js +155 -0
  7. package/dist/components/data-display/scroll-area.js +16 -2
  8. package/dist/components/data-display/service-launcher-card.d.ts +25 -0
  9. package/dist/components/data-display/table.d.ts +38 -1
  10. package/dist/components/data-display/table.js +57 -39
  11. package/dist/components/data-entry/form-field.js +29 -12
  12. package/dist/components/general/reveal.d.ts +23 -2
  13. package/dist/components/general/reveal.js +37 -7
  14. package/dist/components/general/typography.d.ts +4 -1
  15. package/dist/components/general/typography.js +14 -1
  16. package/dist/components/layout/affix.d.ts +86 -0
  17. package/dist/components/layout/affix.js +187 -0
  18. package/dist/components/layout/index.d.ts +4 -0
  19. package/dist/components/layout/index.js +4 -0
  20. package/dist/components/layout/legal-document-shell.js +4 -3
  21. package/dist/components/layout/masonry.d.ts +74 -0
  22. package/dist/components/layout/masonry.js +214 -0
  23. package/dist/components/layout/page-container.js +5 -20
  24. package/dist/components/navigation/anchor.d.ts +64 -0
  25. package/dist/components/navigation/anchor.js +284 -0
  26. package/dist/components/navigation/app-setting-picker.js +20 -3
  27. package/dist/components/navigation/index.d.ts +4 -0
  28. package/dist/components/navigation/index.js +4 -0
  29. package/dist/components/navigation/mega-menu.d.ts +21 -0
  30. package/dist/components/navigation/mega-menu.js +526 -0
  31. package/dist/components/ui/table.js +1 -0
  32. package/dist/contracts/measurement.json +1 -1
  33. package/dist/i18n/messages/en.json +522 -1
  34. package/dist/i18n/messages/ja.json +518 -1
  35. package/dist/i18n/messages/vi.json +518 -1
  36. package/dist/lib/hooks.d.ts +91 -0
  37. package/dist/lib/hooks.js +79 -0
  38. package/dist/lib/platform.d.ts +14 -0
  39. package/dist/lib/platform.js +10 -1
  40. package/dist/lib/utils.d.ts +1 -1
  41. package/dist/lib/utils.js +3 -2
  42. package/dist/props/components/data-display.prop.d.ts +109 -1
  43. package/dist/props/components/general.prop.d.ts +47 -3
  44. package/dist/props/components/layout.prop.d.ts +194 -0
  45. package/dist/props/components/navigation.prop.d.ts +263 -0
  46. package/dist/props/registry.d.ts +360 -5
  47. package/dist/props/registry.js +474 -4
  48. package/dist/props/vocabulary/index.d.ts +1 -1
  49. package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
  50. package/dist/styles/control.css +5 -6
  51. package/dist/styles/data-display-layout.css +2 -1
  52. package/dist/styles/density.css +4 -0
  53. package/dist/styles/form-layout.css +22 -0
  54. package/dist/styles/layout.css +79 -1
  55. package/dist/styles/motion.css +121 -1
  56. package/dist/styles/navigation-layout.css +415 -20
  57. package/dist/styles/shell-layout.css +3 -0
  58. package/dist/styles/text-layout.css +52 -4
  59. package/dist/tokens/base.css +5 -0
  60. package/dist/tokens/components/affix.css +7 -0
  61. package/dist/tokens/components/anchor.css +17 -0
  62. package/dist/tokens/components/control.css +3 -3
  63. package/dist/tokens/components/form.css +3 -1
  64. package/dist/tokens/components/marquee.css +7 -0
  65. package/dist/tokens/components/masonry.css +6 -0
  66. package/dist/tokens/components/mega-menu.css +62 -0
  67. package/dist/tokens/components/navigation.css +2 -0
  68. package/dist/tokens/components/shell.css +3 -0
  69. package/dist/tokens/foundation.css +11 -0
  70. package/dist/tokens/semantic/layout.css +7 -0
  71. package/docs/COMPOSITION-VS-COMPONENT.md +21 -0
  72. package/docs/DESIGN-AUTHORITY.md +124 -23
  73. package/docs/FRAME-COVERAGE-REPORT.md +7 -2
  74. package/docs/data-display/marquee.tsx +254 -0
  75. package/docs/data-display/scroll-area.tsx +31 -0
  76. package/docs/data-display/service-launcher-card.tsx +275 -115
  77. package/docs/foundation/_theme-editor-scope.ts +222 -0
  78. package/docs/foundation/density.tsx +12 -2
  79. package/docs/foundation/spacing.tsx +5 -0
  80. package/docs/foundation/theme-editor.tsx +645 -0
  81. package/docs/general/activity.tsx +65 -0
  82. package/docs/general/reveal.tsx +290 -22
  83. package/docs/general/typography.tsx +91 -1
  84. package/docs/layout/affix.tsx +209 -0
  85. package/docs/layout/masonry.tsx +291 -0
  86. package/docs/navigation/anchor.tsx +285 -0
  87. package/docs/navigation/mega-menu-panel.tsx +86 -0
  88. package/docs/navigation/mega-menu.tsx +254 -0
  89. package/docs/roadmap/website-components.md +779 -0
  90. package/docs/showcase/acme-website.tsx +75 -39
  91. package/docs/showcase/futurelastic-web.tsx +91 -49
  92. package/docs/showcase/marketing-page.tsx +885 -0
  93. package/docs/showcase/table-footer-totals.tsx +12 -2
  94. package/docs/showcase/theme-customization.tsx +1259 -0
  95. package/package.json +8 -5
  96. package/scripts/brand-accent.generated.mjs +27 -0
  97. package/scripts/ui-audit.mjs +66 -0
  98. package/scripts/visual-audit-rules.mjs +46 -2
  99. package/scripts/visual-audit.mjs +29 -1
@@ -123,7 +123,12 @@ export default function Demo() {
123
123
  </CardDescription>
124
124
  </CardHeader>
125
125
  <CardContent>
126
- <Flex direction="row" gap="lg" align="start" wrap>
126
+ {/* A STACK ON A PHONE, said with `direction` rather than left to `wrap`.
127
+ `wrap` never fired here: each column carries `min-w-0 flex-1`, and an item that
128
+ may shrink to nothing never reaches the wrap threshold — so at 375px this was
129
+ three 92px columns, and the unbreakable `density="comfortable"` label painted
130
+ 36px over its neighbour with no scrollport to reach it. */}
131
+ <Flex direction={{ base: "col", md: "row" }} gap="lg" align="start" wrap>
127
132
  {density.map((d) => (
128
133
  <Flex key={d.cls} direction="col" gap="sm" className="min-w-0 flex-1 sm:min-w-72">
129
134
  <Flex direction="row" align="center" justify="between" gap="sm">
@@ -154,7 +159,12 @@ export default function Demo() {
154
159
  </CardDescription>
155
160
  </CardHeader>
156
161
  <CardContent>
157
- <Flex direction="row" gap="lg" align="start" wrap>
162
+ {/* A STACK ON A PHONE, said with `direction` rather than left to `wrap`.
163
+ `wrap` never fired here: each column carries `min-w-0 flex-1`, and an item that
164
+ may shrink to nothing never reaches the wrap threshold — so at 375px this was
165
+ three 92px columns, and the unbreakable `density="comfortable"` label painted
166
+ 36px over its neighbour with no scrollport to reach it. */}
167
+ <Flex direction={{ base: "col", md: "row" }} gap="lg" align="start" wrap>
158
168
  <Flex direction="col" gap="sm" className="min-w-0 flex-1 sm:min-w-72">
159
169
  <Text size="xs" mono>
160
170
  density=&quot;compact&quot;
@@ -22,6 +22,9 @@ const rawScale = [
22
22
  "--space-8",
23
23
  "--space-10",
24
24
  "--space-12",
25
+ // The DISPLAY end of the grid (gh#831) — Carbon $spacing-11 / $spacing-12, for marketing bands.
26
+ "--space-20",
27
+ "--space-24",
25
28
  ];
26
29
 
27
30
  const phiScale = [
@@ -36,6 +39,8 @@ const layoutScale = [
36
39
  { token: "--space-page-x", role: "page gutter X (PageContainer)" },
37
40
  { token: "--space-page-y", role: "page gutter Y (PageContainer)" },
38
41
  { token: "--space-section", role: "section gap (= --phi-0)" },
42
+ { token: "--space-section-band", role: "marketing band padding-block (= --space-20)" },
43
+ { token: "--space-section-hero", role: "hero band padding-block (= --space-24)" },
39
44
  { token: "--space-stack-xs", role: "Flex gap='xs'" },
40
45
  { token: "--space-stack-sm", role: "Flex gap='sm'" },
41
46
  { token: "--space-stack-md", role: "Flex gap='md' (default · = --phi-0)" },
@@ -0,0 +1,645 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
+ import {
4
+ Badge,
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ CodeBlock,
11
+ DataTable,
12
+ Swatch,
13
+ type ColumnDef,
14
+ } from "@godxjp/ui/data-display";
15
+ import {
16
+ Checkbox,
17
+ ColorPicker,
18
+ Field,
19
+ FormField,
20
+ Input,
21
+ Segmented,
22
+ Select,
23
+ Slider,
24
+ Switch,
25
+ } from "@godxjp/ui/data-entry";
26
+ import { Alert, AlertDescription, AlertTitle } from "@godxjp/ui/feedback";
27
+ import { Button, Text } from "@godxjp/ui/general";
28
+ import { useTranslation } from "@godxjp/ui/i18n";
29
+ import { Flex, PageContainer, ResponsiveGrid } from "@godxjp/ui/layout";
30
+
31
+ import {
32
+ SEED_PRESETS,
33
+ brandFor,
34
+ genBrandCommand,
35
+ parseHex,
36
+ previewScope,
37
+ readThemeSpine,
38
+ toHex,
39
+ type Brand,
40
+ type BrandTheme,
41
+ type ContrastRow,
42
+ type ThemeSpine,
43
+ } from "./_theme-editor-scope";
44
+
45
+ /**
46
+ * Theme editor · one seed colour in, a complete brand out, MEASURED rather than asserted.
47
+ *
48
+ * THE PAGE IS THE HIERARCHY. `docs/CUSTOMER-THEMING.md` opens with a priority order — seed, role,
49
+ * scope, instance — and says the point of the order is that you stop at the first level that does
50
+ * the job. So the seed is one control at the top, the roles it produces are READ-ONLY underneath
51
+ * it (the causality has to be visible, not asserted), and the two per-token overrides sit below
52
+ * both, after the sentence that says what they cost. Ant Design reaches the same conclusion from
53
+ * the other end: "In most cases, using Seed Tokens is sufficient for custom themes".
54
+ *
55
+ * THE EXPORT IS `gen:brand`'s, NOT A SECOND OPINION. Both call `deriveBrand`
56
+ * (src/tokens/__tests__/brand-derivation.ts), so for the same hex the CSS below is byte for byte
57
+ * what `pnpm gen:brand '#RRGGBB'` writes — including the three tokens per theme it emits and the
58
+ * four it refuses to, because those derive from the `--primary` in scope and a literal would pin
59
+ * them (gh#678). The one input that has no CLI flag is the authored dark lightness, and when it is
60
+ * used the page stops printing a command rather than print one that emits something else.
61
+ *
62
+ * THE PREVIEW IS SCOPED, NOT ROOTED, and that is the freeze rule doing real work. Each pane
63
+ * declares the seed on ITSELF, which is the only way two seeds can be on screen at once; what a
64
+ * pane must declare, and why `--ring` is one of them, is documented at `previewScope`.
65
+ *
66
+ * NO CHART. The `--chart-*` series are decorative primitives on purpose and do not derive from the
67
+ * seed, so a chart here would move for no reason a reader could learn anything from.
68
+ *
69
+ * Composed only from real @godxjp/ui components.
70
+ */
71
+
72
+ type LabelMode = "auto" | "light" | "dark";
73
+
74
+ /** `gen:brand --foreground` takes the LABEL colour, so `light` is a white label on the fill. */
75
+ const FORCED_LABEL: Record<LabelMode, string | null> = {
76
+ auto: null,
77
+ light: "#ffffff",
78
+ dark: "#000000",
79
+ };
80
+
81
+ type RoleRow = {
82
+ id: string;
83
+ token: string;
84
+ light: string;
85
+ dark: string;
86
+ emitted: boolean;
87
+ };
88
+
89
+ type LineRow = { id: string; item: string; owner: string; state: "active" | "pending" };
90
+
91
+ const PREVIEW_LINES: LineRow[] = [
92
+ { id: "INV-2041", item: "themeEditor.preview.line.licence", owner: "OP-14", state: "active" },
93
+ { id: "INV-2042", item: "themeEditor.preview.line.support", owner: "OP-03", state: "pending" },
94
+ { id: "INV-2043", item: "themeEditor.preview.line.setup", owner: "OP-14", state: "active" },
95
+ ];
96
+
97
+ /**
98
+ * ONE PREVIEW, RENDERED TWICE — once plain and once under `.dark`, each with its own seed on
99
+ * ITSELF. The dark seed is searched for contrast parity with the light one, so an editor that
100
+ * showed only light would hide the half that is hard.
101
+ */
102
+ function PreviewPane({
103
+ theme,
104
+ dark,
105
+ idPrefix,
106
+ }: {
107
+ theme: BrandTheme;
108
+ dark: boolean;
109
+ idPrefix: string;
110
+ }) {
111
+ const { t } = useTranslation();
112
+
113
+ const columns: ColumnDef<LineRow>[] = [
114
+ { key: "id", header: t("themeEditor.preview.column.id"), width: "w-28" },
115
+ { key: "item", header: t("themeEditor.preview.column.item"), render: (row) => t(row.item) },
116
+ { key: "owner", header: t("themeEditor.preview.column.owner") },
117
+ {
118
+ key: "state",
119
+ header: t("themeEditor.preview.column.state"),
120
+ render: (row) => (
121
+ <Badge tone={row.state === "active" ? "primary" : "neutral"}>
122
+ {t(`themeEditor.preview.state.${row.state}`)}
123
+ </Badge>
124
+ ),
125
+ },
126
+ ];
127
+
128
+ return (
129
+ <Flex
130
+ direction="col"
131
+ gap="md"
132
+ className={dark ? "dark" : undefined}
133
+ style={previewScope(theme)}
134
+ >
135
+ <Card>
136
+ <CardHeader>
137
+ <CardTitle level={3}>
138
+ {t(dark ? "themeEditor.preview.darkTitle" : "themeEditor.preview.lightTitle")}
139
+ </CardTitle>
140
+ <CardDescription>
141
+ {t("themeEditor.preview.seedIs", { hex: toHex(theme.seed) })}
142
+ </CardDescription>
143
+ </CardHeader>
144
+ <CardContent>
145
+ <Flex direction="col" gap="lg">
146
+ <Flex gap="sm" wrap>
147
+ <Button>{t("themeEditor.preview.action.save")}</Button>
148
+ <Button variant="secondary">{t("themeEditor.preview.action.duplicate")}</Button>
149
+ <Button variant="outline">{t("themeEditor.preview.action.export")}</Button>
150
+ <Button variant="dashed">{t("themeEditor.preview.action.add")}</Button>
151
+ <Button variant="ghost">{t("themeEditor.preview.action.dismiss")}</Button>
152
+ <Button variant="destructive">{t("themeEditor.preview.action.delete")}</Button>
153
+ <Button variant="link">{t("themeEditor.preview.action.details")}</Button>
154
+ </Flex>
155
+
156
+ <Flex gap="sm" wrap>
157
+ <Badge tone="primary">{t("themeEditor.preview.badge.brand")}</Badge>
158
+ <Badge tone="success">{t("themeEditor.preview.badge.success")}</Badge>
159
+ <Badge tone="warning">{t("themeEditor.preview.badge.warning")}</Badge>
160
+ <Badge tone="destructive">{t("themeEditor.preview.badge.destructive")}</Badge>
161
+ <Badge tone="info">{t("themeEditor.preview.badge.info")}</Badge>
162
+ <Badge tone="neutral">{t("themeEditor.preview.badge.neutral")}</Badge>
163
+ </Flex>
164
+
165
+ <Alert tone="info">
166
+ <AlertTitle>{t("themeEditor.preview.alert.title")}</AlertTitle>
167
+ <AlertDescription>
168
+ {t("themeEditor.preview.alert.description")}{" "}
169
+ <Text link href="#theme-editor-seed">
170
+ {t("themeEditor.preview.alert.link")}
171
+ </Text>
172
+ </AlertDescription>
173
+ </Alert>
174
+
175
+ <ResponsiveGrid columns={{ base: 1, md: 2 }}>
176
+ <FormField
177
+ id={`${idPrefix}-org`}
178
+ label={t("themeEditor.preview.field.org")}
179
+ helper={t("themeEditor.preview.field.orgHelper")}
180
+ >
181
+ <Input id={`${idPrefix}-org`} defaultValue="Acme 株式会社" />
182
+ </FormField>
183
+ <FormField id={`${idPrefix}-plan`} label={t("themeEditor.preview.field.plan")}>
184
+ <Select
185
+ id={`${idPrefix}-plan`}
186
+ defaultValue="pro"
187
+ options={[
188
+ { value: "starter", label: t("themeEditor.preview.plan.starter") },
189
+ { value: "pro", label: t("themeEditor.preview.plan.pro") },
190
+ { value: "scale", label: t("themeEditor.preview.plan.scale") },
191
+ ]}
192
+ />
193
+ </FormField>
194
+ </ResponsiveGrid>
195
+
196
+ <Flex gap="lg" wrap>
197
+ <Field id={`${idPrefix}-tax`} label={t("themeEditor.preview.field.tax")}>
198
+ <Checkbox id={`${idPrefix}-tax`} defaultChecked />
199
+ </Field>
200
+ <Field id={`${idPrefix}-mail`} label={t("themeEditor.preview.field.mail")}>
201
+ <Switch id={`${idPrefix}-mail`} defaultChecked />
202
+ </Field>
203
+ </Flex>
204
+ </Flex>
205
+ </CardContent>
206
+ </Card>
207
+
208
+ <Card>
209
+ <CardContent flush>
210
+ <DataTable
211
+ columns={columns}
212
+ data={PREVIEW_LINES}
213
+ getRowId={(row) => row.id}
214
+ label={t("themeEditor.preview.tableLabel")}
215
+ />
216
+ </CardContent>
217
+ </Card>
218
+ </Flex>
219
+ );
220
+ }
221
+
222
+ export default function Demo() {
223
+ const { t, locale } = useTranslation();
224
+
225
+ /* The spine is read once, eagerly, so the first paint already has a brand on it; the effect is
226
+ * the retry for the case where the stylesheet had not landed when the module ran. */
227
+ const [spine, setSpine] = useState<ThemeSpine | null>(() => readThemeSpine());
228
+ useEffect(() => {
229
+ if (!spine) setSpine(readThemeSpine());
230
+ }, [spine]);
231
+
232
+ const [seed, setSeed] = useState(SEED_PRESETS[0].hex);
233
+ const [labelMode, setLabelMode] = useState<LabelMode>("auto");
234
+ const [darkL, setDarkL] = useState<number | null>(null);
235
+ const [name, setName] = useState("acme");
236
+
237
+ const safeName = name.trim().length > 0 ? name.trim() : "brand";
238
+ const brand: Brand | null = useMemo(() => {
239
+ if (!spine || !parseHex(seed)) return null;
240
+ return brandFor(spine, seed, safeName, FORCED_LABEL[labelMode], darkL);
241
+ }, [spine, seed, safeName, labelMode, darkL]);
242
+
243
+ /* A ratio is a number a person reads, so it is formatted for the active locale rather than
244
+ * printed with a hard-coded dot. The VALUE still comes from `deriveBrand`'s own 2dp string, so
245
+ * what is shown and what `gen:brand` prints cannot round differently. */
246
+ const ratioFormat = useMemo(
247
+ () => new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
248
+ [locale],
249
+ );
250
+ const percentFormat = useMemo(
251
+ () =>
252
+ new Intl.NumberFormat(locale, { style: "unit", unit: "percent", maximumFractionDigits: 1 }),
253
+ [locale],
254
+ );
255
+
256
+ if (!spine) {
257
+ return (
258
+ <PageContainer title={t("themeEditor.title")} subtitle={t("themeEditor.subtitle")}>
259
+ <Alert tone="destructive">
260
+ <AlertTitle>{t("themeEditor.unreadable.title")}</AlertTitle>
261
+ <AlertDescription>{t("themeEditor.unreadable.description")}</AlertDescription>
262
+ </Alert>
263
+ </PageContainer>
264
+ );
265
+ }
266
+
267
+ const searchedL = brand?.darkSearched?.[2] ?? null;
268
+ const darkOverridden = darkL !== null && searchedL !== null && darkL !== searchedL;
269
+ const command = genBrandCommand(seed, safeName, FORCED_LABEL[labelMode], darkOverridden);
270
+
271
+ const roleRows: RoleRow[] =
272
+ brand && brand.dark
273
+ ? [
274
+ {
275
+ id: "primary",
276
+ token: "--primary",
277
+ light: toHex(brand.light.seed),
278
+ dark: toHex(brand.dark.seed),
279
+ emitted: true,
280
+ },
281
+ {
282
+ id: "primary-foreground",
283
+ token: "--primary-foreground",
284
+ light: toHex(brand.light.label),
285
+ dark: toHex(brand.dark.label),
286
+ emitted: true,
287
+ },
288
+ {
289
+ id: "ring",
290
+ token: "--ring",
291
+ light: toHex(brand.light.seed),
292
+ dark: toHex(brand.dark.seed),
293
+ emitted: true,
294
+ },
295
+ {
296
+ id: "text-link",
297
+ token: "--text-link",
298
+ light: toHex(brand.light.text.link),
299
+ dark: toHex(brand.dark.text.link),
300
+ emitted: false,
301
+ },
302
+ {
303
+ id: "text-brand",
304
+ token: "--text-brand",
305
+ light: toHex(brand.light.text.brand),
306
+ dark: toHex(brand.dark.text.brand),
307
+ emitted: false,
308
+ },
309
+ {
310
+ id: "text-primary",
311
+ token: "--text-primary",
312
+ light: toHex(brand.light.text.primary),
313
+ dark: toHex(brand.dark.text.primary),
314
+ emitted: false,
315
+ },
316
+ ]
317
+ : [];
318
+
319
+ const roleColumns: ColumnDef<RoleRow>[] = [
320
+ {
321
+ key: "token",
322
+ header: t("themeEditor.derived.column.token"),
323
+ render: (row) => (
324
+ <Text mono size="xs">
325
+ {row.token}
326
+ </Text>
327
+ ),
328
+ },
329
+ {
330
+ key: "light",
331
+ header: t("themeEditor.derived.column.light"),
332
+ render: (row) => (
333
+ <Flex gap="xs" align="center">
334
+ <Swatch color={row.light} />
335
+ <Text mono size="xs">
336
+ {row.light}
337
+ </Text>
338
+ </Flex>
339
+ ),
340
+ },
341
+ {
342
+ key: "dark",
343
+ header: t("themeEditor.derived.column.dark"),
344
+ render: (row) => (
345
+ <Flex gap="xs" align="center">
346
+ <Swatch color={row.dark} />
347
+ <Text mono size="xs">
348
+ {row.dark}
349
+ </Text>
350
+ </Flex>
351
+ ),
352
+ },
353
+ {
354
+ key: "emitted",
355
+ header: t("themeEditor.derived.column.emitted"),
356
+ render: (row) => (
357
+ <Badge tone={row.emitted ? "primary" : "neutral"} variant="outline">
358
+ {t(row.emitted ? "themeEditor.derived.written" : "themeEditor.derived.derivedOnly")}
359
+ </Badge>
360
+ ),
361
+ },
362
+ ];
363
+
364
+ const contrastColumns: ColumnDef<ContrastRow>[] = [
365
+ {
366
+ key: "id",
367
+ header: t("themeEditor.contrast.column.pair"),
368
+ render: (row) => t(`themeEditor.contrast.pair.${row.id}`),
369
+ },
370
+ {
371
+ key: "measured",
372
+ header: t("themeEditor.contrast.column.measured"),
373
+ align: "right",
374
+ render: (row) => (
375
+ <Text tabular mono size="xs">
376
+ {t("themeEditor.contrast.ratio", { value: ratioFormat.format(Number(row.measured)) })}
377
+ </Text>
378
+ ),
379
+ },
380
+ {
381
+ key: "threshold",
382
+ header: t("themeEditor.contrast.column.threshold"),
383
+ align: "right",
384
+ render: (row) => (
385
+ <Text tabular mono size="xs" tone="muted">
386
+ {t("themeEditor.contrast.ratio", { value: ratioFormat.format(row.threshold) })}
387
+ </Text>
388
+ ),
389
+ },
390
+ {
391
+ key: "ok",
392
+ header: t("themeEditor.contrast.column.result"),
393
+ /* NEVER COLOUR ALONE (WCAG 1.4.1): the tone carries the same word the Badge says. */
394
+ render: (row) => (
395
+ <Badge tone={row.ok ? "success" : "destructive"}>
396
+ {t(row.ok ? "themeEditor.contrast.pass" : "themeEditor.contrast.fail")}
397
+ </Badge>
398
+ ),
399
+ },
400
+ ];
401
+
402
+ const report = brand?.report ?? [];
403
+ const passing = report.filter((row) => row.ok).length;
404
+
405
+ return (
406
+ <PageContainer title={t("themeEditor.title")} subtitle={t("themeEditor.subtitle")}>
407
+ <Alert tone="info" variant="callout">
408
+ <AlertTitle>{t("themeEditor.order.title")}</AlertTitle>
409
+ <AlertDescription>{t("themeEditor.order.description")}</AlertDescription>
410
+ </Alert>
411
+
412
+ <ResponsiveGrid columns={{ base: 1, lg: 2 }} gap="lg">
413
+ <Flex direction="col" gap="lg">
414
+ <Card accent="primary">
415
+ <CardHeader>
416
+ <CardTitle level={2}>{t("themeEditor.seed.title")}</CardTitle>
417
+ <CardDescription>{t("themeEditor.seed.description")}</CardDescription>
418
+ </CardHeader>
419
+ <CardContent>
420
+ <Flex direction="col" gap="md">
421
+ <FormField
422
+ id="theme-editor-seed"
423
+ label={t("themeEditor.seed.label")}
424
+ helper={t("themeEditor.seed.helper")}
425
+ >
426
+ <ColorPicker id="theme-editor-seed" value={seed} onValueChange={setSeed} />
427
+ </FormField>
428
+
429
+ <FormField
430
+ id="theme-editor-preset"
431
+ label={t("themeEditor.seed.presetLabel")}
432
+ helper={t("themeEditor.seed.presetHelper")}
433
+ >
434
+ <Select
435
+ id="theme-editor-preset"
436
+ value={SEED_PRESETS.find((p) => p.hex === seed)?.id ?? ""}
437
+ placeholder={t("themeEditor.seed.presetPlaceholder")}
438
+ onValueChange={(next: string) => {
439
+ const preset = SEED_PRESETS.find((p) => p.id === next);
440
+ if (!preset) return;
441
+ setSeed(preset.hex);
442
+ setDarkL(null);
443
+ }}
444
+ options={SEED_PRESETS.map((preset) => ({
445
+ value: preset.id,
446
+ label: t(`themeEditor.seed.preset.${preset.id}`),
447
+ sublabel: preset.hex,
448
+ }))}
449
+ />
450
+ </FormField>
451
+
452
+ {/* The live region. It is VISIBLE as well as announced: the same sentence a screen
453
+ reader hears is the one the summary line needs anyway, so there is no hidden
454
+ copy to drift out of sync with what is on screen. */}
455
+ <Text as="div" size="xs" tone="muted" role="status" aria-live="polite">
456
+ {brand && brand.dark
457
+ ? t("themeEditor.seed.announce", {
458
+ hex: toHex(brand.light.seed),
459
+ light: toHex(brand.light.seed),
460
+ dark: toHex(brand.dark.seed),
461
+ pass: passing,
462
+ count: report.length,
463
+ })
464
+ : t("themeEditor.seed.announceNoDark", { hex: seed })}
465
+ </Text>
466
+ </Flex>
467
+ </CardContent>
468
+ </Card>
469
+
470
+ <Card>
471
+ <CardHeader>
472
+ <CardTitle level={2}>{t("themeEditor.derived.title")}</CardTitle>
473
+ <CardDescription>{t("themeEditor.derived.description")}</CardDescription>
474
+ </CardHeader>
475
+ <CardContent flush>
476
+ <DataTable
477
+ columns={roleColumns}
478
+ data={roleRows}
479
+ getRowId={(row) => row.id}
480
+ label={t("themeEditor.derived.tableLabel")}
481
+ />
482
+ </CardContent>
483
+ </Card>
484
+
485
+ <Card>
486
+ <CardHeader>
487
+ <CardTitle level={2}>{t("themeEditor.contrast.title")}</CardTitle>
488
+ <CardDescription>{t("themeEditor.contrast.description")}</CardDescription>
489
+ </CardHeader>
490
+ <CardContent flush>
491
+ <DataTable
492
+ columns={contrastColumns}
493
+ data={report}
494
+ getRowId={(row) => row.id}
495
+ label={t("themeEditor.contrast.tableLabel")}
496
+ />
497
+ </CardContent>
498
+ </Card>
499
+
500
+ <Card>
501
+ <CardHeader>
502
+ <CardTitle level={2}>{t("themeEditor.overrides.title")}</CardTitle>
503
+ <CardDescription>{t("themeEditor.overrides.description")}</CardDescription>
504
+ </CardHeader>
505
+ <CardContent>
506
+ <Flex direction="col" gap="md">
507
+ <Alert tone="warning">
508
+ <AlertTitle>{t("themeEditor.overrides.costTitle")}</AlertTitle>
509
+ <AlertDescription>{t("themeEditor.overrides.costDescription")}</AlertDescription>
510
+ </Alert>
511
+
512
+ <FormField
513
+ id="theme-editor-label"
514
+ label={t("themeEditor.overrides.labelTitle")}
515
+ helper={t("themeEditor.overrides.labelHelper")}
516
+ >
517
+ <Segmented
518
+ id="theme-editor-label"
519
+ aria-label={t("themeEditor.overrides.labelTitle")}
520
+ value={labelMode}
521
+ onValueChange={(next) => setLabelMode(next as LabelMode)}
522
+ options={[
523
+ { value: "auto", label: t("themeEditor.overrides.labelAuto") },
524
+ { value: "light", label: t("themeEditor.overrides.labelLight") },
525
+ { value: "dark", label: t("themeEditor.overrides.labelDark") },
526
+ ]}
527
+ />
528
+ </FormField>
529
+
530
+ <FormField
531
+ id="theme-editor-dark-l"
532
+ label={t("themeEditor.overrides.darkTitle")}
533
+ helper={
534
+ searchedL === null
535
+ ? t("themeEditor.overrides.darkNoParity")
536
+ : t("themeEditor.overrides.darkHelper", {
537
+ parity: percentFormat.format(searchedL),
538
+ })
539
+ }
540
+ >
541
+ <Slider
542
+ id="theme-editor-dark-l"
543
+ aria-label={t("themeEditor.overrides.darkTitle")}
544
+ min={0}
545
+ max={100}
546
+ step={0.1}
547
+ value={darkL ?? searchedL ?? 0}
548
+ onChange={(next: number) => setDarkL(next)}
549
+ tooltip={{ formatter: (value) => percentFormat.format(value) }}
550
+ marks={searchedL === null ? undefined : { [searchedL]: "" }}
551
+ />
552
+ </FormField>
553
+
554
+ <Flex gap="sm" wrap align="center">
555
+ <Button
556
+ variant="outline"
557
+ size="sm"
558
+ disabled={!darkOverridden}
559
+ onClick={() => setDarkL(null)}
560
+ >
561
+ {t("themeEditor.overrides.darkReset")}
562
+ </Button>
563
+ <Badge tone={darkOverridden ? "warning" : "neutral"} variant="outline">
564
+ {t(
565
+ darkOverridden
566
+ ? "themeEditor.overrides.darkAuthored"
567
+ : "themeEditor.overrides.darkParity",
568
+ )}
569
+ </Badge>
570
+ </Flex>
571
+ </Flex>
572
+ </CardContent>
573
+ </Card>
574
+
575
+ <Card>
576
+ <CardHeader>
577
+ <CardTitle level={2}>{t("themeEditor.export.title")}</CardTitle>
578
+ <CardDescription>{t("themeEditor.export.description")}</CardDescription>
579
+ </CardHeader>
580
+ <CardContent>
581
+ <Flex direction="col" gap="md">
582
+ <FormField
583
+ id="theme-editor-name"
584
+ label={t("themeEditor.export.nameLabel")}
585
+ helper={t("themeEditor.export.nameHelper", { name: safeName })}
586
+ >
587
+ <Input id="theme-editor-name" value={name} onValueChange={setName} />
588
+ </FormField>
589
+
590
+ <Flex gap="sm" wrap align="center">
591
+ <Badge tone={command ? "success" : "warning"} variant="outline">
592
+ {t(command ? "themeEditor.export.matches" : "themeEditor.export.diverges")}
593
+ </Badge>
594
+ {command ? (
595
+ <Text mono size="xs" copyable>
596
+ {command}
597
+ </Text>
598
+ ) : (
599
+ <Text size="xs" tone="muted">
600
+ {t("themeEditor.export.divergesWhy")}
601
+ </Text>
602
+ )}
603
+ </Flex>
604
+
605
+ {brand?.css ? (
606
+ <CodeBlock
607
+ language="css"
608
+ maxHeight="md"
609
+ aria-label={t("themeEditor.export.cssLabel", { name: safeName })}
610
+ >
611
+ {brand.css}
612
+ </CodeBlock>
613
+ ) : (
614
+ <Alert tone="destructive">
615
+ <AlertTitle>{t("themeEditor.export.noDarkTitle")}</AlertTitle>
616
+ <AlertDescription>{t("themeEditor.export.noDarkDescription")}</AlertDescription>
617
+ </Alert>
618
+ )}
619
+
620
+ {brand?.email ? (
621
+ <CodeBlock
622
+ language="ts"
623
+ maxHeight="md"
624
+ aria-label={t("themeEditor.export.emailLabel", { name: safeName })}
625
+ >
626
+ {brand.email}
627
+ </CodeBlock>
628
+ ) : null}
629
+ </Flex>
630
+ </CardContent>
631
+ </Card>
632
+ </Flex>
633
+
634
+ <Flex direction="col" gap="lg">
635
+ {brand ? (
636
+ <PreviewPane theme={brand.light} dark={false} idPrefix="theme-editor-light" />
637
+ ) : null}
638
+ {brand?.dark ? (
639
+ <PreviewPane theme={brand.dark} dark idPrefix="theme-editor-dark" />
640
+ ) : null}
641
+ </Flex>
642
+ </ResponsiveGrid>
643
+ </PageContainer>
644
+ );
645
+ }