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