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:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 17:23:21 +02:00
1 parent 6edae3efec
commit d8238855fc
91 files changed
+673 -391

No files matched your search

@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* T5 colour codemod (spec 4.2 and 10.8 step 3). Pure and idempotent:
*
* - `text-primary` -> `text-primary-ink` (honey is a fill, the ink is the text colour);
* `text-primary-foreground`, `text-primary/NN`, `bg-primary…` stay.
* - `text-warning` -> `text-warning-ink`.
* - `text-warning-foreground` and `text-condition-x-foreground` -> `-ink`, unless the same line
* puts the text on the solid surface (`bg-warning`, `bg-condition-x` without `/NN`): on a tint
* or on the card only the ink reaches 4.5:1 in every set.
* - `border-primary` -> `border-foreground` where the line, or one of the two lines above it,
* names a selection state (checked, pressed, selected); `border-primary/NN` stays.
*
* CLI: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>
* --check lists the files that would change and exits 1 if there are any.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Start of a class token: line start, whitespace, quote, backtick, `{`, `(` or a variant `:`. */
const BEFORE = String.raw`(?<=^|[\s"'\`{(:!])`;
/** End of a class token: no further name characters, no `/NN` opacity, no `-suffix`. */
const AFTER = String.raw`(?![\w/-])`;
const TONES = String.raw`(?:warning|condition-(?:excellent|good|average|weak|critical))`;
const SELECTION_STATE = /checked|pressed|selected/;
const SELECTION_CONTEXT_LINES = 2;
const TEXT_PRIMARY = new RegExp(`${BEFORE}text-primary${AFTER}`, "g");
const TEXT_WARNING = new RegExp(`${BEFORE}text-warning${AFTER}`, "g");
const TONE_FOREGROUND = new RegExp(`${BEFORE}text-(${TONES})-foreground${AFTER}`, "g");
const BORDER_PRIMARY = new RegExp(`${BEFORE}border-primary${AFTER}`, "g");
function hasSolidSurface(line, tone) {
return new RegExp(`${BEFORE}bg-${tone}${AFTER}`).test(line);
}
function transformLine(line, context) {
let out = line
.replace(TEXT_PRIMARY, "text-primary-ink")
.replace(TEXT_WARNING, "text-warning-ink")
.replace(TONE_FOREGROUND, (match, tone) => (hasSolidSurface(line, tone) ? match : `text-${tone}-ink`));
if (SELECTION_STATE.test(context)) out = out.replace(BORDER_PRIMARY, "border-foreground");
return out;
}
/** Applies the colour rules to one source text; line endings are kept. */
export function transform(source) {
const lines = source.split("\n");
return lines
.map((line, index) => {
const context = lines.slice(Math.max(0, index - SELECTION_CONTEXT_LINES), index + 1).join("\n");
return transformLine(line, context);
})
.join("\n");
}
function sourceFiles(path) {
if (statSync(path).isDirectory()) {
return readdirSync(path).flatMap((name) => sourceFiles(join(path, name)));
}
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
}
function main(args) {
const check = args.includes("--check");
const targets = args.filter((arg) => arg !== "--check");
if (targets.length === 0) {
console.error("usage: node scripts/codemod-t5-colors.mjs [--check] <file or directory…>");
return 2;
}
const changed = targets.flatMap(sourceFiles).filter((file) => {
const before = readFileSync(file, "utf8");
const after = transform(before);
if (after === before) return false;
if (!check) writeFileSync(file, after);
return true;
});
for (const file of changed) console.log(file);
console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`);
return check && changed.length > 0 ? 1 : 0;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
process.exitCode = main(process.argv.slice(2));
}
@@ -0,0 +1,98 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { transform } from "./codemod-t5-colors.mjs";
describe("codemod t5 colours (spec 10.8 step 3)", () => {
it("text-primary becomes text-primary-ink", () => {
expect(transform('<a className="text-sm text-primary hover:underline">')).toBe(
'<a className="text-sm text-primary-ink hover:underline">',
);
expect(transform('cn("data-active:text-primary", x)')).toBe('cn("data-active:text-primary-ink", x)');
});
it("text-primary-foreground, bg-primary and text-primary/80 stay unchanged", () => {
const source = 'className="bg-primary text-primary-foreground text-primary/80 text-primary/[0.07] bg-primary/10"';
expect(transform(source)).toBe(source);
});
it("text-warning becomes text-warning-ink but text-warning-foreground stays on a solid warning surface", () => {
expect(transform('<AlertTriangle className="size-4 text-warning" />')).toBe(
'<AlertTriangle className="size-4 text-warning-ink" />',
);
const solid = 'className="bg-warning text-warning-foreground"';
expect(transform(solid)).toBe(solid);
});
it("warning text on a tint or on the card becomes the ink", () => {
expect(transform('"bg-warning/15 text-warning-foreground"')).toBe('"bg-warning/15 text-warning-ink"');
expect(transform('<span className="text-warning-foreground">')).toBe('<span className="text-warning-ink">');
});
it("badge classes with text-condition-good-foreground become -ink", () => {
expect(transform('GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",')).toBe(
'GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",',
);
expect(transform('<CheckCircle2 className="size-5 text-condition-critical-foreground" />')).toBe(
'<CheckCircle2 className="size-5 text-condition-critical-ink" />',
);
});
it("text on a solid condition surface keeps its foreground", () => {
const solid = '4: "border-condition-good bg-condition-good text-condition-good-foreground",';
expect(transform(solid)).toBe(solid);
});
it("border-primary on a selection state becomes border-foreground", () => {
expect(transform('"data-checked:border-primary data-checked:bg-primary"')).toBe(
'"data-checked:border-foreground data-checked:bg-primary"',
);
expect(transform('className="has-[:checked]:border-primary has-[:checked]:bg-primary/10"')).toBe(
'className="has-[:checked]:border-foreground has-[:checked]:bg-primary/10"',
);
// The state may sit on the line(s) just above the class string.
expect(transform('selected &&\n "border-primary bg-primary font-medium",')).toBe(
'selected &&\n "border-foreground bg-primary font-medium",',
);
});
it("border-primary without a selection state and translucent borders stay", () => {
const plain = 'className={ok ? "rounded-lg border border-primary bg-primary/5" : "border-destructive"}';
expect(transform(plain)).toBe(plain);
const translucent = 'className={selected ? "border-primary/40" : ""}';
expect(transform(translucent)).toBe(translucent);
});
it("keeps CRLF line endings", () => {
expect(transform('a\r\nclassName="text-primary"\r\n')).toBe('a\r\nclassName="text-primary-ink"\r\n');
});
it("is idempotent", () => {
const source = [
'className="text-primary text-warning bg-warning/15 text-warning-foreground"',
'checked ? "border-primary bg-primary/10" : "border-border"',
'"bg-condition-weak/15 text-condition-weak-foreground"',
].join("\n");
const once = transform(source);
expect(once).not.toBe(source);
expect(transform(once)).toBe(once);
});
it("leaves nothing to migrate in src", () => {
const files = sourceFiles(join(process.cwd(), "src"));
expect(files.length).toBeGreaterThan(100);
const pending = files.filter((file) => {
const text = readFileSync(file, "utf8");
return transform(text) !== text;
});
expect(pending).toEqual([]);
});
});
function sourceFiles(dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
});
}
@@ -126,8 +126,8 @@ export function WeatherCard({
<div <div
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${ className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
todaySuitability todaySuitability
? "border-primary/30 bg-primary/5 text-primary" ? "border-primary/30 bg-primary/5 text-primary-ink"
: "border-warning/40 bg-warning/10 text-warning-foreground" : "border-warning/40 bg-warning/10 text-warning-ink"
}`} }`}
> >
{todaySuitability ? ( {todaySuitability ? (
@@ -155,7 +155,7 @@ export function WeatherCard({
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"} {day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
</span> </span>
{day.precipitationMm !== null && day.precipitationMm > 0 ? ( {day.precipitationMm !== null && day.precipitationMm > 0 ? (
<span className="text-xs text-primary"> <span className="text-xs text-primary-ink">
{day.precipitationMm.toFixed(0)} mm {day.precipitationMm.toFixed(0)} mm
</span> </span>
) : null} ) : null}
@@ -26,7 +26,7 @@ const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht."; const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden."; const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
const LINK_CLASS = const LINK_CLASS =
"inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring"; "inline-flex min-h-11 items-center rounded-md text-sm font-medium text-primary-ink underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-ring";
type FixTarget = "master" | "product" | "designer"; type FixTarget = "master" | "product" | "designer";
@@ -301,7 +301,7 @@ export function PrintLabelsCard({
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{warnings.map((warning, index) => ( {warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm"> <li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" /> <TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
<span> <span>
<span className="font-medium">Hinweis:</span> {warning.title} <span className="font-medium">Hinweis:</span> {warning.title}
</span> </span>
@@ -452,14 +452,14 @@ export default function NewBottlingBatchPage() {
/> />
{withdrawalCheckFailed ? ( {withdrawalCheckFailed ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm"> <p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
Wartezeit konnte nicht geprüft werden Wartezeit konnte nicht geprüft werden
</p> </p>
) : null} ) : null}
{withdrawalHints.length > 0 ? ( {withdrawalHints.length > 0 ? (
<div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm"> <div className="flex flex-col gap-1.5 rounded-lg bg-warning/15 p-3 text-sm">
<p className="flex items-center gap-2 font-medium"> <p className="flex items-center gap-2 font-medium">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
Honig aus einer Wartezeit Honig aus einer Wartezeit
</p> </p>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
@@ -28,7 +28,7 @@ export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
className={cn( className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs", "flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isActive ? "text-primary" : "text-muted-foreground", isActive ? "text-primary-ink" : "text-muted-foreground",
)} )}
> >
<Icon className="size-5" aria-hidden="true" /> <Icon className="size-5" aria-hidden="true" />
@@ -355,7 +355,7 @@ export default function CalendarPage() {
className={cn( className={cn(
"flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0", "flex min-h-24 flex-col gap-1 border-r border-b p-1.5 text-left last:border-r-0",
isCurrentMonth ? "bg-card" : "bg-muted/40", isCurrentMonth ? "bg-card" : "bg-muted/40",
isSelected && "outline-2 -outline-offset-2 outline-primary", isSelected && "outline-2 -outline-offset-2 outline-foreground",
)} )}
> >
<span <span
@@ -209,7 +209,7 @@ function VarroaTrendChart({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<svg <svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`} viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full text-primary" className="w-full text-primary-ink"
role="img" role="img"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={descId} aria-describedby={descId}
@@ -293,7 +293,7 @@ function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolea
return ( return (
<ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"> <ul aria-label="Legende" className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<li className="flex items-center gap-1.5"> <li className="flex items-center gap-1.5">
<span className="text-primary"> <span className="text-primary-ink">
<LegendSwatch width={2} /> <LegendSwatch width={2} />
</span> </span>
<span>Milbenfall pro Tag</span> <span>Milbenfall pro Tag</span>
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createColony } from "@/lib/colonies/api"; import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
@@ -33,7 +33,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
return ( return (
<li <li
aria-current={state === "current" ? "step" : undefined} aria-current={state === "current" ? "step" : undefined}
className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-primary aria-[current=step]:bg-primary/10" className="flex flex-col gap-1 rounded-lg border border-border/60 p-3 aria-[current=step]:border-foreground aria-[current=step]:bg-primary/10"
> >
<span className="flex flex-wrap items-center justify-between gap-2"> <span className="flex flex-wrap items-center justify-between gap-2">
<span <span
@@ -136,7 +136,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
{NOTICE_KINDS.map((entry) => ( {NOTICE_KINDS.map((entry) => (
<label <label
key={entry} key={entry}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-primary has-[:checked]:bg-primary/10" className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border border-border px-3 py-2 has-[:checked]:border-foreground has-[:checked]:bg-primary/10"
> >
<input <input
type="radio" type="radio"
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
}; };
const FIX_LINK_CLASS = const FIX_LINK_CLASS =
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"; "inline-flex min-h-11 shrink-0 items-center gap-1 rounded-lg px-2 font-medium text-primary-ink underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50";
function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) { function FixLink({ itemKey, subject, context }: { itemKey: string; subject: ReadinessSubject | null; context: string }) {
const { href, label, available } = fixLinkFor(itemKey, subject); const { href, label, available } = fixLinkFor(itemKey, subject);
@@ -2,7 +2,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader } from "@/components/detail-header"; import { DetailHeader } from "@/components/detail-header";
import { StandNote } from "@/components/offline/stand-note"; import { StandNote } from "@/components/offline/stand-note";
@@ -1,7 +1,6 @@
"use client"; "use client";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access"; import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header"; import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
@@ -219,7 +219,7 @@ export default function DashboardPage() {
</span> </span>
{href ? ( {href ? (
<Link <Link
className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary underline-offset-4 hover:underline" className="inline-flex min-h-11 shrink-0 items-center rounded-lg px-2 font-medium text-primary-ink underline-offset-4 hover:underline"
href={href} href={href}
> >
Ansehen Ansehen
@@ -247,7 +247,7 @@ export default function DashboardPage() {
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring" className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
> >
<Icon <Icon
className={`size-5 shrink-0 ${tile.attention ? "text-warning" : "text-primary"}`} className={`size-5 shrink-0 ${tile.attention ? "text-warning-ink" : "text-primary-ink"}`}
aria-hidden aria-hidden
/> />
<span className="font-heading text-lg font-semibold tabular-nums"> <span className="font-heading text-lg font-semibold tabular-nums">
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api"; import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createEquipment } from "@/lib/equipment/api"; import { createEquipment } from "@/lib/equipment/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchExtraction } from "@/lib/extractions/api"; import { fetchExtraction } from "@/lib/extractions/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchHarvests } from "@/lib/harvests/api"; import { fetchHarvests } from "@/lib/harvests/api";
import { import {
createExtraction, createExtraction,
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createFeeding } from "@/lib/feedings/api"; import { createFeeding } from "@/lib/feedings/api";
import { createFeedingsBulk } from "@/lib/bulk/api"; import { createFeedingsBulk } from "@/lib/bulk/api";
import { import {
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchHarvest } from "@/lib/harvests/api"; import { fetchHarvest } from "@/lib/harvests/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -284,7 +284,7 @@ export default function NewHarvestPage() {
</Field> </Field>
{withdrawalQuery.isError ? ( {withdrawalQuery.isError ? (
<p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm"> <p role="alert" className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der Wartezeiten konnten nicht geprüft werden. Vor der Ernte die Behandlungen der
Völker selbst ansehen. Völker selbst ansehen.
</p> </p>
@@ -114,7 +114,7 @@ function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
className={ className={
movement.quantityGrams < 0 movement.quantityGrams < 0
? "font-semibold tabular-nums text-destructive" ? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary" : "font-semibold tabular-nums text-primary-ink"
} }
> >
{formatSignedKg(gramsToKg(movement.quantityGrams))} {formatSignedKg(gramsToKg(movement.quantityGrams))}
@@ -98,7 +98,7 @@ function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: Hive
</div> </div>
{blocked?.index === index ? ( {blocked?.index === index ? (
<p role="alert" className="flex items-start gap-2 text-sm"> <p role="alert" className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
{blocked.reason} Mach zuerst die späteren Änderungen rückgängig. {blocked.reason} Mach zuerst die späteren Änderungen rückgängig.
</p> </p>
) : null} ) : null}
@@ -734,7 +734,7 @@ function InspectionCaptureForm({
{noQueenSigns ? ( {noQueenSigns ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm"> <p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle <AlertTriangle
className="mt-0.5 size-4 shrink-0 text-warning" className="mt-0.5 size-4 shrink-0 text-warning-ink"
aria-hidden aria-hidden
/> />
Weder Königin noch Stifte gesehen. Nach dem Speichern wird Weder Königin noch Stifte gesehen. Nach dem Speichern wird
@@ -894,7 +894,7 @@ function InspectionCaptureForm({
/> />
{values.feedLegacyText !== null ? ( {values.feedLegacyText !== null ? (
<p role="status" className="flex items-start gap-2 text-sm"> <p role="status" className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
{feedLegacyHint(values.feedLegacyText)} {feedLegacyHint(values.feedLegacyText)}
</p> </p>
) : null} ) : null}
@@ -3,7 +3,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { buildBook } from "@/lib/medicines/book"; import { buildBook } from "@/lib/medicines/book";
@@ -37,7 +37,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
<SheetTrigger <SheetTrigger
className={cn( className={cn(
"flex flex-1 flex-col items-center justify-center gap-1 text-xs", "flex flex-1 flex-col items-center justify-center gap-1 text-xs",
isMoreActive ? "text-primary" : "text-muted-foreground", isMoreActive ? "text-primary-ink" : "text-muted-foreground",
)} )}
> >
<Menu className="size-5" aria-hidden="true" /> <Menu className="size-5" aria-hidden="true" />
@@ -4,7 +4,6 @@ import { Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
import { import {
OFFLINE_PACKS_QUERY_KEY, OFFLINE_PACKS_QUERY_KEY,
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -2,7 +2,6 @@
import { FormEvent, useRef, useState } from "react"; import { FormEvent, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createProductionBatch } from "@/lib/production-batches/api"; import { createProductionBatch } from "@/lib/production-batches/api";
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "@/lib/products/api"; import { fetchProduct } from "@/lib/products/api";
import { fetchProductionBatches } from "@/lib/production-batches/api"; import { fetchProductionBatches } from "@/lib/production-batches/api";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createProduct } from "@/lib/products/api"; import { createProduct } from "@/lib/products/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers"; import { mockMedia, resetDocument } from "@/lib/appearance/test-helpers";
@@ -30,21 +30,20 @@ describe("AppearanceSection", () => {
expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument(); expect(screen.getByText("Gilt nur für dieses Gerät.")).toBeInTheDocument();
}); });
it("hides Dunkel and Wie Gerät without the dev flag", () => { it("starts on Hell and offers Dunkel and Wie Gerät without a flag", () => {
render(<AppearanceSection />); render(<AppearanceSection />);
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: /^Hell/ })).toBeChecked();
expect(screen.queryByRole("radio", { name: /^Dunkel/ })).not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: /^Dunkel/ })).not.toBeChecked();
expect(screen.queryByRole("radio", { name: /^Wie Gerät/ })).not.toBeInTheDocument(); expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).not.toBeChecked();
}); });
it("offers Dunkel and Wie Gerät with the dev flag", async () => { it("dark is selectable without a flag", async () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
render(<AppearanceSection />); render(<AppearanceSection />);
await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ })); await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
expect(document.documentElement).toHaveClass("dark"); expect(document.documentElement).toHaveClass("dark");
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).toBeInTheDocument(); expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
}); });
it("changes the motion level to Dezent and applies it", async () => { it("changes the motion level to Dezent and applies it", async () => {
@@ -3,12 +3,7 @@
import { useId } from "react"; import { useId } from "react";
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group"; import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
isDarkEnabled,
useAppearance,
type ContrastChoice,
type ThemeChoice,
} from "@/lib/appearance/appearance";
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference"; import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left"; const OPTION_CLASS = "h-auto justify-start px-3 py-2 text-left";
@@ -38,8 +33,6 @@ const MOTION_OPTIONS: readonly ChoiceOption<MotionLevel>[] = MOTION_LEVELS.map((
export function AppearanceSection() { export function AppearanceSection() {
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance(); const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() }; const ids = { theme: useId(), contrast: useId(), motion: useId(), motionHint: useId() };
// Dark has unreadable old values until task A4; the dev flag shows it for work on A4.
const themeOptions = isDarkEnabled() ? THEME_OPTIONS : THEME_OPTIONS.slice(0, 1);
return ( return (
<Card> <Card>
@@ -56,7 +49,7 @@ export function AppearanceSection() {
Farbschema Farbschema
</p> </p>
<ChoiceGroup <ChoiceGroup
options={themeOptions} options={THEME_OPTIONS}
value={theme} value={theme}
onChange={setTheme} onChange={setTheme}
labelledBy={ids.theme} labelledBy={ids.theme}
@@ -1,7 +1,6 @@
"use client"; "use client";
import { ChangeEvent, FormEvent, useRef, useState } from "react"; import { ChangeEvent, FormEvent, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -2,7 +2,6 @@
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchQueen } from "@/lib/queens/api"; import { fetchQueen } from "@/lib/queens/api";
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels"; import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createQueen, fetchQueens } from "@/lib/queens/api"; import { createQueen, fetchQueens } from "@/lib/queens/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -2,7 +2,6 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createRawMaterialLot } from "@/lib/raw-materials/api"; import { createRawMaterialLot } from "@/lib/raw-materials/api";
@@ -53,8 +53,8 @@ export function ApiKeyAllowlist({ apiKey }: { apiKey: ApiKey }) {
Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span> Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span>
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1 text-warning-foreground"> <span className="flex items-center gap-1 text-warning-ink">
<ShieldAlert className="size-3.5 text-warning" aria-hidden /> <ShieldAlert className="size-3.5 text-warning-ink" aria-hidden />
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken. Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
</span> </span>
)} )}
@@ -155,7 +155,7 @@ export function ApiKeysTab() {
className="flex flex-col gap-3 rounded-2xl border border-primary/40 bg-primary/5 p-4" className="flex flex-col gap-3 rounded-2xl border border-primary/40 bg-primary/5 p-4"
> >
<div className="flex items-center gap-2 font-medium"> <div className="flex items-center gap-2 font-medium">
<ShieldCheck className="size-5 text-primary" aria-hidden /> <ShieldCheck className="size-5 text-primary-ink" aria-hidden />
Schlüssel „{created.name}“ angelegt Schlüssel „{created.name}“ angelegt
</div> </div>
<p className="text-sm"> <p className="text-sm">
@@ -2,7 +2,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher"; import { TabSwitcher } from "@/components/tab-switcher";
import { WebhooksTab } from "./webhooks-tab"; import { WebhooksTab } from "./webhooks-tab";
@@ -133,7 +133,7 @@ function ElementBox({
<div <div
className={cn( className={cn(
"absolute cursor-move touch-none select-none", "absolute cursor-move touch-none select-none",
selected && "outline outline-2 outline-primary", selected && "outline outline-2 outline-foreground",
)} )}
style={{ style={{
left: box.left, left: box.left,
@@ -146,7 +146,7 @@ function ElementBox({
{renderContent(element, scale)} {renderContent(element, scale)}
{selected ? ( {selected ? (
<div <div
className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-primary bg-background" className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-foreground bg-background"
onPointerDown={(event) => { onPointerDown={(event) => {
event.stopPropagation(); event.stopPropagation();
onStartResize(event); onStartResize(event);
@@ -1,6 +1,5 @@
"use client"; "use client";
import { createLabelTemplate } from "@/lib/label-templates/api"; import { createLabelTemplate } from "@/lib/label-templates/api";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
@@ -2,7 +2,6 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { TabSwitcher } from "@/components/tab-switcher"; import { TabSwitcher } from "@/components/tab-switcher";
import { UsersTab } from "./users-tab"; import { UsersTab } from "./users-tab";
@@ -2,7 +2,6 @@
import { useSyncExternalStore } from "react"; import { useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { createTask } from "@/lib/tasks/api"; import { createTask } from "@/lib/tasks/api";
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels"; import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
import { fetchUsers } from "@/lib/users/api"; import { fetchUsers } from "@/lib/users/api";
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { saveErrorText } from "@/lib/honey-batches/errors"; import { saveErrorText } from "@/lib/honey-batches/errors";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
+124 -130
View File
@@ -102,55 +102,52 @@
} }
:root { :root {
--background: oklch(0.955 0.017 80); --background: oklch(0.968 0.014 85);
--foreground: oklch(0.22 0.028 55); --foreground: oklch(0.21 0.03 55);
--card: oklch(0.995 0.004 90); --card: oklch(0.995 0.004 90);
--card-foreground: oklch(0.22 0.028 55); --card-foreground: oklch(0.21 0.03 55);
--popover: oklch(0.995 0.004 90); --popover: oklch(0.995 0.004 90);
--popover-foreground: oklch(0.22 0.028 55); --popover-foreground: oklch(0.21 0.03 55);
--primary: oklch(0.6 0.15 48); --primary: oklch(0.8 0.145 78);
--primary-foreground: oklch(0.99 0.02 85); --primary-foreground: oklch(0.21 0.035 55);
--secondary: oklch(0.44 0.07 145); --secondary: oklch(0.44 0.07 145);
--secondary-foreground: oklch(0.98 0.015 100); --secondary-foreground: oklch(0.98 0.015 100);
--muted: oklch(0.9 0.02 82); --muted: oklch(0.925 0.018 82);
--muted-foreground: oklch(0.48 0.032 65); --muted-foreground: oklch(0.44 0.03 60);
--accent: oklch(0.93 0.032 74); --accent: oklch(0.935 0.03 78);
--accent-foreground: oklch(0.26 0.045 50); --accent-foreground: oklch(0.25 0.045 50);
--destructive: oklch(0.5 0.16 30); --destructive: oklch(0.5 0.17 28);
--border: oklch(0.85 0.024 78); --border: oklch(0.86 0.022 78);
--input: oklch(0.85 0.024 78); --input: oklch(0.6 0.035 65);
--ring: oklch(0.6 0.15 48); --ring: oklch(0.47 0.115 55);
--warning: oklch(0.68 0.14 75); --warning: oklch(0.79 0.15 80);
--warning-foreground: oklch(0.24 0.04 60); --warning-foreground: oklch(0.23 0.04 60);
--success: oklch(0.46 0.06 145); --success: oklch(0.46 0.08 145);
--success-foreground: oklch(0.98 0.015 100); --success-foreground: oklch(0.98 0.015 100);
--condition-excellent: oklch(0.58 0.13 145); --condition-excellent: oklch(0.62 0.13 150);
--condition-excellent-foreground: oklch(0.16 0.04 145); --condition-excellent-foreground: oklch(0.15 0.04 150);
--condition-good: oklch(0.64 0.13 118); --condition-good: oklch(0.645 0.13 125);
--condition-good-foreground: oklch(0.26 0.05 118); --condition-good-foreground: oklch(0.17 0.04 125);
--condition-average: oklch(0.7 0.14 90); --condition-average: oklch(0.665 0.125 88);
--condition-average-foreground: oklch(0.28 0.05 90); --condition-average-foreground: oklch(0.19 0.04 90);
--condition-weak: oklch(0.68 0.15 55); --condition-weak: oklch(0.665 0.14 58);
--condition-weak-foreground: oklch(0.26 0.05 55); --condition-weak-foreground: oklch(0.18 0.04 55);
--condition-critical: oklch(0.64 0.16 30); --condition-critical: oklch(0.62 0.17 30);
--condition-critical-foreground: oklch(0.15 0.04 30); --condition-critical-foreground: oklch(0.15 0.04 30);
--chart-1: oklch(0.6 0.135 50); --chart-1: oklch(0.56 0.13 55);
--chart-2: oklch(0.46 0.06 145); --chart-2: oklch(0.46 0.08 145);
--chart-3: oklch(0.68 0.14 75); --chart-3: oklch(0.6 0.12 85);
--chart-4: oklch(0.5 0.16 30); --chart-4: oklch(0.5 0.17 28);
--chart-5: oklch(0.55 0.05 250); --chart-5: oklch(0.52 0.08 250);
--radius: 0.75rem; --radius: 0.75rem;
--sidebar: oklch(0.995 0.004 90); --sidebar: oklch(0.995 0.004 90);
--sidebar-foreground: oklch(0.22 0.028 55); --sidebar-foreground: oklch(0.21 0.03 55);
--sidebar-primary: oklch(0.6 0.15 48); --sidebar-primary: oklch(0.8 0.145 78);
--sidebar-primary-foreground: oklch(0.99 0.02 85); --sidebar-primary-foreground: oklch(0.21 0.035 55);
--sidebar-accent: oklch(0.93 0.032 74); --sidebar-accent: oklch(0.935 0.03 78);
--sidebar-accent-foreground: oklch(0.26 0.045 50); --sidebar-accent-foreground: oklch(0.25 0.045 50);
--sidebar-border: oklch(0.85 0.024 78); --sidebar-border: oklch(0.86 0.022 78);
--sidebar-ring: oklch(0.6 0.15 48); --sidebar-ring: oklch(0.47 0.115 55);
--gradient-honey: linear-gradient(135deg, oklch(0.74 0.15 62) 0%, oklch(0.56 0.16 38) 100%);
--gradient-sage: linear-gradient(135deg, oklch(0.64 0.09 150) 0%, oklch(0.4 0.075 145) 100%);
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%); --shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%); --shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */
@@ -160,7 +157,7 @@
--holo-fill: oklch(0.78 0.14 220 / 10%); --holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250); --holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230); --holo-label-fg: oklch(0.95 0.02 230);
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */ /* T5 tokens (spec 4.2). */
--primary-ink: oklch(0.47 0.115 55); --primary-ink: oklch(0.47 0.115 55);
--warning-ink: oklch(0.5 0.11 65); --warning-ink: oklch(0.5 0.11 65);
--destructive-foreground: oklch(0.985 0.005 30); --destructive-foreground: oklch(0.985 0.005 30);
@@ -188,54 +185,51 @@
} }
.dark { .dark {
--background: oklch(0.2 0.018 55); --background: oklch(0.19 0.016 55);
--foreground: oklch(0.94 0.02 80); --foreground: oklch(0.95 0.018 80);
--card: oklch(0.25 0.02 55); --card: oklch(0.235 0.018 55);
--card-foreground: oklch(0.94 0.02 80); --card-foreground: oklch(0.95 0.018 80);
--popover: oklch(0.25 0.02 55); --popover: oklch(0.235 0.018 55);
--popover-foreground: oklch(0.94 0.02 80); --popover-foreground: oklch(0.95 0.018 80);
--primary: oklch(0.72 0.13 55); --primary: oklch(0.8 0.14 78);
--primary-foreground: oklch(0.2 0.02 55); --primary-foreground: oklch(0.19 0.03 55);
--secondary: oklch(0.62 0.075 145); --secondary: oklch(0.66 0.08 145);
--secondary-foreground: oklch(0.15 0.02 145); --secondary-foreground: oklch(0.16 0.02 145);
--muted: oklch(0.29 0.018 55); --muted: oklch(0.28 0.018 55);
--muted-foreground: oklch(0.7 0.025 75); --muted-foreground: oklch(0.76 0.025 75);
--accent: oklch(0.32 0.03 60); --accent: oklch(0.31 0.03 60);
--accent-foreground: oklch(0.94 0.02 80); --accent-foreground: oklch(0.95 0.018 80);
--destructive: oklch(0.62 0.16 30); --destructive: oklch(0.7 0.15 28);
--border: oklch(1 0 0 / 12%); --border: oklch(0.34 0.02 60);
--input: oklch(1 0 0 / 16%); --input: oklch(0.56 0.025 70);
--ring: oklch(0.72 0.13 55); --ring: oklch(0.82 0.13 80);
--warning: oklch(0.74 0.13 75); --warning: oklch(0.8 0.14 80);
--warning-foreground: oklch(0.2 0.03 60); --warning-foreground: oklch(0.2 0.03 60);
--success: oklch(0.62 0.075 145); --success: oklch(0.7 0.09 145);
--success-foreground: oklch(0.15 0.02 145); --success-foreground: oklch(0.16 0.02 145);
--condition-excellent: oklch(0.66 0.11 145); --condition-excellent: oklch(0.68 0.12 150);
--condition-excellent-foreground: oklch(0.2 0.04 145); --condition-excellent-foreground: oklch(0.17 0.04 150);
--condition-good: oklch(0.72 0.12 118); --condition-good: oklch(0.72 0.12 125);
--condition-good-foreground: oklch(0.22 0.04 118); --condition-good-foreground: oklch(0.18 0.04 125);
--condition-average: oklch(0.76 0.13 90); --condition-average: oklch(0.77 0.13 90);
--condition-average-foreground: oklch(0.24 0.04 90); --condition-average-foreground: oklch(0.2 0.04 90);
--condition-weak: oklch(0.74 0.13 55); --condition-weak: oklch(0.74 0.13 60);
--condition-weak-foreground: oklch(0.22 0.04 55); --condition-weak-foreground: oklch(0.19 0.04 55);
--condition-critical: oklch(0.62 0.16 30); --condition-critical: oklch(0.68 0.16 28);
--condition-critical-foreground: oklch(0.2 0.04 30); --condition-critical-foreground: oklch(0.16 0.04 30);
--chart-1: oklch(0.72 0.13 55); --chart-1: oklch(0.78 0.13 70);
--chart-2: oklch(0.62 0.075 145); --chart-2: oklch(0.7 0.09 145);
--chart-3: oklch(0.74 0.13 75); --chart-3: oklch(0.82 0.12 90);
--chart-4: oklch(0.62 0.16 30); --chart-4: oklch(0.7 0.15 28);
--chart-5: oklch(0.65 0.06 250); --chart-5: oklch(0.72 0.08 250);
--sidebar: oklch(0.22 0.02 55); --sidebar: oklch(0.235 0.018 55);
--sidebar-foreground: oklch(0.94 0.02 80); --sidebar-foreground: oklch(0.95 0.018 80);
--sidebar-primary: oklch(0.72 0.13 55); --sidebar-primary: oklch(0.8 0.14 78);
--sidebar-primary-foreground: oklch(0.2 0.02 55); --sidebar-primary-foreground: oklch(0.19 0.03 55);
--sidebar-accent: oklch(0.32 0.03 60); --sidebar-accent: oklch(0.31 0.03 60);
--sidebar-accent-foreground: oklch(0.94 0.02 80); --sidebar-accent-foreground: oklch(0.95 0.018 80);
--sidebar-border: oklch(1 0 0 / 12%); --sidebar-border: oklch(0.34 0.02 60);
--sidebar-ring: oklch(0.72 0.13 55); --sidebar-ring: oklch(0.82 0.13 80);
--gradient-honey: linear-gradient(135deg, oklch(0.62 0.14 55) 0%, oklch(0.38 0.13 40) 100%);
--gradient-sage: linear-gradient(135deg, oklch(0.5 0.08 148) 0%, oklch(0.3 0.06 145) 100%);
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%); --shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%); --shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */ /* Hive "screen": always dark, in light and dark mode (spec 9.3). */
@@ -245,7 +239,7 @@
--holo-fill: oklch(0.78 0.14 220 / 10%); --holo-fill: oklch(0.78 0.14 220 / 10%);
--holo-label-bg: oklch(0.26 0.04 250); --holo-label-bg: oklch(0.26 0.04 250);
--holo-label-fg: oklch(0.95 0.02 230); --holo-label-fg: oklch(0.95 0.02 230);
/* T5 tokens, end values (spec 4.2); the old values above switch in task A4. */ /* T5 tokens (spec 4.2). */
--primary-ink: oklch(0.82 0.13 80); --primary-ink: oklch(0.82 0.13 80);
--warning-ink: oklch(0.82 0.13 82); --warning-ink: oklch(0.82 0.13 82);
--destructive-foreground: oklch(0.17 0.03 30); --destructive-foreground: oklch(0.17 0.03 30);
@@ -364,51 +358,51 @@
.dark, .dark,
:root[data-contrast="sun"], :root[data-contrast="sun"],
.dark[data-contrast="sun"] { .dark[data-contrast="sun"] {
--background: oklch(0.955 0.017 80); --background: oklch(0.968 0.014 85);
--foreground: oklch(0.22 0.028 55); --foreground: oklch(0.21 0.03 55);
--card: oklch(0.995 0.004 90); --card: oklch(0.995 0.004 90);
--card-foreground: oklch(0.22 0.028 55); --card-foreground: oklch(0.21 0.03 55);
--popover: oklch(0.995 0.004 90); --popover: oklch(0.995 0.004 90);
--popover-foreground: oklch(0.22 0.028 55); --popover-foreground: oklch(0.21 0.03 55);
--primary: oklch(0.6 0.15 48); --primary: oklch(0.8 0.145 78);
--primary-foreground: oklch(0.99 0.02 85); --primary-foreground: oklch(0.21 0.035 55);
--secondary: oklch(0.44 0.07 145); --secondary: oklch(0.44 0.07 145);
--secondary-foreground: oklch(0.98 0.015 100); --secondary-foreground: oklch(0.98 0.015 100);
--muted: oklch(0.9 0.02 82); --muted: oklch(0.925 0.018 82);
--muted-foreground: oklch(0.48 0.032 65); --muted-foreground: oklch(0.44 0.03 60);
--accent: oklch(0.93 0.032 74); --accent: oklch(0.935 0.03 78);
--accent-foreground: oklch(0.26 0.045 50); --accent-foreground: oklch(0.25 0.045 50);
--destructive: oklch(0.5 0.16 30); --destructive: oklch(0.5 0.17 28);
--border: oklch(0.85 0.024 78); --border: oklch(0.86 0.022 78);
--input: oklch(0.85 0.024 78); --input: oklch(0.6 0.035 65);
--ring: oklch(0.6 0.15 48); --ring: oklch(0.47 0.115 55);
--warning: oklch(0.68 0.14 75); --warning: oklch(0.79 0.15 80);
--warning-foreground: oklch(0.24 0.04 60); --warning-foreground: oklch(0.23 0.04 60);
--success: oklch(0.46 0.06 145); --success: oklch(0.46 0.08 145);
--success-foreground: oklch(0.98 0.015 100); --success-foreground: oklch(0.98 0.015 100);
--condition-excellent: oklch(0.58 0.13 145); --condition-excellent: oklch(0.62 0.13 150);
--condition-excellent-foreground: oklch(0.16 0.04 145); --condition-excellent-foreground: oklch(0.15 0.04 150);
--condition-good: oklch(0.64 0.13 118); --condition-good: oklch(0.645 0.13 125);
--condition-good-foreground: oklch(0.26 0.05 118); --condition-good-foreground: oklch(0.17 0.04 125);
--condition-average: oklch(0.7 0.14 90); --condition-average: oklch(0.665 0.125 88);
--condition-average-foreground: oklch(0.28 0.05 90); --condition-average-foreground: oklch(0.19 0.04 90);
--condition-weak: oklch(0.68 0.15 55); --condition-weak: oklch(0.665 0.14 58);
--condition-weak-foreground: oklch(0.26 0.05 55); --condition-weak-foreground: oklch(0.18 0.04 55);
--condition-critical: oklch(0.64 0.16 30); --condition-critical: oklch(0.62 0.17 30);
--condition-critical-foreground: oklch(0.15 0.04 30); --condition-critical-foreground: oklch(0.15 0.04 30);
--chart-1: oklch(0.6 0.135 50); --chart-1: oklch(0.56 0.13 55);
--chart-2: oklch(0.46 0.06 145); --chart-2: oklch(0.46 0.08 145);
--chart-3: oklch(0.68 0.14 75); --chart-3: oklch(0.6 0.12 85);
--chart-4: oklch(0.5 0.16 30); --chart-4: oklch(0.5 0.17 28);
--chart-5: oklch(0.55 0.05 250); --chart-5: oklch(0.52 0.08 250);
--sidebar: oklch(0.995 0.004 90); --sidebar: oklch(0.995 0.004 90);
--sidebar-foreground: oklch(0.22 0.028 55); --sidebar-foreground: oklch(0.21 0.03 55);
--sidebar-primary: oklch(0.6 0.15 48); --sidebar-primary: oklch(0.8 0.145 78);
--sidebar-primary-foreground: oklch(0.99 0.02 85); --sidebar-primary-foreground: oklch(0.21 0.035 55);
--sidebar-accent: oklch(0.93 0.032 74); --sidebar-accent: oklch(0.935 0.03 78);
--sidebar-accent-foreground: oklch(0.26 0.045 50); --sidebar-accent-foreground: oklch(0.25 0.045 50);
--sidebar-border: oklch(0.85 0.024 78); --sidebar-border: oklch(0.86 0.022 78);
--sidebar-ring: oklch(0.6 0.15 48); --sidebar-ring: oklch(0.47 0.115 55);
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%); --shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%); --shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
--holo-bg: oklch(0.18 0.03 250); --holo-bg: oklch(0.18 0.03 250);
@@ -515,7 +509,7 @@
} }
input, input,
textarea { textarea {
caret-color: var(--primary); caret-color: var(--primary-ink);
} }
} }
@@ -587,7 +581,7 @@
position: absolute; position: absolute;
inset: 0.375rem; inset: 0.375rem;
border-radius: 0.6rem; border-radius: 0.6rem;
border: 2px solid color-mix(in oklch, var(--primary) 65%, var(--border)); border: 2px solid var(--input);
background: var(--card); background: var(--card);
transition: transition:
border-color 150ms ease-out, border-color 150ms ease-out,
@@ -62,7 +62,7 @@ interface InfoRowProps {
function InfoRow({ icon: Icon, label, value }: InfoRowProps) { function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
return ( return (
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Icon className="mt-0.5 size-4 shrink-0 text-primary" /> <Icon className="mt-0.5 size-4 shrink-0 text-primary-ink" />
<div> <div>
<dt className="text-muted-foreground">{label}</dt> <dt className="text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd> <dd className="font-medium">{value}</dd>
@@ -80,7 +80,7 @@ interface StatProps {
function Stat({ icon: Icon, label, value }: StatProps) { function Stat({ icon: Icon, label, value }: StatProps) {
return ( return (
<div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center"> <div className="flex flex-col items-center gap-1 rounded-xl bg-accent/40 px-3 py-3 text-center">
<Icon className="size-4 text-primary" /> <Icon className="size-4 text-primary-ink" />
<span className="text-sm font-semibold">{value}</span> <span className="text-sm font-semibold">{value}</span>
<span className="text-[0.7rem] text-muted-foreground">{label}</span> <span className="text-[0.7rem] text-muted-foreground">{label}</span>
</div> </div>
@@ -137,7 +137,7 @@ export default function PublicBatchPage() {
{batch.productName ?? "Honig"} {batch.productName ?? "Honig"}
</h1> </h1>
{batch.honeyType ? ( {batch.honeyType ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary"> <span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary-ink">
<Sparkles className="size-3" /> <Sparkles className="size-3" />
{batch.honeyType} {batch.honeyType}
</span> </span>
@@ -197,7 +197,7 @@ export default function PublicBatchPage() {
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20" className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary-ink transition-colors hover:bg-primary/20"
> >
<Icon className="size-4" /> <Icon className="size-4" />
{link.label} {link.label}
@@ -37,7 +37,7 @@ export function ApiaryMap({ apiaries }: ApiaryMapProps) {
{apiaries.map((apiary) => ( {apiaries.map((apiary) => (
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}> <Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
<Popup> <Popup>
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary underline"> <Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary-ink underline">
{apiary.name} {apiary.name}
</Link> </Link>
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null} {apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
@@ -23,7 +23,7 @@ afterEach(() => {
describe("appearanceScript", () => { describe("appearanceScript", () => {
it("uses the same storage keys as the hook", () => { it("uses the same storage keys as the hook", () => {
const source = appearanceScript(true); const source = appearanceScript();
for (const key of Object.values(APPEARANCE_KEYS)) expect(source).toContain(key); for (const key of Object.values(APPEARANCE_KEYS)) expect(source).toContain(key);
}); });
@@ -32,7 +32,7 @@ describe("appearanceScript", () => {
window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun"); window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun");
window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle"); window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle");
run(appearanceScript(true)); run(appearanceScript());
const root = document.documentElement; const root = document.documentElement;
expect(root).toHaveClass("dark"); expect(root).toHaveClass("dark");
@@ -44,29 +44,25 @@ describe("appearanceScript", () => {
const themes = [null, "light", "dark", "system", "bogus"]; const themes = [null, "light", "dark", "system", "bogus"];
const contrasts = [null, "normal", "sun", "bogus"]; const contrasts = [null, "normal", "sun", "bogus"];
const motions = [null, "full", "subtle", "off", "bogus"]; const motions = [null, "full", "subtle", "off", "bogus"];
for (const darkEnabled of [true, false]) for (const prefersDark of [true, false])
for (const prefersDark of [true, false]) for (const prefersMore of [true, false])
for (const prefersMore of [true, false]) for (const theme of themes)
for (const theme of themes) for (const contrast of contrasts)
for (const contrast of contrasts) for (const motion of motions) {
for (const motion of motions) { resetDocument();
resetDocument(); stubMedia(prefersDark, prefersMore);
stubMedia(prefersDark, prefersMore); if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme); if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast); if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
run(appearanceScript(darkEnabled)); run(appearanceScript());
const expected = resolveAppearance( const expected = resolveAppearance({ theme, contrast, motion }, { prefersDark, prefersMore });
{ theme, contrast, motion }, const root = document.documentElement;
{ prefersDark, prefersMore, darkEnabled }, expect(root.classList.contains("dark")).toBe(expected.dark);
); expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
const root = document.documentElement; expect(root.dataset.motion).toBe(expected.motion);
expect(root.classList.contains("dark")).toBe(expected.dark); }
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
expect(root.dataset.motion).toBe(expected.motion);
}
}); });
it("blocked storage falls back to the default", () => { it("blocked storage falls back to the default", () => {
@@ -78,7 +74,7 @@ describe("appearanceScript", () => {
}, },
}); });
try { try {
run(appearanceScript(true)); run(appearanceScript());
} finally { } finally {
Object.defineProperty(window, "localStorage", real); Object.defineProperty(window, "localStorage", real);
} }
@@ -102,14 +98,18 @@ describe("appearanceScript", () => {
for (const [theme, contrast, color] of cases) { for (const [theme, contrast, color] of cases) {
window.localStorage.setItem(APPEARANCE_KEYS.theme, theme); window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast); window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
run(appearanceScript(true)); run(appearanceScript());
expect(meta.content).toBe(color); expect(meta.content).toBe(color);
} }
}); });
it("has no build flag for dark any more (task A4)", () => {
expect(appearanceScript()).not.toMatch(/DARK=/);
});
it("contains no stored or user data, only constants", () => { it("contains no stored or user data, only constants", () => {
window.localStorage.setItem(APPEARANCE_KEYS.theme, "secret-value"); window.localStorage.setItem(APPEARANCE_KEYS.theme, "secret-value");
expect(appearanceScript(true)).not.toContain("secret-value"); expect(appearanceScript()).not.toContain("secret-value");
}); });
}); });
@@ -15,7 +15,7 @@ export function AppearanceScript() {
return ( return (
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: appearanceScript(process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1"), __html: appearanceScript(),
}} }}
/> />
); );
@@ -27,10 +27,8 @@ describe("area migration", () => {
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
it("no source other than globals.css uses the --gradient tokens", () => { it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
const offenders = sourceFiles(SRC) const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
.filter((file) => !file.endsWith("globals.css"))
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]); expect(offenders).toEqual([]);
}); });
@@ -157,7 +157,7 @@ export function NextStepsView({
className="flex flex-col gap-3 rounded-xl border border-border/60 p-3" className="flex flex-col gap-3 rounded-xl border border-border/60 p-3"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
<Icon className="size-5" aria-hidden /> <Icon className="size-5" aria-hidden />
</span> </span>
<div className="flex min-w-0 flex-1 flex-col gap-1.5"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
@@ -13,6 +13,13 @@ describe("BrandMark", () => {
expect(screen.getByText("ApiCore")).toBeInTheDocument(); expect(screen.getByText("ApiCore")).toBeInTheDocument();
}); });
it("fills the cell with --primary (= spec honey #f0b13f, see contrast.spec) and the lid with wax", () => {
const { container } = render(<BrandMark />);
const [cell, lid] = container.querySelectorAll("svg path");
expect(cell).toHaveAttribute("fill", "var(--primary)");
expect(lid).toHaveAttribute("fill", "oklch(0.88 0.11 85)");
});
it("is decorative without a wordmark and sized 24 or 32 px", () => { it("is decorative without a wordmark and sized 24 or 32 px", () => {
const { container, rerender } = render(<BrandMark />); const { container, rerender } = render(<BrandMark />);
const svg = container.querySelector("svg") as SVGElement; const svg = container.querySelector("svg") as SVGElement;
@@ -15,7 +15,11 @@ interface BrandMarkProps {
className?: string; className?: string;
} }
/** The ApiCore mark: a capped cell, optionally followed by the wordmark. */ /**
* The ApiCore mark: a capped cell, optionally followed by the wordmark. The cell is --primary:
* its light value is the spec honey #f0b13f (pinned in contrast.spec), dark differs by one step
* (#efb146), so no separate brand token is needed. Static files (task A9) use the hex directly.
*/
export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) { export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) {
const mark = ( const mark = (
<svg <svg
@@ -118,7 +118,7 @@ export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
<div aria-live="polite"> <div aria-live="polite">
{restricted ? ( {restricted ? (
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm"> <p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span> <span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
</p> </p>
) : null} ) : null}
@@ -142,7 +142,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground", selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
selected && selected &&
!isUnanswered && !isUnanswered &&
"border-primary bg-primary font-medium text-primary-foreground", "border-foreground bg-primary font-medium text-primary-foreground",
selected && option.selectedClassName, selected && option.selectedClassName,
optionClassName, optionClassName,
)} )}
@@ -25,7 +25,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
className={cn( className={cn(
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50", "flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
checked checked
? "border-primary bg-primary/10 font-medium" ? "border-foreground bg-primary/10 font-medium"
: "border-border bg-background hover:bg-muted", : "border-border bg-background hover:bg-muted",
)} )}
> >
@@ -34,7 +34,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
className={cn( className={cn(
"flex size-5 shrink-0 items-center justify-center rounded border", "flex size-5 shrink-0 items-center justify-center rounded border",
checked checked
? "border-primary bg-primary text-primary-foreground" ? "border-foreground bg-primary text-primary-foreground"
: "border-border", : "border-border",
)} )}
> >
@@ -121,7 +121,7 @@ export function LabelCompliancePanel({
<ul aria-label="Hinweise" className="flex flex-col gap-2"> <ul aria-label="Hinweise" className="flex flex-col gap-2">
{result.warnings.map((warning, index) => ( {result.warnings.map((warning, index) => (
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm"> <li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" /> <TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
<span className="flex flex-col"> <span className="flex flex-col">
<span className="font-medium">Hinweis: {warning.title}</span> <span className="font-medium">Hinweis: {warning.title}</span>
{warning.detail ? ( {warning.detail ? (
@@ -37,7 +37,7 @@ export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p> <p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
{isPackStale(stored, now) ? ( {isPackStale(stored, now) ? (
<p className="flex items-center gap-1.5 font-medium"> <p className="flex items-center gap-1.5 font-medium">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`} {`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
</p> </p>
) : null} ) : null}
@@ -48,7 +48,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
</> </>
) : null} ) : null}
{state.note ? ( {state.note ? (
<span className="text-warning-foreground">{SEASON_NOTE_LABELS[state.note]}</span> <span className="text-warning-ink">{SEASON_NOTE_LABELS[state.note]}</span>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -21,7 +21,7 @@ export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }:
<Card className="max-w-lg"> <Card className="max-w-lg">
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<p role="status" className="flex items-center gap-2 text-lg font-medium"> <p role="status" className="flex items-center gap-2 text-lg font-medium">
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden /> <CheckCircle2 className="size-5 text-condition-good-ink" aria-hidden />
Gespeichert Gespeichert
</p> </p>
{savedOffline ? ( {savedOffline ? (
@@ -190,7 +190,7 @@ function MovementList({ movements }: { movements: StockMovement[] }) {
className={ className={
movement.quantity < 0 movement.quantity < 0
? "font-semibold tabular-nums text-destructive" ? "font-semibold tabular-nums text-destructive"
: "font-semibold tabular-nums text-primary" : "font-semibold tabular-nums text-primary-ink"
} }
> >
{movement.quantity > 0 ? "+" : ""} {movement.quantity > 0 ? "+" : ""}
@@ -109,7 +109,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
return ( return (
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]"> <article className="flex min-w-0 flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
<Icon className="size-5" aria-hidden /> <Icon className="size-5" aria-hidden />
</span> </span>
<div className="flex min-w-0 flex-1 flex-col gap-1.5"> <div className="flex min-w-0 flex-1 flex-col gap-1.5">
@@ -62,7 +62,7 @@ function CombinedRow({
return ( return (
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]"> <div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
<Icon className="size-5" aria-hidden /> <Icon className="size-5" aria-hidden />
</span> </span>
<h3 className="min-w-0 flex-1 font-medium"> <h3 className="min-w-0 flex-1 font-medium">
@@ -56,7 +56,7 @@ export function TaskSourceBadge({
title={source.title} title={source.title}
className="inline-flex items-center gap-1 rounded-full border border-dashed border-primary/45 px-2 py-0.5 text-xs text-foreground/80" className="inline-flex items-center gap-1 rounded-full border border-dashed border-primary/45 px-2 py-0.5 text-xs text-foreground/80"
> >
<Icon className="size-3.5 text-primary" aria-hidden /> <Icon className="size-3.5 text-primary-ink" aria-hidden />
{source.label} {source.label}
</span> </span>
); );
@@ -40,7 +40,7 @@ export function InspectionDueCard({ entry, primary = true }: InspectionDueCardPr
className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5" className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
> >
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
<ClipboardCheck className="size-5" aria-hidden /> <ClipboardCheck className="size-5" aria-hidden />
</span> </span>
<h3 id={headingId} className="min-w-0 flex-1 font-medium"> <h3 id={headingId} className="min-w-0 flex-1 font-medium">
@@ -157,7 +157,7 @@ export function TodayPanel({
</h2> </h2>
<Link <Link
href="/tasks" href="/tasks"
className="inline-flex min-h-11 items-center text-sm font-medium text-primary underline-offset-4 hover:underline" className="inline-flex min-h-11 items-center text-sm font-medium text-primary-ink underline-offset-4 hover:underline"
> >
Alle Aufgaben Alle Aufgaben
</Link> </Link>
@@ -307,7 +307,7 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
<div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4"> <div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl"> <p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
<Icon className="mt-1 size-6 shrink-0 text-primary" aria-hidden /> <Icon className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
{step.title} {step.title}
</p> </p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -445,7 +445,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
function NothingDue() { function NothingDue() {
return ( return (
<section className="flex flex-col items-start gap-4 rounded-2xl border border-border/60 bg-card p-5 shadow-[var(--shadow-soft)] sm:p-6"> <section className="flex flex-col items-start gap-4 rounded-2xl border border-border/60 bg-card p-5 shadow-[var(--shadow-soft)] sm:p-6">
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary"> <span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary-ink">
<Sun className="size-6" aria-hidden /> <Sun className="size-6" aria-hidden />
</span> </span>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -321,7 +321,7 @@ export function BookingNotes({
) : null} ) : null}
{warnings.map((warning) => ( {warnings.map((warning) => (
<p key={warning} className="flex items-start gap-2 text-sm font-medium"> <p key={warning} className="flex items-start gap-2 text-sm font-medium">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden /> <AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
<span>{warning}</span> <span>{warning}</span>
</p> </p>
))} ))}
@@ -366,7 +366,7 @@ function TemperatureNote({
const Icon = hint.warn ? AlertTriangle : Thermometer; const Icon = hint.warn ? AlertTriangle : Thermometer;
return ( return (
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm"> <p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden /> <Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning-ink" : ""}`} aria-hidden />
<span>{hint.text}</span> <span>{hint.text}</span>
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null} {hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
</p> </p>
@@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar" data-slot="avatar"
data-size={size} data-size={size}
className={cn( className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", "group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border data-[size=lg]:size-10 data-[size=sm]:size-6",
className className
)} )}
{...props} {...props}
+5 -5
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!",
{ {
variants: { variants: {
variant: { variant: {
@@ -13,14 +13,14 @@ const badgeVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 [a]:hover:bg-destructive/20",
warning: warning:
"bg-warning/15 text-warning-foreground focus-visible:ring-warning/30 [a]:hover:bg-warning/25", "bg-warning/15 text-warning-ink focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-muted-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary-ink underline-offset-4 hover:underline",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95", "bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
outline: outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary-ink underline-offset-4 hover:underline",
}, },
size: { size: {
// Phones get at least 40 px: tapped with a thumb, often with gloves. // Phones get at least 40 px: tapped with a thumb, often with gloves.
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground", "peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
className className
)} )}
{...props} {...props}
@@ -88,7 +88,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
className className
)} )}
{...props} {...props}
@@ -41,7 +41,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}
+3 -3
View File
@@ -58,9 +58,9 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-card data-active:text-primary dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "data-active:bg-card data-active:text-primary-ink",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className className
)} )}
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
className className
)} )}
{...props} {...props}
@@ -0,0 +1,54 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
import { Button } from "./button";
const SRC = join(__dirname, "..", "..");
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
});
}
/** A Tailwind class with the dark variant, e.g. `dark:bg-input/30` (not an object key `dark: x`). */
const DARK_CLASS = /(^|[\s"'`])dark:[^\s"'`]/m;
describe("ui colours after the value switch (spec 10.8 step 4)", () => {
it("the default button uses solid primary with primary-foreground and no gradient", () => {
render(<Button>Speichern</Button>);
const classes = screen.getByRole("button", { name: "Speichern" }).className;
expect(classes).toMatch(/(^|\s)bg-primary(\s|$)/);
expect(classes).toMatch(/(^|\s)text-primary-foreground(\s|$)/);
expect(classes).not.toMatch(/gradient|background-image/);
});
it("link-style buttons and badges use the primary ink, not the honey fill colour, for text", () => {
render(
<>
<Button variant="link">Mehr</Button>
<Badge variant="link">Link</Badge>
<Badge variant="warning">Achtung</Badge>
</>,
);
expect(screen.getByRole("button", { name: "Mehr" }).className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
expect(screen.getByText("Link").className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/);
});
it("ui components contain no dark: classes", () => {
const offenders = sourceFiles(join(SRC, "components", "ui")).filter((file) =>
DARK_CLASS.test(readFileSync(file, "utf8")),
);
expect(offenders).toEqual([]);
});
it("no source file uses dark: classes - a mode is only another token set", () => {
const offenders = sourceFiles(SRC).filter((file) => DARK_CLASS.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
});
@@ -254,7 +254,7 @@ export function VoiceRecorder({
> >
{rejected ? null : ( {rejected ? null : (
<CheckCircle2 <CheckCircle2
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground" className="mt-0.5 size-5 shrink-0 text-condition-good-ink"
aria-hidden aria-hidden
/> />
)} )}
@@ -311,7 +311,7 @@ export function VoiceRecorder({
{queuedNoteId ? ( {queuedNoteId ? (
<p role="status" className="flex items-start gap-2 text-base font-medium"> <p role="status" className="flex items-start gap-2 text-base font-medium">
<CheckCircle2 <CheckCircle2
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground" className="mt-0.5 size-5 shrink-0 text-condition-good-ink"
aria-hidden aria-hidden
/> />
{QUEUED_TEXT} {QUEUED_TEXT}
@@ -18,7 +18,7 @@ export const APPEARANCE_KEYS = {
export type ThemeChoice = "light" | "dark" | "system"; export type ThemeChoice = "light" | "dark" | "system";
export type ContrastChoice = "normal" | "sun"; 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"; export const DEFAULT_THEME: ThemeChoice = "light";
/** Fired on window after every change of colour scheme or contrast (the 3D scene listens). */ /** 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 { export interface AppearanceEnvironment {
prefersDark: boolean; prefersDark: boolean;
prefersMore: boolean; prefersMore: boolean;
/** Dev flag NEXT_PUBLIC_APPEARANCE_DARK=1; removed with task A4. */
darkEnabled: boolean;
} }
export interface ResolvedAppearance { export interface ResolvedAppearance {
@@ -73,7 +71,7 @@ export function resolveAppearance(
const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME; const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME;
const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark); const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark);
return { return {
dark: env.darkEnabled && wantsDark, dark: wantsDark,
sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore, sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore,
motion: isMotionLevel(stored.motion) ? stored.motion : "full", 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). * 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. * 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 keys = JSON.stringify(APPEARANCE_KEYS);
const colors = JSON.stringify(THEME_COLORS); const colors = JSON.stringify(THEME_COLORS);
return `(function(){try{ 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; var w=window,root=document.documentElement;
function get(k){try{return w.localStorage.getItem(k)}catch(e){return null}} 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}} 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); var t=get(K.theme),c=get(K.contrast),m=get(K.motion);
if(t!=="light"&&t!=="dark"&&t!=="system")t=DEF; 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)}); var sun=c==="sun"?true:c==="normal"?false:mq(${JSON.stringify(MORE_CONTRAST_QUERY)});
if(m!=="full"&&m!=="subtle"&&m!=="off")m="full"; if(m!=="full"&&m!=="subtle"&&m!=="off")m="full";
root.classList.toggle("dark",dark); root.classList.toggle("dark",dark);
@@ -17,7 +17,7 @@ afterEach(() => {
}); });
describe("resolveAppearance", () => { describe("resolveAppearance", () => {
const env = { prefersDark: false, prefersMore: false, darkEnabled: true }; const env = { prefersDark: false, prefersMore: false };
it("defaults to light", () => { it("defaults to light", () => {
expect(DEFAULT_THEME).toBe("light"); expect(DEFAULT_THEME).toBe("light");
@@ -39,8 +39,9 @@ describe("resolveAppearance", () => {
expect(resolveAppearance({ contrast: "sun" }, env).sun).toBe(true); expect(resolveAppearance({ contrast: "sun" }, env).sun).toBe(true);
}); });
it("dark is ignored without the dev flag", () => { it("dark is selectable without a flag and only on request", () => {
expect(resolveAppearance({ theme: "dark" }, { ...env, darkEnabled: false }).dark).toBe(false); expect(resolveAppearance({ theme: "dark" }, env).dark).toBe(true);
expect(resolveAppearance({}, { ...env, prefersDark: true }).dark).toBe(false);
}); });
it("ignores garbage values", () => { it("ignores garbage values", () => {
@@ -65,7 +66,6 @@ describe("useAppearance", () => {
}); });
it("system follows matchMedia and reacts to a change", () => { it("system follows matchMedia and reacts to a change", () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
window.localStorage.setItem("apicore.theme", "system"); window.localStorage.setItem("apicore.theme", "system");
const { result } = renderHook(() => useAppearance()); const { result } = renderHook(() => useAppearance());
expect(result.current.resolvedTheme).toBe("light"); expect(result.current.resolvedTheme).toBe("light");
@@ -120,7 +120,6 @@ describe("useAppearance", () => {
}); });
it("a storage event from another tab is picked up", () => { it("a storage event from another tab is picked up", () => {
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
const { result } = renderHook(() => useAppearance()); const { result } = renderHook(() => useAppearance());
act(() => { act(() => {
@@ -132,13 +131,13 @@ describe("useAppearance", () => {
expect(document.documentElement).toHaveClass("dark"); expect(document.documentElement).toHaveClass("dark");
}); });
it("dark is ignored without the dev flag", () => { it("dark is selectable without a flag", () => {
window.localStorage.setItem("apicore.theme", "dark");
const { result } = renderHook(() => useAppearance()); const { result } = renderHook(() => useAppearance());
expect(result.current.resolvedTheme).toBe("light"); expect(result.current.resolvedTheme).toBe("light");
act(() => result.current.setTheme("dark")); act(() => result.current.setTheme("dark"));
expect(result.current.resolvedTheme).toBe("light"); expect(result.current.resolvedTheme).toBe("dark");
expect(document.documentElement).not.toHaveClass("dark"); expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
}); });
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => { it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
@@ -37,11 +37,6 @@ export interface Appearance {
setMotion(value: MotionLevel): void; 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). */ /** Values chosen in this visit; used when localStorage is blocked (private mode). */
const session: { theme?: string; contrast?: string } = {}; 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. */ /** Same effect as the inline script, for changes after the first paint. */
function applyToDocument(snapshot: Snapshot): void { function applyToDocument(snapshot: Snapshot): void {
const { dark, sun } = resolveAppearance(snapshot, { ...snapshot, darkEnabled: isDarkEnabled() }); const { dark, sun } = resolveAppearance(snapshot, snapshot);
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", dark); root.classList.toggle("dark", dark);
root.setAttribute("data-contrast", sun ? "sun" : "normal"); root.setAttribute("data-contrast", sun ? "sun" : "normal");
@@ -168,7 +163,7 @@ export function useAppearance(): Appearance {
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const state = useMemo(() => { const state = useMemo(() => {
const parsed = JSON.parse(raw) as Snapshot & { motion: MotionLevel }; const parsed = JSON.parse(raw) as Snapshot & { motion: MotionLevel };
const resolved = resolveAppearance(parsed, { ...parsed, darkEnabled: isDarkEnabled() }); const resolved = resolveAppearance(parsed, parsed);
return { return {
theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME, theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME,
contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const), contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const),
+85 -1
View File
@@ -1,5 +1,40 @@
import { describe, expect, it } from "vitest"; 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 { ADMIN_GROUP, NAV_GROUPS } from "@/app/(app)/nav-config";
import { QUICK_ACTIONS } from "@/app/(app)/quick-actions"; 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);
});
});
+5 -5
View File
@@ -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). // Tailwind-Klassen auf Basis der --condition-* Tokens aus globals.css (hell/dunkel-faehig).
export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = { export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-foreground", EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-ink",
GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground", GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-foreground", AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-ink",
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-foreground", WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-ink",
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-foreground", CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-ink",
}; };
/** Volksstärke 1-10 (besetzte Waben), as select values named by themselves. */ /** 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);
});
});
+67 -11
View File
@@ -1,14 +1,22 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet, type TokenSet } from "./css-tokens"; import {
atRuleDeclarations,
blend,
contrastOf,
hexOf,
inGamut,
oklchOf,
tokenSet,
type TokenSet,
} from "./css-tokens";
/** /**
* Task A4 sets this to true. Until then globals.css keeps the old base values for the tokens in * Task A4 switched globals.css to the end values (spec 4.2): the guard reads the real sets and
* TARGET; the guard measures the table against the end values so the plan numbers are proven * pins the base sets to TARGET. Before A4 it measured TARGET instead of the old values.
* now, and the switch then pins globals.css to exactly these values.
*/ */
export const VALUES_SWITCHED = false; export const VALUES_SWITCHED = true;
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8"); const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
const SETS: TokenSet[] = ["light", "dark", "sun-light", "sun-dark"]; const SETS: TokenSet[] = ["light", "dark", "sun-light", "sun-dark"];
@@ -88,7 +96,7 @@ const TARGET: Record<"light" | "dark", Record<string, string>> = {
}, },
}; };
/** A token as it will be after task A4: new tokens and the sun sets are final today. */ /** A token of a set as globals.css resolves it (before task A4: the TARGET end values). */
function value(set: TokenSet, token: string): string { function value(set: TokenSet, token: string): string {
const base = set === "light" || set === "sun-light" ? "light" : "dark"; const base = set === "light" || set === "sun-light" ? "light" : "dark";
const fromSun = set.startsWith("sun") ? own(set)[token] : undefined; const fromSun = set.startsWith("sun") ? own(set)[token] : undefined;
@@ -99,6 +107,9 @@ function value(set: TokenSet, token: string): string {
return found; return found;
} }
const rgb = (set: TokenSet, token: string): Rgb => oklchOf(value(set, token)); const rgb = (set: TokenSet, token: string): Rgb => oklchOf(value(set, token));
/** `oklch(0.80 …)` and `oklch(0.8 …)` are the same colour. */
const sameColour = (a: string | undefined, b: string) =>
a !== undefined && oklchOf(a).every((part, i) => part === oklchOf(b)[i]);
const TONES = ["excellent", "good", "average", "weak", "critical"] as const; const TONES = ["excellent", "good", "average", "weak", "critical"] as const;
const AREA_TONES = ["betrieb", "erfassung", "arbeit", "ernte", "neutral"] as const; const AREA_TONES = ["betrieb", "erfassung", "arbeit", "ernte", "neutral"] as const;
@@ -178,8 +189,6 @@ const NEW_COLOUR_TOKENS = [
...AREA_TONES.map((a) => `area-${a}`), ...AREA_TONES.map((a) => `area-${a}`),
...QUEEN_COLOURS.map((q) => `queen-${q}`), ...QUEEN_COLOURS.map((q) => `queen-${q}`),
]; ];
/** Old light values slightly outside sRGB (near-white with chroma); task A4 replaces them. */
const LEGACY_OUT_OF_GAMUT = ["primary-foreground", "sidebar-primary-foreground"];
const SUN_TABLE = [ const SUN_TABLE = [
"background", "card", "popover", "sidebar", "foreground", "primary-foreground", "ring", "background", "card", "popover", "sidebar", "foreground", "primary-foreground", "ring",
"muted", "muted-foreground", "border", "input", "primary-ink", "muted", "muted-foreground", "border", "input", "primary-ink",
@@ -202,7 +211,6 @@ describe("token sets", () => {
it.each(SETS)("all colour tokens are inside the sRGB gamut in %s", (set) => { it.each(SETS)("all colour tokens are inside the sRGB gamut in %s", (set) => {
const outside = Object.entries(sets[set]) const outside = Object.entries(sets[set])
.filter(([key]) => VALUES_SWITCHED || !LEGACY_OUT_OF_GAMUT.includes(key))
.filter(([, v]) => /^oklch\(/.test(v)) .filter(([, v]) => /^oklch\(/.test(v))
.filter(([, v]) => !inGamut(oklchOf(v))) .filter(([, v]) => !inGamut(oklchOf(v)))
.map(([k]) => k); .map(([k]) => k);
@@ -276,11 +284,43 @@ describe("token sets", () => {
it("VALUES_SWITCHED is flipped as soon as globals.css holds the end values", () => { it("VALUES_SWITCHED is flipped as soon as globals.css holds the end values", () => {
const allEqual = (["light", "dark"] as const).every((base) => const allEqual = (["light", "dark"] as const).every((base) =>
Object.entries(TARGET[base]).every(([token, v]) => sets[base][token] === v), Object.entries(TARGET[base]).every(([token, v]) => sameColour(sets[base][token], v)),
); );
expect(allEqual && !VALUES_SWITCHED).toBe(false); expect(allEqual && !VALUES_SWITCHED).toBe(false);
}); });
it("no --gradient-* token remains", () => {
expect(css).not.toMatch(/--gradient-/);
});
it("the honey of the brand mark (spec 12.1, #f0b13f) is the light --primary", () => {
expect(hexOf(oklchOf(sets.light.primary))).toBe("#f0b13f");
});
it("the sidebar follows card, primary, accent, border and ring in light and dark", () => {
for (const set of ["light", "dark"] as const) {
const pairs: Array<[string, string]> = [
["sidebar", "card"],
["sidebar-foreground", "foreground"],
["sidebar-primary", "primary"],
["sidebar-primary-foreground", "primary-foreground"],
["sidebar-accent", "accent"],
["sidebar-accent-foreground", "accent-foreground"],
["sidebar-border", "border"],
["sidebar-ring", "ring"],
];
for (const [sidebar, base] of pairs)
expect(sets[set][sidebar], `--${sidebar} in ${set}`).toBe(sets[set][base]);
}
});
it("card and popover text is the foreground in every set", () => {
for (const set of SETS) {
expect(sets[set]["card-foreground"]).toBe(sets[set].foreground);
expect(sets[set]["popover-foreground"]).toBe(sets[set].foreground);
}
});
it("forced-colors maps ring, border and input to CanvasText", () => { it("forced-colors maps ring, border and input to CanvasText", () => {
const forced = atRuleDeclarations(css, "@media (forced-colors: active)"); const forced = atRuleDeclarations(css, "@media (forced-colors: active)");
expect(forced).toMatchObject({ ring: "CanvasText", border: "CanvasText", input: "CanvasText" }); expect(forced).toMatchObject({ ring: "CanvasText", border: "CanvasText", input: "CanvasText" });
@@ -290,7 +330,7 @@ describe("token sets", () => {
if (!VALUES_SWITCHED) return; if (!VALUES_SWITCHED) return;
for (const base of ["light", "dark"] as const) for (const base of ["light", "dark"] as const)
for (const [token, expected] of Object.entries(TARGET[base])) for (const [token, expected] of Object.entries(TARGET[base]))
expect(sets[base][token], `--${token} in ${base}`).toBe(expected); expect(oklchOf(sets[base][token]), `--${token} in ${base}`).toEqual(oklchOf(expected));
}); });
}); });
@@ -327,7 +367,23 @@ describe.each(SETS)("contrast guard in %s", (set) => {
}); });
}); });
describe.each(SETS)("condition tones in %s", (set) => {
it.each(TONES)("%s has surface, text-on-surface and ink, each above its limit", (tone) => {
const surface = rgb(set, `condition-${tone}`);
const ink = rgb(set, `condition-${tone}-ink`);
expect(contrastOf(rgb(set, `condition-${tone}-foreground`), surface)).toBeGreaterThanOrEqual(4.5);
expect(contrastOf(ink, tint(set, `condition-${tone}`))).toBeGreaterThanOrEqual(4.5);
expect(contrastOf(ink, rgb(set, "card"))).toBeGreaterThanOrEqual(4.5);
expect(contrastOf(surface, rgb(set, "card"))).toBeGreaterThanOrEqual(3);
});
});
describe("the stage frame", () => { describe("the stage frame", () => {
it("holo edge keeps 4.5:1 on the stage in light and dark (was rating-contrast.spec)", () => {
for (const set of ["light", "dark"] as const)
expect(contrastOf(rgb(set, "holo-edge"), rgb(set, "holo-bg"))).toBeGreaterThanOrEqual(4.5);
});
it(".holo-stage sets --ring to --holo-edge-strong", () => { it(".holo-stage sets --ring to --holo-edge-strong", () => {
expect(css).toMatch(/\.holo-stage\s*\{[^}]*--ring:\s*var\(--holo-edge-strong\)/); expect(css).toMatch(/\.holo-stage\s*\{[^}]*--ring:\s*var\(--holo-edge-strong\)/);
}); });
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { alphaOf, atRuleDeclarations, blend, contrastOf, inGamut, oklchOf, tokenSet } from "./css-tokens"; import { alphaOf, atRuleDeclarations, blend, contrastOf, hexOf, inGamut, oklchOf, tokenSet } from "./css-tokens";
const CSS = ` const CSS = `
@theme inline { --color-x: var(--x); } @theme inline { --color-x: var(--x); }
@@ -19,6 +19,12 @@ describe("css-tokens", () => {
expect(() => oklchOf("1rem")).toThrow(); expect(() => oklchOf("1rem")).toThrow();
}); });
it("converts oklch to an sRGB hex code", () => {
expect(hexOf([1, 0, 0])).toBe("#ffffff");
expect(hexOf([0, 0, 0])).toBe("#000000");
expect(hexOf(oklchOf("oklch(0.968 0.014 85)"))).toBe("#f9f4ea");
});
it("contrast of black on white is 21", () => { it("contrast of black on white is 21", () => {
expect(contrastOf([0, 0, 0], [1, 0, 0])).toBeCloseTo(21, 1); expect(contrastOf([0, 0, 0], [1, 0, 0])).toBeCloseTo(21, 1);
expect(contrastOf([1, 0, 0], [1, 0, 0])).toBeCloseTo(1, 5); expect(contrastOf([1, 0, 0], [1, 0, 0])).toBeCloseTo(1, 5);
@@ -155,6 +155,15 @@ function linearRgbToOklch([r, g, b]: number[]): Oklch {
return [lightness, Math.hypot(a, bb), hue]; return [lightness, Math.hypot(a, bb), hue];
} }
/** sRGB hex code (`#rrggbb`) of an OKLCH colour, clipped to the gamut. */
export function hexOf(color: Oklch): string {
const byte = (channel: number) =>
Math.round(toGamma(clip(channel)) * 255)
.toString(16)
.padStart(2, "0");
return `#${rawLinearRgb(color).map(byte).join("")}`;
}
export function inGamut(color: Oklch): boolean { export function inGamut(color: Oklch): boolean {
return rawLinearRgb(color).every((c) => c >= -GAMUT_EPSILON && c <= 1 + GAMUT_EPSILON); return rawLinearRgb(color).every((c) => c >= -GAMUT_EPSILON && c <= 1 + GAMUT_EPSILON);
} }