feat(t5): neue Farbwerte, Dunkel für alle, Farb-Codemod (T5a A4)
- globals.css: Hell, Dunkel und Druck mit den Spec-Werten aus 4.2, Fokusring = --primary-ink, Verlaufs-Token entfernt. - Codemod scripts/codemod-t5-colors.mjs: text-primary/-warning auf -ink, Vordergrund nur auf voller Fläche, Auswahlrahmen dunkel; --check-Modus und Spec halten src sauber. - Kontrast-Wächter misst die echte globals.css (VALUES_SWITCHED), alle 21 Zeilen aus 4.4 in allen vier Sätzen; keine dark:-Klassen mehr in src. - Dunkel ist ohne Entwickler-Schalter wählbar, Standard bleibt Hell. - Review-Nacharbeit: Auswahl-Umrisse und aktueller Schritt in Vordergrund, deaktivierte Felder bg-muted, Avatar ohne Blend, Honig-Check-Rahmen über --input. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6edae3efec
commit
d8238855fc
91 files changed
+673
-391
No files matched your search
@@ -18,7 +18,7 @@ export const APPEARANCE_KEYS = {
|
||||
export type ThemeChoice = "light" | "dark" | "system";
|
||||
export type ContrastChoice = "normal" | "sun";
|
||||
|
||||
/** Colour scheme on a device that never chose one (open question 3, answer A). */
|
||||
/** Colour scheme on a device that never chose one (open question 3, answer A: dark only on request). */
|
||||
export const DEFAULT_THEME: ThemeChoice = "light";
|
||||
|
||||
/** Fired on window after every change of colour scheme or contrast (the 3D scene listens). */
|
||||
@@ -44,8 +44,6 @@ export interface StoredAppearance {
|
||||
export interface AppearanceEnvironment {
|
||||
prefersDark: boolean;
|
||||
prefersMore: boolean;
|
||||
/** Dev flag NEXT_PUBLIC_APPEARANCE_DARK=1; removed with task A4. */
|
||||
darkEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAppearance {
|
||||
@@ -73,7 +71,7 @@ export function resolveAppearance(
|
||||
const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME;
|
||||
const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark);
|
||||
return {
|
||||
dark: env.darkEnabled && wantsDark,
|
||||
dark: wantsDark,
|
||||
sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore,
|
||||
motion: isMotionLevel(stored.motion) ? stored.motion : "full",
|
||||
};
|
||||
@@ -88,17 +86,17 @@ export function themeColorOf({ dark, sun }: Pick<ResolvedAppearance, "dark" | "s
|
||||
* Source of the blocking inline script (an IIFE, ES5 so it parses everywhere).
|
||||
* Only constants go in; nothing from the request or from storage is embedded.
|
||||
*/
|
||||
export function appearanceScript(darkEnabled: boolean): string {
|
||||
export function appearanceScript(): string {
|
||||
const keys = JSON.stringify(APPEARANCE_KEYS);
|
||||
const colors = JSON.stringify(THEME_COLORS);
|
||||
return `(function(){try{
|
||||
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)},DARK=${darkEnabled ? "true" : "false"};
|
||||
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)};
|
||||
var w=window,root=document.documentElement;
|
||||
function get(k){try{return w.localStorage.getItem(k)}catch(e){return null}}
|
||||
function mq(q){try{return w.matchMedia(q).matches}catch(e){return false}}
|
||||
var t=get(K.theme),c=get(K.contrast),m=get(K.motion);
|
||||
if(t!=="light"&&t!=="dark"&&t!=="system")t=DEF;
|
||||
var dark=DARK&&(t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)})));
|
||||
var dark=t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)}));
|
||||
var sun=c==="sun"?true:c==="normal"?false:mq(${JSON.stringify(MORE_CONTRAST_QUERY)});
|
||||
if(m!=="full"&&m!=="subtle"&&m!=="off")m="full";
|
||||
root.classList.toggle("dark",dark);
|
||||
|
||||
@@ -17,7 +17,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveAppearance", () => {
|
||||
const env = { prefersDark: false, prefersMore: false, darkEnabled: true };
|
||||
const env = { prefersDark: false, prefersMore: false };
|
||||
|
||||
it("defaults to light", () => {
|
||||
expect(DEFAULT_THEME).toBe("light");
|
||||
@@ -39,8 +39,9 @@ describe("resolveAppearance", () => {
|
||||
expect(resolveAppearance({ contrast: "sun" }, env).sun).toBe(true);
|
||||
});
|
||||
|
||||
it("dark is ignored without the dev flag", () => {
|
||||
expect(resolveAppearance({ theme: "dark" }, { ...env, darkEnabled: false }).dark).toBe(false);
|
||||
it("dark is selectable without a flag and only on request", () => {
|
||||
expect(resolveAppearance({ theme: "dark" }, env).dark).toBe(true);
|
||||
expect(resolveAppearance({}, { ...env, prefersDark: true }).dark).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores garbage values", () => {
|
||||
@@ -65,7 +66,6 @@ describe("useAppearance", () => {
|
||||
});
|
||||
|
||||
it("system follows matchMedia and reacts to a change", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
window.localStorage.setItem("apicore.theme", "system");
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
@@ -120,7 +120,6 @@ describe("useAppearance", () => {
|
||||
});
|
||||
|
||||
it("a storage event from another tab is picked up", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
|
||||
act(() => {
|
||||
@@ -132,13 +131,13 @@ describe("useAppearance", () => {
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("dark is ignored without the dev flag", () => {
|
||||
window.localStorage.setItem("apicore.theme", "dark");
|
||||
it("dark is selectable without a flag", () => {
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
act(() => result.current.setTheme("dark"));
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
expect(document.documentElement).not.toHaveClass("dark");
|
||||
expect(result.current.resolvedTheme).toBe("dark");
|
||||
expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
|
||||
|
||||
@@ -37,11 +37,6 @@ export interface Appearance {
|
||||
setMotion(value: MotionLevel): void;
|
||||
}
|
||||
|
||||
/** Whether "Dunkel" may be applied at all (until task A4 brings readable dark values). */
|
||||
export function isDarkEnabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1";
|
||||
}
|
||||
|
||||
/** Values chosen in this visit; used when localStorage is blocked (private mode). */
|
||||
const session: { theme?: string; contrast?: string } = {};
|
||||
|
||||
@@ -84,7 +79,7 @@ function readSnapshot(): Snapshot {
|
||||
|
||||
/** Same effect as the inline script, for changes after the first paint. */
|
||||
function applyToDocument(snapshot: Snapshot): void {
|
||||
const { dark, sun } = resolveAppearance(snapshot, { ...snapshot, darkEnabled: isDarkEnabled() });
|
||||
const { dark, sun } = resolveAppearance(snapshot, snapshot);
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", dark);
|
||||
root.setAttribute("data-contrast", sun ? "sun" : "normal");
|
||||
@@ -168,7 +163,7 @@ export function useAppearance(): Appearance {
|
||||
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const state = useMemo(() => {
|
||||
const parsed = JSON.parse(raw) as Snapshot & { motion: MotionLevel };
|
||||
const resolved = resolveAppearance(parsed, { ...parsed, darkEnabled: isDarkEnabled() });
|
||||
const resolved = resolveAppearance(parsed, parsed);
|
||||
return {
|
||||
theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME,
|
||||
contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const),
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AREAS, type AreaKey } from "./areas";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Boxes,
|
||||
Bug,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
Crown,
|
||||
Database,
|
||||
Droplet,
|
||||
Factory,
|
||||
FlaskConical,
|
||||
Flower2,
|
||||
History,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
MapPin,
|
||||
Package,
|
||||
Scissors,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Snowflake,
|
||||
Sprout,
|
||||
Syringe,
|
||||
Tag,
|
||||
UserRound,
|
||||
Users,
|
||||
Utensils,
|
||||
Warehouse,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { AREAS, type AreaKey, type AreaTone } from "./areas";
|
||||
import { ADMIN_GROUP, NAV_GROUPS } from "@/app/(app)/nav-config";
|
||||
import { QUICK_ACTIONS } from "@/app/(app)/quick-actions";
|
||||
|
||||
@@ -53,3 +88,52 @@ describe("AREAS", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Spec 11, table "Icon und Farbe je Bereich": the 32 areas of the spec, row by row. */
|
||||
const SPEC_AREAS: ReadonlyArray<[AreaKey, LucideIcon, AreaTone]> = [
|
||||
["dashboard", LayoutDashboard, "neutral"],
|
||||
["search", Search, "neutral"],
|
||||
["apiaries", MapPin, "betrieb"],
|
||||
["colonies", Layers, "betrieb"],
|
||||
["queens", Crown, "betrieb"],
|
||||
["winter", Snowflake, "betrieb"],
|
||||
["phenology", Flower2, "betrieb"],
|
||||
["inspections", ClipboardCheck, "erfassung"],
|
||||
["varroa", Bug, "erfassung"],
|
||||
["swarm-control", Sprout, "erfassung"],
|
||||
["treatments", Syringe, "erfassung"],
|
||||
["feedings", Utensils, "erfassung"],
|
||||
["tasks", ListTodo, "arbeit"],
|
||||
["calendar", CalendarDays, "arbeit"],
|
||||
["suggestions", Lightbulb, "arbeit"],
|
||||
["notifications", Bell, "arbeit"],
|
||||
["harvests", Scissors, "ernte"],
|
||||
["extractions", FlaskConical, "ernte"],
|
||||
["honey-batches", Droplet, "ernte"],
|
||||
["bottling", Package, "ernte"],
|
||||
["products", ShoppingBag, "ernte"],
|
||||
["raw-materials", Boxes, "ernte"],
|
||||
["production-batches", Factory, "ernte"],
|
||||
["storage", Warehouse, "ernte"],
|
||||
["equipment", Wrench, "ernte"],
|
||||
["statistics", BarChart3, "neutral"],
|
||||
["users-roles", Users, "neutral"],
|
||||
["automation", Zap, "neutral"],
|
||||
["label-templates", Tag, "neutral"],
|
||||
["backup", Database, "neutral"],
|
||||
["audit-log", History, "neutral"],
|
||||
["profile", UserRound, "neutral"],
|
||||
];
|
||||
|
||||
describe("AREAS against spec 11", () => {
|
||||
it("lists all 32 rows of the spec table", () => {
|
||||
expect(SPEC_AREAS).toHaveLength(32);
|
||||
expect(new Set(SPEC_AREAS.map(([key]) => key)).size).toBe(32);
|
||||
});
|
||||
|
||||
it.each(SPEC_AREAS)("%s has the icon and tone of the spec table", (key, icon, tone) => {
|
||||
expect(AREAS[key].key).toBe(key);
|
||||
expect(AREAS[key].icon).toBe(icon);
|
||||
expect(AREAS[key].tone).toBe(tone);
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,11 @@ export const COLONY_CONDITION_LABELS: Record<ColonyCondition, string> = {
|
||||
|
||||
// Tailwind-Klassen auf Basis der --condition-* Tokens aus globals.css (hell/dunkel-faehig).
|
||||
export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
|
||||
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-foreground",
|
||||
GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",
|
||||
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-foreground",
|
||||
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-foreground",
|
||||
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-foreground",
|
||||
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-ink",
|
||||
GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",
|
||||
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-ink",
|
||||
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-ink",
|
||||
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-ink",
|
||||
};
|
||||
|
||||
/** Volksstärke 1-10 (besetzte Waben), as select values named by themselves. */
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MIN_CONTRAST = 4.5;
|
||||
const TONES = ["critical", "weak", "average", "good", "excellent"] as const;
|
||||
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
|
||||
|
||||
function block(selector: string): string {
|
||||
const start = css.indexOf(`${selector} {`);
|
||||
return css.slice(start, css.indexOf("\n}", start));
|
||||
}
|
||||
|
||||
function oklch(block: string, token: string): [number, number, number] {
|
||||
const match = new RegExp(`--${token}:\\s*oklch\\(([\\d.]+) ([\\d.]+) ([\\d.]+)\\)`).exec(block);
|
||||
if (!match) throw new Error(`token ${token} not found`);
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
/** oklch -> linear sRGB (Ottosson), clipped to gamut. */
|
||||
function linearRgb([l, c, h]: [number, number, number]): number[] {
|
||||
const a = c * Math.cos((h * Math.PI) / 180);
|
||||
const b = c * Math.sin((h * Math.PI) / 180);
|
||||
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
return [
|
||||
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
||||
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
||||
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
||||
].map((channel) => Math.min(1, Math.max(0, channel)));
|
||||
}
|
||||
|
||||
function luminance(color: [number, number, number]): number {
|
||||
const [r, g, b] = linearRgb(color);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: [number, number, number], b: [number, number, number]): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["light", ":root"],
|
||||
["dark", ".dark"],
|
||||
])("condition tones in %s mode", (_mode, selector) => {
|
||||
it.each(TONES)(`%s: text on tone is at least ${MIN_CONTRAST}:1`, (tone) => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(
|
||||
oklch(scope, `condition-${tone}`),
|
||||
oklch(scope, `condition-${tone}-foreground`),
|
||||
);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["light", ":root"],
|
||||
["dark", ".dark"],
|
||||
])("hive label in %s mode", (_mode, selector) => {
|
||||
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
|
||||
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user