+
+ Ernten
+
{(harvests ?? []).length === 0 ? (
Keine Ernten erfasst.
) : null}
-
diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx
index 649d622..2cb0a46 100644
--- a/ApiCore-Frontend/src/components/today/today-panel.tsx
+++ b/ApiCore-Frontend/src/components/today/today-panel.tsx
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
+import { CheckboxField } from "@/components/form/checkbox-field";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
@@ -391,28 +392,16 @@ function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps)
-
+ describedBy={describedBy}
+ onCheckedChange={(next) => {
+ if (next) onComplete();
+ }}
+ />
{task.title}
diff --git a/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx b/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx
new file mode 100644
index 0000000..efdaaa3
--- /dev/null
+++ b/ApiCore-Frontend/src/components/ui/checkbox.spec.tsx
@@ -0,0 +1,121 @@
+import { useState, type FormEvent } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { Checkbox, CheckboxMark } from "./checkbox";
+import { Switch, SwitchMark } from "./switch";
+
+const classesOf = (element: Element) => (element.getAttribute("class") ?? "").split(/\s+/);
+
+function Harness({ onChange }: { onChange?: (next: boolean) => void }) {
+ const [checked, setChecked] = useState(false);
+ return (
+ {
+ setChecked(next);
+ onChange?.(next);
+ }}
+ />
+ );
+}
+
+describe("ui/checkbox (spec 10.3)", () => {
+ it("the box is 20 px with a 2 px --input border and a 6 px radius", () => {
+ const { container } = render();
+ const mark = container.firstElementChild as HTMLElement;
+ expect(mark).toHaveAttribute("aria-hidden", "true");
+ expect(classesOf(mark)).toEqual(expect.arrayContaining(["size-5", "border-2", "border-input", "rounded-[6px]"]));
+ expect(mark.querySelector("svg")).toBeNull();
+ });
+
+ it("checked: honey surface, ink border and a 14 px tick with stroke width 3", () => {
+ const { container } = render();
+ const mark = container.firstElementChild as HTMLElement;
+ expect(classesOf(mark)).toEqual(
+ expect.arrayContaining(["bg-primary", "border-foreground", "text-primary-foreground"]),
+ );
+ const tick = mark.querySelector("svg.lucide-check");
+ expect(tick).not.toBeNull();
+ expect(classesOf(tick as Element)).toContain("size-3.5");
+ expect(tick).toHaveAttribute("stroke-width", "3");
+ });
+
+ it("indeterminate shows a dash, not a tick", () => {
+ const { container } = render();
+ expect(container.querySelector("svg.lucide-minus")).not.toBeNull();
+ expect(container.querySelector("svg.lucide-check")).toBeNull();
+ });
+
+ it("the primitive is a native button with role checkbox; click and Space toggle it", async () => {
+ const onChange = vi.fn();
+ render();
+ const box = screen.getByRole("checkbox", { name: "Aktiv" });
+ expect(box.tagName).toBe("BUTTON");
+ expect(box).toHaveAttribute("type", "button");
+ expect(box).not.toBeChecked();
+
+ await userEvent.click(box);
+ expect(box).toBeChecked();
+ box.focus();
+ await userEvent.keyboard(" ");
+ expect(box).not.toBeChecked();
+ expect(onChange).toHaveBeenNthCalledWith(1, true);
+ expect(onChange).toHaveBeenNthCalledWith(2, false);
+ });
+
+ it("Enter inside a form neither submits the form nor toggles (APG: Space toggles)", async () => {
+ const onSubmit = vi.fn((event: FormEvent) => event.preventDefault());
+ const onChange = vi.fn();
+ const onKeyDown = vi.fn();
+ render(
+ ,
+ );
+ screen.getByRole("checkbox", { name: "Aktiv" }).focus();
+ await userEvent.keyboard("{Enter}");
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(onChange).not.toHaveBeenCalled();
+ expect(onKeyDown).toHaveBeenCalled();
+ await userEvent.keyboard(" ");
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("disabled is a real disabled button", () => {
+ render( {}} />);
+ expect(screen.getByRole("checkbox", { name: "Aktiv" })).toBeDisabled();
+ });
+});
+
+describe("ui/switch", () => {
+ it("is a native button with role switch and shows a tick in the thumb when on", async () => {
+ const onChange = vi.fn();
+ render();
+ const control = screen.getByRole("switch", { name: "Erlauben" });
+ expect(control.tagName).toBe("BUTTON");
+ expect(control).toBeChecked();
+ expect(control.querySelector("svg.lucide-check")).not.toBeNull();
+
+ await userEvent.click(control);
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("off: thumb at the start and no tick, so the state is not colour only", () => {
+ const { container } = render();
+ expect(container.querySelector("svg")).toBeNull();
+ expect(container.innerHTML).not.toContain("translate-x-5");
+ });
+
+ it("the thumb only slides with full motion", () => {
+ const { container } = render();
+ const thumb = container.querySelector("[data-slot=switch-thumb]") as HTMLElement;
+ expect(classesOf(thumb)).toContain("translate-x-5");
+ expect(classesOf(thumb)).toContain("motion-full:transition-transform");
+ expect(thumb.className).not.toMatch(/(^|\s)transition/);
+ });
+});
diff --git a/ApiCore-Frontend/src/components/ui/checkbox.tsx b/ApiCore-Frontend/src/components/ui/checkbox.tsx
index f9aad72..617b74d 100644
--- a/ApiCore-Frontend/src/components/ui/checkbox.tsx
+++ b/ApiCore-Frontend/src/components/ui/checkbox.tsx
@@ -1,23 +1,78 @@
+"use client"
+
+import type { ReactNode } from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
-import { Check } from "lucide-react"
+import { Check, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
-function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+/** Checked, unchecked or partly checked (a group whose items are only partly chosen). */
+export type CheckState = boolean | "indeterminate"
+
+/**
+ * The 20 px box (spec 10.3): 2 px --input border, radius 6 px. Checked: honey surface, ink
+ * border and a 14 px tick (stroke 3); indeterminate: a dash. Purely visual - the control
+ * around it carries role and state, and the tick keeps the state from being colour only.
+ */
+function CheckboxMark({ checked, className }: { checked: CheckState; className?: string }) {
+ const isOn = checked !== false
+ return (
+
+ {checked === "indeterminate" ? (
+
+ ) : checked ? (
+
+ ) : null}
+
+ )
+}
+
+interface CheckboxProps
+ extends Omit<
+ CheckboxPrimitive.Root.Props,
+ "checked" | "indeterminate" | "onCheckedChange" | "nativeButton" | "render" | "children"
+ > {
+ checked: CheckState
+ onCheckedChange?: (next: boolean) => void
+ /** The content of the control; the box alone without it. */
+ children?: ReactNode
+}
+
+/**
+ * The one checkbox of the app: Base UI `Checkbox` rendered as a native button, so role,
+ * aria-checked, Space and `disabled` come from the primitive. With children the whole row,
+ * tile or cell is the control (CheckboxField); without, it is the bare box.
+ */
+function Checkbox({ checked, onCheckedChange, onKeyDown, className, children, ...props }: CheckboxProps) {
return (
}
+ checked={checked === true}
+ indeterminate={checked === "indeterminate"}
+ onCheckedChange={(next) => onCheckedChange?.(next)}
+ onKeyDown={(event) => {
+ onKeyDown?.(event)
+ // Enter neither toggles nor submits a surrounding form (APG: Space toggles a
+ // checkbox). Base UI skips its submit-on-Enter once the event is prevented, and the
+ // native button no longer turns Enter into a click.
+ if (event.key === "Enter") event.preventDefault()
+ }}
+ className={cn("disabled:cursor-not-allowed", className)}
{...props}
>
-
-
-
+ {children ?? }
)
}
-export { Checkbox }
+export { Checkbox, CheckboxMark }
diff --git a/ApiCore-Frontend/src/components/ui/switch.tsx b/ApiCore-Frontend/src/components/ui/switch.tsx
new file mode 100644
index 0000000..8f5c283
--- /dev/null
+++ b/ApiCore-Frontend/src/components/ui/switch.tsx
@@ -0,0 +1,63 @@
+"use client"
+
+import type { ReactNode } from "react"
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+/**
+ * Track and thumb of a yes/no setting. On: honey track with ink border and the thumb at the
+ * end carrying a tick, so the state reads from position and icon, not only from colour. The
+ * thumb only slides with full motion (spec 17); otherwise it jumps.
+ */
+function SwitchMark({ checked, className }: { checked: boolean; className?: string }) {
+ return (
+
+
+ {checked ? : null}
+
+
+ )
+}
+
+interface SwitchProps
+ extends Omit {
+ checked: boolean
+ onCheckedChange?: (next: boolean) => void
+ /** The content of the control; the track alone without it. */
+ children?: ReactNode
+}
+
+/** Base UI `Switch` as a native button (role switch, Space, `disabled` from the primitive). */
+function Switch({ checked, onCheckedChange, className, children, ...props }: SwitchProps) {
+ return (
+ }
+ checked={checked}
+ onCheckedChange={(next) => onCheckedChange?.(next)}
+ className={cn("disabled:cursor-not-allowed", className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+export { Switch, SwitchMark }
diff --git a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
index d52c1bd..7f0e8c7 100644
--- a/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
+++ b/ApiCore-Frontend/src/styles/baukasten-guard.spec.ts
@@ -19,6 +19,20 @@ interface GuardRule {
const FRONTEND = join(__dirname, "..", "..");
const SRC = join(FRONTEND, "src");
+/**
+ * `keys` (role, type) set to `value` by hand: JSX (role="x", role={on ? "x" : y}), object or
+ * spread form (role: "x") and setAttribute("role", "x"). A CSS selector ([role="x"]) only finds
+ * kit controls, and the `role` prop inside a tag is the kit's own API.
+ */
+function ownRole(keys: string, value: string): RegExp {
+ const quote = "[\"'`]";
+ const quoted = `${quote}${value}${quote}`;
+ // role="x" | role={... "x" ...} | role: ... "x" (not role?: or role === "x")
+ const attribute = String.raw`(?]*)(?:${attribute}|${setAttribute})`);
+}
+
const RULES: GuardRule[] = [
// Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
{
@@ -95,6 +109,23 @@ const RULES: GuardRule[] = [
},
],
},
+ // Task A7: one checkbox for the whole app (spec 10.3, 10.7). Base UI draws role checkbox and
+ // its hidden input inside ui/checkbox.tsx, so no source file writes them by hand and the list
+ // of exceptions stays empty. The `role` prop of picks the kit's own control
+ // and is no own build (only within that same tag, so put `role` before any arrow handler).
+ {
+ id: "no role checkbox or type checkbox outside ui/checkbox.tsx",
+ pattern: ownRole("role|type", "checkbox"),
+ scope: "src/**/*.{ts,tsx}",
+ allow: [],
+ },
+ // Task A7: a yes/no setting is on Base UI Switch (ui/switch.tsx).
+ {
+ id: "no role switch outside ui/checkbox.tsx and ui/switch.tsx",
+ pattern: ownRole("role", "switch"),
+ scope: "src/**/*.{ts,tsx}",
+ allow: [],
+ },
// Task A6: a toggle button sets aria-pressed through Button `pressed` (spec 10.2, 10.7).
{
id: "aria-pressed only through Button pressed",
@@ -167,6 +198,30 @@ describe("baukasten guard (spec 10.7)", () => {
}
});
+ it("ToggleChip and SwitchRow are gone (CheckboxField tile and switch replace them)", () => {
+ for (const name of ["toggle-chip", "switch-row"]) {
+ expect(existsSync(join(SRC, "components", "form", `${name}.tsx`)), name).toBe(false);
+ }
+ const users = FILES.filter(({ text }) => /\b(?:ToggleChip|SwitchRow)\b|form\/(?:toggle-chip|switch-row)/.test(text));
+ expect(users.map(({ file }) => file)).toEqual([]);
+ });
+
+ it("a list of CheckboxFields keeps neighbouring targets at least 4 px apart (spec 4.5)", () => {
+ // `.map(… => ) and the container opened before it.
+ const lists = FILES.filter(({ file }) => file.endsWith(".tsx")).flatMap(({ file, text }) =>
+ [...text.matchAll(/\.map\(\s*\([^)]*\)\s*=>\s*\(?\s*(?:]*>\s*)? {
+ const before = text.slice(0, match.index);
+ const containers = [...before.matchAll(/<(?:div|ul|fieldset)\b[^>]*?className="([^"]*)"/g)];
+ const classes = containers.at(-1)?.[1] ?? "";
+ const gap = /(?:^|\s)gap-(?:y-)?(\d+(?:\.5)?)(?=\s|$)/.exec(classes)?.[1];
+ const line = before.split("\n").length;
+ return { where: `${file}:${line}`, gapPx: gap === undefined ? 0 : Number(gap) * 4 };
+ }),
+ );
+ expect(lists.length).toBeGreaterThanOrEqual(8);
+ expect(lists.filter(({ gapPx }) => gapPx < 4).map(({ where, gapPx }) => `${where}: ${gapPx} px`)).toEqual([]);
+ });
+
it("no component removes the outline without the global rule", () => {
expect(GLOBALS).toMatch(/:where\(:focus-visible\)\s*\{[^}]*outline:\s*var\(--focus-width\)\s+solid\s+var\(--ring\)/);
for (const id of ["no ring-ring/50", "no outline-none"]) {
@@ -208,6 +263,30 @@ describe("baukasten guard (spec 10.7)", () => {
expect(radio.test('{ type: "radio" }')).toBe(true);
expect(radio.test('{ role: "radioactive" }')).toBe(false);
expect(radio.test(`querySelector('[role="radio"][tabindex="0"]')`)).toBe(false);
+ const checkbox = rule("no role checkbox or type checkbox outside ui/checkbox.tsx").pattern;
+ expect(checkbox.test('')).toBe(true);
+ expect(checkbox.test('