@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.
- package/dist/components/data-display/index.d.ts +2 -0
- package/dist/components/data-display/index.js +2 -0
- package/dist/components/data-display/marquee.d.ts +16 -0
- package/dist/components/data-display/marquee.js +155 -0
- package/dist/components/general/reveal.d.ts +23 -2
- package/dist/components/general/reveal.js +37 -7
- package/dist/components/general/typography.d.ts +4 -1
- package/dist/components/general/typography.js +14 -1
- package/dist/components/layout/affix.d.ts +86 -0
- package/dist/components/layout/affix.js +187 -0
- package/dist/components/layout/index.d.ts +4 -0
- package/dist/components/layout/index.js +4 -0
- package/dist/components/layout/legal-document-shell.js +4 -3
- package/dist/components/layout/masonry.d.ts +74 -0
- package/dist/components/layout/masonry.js +214 -0
- package/dist/components/layout/page-container.js +5 -20
- package/dist/components/navigation/anchor.d.ts +64 -0
- package/dist/components/navigation/anchor.js +284 -0
- package/dist/components/navigation/index.d.ts +4 -0
- package/dist/components/navigation/index.js +4 -0
- package/dist/components/navigation/mega-menu.d.ts +21 -0
- package/dist/components/navigation/mega-menu.js +526 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +517 -0
- package/dist/i18n/messages/ja.json +513 -0
- package/dist/i18n/messages/vi.json +513 -0
- package/dist/lib/hooks.d.ts +68 -0
- package/dist/lib/hooks.js +52 -0
- package/dist/lib/platform.d.ts +14 -0
- package/dist/lib/platform.js +10 -1
- package/dist/lib/utils.d.ts +1 -1
- package/dist/lib/utils.js +3 -2
- package/dist/props/components/data-display.prop.d.ts +95 -1
- package/dist/props/components/general.prop.d.ts +47 -3
- package/dist/props/components/layout.prop.d.ts +194 -0
- package/dist/props/components/navigation.prop.d.ts +263 -0
- package/dist/props/registry.d.ts +359 -4
- package/dist/props/registry.js +472 -3
- package/dist/props/vocabulary/index.d.ts +1 -1
- package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
- package/dist/styles/control.css +5 -6
- package/dist/styles/data-display-layout.css +2 -1
- package/dist/styles/density.css +4 -0
- package/dist/styles/layout.css +79 -0
- package/dist/styles/motion.css +121 -1
- package/dist/styles/navigation-layout.css +397 -1
- package/dist/styles/shell-layout.css +3 -0
- package/dist/styles/text-layout.css +52 -4
- package/dist/tokens/base.css +5 -0
- package/dist/tokens/components/affix.css +7 -0
- package/dist/tokens/components/anchor.css +17 -0
- package/dist/tokens/components/control.css +3 -3
- package/dist/tokens/components/form.css +1 -1
- package/dist/tokens/components/marquee.css +7 -0
- package/dist/tokens/components/masonry.css +6 -0
- package/dist/tokens/components/mega-menu.css +62 -0
- package/dist/tokens/components/shell.css +3 -0
- package/dist/tokens/foundation.css +11 -0
- package/dist/tokens/semantic/layout.css +7 -0
- package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
- package/docs/DESIGN-AUTHORITY.md +99 -18
- package/docs/FRAME-COVERAGE-REPORT.md +7 -2
- package/docs/data-display/marquee.tsx +254 -0
- package/docs/foundation/_theme-editor-scope.ts +222 -0
- package/docs/foundation/density.tsx +12 -2
- package/docs/foundation/spacing.tsx +5 -0
- package/docs/foundation/theme-editor.tsx +645 -0
- package/docs/general/activity.tsx +65 -0
- package/docs/general/reveal.tsx +290 -22
- package/docs/general/typography.tsx +91 -1
- package/docs/layout/affix.tsx +209 -0
- package/docs/layout/masonry.tsx +291 -0
- package/docs/navigation/anchor.tsx +285 -0
- package/docs/navigation/mega-menu-panel.tsx +86 -0
- package/docs/navigation/mega-menu.tsx +254 -0
- package/docs/roadmap/website-components.md +779 -0
- package/docs/showcase/acme-website.tsx +75 -39
- package/docs/showcase/futurelastic-web.tsx +91 -49
- package/docs/showcase/marketing-page.tsx +885 -0
- package/docs/showcase/table-footer-totals.tsx +12 -2
- package/docs/showcase/theme-customization.tsx +1259 -0
- package/package.json +5 -3
- package/scripts/brand-accent.generated.mjs +27 -0
- package/scripts/ui-audit.mjs +66 -0
- 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
|
+
}
|