feat: govern accessible appearance overrides
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import { useRef, useState, type CSSProperties, type ChangeEvent } from "react";
|
||||
import type {
|
||||
AppearanceModeOverrides,
|
||||
AppearanceOverridesDocument,
|
||||
AppearanceOverrideToken,
|
||||
UserUiTheme
|
||||
} from "../types";
|
||||
import Button from "./Button";
|
||||
import ColorPickerField from "./ColorPickerField";
|
||||
import ContentGrid from "./ContentGrid";
|
||||
import DismissibleAlert from "./DismissibleAlert";
|
||||
import FormField from "./FormField";
|
||||
import SegmentedControl from "./SegmentedControl";
|
||||
|
||||
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
|
||||
"accent", "accent_foreground", "surface", "surface_foreground",
|
||||
"success", "success_foreground", "info", "info_foreground",
|
||||
"warning", "warning_foreground", "danger", "danger_foreground"
|
||||
];
|
||||
|
||||
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
|
||||
accent: "i18n:govoplan-core.override_accent",
|
||||
accent_foreground: "i18n:govoplan-core.override_accent_foreground",
|
||||
surface: "i18n:govoplan-core.override_surface",
|
||||
surface_foreground: "i18n:govoplan-core.override_surface_foreground",
|
||||
success: "i18n:govoplan-core.override_success",
|
||||
success_foreground: "i18n:govoplan-core.override_success_foreground",
|
||||
info: "i18n:govoplan-core.override_info",
|
||||
info_foreground: "i18n:govoplan-core.override_info_foreground",
|
||||
warning: "i18n:govoplan-core.override_warning",
|
||||
warning_foreground: "i18n:govoplan-core.override_warning_foreground",
|
||||
danger: "i18n:govoplan-core.override_danger",
|
||||
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
|
||||
};
|
||||
|
||||
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
const RUNTIME_PROPERTIES = new Set<string>();
|
||||
|
||||
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
|
||||
accent: ["--accent", "--action-primary-bg"],
|
||||
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
|
||||
surface: ["--surface", "--panel-soft"],
|
||||
surface_foreground: ["--text", "--text-strong"],
|
||||
success: ["--success-bg", "--success-soft"],
|
||||
success_foreground: ["--success-text", "--success-text-strong"],
|
||||
info: ["--info-bg", "--info-soft"],
|
||||
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
|
||||
warning: ["--warning-bg", "--warning-soft"],
|
||||
warning_foreground: ["--warning-text", "--warning-text-strong"],
|
||||
danger: ["--danger-bg", "--danger-soft"],
|
||||
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
|
||||
};
|
||||
|
||||
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
|
||||
for (const property of properties) RUNTIME_PROPERTIES.add(property);
|
||||
}
|
||||
|
||||
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
|
||||
schema_version: "1",
|
||||
light: {
|
||||
accent: "#245f91", accent_foreground: "#ffffff",
|
||||
surface: "#ffffff", surface_foreground: "#303135",
|
||||
success: "#d8eee8", success_foreground: "#315f55",
|
||||
info: "#dce9f3", info_foreground: "#294a61",
|
||||
warning: "#ffe1a3", warning_foreground: "#593700",
|
||||
danger: "#f8d1cc", danger_foreground: "#873c35"
|
||||
},
|
||||
dark: {
|
||||
accent: "#7ea6c5", accent_foreground: "#242424",
|
||||
surface: "#262724", surface_foreground: "#f1f1f1",
|
||||
success: "#24473f", success_foreground: "#d8eee8",
|
||||
info: "#243d4e", info_foreground: "#dce9f3",
|
||||
warning: "#5a431f", warning_foreground: "#ffe1a3",
|
||||
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
|
||||
}
|
||||
};
|
||||
|
||||
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
|
||||
}
|
||||
|
||||
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
|
||||
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
const document = value as unknown as AppearanceOverridesDocument;
|
||||
for (const modeName of ["light", "dark"] as const) {
|
||||
const mode = document[modeName];
|
||||
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
|
||||
}
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
|
||||
}
|
||||
}
|
||||
for (const [background, foreground] of [
|
||||
["accent", "accent_foreground"], ["surface", "surface_foreground"],
|
||||
["success", "success_foreground"], ["info", "info_foreground"],
|
||||
["warning", "warning_foreground"], ["danger", "danger_foreground"]
|
||||
] as const) {
|
||||
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
|
||||
}
|
||||
}
|
||||
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
|
||||
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
|
||||
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
|
||||
throw new Error("i18n:govoplan-core.appearance_override_status_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
export function applyAppearanceOverrides(
|
||||
root: HTMLElement,
|
||||
document: AppearanceOverridesDocument | null,
|
||||
theme: "light" | "dark"
|
||||
) {
|
||||
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
|
||||
if (!document) return;
|
||||
let validated: AppearanceOverridesDocument;
|
||||
try {
|
||||
validated = validateAppearanceOverrides(document);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const colors = validated[theme];
|
||||
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
|
||||
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
|
||||
root.style.setProperty(property, colors[token]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppearanceOverridesEditor({
|
||||
value,
|
||||
onChange,
|
||||
theme = "system",
|
||||
disabled = false
|
||||
}: {
|
||||
value: AppearanceOverridesDocument | null;
|
||||
onChange: (value: AppearanceOverridesDocument | null) => void;
|
||||
theme?: UserUiTheme;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [mode, setMode] = useState<"light" | "dark">(theme === "dark" ? "dark" : "light");
|
||||
const [message, setMessage] = useState("");
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
const validationMessage = appearanceOverridesValidationMessage(value);
|
||||
|
||||
function updateToken(token: AppearanceOverrideToken, color: string) {
|
||||
const document = value ?? cloneDefaultAppearanceOverrides();
|
||||
onChange({
|
||||
...document,
|
||||
[mode]: { ...document[mode], [token]: color }
|
||||
});
|
||||
}
|
||||
|
||||
function exportDocument() {
|
||||
if (!value) return;
|
||||
const blob = new Blob([`${JSON.stringify(value, null, 2)}\n`], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "govoplan-appearance-overrides-v1.json";
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function importDocument(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const document = validateAppearanceOverrides(JSON.parse(await file.text()));
|
||||
onChange(document);
|
||||
setMessage("i18n:govoplan-core.appearance_override_imported");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="appearance-overrides-editor" aria-label="i18n:govoplan-core.advanced_theme_overrides">
|
||||
<div className="appearance-overrides-actions">
|
||||
<Button onClick={() => onChange(cloneDefaultAppearanceOverrides())} disabled={disabled}>
|
||||
{value ? "i18n:govoplan-core.restore_safe_defaults" : "i18n:govoplan-core.configure_overrides"}
|
||||
</Button>
|
||||
<Button onClick={() => fileInput.current?.click()} disabled={disabled}>i18n:govoplan-core.import_overrides</Button>
|
||||
<Button onClick={exportDocument} disabled={!value}>i18n:govoplan-core.export_overrides</Button>
|
||||
<Button onClick={() => onChange(null)} disabled={!value}>i18n:govoplan-core.remove_overrides</Button>
|
||||
<input ref={fileInput} type="file" accept="application/json,.json" hidden onChange={(event) => void importDocument(event)} />
|
||||
</div>
|
||||
{message && <DismissibleAlert tone={message.endsWith("imported") ? "success" : "warning"} resetKey={message}>{message}</DismissibleAlert>}
|
||||
{!value && <p className="muted small-note">i18n:govoplan-core.appearance_override_not_configured_help</p>}
|
||||
{value && <>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ id: "light" as const, label: "i18n:govoplan-core.light_theme.7878f1fa" },
|
||||
{ id: "dark" as const, label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
]}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
ariaLabel="i18n:govoplan-core.appearance_override_mode"
|
||||
/>
|
||||
<ContentGrid columns={3} collapseAt="workspace" className="appearance-overrides-fields">
|
||||
{APPEARANCE_OVERRIDE_TOKENS.map((token) => (
|
||||
<FormField key={token} label={TOKEN_LABELS[token]}>
|
||||
<ColorPickerField value={value[mode][token]} onChange={(color) => updateToken(token, color)} disabled={disabled} />
|
||||
</FormField>
|
||||
))}
|
||||
</ContentGrid>
|
||||
<AppearanceOverridesPreview mode={mode} colors={value[mode]} />
|
||||
<p className={validationMessage ? "form-error" : "muted small-note"}>
|
||||
{validationMessage || "i18n:govoplan-core.appearance_override_validation_ok"}
|
||||
</p>
|
||||
</>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceOverridesPreview({ mode, colors }: { mode: "light" | "dark"; colors: AppearanceModeOverrides }) {
|
||||
const style = {
|
||||
"--appearance-preview-accent": colors.accent,
|
||||
"--appearance-preview-accent-foreground": colors.accent_foreground,
|
||||
"--appearance-preview-surface": colors.surface,
|
||||
"--appearance-preview-surface-foreground": colors.surface_foreground
|
||||
} as CSSProperties;
|
||||
return (
|
||||
<div className="appearance-overrides-preview" data-mode={mode} style={style}>
|
||||
<strong>GovOPlaN</strong>
|
||||
<button type="button">i18n:govoplan-core.preview_action</button>
|
||||
<div className="appearance-overrides-statuses">
|
||||
{STATUS_TOKENS.map((token) => (
|
||||
<span key={token} style={{ backgroundColor: colors[token], color: colors[`${token}_foreground` as AppearanceOverrideToken] }}>
|
||||
{TOKEN_LABELS[token]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument | null): string {
|
||||
if (!value) return "";
|
||||
try {
|
||||
validateAppearanceOverrides(value);
|
||||
return "";
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function relativeLuminance(color: string): number {
|
||||
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
|
||||
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrastRatio(first: string, second: string): number {
|
||||
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
|
||||
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
|
||||
}
|
||||
|
||||
function rgbDistance(first: string, second: string): number {
|
||||
return Math.sqrt([1, 3, 5].reduce((total, index) => {
|
||||
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
|
||||
return total + delta * delta;
|
||||
}, 0));
|
||||
}
|
||||
Reference in New Issue
Block a user