import { useEffect, useMemo, useState } from "react"; import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, CodeBlock, DataTable, Swatch, type ColumnDef, } from "@godxjp/ui/data-display"; import { Checkbox, ColorPicker, Field, FormField, Input, Segmented, Select, Slider, Switch, } from "@godxjp/ui/data-entry"; import { Alert, AlertDescription, AlertTitle } from "@godxjp/ui/feedback"; import { Button, Text } from "@godxjp/ui/general"; import { useTranslation } from "@godxjp/ui/i18n"; import { Flex, PageContainer, ResponsiveGrid } from "@godxjp/ui/layout"; import { SEED_PRESETS, brandFor, genBrandCommand, parseHex, previewScope, readThemeSpine, toHex, type Brand, type BrandTheme, type ContrastRow, type ThemeSpine, } from "./_theme-editor-scope"; /** * Theme editor · one seed colour in, a complete brand out, MEASURED rather than asserted. * * THE PAGE IS THE HIERARCHY. `docs/CUSTOMER-THEMING.md` opens with a priority order — seed, role, * scope, instance — and says the point of the order is that you stop at the first level that does * the job. So the seed is one control at the top, the roles it produces are READ-ONLY underneath * it (the causality has to be visible, not asserted), and the two per-token overrides sit below * both, after the sentence that says what they cost. Ant Design reaches the same conclusion from * the other end: "In most cases, using Seed Tokens is sufficient for custom themes". * * THE EXPORT IS `gen:brand`'s, NOT A SECOND OPINION. Both call `deriveBrand` * (src/tokens/__tests__/brand-derivation.ts), so for the same hex the CSS below is byte for byte * what `pnpm gen:brand '#RRGGBB'` writes — including the three tokens per theme it emits and the * four it refuses to, because those derive from the `--primary` in scope and a literal would pin * them (gh#678). The one input that has no CLI flag is the authored dark lightness, and when it is * used the page stops printing a command rather than print one that emits something else. * * THE PREVIEW IS SCOPED, NOT ROOTED, and that is the freeze rule doing real work. Each pane * declares the seed on ITSELF, which is the only way two seeds can be on screen at once; what a * pane must declare, and why `--ring` is one of them, is documented at `previewScope`. * * NO CHART. The `--chart-*` series are decorative primitives on purpose and do not derive from the * seed, so a chart here would move for no reason a reader could learn anything from. * * Composed only from real @godxjp/ui components. */ type LabelMode = "auto" | "light" | "dark"; /** `gen:brand --foreground` takes the LABEL colour, so `light` is a white label on the fill. */ const FORCED_LABEL: Record = { auto: null, light: "#ffffff", dark: "#000000", }; type RoleRow = { id: string; token: string; light: string; dark: string; emitted: boolean; }; type LineRow = { id: string; item: string; owner: string; state: "active" | "pending" }; const PREVIEW_LINES: LineRow[] = [ { id: "INV-2041", item: "themeEditor.preview.line.licence", owner: "OP-14", state: "active" }, { id: "INV-2042", item: "themeEditor.preview.line.support", owner: "OP-03", state: "pending" }, { id: "INV-2043", item: "themeEditor.preview.line.setup", owner: "OP-14", state: "active" }, ]; /** * ONE PREVIEW, RENDERED TWICE — once plain and once under `.dark`, each with its own seed on * ITSELF. The dark seed is searched for contrast parity with the light one, so an editor that * showed only light would hide the half that is hard. */ function PreviewPane({ theme, dark, idPrefix, }: { theme: BrandTheme; dark: boolean; idPrefix: string; }) { const { t } = useTranslation(); const columns: ColumnDef[] = [ { key: "id", header: t("themeEditor.preview.column.id"), width: "w-28" }, { key: "item", header: t("themeEditor.preview.column.item"), render: (row) => t(row.item) }, { key: "owner", header: t("themeEditor.preview.column.owner") }, { key: "state", header: t("themeEditor.preview.column.state"), render: (row) => ( {t(`themeEditor.preview.state.${row.state}`)} ), }, ]; return ( {t(dark ? "themeEditor.preview.darkTitle" : "themeEditor.preview.lightTitle")} {t("themeEditor.preview.seedIs", { hex: toHex(theme.seed) })} {t("themeEditor.preview.badge.brand")} {t("themeEditor.preview.badge.success")} {t("themeEditor.preview.badge.warning")} {t("themeEditor.preview.badge.destructive")} {t("themeEditor.preview.badge.info")} {t("themeEditor.preview.badge.neutral")} {t("themeEditor.preview.alert.title")} {t("themeEditor.preview.alert.description")}{" "} {t("themeEditor.preview.alert.link")} p.hex === seed)?.id ?? ""} placeholder={t("themeEditor.seed.presetPlaceholder")} onValueChange={(next: string) => { const preset = SEED_PRESETS.find((p) => p.id === next); if (!preset) return; setSeed(preset.hex); setDarkL(null); }} options={SEED_PRESETS.map((preset) => ({ value: preset.id, label: t(`themeEditor.seed.preset.${preset.id}`), sublabel: preset.hex, }))} /> {/* The live region. It is VISIBLE as well as announced: the same sentence a screen reader hears is the one the summary line needs anyway, so there is no hidden copy to drift out of sync with what is on screen. */} {brand && brand.dark ? t("themeEditor.seed.announce", { hex: toHex(brand.light.seed), light: toHex(brand.light.seed), dark: toHex(brand.dark.seed), pass: passing, count: report.length, }) : t("themeEditor.seed.announceNoDark", { hex: seed })} {t("themeEditor.derived.title")} {t("themeEditor.derived.description")} row.id} label={t("themeEditor.derived.tableLabel")} /> {t("themeEditor.contrast.title")} {t("themeEditor.contrast.description")} row.id} label={t("themeEditor.contrast.tableLabel")} /> {t("themeEditor.overrides.title")} {t("themeEditor.overrides.description")} {t("themeEditor.overrides.costTitle")} {t("themeEditor.overrides.costDescription")} setLabelMode(next as LabelMode)} options={[ { value: "auto", label: t("themeEditor.overrides.labelAuto") }, { value: "light", label: t("themeEditor.overrides.labelLight") }, { value: "dark", label: t("themeEditor.overrides.labelDark") }, ]} /> setDarkL(next)} tooltip={{ formatter: (value) => percentFormat.format(value) }} marks={searchedL === null ? undefined : { [searchedL]: "" }} /> {t( darkOverridden ? "themeEditor.overrides.darkAuthored" : "themeEditor.overrides.darkParity", )} {t("themeEditor.export.title")} {t("themeEditor.export.description")} {t(command ? "themeEditor.export.matches" : "themeEditor.export.diverges")} {command ? ( {command} ) : ( {t("themeEditor.export.divergesWhy")} )} {brand?.css ? ( {brand.css} ) : ( {t("themeEditor.export.noDarkTitle")} {t("themeEditor.export.noDarkDescription")} )} {brand?.email ? ( {brand.email} ) : null} {brand ? ( ) : null} {brand?.dark ? ( ) : null} ); }