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
@@ -37,7 +37,7 @@ export function ApiaryMap({ apiaries }: ApiaryMapProps) {
|
||||
{apiaries.map((apiary) => (
|
||||
<Marker key={apiary.id} position={[apiary.latitude, apiary.longitude]} icon={markerIcon}>
|
||||
<Popup>
|
||||
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary underline">
|
||||
<Link href={`/apiaries/${apiary.id}`} className="font-medium text-primary-ink underline">
|
||||
{apiary.name}
|
||||
</Link>
|
||||
{apiary.location ? <p className="text-sm text-muted-foreground">{apiary.location}</p> : null}
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(() => {
|
||||
|
||||
describe("appearanceScript", () => {
|
||||
it("uses the same storage keys as the hook", () => {
|
||||
const source = appearanceScript(true);
|
||||
const source = appearanceScript();
|
||||
for (const key of Object.values(APPEARANCE_KEYS)) expect(source).toContain(key);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("appearanceScript", () => {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, "sun");
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.motion, "subtle");
|
||||
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
|
||||
const root = document.documentElement;
|
||||
expect(root).toHaveClass("dark");
|
||||
@@ -44,29 +44,25 @@ describe("appearanceScript", () => {
|
||||
const themes = [null, "light", "dark", "system", "bogus"];
|
||||
const contrasts = [null, "normal", "sun", "bogus"];
|
||||
const motions = [null, "full", "subtle", "off", "bogus"];
|
||||
for (const darkEnabled of [true, false])
|
||||
for (const prefersDark of [true, false])
|
||||
for (const prefersMore of [true, false])
|
||||
for (const theme of themes)
|
||||
for (const contrast of contrasts)
|
||||
for (const motion of motions) {
|
||||
resetDocument();
|
||||
stubMedia(prefersDark, prefersMore);
|
||||
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
|
||||
for (const prefersDark of [true, false])
|
||||
for (const prefersMore of [true, false])
|
||||
for (const theme of themes)
|
||||
for (const contrast of contrasts)
|
||||
for (const motion of motions) {
|
||||
resetDocument();
|
||||
stubMedia(prefersDark, prefersMore);
|
||||
if (theme) window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
if (contrast) window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
if (motion) window.localStorage.setItem(APPEARANCE_KEYS.motion, motion);
|
||||
|
||||
run(appearanceScript(darkEnabled));
|
||||
run(appearanceScript());
|
||||
|
||||
const expected = resolveAppearance(
|
||||
{ theme, contrast, motion },
|
||||
{ prefersDark, prefersMore, darkEnabled },
|
||||
);
|
||||
const root = document.documentElement;
|
||||
expect(root.classList.contains("dark")).toBe(expected.dark);
|
||||
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
|
||||
expect(root.dataset.motion).toBe(expected.motion);
|
||||
}
|
||||
const expected = resolveAppearance({ theme, contrast, motion }, { prefersDark, prefersMore });
|
||||
const root = document.documentElement;
|
||||
expect(root.classList.contains("dark")).toBe(expected.dark);
|
||||
expect(root.dataset.contrast).toBe(expected.sun ? "sun" : "normal");
|
||||
expect(root.dataset.motion).toBe(expected.motion);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocked storage falls back to the default", () => {
|
||||
@@ -78,7 +74,7 @@ describe("appearanceScript", () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
} finally {
|
||||
Object.defineProperty(window, "localStorage", real);
|
||||
}
|
||||
@@ -102,14 +98,18 @@ describe("appearanceScript", () => {
|
||||
for (const [theme, contrast, color] of cases) {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.theme, theme);
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.contrast, contrast);
|
||||
run(appearanceScript(true));
|
||||
run(appearanceScript());
|
||||
expect(meta.content).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no build flag for dark any more (task A4)", () => {
|
||||
expect(appearanceScript()).not.toMatch(/DARK=/);
|
||||
});
|
||||
|
||||
it("contains no stored or user data, only constants", () => {
|
||||
window.localStorage.setItem(APPEARANCE_KEYS.theme, "secret-value");
|
||||
expect(appearanceScript(true)).not.toContain("secret-value");
|
||||
expect(appearanceScript()).not.toContain("secret-value");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export function AppearanceScript() {
|
||||
return (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: appearanceScript(process.env.NEXT_PUBLIC_APPEARANCE_DARK === "1"),
|
||||
__html: appearanceScript(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,10 +27,8 @@ describe("area migration", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source other than globals.css uses the --gradient tokens", () => {
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => !file.endsWith("globals.css"))
|
||||
.filter((file) => /--gradient-(honey|sage|sky)/.test(readFileSync(file, "utf8")));
|
||||
it("no source uses or defines the --gradient tokens any more (removed in task A4)", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => /--gradient-/.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export function NextStepsView({
|
||||
className="flex flex-col gap-3 rounded-xl border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -13,6 +13,13 @@ describe("BrandMark", () => {
|
||||
expect(screen.getByText("ApiCore")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fills the cell with --primary (= spec honey #f0b13f, see contrast.spec) and the lid with wax", () => {
|
||||
const { container } = render(<BrandMark />);
|
||||
const [cell, lid] = container.querySelectorAll("svg path");
|
||||
expect(cell).toHaveAttribute("fill", "var(--primary)");
|
||||
expect(lid).toHaveAttribute("fill", "oklch(0.88 0.11 85)");
|
||||
});
|
||||
|
||||
it("is decorative without a wordmark and sized 24 or 32 px", () => {
|
||||
const { container, rerender } = render(<BrandMark />);
|
||||
const svg = container.querySelector("svg") as SVGElement;
|
||||
|
||||
@@ -15,7 +15,11 @@ interface BrandMarkProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The ApiCore mark: a capped cell, optionally followed by the wordmark. */
|
||||
/**
|
||||
* The ApiCore mark: a capped cell, optionally followed by the wordmark. The cell is --primary:
|
||||
* its light value is the spec honey #f0b13f (pinned in contrast.spec), dark differs by one step
|
||||
* (#efb146), so no separate brand token is needed. Static files (task A9) use the hex directly.
|
||||
*/
|
||||
export function BrandMark({ size = 24, withWordmark = false, className }: BrandMarkProps) {
|
||||
const mark = (
|
||||
<svg
|
||||
|
||||
@@ -118,7 +118,7 @@ export function HarvestRestrictionWarning({ apiaryId }: { apiaryId: string }) {
|
||||
<div aria-live="polite">
|
||||
{restricted ? (
|
||||
<p className="flex items-start gap-2 rounded-lg bg-warning/15 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
<span>Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen.</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -142,7 +142,7 @@ export function ChoiceGroup<T extends ChoiceValue>({
|
||||
selected && isUnanswered && "border-foreground/30 bg-muted font-medium text-foreground",
|
||||
selected &&
|
||||
!isUnanswered &&
|
||||
"border-primary bg-primary font-medium text-primary-foreground",
|
||||
"border-foreground bg-primary font-medium text-primary-foreground",
|
||||
selected && option.selectedClassName,
|
||||
optionClassName,
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
checked
|
||||
? "border-primary bg-primary/10 font-medium"
|
||||
? "border-foreground bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -34,7 +34,7 @@ export function ToggleChip({ label, checked, onChange, describedBy, disabled }:
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
? "border-foreground bg-primary text-primary-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function LabelCompliancePanel({
|
||||
<ul aria-label="Hinweise" className="flex flex-col gap-2">
|
||||
{result.warnings.map((warning, index) => (
|
||||
<li key={`${warning.code}-${index}`} className="flex items-start gap-2 text-sm">
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-warning-ink" />
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Hinweis: {warning.title}</span>
|
||||
{warning.detail ? (
|
||||
|
||||
@@ -37,7 +37,7 @@ export function PackAgeNote({ stored, now }: PackAgeNoteProps) {
|
||||
<p className="text-muted-foreground">Geladen {packAgeText(stored.savedAt, now)}</p>
|
||||
{isPackStale(stored, now) ? (
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
{`Stand-Daten von ${packDayText(stored.savedAt)} – bei Netz neu laden`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function ApiarySeason({ apiary }: ApiarySeasonProps) {
|
||||
</>
|
||||
) : null}
|
||||
{state.note ? (
|
||||
<span className="text-warning-foreground">{SEASON_NOTE_LABELS[state.note]}</span>
|
||||
<span className="text-warning-ink">{SEASON_NOTE_LABELS[state.note]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function RoundNextPanel({ next, doneCount, savedOffline, onNext, onEnd }:
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p role="status" className="flex items-center gap-2 text-lg font-medium">
|
||||
<CheckCircle2 className="size-5 text-condition-good-foreground" aria-hidden />
|
||||
<CheckCircle2 className="size-5 text-condition-good-ink" aria-hidden />
|
||||
Gespeichert
|
||||
</p>
|
||||
{savedOffline ? (
|
||||
|
||||
@@ -190,7 +190,7 @@ function MovementList({ movements }: { movements: StockMovement[] }) {
|
||||
className={
|
||||
movement.quantity < 0
|
||||
? "font-semibold tabular-nums text-destructive"
|
||||
: "font-semibold tabular-nums text-primary"
|
||||
: "font-semibold tabular-nums text-primary-ink"
|
||||
}
|
||||
>
|
||||
{movement.quantity > 0 ? "+" : ""}
|
||||
|
||||
@@ -109,7 +109,7 @@ export function SuggestionCard(props: SuggestionCardProps) {
|
||||
return (
|
||||
<article className="flex min-w-0 flex-col gap-3 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
|
||||
@@ -62,7 +62,7 @@ function CombinedRow({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<Icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TaskSourceBadge({
|
||||
title={source.title}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-primary/45 px-2 py-0.5 text-xs text-foreground/80"
|
||||
>
|
||||
<Icon className="size-3.5 text-primary" aria-hidden />
|
||||
<Icon className="size-3.5 text-primary-ink" aria-hidden />
|
||||
{source.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function InspectionDueCard({ entry, primary = true }: InspectionDueCardPr
|
||||
className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-4 shadow-[var(--shadow-soft)] sm:p-5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/12 text-primary-ink">
|
||||
<ClipboardCheck className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h3 id={headingId} className="min-w-0 flex-1 font-medium">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function TodayPanel({
|
||||
</h2>
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="inline-flex min-h-11 items-center text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
className="inline-flex min-h-11 items-center text-sm font-medium text-primary-ink underline-offset-4 hover:underline"
|
||||
>
|
||||
Alle Aufgaben
|
||||
</Link>
|
||||
@@ -307,7 +307,7 @@ function AutopilotStepCard({ step, busy, ...handlers }: AutopilotStepCardProps)
|
||||
<div key={step.key} className="next-step-enter mt-2 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="flex items-start gap-3 font-heading text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl">
|
||||
<Icon className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
|
||||
<Icon className="mt-1 size-6 shrink-0 text-primary-ink" aria-hidden />
|
||||
{step.title}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -445,7 +445,7 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
|
||||
function NothingDue() {
|
||||
return (
|
||||
<section className="flex flex-col items-start gap-4 rounded-2xl border border-border/60 bg-card p-5 shadow-[var(--shadow-soft)] sm:p-6">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/12 text-primary-ink">
|
||||
<Sun className="size-6" aria-hidden />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -321,7 +321,7 @@ export function BookingNotes({
|
||||
) : null}
|
||||
{warnings.map((warning) => (
|
||||
<p key={warning} className="flex items-start gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warning-ink" aria-hidden />
|
||||
<span>{warning}</span>
|
||||
</p>
|
||||
))}
|
||||
@@ -366,7 +366,7 @@ function TemperatureNote({
|
||||
const Icon = hint.warn ? AlertTriangle : Thermometer;
|
||||
return (
|
||||
<p data-warn={hint.warn} className="flex flex-wrap items-start gap-x-2 text-sm">
|
||||
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning" : ""}`} aria-hidden />
|
||||
<Icon className={`mt-0.5 size-4 shrink-0 ${hint.warn ? "text-warning-ink" : ""}`} aria-hidden />
|
||||
<span>{hint.text}</span>
|
||||
{hint.warn ? <span className="font-medium">Außerhalb des Katalog-Bereichs</span> : null}
|
||||
</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -13,14 +13,14 @@ const badgeVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 [a]:hover:bg-destructive/20",
|
||||
warning:
|
||||
"bg-warning/15 text-warning-foreground focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
|
||||
"bg-warning/15 text-warning-ink focus-visible:ring-warning/30 [a]:hover:bg-warning/25",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"hover:bg-muted hover:text-muted-foreground",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:shadow-[var(--shadow-elevated)] hover:brightness-105 active:brightness-95",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20",
|
||||
link: "text-primary-ink underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
// Phones get at least 40 px: tapped with a thumb, often with gloves.
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
"peer flex size-4 shrink-0 items-center justify-center rounded-[6px] border border-input bg-background shadow-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-foreground data-checked:bg-primary data-checked:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -88,7 +88,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"h-11 w-full min-w-0 sm:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all outline-none select-none hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 sm:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,9 +58,9 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-card data-active:text-primary dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-lg border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-[var(--shadow-soft)] group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-card data-active:text-primary-ink",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all outline-none placeholder:text-muted-foreground hover:border-foreground/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Badge } from "./badge";
|
||||
import { Button } from "./button";
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) return sourceFiles(path);
|
||||
return /\.tsx?$/.test(name) && !/\.spec\./.test(name) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** A Tailwind class with the dark variant, e.g. `dark:bg-input/30` (not an object key `dark: x`). */
|
||||
const DARK_CLASS = /(^|[\s"'`])dark:[^\s"'`]/m;
|
||||
|
||||
describe("ui colours after the value switch (spec 10.8 step 4)", () => {
|
||||
it("the default button uses solid primary with primary-foreground and no gradient", () => {
|
||||
render(<Button>Speichern</Button>);
|
||||
const classes = screen.getByRole("button", { name: "Speichern" }).className;
|
||||
expect(classes).toMatch(/(^|\s)bg-primary(\s|$)/);
|
||||
expect(classes).toMatch(/(^|\s)text-primary-foreground(\s|$)/);
|
||||
expect(classes).not.toMatch(/gradient|background-image/);
|
||||
});
|
||||
|
||||
it("link-style buttons and badges use the primary ink, not the honey fill colour, for text", () => {
|
||||
render(
|
||||
<>
|
||||
<Button variant="link">Mehr</Button>
|
||||
<Badge variant="link">Link</Badge>
|
||||
<Badge variant="warning">Achtung</Badge>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Mehr" }).className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
|
||||
expect(screen.getByText("Link").className).toMatch(/(^|\s)text-primary-ink(\s|$)/);
|
||||
expect(screen.getByText("Achtung").className).toMatch(/(^|\s)text-warning-ink(\s|$)/);
|
||||
});
|
||||
|
||||
it("ui components contain no dark: classes", () => {
|
||||
const offenders = sourceFiles(join(SRC, "components", "ui")).filter((file) =>
|
||||
DARK_CLASS.test(readFileSync(file, "utf8")),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("no source file uses dark: classes - a mode is only another token set", () => {
|
||||
const offenders = sourceFiles(SRC).filter((file) => DARK_CLASS.test(readFileSync(file, "utf8")));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,7 @@ export function VoiceRecorder({
|
||||
>
|
||||
{rejected ? null : (
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
@@ -311,7 +311,7 @@ export function VoiceRecorder({
|
||||
{queuedNoteId ? (
|
||||
<p role="status" className="flex items-start gap-2 text-base font-medium">
|
||||
<CheckCircle2
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-foreground"
|
||||
className="mt-0.5 size-5 shrink-0 text-condition-good-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
{QUEUED_TEXT}
|
||||
|
||||
Reference in new issue
Block a user