feat(t5): neue Farbwerte, Dunkel für alle, Farb-Codemod (T5a A4)
- globals.css: Hell, Dunkel und Druck mit den Spec-Werten aus 4.2, Fokusring = --primary-ink, Verlaufs-Token entfernt. - Codemod scripts/codemod-t5-colors.mjs: text-primary/-warning auf -ink, Vordergrund nur auf voller Fläche, Auswahlrahmen dunkel; --check-Modus und Spec halten src sauber. - Kontrast-Wächter misst die echte globals.css (VALUES_SWITCHED), alle 21 Zeilen aus 4.4 in allen vier Sätzen; keine dark:-Klassen mehr in src. - Dunkel ist ohne Entwickler-Schalter wählbar, Standard bleibt Hell. - Review-Nacharbeit: Auswahl-Umrisse und aktueller Schritt in Vordergrund, deaktivierte Felder bg-muted, Avatar ohne Blend, Honig-Check-Rahmen über --input. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6edae3efec
commit
d8238855fc
91 files changed
+658
-376
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
|
||||
className={`flex items-center gap-2 rounded-lg border p-2.5 text-sm ${
|
||||
todaySuitability
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-warning/40 bg-warning/10 text-warning-foreground"
|
||||
? "border-primary/30 bg-primary/5 text-primary-ink"
|
||||
: "border-warning/40 bg-warning/10 text-warning-ink"
|
||||
}`}
|
||||
>
|
||||
{todaySuitability ? (
|
||||
@@ -155,7 +155,7 @@ export function WeatherCard({
|
||||
{day.tempMinC !== null ? `${Math.round(day.tempMinC)}°` : "–"}
|
||||
</span>
|
||||
{day.precipitationMm !== null && day.precipitationMm > 0 ? (
|
||||
<span className="text-xs text-primary">
|
||||
<span className="text-xs text-primary-ink">
|
||||
{day.precipitationMm.toFixed(0)} mm
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -26,7 +26,7 @@ const PRINT_DONE = "Etiketten-PDF heruntergeladen.";
|
||||
const PROOF_DONE = "Probedruck (MUSTER) heruntergeladen. Er ist nicht zum Verkauf gedacht.";
|
||||
const CHECK_FALLBACK = "Die Pflichtangaben konnten nicht geprüft werden.";
|
||||
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";
|
||||
|
||||
@@ -301,7 +301,7 @@ export function PrintLabelsCard({
|
||||
<ul className="flex flex-col gap-1">
|
||||
{warnings.map((warning, index) => (
|
||||
<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 className="font-medium">Hinweis:</span> {warning.title}
|
||||
</span>
|
||||
|
||||
@@ -452,14 +452,14 @@ export default function NewBottlingBatchPage() {
|
||||
/>
|
||||
{withdrawalCheckFailed ? (
|
||||
<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
|
||||
</p>
|
||||
) : null}
|
||||
{withdrawalHints.length > 0 ? (
|
||||
<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">
|
||||
<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
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1">
|
||||
|
||||
@@ -28,7 +28,7 @@ export function BottomNav({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"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" />
|
||||
|
||||
@@ -355,7 +355,7 @@ export default function CalendarPage() {
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-primary",
|
||||
isSelected && "outline-2 -outline-offset-2 outline-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -209,7 +209,7 @@ function VarroaTrendChart({
|
||||
<div className="flex flex-col gap-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
className="w-full text-primary"
|
||||
className="w-full text-primary-ink"
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descId}
|
||||
@@ -293,7 +293,7 @@ function ChartLegend({ hasRed, hasYellow }: { hasRed: boolean; hasYellow: boolea
|
||||
return (
|
||||
<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">
|
||||
<span className="text-primary">
|
||||
<span className="text-primary-ink">
|
||||
<LegendSwatch width={2} />
|
||||
</span>
|
||||
<span>Milbenfall pro Tag</span>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createColony } from "@/lib/colonies/api";
|
||||
import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
|
||||
@@ -33,7 +33,7 @@ function Step({ number, title, titleId, state, children }: StepProps) {
|
||||
return (
|
||||
<li
|
||||
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
|
||||
|
||||
@@ -136,7 +136,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
{NOTICE_KINDS.map((entry) => (
|
||||
<label
|
||||
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
|
||||
type="radio"
|
||||
|
||||
@@ -17,7 +17,7 @@ const STATE_ICON: Record<ReadinessItemState, LucideIcon> = {
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const { href, label, available } = fixLinkFor(itemKey, subject);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader } from "@/components/detail-header";
|
||||
import { StandNote } from "@/components/offline/stand-note";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
{href ? (
|
||||
<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}
|
||||
>
|
||||
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"
|
||||
>
|
||||
<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
|
||||
/>
|
||||
<span className="font-heading text-lg font-semibold tabular-nums">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { fetchEquipment, updateEquipment } from "@/lib/equipment/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createEquipment } from "@/lib/equipment/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchExtraction } from "@/lib/extractions/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import {
|
||||
createExtraction,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createFeeding } from "@/lib/feedings/api";
|
||||
import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHarvest } from "@/lib/harvests/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function NewHarvestPage() {
|
||||
</Field>
|
||||
{withdrawalQuery.isError ? (
|
||||
<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
|
||||
Völker selbst ansehen.
|
||||
</p>
|
||||
|
||||
@@ -114,7 +114,7 @@ function MovementList({ movements }: { movements: HoneyBatchMovement[] }) {
|
||||
className={
|
||||
movement.quantityGrams < 0
|
||||
? "font-semibold tabular-nums text-destructive"
|
||||
: "font-semibold tabular-nums text-primary"
|
||||
: "font-semibold tabular-nums text-primary-ink"
|
||||
}
|
||||
>
|
||||
{formatSignedKg(gramsToKg(movement.quantityGrams))}
|
||||
|
||||
@@ -98,7 +98,7 @@ function HiveOpsList({ ops, base, catalog, onChange, onUndone, onEmptied }: Hive
|
||||
</div>
|
||||
{blocked?.index === index ? (
|
||||
<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.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -734,7 +734,7 @@ function InspectionCaptureForm({
|
||||
{noQueenSigns ? (
|
||||
<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"
|
||||
className="mt-0.5 size-4 shrink-0 text-warning-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
Weder Königin noch Stifte gesehen. Nach dem Speichern wird
|
||||
@@ -894,7 +894,7 @@ function InspectionCaptureForm({
|
||||
/>
|
||||
{values.feedLegacyText !== null ? (
|
||||
<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)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { buildBook } from "@/lib/medicines/book";
|
||||
|
||||
@@ -37,7 +37,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
||||
<SheetTrigger
|
||||
className={cn(
|
||||
"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" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context";
|
||||
import {
|
||||
OFFLINE_PACKS_QUERY_KEY,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { FormEvent, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { createProductionBatch } from "@/lib/production-batches/api";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchProduct } from "@/lib/products/api";
|
||||
import { fetchProductionBatches } from "@/lib/production-batches/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createProduct } from "@/lib/products/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
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();
|
||||
});
|
||||
|
||||
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 />);
|
||||
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: /^Dunkel/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("radio", { name: /^Wie Gerät/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /^Hell/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Dunkel/ })).not.toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Wie Gerät/ })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Dunkel and Wie Gerät with the dev flag", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
it("dark is selectable without a flag", async () => {
|
||||
render(<AppearanceSection />);
|
||||
|
||||
await userEvent.click(screen.getByRole("radio", { name: /^Dunkel/ }));
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import { useId } from "react";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
isDarkEnabled,
|
||||
useAppearance,
|
||||
type ContrastChoice,
|
||||
type ThemeChoice,
|
||||
} from "@/lib/appearance/appearance";
|
||||
import { useAppearance, type ContrastChoice, type ThemeChoice } from "@/lib/appearance/appearance";
|
||||
import { MOTION_LEVELS, type MotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
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() {
|
||||
const { theme, contrast, motion, setTheme, setContrast, setMotion } = useAppearance();
|
||||
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 (
|
||||
<Card>
|
||||
@@ -56,7 +49,7 @@ export function AppearanceSection() {
|
||||
Farbschema
|
||||
</p>
|
||||
<ChoiceGroup
|
||||
options={themeOptions}
|
||||
options={THEME_OPTIONS}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
labelledBy={ids.theme}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { fetchQueen } from "@/lib/queens/api";
|
||||
import { QUEEN_STATUS_LABELS } from "@/lib/queens/labels";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createQueen, fetchQueens } from "@/lib/queens/api";
|
||||
import { useFormDraft } from "@/lib/forms/use-form-draft";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
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>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-warning-foreground">
|
||||
<ShieldAlert className="size-3.5 text-warning" aria-hidden />
|
||||
<span className="flex items-center gap-1 text-warning-ink">
|
||||
<ShieldAlert className="size-3.5 text-warning-ink" aria-hidden />
|
||||
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
|
||||
</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"
|
||||
>
|
||||
<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
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
|
||||
@@ -133,7 +133,7 @@ function ElementBox({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute cursor-move touch-none select-none",
|
||||
selected && "outline outline-2 outline-primary",
|
||||
selected && "outline outline-2 outline-foreground",
|
||||
)}
|
||||
style={{
|
||||
left: box.left,
|
||||
@@ -146,7 +146,7 @@ function ElementBox({
|
||||
{renderContent(element, scale)}
|
||||
{selected ? (
|
||||
<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) => {
|
||||
event.stopPropagation();
|
||||
onStartResize(event);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import { createLabelTemplate } from "@/lib/label-templates/api";
|
||||
import { FormBackLink } from "@/components/form/form-back-link";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { UsersTab } from "./users-tab";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createTask } from "@/lib/tasks/api";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { fetchUsers } from "@/lib/users/api";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormEvent, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
|
||||
@@ -102,55 +102,52 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.955 0.017 80);
|
||||
--foreground: oklch(0.22 0.028 55);
|
||||
--background: oklch(0.968 0.014 85);
|
||||
--foreground: oklch(0.21 0.03 55);
|
||||
--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-foreground: oklch(0.22 0.028 55);
|
||||
--primary: oklch(0.6 0.15 48);
|
||||
--primary-foreground: oklch(0.99 0.02 85);
|
||||
--popover-foreground: oklch(0.21 0.03 55);
|
||||
--primary: oklch(0.8 0.145 78);
|
||||
--primary-foreground: oklch(0.21 0.035 55);
|
||||
--secondary: oklch(0.44 0.07 145);
|
||||
--secondary-foreground: oklch(0.98 0.015 100);
|
||||
--muted: oklch(0.9 0.02 82);
|
||||
--muted-foreground: oklch(0.48 0.032 65);
|
||||
--accent: oklch(0.93 0.032 74);
|
||||
--accent-foreground: oklch(0.26 0.045 50);
|
||||
--destructive: oklch(0.5 0.16 30);
|
||||
--border: oklch(0.85 0.024 78);
|
||||
--input: oklch(0.85 0.024 78);
|
||||
--ring: oklch(0.6 0.15 48);
|
||||
--warning: oklch(0.68 0.14 75);
|
||||
--warning-foreground: oklch(0.24 0.04 60);
|
||||
--success: oklch(0.46 0.06 145);
|
||||
--muted: oklch(0.925 0.018 82);
|
||||
--muted-foreground: oklch(0.44 0.03 60);
|
||||
--accent: oklch(0.935 0.03 78);
|
||||
--accent-foreground: oklch(0.25 0.045 50);
|
||||
--destructive: oklch(0.5 0.17 28);
|
||||
--border: oklch(0.86 0.022 78);
|
||||
--input: oklch(0.6 0.035 65);
|
||||
--ring: oklch(0.47 0.115 55);
|
||||
--warning: oklch(0.79 0.15 80);
|
||||
--warning-foreground: oklch(0.23 0.04 60);
|
||||
--success: oklch(0.46 0.08 145);
|
||||
--success-foreground: oklch(0.98 0.015 100);
|
||||
--condition-excellent: oklch(0.58 0.13 145);
|
||||
--condition-excellent-foreground: oklch(0.16 0.04 145);
|
||||
--condition-good: oklch(0.64 0.13 118);
|
||||
--condition-good-foreground: oklch(0.26 0.05 118);
|
||||
--condition-average: oklch(0.7 0.14 90);
|
||||
--condition-average-foreground: oklch(0.28 0.05 90);
|
||||
--condition-weak: oklch(0.68 0.15 55);
|
||||
--condition-weak-foreground: oklch(0.26 0.05 55);
|
||||
--condition-critical: oklch(0.64 0.16 30);
|
||||
--condition-excellent: oklch(0.62 0.13 150);
|
||||
--condition-excellent-foreground: oklch(0.15 0.04 150);
|
||||
--condition-good: oklch(0.645 0.13 125);
|
||||
--condition-good-foreground: oklch(0.17 0.04 125);
|
||||
--condition-average: oklch(0.665 0.125 88);
|
||||
--condition-average-foreground: oklch(0.19 0.04 90);
|
||||
--condition-weak: oklch(0.665 0.14 58);
|
||||
--condition-weak-foreground: oklch(0.18 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.17 30);
|
||||
--condition-critical-foreground: oklch(0.15 0.04 30);
|
||||
--chart-1: oklch(0.6 0.135 50);
|
||||
--chart-2: oklch(0.46 0.06 145);
|
||||
--chart-3: oklch(0.68 0.14 75);
|
||||
--chart-4: oklch(0.5 0.16 30);
|
||||
--chart-5: oklch(0.55 0.05 250);
|
||||
--chart-1: oklch(0.56 0.13 55);
|
||||
--chart-2: oklch(0.46 0.08 145);
|
||||
--chart-3: oklch(0.6 0.12 85);
|
||||
--chart-4: oklch(0.5 0.17 28);
|
||||
--chart-5: oklch(0.52 0.08 250);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.995 0.004 90);
|
||||
--sidebar-foreground: oklch(0.22 0.028 55);
|
||||
--sidebar-primary: oklch(0.6 0.15 48);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.02 85);
|
||||
--sidebar-accent: oklch(0.93 0.032 74);
|
||||
--sidebar-accent-foreground: oklch(0.26 0.045 50);
|
||||
--sidebar-border: oklch(0.85 0.024 78);
|
||||
--sidebar-ring: oklch(0.6 0.15 48);
|
||||
--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%);
|
||||
--sidebar-foreground: oklch(0.21 0.03 55);
|
||||
--sidebar-primary: oklch(0.8 0.145 78);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.035 55);
|
||||
--sidebar-accent: oklch(0.935 0.03 78);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.045 50);
|
||||
--sidebar-border: oklch(0.86 0.022 78);
|
||||
--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-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). */
|
||||
@@ -160,7 +157,7 @@
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--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);
|
||||
--warning-ink: oklch(0.5 0.11 65);
|
||||
--destructive-foreground: oklch(0.985 0.005 30);
|
||||
@@ -188,54 +185,51 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.2 0.018 55);
|
||||
--foreground: oklch(0.94 0.02 80);
|
||||
--card: oklch(0.25 0.02 55);
|
||||
--card-foreground: oklch(0.94 0.02 80);
|
||||
--popover: oklch(0.25 0.02 55);
|
||||
--popover-foreground: oklch(0.94 0.02 80);
|
||||
--primary: oklch(0.72 0.13 55);
|
||||
--primary-foreground: oklch(0.2 0.02 55);
|
||||
--secondary: oklch(0.62 0.075 145);
|
||||
--secondary-foreground: oklch(0.15 0.02 145);
|
||||
--muted: oklch(0.29 0.018 55);
|
||||
--muted-foreground: oklch(0.7 0.025 75);
|
||||
--accent: oklch(0.32 0.03 60);
|
||||
--accent-foreground: oklch(0.94 0.02 80);
|
||||
--destructive: oklch(0.62 0.16 30);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.72 0.13 55);
|
||||
--warning: oklch(0.74 0.13 75);
|
||||
--background: oklch(0.19 0.016 55);
|
||||
--foreground: oklch(0.95 0.018 80);
|
||||
--card: oklch(0.235 0.018 55);
|
||||
--card-foreground: oklch(0.95 0.018 80);
|
||||
--popover: oklch(0.235 0.018 55);
|
||||
--popover-foreground: oklch(0.95 0.018 80);
|
||||
--primary: oklch(0.8 0.14 78);
|
||||
--primary-foreground: oklch(0.19 0.03 55);
|
||||
--secondary: oklch(0.66 0.08 145);
|
||||
--secondary-foreground: oklch(0.16 0.02 145);
|
||||
--muted: oklch(0.28 0.018 55);
|
||||
--muted-foreground: oklch(0.76 0.025 75);
|
||||
--accent: oklch(0.31 0.03 60);
|
||||
--accent-foreground: oklch(0.95 0.018 80);
|
||||
--destructive: oklch(0.7 0.15 28);
|
||||
--border: oklch(0.34 0.02 60);
|
||||
--input: oklch(0.56 0.025 70);
|
||||
--ring: oklch(0.82 0.13 80);
|
||||
--warning: oklch(0.8 0.14 80);
|
||||
--warning-foreground: oklch(0.2 0.03 60);
|
||||
--success: oklch(0.62 0.075 145);
|
||||
--success-foreground: oklch(0.15 0.02 145);
|
||||
--condition-excellent: oklch(0.66 0.11 145);
|
||||
--condition-excellent-foreground: oklch(0.2 0.04 145);
|
||||
--condition-good: oklch(0.72 0.12 118);
|
||||
--condition-good-foreground: oklch(0.22 0.04 118);
|
||||
--condition-average: oklch(0.76 0.13 90);
|
||||
--condition-average-foreground: oklch(0.24 0.04 90);
|
||||
--condition-weak: oklch(0.74 0.13 55);
|
||||
--condition-weak-foreground: oklch(0.22 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.16 30);
|
||||
--condition-critical-foreground: oklch(0.2 0.04 30);
|
||||
--chart-1: oklch(0.72 0.13 55);
|
||||
--chart-2: oklch(0.62 0.075 145);
|
||||
--chart-3: oklch(0.74 0.13 75);
|
||||
--chart-4: oklch(0.62 0.16 30);
|
||||
--chart-5: oklch(0.65 0.06 250);
|
||||
--sidebar: oklch(0.22 0.02 55);
|
||||
--sidebar-foreground: oklch(0.94 0.02 80);
|
||||
--sidebar-primary: oklch(0.72 0.13 55);
|
||||
--sidebar-primary-foreground: oklch(0.2 0.02 55);
|
||||
--sidebar-accent: oklch(0.32 0.03 60);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.02 80);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.72 0.13 55);
|
||||
--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%);
|
||||
--success: oklch(0.7 0.09 145);
|
||||
--success-foreground: oklch(0.16 0.02 145);
|
||||
--condition-excellent: oklch(0.68 0.12 150);
|
||||
--condition-excellent-foreground: oklch(0.17 0.04 150);
|
||||
--condition-good: oklch(0.72 0.12 125);
|
||||
--condition-good-foreground: oklch(0.18 0.04 125);
|
||||
--condition-average: oklch(0.77 0.13 90);
|
||||
--condition-average-foreground: oklch(0.2 0.04 90);
|
||||
--condition-weak: oklch(0.74 0.13 60);
|
||||
--condition-weak-foreground: oklch(0.19 0.04 55);
|
||||
--condition-critical: oklch(0.68 0.16 28);
|
||||
--condition-critical-foreground: oklch(0.16 0.04 30);
|
||||
--chart-1: oklch(0.78 0.13 70);
|
||||
--chart-2: oklch(0.7 0.09 145);
|
||||
--chart-3: oklch(0.82 0.12 90);
|
||||
--chart-4: oklch(0.7 0.15 28);
|
||||
--chart-5: oklch(0.72 0.08 250);
|
||||
--sidebar: oklch(0.235 0.018 55);
|
||||
--sidebar-foreground: oklch(0.95 0.018 80);
|
||||
--sidebar-primary: oklch(0.8 0.14 78);
|
||||
--sidebar-primary-foreground: oklch(0.19 0.03 55);
|
||||
--sidebar-accent: oklch(0.31 0.03 60);
|
||||
--sidebar-accent-foreground: oklch(0.95 0.018 80);
|
||||
--sidebar-border: oklch(0.34 0.02 60);
|
||||
--sidebar-ring: oklch(0.82 0.13 80);
|
||||
--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%);
|
||||
/* 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-label-bg: oklch(0.26 0.04 250);
|
||||
--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);
|
||||
--warning-ink: oklch(0.82 0.13 82);
|
||||
--destructive-foreground: oklch(0.17 0.03 30);
|
||||
@@ -364,51 +358,51 @@
|
||||
.dark,
|
||||
:root[data-contrast="sun"],
|
||||
.dark[data-contrast="sun"] {
|
||||
--background: oklch(0.955 0.017 80);
|
||||
--foreground: oklch(0.22 0.028 55);
|
||||
--background: oklch(0.968 0.014 85);
|
||||
--foreground: oklch(0.21 0.03 55);
|
||||
--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-foreground: oklch(0.22 0.028 55);
|
||||
--primary: oklch(0.6 0.15 48);
|
||||
--primary-foreground: oklch(0.99 0.02 85);
|
||||
--popover-foreground: oklch(0.21 0.03 55);
|
||||
--primary: oklch(0.8 0.145 78);
|
||||
--primary-foreground: oklch(0.21 0.035 55);
|
||||
--secondary: oklch(0.44 0.07 145);
|
||||
--secondary-foreground: oklch(0.98 0.015 100);
|
||||
--muted: oklch(0.9 0.02 82);
|
||||
--muted-foreground: oklch(0.48 0.032 65);
|
||||
--accent: oklch(0.93 0.032 74);
|
||||
--accent-foreground: oklch(0.26 0.045 50);
|
||||
--destructive: oklch(0.5 0.16 30);
|
||||
--border: oklch(0.85 0.024 78);
|
||||
--input: oklch(0.85 0.024 78);
|
||||
--ring: oklch(0.6 0.15 48);
|
||||
--warning: oklch(0.68 0.14 75);
|
||||
--warning-foreground: oklch(0.24 0.04 60);
|
||||
--success: oklch(0.46 0.06 145);
|
||||
--muted: oklch(0.925 0.018 82);
|
||||
--muted-foreground: oklch(0.44 0.03 60);
|
||||
--accent: oklch(0.935 0.03 78);
|
||||
--accent-foreground: oklch(0.25 0.045 50);
|
||||
--destructive: oklch(0.5 0.17 28);
|
||||
--border: oklch(0.86 0.022 78);
|
||||
--input: oklch(0.6 0.035 65);
|
||||
--ring: oklch(0.47 0.115 55);
|
||||
--warning: oklch(0.79 0.15 80);
|
||||
--warning-foreground: oklch(0.23 0.04 60);
|
||||
--success: oklch(0.46 0.08 145);
|
||||
--success-foreground: oklch(0.98 0.015 100);
|
||||
--condition-excellent: oklch(0.58 0.13 145);
|
||||
--condition-excellent-foreground: oklch(0.16 0.04 145);
|
||||
--condition-good: oklch(0.64 0.13 118);
|
||||
--condition-good-foreground: oklch(0.26 0.05 118);
|
||||
--condition-average: oklch(0.7 0.14 90);
|
||||
--condition-average-foreground: oklch(0.28 0.05 90);
|
||||
--condition-weak: oklch(0.68 0.15 55);
|
||||
--condition-weak-foreground: oklch(0.26 0.05 55);
|
||||
--condition-critical: oklch(0.64 0.16 30);
|
||||
--condition-excellent: oklch(0.62 0.13 150);
|
||||
--condition-excellent-foreground: oklch(0.15 0.04 150);
|
||||
--condition-good: oklch(0.645 0.13 125);
|
||||
--condition-good-foreground: oklch(0.17 0.04 125);
|
||||
--condition-average: oklch(0.665 0.125 88);
|
||||
--condition-average-foreground: oklch(0.19 0.04 90);
|
||||
--condition-weak: oklch(0.665 0.14 58);
|
||||
--condition-weak-foreground: oklch(0.18 0.04 55);
|
||||
--condition-critical: oklch(0.62 0.17 30);
|
||||
--condition-critical-foreground: oklch(0.15 0.04 30);
|
||||
--chart-1: oklch(0.6 0.135 50);
|
||||
--chart-2: oklch(0.46 0.06 145);
|
||||
--chart-3: oklch(0.68 0.14 75);
|
||||
--chart-4: oklch(0.5 0.16 30);
|
||||
--chart-5: oklch(0.55 0.05 250);
|
||||
--chart-1: oklch(0.56 0.13 55);
|
||||
--chart-2: oklch(0.46 0.08 145);
|
||||
--chart-3: oklch(0.6 0.12 85);
|
||||
--chart-4: oklch(0.5 0.17 28);
|
||||
--chart-5: oklch(0.52 0.08 250);
|
||||
--sidebar: oklch(0.995 0.004 90);
|
||||
--sidebar-foreground: oklch(0.22 0.028 55);
|
||||
--sidebar-primary: oklch(0.6 0.15 48);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.02 85);
|
||||
--sidebar-accent: oklch(0.93 0.032 74);
|
||||
--sidebar-accent-foreground: oklch(0.26 0.045 50);
|
||||
--sidebar-border: oklch(0.85 0.024 78);
|
||||
--sidebar-ring: oklch(0.6 0.15 48);
|
||||
--sidebar-foreground: oklch(0.21 0.03 55);
|
||||
--sidebar-primary: oklch(0.8 0.145 78);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.035 55);
|
||||
--sidebar-accent: oklch(0.935 0.03 78);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.045 50);
|
||||
--sidebar-border: oklch(0.86 0.022 78);
|
||||
--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-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);
|
||||
@@ -515,7 +509,7 @@
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
caret-color: var(--primary);
|
||||
caret-color: var(--primary-ink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +581,7 @@
|
||||
position: absolute;
|
||||
inset: 0.375rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 2px solid color-mix(in oklch, var(--primary) 65%, var(--border));
|
||||
border: 2px solid var(--input);
|
||||
background: var(--card);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
|
||||
@@ -62,7 +62,7 @@ interface InfoRowProps {
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<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>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
@@ -80,7 +80,7 @@ interface StatProps {
|
||||
function Stat({ icon: Icon, label, value }: StatProps) {
|
||||
return (
|
||||
<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-[0.7rem] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ export default function PublicBatchPage() {
|
||||
{batch.productName ?? "Honig"}
|
||||
</h1>
|
||||
{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" />
|
||||
{batch.honeyType}
|
||||
</span>
|
||||
@@ -197,7 +197,7 @@ export default function PublicBatchPage() {
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
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" />
|
||||
{link.label}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ApiaryMap({ apiaries }: ApiaryMapProps) {
|
||||
{apiaries.map((apiary) => (
|
||||
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
|
||||
<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}
|
||||
</Link>
|
||||
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(() => {
|
||||
|
||||
describe("appearanceScript", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("appearanceScript", () => {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun");
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle");
|
||||
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
|
||||
const root = document.documentElement;
|
||||
expect(root).toHaveClass("dark");
|
||||
@@ -44,7 +44,6 @@ describe("appearanceScript", () => {
|
||||
const themes = [null, "light", "dark", "system", "bogus"];
|
||||
const contrasts = [null, "normal", "sun", "bogus"];
|
||||
const motions = [null, "full", "subtle", "off", "bogus"];
|
||||
for (const darkEnabled of [true, false])
|
||||
for (const prefersDark of [true, false])
|
||||
for (const prefersMore of [true, false])
|
||||
for (const theme of themes)
|
||||
@@ -56,12 +55,9 @@ describe("appearanceScript", () => {
|
||||
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
|
||||
|
||||
run(appearanceScript(darkEnabled));
|
||||
run(appearanceScript());
|
||||
|
||||
const expected = resolveAppearance(
|
||||
{ theme, contrast, motion },
|
||||
{ prefersDark, prefersMore, darkEnabled },
|
||||
);
|
||||
const expected = resolveAppearance({ theme, contrast, motion }, { prefersDark, prefersMore });
|
||||
const root = document.documentElement;
|
||||
expect(root.classList.contains("dark")).toBe(expected.dark);
|
||||
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
|
||||
@@ -78,7 +74,7 @@ describe("appearanceScript", () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
} finally {
|
||||
Object.defineProperty(window, "localStorage", real);
|
||||
}
|
||||
@@ -102,14 +98,18 @@ describe("appearanceScript", () => {
|
||||
for (const [theme, contrast, color] of cases) {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
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", () => {
|
||||
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 (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: appearanceScript(process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1"),
|
||||
__html: appearanceScript(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,10 +27,8 @@ describe("area migration", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source other than globals.css uses the --gradient tokens", () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => !file.endsWith("globals.css"))
|
||||
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
|
||||
it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export function NextStepsView({
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 p-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 />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -13,6 +13,13 @@ describe("BrandMark", () => {
|
||||
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", () => {
|
||||
const { container, rerender } = render(<BrandMark />);
|
||||
const svg = container.querySelector("svg") as SVGElement;
|
||||
|
||||
@@ -15,7 +15,11 @@ interface BrandMarkProps {
|
||||
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) {
|
||||
const mark = (
|
||||
<svg
|
||||
|
||||
@@ -118,7 +118,7 @@ export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
|
||||
<div aria-live="polite">
|
||||
{restricted ? (
|
||||
<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>
|
||||
</p>
|
||||
) : 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-primary bg-primary font-medium text-primary-foreground",
|
||||
"border-foreground bg-primary font-medium text-primary-foreground",
|
||||
selected && option.selectedClassName,
|
||||
optionClassName,
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
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",
|
||||
checked
|
||||
? "border-primary bg-primary/10 font-medium"
|
||||
? "border-foreground bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -34,7 +34,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
? "border-foreground bg-primary text-primary-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function LabelCompliancePanel({
|
||||
<ul aria-label="Hinweise" className="flex flex-col gap-2">
|
||||
{result.warnings.map((warning, index) => (
|
||||
<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="font-medium">Hinweis: {warning.title}</span>
|
||||
{warning.detail ? (
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
|
||||
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
|
||||
{isPackStale(stored, now) ? (
|
||||
<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`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
</>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }:
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<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
|
||||
</p>
|
||||
{savedOffline ? (
|
||||
|
||||
@@ -190,7 +190,7 @@ function MovementList({ movements }: { movements: StockMovement[] }) {
|
||||
className={
|
||||
movement.quantity < 0
|
||||
? "font-semibold tabular-nums text-destructive"
|
||||
: "font-semibold tabular-nums text-primary"
|
||||
: "font-semibold tabular-nums text-primary-ink"
|
||||
}
|
||||
>
|
||||
{movement.quantity > 0 ? "+" : ""}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
|
||||
return (
|
||||
<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">
|
||||
<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 />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -62,7 +62,7 @@ function CombinedRow({
|
||||
return (
|
||||
<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">
|
||||
<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 />
|
||||
</span>
|
||||
<h3 className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TaskSourceBadge({
|
||||
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"
|
||||
>
|
||||
<Icon className="size-3.5 text-primary" aria-hidden />
|
||||
<Icon className="size-3.5 text-primary-ink" aria-hidden />
|
||||
{source.label}
|
||||
</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"
|
||||
>
|
||||
<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 />
|
||||
</span>
|
||||
<h3 id={headingId} className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function TodayPanel({
|
||||
</h2>
|
||||
<Link
|
||||
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
|
||||
</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 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">
|
||||
<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}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -445,7 +445,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
|
||||
function NothingDue() {
|
||||
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">
|
||||
<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 />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -321,7 +321,7 @@ export function BookingNotes({
|
||||
) : null}
|
||||
{warnings.map((warning) => (
|
||||
<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>
|
||||
</p>
|
||||
))}
|
||||
@@ -366,7 +366,7 @@ function TemperatureNote({
|
||||
const Icon = hint.warn ? AlertTriangle : Thermometer;
|
||||
return (
|
||||
<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>
|
||||
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@@ -13,14 +13,14 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
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:
|
||||
"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:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"hover:bg-muted hover:text-muted-foreground",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
|
||||
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:
|
||||
"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:
|
||||
"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:
|
||||
"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",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// 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
|
||||
data-slot="checkbox"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,9 +58,9 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
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",
|
||||
"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",
|
||||
"data-active:bg-card data-active:text-primary dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
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
|
||||
)}
|
||||
{...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 : (
|
||||
<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
|
||||
/>
|
||||
)}
|
||||
@@ -311,7 +311,7 @@ export function VoiceRecorder({
|
||||
{queuedNoteId ? (
|
||||
<p role="status" className="flex items-start gap-2 text-base font-medium">
|
||||
<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
|
||||
/>
|
||||
{QUEUED_TEXT}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const APPEARANCE_KEYS = {
|
||||
export type ThemeChoice = "light" | "dark" | "system";
|
||||
export type ContrastChoice = "normal" | "sun";
|
||||
|
||||
/** Colour scheme on a device that never chose one (open question 3, answer A). */
|
||||
/** Colour scheme on a device that never chose one (open question 3, answer A: dark only on request). */
|
||||
export const DEFAULT_THEME: ThemeChoice = "light";
|
||||
|
||||
/** Fired on window after every change of colour scheme or contrast (the 3D scene listens). */
|
||||
@@ -44,8 +44,6 @@ export interface StoredAppearance {
|
||||
export interface AppearanceEnvironment {
|
||||
prefersDark: boolean;
|
||||
prefersMore: boolean;
|
||||
/** Dev flag NEXT_PUBLIC_APPEARANCE_DARK=1; removed with task A4. */
|
||||
darkEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAppearance {
|
||||
@@ -73,7 +71,7 @@ export function resolveAppearance(
|
||||
const theme = isThemeChoice(stored.theme) ? stored.theme : DEFAULT_THEME;
|
||||
const wantsDark = theme === "dark" || (theme === "system" && env.prefersDark);
|
||||
return {
|
||||
dark: env.darkEnabled && wantsDark,
|
||||
dark: wantsDark,
|
||||
sun: isContrastChoice(stored.contrast) ? stored.contrast === "sun" : env.prefersMore,
|
||||
motion: isMotionLevel(stored.motion) ? stored.motion : "full",
|
||||
};
|
||||
@@ -88,17 +86,17 @@ export function themeColorOf({ dark, sun }: Pick<ResolvedAppearance, "dark" | "s
|
||||
* Source of the blocking inline script (an IIFE, ES5 so it parses everywhere).
|
||||
* Only constants go in; nothing from the request or from storage is embedded.
|
||||
*/
|
||||
export function appearanceScript(darkEnabled: boolean): string {
|
||||
export function appearanceScript(): string {
|
||||
const keys = JSON.stringify(APPEARANCE_KEYS);
|
||||
const colors = JSON.stringify(THEME_COLORS);
|
||||
return `(function(){try{
|
||||
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)},DARK=${darkEnabled ? "true" : "false"};
|
||||
var K=${keys},C=${colors},DEF=${JSON.stringify(DEFAULT_THEME)};
|
||||
var w=window,root=document.documentElement;
|
||||
function get(k){try{return w.localStorage.getItem(k)}catch(e){return null}}
|
||||
function mq(q){try{return w.matchMedia(q).matches}catch(e){return false}}
|
||||
var t=get(K.theme),c=get(K.contrast),m=get(K.motion);
|
||||
if(t!=="light"&&t!=="dark"&&t!=="system")t=DEF;
|
||||
var dark=DARK&&(t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)})));
|
||||
var dark=t==="dark"||(t==="system"&&mq(${JSON.stringify(DARK_QUERY)}));
|
||||
var sun=c==="sun"?true:c==="normal"?false:mq(${JSON.stringify(MORE_CONTRAST_QUERY)});
|
||||
if(m!=="full"&&m!=="subtle"&&m!=="off")m="full";
|
||||
root.classList.toggle("dark",dark);
|
||||
|
||||
@@ -17,7 +17,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveAppearance", () => {
|
||||
const env = { prefersDark: false, prefersMore: false, darkEnabled: true };
|
||||
const env = { prefersDark: false, prefersMore: false };
|
||||
|
||||
it("defaults to light", () => {
|
||||
expect(DEFAULT_THEME).toBe("light");
|
||||
@@ -39,8 +39,9 @@ describe("resolveAppearance", () => {
|
||||
expect(resolveAppearance({ contrast: "sun" }, env).sun).toBe(true);
|
||||
});
|
||||
|
||||
it("dark is ignored without the dev flag", () => {
|
||||
expect(resolveAppearance({ theme: "dark" }, { ...env, darkEnabled: false }).dark).toBe(false);
|
||||
it("dark is selectable without a flag and only on request", () => {
|
||||
expect(resolveAppearance({ theme: "dark" }, env).dark).toBe(true);
|
||||
expect(resolveAppearance({}, { ...env, prefersDark: true }).dark).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores garbage values", () => {
|
||||
@@ -65,7 +66,6 @@ describe("useAppearance", () => {
|
||||
});
|
||||
|
||||
it("system follows matchMedia and reacts to a change", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
window.localStorage.setItem("apicore.theme", "system");
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
@@ -120,7 +120,6 @@ describe("useAppearance", () => {
|
||||
});
|
||||
|
||||
it("a storage event from another tab is picked up", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APPEARANCE_DARK", "1");
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
|
||||
act(() => {
|
||||
@@ -132,13 +131,13 @@ describe("useAppearance", () => {
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("dark is ignored without the dev flag", () => {
|
||||
window.localStorage.setItem("apicore.theme", "dark");
|
||||
it("dark is selectable without a flag", () => {
|
||||
const { result } = renderHook(() => useAppearance());
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
act(() => result.current.setTheme("dark"));
|
||||
expect(result.current.resolvedTheme).toBe("light");
|
||||
expect(document.documentElement).not.toHaveClass("dark");
|
||||
expect(result.current.resolvedTheme).toBe("dark");
|
||||
expect(window.localStorage.getItem("apicore.theme")).toBe("dark");
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("a motion change reaches useEffectiveMotionLevel in the same tab", () => {
|
||||
|
||||
@@ -37,11 +37,6 @@ export interface Appearance {
|
||||
setMotion(value: MotionLevel): void;
|
||||
}
|
||||
|
||||
/** Whether "Dunkel" may be applied at all (until task A4 brings readable dark values). */
|
||||
export function isDarkEnabled(): boolean {
|
||||
return process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1";
|
||||
}
|
||||
|
||||
/** Values chosen in this visit; used when localStorage is blocked (private mode). */
|
||||
const session: { theme?: string; contrast?: string } = {};
|
||||
|
||||
@@ -84,7 +79,7 @@ function readSnapshot(): Snapshot {
|
||||
|
||||
/** Same effect as the inline script, for changes after the first paint. */
|
||||
function applyToDocument(snapshot: Snapshot): void {
|
||||
const { dark, sun } = resolveAppearance(snapshot, { ...snapshot, darkEnabled: isDarkEnabled() });
|
||||
const { dark, sun } = resolveAppearance(snapshot, snapshot);
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", dark);
|
||||
root.setAttribute("data-contrast", sun ? "sun" : "normal");
|
||||
@@ -168,7 +163,7 @@ export function useAppearance(): Appearance {
|
||||
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const state = useMemo(() => {
|
||||
const parsed = JSON.parse(raw) as Snapshot & { motion: MotionLevel };
|
||||
const resolved = resolveAppearance(parsed, { ...parsed, darkEnabled: isDarkEnabled() });
|
||||
const resolved = resolveAppearance(parsed, parsed);
|
||||
return {
|
||||
theme: isThemeChoice(parsed.theme) ? parsed.theme : DEFAULT_THEME,
|
||||
contrast: isContrastChoice(parsed.contrast) ? parsed.contrast : ("normal" as const),
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AREAS, type AreaKey } from "./areas";
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Boxes,
|
||||
Bug,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
Crown,
|
||||
Database,
|
||||
Droplet,
|
||||
Factory,
|
||||
FlaskConical,
|
||||
Flower2,
|
||||
History,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
type LucideIcon,
|
||||
MapPin,
|
||||
Package,
|
||||
Scissors,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
Snowflake,
|
||||
Sprout,
|
||||
Syringe,
|
||||
Tag,
|
||||
UserRound,
|
||||
Users,
|
||||
Utensils,
|
||||
Warehouse,
|
||||
Wrench,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { AREAS, type AreaKey, type AreaTone } from "./areas";
|
||||
import { ADMIN_GROUP, NAV_GROUPS } from "@/app/(app)/nav-config";
|
||||
import { QUICK_ACTIONS } from "@/app/(app)/quick-actions";
|
||||
|
||||
@@ -53,3 +88,52 @@ describe("AREAS", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** Spec 11, table "Icon und Farbe je Bereich": the 32 areas of the spec, row by row. */
|
||||
const SPEC_AREAS: ReadonlyArray<[AreaKey, LucideIcon, AreaTone]> = [
|
||||
["dashboard", LayoutDashboard, "neutral"],
|
||||
["search", Search, "neutral"],
|
||||
["apiaries", MapPin, "betrieb"],
|
||||
["colonies", Layers, "betrieb"],
|
||||
["queens", Crown, "betrieb"],
|
||||
["winter", Snowflake, "betrieb"],
|
||||
["phenology", Flower2, "betrieb"],
|
||||
["inspections", ClipboardCheck, "erfassung"],
|
||||
["varroa", Bug, "erfassung"],
|
||||
["swarm-control", Sprout, "erfassung"],
|
||||
["treatments", Syringe, "erfassung"],
|
||||
["feedings", Utensils, "erfassung"],
|
||||
["tasks", ListTodo, "arbeit"],
|
||||
["calendar", CalendarDays, "arbeit"],
|
||||
["suggestions", Lightbulb, "arbeit"],
|
||||
["notifications", Bell, "arbeit"],
|
||||
["harvests", Scissors, "ernte"],
|
||||
["extractions", FlaskConical, "ernte"],
|
||||
["honey-batches", Droplet, "ernte"],
|
||||
["bottling", Package, "ernte"],
|
||||
["products", ShoppingBag, "ernte"],
|
||||
["raw-materials", Boxes, "ernte"],
|
||||
["production-batches", Factory, "ernte"],
|
||||
["storage", Warehouse, "ernte"],
|
||||
["equipment", Wrench, "ernte"],
|
||||
["statistics", BarChart3, "neutral"],
|
||||
["users-roles", Users, "neutral"],
|
||||
["automation", Zap, "neutral"],
|
||||
["label-templates", Tag, "neutral"],
|
||||
["backup", Database, "neutral"],
|
||||
["audit-log", History, "neutral"],
|
||||
["profile", UserRound, "neutral"],
|
||||
];
|
||||
|
||||
describe("AREAS against spec 11", () => {
|
||||
it("lists all 32 rows of the spec table", () => {
|
||||
expect(SPEC_AREAS).toHaveLength(32);
|
||||
expect(new Set(SPEC_AREAS.map(([key]) => key)).size).toBe(32);
|
||||
});
|
||||
|
||||
it.each(SPEC_AREAS)("%s has the icon and tone of the spec table", (key, icon, tone) => {
|
||||
expect(AREAS[key].key).toBe(key);
|
||||
expect(AREAS[key].icon).toBe(icon);
|
||||
expect(AREAS[key].tone).toBe(tone);
|
||||
});
|
||||
});
|
||||
@@ -68,11 +68,11 @@ export const COLONY_CONDITION_LABELS: Record<ColonyCondition, string> = {
|
||||
|
||||
// Tailwind-Klassen auf Basis der --condition-* Tokens aus globals.css (hell/dunkel-faehig).
|
||||
export const COLONY_CONDITION_TONE_CLASS: Record<ColonyCondition, string> = {
|
||||
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-foreground",
|
||||
GOOD: "border-transparent bg-condition-good/15 text-condition-good-foreground",
|
||||
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-foreground",
|
||||
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-foreground",
|
||||
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-foreground",
|
||||
EXCELLENT: "border-transparent bg-condition-excellent/15 text-condition-excellent-ink",
|
||||
GOOD: "border-transparent bg-condition-good/15 text-condition-good-ink",
|
||||
AVERAGE: "border-transparent bg-condition-average/15 text-condition-average-ink",
|
||||
WEAK: "border-transparent bg-condition-weak/15 text-condition-weak-ink",
|
||||
CRITICAL: "border-transparent bg-condition-critical/15 text-condition-critical-ink",
|
||||
};
|
||||
|
||||
/** Volksstärke 1-10 (besetzte Waben), as select values named by themselves. */
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MIN_CONTRAST = 4.5;
|
||||
const TONES = ["critical", "weak", "average", "good", "excellent"] as const;
|
||||
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
|
||||
|
||||
function block(selector: string): string {
|
||||
const start = css.indexOf(`${selector} {`);
|
||||
return css.slice(start, css.indexOf("\n}", start));
|
||||
}
|
||||
|
||||
function oklch(block: string, token: string): [number, number, number] {
|
||||
const match = new RegExp(`--${token}:\\s*oklch\\(([\\d.]+) ([\\d.]+) ([\\d.]+)\\)`).exec(block);
|
||||
if (!match) throw new Error(`token ${token} not found`);
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
/** oklch -> linear sRGB (Ottosson), clipped to gamut. */
|
||||
function linearRgb([l, c, h]: [number, number, number]): number[] {
|
||||
const a = c * Math.cos((h * Math.PI) / 180);
|
||||
const b = c * Math.sin((h * Math.PI) / 180);
|
||||
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
||||
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
||||
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
return [
|
||||
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
||||
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
||||
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
||||
].map((channel) => Math.min(1, Math.max(0, channel)));
|
||||
}
|
||||
|
||||
function luminance(color: [number, number, number]): number {
|
||||
const [r, g, b] = linearRgb(color);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
function contrast(a: [number, number, number], b: [number, number, number]): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
["light", ":root"],
|
||||
["dark", ".dark"],
|
||||
])("condition tones in %s mode", (_mode, selector) => {
|
||||
it.each(TONES)(`%s: text on tone is at least ${MIN_CONTRAST}:1`, (tone) => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(
|
||||
oklch(scope, `condition-${tone}`),
|
||||
oklch(scope, `condition-${tone}-foreground`),
|
||||
);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["light", ":root"],
|
||||
["dark", ".dark"],
|
||||
])("hive label in %s mode", (_mode, selector) => {
|
||||
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
|
||||
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,22 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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
|
||||
* TARGET; the guard measures the table against the end values so the plan numbers are proven
|
||||
* now, and the switch then pins globals.css to exactly these values.
|
||||
* Task A4 switched globals.css to the end values (spec 4.2): the guard reads the real sets and
|
||||
* pins the base sets to TARGET. Before A4 it measured TARGET instead of the old values.
|
||||
*/
|
||||
export const VALUES_SWITCHED = false;
|
||||
export const VALUES_SWITCHED = true;
|
||||
|
||||
const css = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf8");
|
||||
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 {
|
||||
const base = set === "light" || set === "sun-light" ? "light" : "dark";
|
||||
const fromSun = set.startsWith("sun") ? own(set)[token] : undefined;
|
||||
@@ -99,6 +107,9 @@ function value(set: TokenSet, token: string): string {
|
||||
return found;
|
||||
}
|
||||
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 AREA_TONES = ["betrieb", "erfassung", "arbeit", "ernte", "neutral"] as const;
|
||||
@@ -178,8 +189,6 @@ const NEW_COLOUR_TOKENS = [
|
||||
...AREA_TONES.map((a) => `area-${a}`),
|
||||
...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 = [
|
||||
"background", "card", "popover", "sidebar", "foreground", "primary-foreground", "ring",
|
||||
"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) => {
|
||||
const outside = Object.entries(sets[set])
|
||||
.filter(([key]) => VALUES_SWITCHED || !LEGACY_OUT_OF_GAMUT.includes(key))
|
||||
.filter(([, v]) => /^oklch\(/.test(v))
|
||||
.filter(([, v]) => !inGamut(oklchOf(v)))
|
||||
.map(([k]) => k);
|
||||
@@ -276,11 +284,43 @@ describe("token sets", () => {
|
||||
|
||||
it("VALUES_SWITCHED is flipped as soon as globals.css holds the end values", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const forced = atRuleDeclarations(css, "@media (forced-colors: active)");
|
||||
expect(forced).toMatchObject({ ring: "CanvasText", border: "CanvasText", input: "CanvasText" });
|
||||
@@ -290,7 +330,7 @@ describe("token sets", () => {
|
||||
if (!VALUES_SWITCHED) return;
|
||||
for (const base of ["light", "dark"] as const)
|
||||
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", () => {
|
||||
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", () => {
|
||||
expect(css).toMatch(/\.holo-stage\s*\{[^}]*--ring:\s*var\(--holo-edge-strong\)/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = `
|
||||
@theme inline { --color-x: var(--x); }
|
||||
@@ -19,6 +19,12 @@ describe("css-tokens", () => {
|
||||
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", () => {
|
||||
expect(contrastOf([0, 0, 0], [1, 0, 0])).toBeCloseTo(21, 1);
|
||||
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];
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
return rawLinearRgb(color).every((c) => c >= -GAMUT_EPSILON && c <= 1 + GAMUT_EPSILON);
|
||||
}
|
||||
Reference in new issue
Block a user