@godxjp/ui 28.7.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 (85) hide show
  1. package/dist/components/data-display/index.d.ts +2 -0
  2. package/dist/components/data-display/index.js +2 -0
  3. package/dist/components/data-display/marquee.d.ts +16 -0
  4. package/dist/components/data-display/marquee.js +155 -0
  5. package/dist/components/general/reveal.d.ts +23 -2
  6. package/dist/components/general/reveal.js +37 -7
  7. package/dist/components/general/typography.d.ts +4 -1
  8. package/dist/components/general/typography.js +14 -1
  9. package/dist/components/layout/affix.d.ts +86 -0
  10. package/dist/components/layout/affix.js +187 -0
  11. package/dist/components/layout/index.d.ts +4 -0
  12. package/dist/components/layout/index.js +4 -0
  13. package/dist/components/layout/legal-document-shell.js +4 -3
  14. package/dist/components/layout/masonry.d.ts +74 -0
  15. package/dist/components/layout/masonry.js +214 -0
  16. package/dist/components/layout/page-container.js +5 -20
  17. package/dist/components/navigation/anchor.d.ts +64 -0
  18. package/dist/components/navigation/anchor.js +284 -0
  19. package/dist/components/navigation/index.d.ts +4 -0
  20. package/dist/components/navigation/index.js +4 -0
  21. package/dist/components/navigation/mega-menu.d.ts +21 -0
  22. package/dist/components/navigation/mega-menu.js +526 -0
  23. package/dist/contracts/measurement.json +1 -1
  24. package/dist/i18n/messages/en.json +517 -0
  25. package/dist/i18n/messages/ja.json +513 -0
  26. package/dist/i18n/messages/vi.json +513 -0
  27. package/dist/lib/hooks.d.ts +68 -0
  28. package/dist/lib/hooks.js +52 -0
  29. package/dist/lib/platform.d.ts +14 -0
  30. package/dist/lib/platform.js +10 -1
  31. package/dist/lib/utils.d.ts +1 -1
  32. package/dist/lib/utils.js +3 -2
  33. package/dist/props/components/data-display.prop.d.ts +95 -1
  34. package/dist/props/components/general.prop.d.ts +47 -3
  35. package/dist/props/components/layout.prop.d.ts +194 -0
  36. package/dist/props/components/navigation.prop.d.ts +263 -0
  37. package/dist/props/registry.d.ts +359 -4
  38. package/dist/props/registry.js +472 -3
  39. package/dist/props/vocabulary/index.d.ts +1 -1
  40. package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
  41. package/dist/styles/control.css +5 -6
  42. package/dist/styles/data-display-layout.css +2 -1
  43. package/dist/styles/density.css +4 -0
  44. package/dist/styles/layout.css +79 -0
  45. package/dist/styles/motion.css +121 -1
  46. package/dist/styles/navigation-layout.css +397 -1
  47. package/dist/styles/shell-layout.css +3 -0
  48. package/dist/styles/text-layout.css +52 -4
  49. package/dist/tokens/base.css +5 -0
  50. package/dist/tokens/components/affix.css +7 -0
  51. package/dist/tokens/components/anchor.css +17 -0
  52. package/dist/tokens/components/control.css +3 -3
  53. package/dist/tokens/components/form.css +1 -1
  54. package/dist/tokens/components/marquee.css +7 -0
  55. package/dist/tokens/components/masonry.css +6 -0
  56. package/dist/tokens/components/mega-menu.css +62 -0
  57. package/dist/tokens/components/shell.css +3 -0
  58. package/dist/tokens/foundation.css +11 -0
  59. package/dist/tokens/semantic/layout.css +7 -0
  60. package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
  61. package/docs/DESIGN-AUTHORITY.md +99 -18
  62. package/docs/FRAME-COVERAGE-REPORT.md +7 -2
  63. package/docs/data-display/marquee.tsx +254 -0
  64. package/docs/foundation/_theme-editor-scope.ts +222 -0
  65. package/docs/foundation/density.tsx +12 -2
  66. package/docs/foundation/spacing.tsx +5 -0
  67. package/docs/foundation/theme-editor.tsx +645 -0
  68. package/docs/general/activity.tsx +65 -0
  69. package/docs/general/reveal.tsx +290 -22
  70. package/docs/general/typography.tsx +91 -1
  71. package/docs/layout/affix.tsx +209 -0
  72. package/docs/layout/masonry.tsx +291 -0
  73. package/docs/navigation/anchor.tsx +285 -0
  74. package/docs/navigation/mega-menu-panel.tsx +86 -0
  75. package/docs/navigation/mega-menu.tsx +254 -0
  76. package/docs/roadmap/website-components.md +779 -0
  77. package/docs/showcase/acme-website.tsx +75 -39
  78. package/docs/showcase/futurelastic-web.tsx +91 -49
  79. package/docs/showcase/marketing-page.tsx +885 -0
  80. package/docs/showcase/table-footer-totals.tsx +12 -2
  81. package/docs/showcase/theme-customization.tsx +1259 -0
  82. package/package.json +5 -3
  83. package/scripts/brand-accent.generated.mjs +27 -0
  84. package/scripts/ui-audit.mjs +66 -0
  85. package/scripts/visual-audit-rules.mjs +46 -2
@@ -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
+ }