Compare commits
9
Commits
2f95696efb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cda3e02a31 | ||
|
|
7025a6b589 | ||
|
|
fce9d10192 | ||
|
|
759d85dc67 | ||
|
|
6eecd57df4 | ||
|
|
476770c1ed | ||
|
|
848ce168da | ||
|
|
f307e7da66 | ||
|
|
11bccbeb50 |
No files matched your search
@@ -107,4 +107,6 @@ jobs:
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: E2E-Tests
|
||||
run: npm run test:e2e -- --ci
|
||||
# Auf dem geteilten Runner brauchten App-Start plus bcrypt-Logins in
|
||||
# beforeAll mehr als die lokalen 20 s (Laeufe 826, 836); hier 60 s.
|
||||
run: npm run test:e2e -- --ci --testTimeout=60000
|
||||
@@ -105,6 +105,7 @@
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
"testEnvironment": "node",
|
||||
"testTimeout": 20000
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,17 @@ describe('loadLots', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out a lot whose medicine vanished between the two reads', async () => {
|
||||
const { db } = fakeDb(
|
||||
[lotRow({ id: 'l1' }), lotRow({ id: 'gone', medicine: null })],
|
||||
[],
|
||||
);
|
||||
|
||||
const { lots } = await loadLots(db);
|
||||
|
||||
expect(lots.map((lot) => lot.id)).toEqual(['l1']);
|
||||
});
|
||||
|
||||
it('reads only active medicines', async () => {
|
||||
const { db } = fakeDb(
|
||||
[],
|
||||
|
||||
@@ -57,12 +57,16 @@ export async function loadLots(db: DbClient): Promise<ReadinessLots> {
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
]);
|
||||
// Prisma reads the medicine in a second query: a lot deleted together with
|
||||
// its medicine in between comes back with medicine null (CI run 836, 500).
|
||||
// Such a lot is gone, so it is left out.
|
||||
const present = rows.filter((row) => (row.medicine as unknown) !== null);
|
||||
const totals = await lotTotals(
|
||||
db,
|
||||
rows.map((row) => row.id),
|
||||
present.map((row) => row.id),
|
||||
);
|
||||
return {
|
||||
lots: rows.map((row): ReadinessLot => ({
|
||||
lots: present.map((row): ReadinessLot => ({
|
||||
id: row.id,
|
||||
medicineId: row.medicineId,
|
||||
medicineName: row.medicine.name,
|
||||
|
||||
@@ -13,6 +13,8 @@ RUN npm run lint && npx tsc --noEmit && npm test
|
||||
RUN npm run build
|
||||
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
|
||||
RUN npm run check:3d-budget
|
||||
# Bundle-Budget (Spec T5 §17.6): schlaegt fehl, wenn motion zusammen > 30 kB gzip ist.
|
||||
RUN npm run check:motion-budget
|
||||
|
||||
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check:3d-budget": "node scripts/check-3d-budget.mjs",
|
||||
"check:motion-budget": "node scripts/check-motion-budget.mjs",
|
||||
"icons": "node scripts/generate-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Bundle-Budget fuer `motion` im DOM (Spec T5 §17.6: hoechstens 30 kB gzip).
|
||||
// Laeuft nach `next build` wie check-3d-budget.mjs; Exit 1, wenn die motion-Chunks
|
||||
// zusammen das Budget ueberschreiten oder gar kein motion-Chunk gefunden wird.
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
// Spec §17.6: hoechstens 30 kB gzip (1 kB = 1000 Byte) fuer motion insgesamt:
|
||||
// den Teil in der App-Huelle und die asynchron geladenen Features.
|
||||
export const BUDGET_BYTES = 30_000;
|
||||
|
||||
// String-Literale, die motion auch minifiziert behaelt und die kein App-Code schreibt:
|
||||
// der Stil-Schluessel der Render-Basis, das Attribut von AnimatePresence (popLayout)
|
||||
// und die Gesten-Props der Features.
|
||||
export const MOTION_MARKERS = ["transformPerspective", "data-motion-pop-id", "whileHover"];
|
||||
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
|
||||
|
||||
export function isMotionChunk(source) {
|
||||
if (THREE_MARKERS.every((marker) => source.includes(marker))) return false;
|
||||
return MOTION_MARKERS.some((marker) => source.includes(marker));
|
||||
}
|
||||
|
||||
/**
|
||||
* Misst alle motion-Chunks unter chunksDir (gzip level 9) und zaehlt sie zusammen.
|
||||
* @returns {{ ok: boolean, files: { file: string, gzipBytes: number }[], gzipBytes: number, budget: number, message: string }}
|
||||
*/
|
||||
export function checkMotionBudget(chunksDir, budget = BUDGET_BYTES) {
|
||||
const files = [];
|
||||
for (const name of readdirSync(chunksDir)) {
|
||||
if (!name.endsWith(".js")) continue;
|
||||
const source = readFileSync(path.join(chunksDir, name));
|
||||
if (!isMotionChunk(source.toString("latin1"))) continue;
|
||||
files.push({ file: name, gzipBytes: gzipSync(source, { level: 9 }).length });
|
||||
}
|
||||
const gzipBytes = files.reduce((sum, entry) => sum + entry.gzipBytes, 0);
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `Kein motion-Chunk in ${chunksDir} gefunden (Marker: ${MOTION_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
|
||||
};
|
||||
}
|
||||
const ok = gzipBytes <= budget;
|
||||
const list = files.map((entry) => `${entry.file} ${entry.gzipBytes}`).join(", ");
|
||||
return {
|
||||
ok,
|
||||
files,
|
||||
gzipBytes,
|
||||
budget,
|
||||
message: `motion: ${gzipBytes} Byte gzip in ${files.length} Chunks (${list}), Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
|
||||
let result;
|
||||
try {
|
||||
result = checkMotionBudget(chunksDir);
|
||||
} catch (error) {
|
||||
console.error(`check-motion-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
(result.ok ? console.log : console.error)(result.message);
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BUDGET_BYTES, MOTION_MARKERS, checkMotionBudget, isMotionChunk } from "./check-motion-budget.mjs";
|
||||
|
||||
const motionChunk = (filler: string, marker = MOTION_MARKERS[0]) => `/*${marker}*/var a="${marker}";${filler}`;
|
||||
// Hex of random bytes is barely compressible by gzip (about 50 %).
|
||||
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
|
||||
|
||||
describe("check-motion-budget (spec 17.6: motion at most 30 kB gzip)", () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "motion-budget-"));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it("adds up every motion chunk (the shell part and the lazily loaded features)", () => {
|
||||
writeFileSync(path.join(dir, "core.js"), motionChunk(noise(500), MOTION_MARKERS[0]));
|
||||
writeFileSync(path.join(dir, "features.js"), motionChunk(noise(500), MOTION_MARKERS.at(-1)));
|
||||
writeFileSync(path.join(dir, "app.js"), "console.log('Außerdem heute')");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.files.map(({ file }) => file).sort()).toEqual(["core.js", "features.js"]);
|
||||
expect(result.gzipBytes).toBe(result.files.reduce((sum, { gzipBytes }) => sum + gzipBytes, 0));
|
||||
});
|
||||
|
||||
it("fails when the motion chunks together are over budget", () => {
|
||||
writeFileSync(path.join(dir, "a.js"), motionChunk(noise(400)));
|
||||
writeFileSync(path.join(dir, "b.js"), motionChunk(noise(400)));
|
||||
const result = checkMotionBudget(dir, 600);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.gzipBytes).toBeGreaterThan(600);
|
||||
});
|
||||
|
||||
it("fails when no motion chunk exists (check broken or bundle rebuilt)", () => {
|
||||
writeFileSync(path.join(dir, "app.js"), "export const a = 1;");
|
||||
const result = checkMotionBudget(dir);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not count the three.js chunk", () => {
|
||||
expect(isMotionChunk(`/*WebGLRenderer*/window.__THREE__="186";${MOTION_MARKERS[0]}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the spec budget of 30 kB by default", () => {
|
||||
expect(BUDGET_BYTES).toBe(30_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* T5 motion codemod (spec 17.2, 17.4, 17.7, Task A11). Pure and idempotent:
|
||||
*
|
||||
* - `motion-safe:` only asks the system and skips the level chosen in the app. Movement behind it
|
||||
* (translate, scale, rotate, slide-, zoom-, animate-, transition-transform, also behind further
|
||||
* variants) moves to `motion-full:`.
|
||||
* - Colour and opacity transitions need no variant ("Aus" sets every duration to zero):
|
||||
* `motion-safe:transition-colors`/`-opacity` lose the variant.
|
||||
* - Pulsing and spinning are full motion: a bare `animate-pulse`/`animate-spin` gets
|
||||
* `motion-full:`, and `motion-reduce:animate-none` (the old opt-out) goes.
|
||||
*
|
||||
* Hover lifts, `transition-all` and overlay timings are changed by hand (they need a judgement).
|
||||
*
|
||||
* CLI: node scripts/codemod-t5-motion.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";
|
||||
|
||||
/** Characters that belong to a class token (so a match never starts inside one). */
|
||||
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
|
||||
/** Further variants between the motion variant and the utility (`data-open:`, `group-hover:`). */
|
||||
const VARIANTS = String.raw`(?:[^\s"'\`:]+:)*`;
|
||||
const MOVEMENT = String.raw`-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)`;
|
||||
|
||||
const SAFE_MOVEMENT = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=${VARIANTS}${MOVEMENT})`, "g");
|
||||
const SAFE_FADE = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=transition-(?:colors|opacity)(?!${TOKEN_CHAR}))`, "g");
|
||||
const REDUCE_NONE = new RegExp(String.raw` ?(?<!${TOKEN_CHAR})motion-reduce:animate-none(?!${TOKEN_CHAR})`, "g");
|
||||
const BARE_PULSE_SPIN = new RegExp(String.raw`(?<!${TOKEN_CHAR})animate-(pulse|spin)(?![\w-])`, "g");
|
||||
|
||||
/** Rewrites the motion classes of one source text. */
|
||||
export function transform(text) {
|
||||
return text
|
||||
.replace(SAFE_MOVEMENT, "motion-full:")
|
||||
.replace(SAFE_FADE, "")
|
||||
.replace(REDUCE_NONE, "")
|
||||
.replace(BARE_PULSE_SPIN, "motion-full:animate-$1");
|
||||
}
|
||||
|
||||
function filesOf(path) {
|
||||
if (statSync(path).isDirectory()) {
|
||||
return readdirSync(path).flatMap((name) => filesOf(join(path, name)));
|
||||
}
|
||||
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.includes("--check");
|
||||
const changed = args
|
||||
.filter((arg) => arg !== "--check")
|
||||
.flatMap((arg) => filesOf(resolve(arg)))
|
||||
.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);
|
||||
if (check && changed.length > 0) process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transform } from "./codemod-t5-motion.mjs";
|
||||
|
||||
describe("codemod t5 motion (spec 17.2, 17.4, 17.7)", () => {
|
||||
it("puts movement behind motion-full: instead of motion-safe:", () => {
|
||||
expect(transform('cn("size-4 motion-safe:transition-transform", open && "rotate-180")')).toBe(
|
||||
'cn("size-4 motion-full:transition-transform", open && "rotate-180")',
|
||||
);
|
||||
expect(transform('"holo-stage bg-holo-bg motion-safe:animate-pulse"')).toBe('"holo-stage bg-holo-bg motion-full:animate-pulse"');
|
||||
expect(transform('" motion-safe:animate-spin"')).toBe('" motion-full:animate-spin"');
|
||||
expect(transform('"motion-safe:data-open:zoom-in-95 motion-safe:slide-in-from-top-2"')).toBe(
|
||||
'"motion-full:data-open:zoom-in-95 motion-full:slide-in-from-top-2"',
|
||||
);
|
||||
expect(transform('"motion-safe:-translate-y-1 motion-safe:scale-95 motion-safe:rotate-90"')).toBe(
|
||||
'"motion-full:-translate-y-1 motion-full:scale-95 motion-full:rotate-90"',
|
||||
);
|
||||
});
|
||||
|
||||
it("drops the variant from colour and opacity transitions (off sets their duration to zero)", () => {
|
||||
expect(transform('"rounded-lg px-2 motion-safe:transition-colors hover:bg-muted"')).toBe(
|
||||
'"rounded-lg px-2 transition-colors hover:bg-muted"',
|
||||
);
|
||||
expect(transform('"motion-safe:transition-opacity"')).toBe('"transition-opacity"');
|
||||
});
|
||||
|
||||
it("gates bare pulse and spin behind full motion and drops motion-reduce:animate-none", () => {
|
||||
expect(transform('"size-4 animate-spin motion-reduce:animate-none"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
expect(transform('"h-14 animate-pulse rounded-xl"')).toBe('"h-14 motion-full:animate-pulse rounded-xl"');
|
||||
expect(transform('"size-4 animate-spin"')).toBe('"size-4 motion-full:animate-spin"');
|
||||
});
|
||||
|
||||
it("leaves other animate classes, tokens and identifiers alone", () => {
|
||||
const untouched = [
|
||||
'"data-open:animate-in data-closed:animate-out"',
|
||||
'"motion-full:animate-pulse"',
|
||||
"const isAnimating = animate-spinner;",
|
||||
'"animate-page-in"',
|
||||
"// motion-safe: is forbidden for movement",
|
||||
];
|
||||
for (const text of untouched) expect(transform(text)).toBe(text);
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const once = transform('"a motion-safe:transition-transform animate-spin motion-reduce:animate-none motion-safe:transition-colors"');
|
||||
expect(transform(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,25 @@ export default function ApiariesPage() {
|
||||
);
|
||||
const activeApiaries = byLocation.filter((apiary) => apiary.status === "ACTIVE");
|
||||
const historyApiaries = byLocation.filter((apiary) => apiary.status !== "ACTIVE");
|
||||
const visibleApiaries = tab === "active" ? activeApiaries : historyApiaries;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const apiaries = bucket === "active" ? activeApiaries : historyApiaries;
|
||||
return (
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
apiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={apiaries} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -63,35 +81,23 @@ export default function ApiariesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
/>
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={apiariesQuery} what="Bienenstände">
|
||||
{() =>
|
||||
visibleApiaries.length === 0 ? (
|
||||
<ApiariesEmpty
|
||||
tab={tab}
|
||||
isFiltered={location !== ""}
|
||||
onResetFilter={() => setLocation("")}
|
||||
/>
|
||||
) : (
|
||||
<ApiaryGrid apiaries={visibleApiaries} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeApiaries.length}
|
||||
historyCount={historyApiaries.length}
|
||||
toolbar={
|
||||
<Input
|
||||
className="w-48"
|
||||
placeholder="Standort"
|
||||
aria-label="Nach Standort filtern"
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
|
||||
);
|
||||
}
|
||||
if (!colony.data || !hive.data || !catalog) {
|
||||
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
|
||||
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-full:animate-pulse" />;
|
||||
}
|
||||
return (
|
||||
<HiveEditor
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ColonySelectField } from "@/components/colony-select-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -127,10 +127,11 @@ export function SwarmControlEditDialog({ check }: { check: SwarmControlCheck })
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Weiselzellen bereits verdeckelt (akut)"
|
||||
checked={queenCellsCapped}
|
||||
onChange={setQueenCellsCapped}
|
||||
onCheckedChange={setQueenCellsCapped}
|
||||
/>
|
||||
<Field label="Schwarmstimmung" required>
|
||||
{({ id }) => (
|
||||
|
||||
@@ -48,10 +48,30 @@ export default function ColoniesPage() {
|
||||
const colonies = coloniesQuery.data ?? [];
|
||||
const activeColonies = colonies.filter((colony) => colony.deactivatedAt === null);
|
||||
const historyColonies = colonies.filter((colony) => colony.deactivatedAt !== null);
|
||||
const bucketColonies = tab === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status ? bucketColonies.filter((colony) => colony.status === status) : bucketColonies;
|
||||
const statusOptions = tab === "active" ? LIVING_COLONY_STATUSES : ENDED_COLONY_STATUSES;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketColonies = bucket === "active" ? activeColonies : historyColonies;
|
||||
const visibleColonies = status
|
||||
? bucketColonies.filter((colony) => colony.status === status)
|
||||
: bucketColonies;
|
||||
return (
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
@@ -82,40 +102,28 @@ export default function ColoniesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={coloniesQuery} what="Völker">
|
||||
{() =>
|
||||
visibleColonies.length === 0 ? (
|
||||
<ColoniesEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketColonies.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<ColonyGrid colonies={visibleColonies} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeColonies.length}
|
||||
historyCount={historyColonies.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={statusOptions}
|
||||
labels={COLONY_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -165,7 +165,7 @@ function CreateNoticeForm({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ToggleChip label="Bereits angezeigt" checked={alreadySent} onChange={setAlreadySent} />
|
||||
<CheckboxField appearance="tile" label="Bereits angezeigt" checked={alreadySent} onCheckedChange={setAlreadySent} />
|
||||
{alreadySent ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="notice-sent-on">Angezeigt am</Label>
|
||||
|
||||
+4
-3
@@ -20,7 +20,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, type ChoiceOption } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -207,11 +207,12 @@ export function CreateRestrictionDialog() {
|
||||
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
>
|
||||
{apiaries.map((apiary) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={apiary.id}
|
||||
label={apiary.name}
|
||||
checked={values.apiaryIds.includes(apiary.id)}
|
||||
onChange={(checked) => toggleApiary(apiary.id, checked)}
|
||||
onCheckedChange={(checked) => toggleApiary(apiary.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { settingsFixture } from "@/lib/compliance/notice-fixtures";
|
||||
import { fetchComplianceSettings, updateComplianceSettings } from "@/lib/compliance/settings-api";
|
||||
|
||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||
const READ_ONLY_TEXT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||
|
||||
let permissions = ["compliance:read", "compliance:manage"];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
@@ -250,6 +251,26 @@ describe("ComplianceSettingsPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Speichern" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read-only tiles say why they are off (hint text, not grey only)", async () => {
|
||||
permissions = ["compliance:read"];
|
||||
renderPage();
|
||||
const trigger = await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||
await userEvent.click(screen.getByRole("button", { name: /Erweitert/ }));
|
||||
const counted = screen.getByRole("checkbox", { name: "Ableger" });
|
||||
|
||||
// Both trigger tiles and the counted colony types show the reason next to them.
|
||||
expect(screen.getAllByText(READ_ONLY_TEXT)).toHaveLength(3);
|
||||
for (const hint of screen.getAllByText(READ_ONLY_TEXT)) expect(hint).toBeVisible();
|
||||
expect(trigger).toHaveAccessibleDescription(expect.stringContaining(READ_ONLY_TEXT));
|
||||
expect(counted).toHaveAccessibleDescription(READ_ONLY_TEXT);
|
||||
});
|
||||
|
||||
it("editable tiles show no read-only hint", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("checkbox", { name: "Bei geänderter Völkerzahl" });
|
||||
expect(screen.queryByText(READ_ONLY_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so without the read permission and loads nothing", () => {
|
||||
permissions = [];
|
||||
renderPage();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { NoComplianceAccess } from "@/components/compliance/no-compliance-access";
|
||||
import { DetailHeader, DetailQueryFallback } from "@/components/detail-header";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
} from "./digit-rules-field";
|
||||
|
||||
const ASSUMPTION = "Annahme – bitte prüfen";
|
||||
/** Why a read-only tile is off (spec 10.3: hint text, not grey only). */
|
||||
const READ_ONLY_HINT = "Nur ansehen – Ändern braucht das Recht, Meldungen zu verwalten.";
|
||||
|
||||
/** Input id per form field that can carry a validation error, in page order. */
|
||||
const FIELD_INPUT_ID: Readonly<Record<string, string>> = {
|
||||
@@ -277,22 +279,26 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
||||
|
||||
<FormSection title="Wann soll ich dich an eine Anzeige erinnern?" step={2}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Bei geänderter Völkerzahl"
|
||||
checked={values.noticeOnColonyCountChange}
|
||||
disabled={readOnly}
|
||||
disabledHint={READ_ONLY_HINT}
|
||||
describedBy="note-trigger-count"
|
||||
onChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
||||
onCheckedChange={(checked) => set("noticeOnColonyCountChange", checked)}
|
||||
/>
|
||||
<AssumptionNote id="note-trigger-count" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Bei geändertem Stand"
|
||||
checked={values.noticeOnApiaryChange}
|
||||
disabled={readOnly}
|
||||
disabledHint={READ_ONLY_HINT}
|
||||
describedBy="note-trigger-apiary"
|
||||
onChange={(checked) => set("noticeOnApiaryChange", checked)}
|
||||
onCheckedChange={(checked) => set("noticeOnApiaryChange", checked)}
|
||||
/>
|
||||
<AssumptionNote id="note-trigger-apiary" />
|
||||
</div>
|
||||
@@ -361,15 +367,22 @@ function SettingsForm({ settings, canManage }: { settings: ComplianceSettings; c
|
||||
<p className="text-xs text-muted-foreground">Nur diese Arten zählen in der TSK-Zählung mit.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{COLONY_TYPES.map((type) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={type}
|
||||
label={COLONY_TYPE_LABELS[type]}
|
||||
checked={values.counted.includes(type)}
|
||||
disabled={readOnly}
|
||||
onChange={(checked) => toggleCounted(type, checked)}
|
||||
describedBy={readOnly ? "note-counted-read-only" : undefined}
|
||||
onCheckedChange={(checked) => toggleCounted(type, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<p id="note-counted-read-only" className="text-sm font-medium text-muted-foreground">
|
||||
{READ_ONLY_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
<DigitRulesField
|
||||
rows={values.rules}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import DashboardPage from "./page";
|
||||
import { fetchAutopilotToday } from "@/lib/autopilot/api";
|
||||
import { stepFixture, todayFixture } from "@/lib/autopilot/step-fixtures";
|
||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
@@ -229,6 +230,27 @@ describe("DashboardPage – Heute", () => {
|
||||
expect(screen.getAllByText("Nur mit Verbindung").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ticking a task on the Base UI honey-check cell completes it (Space works too)", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
// The first task is the next step; the second one is a row with the tick cell.
|
||||
vi.mocked(fetchTasks).mockResolvedValue([
|
||||
overdueTask(),
|
||||
{ ...overdueTask(), id: "t2", title: "Zweite Aufgabe" },
|
||||
]);
|
||||
vi.mocked(completeTask).mockResolvedValue({ ...overdueTask(), status: "DONE" } as never);
|
||||
renderPage();
|
||||
|
||||
const cell = await screen.findByRole("checkbox", { name: "„Zweite Aufgabe“ als erledigt markieren" });
|
||||
expect(cell).toHaveAttribute("data-slot", "checkbox");
|
||||
expect(cell).toHaveClass("honey-check");
|
||||
expect(cell).not.toBeChecked();
|
||||
cell.focus();
|
||||
await userEvent.keyboard(" ");
|
||||
|
||||
expect(cell).toBeChecked();
|
||||
await waitFor(() => expect(completeTask).toHaveBeenCalledWith("t2"), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it("gives the dashboard text links a 44 px target", async () => {
|
||||
vi.mocked(fetchAutopilotToday).mockResolvedValue(todayFixture({}));
|
||||
renderPage();
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
@@ -205,13 +205,14 @@ export default function NewExtractionPage() {
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{harvests?.map((harvest) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={harvest.id}
|
||||
label={`${new Date(harvest.date).toLocaleDateString("de-DE")} · ${harvest.colonies
|
||||
.map((entry) => entry.colony.code)
|
||||
.join(", ")}`}
|
||||
checked={values.harvestIds.includes(harvest.id)}
|
||||
onChange={(checked) =>
|
||||
onCheckedChange={(checked) =>
|
||||
toggleHarvest(harvest.id, checked)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import {
|
||||
DATE_REQUIRED_MESSAGE,
|
||||
DialogSubmitBar,
|
||||
@@ -177,11 +177,12 @@ export function HarvestEditDialog({ harvest }: { harvest: Harvest }) {
|
||||
className="grid grid-cols-2 gap-2"
|
||||
>
|
||||
{selectableColonies.map((colony) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={colony.id}
|
||||
label={colony.code}
|
||||
checked={colonyIds.includes(colony.id)}
|
||||
onChange={(checked) => toggleColony(colony.id, checked)}
|
||||
onCheckedChange={(checked) => toggleColony(colony.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import { HarvestRestrictionWarning } from "@/components/compliance/restriction-notice";
|
||||
@@ -259,10 +259,11 @@ export default function NewHarvestPage() {
|
||||
const withdrawal = withdrawalByColony.get(colony.id);
|
||||
return (
|
||||
<div key={colony.id} className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label={colonyOptionLabel(colony)}
|
||||
checked={values.colonyIds.includes(colony.id)}
|
||||
onChange={(checked) =>
|
||||
onCheckedChange={(checked) =>
|
||||
toggleColony(colony.id, checked)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,34 @@ export default function HoneyBatchesPage() {
|
||||
const honeyBatches = honeyBatchesQuery.data ?? [];
|
||||
const activeBatches = honeyBatches.filter((batch) => !HISTORY_STATUSES.includes(batch.status));
|
||||
const historyBatches = honeyBatches.filter((batch) => HISTORY_STATUSES.includes(batch.status));
|
||||
const visibleBatches = tab === "active" ? activeBatches : historyBatches;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const batches = bucket === "active" ? activeBatches : historyBatches;
|
||||
return (
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
batches.length === 0 ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={batches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -74,33 +101,12 @@ export default function HoneyBatchesPage() {
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeBatches.length}
|
||||
historyCount={historyBatches.length}
|
||||
/>
|
||||
|
||||
<QueryState query={honeyBatchesQuery} what="Honigchargen">
|
||||
{() =>
|
||||
visibleBatches.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Noch keine Honigchargen"
|
||||
description="Lege eine Charge an, sobald du geschleudert hast."
|
||||
action={<CreateHoneyBatchDialog />}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Droplet}
|
||||
title="Keine Historie"
|
||||
description="Verkaufte oder verbrauchte Chargen erscheinen hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<HoneyBatchGrid honeyBatches={visibleBatches} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useAppearance } from "@/lib/appearance/appearance";
|
||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { AppMotion } from "@/components/app-motion";
|
||||
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { NotificationBell } from "@/components/notification-bell";
|
||||
@@ -84,99 +85,101 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
// scroll container (overflow auto/scroll/hidden): it would capture
|
||||
// `position: sticky` and the StickySubmit save bar would never stick.
|
||||
return (
|
||||
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
||||
<a
|
||||
href={`#${MAIN_CONTENT_ID}`}
|
||||
inert={isShellInert}
|
||||
onClick={(event) => {
|
||||
// The hash alone would not move the focus into a Next.js page reliably.
|
||||
event.preventDefault();
|
||||
pageContentRef.current?.focus();
|
||||
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<Sidebar user={user} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header
|
||||
<AppMotion>
|
||||
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
|
||||
<a
|
||||
href={`#${MAIN_CONTENT_ID}`}
|
||||
inert={isShellInert}
|
||||
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center font-heading font-semibold sm:hidden"
|
||||
>
|
||||
<BrandMark withWordmark />
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
|
||||
{user.email}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sunActive}
|
||||
onCheckedChange={(checked) => {
|
||||
setContrast(checked ? "sun" : "normal");
|
||||
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Sun aria-hidden="true" />
|
||||
Sonnenmodus
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href="/settings/voice" />}>
|
||||
Sprache & Datenschutz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<OfflineBand
|
||||
userId={user.id}
|
||||
pageContentRef={pageContentRef}
|
||||
syncEnabled={sessionMode === "online"}
|
||||
inert={isShellInert}
|
||||
/>
|
||||
<div
|
||||
id={MAIN_CONTENT_ID}
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
|
||||
onClick={(event) => {
|
||||
// The hash alone would not move the focus into a Next.js page reliably.
|
||||
event.preventDefault();
|
||||
pageContentRef.current?.focus();
|
||||
pageContentRef.current?.scrollIntoView?.({ block: "start" });
|
||||
}}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<Sidebar user={user} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header
|
||||
inert={isShellInert}
|
||||
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center font-heading font-semibold sm:hidden"
|
||||
>
|
||||
<BrandMark withWordmark />
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<QuickAddSheet user={user} variant="header" />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
|
||||
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
|
||||
{user.email}
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={sunActive}
|
||||
onCheckedChange={(checked) => {
|
||||
setContrast(checked ? "sun" : "normal");
|
||||
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Sun aria-hidden="true" />
|
||||
Sonnenmodus
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuItem render={<Link href="/profile" />}>
|
||||
Profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href="/settings/voice" />}>
|
||||
Sprache & Datenschutz
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
>
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
<OfflineBand
|
||||
userId={user.id}
|
||||
pageContentRef={pageContentRef}
|
||||
syncEnabled={sessionMode === "online"}
|
||||
inert={isShellInert}
|
||||
/>
|
||||
<div
|
||||
id={MAIN_CONTENT_ID}
|
||||
ref={pageContentRef}
|
||||
tabIndex={-1}
|
||||
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
|
||||
>
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
</div>
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutOpen}
|
||||
onOpenChange={setIsLogoutOpen}
|
||||
userId={user.id}
|
||||
onLoggedOut={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
<div className="contents" inert={isShellInert}>
|
||||
<BottomNav user={user} />
|
||||
<QuickAddSheet user={user} variant="fab" />
|
||||
</div>
|
||||
<LogoutConfirmDialog
|
||||
open={isLogoutOpen}
|
||||
onOpenChange={setIsLogoutOpen}
|
||||
userId={user.id}
|
||||
onLoggedOut={() => router.push("/login")}
|
||||
/>
|
||||
</div>
|
||||
</AppMotion>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -219,10 +219,11 @@ function MedicineForm({ medicine, prefill, onDone }: MedicineFormProps) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Verschreibungspflichtig"
|
||||
checked={values.prescriptionOnly}
|
||||
onChange={(checked) => set("prescriptionOnly", checked)}
|
||||
onCheckedChange={(checked) => set("prescriptionOnly", checked)}
|
||||
/>
|
||||
<Field label="Anwendung" id="medicine-method" error={errorOf("applicationMethod")}>
|
||||
{({ id, describedBy: fieldDescribedBy }) => (
|
||||
|
||||
@@ -35,6 +35,22 @@ export default function MedicinesPage() {
|
||||
? `${visible.length} ${tab === "active" ? (visible.length === 1 ? "aktives Mittel" : "aktive Mittel") : visible.length === 1 ? "deaktiviertes Mittel" : "deaktivierte Mittel"}`
|
||||
: "";
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const shown = bucket === "active" ? active : inactive;
|
||||
if (shown.length > 0) return <MedicineGrid medicines={shown} />;
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={bucket === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!canRead) return <NoReadAccess />;
|
||||
|
||||
return (
|
||||
@@ -47,35 +63,24 @@ export default function MedicinesPage() {
|
||||
/>
|
||||
{canCreate ? <OnlineOnlyHint id={hintId} /> : null}
|
||||
|
||||
{medicines.length > 0 ? (
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{medicines.length > 0 ? countText : ""}
|
||||
</p>
|
||||
|
||||
<QueryState query={medicinesQuery} what="Tierarzneimittel">
|
||||
{() =>
|
||||
medicines.length === 0 ? (
|
||||
<MedicineSuggestions canCreate={canCreate} disabled={isOffline} describedBy={describedBy} />
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Pill}
|
||||
title={tab === "active" ? "Keine aktiven Mittel" : "Keine deaktivierten Mittel"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Alle Mittel sind deaktiviert. Aktiviere eins wieder auf seiner Seite."
|
||||
: "Deaktivierte Mittel erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MedicineGrid medicines={visible} />
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={active.length}
|
||||
historyCount={inactive.length}
|
||||
toolbar={
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{countText}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* A new page comes in (spec 17.4): at "Voll" it fades in and rises 6 px (--motion-enter,
|
||||
* --ease-out-expo), at "Dezent" or with the system's reduce motion it only fades (globals.css),
|
||||
* at "Aus" it stands at once. There is no exit: the old page goes at once, navigation never waits.
|
||||
*/
|
||||
export function PageTransition({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div key={pathname} className="motion-safe:animate-page-in">
|
||||
<div key={pathname} className="animate-page-in">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,32 +27,14 @@ export default function ProductsPage() {
|
||||
const products = productsQuery.data ?? [];
|
||||
const activeProducts = products.filter((product) => product.status === "ACTIVE");
|
||||
const historyProducts = products.filter((product) => product.status !== "ACTIVE");
|
||||
const visibleProducts = tab === "active" ? activeProducts : historyProducts;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
/>
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleProducts = bucket === "active" ? activeProducts : historyProducts;
|
||||
return (
|
||||
<QueryState query={productsQuery} what="Produkte">
|
||||
{() =>
|
||||
visibleProducts.length === 0 ? (
|
||||
tab === "active" ? (
|
||||
bucket === "active" ? (
|
||||
<EmptyState
|
||||
icon={ShoppingBag}
|
||||
title="Noch keine Produkte"
|
||||
@@ -75,6 +57,30 @@ export default function ProductsPage() {
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<PageHeader
|
||||
area="products"
|
||||
title="Produkte"
|
||||
description="Hier legst du deine verkaufsfertigen Produkte an und pflegst sie."
|
||||
actions={
|
||||
<Button size="touch" nativeButton={false} render={<Link href="/products/new" />}>
|
||||
+ Neues Produkt
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeProducts.length}
|
||||
historyCount={historyProducts.length}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
validateQuietHours,
|
||||
} from "@/lib/notification-settings/validation";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -104,17 +104,19 @@ function NotificationSettingsForm({
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Erinnerung je Aufgabe"
|
||||
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
||||
checked={draft.taskDueEnabled}
|
||||
onChange={(checked) => change("taskDueEnabled", checked)}
|
||||
onCheckedChange={(checked) => change("taskDueEnabled", checked)}
|
||||
/>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Morgenübersicht"
|
||||
description="Eine Meldung am Morgen mit allem, was heute ansteht."
|
||||
checked={draft.digestEnabled}
|
||||
onChange={(checked) => change("digestEnabled", checked)}
|
||||
onCheckedChange={(checked) => change("digestEnabled", checked)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="digest-time">Uhrzeit der Morgenübersicht</Label>
|
||||
|
||||
@@ -26,8 +26,7 @@ import { formatBerlinDate, formatBerlinTime } from "@/lib/telegram/format";
|
||||
import type { StepUpProof, TelegramLinkToken } from "@/lib/telegram/types";
|
||||
import { StepUpDialog } from "@/components/auth/step-up-dialog";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { SwitchRow } from "@/components/form/switch-row";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { ErrorState } from "@/components/query-state";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@@ -100,23 +99,26 @@ function DeliverySettings({ settings, isOffline, hintId }: DeliverySettingsProps
|
||||
<legend className="text-sm font-medium">Das kommt per Telegram</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NOTIFICATION_CATEGORIES.map((category) => (
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
key={category}
|
||||
label={NOTIFICATION_CATEGORY_LABELS[category]}
|
||||
checked={settings.telegramCategories.includes(category)}
|
||||
onChange={(checked) => toggleCategory(category, checked)}
|
||||
onCheckedChange={(checked) => toggleCategory(category, checked)}
|
||||
describedBy={isOffline ? hintId : undefined}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<SwitchRow
|
||||
<CheckboxField
|
||||
role="switch"
|
||||
label="Mit Details"
|
||||
description={PRIVACY_NOTE}
|
||||
checked={settings.telegramDetail}
|
||||
disabled={mutation.isPending || isOffline}
|
||||
onChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||
describedBy={isOffline ? hintId : undefined}
|
||||
onCheckedChange={(checked) => mutation.mutate({ telegramDetail: checked })}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
|
||||
@@ -28,8 +28,26 @@ export default function QueensPage() {
|
||||
const queens = queensQuery.data ?? [];
|
||||
const activeQueens = queens.filter((queen) => queen.deactivatedAt === null);
|
||||
const historyQueens = queens.filter((queen) => queen.deactivatedAt !== null);
|
||||
const bucketQueens = tab === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketQueens = bucket === "active" ? activeQueens : historyQueens;
|
||||
const visibleQueens = status ? bucketQueens.filter((queen) => queen.status === status) : bucketQueens;
|
||||
return (
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={bucket}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -44,40 +62,28 @@ export default function QueensPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QueryState query={queensQuery} what="Königinnen">
|
||||
{() =>
|
||||
visibleQueens.length === 0 ? (
|
||||
<QueensEmpty
|
||||
tab={tab}
|
||||
isFiltered={status !== "" && bucketQueens.length > 0}
|
||||
onResetFilter={() => setStatus("")}
|
||||
/>
|
||||
) : (
|
||||
<QueenGrid queens={visibleQueens} />
|
||||
)
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
setTab(next);
|
||||
setStatus("");
|
||||
}}
|
||||
activeCount={activeQueens.length}
|
||||
historyCount={historyQueens.length}
|
||||
toolbar={
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Status filtern"
|
||||
className="w-40"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
values={QUEEN_STATUSES}
|
||||
labels={QUEEN_STATUS_LABELS}
|
||||
noneLabel="Alle Status"
|
||||
/>
|
||||
}
|
||||
</QueryState>
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { formatGeneratedNumber } from "@/lib/automation/number-format";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -280,11 +280,12 @@ function RuleCard({
|
||||
))}
|
||||
{!numberFormatRule ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Zusätzlich melden"
|
||||
checked={notify}
|
||||
describedBy={`notify-hint-${rule.id}`}
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
clearIssues("notify");
|
||||
setNotify(checked);
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CalendarSyncSettings } from "@/lib/calendar-sync/types";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -81,14 +81,12 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
|
||||
Microsoft-Anmeldung (SSO) – dafür muss diese zusätzlich die
|
||||
Application-Berechtigung „Calendars.ReadWrite“ mit Admin-Consent erhalten.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="sync-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => setEnabled(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="sync-enabled"
|
||||
label="Synchronisation aktiv"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="sync-group-id">Kennung des Microsoft-365-Gruppenkalenders</Label>
|
||||
<Input
|
||||
@@ -103,19 +101,20 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
|
||||
„Objekt-ID“. Bei Unklarheit die IT fragen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Welche Einträge übertragen werden</p>
|
||||
<div role="group" aria-labelledby="sync-types-label" className="flex flex-col gap-1">
|
||||
<p id="sync-types-label" className="mb-1 text-sm font-medium">
|
||||
Welche Einträge übertragen werden
|
||||
</p>
|
||||
{SYNC_TYPE_FIELDS.map((field) => (
|
||||
<div key={field.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`sync-${field.key}`}
|
||||
checked={typeFlags[field.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setTypeFlags((prev) => ({ ...prev, [field.key]: checked === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`sync-${field.key}`}>{field.label}</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
key={field.key}
|
||||
id={`sync-${field.key}`}
|
||||
label={field.label}
|
||||
checked={typeFlags[field.key]}
|
||||
onCheckedChange={(checked) =>
|
||||
setTypeFlags((prev) => ({ ...prev, [field.key]: checked }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -133,28 +134,21 @@ export function CreateWebhookDialog() {
|
||||
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ereignisse</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div role="group" aria-labelledby="webhook-events-label" className="flex flex-col gap-2">
|
||||
<span id="webhook-events-label" className="text-sm font-medium">
|
||||
Ereignisse
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WEBHOOK_EVENTS.map((webhookEvent) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={webhookEvent}
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor={`webhook-event-${webhookEvent}`}
|
||||
>
|
||||
<input
|
||||
id={`webhook-event-${webhookEvent}`}
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(current, webhookEvent, inputEvent.target.checked),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
id={`webhook-event-${webhookEvent}`}
|
||||
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
checked={events.includes(webhookEvent)}
|
||||
onCheckedChange={(checked) =>
|
||||
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/t
|
||||
import { TaskPriority } from "@/lib/tasks/types";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { fetchUsers } from "@/lib/users/api";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -193,19 +194,13 @@ export function EditTaskTemplateDialog({ template }: { template: TaskTemplate })
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="template-edit-active"
|
||||
>
|
||||
<input
|
||||
id="template-edit-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
Aktiv – eine deaktivierte Vorlage erzeugt keine Aufgaben mehr
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="template-edit-active"
|
||||
label="Aktiv"
|
||||
description="Eine deaktivierte Vorlage erzeugt keine Aufgaben mehr."
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
WEBHOOK_EVENT_LABELS,
|
||||
toggleWebhookEvent,
|
||||
} from "@/lib/webhooks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -81,48 +82,31 @@ function EditWebhookForm({
|
||||
onChange={(inputEvent) => setUrl(inputEvent.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ereignisse</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div role="group" aria-labelledby="webhook-edit-events-label" className="flex flex-col gap-2">
|
||||
<span id="webhook-edit-events-label" className="text-sm font-medium">
|
||||
Ereignisse
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WEBHOOK_EVENTS.map((webhookEvent) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={webhookEvent}
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor={`webhook-edit-event-${webhookEvent}`}
|
||||
>
|
||||
<input
|
||||
id={`webhook-edit-event-${webhookEvent}`}
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={events.includes(webhookEvent)}
|
||||
onChange={(inputEvent) =>
|
||||
setEvents((current) =>
|
||||
toggleWebhookEvent(
|
||||
current,
|
||||
webhookEvent,
|
||||
inputEvent.target.checked,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
</label>
|
||||
id={`webhook-edit-event-${webhookEvent}`}
|
||||
label={WEBHOOK_EVENT_LABELS[webhookEvent]}
|
||||
checked={events.includes(webhookEvent)}
|
||||
onCheckedChange={(checked) =>
|
||||
setEvents((current) => toggleWebhookEvent(current, webhookEvent, checked))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
className="flex h-9 items-center gap-3 text-sm"
|
||||
htmlFor="webhook-edit-active"
|
||||
>
|
||||
<input
|
||||
id="webhook-edit-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={active}
|
||||
onChange={(inputEvent) => setActive(inputEvent.target.checked)}
|
||||
/>
|
||||
Aktiv – bei deaktiviertem Webhook werden keine Ereignisse zugestellt
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="webhook-edit-active"
|
||||
label="Aktiv"
|
||||
description="Bei deaktiviertem Webhook werden keine Ereignisse zugestellt."
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
/>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, type ComponentType } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { WebhooksTab } from "./webhooks-tab";
|
||||
import { TaskTemplatesTab } from "./task-templates-tab";
|
||||
import { LabelTemplatesTab } from "./label-templates-tab";
|
||||
@@ -22,17 +22,17 @@ type Tab =
|
||||
| "api-keys";
|
||||
|
||||
// Everyday settings first, connections to other systems last.
|
||||
const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "rules", label: "Vorschläge" },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben" },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen" },
|
||||
{ value: "social-media", label: "Stammdaten & Web" },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync" },
|
||||
{ value: "api-keys", label: "API-Schlüssel" },
|
||||
{ value: "webhooks", label: "Webhooks" },
|
||||
const TABS: { value: Tab; label: string; Content: ComponentType }[] = [
|
||||
{ value: "rules", label: "Vorschläge", Content: AutomationRulesTab },
|
||||
{ value: "task-templates", label: "Wiederkehrende Aufgaben", Content: TaskTemplatesTab },
|
||||
{ value: "label-templates", label: "Etikettenvorlagen", Content: LabelTemplatesTab },
|
||||
{ value: "social-media", label: "Stammdaten & Web", Content: SocialMediaTab },
|
||||
{ value: "calendar-sync", label: "Kalender-Sync", Content: CalendarSyncTab },
|
||||
{ value: "api-keys", label: "API-Schlüssel", Content: ApiKeysTab },
|
||||
{ value: "webhooks", label: "Webhooks", Content: WebhooksTab },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return TABS.some((tab) => tab.value === value);
|
||||
}
|
||||
|
||||
@@ -54,15 +54,25 @@ function AutomationPageContent() {
|
||||
description="Was ApiCore dir abnimmt – und wie es mit anderen Systemen verbunden ist."
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "webhooks" ? <WebhooksTab /> : null}
|
||||
{tab === "task-templates" ? <TaskTemplatesTab /> : null}
|
||||
{tab === "label-templates" ? <LabelTemplatesTab /> : null}
|
||||
{tab === "social-media" ? <SocialMediaTab /> : null}
|
||||
{tab === "rules" ? <AutomationRulesTab /> : null}
|
||||
{tab === "calendar-sync" ? <CalendarSyncTab /> : null}
|
||||
{tab === "api-keys" ? <ApiKeysTab /> : null}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Bereiche der Automatisierung">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{TABS.map(({ value, Content }) => (
|
||||
<TabsContent key={value} value={value}>
|
||||
<Content />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
|
||||
className={cn("mt-1 size-4 shrink-0 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FormEvent, ReactNode, useId, useMemo } from "react";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -118,10 +118,11 @@ export function RuleToggle({ trigger, draft, path, label, hint }: RuleToggleProp
|
||||
const errorId = error ? `${baseId}-error` : undefined;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label={label}
|
||||
checked={draft.values[path] === true}
|
||||
onChange={(checked) => draft.set(path, checked)}
|
||||
onCheckedChange={(checked) => draft.set(path, checked)}
|
||||
describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined}
|
||||
/>
|
||||
{hintText ? (
|
||||
|
||||
@@ -48,74 +48,81 @@ export function TaskTemplatesTab() {
|
||||
|
||||
const activeTemplates = (templates ?? []).filter((template) => template.active);
|
||||
const historyTemplates = (templates ?? []).filter((template) => !template.active);
|
||||
const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates;
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleTemplates = bucket === "active" ? activeTemplates : historyTemplates;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={bucket === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
/>
|
||||
<CreateTaskTemplateDialog />
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Vorlagen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{templates && visibleTemplates.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={tab === "active" ? "Noch keine Vorlagen angelegt" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Vorlage an, um wiederkehrende Aufgaben zu automatisieren."
|
||||
: "Deaktivierte Vorlagen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTemplates.map((template) => (
|
||||
<EntityCard
|
||||
key={template.id}
|
||||
area="tasks"
|
||||
title={template.title}
|
||||
badge={
|
||||
<Badge variant={template.active ? "default" : "outline"}>
|
||||
{template.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Wiederholung", value: describeRecurrence(template) },
|
||||
{ label: "Nächste Fälligkeit", value: formatDate(template.nextDueDate) },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<EditTaskTemplateDialog template={template} />
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerVariant="ghost"
|
||||
triggerDisabled={!template.active || deactivateMutation.isPending}
|
||||
title="Wiederkehrende Aufgabe beenden?"
|
||||
description={`Für „${template.title}“ werden keine neuen Aufgaben mehr angelegt. Bereits angelegte Aufgaben bleiben in der Liste.`}
|
||||
confirmLabel="Beenden"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(template.id)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTemplates.length}
|
||||
historyCount={historyTemplates.length}
|
||||
toolbar={<CreateTaskTemplateDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -146,16 +147,13 @@ function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; on
|
||||
</Field>
|
||||
))}
|
||||
{system ? (
|
||||
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
|
||||
<input
|
||||
id="hive-system-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={values.active}
|
||||
onChange={(event) => setValues({ ...values, active: event.target.checked })}
|
||||
/>
|
||||
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="hive-system-active"
|
||||
label="Aktiv"
|
||||
description="Ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl."
|
||||
checked={values.active}
|
||||
onCheckedChange={(active) => setValues({ ...values, active })}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
|
||||
+13
-25
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/label-templates/check";
|
||||
import { buildComplianceBlock } from "@/lib/label-templates/compliance-block";
|
||||
import { LabelCompliancePanel } from "@/components/labels/label-compliance-panel";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -680,15 +681,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
|
||||
<input
|
||||
id="designer-default"
|
||||
type="checkbox"
|
||||
checked={isDefault}
|
||||
onChange={(event) => setIsDefault(event.target.checked)}
|
||||
/>
|
||||
Als Standardvorlage verwenden
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="designer-default"
|
||||
label="Als Standardvorlage verwenden"
|
||||
checked={isDefault}
|
||||
onCheckedChange={setIsDefault}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -786,22 +784,12 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border border-border p-3 focus-ring-within">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
checked={cropMarks}
|
||||
onChange={(event) => setCropMarks(event.target.checked)}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">Schnittmarken am Blattrand</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider.
|
||||
Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die
|
||||
Striche gedruckt werden.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<CheckboxField
|
||||
label="Schnittmarken am Blattrand"
|
||||
description="Kleine Striche im Rand an jeder Schnittkante – zum Anlegen am Papierschneider. Ausschalten bei vorgestanzten Etikettenbögen. Rand mindestens 5 mm, damit die Striche gedruckt werden."
|
||||
checked={cropMarks}
|
||||
onCheckedChange={setCropMarks}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AutomationPage from "./automation/page";
|
||||
import UsersRolesPage from "./users-roles/page";
|
||||
|
||||
const replace = vi.fn();
|
||||
let search = "";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
useSearchParams: () => new URLSearchParams(search),
|
||||
}));
|
||||
|
||||
// The tab contents fetch their own data; here only the switch between them matters.
|
||||
vi.mock("./automation/webhooks-tab", () => ({ WebhooksTab: () => <p>Inhalt Webhooks</p> }));
|
||||
vi.mock("./automation/task-templates-tab", () => ({ TaskTemplatesTab: () => <p>Inhalt Vorlagen</p> }));
|
||||
vi.mock("./automation/label-templates-tab", () => ({ LabelTemplatesTab: () => <p>Inhalt Etiketten</p> }));
|
||||
vi.mock("./automation/social-media-tab", () => ({ SocialMediaTab: () => <p>Inhalt Stammdaten</p> }));
|
||||
vi.mock("./automation/automation-rules-tab", () => ({ AutomationRulesTab: () => <p>Inhalt Vorschläge</p> }));
|
||||
vi.mock("./automation/calendar-sync-tab", () => ({ CalendarSyncTab: () => <p>Inhalt Kalender</p> }));
|
||||
vi.mock("./automation/api-keys-tab", () => ({ ApiKeysTab: () => <p>Inhalt API</p> }));
|
||||
vi.mock("./users-roles/users-tab", () => ({ UsersTab: () => <p>Inhalt Benutzer</p> }));
|
||||
vi.mock("./users-roles/roles-tab", () => ({ RolesTab: () => <p>Inhalt Rollen</p> }));
|
||||
|
||||
describe("both settings pages use Tabs directly", () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset();
|
||||
search = "";
|
||||
});
|
||||
|
||||
it("Automatisierung: ?tab= picks the tab, the panel is labelled by it and a switch keeps the URL form", async () => {
|
||||
search = "tab=api-keys";
|
||||
const user = userEvent.setup();
|
||||
render(<AutomationPage />);
|
||||
|
||||
const selected = screen.getByRole("tab", { name: "API-Schlüssel" });
|
||||
expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "API-Schlüssel" })).toHaveTextContent("Inhalt API");
|
||||
expect(screen.getByRole("tablist")).toHaveAttribute("data-variant", "segment");
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Webhooks" }));
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/automation?tab=webhooks");
|
||||
});
|
||||
|
||||
it("Automatisierung: an unknown ?tab= falls back to Vorschläge", () => {
|
||||
search = "tab=unknown";
|
||||
render(<AutomationPage />);
|
||||
expect(screen.getByRole("tab", { name: "Vorschläge" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Vorschläge");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: an arrow only moves the focus, Enter switches the tab through the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UsersRolesPage />);
|
||||
|
||||
expect(screen.getByRole("tabpanel", { name: "Benutzer" })).toHaveTextContent("Inhalt Benutzer");
|
||||
await user.click(screen.getByRole("tab", { name: "Benutzer" }));
|
||||
replace.mockReset();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveFocus();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(replace).toHaveBeenLastCalledWith("/settings/users-roles?tab=roles");
|
||||
});
|
||||
|
||||
it("Benutzer & Rollen: ?tab=roles shows the roles", () => {
|
||||
search = "tab=roles";
|
||||
render(<UsersRolesPage />);
|
||||
expect(screen.getByRole("tab", { name: "Rollen & Rechte" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Inhalt Rollen");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { TabSwitcher } from "@/components/tab-switcher";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UsersTab } from "./users-tab";
|
||||
import { RolesTab } from "./roles-tab";
|
||||
|
||||
@@ -14,7 +14,7 @@ const TABS: { value: Tab; label: string }[] = [
|
||||
{ value: "roles", label: "Rollen & Rechte" },
|
||||
];
|
||||
|
||||
function isTab(value: string | null): value is Tab {
|
||||
function isTab(value: unknown): value is Tab {
|
||||
return value === "users" || value === "roles";
|
||||
}
|
||||
|
||||
@@ -36,9 +36,26 @@ function UsersRolesPageContent() {
|
||||
description="Zugänge, Rollen und Berechtigungen verwalten"
|
||||
/>
|
||||
|
||||
<TabSwitcher tabs={TABS} value={tab} onChange={setTab} />
|
||||
|
||||
{tab === "users" ? <UsersTab /> : <RolesTab />}
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(next) => {
|
||||
if (isTab(next)) setTab(next);
|
||||
}}
|
||||
>
|
||||
<TabsList aria-label="Benutzer oder Rollen">
|
||||
{TABS.map((entry) => (
|
||||
<TabsTrigger key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="users">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<RolesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PermissionCatalogEntry } from "@/lib/roles/types";
|
||||
import { PermissionMatrix } from "./permission-matrix";
|
||||
|
||||
function entry(key: string, labelDe: string, sortOrder: number): PermissionCatalogEntry {
|
||||
return { key, labelDe, groupDe: "Völker", sortOrder } as PermissionCatalogEntry;
|
||||
}
|
||||
|
||||
const CATALOG = [entry("colonies:read", "Ansehen", 1), entry("colonies:update", "Bearbeiten", 2)];
|
||||
|
||||
function Harness({ disabled }: { disabled?: boolean }) {
|
||||
const [selected, setSelected] = useState(new Set(["colonies:read"]));
|
||||
return <PermissionMatrix catalog={CATALOG} selected={selected} onChange={setSelected} disabled={disabled} />;
|
||||
}
|
||||
|
||||
describe("PermissionMatrix", () => {
|
||||
it("the group box is partly checked and checks the whole group", async () => {
|
||||
render(<Harness />);
|
||||
const group = screen.getByRole("checkbox", { name: "Völker" });
|
||||
expect(group).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(group).toHaveAccessibleDescription("1 von 2 gewählt");
|
||||
|
||||
await userEvent.click(group);
|
||||
expect(group).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Bearbeiten" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("neighbouring rows keep at least 4 px between them", () => {
|
||||
render(<Harness />);
|
||||
const group = screen.getByRole("checkbox", { name: "Völker" });
|
||||
const entries = screen.getByRole("checkbox", { name: "Ansehen" }).parentElement as HTMLElement;
|
||||
expect(group.parentElement?.className).toMatch(/(^|\s)gap-1(\s|$)/);
|
||||
expect(entries.className).toMatch(/(^|\s)gap-y-1(\s|$)/);
|
||||
});
|
||||
|
||||
it("disabled says why, visibly and on every box", () => {
|
||||
render(<Harness disabled />);
|
||||
const hint = screen.getByText(/Rechte lassen sich hier nicht ändern/);
|
||||
expect(hint).toBeVisible();
|
||||
for (const box of screen.getAllByRole("checkbox")) {
|
||||
expect(box).toBeDisabled();
|
||||
expect(box).toHaveAccessibleDescription(expect.stringContaining(hint.textContent ?? "x"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useId, useMemo } from "react";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { PermissionCatalogEntry } from "@/lib/roles/types";
|
||||
|
||||
interface PermissionMatrixProps {
|
||||
@@ -10,8 +9,12 @@ interface PermissionMatrixProps {
|
||||
selected: Set<string>;
|
||||
onChange: (next: Set<string>) => void;
|
||||
disabled?: boolean;
|
||||
/** Why the rights are off; shown above the matrix while disabled (not grey only). */
|
||||
disabledHint?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_DISABLED_HINT = "Die Rechte lassen sich hier nicht ändern.";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -41,8 +44,12 @@ export function PermissionMatrix({
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
disabledHint = DEFAULT_DISABLED_HINT,
|
||||
}: PermissionMatrixProps) {
|
||||
const groups = useMemo(() => groupCatalog(catalog), [catalog]);
|
||||
const hintId = useId();
|
||||
// One visible reason for the whole matrix instead of a line under every box.
|
||||
const describedBy = disabled ? hintId : undefined;
|
||||
|
||||
function toggleOne(key: string, checked: boolean) {
|
||||
const next = new Set(selected);
|
||||
@@ -67,44 +74,47 @@ export function PermissionMatrix({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-border/60 p-3">
|
||||
// Container queries: the matrix sits in a 384 px dialog on a wide screen,
|
||||
// so the columns follow its own width, not the viewport.
|
||||
<div className="@container flex flex-col gap-4 rounded-xl border border-border/60 p-3">
|
||||
{disabled ? (
|
||||
<p id={hintId} className="text-sm font-medium text-muted-foreground">
|
||||
{disabledHint}
|
||||
</p>
|
||||
) : null}
|
||||
{groups.map((group) => {
|
||||
const groupSelectedCount = group.entries.filter((entry) =>
|
||||
selected.has(entry.key),
|
||||
).length;
|
||||
const allSelected = groupSelectedCount === group.entries.length;
|
||||
const groupState =
|
||||
groupSelectedCount === group.entries.length
|
||||
? true
|
||||
: groupSelectedCount > 0
|
||||
? "indeterminate"
|
||||
: false;
|
||||
return (
|
||||
<div key={group.groupDe} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`group-${slugify(group.groupDe)}`}
|
||||
checked={allSelected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`group-${slugify(group.groupDe)}`}
|
||||
className="text-sm font-semibold text-foreground"
|
||||
>
|
||||
{group.groupDe}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{groupSelectedCount}/{group.entries.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 pl-6 sm:grid-cols-3">
|
||||
<div key={group.groupDe} className="flex flex-col gap-1">
|
||||
<CheckboxField
|
||||
id={`group-${slugify(group.groupDe)}`}
|
||||
className="font-semibold"
|
||||
label={group.groupDe}
|
||||
description={`${groupSelectedCount} von ${group.entries.length} gewählt`}
|
||||
checked={groupState}
|
||||
disabled={disabled}
|
||||
describedBy={describedBy}
|
||||
onCheckedChange={(checked) => toggleGroup(group.entries, checked)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-1 pl-8 @lg:grid-cols-2 @3xl:grid-cols-3">
|
||||
{group.entries.map((entry) => (
|
||||
<div key={entry.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`perm-${entry.key}`}
|
||||
checked={selected.has(entry.key)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
|
||||
/>
|
||||
<Label htmlFor={`perm-${entry.key}`} className="text-sm font-normal">
|
||||
{entry.labelDe}
|
||||
</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
key={entry.key}
|
||||
id={`perm-${entry.key}`}
|
||||
label={entry.labelDe}
|
||||
checked={selected.has(entry.key)}
|
||||
disabled={disabled}
|
||||
describedBy={describedBy}
|
||||
onCheckedChange={(checked) => toggleOne(entry.key, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,112 +54,119 @@ export function RolesTab() {
|
||||
|
||||
const activeRoles = (roles ?? []).filter((role) => role.status === "ACTIVE");
|
||||
const historyRoles = (roles ?? []).filter((role) => role.status === "INACTIVE");
|
||||
const visibleRoles = tab === "active" ? activeRoles : historyRoles;
|
||||
const catalogLength = catalog?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
/>
|
||||
<CreateRoleDialog />
|
||||
</div>
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleRoles = bucket === "active" ? activeRoles : historyRoles;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Rollen konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={bucket === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{roles && visibleRoles.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title={tab === "active" ? "Noch keine Rollen" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? "Lege eine Rolle an, um Zugriffe granular zu steuern."
|
||||
: "Deaktivierte Rollen erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
{visibleRoles.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleRoles.map((role) => (
|
||||
<EntityCard
|
||||
key={role.id}
|
||||
area="users-roles"
|
||||
title={role.name}
|
||||
subtitle={role.key}
|
||||
badge={
|
||||
role.isSystem ? (
|
||||
<Badge variant="secondary">System-Rolle</Badge>
|
||||
) : (
|
||||
<Badge variant={role.status === "ACTIVE" ? "default" : "outline"}>
|
||||
{role.status === "ACTIVE" ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
fields={[
|
||||
{
|
||||
label: "Rechte",
|
||||
value: `${role.permissionKeys.length}/${catalogLength}`,
|
||||
},
|
||||
{ label: "Benutzer", value: role.userCount },
|
||||
]}
|
||||
actions={
|
||||
role.isSystem ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Systemrollen können nicht bearbeitet werden.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
onClick={() => setEditingRole(role)}
|
||||
>
|
||||
Reaktivieren
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{role.status === "ACTIVE" ? (
|
||||
<ConfirmDialog
|
||||
triggerLabel="Deaktivieren"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={role.userCount > 0 || deactivateMutation.isPending}
|
||||
title="Rolle deaktivieren?"
|
||||
description={
|
||||
role.userCount > 0
|
||||
? `„${role.name}“ ist noch ${role.userCount} Benutzer(n) zugewiesen. Bitte zuerst umverteilen.`
|
||||
: `„${role.name}“ kann keinem Benutzer mehr neu zugewiesen werden. Kann jederzeit reaktiviert werden.`
|
||||
}
|
||||
confirmLabel="Deaktivieren"
|
||||
isPending={deactivateMutation.isPending}
|
||||
onConfirm={() => deactivateMutation.mutate(role.id)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate(role.id)}
|
||||
>
|
||||
Reaktivieren
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeRoles.length}
|
||||
historyCount={historyRoles.length}
|
||||
toolbar={<CreateRoleDialog />}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
|
||||
<EditRoleDialog role={editingRole} onClose={() => setEditingRole(null)} />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
|
||||
import { discardAllPendingVoice, listPendingVoice } from "@/lib/voice/pending-voice";
|
||||
import { isListedVoiceStatus } from "@/lib/voice/status-texts";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -45,20 +46,14 @@ function CloudConsentCard() {
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<h2 className="font-heading text-lg font-semibold">Browser-Cloud-Erkennung</h2>
|
||||
<label
|
||||
htmlFor="voice-cloud-consent"
|
||||
className="flex min-h-14 cursor-pointer items-center justify-between gap-4 rounded-xl border border-input px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-medium">Browser-Cloud-Erkennung erlauben</span>
|
||||
<input
|
||||
id="voice-cloud-consent"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
className="size-6 shrink-0 accent-[var(--primary)]"
|
||||
checked={allowed}
|
||||
onChange={(event) => toggle(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<CheckboxField
|
||||
id="voice-cloud-consent"
|
||||
role="switch"
|
||||
size="sheet"
|
||||
label="Browser-Cloud-Erkennung erlauben"
|
||||
checked={allowed}
|
||||
onCheckedChange={toggle}
|
||||
/>
|
||||
<p role="status" className="text-sm font-medium">
|
||||
{allowed ? "Erlaubt" : "Aus"}
|
||||
</p>
|
||||
|
||||
@@ -17,18 +17,13 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-150",
|
||||
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4.5 shrink-0 transition-transform duration-150",
|
||||
!isActive && "motion-full:group-hover:scale-110",
|
||||
)}
|
||||
/>
|
||||
<Icon className="size-4.5 shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||
</Link>
|
||||
|
||||
@@ -55,4 +55,12 @@ describe("StatisticsPage", () => {
|
||||
expect(fetchColonyStats).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the statistics page uses the segment variant", () => {
|
||||
vi.mocked(fetchColonyStats).mockReturnValue(new Promise(() => {}));
|
||||
renderWithClient(<StatisticsPage />);
|
||||
const list = screen.getByRole("tablist", { hidden: true });
|
||||
expect(list).toHaveAttribute("data-variant", "segment");
|
||||
expect(screen.getByRole("tab", { name: "Völker", hidden: true })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,21 @@ export default function StoragePage() {
|
||||
const containers = containersQuery.data ?? [];
|
||||
const activeContainers = containers.filter((container) => container.status !== "OUT_OF_SERVICE");
|
||||
const historyContainers = containers.filter((container) => container.status === "OUT_OF_SERVICE");
|
||||
const visibleContainers = containerTab === "active" ? activeContainers : historyContainers;
|
||||
|
||||
const containersOf = (bucket: EntityLifecycleTab) => {
|
||||
const visibleContainers = bucket === "active" ? activeContainers : historyContainers;
|
||||
return (
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={bucket} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-8 p-4 sm:p-6">
|
||||
@@ -47,24 +61,15 @@ export default function StoragePage() {
|
||||
actions={<CreateStorageContainerDialog />}
|
||||
/>
|
||||
{hasPermission("veterinary-medicines:read") ? <MedicinesCard /> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>
|
||||
<ActiveHistoryTabs
|
||||
value={containerTab}
|
||||
onChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
/>
|
||||
</div>
|
||||
<QueryState query={containersQuery} what="Lagerbehälter">
|
||||
{() =>
|
||||
visibleContainers.length === 0 ? (
|
||||
<ContainersEmpty tab={containerTab} />
|
||||
) : (
|
||||
<ContainerGrid containers={visibleContainers} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
<ActiveHistoryTabs
|
||||
heading={<h2 className="text-lg font-semibold tracking-tight">Lagerbehälter</h2>}
|
||||
value={containerTab}
|
||||
onValueChange={setContainerTab}
|
||||
activeCount={activeContainers.length}
|
||||
historyCount={historyContainers.length}
|
||||
>
|
||||
{{ active: containersOf("active"), history: containersOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 border-t border-border/60 pt-6">
|
||||
|
||||
@@ -505,7 +505,7 @@ describe("SuggestionsPage", () => {
|
||||
|
||||
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
|
||||
const chevron = toggle.querySelector("svg");
|
||||
expect(chevron).toHaveClass("motion-safe:transition-transform");
|
||||
expect(chevron).toHaveClass("motion-full:transition-transform");
|
||||
expect(chevron).not.toHaveClass("transition-transform");
|
||||
});
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ export default function SuggestionsPage() {
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
|
||||
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", showDismissed && "rotate-180")}
|
||||
/>
|
||||
Verworfene (30 Tage)
|
||||
</Button>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { LivingColonySelectField } from "@/components/colony-select-field";
|
||||
import { FormSection } from "@/components/form/form-section";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { DraftBanner } from "@/components/form/draft-banner";
|
||||
import {
|
||||
@@ -186,10 +186,11 @@ export default function NewSwarmControlCheckPage() {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ToggleChip
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Weiselzellen bereits verdeckelt (akut)"
|
||||
checked={values.queenCellsCapped}
|
||||
onChange={(checked) => set("queenCellsCapped", checked)}
|
||||
onCheckedChange={(checked) => set("queenCellsCapped", checked)}
|
||||
/>
|
||||
<Field
|
||||
label="Schwarmstimmung"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TasksPage from "./page";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tasks/api", () => ({
|
||||
fetchTasks: vi.fn(),
|
||||
completeTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./task-edit-dialog", () => ({ TaskEditDialog: () => null }));
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TasksPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("TasksPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTasks).mockReset();
|
||||
vi.mocked(fetchTasks).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("refetches with the chosen priority when the filter changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const client = renderPage();
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenCalledWith({ priority: undefined }));
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: "Nach Priorität filtern" }));
|
||||
await user.click(await screen.findByRole("option", { name: "Hoch" }));
|
||||
|
||||
await waitFor(() => expect(fetchTasks).toHaveBeenLastCalledWith({ priority: "HIGH" }));
|
||||
// The key still starts with "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) hits it.
|
||||
const keys = client.getQueryCache().getAll().map((query) => query.queryKey);
|
||||
expect(keys.every((key) => key[0] === "tasks")).toBe(true);
|
||||
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
@@ -26,7 +27,9 @@ export default function TasksPage() {
|
||||
const [onlyOverdue, setOnlyOverdue] = useState(false);
|
||||
|
||||
const { data: tasks, isLoading, isError } = useQuery({
|
||||
queryKey: ["tasks"],
|
||||
// The filter belongs to the key, or a new priority never refetches. The key keeps the
|
||||
// prefix "tasks", so every invalidateQueries({ queryKey: ["tasks"] }) still reaches it.
|
||||
queryKey: ["tasks", "list", { priority: priority || null }],
|
||||
queryFn: () => fetchTasks({ priority: priority || undefined }),
|
||||
});
|
||||
|
||||
@@ -43,11 +46,79 @@ export default function TasksPage() {
|
||||
const [now] = useState(() => Date.now());
|
||||
const activeTasks = (tasks ?? []).filter((task) => !HISTORY_STATUSES.includes(task.status));
|
||||
const historyTasks = (tasks ?? []).filter((task) => HISTORY_STATUSES.includes(task.status));
|
||||
const bucketTasks = tab === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
|
||||
const listOf = (bucket: EntityLifecycleTab) => {
|
||||
const bucketTasks = bucket === "active" ? activeTasks : historyTasks;
|
||||
const visibleTasks = bucketTasks.filter((task) => {
|
||||
if (!onlyOverdue) return true;
|
||||
return task.dueDate !== null && new Date(task.dueDate).getTime() < now;
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={bucket === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
bucket === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
@@ -62,95 +133,35 @@ export default function TasksPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
/>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
{tab === "active" ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOverdue}
|
||||
onChange={(event) => setOnlyOverdue(event.target.checked)}
|
||||
<ActiveHistoryTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
activeCount={activeTasks.length}
|
||||
historyCount={historyTasks.length}
|
||||
toolbar={
|
||||
<>
|
||||
<EnumSelect
|
||||
ariaLabel="Nach Priorität filtern"
|
||||
className="w-40"
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
values={TASK_PRIORITIES}
|
||||
labels={TASK_PRIORITY_LABELS}
|
||||
noneLabel="Alle Prioritäten"
|
||||
/>
|
||||
Nur überfällige
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Aufgaben konnten nicht geladen werden.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tasks && visibleTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title={tab === "active" ? "Keine Aufgaben" : "Keine Historie"}
|
||||
description={
|
||||
tab === "active"
|
||||
? onlyOverdue
|
||||
? "Keine überfälligen Aufgaben – gut gemacht!"
|
||||
: "Lege deine erste Aufgabe an, um den Überblick zu behalten."
|
||||
: "Erledigte oder abgebrochene Aufgaben erscheinen hier."
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleTasks.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<EntityCard
|
||||
key={task.id}
|
||||
area="tasks"
|
||||
title={task.title}
|
||||
badge={
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
{
|
||||
label: "Fällig",
|
||||
value: task.dueDate ? new Date(task.dueDate).toLocaleDateString("de-DE") : "—",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "active" ? (
|
||||
<CheckboxField
|
||||
className="w-auto"
|
||||
label="Nur überfällige"
|
||||
checked={onlyOverdue}
|
||||
onCheckedChange={setOnlyOverdue}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{{ active: listOf("active"), history: listOf("history") }}
|
||||
</ActiveHistoryTabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/lib/voice/use-voice-recording-switch";
|
||||
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { ErrorState, LoadingState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -175,19 +176,19 @@ function VoiceNotes() {
|
||||
/>
|
||||
) : null}
|
||||
{isEmpty ? null : (
|
||||
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||
<PresenceList className="flex flex-col gap-3" aria-label="Sprachnotizen">
|
||||
{local.map((note) => (
|
||||
<li key={note.id}>
|
||||
<PresenceItem key={note.id}>
|
||||
<LocalVoiceCard
|
||||
note={note}
|
||||
colonyName={colonyName(note.colonyId)}
|
||||
online={online}
|
||||
onDiscard={() => discardLocal.mutate(note.id)}
|
||||
/>
|
||||
</li>
|
||||
</PresenceItem>
|
||||
))}
|
||||
{listed.map((item) => (
|
||||
<li key={item.id}>
|
||||
<PresenceItem key={item.id}>
|
||||
<VoiceCaptureCard
|
||||
item={item}
|
||||
extractionEnabled={extractionEnabled}
|
||||
@@ -195,9 +196,9 @@ function VoiceNotes() {
|
||||
onRetry={() => retry.mutate(item.id)}
|
||||
onDiscard={() => discard.mutate(item.id)}
|
||||
/>
|
||||
</li>
|
||||
</PresenceItem>
|
||||
))}
|
||||
</ul>
|
||||
</PresenceList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/lib/winter-checks/types";
|
||||
import { winterLabel } from "@/lib/winter-checks/winter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -178,14 +178,12 @@ export function WinterCheckDialog({
|
||||
</p>
|
||||
</div>
|
||||
{target.isLiving ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="winter-mark-deceased"
|
||||
checked={markDeceased}
|
||||
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="winter-mark-deceased"
|
||||
label="Volk als abgestorben kennzeichnen"
|
||||
checked={markDeceased}
|
||||
onCheckedChange={setMarkDeceased}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--animate-page-in: page-in 260ms ease-out;
|
||||
--animate-page-in: page-in var(--motion-enter) var(--ease-out-expo);
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
@@ -564,8 +564,19 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion tokens (spec 17.3). Mirrored in lib/motion/tokens.ts; tokens.spec.ts keeps both equal.
|
||||
No DOM transition is longer than --motion-moment; exits are never longer than --motion-fade. */
|
||||
:root {
|
||||
--motion-fade: 150ms;
|
||||
--motion-enter: 200ms;
|
||||
--motion-settle: 350ms;
|
||||
--motion-moment: 420ms;
|
||||
--motion-shift: 8px;
|
||||
--motion-spring: 500ms;
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
/* The hive's flight spring (stiffness 210, damping 21) over --motion-spring: springEasing(). */
|
||||
--ease-spring: linear(0, 0.039, 0.113, 0.209, 0.314, 0.421, 0.524, 0.618, 0.702, 0.775, 0.837, 0.887, 0.928, 0.959, 0.983, 1, 1.012, 1.02, 1.024, 1.026, 1.026, 1.024, 1.022, 1.02, 1.017, 1.014, 1.012, 1.009, 1.007, 1.005, 1.004, 1);
|
||||
}
|
||||
|
||||
/* Off: every state change is instant. Colour and icon still carry the state. */
|
||||
@@ -578,17 +589,47 @@ html {
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
|
||||
/* Subtle / system reduce: keep fades, drop movement. */
|
||||
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
|
||||
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
|
||||
:root[data-motion="subtle"] .animate-page-in {
|
||||
animation-name: fade-in;
|
||||
animation-duration: var(--motion-fade);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-safe\:animate-page-in {
|
||||
animation: fade-in 160ms ease-out;
|
||||
.animate-page-in {
|
||||
animation-name: fade-in;
|
||||
animation-duration: var(--motion-fade);
|
||||
}
|
||||
}
|
||||
:root[data-motion="subtle"] [data-slot="card"] {
|
||||
translate: none !important;
|
||||
|
||||
/* Overlays at "Dezent" and with the system's reduce motion: they only fade, and in --motion-fade
|
||||
both ways (spec 17.2); the move behind motion-full: is off anyway. */
|
||||
:root[data-motion="subtle"] [data-slot="sheet-content"],
|
||||
:root[data-motion="subtle"] [data-slot="sheet-overlay"],
|
||||
:root[data-motion="subtle"] [data-slot="dialog-content"],
|
||||
:root[data-motion="subtle"] [data-slot="dialog-overlay"],
|
||||
:root[data-motion="subtle"] [data-slot="dropdown-menu-content"],
|
||||
:root[data-motion="subtle"] [data-slot="dropdown-menu-sub-content"],
|
||||
:root[data-motion="subtle"] [data-slot="select-content"] {
|
||||
transition-duration: var(--motion-fade);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="sheet-content"],
|
||||
[data-slot="sheet-overlay"],
|
||||
[data-slot="dialog-content"],
|
||||
[data-slot="dialog-overlay"],
|
||||
[data-slot="dropdown-menu-content"],
|
||||
[data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"] {
|
||||
transition-duration: var(--motion-fade);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toasts (spec 17.4): Sonner's own slide is full motion. At "Dezent" a toast stands at its place
|
||||
at once and only fades; Sonner itself drops every transition under system reduce. */
|
||||
:root[data-motion="subtle"] [data-sonner-toast],
|
||||
:root[data-motion="subtle"] [data-sonner-toast] > * {
|
||||
transition-property: opacity !important;
|
||||
transition-duration: var(--motion-fade) !important;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@@ -601,7 +642,8 @@ html {
|
||||
}
|
||||
|
||||
/* --- Task completion: the one authored moment ------------------------------
|
||||
The honey cell fills, the tick draws itself, then the row folds away. */
|
||||
The honey cell fills (--motion-moment), the tick draws itself, then the row leaves the list
|
||||
(TodayPanel: AnimatePresence, the others move up with the flight spring). */
|
||||
.honey-check {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
@@ -620,8 +662,8 @@ html {
|
||||
border: 2px solid var(--input);
|
||||
background: var(--card);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background-color 150ms ease-out;
|
||||
border-color var(--motion-fade) ease-out,
|
||||
background-color var(--motion-fade) ease-out;
|
||||
}
|
||||
.honey-check::after {
|
||||
content: "";
|
||||
@@ -630,7 +672,7 @@ html {
|
||||
border-radius: 0.6rem;
|
||||
background: var(--primary);
|
||||
clip-path: circle(0% at 50% 60%);
|
||||
transition: clip-path 420ms var(--ease-out-expo);
|
||||
transition: clip-path var(--motion-moment) var(--ease-out-expo);
|
||||
}
|
||||
.honey-check:hover::before {
|
||||
border-color: var(--primary);
|
||||
@@ -643,7 +685,8 @@ html {
|
||||
height: 1.1rem;
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
|
||||
/* Starts as the cell is half full and ends with it: inside the moment. */
|
||||
transition: stroke-dashoffset calc(var(--motion-moment) - 160ms) var(--ease-out-expo) 160ms;
|
||||
}
|
||||
.honey-check[aria-checked="true"]::after {
|
||||
clip-path: circle(75% at 50% 60%);
|
||||
@@ -652,63 +695,37 @@ html {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.today-row {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
transition:
|
||||
grid-template-rows 320ms var(--ease-out-expo) 380ms,
|
||||
opacity 220ms ease-out 360ms;
|
||||
}
|
||||
.today-row > * {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.today-row[data-leaving="true"] {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The next step changes: bring the new one in with a short focus pull. */
|
||||
/* The next step changes: the new one comes in by --motion-shift (no blur: filter never moves). */
|
||||
@keyframes next-step-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
transform: translateY(8px);
|
||||
transform: translateY(var(--motion-shift));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes next-step-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.next-step-enter {
|
||||
animation: next-step-in 480ms var(--ease-out-expo);
|
||||
animation: next-step-in var(--motion-enter) var(--ease-out-expo);
|
||||
}
|
||||
:root[data-motion="subtle"] .next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
animation: fade-in var(--motion-fade) ease-out;
|
||||
}
|
||||
:root[data-motion="subtle"] .today-row,
|
||||
:root[data-motion="subtle"] .honey-check::after {
|
||||
/* The honey-check moment (cell fills, tick draws itself) belongs to full motion; subtle keeps
|
||||
the tick but drops the slow fill and the stroke delay. */
|
||||
:root[data-motion="subtle"] .honey-check::after,
|
||||
:root[data-motion="subtle"] .honey-check svg {
|
||||
transition-duration: 120ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
animation: fade-in var(--motion-fade) ease-out;
|
||||
}
|
||||
.today-row,
|
||||
.honey-check::after,
|
||||
.honey-check svg {
|
||||
transition-duration: 120ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ActiveHistoryTabs, type EntityLifecycleTab } from "./active-history-tabs";
|
||||
|
||||
function Harness({ onValueChange }: { onValueChange?: (value: EntityLifecycleTab) => void }) {
|
||||
const [value, setValue] = useState<EntityLifecycleTab>("active");
|
||||
return (
|
||||
<ActiveHistoryTabs
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
setValue(next);
|
||||
onValueChange?.(next);
|
||||
}}
|
||||
activeCount={1}
|
||||
historyCount={0}
|
||||
toolbar={<button type="button">Filter</button>}
|
||||
>
|
||||
{{ active: <p>Liste aktiv</p>, history: <p>Liste Historie</p> }}
|
||||
</ActiveHistoryTabs>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActiveHistoryTabs (spec 10.4)", () => {
|
||||
it("ActiveHistoryTabs shows the counts and renders the matching panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
render(<Harness onValueChange={onValueChange} />);
|
||||
|
||||
const active = screen.getByRole("tab", { name: "Aktiv (1)" });
|
||||
const history = screen.getByRole("tab", { name: "Historie (0)" });
|
||||
expect(active).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Aktiv (1)" })).toHaveTextContent("Liste aktiv");
|
||||
expect(screen.queryByText("Liste Historie")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(history);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("history");
|
||||
expect(history).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel", { name: "Historie (0)" })).toHaveTextContent("Liste Historie");
|
||||
expect(screen.queryByText("Liste aktiv")).not.toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("active");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste aktiv");
|
||||
});
|
||||
|
||||
it("leaves the counts out while they are unknown", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs value="active" onValueChange={() => {}}>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "Aktiv" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Historie" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts the toolbar next to the tabs, outside the panels", () => {
|
||||
render(<Harness />);
|
||||
const filter = screen.getByRole("button", { name: "Filter" });
|
||||
expect(within(screen.getByRole("tabpanel")).queryByRole("button", { name: "Filter" })).toBeNull();
|
||||
expect(screen.getByRole("tablist").parentElement).toContainElement(filter);
|
||||
});
|
||||
|
||||
it("arrow keys switch at once here, because both panels filter data that is already loaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
await user.click(screen.getByRole("tab", { name: "Aktiv (1)" }));
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "Historie (0)" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("tabpanel")).toHaveTextContent("Liste Historie");
|
||||
});
|
||||
|
||||
it("toolbarAlign end pushes the toolbar to the far right of the row", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs
|
||||
value="active"
|
||||
onValueChange={() => {}}
|
||||
toolbar={<button type="button">Neue Rolle</button>}
|
||||
toolbarAlign="end"
|
||||
>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
const wrapper = screen.getByRole("button", { name: "Neue Rolle" }).parentElement as HTMLElement;
|
||||
expect(wrapper.className.split(/\s+/)).toContain("ml-auto");
|
||||
expect(screen.getByRole("tablist").parentElement).toContainElement(wrapper);
|
||||
});
|
||||
|
||||
it("a heading opens the row before the tabs, the tabs sit on the right", () => {
|
||||
render(
|
||||
<ActiveHistoryTabs
|
||||
value="active"
|
||||
onValueChange={() => {}}
|
||||
heading={<h2>Lagerbehälter</h2>}
|
||||
>
|
||||
{{ active: null, history: null }}
|
||||
</ActiveHistoryTabs>,
|
||||
);
|
||||
const heading = screen.getByRole("heading", { name: "Lagerbehälter" });
|
||||
const list = screen.getByRole("tablist");
|
||||
expect(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(list.parentElement).toContainElement(heading);
|
||||
expect(list.className.split(/\s+/)).toContain("ml-auto");
|
||||
});
|
||||
|
||||
it("the chips are 44 px high on touch and compact only with a fine pointer", () => {
|
||||
render(<Harness />);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
const classes = tab.className.split(/\s+/);
|
||||
expect(classes).toContain("h-11");
|
||||
expect(classes).toContain("pointer-fine:h-9");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,53 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export type EntityLifecycleTab = "active" | "history";
|
||||
|
||||
interface ActiveHistoryTabsProps {
|
||||
export interface ActiveHistoryTabsProps {
|
||||
value: EntityLifecycleTab;
|
||||
onChange: (value: EntityLifecycleTab) => void;
|
||||
onValueChange: (value: EntityLifecycleTab) => void;
|
||||
activeCount?: number;
|
||||
historyCount?: number;
|
||||
/** A section heading that opens the row; the tabs then sit on its right. */
|
||||
heading?: ReactNode;
|
||||
/** Controls next to the tabs (a filter, a create button), outside the panels. */
|
||||
toolbar?: ReactNode;
|
||||
/** `end` pushes the toolbar to the far right of the row (a create button). */
|
||||
toolbarAlign?: "start" | "end";
|
||||
/** The list of each panel; only the selected one is mounted. */
|
||||
children: { active: ReactNode; history: ReactNode };
|
||||
}
|
||||
|
||||
const withCount = (label: string, count: number | undefined) =>
|
||||
count === undefined ? label : `${label} (${count})`;
|
||||
|
||||
const isLifecycleTab = (value: unknown): value is EntityLifecycleTab =>
|
||||
value === "active" || value === "history";
|
||||
|
||||
/**
|
||||
* Template on Tabs (spec 10.4): „Aktiv“ and „Historie“, each panel holds its list. Both panels
|
||||
* filter data the page has already loaded, so the arrow keys switch at once (activateOnFocus).
|
||||
*/
|
||||
export function ActiveHistoryTabs({
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
activeCount,
|
||||
historyCount,
|
||||
heading,
|
||||
toolbar,
|
||||
toolbarAlign = "start",
|
||||
children,
|
||||
}: ActiveHistoryTabsProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className="inline-flex w-fit rounded-xl border border-border/60 bg-muted/40 p-1"
|
||||
<Tabs
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
if (isLifecycleTab(next)) onValueChange(next);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === "active"}
|
||||
onClick={() => onChange("active")}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
value === "active"
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{heading}
|
||||
<TabsList
|
||||
aria-label="Aktiv oder Historie"
|
||||
activateOnFocus
|
||||
className={cn(heading !== undefined && "ml-auto")}
|
||||
>
|
||||
<TabsTrigger value="active">{withCount("Aktiv", activeCount)}</TabsTrigger>
|
||||
<TabsTrigger value="history">{withCount("Historie", historyCount)}</TabsTrigger>
|
||||
</TabsList>
|
||||
{toolbarAlign === "end" && toolbar !== undefined ? (
|
||||
<div className="ml-auto">{toolbar}</div>
|
||||
) : (
|
||||
toolbar
|
||||
)}
|
||||
>
|
||||
Aktiv{activeCount !== undefined ? ` (${activeCount})` : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === "history"}
|
||||
onClick={() => onChange("history")}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
value === "history"
|
||||
? "bg-card text-foreground shadow-[var(--shadow-soft)]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Historie{historyCount !== undefined ? ` (${historyCount})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value="active">{children.active}</TabsContent>
|
||||
<TabsContent value="history">{children.history}</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const load = vi.fn(() => Promise.resolve({}));
|
||||
vi.mock("@/components/motion/presence-list", () => ({ loadMotionRuntime: () => load() }));
|
||||
|
||||
const { AppMotion } = await import("./app-motion");
|
||||
|
||||
describe("AppMotion (spec 17.2, 17.6)", () => {
|
||||
afterEach(() => load.mockClear());
|
||||
|
||||
it("renders its children right away, without waiting for motion", () => {
|
||||
render(
|
||||
<AppMotion>
|
||||
<p>Inhalt</p>
|
||||
</AppMotion>,
|
||||
);
|
||||
expect(screen.getByText("Inhalt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preloads the one motion chunk once the shell has mounted", async () => {
|
||||
render(
|
||||
<AppMotion>
|
||||
<p>Inhalt</p>
|
||||
</AppMotion>,
|
||||
);
|
||||
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("keeps the app running when the chunk cannot be loaded (offline, first visit)", async () => {
|
||||
load.mockReturnValueOnce(Promise.reject(new Error("offline")));
|
||||
render(
|
||||
<AppMotion>
|
||||
<p>Inhalt</p>
|
||||
</AppMotion>,
|
||||
);
|
||||
await waitFor(() => expect(load).toHaveBeenCalled());
|
||||
expect(screen.getByText("Inhalt")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { loadMotionRuntime } from "@/components/motion/presence-list";
|
||||
|
||||
/**
|
||||
* The motion frame of the (app) layout (spec 17.2, 17.6). It carries no `motion` code itself: once
|
||||
* the shell has mounted it preloads the one async motion chunk (motion-runtime.tsx: LazyMotion,
|
||||
* MotionConfig per level, the animated list), so lists are animated by the time they need it and
|
||||
* login, Herkunft and every first load stay free of motion.
|
||||
*/
|
||||
export function AppMotion({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
// Without the chunk (offline, first visit) lists simply stay plain.
|
||||
loadMotionRuntime().catch(() => undefined);
|
||||
}, []);
|
||||
return children;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { InspectionFormValues } from "@/app/(app)/inspections/new/inspectio
|
||||
import { FEED_CONDITION_LABELS, ratingToCondition } from "@/lib/colonies/condition";
|
||||
import { COLONY_CONDITION_LABELS } from "@/lib/colonies/labels";
|
||||
import type { Colony, ColonyCondition } from "@/lib/colonies/types";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { COLONY_TAKEOVER_FIELDS, type ColonyTakeoverField } from "@/lib/inspections/types";
|
||||
|
||||
export interface ColonyTakeoverSectionProps {
|
||||
@@ -124,15 +125,12 @@ export function ColonyTakeoverSection({
|
||||
Diese Angaben auch auf der Volkskarte eintragen:
|
||||
</p>
|
||||
{rows.map((row) => (
|
||||
<label key={row.field} className="flex min-h-11 items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-5 shrink-0 accent-primary"
|
||||
checked={selected.includes(row.field)}
|
||||
onChange={(event) => toggle(row.field, event.target.checked)}
|
||||
/>
|
||||
{row.label}
|
||||
</label>
|
||||
<CheckboxField
|
||||
key={row.field}
|
||||
label={row.label}
|
||||
checked={selected.includes(row.field)}
|
||||
onCheckedChange={(checked) => toggle(row.field, checked)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
|
||||
Ausnahme mit Begründung
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
|
||||
className={cn("size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CheckboxField, type CheckboxFieldProps } from "./checkbox-field";
|
||||
|
||||
const classesOf = (element: Element) => (element.getAttribute("class") ?? "").split(/\s+/);
|
||||
|
||||
type HarnessProps = Omit<CheckboxFieldProps, "checked" | "onCheckedChange"> & {
|
||||
initial?: boolean;
|
||||
onChange?: (next: boolean) => void;
|
||||
};
|
||||
|
||||
function Harness({ initial = false, onChange, ...props }: HarnessProps) {
|
||||
const [checked, setChecked] = useState(initial);
|
||||
return (
|
||||
<CheckboxField
|
||||
{...props}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
setChecked(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("CheckboxField (spec 10.3)", () => {
|
||||
it("the whole row toggles and the name comes from the label", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
label="Schnittmarken am Blattrand"
|
||||
description="Kleine Striche im Rand an jeder Schnittkante."
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("checkbox", { name: "Schnittmarken am Blattrand" });
|
||||
expect(row).toHaveAccessibleDescription("Kleine Striche im Rand an jeder Schnittkante.");
|
||||
|
||||
// A tap on the description (not on the box) still toggles: the row is the control.
|
||||
await userEvent.click(screen.getByText("Kleine Striche im Rand an jeder Schnittkante."));
|
||||
expect(row).toBeChecked();
|
||||
await userEvent.click(screen.getByText("Schnittmarken am Blattrand"));
|
||||
expect(row).not.toBeChecked();
|
||||
expect(onChange.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it("the row is at least 44 px high (min-h-11) and in a sheet 56 px (min-h-14)", () => {
|
||||
render(
|
||||
<>
|
||||
<Harness label="Zeile" />
|
||||
<Harness label="Im Sheet" size="sheet" />
|
||||
</>,
|
||||
);
|
||||
const row = screen.getByRole("checkbox", { name: "Zeile" });
|
||||
expect(classesOf(row)).toContain("min-h-11");
|
||||
expect(classesOf(row)).toContain("text-base");
|
||||
expect(classesOf(screen.getByRole("checkbox", { name: "Im Sheet" }))).toContain("min-h-14");
|
||||
});
|
||||
|
||||
it("tile and task have role checkbox from the primitive", () => {
|
||||
render(
|
||||
<>
|
||||
<Harness label="V-01" appearance="tile" />
|
||||
<Harness label="„Stock prüfen“ als erledigt markieren" appearance="task" />
|
||||
</>,
|
||||
);
|
||||
for (const name of ["V-01", "„Stock prüfen“ als erledigt markieren"]) {
|
||||
const control = screen.getByRole("checkbox", { name });
|
||||
expect(control.tagName).toBe("BUTTON");
|
||||
expect(control).toHaveAttribute("data-slot", "checkbox");
|
||||
}
|
||||
expect(screen.getByRole("checkbox", { name: "V-01" }).textContent).toBe("V-01");
|
||||
});
|
||||
|
||||
it("checked shows the tick icon and a 2 px foreground border", async () => {
|
||||
render(<Harness label="Aktiv" />);
|
||||
const row = screen.getByRole("checkbox", { name: "Aktiv" });
|
||||
expect(row.querySelector("svg.lucide-check")).toBeNull();
|
||||
|
||||
await userEvent.click(row);
|
||||
const mark = row.querySelector("[data-slot=checkbox-mark]") as HTMLElement;
|
||||
expect(classesOf(mark)).toEqual(expect.arrayContaining(["border-2", "border-foreground", "bg-primary"]));
|
||||
expect(mark.querySelector("svg.lucide-check")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("a checked tile has the honey tint, a 2 px ink border and the tick", () => {
|
||||
render(
|
||||
<>
|
||||
<CheckboxField label="Ja" appearance="tile" checked onCheckedChange={() => {}} />
|
||||
<CheckboxField label="Nein" appearance="tile" checked={false} onCheckedChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
const on = screen.getByRole("checkbox", { name: "Ja" });
|
||||
expect(classesOf(on)).toEqual(expect.arrayContaining(["tint", "border-2", "border-foreground"]));
|
||||
expect(on.querySelector("svg.lucide-check")).not.toBeNull();
|
||||
const off = screen.getByRole("checkbox", { name: "Nein" });
|
||||
expect(classesOf(off)).toEqual(expect.arrayContaining(["border", "border-input", "bg-card"]));
|
||||
expect(classesOf(off)).not.toContain("tint");
|
||||
});
|
||||
|
||||
it("indeterminate shows the dash", () => {
|
||||
render(<CheckboxField label="Bienenstände" checked="indeterminate" onCheckedChange={() => {}} />);
|
||||
const row = screen.getByRole("checkbox", { name: "Bienenstände" });
|
||||
expect(row).toHaveAttribute("aria-checked", "mixed");
|
||||
expect(row.querySelector("svg.lucide-minus")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clicking an indeterminate box checks it", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<CheckboxField label="Gruppe" checked="indeterminate" onCheckedChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Gruppe" }));
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("disabled shows its hint text", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CheckboxField
|
||||
label="Mit Details"
|
||||
checked={false}
|
||||
disabled
|
||||
disabledHint="Nur mit Verbindung"
|
||||
onCheckedChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const row = screen.getByRole("checkbox", { name: "Mit Details" });
|
||||
expect(row).toBeDisabled();
|
||||
expect(screen.getByText("Nur mit Verbindung")).toBeVisible();
|
||||
expect(row).toHaveAccessibleDescription("Nur mit Verbindung");
|
||||
await userEvent.click(row);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a row shows hover only for a fine pointer and only while enabled", () => {
|
||||
render(<CheckboxField label="Aktiv" checked={false} onCheckedChange={() => {}} />);
|
||||
const row = screen.getByRole("checkbox", { name: "Aktiv" });
|
||||
expect(classesOf(row)).toContain("pointer-fine:enabled:hover:bg-muted");
|
||||
expect(classesOf(row)).not.toContain("hover:bg-muted");
|
||||
});
|
||||
|
||||
it("a disabled tile dims as a whole and keeps its hint outside at full strength", () => {
|
||||
render(
|
||||
<CheckboxField
|
||||
appearance="tile"
|
||||
label="Ableger"
|
||||
checked
|
||||
disabled
|
||||
disabledHint="Nur ansehen"
|
||||
onCheckedChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
const tile = screen.getByRole("checkbox", { name: "Ableger" });
|
||||
expect(classesOf(tile)).toContain("opacity-50");
|
||||
expect(tile.querySelector(".opacity-50")).toBeNull();
|
||||
const hint = screen.getByText("Nur ansehen");
|
||||
expect(tile.contains(hint)).toBe(false);
|
||||
expect(hint.closest(".opacity-50")).toBeNull();
|
||||
expect(tile).toHaveAccessibleDescription("Nur ansehen");
|
||||
});
|
||||
|
||||
it("an enabled tile hovers, a disabled one does not", () => {
|
||||
render(<CheckboxField appearance="tile" label="V-01" checked={false} onCheckedChange={() => {}} />);
|
||||
expect(classesOf(screen.getByRole("checkbox", { name: "V-01" }))).toContain("enabled:hover:bg-muted");
|
||||
});
|
||||
|
||||
it("the hint only shows while disabled", () => {
|
||||
render(<CheckboxField label="Aktiv" checked={false} disabledHint="Nur mit Verbindung" onCheckedChange={() => {}} />);
|
||||
expect(screen.queryByText("Nur mit Verbindung")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("joins its description with outside hints in describedBy", () => {
|
||||
render(
|
||||
<>
|
||||
<p id="outside-hint">Annahme</p>
|
||||
<CheckboxField
|
||||
label="A9 Wintersitz"
|
||||
description="Ab Oktober."
|
||||
describedBy="outside-hint"
|
||||
checked={false}
|
||||
onCheckedChange={() => {}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("checkbox", { name: "A9 Wintersitz" })).toHaveAccessibleDescription("Ab Oktober. Annahme");
|
||||
});
|
||||
|
||||
it('role="switch" is a Base UI switch with the tick on the thumb', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Harness
|
||||
role="switch"
|
||||
label="Erinnerung je Aufgabe"
|
||||
description="Eine Meldung, wenn eine Aufgabe fällig wird."
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const control = screen.getByRole("switch", { name: "Erinnerung je Aufgabe" });
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(control).toHaveAccessibleDescription("Eine Meldung, wenn eine Aufgabe fällig wird.");
|
||||
expect(classesOf(control)).toContain("min-h-11");
|
||||
|
||||
await userEvent.click(screen.getByText("Eine Meldung, wenn eine Aufgabe fällig wird."));
|
||||
expect(control).toBeChecked();
|
||||
expect(control.querySelector("svg.lucide-check")).not.toBeNull();
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("Tab reaches it and Space toggles it", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness label="Nur überfällige" onChange={onChange} />);
|
||||
await userEvent.tab();
|
||||
expect(screen.getByRole("checkbox", { name: "Nur überfällige" })).toHaveFocus();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("task is the honey-check cell: label as its name, the drawn tick, no visible text", () => {
|
||||
render(
|
||||
<CheckboxField
|
||||
appearance="task"
|
||||
label="„Stock prüfen“ als erledigt markieren"
|
||||
checked
|
||||
onCheckedChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
const cell = screen.getByRole("checkbox", { name: "„Stock prüfen“ als erledigt markieren" });
|
||||
expect(classesOf(cell)).toContain("honey-check");
|
||||
expect(cell).toHaveAttribute("aria-checked", "true");
|
||||
expect(cell.textContent).toBe("");
|
||||
expect(cell.querySelector("svg path")).toHaveAttribute("d", "M5 12.5l4.5 4.5L19 7.5");
|
||||
});
|
||||
});
|
||||
|
||||
const css = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
describe("honey-check moment (spec 10.3, 17)", () => {
|
||||
it("keys on the state of the Base UI primitive (aria-checked)", () => {
|
||||
expect(css).toMatch(/\.honey-check\[aria-checked="true"\]::after\s*\{/);
|
||||
expect(css).toMatch(/\.honey-check\[aria-checked="true"\] svg\s*\{/);
|
||||
});
|
||||
|
||||
it("today panel ticking a task keeps the honey-check moment only with full motion", () => {
|
||||
// Subtle and the system setting keep the tick, but drop the fill and the stroke delay.
|
||||
const subtle = /:root\[data-motion="subtle"\] \.honey-check::after,\s*:root\[data-motion="subtle"\] \.honey-check svg\s*\{([^}]*)\}/.exec(
|
||||
css,
|
||||
);
|
||||
expect(subtle?.[1]).toMatch(/transition-duration:\s*120ms/);
|
||||
expect(subtle?.[1]).toMatch(/transition-delay:\s*0ms/);
|
||||
expect(css).toMatch(
|
||||
/@media \(prefers-reduced-motion: reduce\)\s*\{[^@]*\.honey-check::after,\s*\.honey-check svg\s*\{[^}]*transition-duration:\s*120ms/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const FRONTEND_SRC = join(__dirname, "..", "..");
|
||||
|
||||
/** Every former checkbox place (31 + the 4 found on recount, see task A7 report). */
|
||||
const CHECKBOX_FIELD_FILES = [
|
||||
// ToggleChip
|
||||
"app/(app)/colonies/[id]/swarm-control-edit-dialog.tsx",
|
||||
"app/(app)/compliance/notices/create-notice-dialog.tsx",
|
||||
"app/(app)/compliance/restrictions/create-restriction-dialog.tsx",
|
||||
"app/(app)/compliance/settings/page.tsx",
|
||||
"app/(app)/extractions/new/page.tsx",
|
||||
"app/(app)/harvests/new/page.tsx",
|
||||
"app/(app)/harvests/[id]/harvest-edit-dialog.tsx",
|
||||
"app/(app)/medicines/medicine-form-dialog.tsx",
|
||||
"app/(app)/profile/telegram-link-card.tsx",
|
||||
"app/(app)/settings/automation/automation-rules-tab.tsx",
|
||||
"app/(app)/settings/automation/rule-forms/rule-form-parts.tsx",
|
||||
"app/(app)/swarm-control/new/page.tsx",
|
||||
// native type="checkbox"
|
||||
"app/(app)/settings/automation/create-webhook-dialog.tsx",
|
||||
"app/(app)/settings/automation/edit-task-template-dialog.tsx",
|
||||
"app/(app)/settings/automation/edit-webhook-dialog.tsx",
|
||||
"app/(app)/settings/hive-systems/system-dialog.tsx",
|
||||
"app/(app)/settings/label-templates/designer/label-designer.tsx",
|
||||
"app/(app)/settings/voice/page.tsx",
|
||||
"app/(app)/tasks/page.tsx",
|
||||
"components/colonies/colony-takeover-section.tsx",
|
||||
"components/harvest-select-field.tsx",
|
||||
// SwitchRow users
|
||||
"app/(app)/profile/notification-settings-card.tsx",
|
||||
// Base UI checkbox users
|
||||
"components/form/colony-multi-select.tsx",
|
||||
"app/(app)/settings/automation/calendar-sync-tab.tsx",
|
||||
"app/(app)/settings/users-roles/permission-matrix.tsx",
|
||||
"app/(app)/winter/winter-check-dialog.tsx",
|
||||
// honey-check
|
||||
"components/today/today-panel.tsx",
|
||||
];
|
||||
|
||||
describe("every checkbox place uses CheckboxField", () => {
|
||||
it.each(CHECKBOX_FIELD_FILES)("%s", (file) => {
|
||||
const text = readFileSync(join(FRONTEND_SRC, file), "utf8");
|
||||
expect(text).toMatch(/import \{ CheckboxField \} from "(?:@\/components\/form|\.)\/checkbox-field"/);
|
||||
expect(text).not.toMatch(/ToggleChip|SwitchRow|components\/ui\/checkbox"/);
|
||||
});
|
||||
|
||||
it("the harvest dialog, webhook dialogs and task template dialog use CheckboxField", () => {
|
||||
for (const file of [
|
||||
"app/(app)/harvests/[id]/harvest-edit-dialog.tsx",
|
||||
"app/(app)/settings/automation/create-webhook-dialog.tsx",
|
||||
"app/(app)/settings/automation/edit-webhook-dialog.tsx",
|
||||
"app/(app)/settings/automation/edit-task-template-dialog.tsx",
|
||||
]) {
|
||||
expect(readFileSync(join(FRONTEND_SRC, file), "utf8"), file).toMatch(/<CheckboxField\b/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { Checkbox, CheckboxMark, type CheckState } from "@/components/ui/checkbox";
|
||||
import { Switch, SwitchMark } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "row" (default): the whole row is the control, label 16 px. "tile": a framed tile for yes/no
|
||||
* observations in a grid. "task": the tick cell of the today list with the honey-check moment.
|
||||
*/
|
||||
export type CheckboxAppearance = "row" | "tile" | "task";
|
||||
|
||||
export interface CheckboxFieldProps {
|
||||
/** Visible label and accessible name; for "task" only the name (the row shows the title). */
|
||||
label: string;
|
||||
/** Second line under the label (14 px), read as the description. */
|
||||
description?: string;
|
||||
checked: CheckState;
|
||||
onCheckedChange(next: boolean): void;
|
||||
disabled?: boolean;
|
||||
/** Says why the field is off; shown (and read) only while disabled. */
|
||||
disabledHint?: string;
|
||||
appearance?: CheckboxAppearance;
|
||||
/** "sheet": rows of a capture sheet are 56 px instead of 44 px. */
|
||||
size?: "default" | "sheet";
|
||||
/** "switch": a yes/no setting that takes effect at once (Base UI Switch, row only). */
|
||||
role?: "checkbox" | "switch";
|
||||
id?: string;
|
||||
/** Ids of hints or errors outside the field. */
|
||||
describedBy?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ROW_SIZE = { default: "min-h-11 py-2.5", sheet: "min-h-14 py-4" } as const;
|
||||
|
||||
/** Frameless row: the hover wash is only for a mouse, never while disabled. */
|
||||
const ROW_CLASS = "w-full px-2 pointer-fine:enabled:hover:bg-muted";
|
||||
|
||||
function tileClass(isOn: boolean): string {
|
||||
// Honey tint with a 2 px ink border; in sun `.tint` turns into the card with the ink border.
|
||||
return isOn
|
||||
? "items-center tint border-2 border-foreground px-[11px] font-semibold [--tint-ink:var(--foreground)]"
|
||||
: "items-center border border-input bg-card px-3 enabled:hover:bg-muted";
|
||||
}
|
||||
|
||||
const HINT_CLASS = "text-sm font-medium text-muted-foreground";
|
||||
|
||||
function joinIds(...ids: (string | false | undefined)[]): string | undefined {
|
||||
return ids.filter(Boolean).join(" ") || undefined;
|
||||
}
|
||||
|
||||
/** The tick cell of the today list: a 44 px honey cell that fills and draws its tick. */
|
||||
function TaskCell({ label, checked, onCheckedChange, disabled, id, describedBy, className }: CheckboxFieldProps) {
|
||||
return (
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-describedby={describedBy}
|
||||
className={cn("honey-check", className)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A yes/no field of the kit (spec 10.3). The control is the Base UI primitive itself, so a tap
|
||||
* anywhere on the row or tile toggles it, Tab stops once and Space toggles. Labels and
|
||||
* descriptions are tied to it by id.
|
||||
*/
|
||||
export function CheckboxField(props: CheckboxFieldProps) {
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
disabledHint,
|
||||
appearance,
|
||||
size,
|
||||
role,
|
||||
id,
|
||||
describedBy,
|
||||
className,
|
||||
} = props;
|
||||
const baseId = useId();
|
||||
if (appearance === "task") return <TaskCell {...props} />;
|
||||
|
||||
const isSwitch = role === "switch";
|
||||
const isTile = appearance === "tile";
|
||||
const isOn = checked !== false;
|
||||
const showHint = Boolean(disabled && disabledHint);
|
||||
const labelId = `${baseId}-label`;
|
||||
const descriptionId = `${baseId}-description`;
|
||||
const hintId = `${baseId}-hint`;
|
||||
const controlProps = {
|
||||
id,
|
||||
disabled,
|
||||
"aria-labelledby": labelId,
|
||||
"aria-describedby": joinIds(description && descriptionId, showHint && hintId, describedBy),
|
||||
className: cn(
|
||||
"flex items-start gap-3 rounded-lg text-left text-base",
|
||||
ROW_SIZE[size ?? "default"],
|
||||
isTile ? tileClass(isOn) : ROW_CLASS,
|
||||
// A disabled tile dims as a whole (frame and surface); a row dims label and mark only,
|
||||
// so its hint stays readable.
|
||||
isTile && disabled && "opacity-50",
|
||||
className,
|
||||
),
|
||||
};
|
||||
const dimmed = disabled && !isTile && "opacity-50";
|
||||
const hint = showHint ? (
|
||||
<span id={hintId} className={HINT_CLASS}>
|
||||
{disabledHint}
|
||||
</span>
|
||||
) : null;
|
||||
const text = (
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span id={labelId} className={cn(dimmed)}>
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={descriptionId} className={cn("text-sm font-normal text-muted-foreground", dimmed)}>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
{isTile ? null : hint}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isSwitch) {
|
||||
return (
|
||||
<Switch {...controlProps} checked={checked === true} onCheckedChange={onCheckedChange}>
|
||||
{text}
|
||||
<SwitchMark checked={checked === true} className={cn(dimmed)} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
const control = (
|
||||
<Checkbox {...controlProps} checked={checked} onCheckedChange={onCheckedChange}>
|
||||
<CheckboxMark checked={checked} className={cn(!isTile && "mt-0.5", dimmed)} />
|
||||
{text}
|
||||
</Checkbox>
|
||||
);
|
||||
if (!isTile || !hint) return control;
|
||||
// The hint of a dimmed tile sits under it at full strength.
|
||||
return (
|
||||
<span className="inline-flex flex-col gap-1">
|
||||
{control}
|
||||
{hint}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -83,9 +83,9 @@ function targetIndex(key: string, current: number, count: number): number | null
|
||||
|
||||
const OPTION_CLASS: Record<ChoiceAppearance, string> = {
|
||||
buttons:
|
||||
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg px-2 text-base motion-safe:transition-colors",
|
||||
"inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1 text-base transition-colors",
|
||||
cards:
|
||||
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base motion-safe:transition-colors",
|
||||
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base transition-colors",
|
||||
};
|
||||
|
||||
const MARK_ICON = { tick: Check, dash: Minus } as const;
|
||||
@@ -141,7 +141,9 @@ function OptionContent<T extends ChoiceValue>({
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>{label}</span>
|
||||
// A long label („Nicht geprüft“ in three columns on a phone) wraps
|
||||
// instead of running into the frame.
|
||||
<span className="min-w-0 text-balance text-center leading-tight">{label}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -125,9 +125,10 @@ describe("ColonyMultiSelect", () => {
|
||||
|
||||
const box = await screen.findByRole("checkbox", { name: "V-01" });
|
||||
|
||||
expect(box.closest("label")).toHaveClass("min-h-14");
|
||||
expect(screen.getAllByRole("checkbox").map((item) => item.closest("label"))).toSatisfy(
|
||||
(rows: (HTMLElement | null)[]) => rows.every((row) => row?.classList.contains("min-h-14")),
|
||||
// The whole row is the checkbox (CheckboxField size="sheet").
|
||||
expect(box).toHaveClass("min-h-14");
|
||||
expect(screen.getAllByRole("checkbox")).toSatisfy((rows: HTMLElement[]) =>
|
||||
rows.every((row) => row.classList.contains("min-h-14")),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
@@ -145,21 +145,14 @@ export function ColonyMultiSelect({
|
||||
<ul className="flex flex-col gap-2">
|
||||
{list.map((colony) => (
|
||||
<li key={colony.id}>
|
||||
<label
|
||||
htmlFor={`${id}-${colony.id}`}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 rounded-lg border border-border px-3 focus-ring-within"
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${colony.id}`}
|
||||
className="size-6"
|
||||
aria-labelledby={`${id}-${colony.id}-label`}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
<span id={`${id}-${colony.id}-label`} className="text-base">
|
||||
{colonyOptionLabel(colony)}
|
||||
</span>
|
||||
</label>
|
||||
<CheckboxField
|
||||
id={`${id}-${colony.id}`}
|
||||
size="sheet"
|
||||
className="border border-border px-3"
|
||||
label={colonyOptionLabel(colony)}
|
||||
checked={selectedIds.includes(colony.id)}
|
||||
onCheckedChange={(isChecked) => toggle(colony.id, isChecked)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function FormSection({
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 motion-safe:transition-transform",
|
||||
"size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface NumberStepperProps {
|
||||
}
|
||||
|
||||
const STEP_BUTTON_CLASS =
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold motion-safe:transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
|
||||
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
const MAX_DIGITS = 4;
|
||||
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
interface SwitchRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** A yes/no setting as a tall row (>= 44 px) with its explanation under the label. */
|
||||
export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 focus-ring-within">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span id={`${id}-label`} className="font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={`${id}-description`} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToggleChipProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Ids of a hint or error shown next to the tile. */
|
||||
describedBy?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** A yes/no observation as a large tile; state shown by tick AND colour. */
|
||||
export function ToggleChip({ label, checked, onChange, describedBy, disabled }: ToggleChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-describedby={describedBy}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
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-foreground bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded border",
|
||||
checked
|
||||
? "border-foreground bg-primary text-primary-foreground"
|
||||
: "border-border",
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="size-3.5" /> : null}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export function TriStateField({ label, value, onChange, previous }: TriStateFiel
|
||||
options={TRI_STATE_OPTIONS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="grid-cols-3"
|
||||
// Three columns only where „Nicht geprüft“ fits (≈ 6.5rem with its
|
||||
// mark); in a narrow form column the third option wraps below.
|
||||
className="grid-cols-[repeat(auto-fit,minmax(6.5rem,1fr))]"
|
||||
/>
|
||||
{previous ? (
|
||||
<PreviousValueHint
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchHarvests } from "@/lib/harvests/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CheckboxField } from "@/components/form/checkbox-field";
|
||||
|
||||
interface HarvestSelectFieldProps {
|
||||
idPrefix: string;
|
||||
@@ -32,34 +32,32 @@ export function HarvestSelectField({
|
||||
);
|
||||
}
|
||||
|
||||
const labelId = `${idPrefix}-label`;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Ernten</Label>
|
||||
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-2">
|
||||
<span id={labelId} className="text-sm font-medium">
|
||||
Ernten
|
||||
</span>
|
||||
{(harvests ?? []).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Ernten erfasst.</p>
|
||||
) : null}
|
||||
<div className="flex max-h-48 flex-col gap-2 overflow-y-auto">
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
|
||||
{(harvests ?? []).map((harvest) => (
|
||||
<label
|
||||
<CheckboxField
|
||||
key={harvest.id}
|
||||
className="flex min-h-11 items-center gap-3 text-sm"
|
||||
htmlFor={`${idPrefix}-${harvest.id}`}
|
||||
>
|
||||
<input
|
||||
id={`${idPrefix}-${harvest.id}`}
|
||||
type="checkbox"
|
||||
className="size-5 shrink-0"
|
||||
checked={value.includes(harvest.id)}
|
||||
onChange={(event) => toggle(harvest.id, event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{new Date(harvest.date).toLocaleDateString("de-DE")}
|
||||
{harvest.netWeight !== null ? ` · ${harvest.netWeight} kg` : ""}
|
||||
{harvest.colonies.length > 0
|
||||
? ` · ${harvest.colonies.map((entry) => entry.colony.code).join(", ")}`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
id={`${idPrefix}-${harvest.id}`}
|
||||
label={[
|
||||
new Date(harvest.date).toLocaleDateString("de-DE"),
|
||||
harvest.netWeight !== null ? `${harvest.netWeight} kg` : null,
|
||||
harvest.colonies.length > 0
|
||||
? harvest.colonies.map((entry) => entry.colony.code).join(", ")
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
checked={value.includes(harvest.id)}
|
||||
onCheckedChange={(checked) => toggle(harvest.id, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ZoomControls } from "./editor-stage-overlay";
|
||||
import { DragChip, ZoomControls } from "./editor-stage-overlay";
|
||||
import type { HiveDragView } from "./use-hive-drag";
|
||||
|
||||
describe("ZoomControls", () => {
|
||||
it("zooms in and out in steps", async () => {
|
||||
@@ -26,3 +27,28 @@ describe("ZoomControls", () => {
|
||||
expect(screen.getByRole("button", { name: "Verkleinern" })).toHaveAttribute("aria-disabled", "false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DragChip", () => {
|
||||
const drag: HiveDragView = {
|
||||
source: { partTypeId: "box" },
|
||||
label: "Zarge 3",
|
||||
slots: [],
|
||||
hover: { kind: "stack", index: 0 } as unknown as HiveDragView["hover"],
|
||||
overRemove: false,
|
||||
};
|
||||
|
||||
it("in sun sits on the same solid plate as the hover label: 16 px, white text, no blur (A12 rule)", () => {
|
||||
render(<DragChip drag={drag} pointer={{ current: null }} />);
|
||||
const where = screen.getByText("Loslassen setzt ein");
|
||||
const chip = where.parentElement as HTMLElement;
|
||||
const classes = chip.className.split(/\s+/);
|
||||
for (const sunClass of ["sun:bg-holo-label-bg", "sun:border-holo-edge", "sun:text-base", "sun:backdrop-blur-none", "sun:shadow-none"]) {
|
||||
expect(classes).toContain(sunClass);
|
||||
}
|
||||
// The hint line is full white and 16 px in sun, not the dimmed 13 px of the normal stage.
|
||||
const whereClasses = where.className.split(/\s+/);
|
||||
expect(whereClasses).toContain("sun:text-holo-label-fg");
|
||||
expect(whereClasses).toContain("sun:text-base");
|
||||
expect(whereClasses).not.toContain("text-xs");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { Trash2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
|
||||
import { GLASS_BUTTON, GLASS_SURFACE, HOLO_LABEL_PILL } from "../hive-glass";
|
||||
import { ZOOM_MAX, ZOOM_MIN, stepZoom } from "../hive-scene/camera-fit";
|
||||
import type { DragPointer, HiveDragView } from "./use-hive-drag";
|
||||
|
||||
@@ -91,10 +91,10 @@ export function DragChip({ drag, pointer }: { drag: HiveDragView; pointer: RefOb
|
||||
<div
|
||||
ref={chip}
|
||||
aria-hidden
|
||||
className={`pointer-events-none fixed start-0 top-0 z-50 rounded-xl px-3 py-1.5 text-sm font-medium ${GLASS_SURFACE}`}
|
||||
className={`pointer-events-none fixed start-0 top-0 z-50 ${HOLO_LABEL_PILL}`}
|
||||
>
|
||||
{drag.label}
|
||||
{where ? <span className="block text-xs text-holo-label-fg/80">{where}</span> : null}
|
||||
{where ? <span className="block text-sm text-holo-label-fg/80 sun:text-base sun:text-holo-label-fg">{where}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Beute wird geladen"
|
||||
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
|
||||
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-full:animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// Frosted glass surfaces over the holo stage (mockups g2/h2). Text is
|
||||
// --holo-label-fg on --holo-label-bg at 70 % over the midnight stage, well
|
||||
// above 4.5:1. The focus ring comes from the global rule; .holo-stage on the
|
||||
// stage root draws it in the strong edge colour (spec 17.8).
|
||||
// stage root draws it in the strong edge colour (spec 17.8). In sun the stage
|
||||
// is black: no blur and no glow (T5 spec 17.6), the fill stays.
|
||||
|
||||
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md";
|
||||
const GLASS_FILL = "bg-holo-label-bg/70 text-holo-label-fg backdrop-blur-md sun:backdrop-blur-none";
|
||||
|
||||
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] ${GLASS_FILL}`;
|
||||
export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.6)] sun:shadow-none ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-holo-edge/70 aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] ${GLASS_FILL}`;
|
||||
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] sun:shadow-none ${GLASS_FILL}`;
|
||||
|
||||
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
|
||||
|
||||
/**
|
||||
* A label on the stage: hover labels in the scene and the drag chip in the editor. In sun 16 px on
|
||||
* a solid plate with white text, without blur and glow (Beute spec 9.3, T5 17.6).
|
||||
*/
|
||||
export const HOLO_LABEL_PILL =
|
||||
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
|
||||
@@ -11,12 +11,12 @@ import type { DropSlot } from "../editor/drop-slots";
|
||||
import { pickAnchor } from "./drop-anchors";
|
||||
import type { SlotAnchor } from "./drop-anchors";
|
||||
import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit";
|
||||
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
||||
import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials";
|
||||
import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry";
|
||||
import type { EdgeSegment } from "./part-geometry";
|
||||
import { noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { setPinching, setUniform, setUniformColor } from "./scene-mutations";
|
||||
import { useGlassEnvironment } from "./use-scene-effects";
|
||||
import { useEdgeViewport, useGlassEnvironment } from "./use-scene-effects";
|
||||
|
||||
// Editor drop targets (spec 9.5): every legal slot glows, the slot under the
|
||||
// pointer lights up and a glass preview of the dragged part snaps into it.
|
||||
@@ -38,11 +38,14 @@ function outline(size: Vec3, strength: number): EdgeSegment[] {
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Tube width of the slot markers (a little wider than the part edges). */
|
||||
const SLOT_EDGE_WIDTH = 0.03;
|
||||
|
||||
/** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */
|
||||
function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) {
|
||||
const flat = anchor.slot.kind === "stack";
|
||||
const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size;
|
||||
const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), 0.03), [mx, my, mz]);
|
||||
const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), SLOT_EDGE_WIDTH), [mx, my, mz]);
|
||||
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||
return (
|
||||
<mesh
|
||||
@@ -68,6 +71,7 @@ function Preview({ anchor }: { anchor: SlotAnchor }) {
|
||||
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75 }), [theme]);
|
||||
useGlassEnvironment(glass, true);
|
||||
const glow = useMemo(() => createEdgeMaterial(theme), [theme]);
|
||||
useEdgeViewport([glow]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose();
|
||||
@@ -81,7 +85,7 @@ function Preview({ anchor }: { anchor: SlotAnchor }) {
|
||||
// Brighter than the parts around it, so the snapped place reads at once.
|
||||
setUniform(glass, "uOpacity", 0.9);
|
||||
setUniform(glass, "uGlow", 2.2);
|
||||
setUniform(glow, "uIntensity", 3);
|
||||
setEdgeIntensity(glow, 3);
|
||||
setUniformColor(glow, "uCyan", theme.edgeStrong);
|
||||
}, [glass, glow, theme]);
|
||||
return (
|
||||
@@ -118,10 +122,18 @@ export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover
|
||||
const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null;
|
||||
|
||||
// Two shared materials for every marker: idle and the one under the pointer.
|
||||
const materials = useMemo(() => ({ idle: createEdgeMaterial(theme), active: createEdgeMaterial(theme) }), [theme]);
|
||||
// In sun the idle ones are grey and the active one white and wider (no glow to tell them apart).
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
idle: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH, sunGrey: true }),
|
||||
active: createEdgeMaterial(theme, { tubeWidth: SLOT_EDGE_WIDTH }),
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
useEdgeViewport([materials.idle, materials.active]);
|
||||
useEffect(() => {
|
||||
setUniform(materials.idle, "uIntensity", 1.4);
|
||||
setUniform(materials.active, "uIntensity", 3.2);
|
||||
setEdgeIntensity(materials.idle, 1.4);
|
||||
setEdgeIntensity(materials.active, 3.2);
|
||||
return () => {
|
||||
materials.idle.dispose();
|
||||
materials.active.dispose();
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Color, MeshStandardMaterial } from "three";
|
||||
import type { Group, ShaderMaterial } from "three";
|
||||
import { MeshBasicMaterial, MeshStandardMaterial } from "three";
|
||||
import type { Group, Material, ShaderMaterial, Texture } from "three";
|
||||
import { subVec3 } from "@/lib/hive/layout";
|
||||
import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||
import { HoloLabel } from "./hive-labels";
|
||||
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
||||
import { createEdgeMaterial, createGlassMaterial, setEdgeIntensity } from "./materials";
|
||||
import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec } from "./part-geometry";
|
||||
import type { PartShapeSpec } from "./part-geometry";
|
||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
|
||||
import { useGlassEnvironment, useLivingEdges } from "./use-scene-effects";
|
||||
import { frameRimColor } from "./frame-textures";
|
||||
import { frameLookFor } from "./frame-textures";
|
||||
import type { SceneLabel } from "./scene-model";
|
||||
import type { NodeHandlers } from "./stack-part";
|
||||
import { useAnimatedNode } from "./use-animated-node";
|
||||
@@ -21,7 +21,8 @@ import type { FrameTarget } from "./use-hive-motion";
|
||||
|
||||
// Frames (spec 9.3/9.4). At rest only their top bars are hinted inside the
|
||||
// box. In an open box each frame is a clear glass plate; hovered or selected
|
||||
// it rises and takes its type look (colour and comb pattern).
|
||||
// it rises and takes its type look (colour and comb pattern; in sun a black
|
||||
// and white hatch per type with a white rim instead).
|
||||
|
||||
/** Faint top bars of all frames of a box, one merged mesh using the box's materials. */
|
||||
export function FrameHints({
|
||||
@@ -69,6 +70,31 @@ export interface FramePlateProps {
|
||||
|
||||
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
|
||||
|
||||
/** Above the neutral tone mapping's knee: the white of a sun hatch comes out white, not 88 % grey. */
|
||||
const SUN_HATCH_GAIN = 6;
|
||||
|
||||
/** The comb of a lifted frame: lit comb texture, or in sun the hatch unlit (stays pure black and white). */
|
||||
export function frameTypeMaterial(texture: Texture, hatch: boolean, type: FrameBox["type"]): Material {
|
||||
if (hatch) {
|
||||
const material = new MeshBasicMaterial({ map: texture, transparent: true, opacity: 0 });
|
||||
material.color.setScalar(SUN_HATCH_GAIN);
|
||||
return material;
|
||||
}
|
||||
return new MeshStandardMaterial({
|
||||
map: texture,
|
||||
// The comb pattern as relief: cells read as depth under the studio light.
|
||||
bumpMap: texture,
|
||||
bumpScale: 4,
|
||||
emissiveMap: texture,
|
||||
emissive: "#ffffff",
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: type === "HONEY" ? 0.2 : 0.45,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
|
||||
const { theme, geometries, textures, motion } = useSceneKit();
|
||||
const group = useRef<Group>(null);
|
||||
@@ -93,32 +119,18 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
|
||||
// While mounted the plate holds its shapes, so the cache does not dispose them.
|
||||
useEffect(() => geometries.retain(barsKey, bars), [geometries, barsKey, bars]);
|
||||
useEffect(() => (combKey && comb ? geometries.retain(combKey, comb) : undefined), [geometries, combKey, comb]);
|
||||
const texture = textures.get(frame.type);
|
||||
const rimHex = frameRimColor(frame.type);
|
||||
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
|
||||
const look = useMemo(() => frameLookFor(frame.type, { sun: theme.sun }), [frame.type, theme.sun]);
|
||||
const texture = look.hatch ? textures.hatch(look.hatch) : textures.get(frame.type);
|
||||
const rim = look.color ?? theme.edgeStrong;
|
||||
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
bars: createGlassMaterial(theme, { frost: 0.28 }),
|
||||
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
|
||||
edges: createEdgeMaterial(theme),
|
||||
typed: texture
|
||||
? new MeshStandardMaterial({
|
||||
map: texture,
|
||||
// The comb pattern as relief: cells read as depth under the studio light.
|
||||
bumpMap: texture,
|
||||
bumpScale: 4,
|
||||
emissiveMap: texture,
|
||||
emissive: "#ffffff",
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: frame.type === "HONEY" ? 0.2 : 0.45,
|
||||
metalness: 0.05,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
})
|
||||
: null,
|
||||
typed: texture ? frameTypeMaterial(texture, look.hatch !== null, frame.type) : null,
|
||||
}),
|
||||
[theme, texture, frame.type],
|
||||
[theme, texture, look.hatch, frame.type],
|
||||
);
|
||||
useGlassEnvironment(materials.bars, true);
|
||||
useGlassEnvironment(materials.comb, false);
|
||||
@@ -140,7 +152,7 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
|
||||
setUniform(materials.comb, "uOpacity", opacity * (1 - (materials.typed ? look * 0.92 : 0)));
|
||||
setUniform(materials.comb, "uGlow", glow);
|
||||
setUniform(materials.edges, "uOpacity", opacity);
|
||||
setUniform(materials.edges, "uIntensity", 1.3 * glow);
|
||||
setEdgeIntensity(materials.edges, 1.3 * glow);
|
||||
// Lifted, the rim takes the colour of the frame type.
|
||||
setUniformMix(materials.edges, "uCyan", theme.edge, rim, look);
|
||||
if (materials.typed) setMaterialOpacity(materials.typed, opacity * look);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FRAME_TYPES } from "@/lib/hive/types";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
import { createFrameTextureCache, framePatternSpec, frameRimColor } from "./frame-textures";
|
||||
import { createFrameTextureCache, frameLookFor, framePatternSpec, frameRimColor } from "./frame-textures";
|
||||
|
||||
/** A canvas whose 2D context records every fill colour and shape drawn. */
|
||||
function fakeCanvas() {
|
||||
@@ -20,13 +20,13 @@ function fakeCanvas() {
|
||||
globalAlpha: 1,
|
||||
fillRect: vi.fn(() => fills.push(`rect:${String(fillStyle)}`)),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
moveTo: vi.fn((x: number, y: number) => fills.push(`move:${x.toFixed(1)},${y.toFixed(1)}`)),
|
||||
lineTo: vi.fn((x: number, y: number) => fills.push(`line:${x.toFixed(1)},${y.toFixed(1)}`)),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
ellipse: vi.fn(),
|
||||
fill: vi.fn(() => fills.push(typeof fillStyle === "string" ? fillStyle : "gradient")),
|
||||
stroke: vi.fn(),
|
||||
stroke: vi.fn(() => fills.push("stroke")),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
createLinearGradient: vi.fn(() => gradient),
|
||||
};
|
||||
@@ -116,3 +116,63 @@ describe("frameRimColor", () => {
|
||||
expect(frameRimColor("NONE")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("frameLookFor", () => {
|
||||
const typed = FRAME_TYPES.filter((type) => type !== "NONE");
|
||||
|
||||
it("tints a lifted frame in its type colour and draws no hatch outside sun", () => {
|
||||
for (const type of typed) {
|
||||
const look = frameLookFor(type, { sun: false });
|
||||
expect(look.hatch).toBeNull();
|
||||
expect(`#${look.color!.getHexString()}`).toBe(frameRimColor(type));
|
||||
}
|
||||
});
|
||||
|
||||
it("in sun every frame type has its own hatch and no tint", () => {
|
||||
const hatches = typed.map((type) => frameLookFor(type, { sun: true }));
|
||||
for (const look of hatches) {
|
||||
expect(look.color).toBeNull();
|
||||
expect(look.hatch).not.toBeNull();
|
||||
}
|
||||
expect(new Set(hatches.map((look) => look.hatch)).size).toBe(typed.length);
|
||||
});
|
||||
|
||||
it("an empty slot has neither tint nor hatch", () => {
|
||||
expect(frameLookFor("NONE", { sun: false })).toEqual({ color: null, hatch: null });
|
||||
expect(frameLookFor("NONE", { sun: true })).toEqual({ color: null, hatch: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFrameTextureCache hatches", () => {
|
||||
it("draws one cached black-and-white hatch texture per kind, each with its own strokes", () => {
|
||||
const drawn: string[][] = [];
|
||||
const cache = createFrameTextureCache(() => {
|
||||
const fake = fakeCanvas();
|
||||
drawn.push(fake.fills);
|
||||
return fake.canvas;
|
||||
});
|
||||
const kinds = FRAME_TYPES.map((type) => frameLookFor(type, { sun: true }).hatch).filter(
|
||||
(kind): kind is NonNullable<typeof kind> => kind !== null,
|
||||
);
|
||||
const first = cache.hatch(kinds[0]);
|
||||
expect(first).not.toBeNull();
|
||||
expect(cache.hatch(kinds[0])).toBe(first);
|
||||
kinds.slice(1).forEach((kind) => cache.hatch(kind));
|
||||
expect(drawn).toHaveLength(kinds.length);
|
||||
// Black ground, white lines: no colour at all.
|
||||
for (const fills of drawn) {
|
||||
expect(fills[0]).toBe("rect:#000000");
|
||||
const paints = fills.filter((entry) => entry.startsWith("rect:") || entry.startsWith("#"));
|
||||
expect(paints.every((entry) => ["rect:#000000", "rect:#ffffff", "#ffffff"].includes(entry))).toBe(true);
|
||||
}
|
||||
expect(new Set(drawn.map((fills) => fills.join("|"))).size).toBe(kinds.length);
|
||||
});
|
||||
|
||||
it("disposes the hatch textures with the comb textures", () => {
|
||||
const cache = createFrameTextureCache(() => fakeCanvas().canvas);
|
||||
const hatch = cache.hatch(frameLookFor("BROOD", { sun: true }).hatch!)!;
|
||||
const dispose = vi.spyOn(hatch, "dispose");
|
||||
cache.dispose();
|
||||
expect(dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CanvasTexture, ClampToEdgeWrapping, SRGBColorSpace } from "three";
|
||||
import { CanvasTexture, ClampToEdgeWrapping, Color, SRGBColorSpace } from "three";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
|
||||
// Procedural comb textures for the frame look (spec 9.3): colour AND pattern
|
||||
// per frame type, drawn once on a canvas and cached per type. No image assets.
|
||||
// In sun the tints give way to a black-and-white hatch per type (spec 9.3,
|
||||
// T5 spec 17.8); the type also stands as a word in the label.
|
||||
|
||||
const WIDTH = 512;
|
||||
const HEIGHT = 288;
|
||||
@@ -145,6 +147,44 @@ export function frameRimColor(type: FrameType): string | null {
|
||||
return SPECS[type]?.rim ?? OPEN_RIMS[type] ?? null;
|
||||
}
|
||||
|
||||
/** Black-and-white patterns that tell the frame types apart in sun without colour. */
|
||||
export type HatchKind =
|
||||
| "crosshatch"
|
||||
| "rings"
|
||||
| "horizontal"
|
||||
| "dots"
|
||||
| "diagonal"
|
||||
| "grid"
|
||||
| "vertical"
|
||||
| "zigzag"
|
||||
| "checker";
|
||||
|
||||
const HATCHES: Partial<Record<FrameType, HatchKind>> = {
|
||||
BROOD: "crosshatch",
|
||||
DRONE: "rings",
|
||||
HONEY: "horizontal",
|
||||
POLLEN: "dots",
|
||||
FEED: "diagonal",
|
||||
FOUNDATION: "grid",
|
||||
EMPTY_COMB: "vertical",
|
||||
DRONE_FRAME: "zigzag",
|
||||
DIVIDER: "checker",
|
||||
};
|
||||
|
||||
export interface FrameLook {
|
||||
/** Rim colour of a lifted or selected frame; null in sun and for an empty slot. */
|
||||
color: Color | null;
|
||||
/** Hatch in place of the tint (sun only). */
|
||||
hatch: HatchKind | null;
|
||||
}
|
||||
|
||||
/** How a lifted or selected frame shows its type: a tint, or in sun a hatch. */
|
||||
export function frameLookFor(type: FrameType, { sun }: { sun: boolean }): FrameLook {
|
||||
if (sun) return { color: null, hatch: HATCHES[type] ?? null };
|
||||
const rim = frameRimColor(type);
|
||||
return { color: rim ? new Color(rim) : null, hatch: null };
|
||||
}
|
||||
|
||||
/** Small deterministic PRNG (mulberry32). */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
@@ -244,41 +284,141 @@ function drawPattern(context: CanvasRenderingContext2D, spec: FramePatternSpec):
|
||||
}
|
||||
}
|
||||
|
||||
/** Hatch spacing and line width in canvas pixels: coarse enough to read on a phone in the sun. */
|
||||
const HATCH_STEP = 24;
|
||||
const HATCH_LINE = 5;
|
||||
const HATCH_INK = "#ffffff";
|
||||
|
||||
function strokeLines(context: CanvasRenderingContext2D, lines: [number, number, number, number][]): void {
|
||||
context.beginPath();
|
||||
for (const [x1, y1, x2, y2] of lines) {
|
||||
context.moveTo(x1, y1);
|
||||
context.lineTo(x2, y2);
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function steps(from: number, to: number, step: number): number[] {
|
||||
const values: number[] = [];
|
||||
for (let value = from; value < to; value += step) values.push(value);
|
||||
return values;
|
||||
}
|
||||
|
||||
const HATCH_DRAWERS: Record<HatchKind, (context: CanvasRenderingContext2D) => void> = {
|
||||
horizontal: (context) =>
|
||||
strokeLines(context, steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP).map((y) => [0, y, WIDTH, y])),
|
||||
vertical: (context) =>
|
||||
strokeLines(context, steps(HATCH_STEP / 2, WIDTH, HATCH_STEP).map((x) => [x, 0, x, HEIGHT])),
|
||||
grid: (context) => {
|
||||
HATCH_DRAWERS.horizontal(context);
|
||||
HATCH_DRAWERS.vertical(context);
|
||||
},
|
||||
diagonal: (context) =>
|
||||
strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, HEIGHT, x + HEIGHT, 0])),
|
||||
crosshatch: (context) => {
|
||||
HATCH_DRAWERS.diagonal(context);
|
||||
strokeLines(context, steps(-HEIGHT, WIDTH, HATCH_STEP).map((x) => [x, 0, x + HEIGHT, HEIGHT]));
|
||||
},
|
||||
zigzag: (context) => {
|
||||
const half = HATCH_STEP / 2;
|
||||
strokeLines(
|
||||
context,
|
||||
steps(HATCH_STEP, HEIGHT, HATCH_STEP * 1.5).flatMap((y) =>
|
||||
steps(0, WIDTH, HATCH_STEP).flatMap((x): [number, number, number, number][] => [
|
||||
[x, y, x + half, y - half],
|
||||
[x + half, y - half, x + HATCH_STEP, y],
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
dots: (context) => {
|
||||
context.fillStyle = HATCH_INK;
|
||||
for (const y of steps(HATCH_STEP / 2, HEIGHT, HATCH_STEP)) {
|
||||
for (const x of steps(HATCH_STEP / 2, WIDTH, HATCH_STEP)) {
|
||||
context.beginPath();
|
||||
context.arc(x, y, HATCH_LINE * 0.9, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
},
|
||||
rings: (context) => {
|
||||
const step = HATCH_STEP * 1.5;
|
||||
for (const y of steps(step / 2, HEIGHT, step)) {
|
||||
for (const x of steps(step / 2, WIDTH, step)) {
|
||||
context.beginPath();
|
||||
context.arc(x, y, step * 0.32, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
},
|
||||
checker: (context) => {
|
||||
const step = HATCH_STEP * 1.5;
|
||||
context.fillStyle = HATCH_INK;
|
||||
steps(0, HEIGHT, step).forEach((y, row) => {
|
||||
for (const x of steps(row % 2 === 0 ? 0 : step, WIDTH, step * 2)) context.fillRect(x, y, step, step);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function drawHatch(context: CanvasRenderingContext2D, kind: HatchKind): void {
|
||||
context.fillStyle = "#000000";
|
||||
context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
context.strokeStyle = HATCH_INK;
|
||||
context.lineWidth = HATCH_LINE;
|
||||
HATCH_DRAWERS[kind](context);
|
||||
}
|
||||
|
||||
export interface FrameTextureCache {
|
||||
/** Texture of a frame type, created on first use; null for types without a comb or without 2D canvas. */
|
||||
get(type: FrameType): CanvasTexture | null;
|
||||
/** Black-and-white hatch of a frame type in sun, created on first use; null without 2D canvas. */
|
||||
hatch(kind: HatchKind): CanvasTexture | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const defaultCanvas = (): HTMLCanvasElement => document.createElement("canvas");
|
||||
|
||||
function canvasTexture(
|
||||
makeCanvas: () => HTMLCanvasElement,
|
||||
draw: (context: CanvasRenderingContext2D) => void,
|
||||
): CanvasTexture | null {
|
||||
const canvas = makeCanvas();
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return null;
|
||||
draw(context);
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.anisotropy = 4;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function createFrameTextureCache(makeCanvas: () => HTMLCanvasElement = defaultCanvas): FrameTextureCache {
|
||||
const textures = new Map<FrameType, CanvasTexture | null>();
|
||||
const hatches = new Map<HatchKind, CanvasTexture | null>();
|
||||
return {
|
||||
get(type) {
|
||||
if (textures.has(type)) return textures.get(type) ?? null;
|
||||
const spec = framePatternSpec(type);
|
||||
if (!spec) return null;
|
||||
const canvas = makeCanvas();
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
textures.set(type, null);
|
||||
return null;
|
||||
}
|
||||
drawPattern(context, spec);
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.anisotropy = 4;
|
||||
const texture = canvasTexture(makeCanvas, (context) => drawPattern(context, spec));
|
||||
textures.set(type, texture);
|
||||
return texture;
|
||||
},
|
||||
hatch(kind) {
|
||||
if (hatches.has(kind)) return hatches.get(kind) ?? null;
|
||||
const texture = canvasTexture(makeCanvas, (context) => drawHatch(context, kind));
|
||||
hatches.set(kind, texture);
|
||||
return texture;
|
||||
},
|
||||
dispose() {
|
||||
textures.forEach((texture) => texture?.dispose());
|
||||
textures.clear();
|
||||
hatches.forEach((texture) => texture?.dispose());
|
||||
hatches.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { Vec3 } from "@/lib/hive/layout";
|
||||
import type { SceneLabel } from "./scene-model";
|
||||
import { HOLO_LABEL_PILL } from "../hive-glass";
|
||||
|
||||
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
|
||||
// pill with a thin leader line. At most three at once (sceneLabels caps it).
|
||||
// In sun 16 px on a solid plate, without blur and glow (spec 9.3, T5 17.6).
|
||||
|
||||
const PILL =
|
||||
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md";
|
||||
// The pill lives in hive-glass.ts (DOM only), so the editor's drag chip can share it without three.
|
||||
export { HOLO_LABEL_PILL } from "../hive-glass";
|
||||
|
||||
export interface HoloLabelProps {
|
||||
position: Vec3;
|
||||
@@ -22,7 +24,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) {
|
||||
<Html position={position} zIndexRange={[30, 10]} style={{ pointerEvents: "none" }}>
|
||||
{tone === "frame" ? (
|
||||
<div className="pointer-events-none flex -translate-x-1/2 -translate-y-full flex-col items-center select-none">
|
||||
<span className={PILL}>{text}</span>
|
||||
<span className={HOLO_LABEL_PILL}>{text}</span>
|
||||
<span aria-hidden className="h-6 w-px bg-holo-edge/80" />
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||
</div>
|
||||
@@ -30,7 +32,7 @@ export function HoloLabel({ position, text, tone }: HoloLabelProps) {
|
||||
<div className="pointer-events-none flex -translate-y-1/2 items-center select-none">
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-holo-edge-strong shadow-[0_0_8px_var(--holo-edge)]" />
|
||||
<span aria-hidden className="h-px w-7 bg-holo-edge/80" />
|
||||
<span className={PILL}>
|
||||
<span className={HOLO_LABEL_PILL}>
|
||||
{tone === "level" ? <span aria-hidden className="me-2 inline-block size-2 rounded-full bg-amber-400 align-middle" /> : null}
|
||||
{text}
|
||||
</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { partDisplayName } from "@/lib/hive/names";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||
import type { HiveViewStore } from "../use-hive-view";
|
||||
import { dropSlots } from "../editor/drop-slots";
|
||||
@@ -105,6 +106,7 @@ async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor):
|
||||
afterEach(async () => {
|
||||
await current?.renderer.unmount();
|
||||
current = null;
|
||||
document.documentElement.removeAttribute("data-contrast");
|
||||
});
|
||||
|
||||
const hit = (renderer: Rendered["renderer"], id: string): ReactThreeTest.ReactThreeTestInstance =>
|
||||
@@ -277,6 +279,44 @@ describe("HiveSceneContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Edge materials of the mounted scene (they carry the travelling pulse). */
|
||||
const edgeMaterials = (renderer: Rendered["renderer"]): ShaderMaterial[] =>
|
||||
renderer.scene
|
||||
.findAll((node) => (node.instance as unknown as Mesh).material instanceof ShaderMaterial)
|
||||
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial)
|
||||
.filter((material) => "uPulse" in material.uniforms);
|
||||
|
||||
describe("HiveSceneContent and the appearance", () => {
|
||||
it("the scene re-reads the holo tokens when the appearance changes", async () => {
|
||||
const { renderer } = await renderScene();
|
||||
const before = edgeMaterials(renderer);
|
||||
expect(before.length).toBeGreaterThan(0);
|
||||
expect(before.every((material) => material.uniforms.uSun.value === 0)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||
});
|
||||
const after = edgeMaterials(renderer);
|
||||
expect(after).not.toContain(before[0]);
|
||||
expect(after.every((material) => material.uniforms.uSun.value === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("sun draws white edges as a 2 px line", async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
const { renderer } = await renderScene();
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
for (const material of edgeMaterials(renderer)) {
|
||||
expect(material.uniforms.uSun.value).toBe(1);
|
||||
expect(material.uniforms.uCyan.value.getHex()).toBe(0xffffff);
|
||||
expect(material.uniforms.uHot.value.getHex()).toBe(0xffffff);
|
||||
expect(material.uniforms.uLinePixels.value).toBe(2);
|
||||
// The line width is in screen pixels: the shader needs the canvas height.
|
||||
expect(material.uniforms.uViewHeight.value).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveSceneContent in the editor", () => {
|
||||
const editorOf = (extra: Partial<SceneEditor> = {}): SceneEditor => ({
|
||||
drag: null,
|
||||
@@ -337,6 +377,102 @@ describe("HiveSceneContent in the editor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveSceneContent in sun: state cues without colour", () => {
|
||||
it("a hovered part draws its edges 3 px, the others 2 px", async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
const { renderer, store } = await renderScene("off");
|
||||
await act(async () => store.setHovered("b1"));
|
||||
await renderer.advanceFrames(2, 1 / 60);
|
||||
const pixelsOf = (id: string) =>
|
||||
hit(renderer, id)
|
||||
.parent!.children.map((node) => (node.instance as unknown as Mesh).material)
|
||||
.filter((material): material is ShaderMaterial => material instanceof ShaderMaterial && "uPulse" in material.uniforms)
|
||||
.map((material) => material.uniforms.uLinePixels.value);
|
||||
expect(pixelsOf("b1")).toContain(3);
|
||||
expect(pixelsOf("fe")).toEqual(pixelsOf("fe").map(() => 2));
|
||||
});
|
||||
|
||||
it("idle drop slots are grey 2 px on the tube they are built from; the hovered slot is white 3 px", async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
const state = hiveState();
|
||||
const slots = dropSlots(state, catalog, { partTypeId: "box" });
|
||||
const editor: SceneEditor = {
|
||||
drag: { source: { partTypeId: "box" }, slots, hover: slots[0] },
|
||||
pointer: { current: null },
|
||||
subscribePointer: () => () => {},
|
||||
onHoverSlot: vi.fn(),
|
||||
onPartPress: vi.fn(),
|
||||
zoom: 1,
|
||||
onZoom: vi.fn(),
|
||||
};
|
||||
const { renderer } = await renderScene("off", editor);
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
const markers = renderer.scene
|
||||
.findAll((node) => node.props.name === "drop-slot")
|
||||
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial);
|
||||
const idle = markers.filter((material) => material.uniforms.uSunGrey.value === 1);
|
||||
const active = markers.filter((material) => material.uniforms.uSunGrey.value === 0);
|
||||
expect(idle.length).toBe(slots.length - 1);
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].uniforms.uLinePixels.value).toBe(3);
|
||||
expect(idle[0].uniforms.uLinePixels.value).toBe(2);
|
||||
for (const material of markers) expect(material.uniforms.uTubeCorner.value).toBeCloseTo(0.015 * Math.SQRT2, 9);
|
||||
});
|
||||
|
||||
it("in sun the active slot follows the hover during a drag (white 3 px moves, the rest stays grey 2 px)", async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
const state = hiveState();
|
||||
const layout = hiveLayout(state, catalog);
|
||||
const slots = dropSlots(state, catalog, { partTypeId: "box" });
|
||||
expect(slots.length).toBeGreaterThan(1);
|
||||
const editorFor = (hover: DropSlot | null): SceneEditor => ({
|
||||
drag: { source: { partTypeId: "box" }, slots, hover },
|
||||
pointer: { current: null },
|
||||
subscribePointer: () => () => {},
|
||||
onHoverSlot: vi.fn(),
|
||||
onPartPress: vi.fn(),
|
||||
zoom: 1,
|
||||
onZoom: vi.fn(),
|
||||
});
|
||||
const store = createHiveViewStore();
|
||||
const scene = (editor: SceneEditor) => (
|
||||
<HiveViewProvider store={store}>
|
||||
<SceneKitProvider motion="off">
|
||||
<HiveSceneContent
|
||||
layout={layout}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
partName={(id) => partDisplayName(state, catalog, id)}
|
||||
onSelectPart={vi.fn()}
|
||||
onSelectFrame={vi.fn()}
|
||||
idle={false}
|
||||
editor={editor}
|
||||
/>
|
||||
</SceneKitProvider>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
const renderer = await ReactThreeTestRenderer.create(scene(editorFor(null)));
|
||||
current = { renderer, store, onSelectPart: vi.fn(), onSelectFrame: vi.fn() };
|
||||
const activeIndexes = () =>
|
||||
renderer.scene
|
||||
.findAll((node) => node.props.name === "drop-slot")
|
||||
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial)
|
||||
.flatMap((material, index) =>
|
||||
material.uniforms.uSunGrey.value === 0 ? [{ index, pixels: material.uniforms.uLinePixels.value }] : [],
|
||||
);
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
expect(activeIndexes()).toEqual([]);
|
||||
|
||||
await renderer.update(scene(editorFor(slots[1])));
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
expect(activeIndexes()).toEqual([{ index: 1, pixels: 3 }]);
|
||||
|
||||
await renderer.update(scene(editorFor(slots[0])));
|
||||
await renderer.advanceFrames(1, 1 / 60);
|
||||
expect(activeIndexes()).toEqual([{ index: 0, pixels: 3 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveSceneContent drop anchors", () => {
|
||||
it("computes the slot anchors and motion targets once per drag, not again on every hover change", async () => {
|
||||
const state = hiveState();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import ReactThreeTestRenderer from "@react-three/test-renderer";
|
||||
import { act, forwardRef, useEffect, useImperativeHandle } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { WebGLRenderTarget } from "three";
|
||||
import { Color, WebGLRenderTarget } from "three";
|
||||
import type { Light, Scene } from "three";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||
import type { HiveViewStore } from "../use-hive-view";
|
||||
import { HiveStage } from "./hive-stage";
|
||||
@@ -28,9 +30,9 @@ vi.mock("@react-three/postprocessing", () => ({
|
||||
});
|
||||
return <group name="depth-of-field" />;
|
||||
}),
|
||||
Bloom: () => null,
|
||||
ToneMapping: () => null,
|
||||
Vignette: () => null,
|
||||
Bloom: () => <group name="bloom" />,
|
||||
ToneMapping: () => <group name="tone-mapping" />,
|
||||
Vignette: () => <group name="vignette" />,
|
||||
}));
|
||||
|
||||
// The test renderer's mock context cannot render into targets: the mirror pass is skipped.
|
||||
@@ -49,6 +51,7 @@ let renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>> | null =
|
||||
afterEach(async () => {
|
||||
await renderer?.unmount();
|
||||
renderer = null;
|
||||
document.documentElement.removeAttribute("data-contrast");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -120,3 +123,60 @@ describe("HiveStage reflective floor", () => {
|
||||
expect(dispose.mock.calls.length - afterMount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
const named = (name: string) => renderer!.scene.findAll((node) => node.props.name === name).length;
|
||||
const isBlack = (value: unknown) => value instanceof Color && value.getHex() === 0x000000;
|
||||
|
||||
describe("HiveStage in sun", () => {
|
||||
it("sun turns bloom off: no bloom, no vignette, no haze or particles, a plain black stage", async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||
expect(named("bloom")).toBe(0);
|
||||
expect(named("vignette")).toBe(0);
|
||||
expect(named("tone-mapping")).toBe(1);
|
||||
expect(named("stage-haze")).toBe(0);
|
||||
expect(named("stage-particles")).toBe(0);
|
||||
expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true);
|
||||
});
|
||||
|
||||
it("sun draws no mirror floor and no blue hex grid, and lights the hive in neutral white", async () => {
|
||||
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||
expect(named("stage-floor-mirror")).toBe(1);
|
||||
expect(named("stage-floor-grid")).toBe(1);
|
||||
await renderer.unmount();
|
||||
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
for (const tier of ["high", "low"] as const) {
|
||||
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), tier, false));
|
||||
expect(named("stage-floor-mirror")).toBe(0);
|
||||
expect(named("stage-floor-grid")).toBe(0);
|
||||
const lights = renderer.scene.findAll((node) => (node.instance as unknown as Light).isLight === true);
|
||||
expect(lights.length).toBeGreaterThan(0);
|
||||
for (const light of lights) {
|
||||
const { r, g, b } = (light.instance as unknown as Light).color;
|
||||
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(1e-6);
|
||||
}
|
||||
await renderer.unmount();
|
||||
}
|
||||
renderer = null;
|
||||
});
|
||||
|
||||
it("switches bloom off and on live when the appearance changes", async () => {
|
||||
renderer = await ReactThreeTestRenderer.create(stage(createHiveViewStore(), "high", false));
|
||||
expect(named("bloom")).toBe(1);
|
||||
expect(named("stage-haze")).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "sun");
|
||||
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||
});
|
||||
expect(named("bloom")).toBe(0);
|
||||
expect(isBlack((renderer.scene.instance as Scene).background)).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
document.documentElement.setAttribute("data-contrast", "normal");
|
||||
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||
});
|
||||
expect(named("bloom")).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ import type { HoloTheme } from "./theme";
|
||||
// The holo stage (spec 9.3, mockups g2/h2): deep midnight backdrop with a
|
||||
// cool light from behind, glossy dark floor with a faint hexagon grid, a few
|
||||
// slow bokeh particles, bloom at half resolution, vignette. Cool light only:
|
||||
// warm colours belong to the content (owner 2026-10-16).
|
||||
// warm colours belong to the content (owner 2026-10-16). In sun the stage is
|
||||
// plain black without bloom, vignette, haze and particles (spec 9.3, T5 17.8).
|
||||
|
||||
/** Objects on this layer are seen by the camera but not by the floor reflection. */
|
||||
const OVERLAY_LAYER = 1;
|
||||
@@ -94,7 +95,7 @@ function Backdrop() {
|
||||
const { theme } = useSceneKit();
|
||||
const scene = useThree((state) => state.scene);
|
||||
useEffect(() => {
|
||||
const texture = backdropTexture(theme);
|
||||
const texture = theme.sun ? null : backdropTexture(theme);
|
||||
setSceneBackground(scene, texture ?? theme.background);
|
||||
return () => {
|
||||
setSceneBackground(scene, null);
|
||||
@@ -197,7 +198,7 @@ function ReflectiveFloor() {
|
||||
if (surface.current) reflector.render(gl, scene, camera, surface.current);
|
||||
});
|
||||
return (
|
||||
<mesh ref={surface} rotation-x={-Math.PI / 2} material={reflector.material} renderOrder={-3}>
|
||||
<mesh ref={surface} name="stage-floor-mirror" rotation-x={-Math.PI / 2} material={reflector.material} renderOrder={-3}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
</mesh>
|
||||
);
|
||||
@@ -229,7 +230,7 @@ function Floor({ tier }: { tier: StageTier }) {
|
||||
|
||||
if (tier === "low") {
|
||||
return (
|
||||
<mesh rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
|
||||
<mesh name="stage-floor-grid" rotation-x={-Math.PI / 2} material={materials.grid} renderOrder={-2}>
|
||||
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
|
||||
</mesh>
|
||||
);
|
||||
@@ -239,6 +240,7 @@ function Floor({ tier }: { tier: StageTier }) {
|
||||
<ReflectiveFloor />
|
||||
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
|
||||
<mesh
|
||||
name="stage-floor-grid"
|
||||
rotation-x={-Math.PI / 2}
|
||||
position-y={0.004}
|
||||
material={materials.grid}
|
||||
@@ -340,14 +342,15 @@ function Particles() {
|
||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||
setUniform(material, "uPixelRatio", dpr);
|
||||
});
|
||||
return <points geometry={geometry} material={material} renderOrder={5} raycast={noRaycast} />;
|
||||
return <points name="stage-particles" geometry={geometry} material={material} renderOrder={5} raycast={noRaycast} />;
|
||||
}
|
||||
|
||||
function Lights() {
|
||||
const { theme } = useSceneKit();
|
||||
return (
|
||||
<>
|
||||
<ambientLight intensity={0.55} color="#cfe6ff" />
|
||||
{/* Sun: neutral white light only (theme.rim is grey there). */}
|
||||
<ambientLight intensity={0.55} color={theme.sun ? "#ffffff" : "#cfe6ff"} />
|
||||
<directionalLight position={[3, 9, 7]} intensity={1.5} color="#ffffff" />
|
||||
<directionalLight position={[-5, 5, -7]} intensity={1.3} color={theme.rim} />
|
||||
</>
|
||||
@@ -382,7 +385,7 @@ function useFocusMode(): boolean {
|
||||
}
|
||||
|
||||
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
|
||||
const { signals } = useSceneKit();
|
||||
const { signals, theme } = useSceneKit();
|
||||
const dof = useRef<DepthOfFieldEffect>(null);
|
||||
// Depth of field runs its seven passes every frame even with no blur, so it is mounted only in
|
||||
// focus mode. Entering focus rebuilds the composer once (one shader compile as the flight starts);
|
||||
@@ -402,16 +405,21 @@ function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolea
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<Bloom
|
||||
mipmapBlur
|
||||
luminanceThreshold={0.7}
|
||||
luminanceSmoothing={0.28}
|
||||
intensity={0.85}
|
||||
radius={0.72}
|
||||
resolutionScale={0.5}
|
||||
/>
|
||||
{/* Sun: no glow around the white edges, no darkened corners on the black stage. */}
|
||||
{theme.sun ? (
|
||||
<></>
|
||||
) : (
|
||||
<Bloom
|
||||
mipmapBlur
|
||||
luminanceThreshold={0.7}
|
||||
luminanceSmoothing={0.28}
|
||||
intensity={0.85}
|
||||
radius={0.72}
|
||||
resolutionScale={0.5}
|
||||
/>
|
||||
)}
|
||||
<ToneMapping mode={ToneMappingMode.NEUTRAL} />
|
||||
<Vignette offset={0.26} darkness={0.7} />
|
||||
{theme.sun ? <></> : <Vignette offset={0.26} darkness={0.7} />}
|
||||
</EffectComposer>
|
||||
);
|
||||
}
|
||||
@@ -462,6 +470,7 @@ function Haze() {
|
||||
useEffect(() => () => material.dispose(), [material]);
|
||||
return (
|
||||
<mesh
|
||||
name="stage-haze"
|
||||
position={[-1.5, HAZE_HEIGHT / 2 - 1, -3]}
|
||||
rotation={[0.18, 0, 0.12]}
|
||||
material={material}
|
||||
@@ -482,13 +491,15 @@ export interface HiveStageProps {
|
||||
}
|
||||
|
||||
export function HiveStage({ tier, depthOfField, onQuality }: HiveStageProps) {
|
||||
const { theme } = useSceneKit();
|
||||
return (
|
||||
<>
|
||||
<Backdrop />
|
||||
<Lights />
|
||||
<Floor tier={tier} />
|
||||
<Haze />
|
||||
{tier === "high" ? <Particles /> : null}
|
||||
{/* Sun: the black stage has no floor (no blue grid, no mirror of the white edges). */}
|
||||
{theme.sun ? null : <Floor tier={tier} />}
|
||||
{theme.sun ? null : <Haze />}
|
||||
{tier === "high" && !theme.sun ? <Particles /> : null}
|
||||
<QualityWatcher onVerdict={onQuality} />
|
||||
<Effects tier={tier} depthOfField={depthOfField && tier === "high"} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CanvasTexture, MeshBasicMaterial, MeshStandardMaterial } from "three";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { frameTypeMaterial } from "./frame-plate";
|
||||
import { SUN_EDGE_PIXELS, SUN_STRONG_EDGE_PIXELS, createEdgeMaterial, setEdgeIntensity, sunEdgePixels } from "./materials";
|
||||
import { EDGE_TUBE_CORNER } from "./part-geometry";
|
||||
import { readHoloTheme } from "./theme";
|
||||
|
||||
afterEach(() => document.documentElement.removeAttribute("data-contrast"));
|
||||
|
||||
const sunTheme = () => readHoloTheme("sun-light");
|
||||
|
||||
describe("createEdgeMaterial", () => {
|
||||
it("finds the axis of the default tube and of a wider one (drop slots)", () => {
|
||||
expect(createEdgeMaterial(sunTheme()).uniforms.uTubeCorner.value).toBeCloseTo(EDGE_TUBE_CORNER, 9);
|
||||
expect(createEdgeMaterial(sunTheme(), { tubeWidth: 0.03 }).uniforms.uTubeCorner.value).toBeCloseTo(
|
||||
0.015 * Math.SQRT2,
|
||||
9,
|
||||
);
|
||||
});
|
||||
|
||||
it("draws quiet lines in detail grey in sun when asked (idle drop slots)", () => {
|
||||
expect(createEdgeMaterial(sunTheme()).uniforms.uSunGrey.value).toBe(0);
|
||||
expect(createEdgeMaterial(sunTheme(), { sunGrey: true }).uniforms.uSunGrey.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sun edge state", () => {
|
||||
it("draws a strong state (hover, lifted frame, active slot) 3 px wide and the rest 2 px", () => {
|
||||
expect(sunEdgePixels(1.25)).toBe(SUN_EDGE_PIXELS);
|
||||
expect(sunEdgePixels(1.4)).toBe(SUN_EDGE_PIXELS);
|
||||
expect(sunEdgePixels(1.875)).toBe(SUN_EDGE_PIXELS);
|
||||
expect(sunEdgePixels(2.21)).toBe(SUN_STRONG_EDGE_PIXELS);
|
||||
expect(sunEdgePixels(2.875)).toBe(SUN_STRONG_EDGE_PIXELS);
|
||||
expect(SUN_STRONG_EDGE_PIXELS).toBe(3);
|
||||
});
|
||||
|
||||
it("setEdgeIntensity sets the glow and the sun line width together", () => {
|
||||
const material = createEdgeMaterial(sunTheme());
|
||||
setEdgeIntensity(material, 3.2);
|
||||
expect(material.uniforms.uIntensity.value).toBe(3.2);
|
||||
expect(material.uniforms.uLinePixels.value).toBe(3);
|
||||
setEdgeIntensity(material, 1.3);
|
||||
expect(material.uniforms.uLinePixels.value).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("frameTypeMaterial", () => {
|
||||
const texture = new CanvasTexture(document.createElement("canvas"));
|
||||
|
||||
it("draws the sun hatch unlit and brighter than 1, so white survives the tone mapping", () => {
|
||||
const material = frameTypeMaterial(texture, true, "BROOD");
|
||||
expect(material).toBeInstanceOf(MeshBasicMaterial);
|
||||
const { r, g, b } = (material as MeshBasicMaterial).color;
|
||||
expect(Math.min(r, g, b)).toBeGreaterThanOrEqual(5);
|
||||
expect(r).toBe(g);
|
||||
expect(g).toBe(b);
|
||||
});
|
||||
|
||||
it("keeps the lit comb outside sun", () => {
|
||||
expect(frameTypeMaterial(texture, false, "HONEY")).toBeInstanceOf(MeshStandardMaterial);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AdditiveBlending, Color, DoubleSide, NormalBlending, ShaderMaterial } from "three";
|
||||
import type { CubeTexture } from "three";
|
||||
import { EDGE_TUBE_CORNER, edgeTubeCorner } from "./part-geometry";
|
||||
import type { HoloTheme } from "./theme";
|
||||
|
||||
// Hand-written shaders instead of transmission. The glass samples a small
|
||||
@@ -132,11 +133,42 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
|
||||
});
|
||||
}
|
||||
|
||||
/** Edge width in sun, in CSS pixels (Beute spec 9.3). */
|
||||
export const SUN_EDGE_PIXELS = 2;
|
||||
/** Sun has no glow: a strong state (hover, lifted frame, active drop slot) draws a wider line. */
|
||||
export const SUN_STRONG_EDGE_PIXELS = 3;
|
||||
/**
|
||||
* Edge intensity from which a state counts as strong. Rest and focus stay below
|
||||
* (stack part 1.25 / 1.875, frame 1.3, idle slot 1.4); hover 2.875, lifted
|
||||
* frame 2.21, active slot 3.2 and the drop preview 3 lie above.
|
||||
*/
|
||||
const SUN_STRONG_INTENSITY = 2;
|
||||
|
||||
/** Line width in sun for an edge intensity. */
|
||||
export function sunEdgePixels(intensity: number): number {
|
||||
return intensity >= SUN_STRONG_INTENSITY ? SUN_STRONG_EDGE_PIXELS : SUN_EDGE_PIXELS;
|
||||
}
|
||||
|
||||
/** Sets the glow of an edge material and, for sun, its line width (no allocation, frame-safe). */
|
||||
export function setEdgeIntensity(material: ShaderMaterial, intensity: number): void {
|
||||
const { uIntensity, uLinePixels } = material.uniforms;
|
||||
if (uIntensity) uIntensity.value = intensity;
|
||||
if (uLinePixels) uLinePixels.value = sunEdgePixels(intensity);
|
||||
}
|
||||
/** Above the neutral tone mapping's knee, so a sun edge comes out pure white. */
|
||||
const SUN_EDGE_GAIN = 6;
|
||||
/** Detail lines (rebates, grips, frame hints) stay a quiet grey in sun, so the outline leads. */
|
||||
const SUN_DETAIL_LEVEL = 0.35;
|
||||
|
||||
const EDGE_VERTEX = /* glsl */ `
|
||||
attribute float aHot;
|
||||
attribute float aStrength;
|
||||
attribute float aAlong;
|
||||
attribute float aPhase;
|
||||
uniform float uSun;
|
||||
uniform float uTubeCorner;
|
||||
uniform float uLinePixels;
|
||||
uniform float uViewHeight;
|
||||
varying float vHot;
|
||||
varying float vStrength;
|
||||
varying float vAlong;
|
||||
@@ -148,6 +180,13 @@ void main() {
|
||||
vAlong = aAlong;
|
||||
vPhase = aPhase;
|
||||
vec4 view = modelViewMatrix * vec4(position, 1.0);
|
||||
if (uSun > 0.5) {
|
||||
// Sun: a line of uLinePixels on screen at any distance. Each vertex sits uTubeCorner
|
||||
// from the edge's axis along its normal; the tube is rebuilt around that axis in pixels.
|
||||
vec4 axis = modelViewMatrix * vec4(position - normal * uTubeCorner, 1.0);
|
||||
float pixel = 2.0 * max(-axis.z, 1e-3) / (projectionMatrix[1][1] * max(uViewHeight, 1.0));
|
||||
view = vec4(axis.xyz + normalize(view.xyz - axis.xyz) * pixel * uLinePixels * 0.70710678, 1.0);
|
||||
}
|
||||
// Tube normal in view space: faces toward the camera form the white-hot core.
|
||||
vec3 normalView = normalize(normalMatrix * normal);
|
||||
vFacing = abs(dot(normalView, normalize(-view.xyz)));
|
||||
@@ -162,12 +201,21 @@ uniform float uIntensity;
|
||||
uniform float uOpacity;
|
||||
uniform float uTime;
|
||||
uniform float uPulse;
|
||||
uniform float uSun;
|
||||
uniform float uSunGrey;
|
||||
varying float vHot;
|
||||
varying float vStrength;
|
||||
varying float vAlong;
|
||||
varying float vPhase;
|
||||
varying float vFacing;
|
||||
void main() {
|
||||
if (uSun > 0.5) {
|
||||
// Sun: flat white outlines (the sun tokens), bright enough to stay white through the tone
|
||||
// mapping, detail lines grey; ghosts fade quadratically so the shadow of the hive stays faint.
|
||||
float level = vStrength >= 0.5 && uSunGrey < 0.5 ? ${SUN_EDGE_GAIN.toFixed(2)} : ${SUN_DETAIL_LEVEL.toFixed(2)};
|
||||
gl_FragColor = vec4(uCyan * level * uOpacity * uOpacity, 1.0);
|
||||
return;
|
||||
}
|
||||
float hot = vHot * vHot;
|
||||
float core = pow(vFacing, 4.0);
|
||||
vec3 color = mix(uCyan, uHot, clamp(max(hot, core * 0.75), 0.0, 1.0));
|
||||
@@ -179,16 +227,32 @@ void main() {
|
||||
}
|
||||
`;
|
||||
|
||||
/** Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`. */
|
||||
export function createEdgeMaterial(theme: HoloTheme): ShaderMaterial {
|
||||
/**
|
||||
* Glowing edges: additive, white-hot core and corners, electric cyan rim, slow pulses at `full`.
|
||||
* In sun (Beute spec 9.3): flat white lines of `SUN_EDGE_PIXELS` on screen, no glow, no pulse.
|
||||
*/
|
||||
export interface EdgeOptions {
|
||||
/** Width the edge geometry was built with, when not the default (drop slots). */
|
||||
tubeWidth?: number;
|
||||
/** Sun: draw every line in detail grey (idle drop slots), so the active one stands out. */
|
||||
sunGrey?: boolean;
|
||||
}
|
||||
|
||||
export function createEdgeMaterial(theme: HoloTheme, options: EdgeOptions = {}): ShaderMaterial {
|
||||
return new ShaderMaterial({
|
||||
uniforms: {
|
||||
uCyan: { value: theme.edge.clone() },
|
||||
uHot: { value: theme.hot.clone() },
|
||||
uHot: { value: theme.sun ? theme.edge.clone() : theme.hot.clone() },
|
||||
uIntensity: { value: 1.6 },
|
||||
uOpacity: { value: 1 },
|
||||
uTime: { value: 0 },
|
||||
uPulse: { value: 0 },
|
||||
uSun: { value: theme.sun ? 1 : 0 },
|
||||
uSunGrey: { value: options.sunGrey ? 1 : 0 },
|
||||
uTubeCorner: { value: options.tubeWidth === undefined ? EDGE_TUBE_CORNER : edgeTubeCorner(options.tubeWidth) },
|
||||
uLinePixels: { value: SUN_EDGE_PIXELS },
|
||||
// Canvas height in CSS pixels, kept current by useEdgeViewport.
|
||||
uViewHeight: { value: 1 },
|
||||
},
|
||||
vertexShader: EDGE_VERTEX,
|
||||
fragmentShader: EDGE_FRAGMENT,
|
||||
|
||||
@@ -503,6 +503,12 @@ export function buildGlassGeometry(slabs: Slab[]): BufferGeometry {
|
||||
|
||||
/** About 1.5 px at rest-view distance, thicker when a part is in focus. */
|
||||
const EDGE_WIDTH = 0.024;
|
||||
/** Distance from an edge's axis to each vertex of a tube of `width` (the sun shader finds the axis with it). */
|
||||
export function edgeTubeCorner(width: number): number {
|
||||
return (width / 2) * Math.SQRT2;
|
||||
}
|
||||
/** Tube corner of the default edge width. */
|
||||
export const EDGE_TUBE_CORNER = edgeTubeCorner(EDGE_WIDTH);
|
||||
/** Length of the white-hot zone at each corner. */
|
||||
const HOT_ZONE = 0.22;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Adaptive quality and the flight spring, as pure functions.
|
||||
import { SPRING_FLIGHT } from "@/lib/motion/tokens";
|
||||
|
||||
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
|
||||
export type StageTier = "high" | "low";
|
||||
@@ -52,8 +53,8 @@ export function createQualityMonitor(
|
||||
}
|
||||
|
||||
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
|
||||
export const SPRING_STIFFNESS = 210;
|
||||
export const SPRING_DAMPING = 21;
|
||||
export const SPRING_STIFFNESS = SPRING_FLIGHT.stiffness;
|
||||
export const SPRING_DAMPING = SPRING_FLIGHT.damping;
|
||||
const SPRING_SUBSTEP = 1 / 120;
|
||||
const SPRING_EPSILON = 1e-3;
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ import { createGeometryCache } from "./part-geometry";
|
||||
import type { GeometryCache } from "./part-geometry";
|
||||
import type { StageTier } from "./quality";
|
||||
import { createStudioEnvironment } from "./scene-env";
|
||||
import { readHoloTheme } from "./theme";
|
||||
import { useHoloTheme } from "./theme";
|
||||
import type { HoloTheme } from "./theme";
|
||||
|
||||
// Shared per canvas: theme (tokens read once), geometries per part kind and
|
||||
// size, comb textures per frame type, the studio cube map, motion level and
|
||||
// quality tier. Everything is disposed when the canvas unmounts (spec 9.9:
|
||||
// no leak between colonies).
|
||||
// Shared per canvas: theme (tokens re-read on each appearance change, T5 spec
|
||||
// 17.8), geometries per part kind and size, comb textures per frame type, the
|
||||
// studio cube map, motion level and quality tier. Everything is disposed when
|
||||
// the canvas unmounts (spec 9.9: no leak between colonies).
|
||||
|
||||
/** Mutable per-frame hand-offs between scene parts (never drive rendering on their own). */
|
||||
export interface SceneSignals {
|
||||
@@ -55,7 +55,8 @@ export interface SceneKitProviderProps {
|
||||
export function SceneKitProvider({ motion, tier = "high", children }: SceneKitProviderProps) {
|
||||
const gl = useThree((state) => state.gl);
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
const [theme] = useState(readHoloTheme);
|
||||
// A new theme rebuilds the materials that use it (a rare event, not per frame).
|
||||
const theme = useHoloTheme(invalidate);
|
||||
const [geometries] = useState(createGeometryCache);
|
||||
const [textures] = useState(() => createFrameTextureCache());
|
||||
const [signals] = useState<SceneSignals>(() => ({
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { FrameBox, Vec3 } from "@/lib/hive/layout";
|
||||
import { FillVolume } from "./fill-volume";
|
||||
import { FrameHints } from "./frame-plate";
|
||||
import { HoloLabel } from "./hive-labels";
|
||||
import { createEdgeMaterial, createGlassMaterial, createGridMaterial } from "./materials";
|
||||
import { createEdgeMaterial, createGlassMaterial, createGridMaterial, setEdgeIntensity } from "./materials";
|
||||
import { evaporatorBottle, feederChambers, partShapeSpec, pocketInner, shapeKey } from "./part-geometry";
|
||||
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
|
||||
import { setUniform, setUniformColor } from "./scene-mutations";
|
||||
@@ -81,7 +81,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
|
||||
setUniform(glass, "uGlow", glow);
|
||||
// Ghost edges keep a little more presence than the faces, so the outline of the hive reads.
|
||||
setUniform(edges, "uOpacity", Math.min(1, opacity * 1.6));
|
||||
setUniform(edges, "uIntensity", EDGE_INTENSITY * glow);
|
||||
setEdgeIntensity(edges, EDGE_INTENSITY * glow);
|
||||
setUniformColor(edges, "uCyan", glow > 1.2 ? theme.edgeStrong : theme.edge);
|
||||
grids.forEach((material) => setUniform(material, "uOpacity", opacity));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||
import { readHoloTheme, useHoloTheme } from "./theme";
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
function setAppearance({ dark, sun }: { dark: boolean; sun: boolean }) {
|
||||
root.classList.toggle("dark", dark);
|
||||
root.setAttribute("data-contrast", sun ? "sun" : "normal");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-contrast");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("readHoloTheme", () => {
|
||||
it("reads the tokens on every call, not once", () => {
|
||||
const style = vi.spyOn(window, "getComputedStyle");
|
||||
readHoloTheme();
|
||||
const first = style.mock.calls.length;
|
||||
expect(first).toBeGreaterThan(0);
|
||||
readHoloTheme();
|
||||
expect(style.mock.calls.length).toBe(first * 2);
|
||||
});
|
||||
|
||||
it("knows sun from the document and falls back to a black stage with white edges there", () => {
|
||||
setAppearance({ dark: false, sun: true });
|
||||
const theme = readHoloTheme();
|
||||
expect(theme.sun).toBe(true);
|
||||
// jsdom has no 2D canvas: the sun fallbacks stand in for the sun tokens.
|
||||
expect(theme.background.getHex()).toBe(0x000000);
|
||||
expect(theme.edge.getHex()).toBe(0xffffff);
|
||||
expect(theme.edgeStrong.getHex()).toBe(0xffffff);
|
||||
|
||||
setAppearance({ dark: false, sun: false });
|
||||
const normal = readHoloTheme();
|
||||
expect(normal.sun).toBe(false);
|
||||
expect(normal.edge.getHex()).not.toBe(0xffffff);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readHoloTheme in sun", () => {
|
||||
it("has no chromatic stage colour: stage, edges, glass, lights and floor are neutral", () => {
|
||||
const theme = readHoloTheme("sun-dark");
|
||||
for (const key of ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const) {
|
||||
const { r, g, b } = theme[key];
|
||||
expect(Math.max(r, g, b) - Math.min(r, g, b), key).toBeLessThan(1e-6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHoloTheme", () => {
|
||||
it("re-reads the tokens when the appearance event fires after a change", async () => {
|
||||
const { result } = renderHook(() => useHoloTheme());
|
||||
const before = result.current;
|
||||
expect(before.sun).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
setAppearance({ dark: false, sun: true });
|
||||
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
expect(result.current.sun).toBe(true);
|
||||
});
|
||||
|
||||
it("follows a change of the document made elsewhere (other tab, device setting)", async () => {
|
||||
const { result } = renderHook(() => useHoloTheme());
|
||||
await act(async () => setAppearance({ dark: true, sun: true }));
|
||||
expect(result.current.sun).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the same theme between light and dark, whose holo tokens are the same (no material rebuild)", async () => {
|
||||
const { result } = renderHook(() => useHoloTheme());
|
||||
const before = result.current;
|
||||
await act(async () => {
|
||||
setAppearance({ dark: true, sun: false });
|
||||
window.dispatchEvent(new Event(APPEARANCE_EVENT));
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
|
||||
it("keeps the same theme while nothing changes (no material rebuild)", async () => {
|
||||
const { result, rerender } = renderHook(() => useHoloTheme());
|
||||
const before = result.current;
|
||||
rerender();
|
||||
await act(async () => window.dispatchEvent(new Event(APPEARANCE_EVENT)));
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Color } from "three";
|
||||
import { APPEARANCE_EVENT } from "@/lib/appearance/appearance-core";
|
||||
|
||||
// Scene colours. The holo tokens (spec 9.3) are read once from :root; the
|
||||
// material colours (honey, wax, white-hot corners) are fixed design values.
|
||||
// Lighting stays cool blue/cyan and white; warm colours only appear as
|
||||
// content (feed in focus, lifted frame looks).
|
||||
// Scene colours. The holo tokens (spec 9.3) are read from :root whenever the
|
||||
// appearance changes (T5 spec 17.8: sun swaps them live); the material colours
|
||||
// (honey, wax, white-hot corners) are fixed design values. Lighting stays cool
|
||||
// blue/cyan and white; warm colours only appear as content (feed in focus,
|
||||
// lifted frame looks).
|
||||
|
||||
export interface HoloTheme {
|
||||
/** Sun is on: black stage, white 2 px edges, no bloom, hatches instead of tints. */
|
||||
sun: boolean;
|
||||
background: Color;
|
||||
/** Electric cyan along the edges. */
|
||||
/** Electric cyan along the edges (white in sun). */
|
||||
edge: Color;
|
||||
/** Brighter edge of a hovered or focused part. */
|
||||
edgeStrong: Color;
|
||||
@@ -27,46 +32,132 @@ export interface HoloTheme {
|
||||
grid: Color;
|
||||
}
|
||||
|
||||
const FALLBACKS = {
|
||||
"--holo-bg": "#0b1224",
|
||||
"--holo-edge": "#33c2f0",
|
||||
"--holo-edge-strong": "#a9dcfd",
|
||||
} as const;
|
||||
/** What the document shows; the tokens differ per set (the holo ones only between sun and not). */
|
||||
export type HoloAppearance = "light" | "dark" | "sun-light" | "sun-dark";
|
||||
|
||||
type TokenName = keyof typeof FALLBACKS;
|
||||
type TokenName = "--holo-bg" | "--holo-edge" | "--holo-edge-strong";
|
||||
|
||||
/**
|
||||
* Resolves a CSS colour token (the holo tokens are oklch) to a hex string
|
||||
* three.js understands, by letting a 2D canvas convert it. Falls back when
|
||||
* there is no 2D context (jsdom) or the token is missing.
|
||||
*/
|
||||
export function resolveCssColor(property: TokenName): string {
|
||||
const fallback = FALLBACKS[property];
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(property).trim();
|
||||
if (!value) return fallback;
|
||||
const context = document.createElement("canvas").getContext("2d");
|
||||
if (!context) return fallback;
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
||||
/** Used where a token cannot be resolved (no 2D canvas, token missing); the sun set mirrors globals.css. */
|
||||
const FALLBACKS: Record<"normal" | "sun", Record<TokenName, string>> = {
|
||||
normal: { "--holo-bg": "#0b1224", "--holo-edge": "#33c2f0", "--holo-edge-strong": "#a9dcfd" },
|
||||
sun: { "--holo-bg": "#000000", "--holo-edge": "#ffffff", "--holo-edge-strong": "#ffffff" },
|
||||
};
|
||||
|
||||
const SERVER_APPEARANCE: HoloAppearance = "light";
|
||||
|
||||
/** The appearance the document shows right now (set by the inline script and useAppearance). */
|
||||
export function currentHoloAppearance(): HoloAppearance {
|
||||
if (typeof document === "undefined") return SERVER_APPEARANCE;
|
||||
const root = document.documentElement;
|
||||
const scheme = root.classList.contains("dark") ? "dark" : "light";
|
||||
return root.getAttribute("data-contrast") === "sun" ? `sun-${scheme}` : scheme;
|
||||
}
|
||||
|
||||
/** Reads the tokens; call once per canvas (useState initialiser). */
|
||||
export function readHoloTheme(): HoloTheme {
|
||||
const toHex = (channel: number) => channel.toString(16).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Resolves CSS colour tokens (the holo tokens are oklch) to hex strings three.js
|
||||
* understands, by letting one 2D canvas convert them. Falls back per token when
|
||||
* there is no 2D context (jsdom) or the token is missing.
|
||||
*/
|
||||
function resolveCssColors(names: readonly TokenName[], fallbacks: Record<TokenName, string>): Record<TokenName, string> {
|
||||
const resolved = { ...fallbacks };
|
||||
if (typeof document === "undefined") return resolved;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const values = names
|
||||
.map((name) => [name, style.getPropertyValue(name).trim()] as const)
|
||||
.filter(([, value]) => value !== "");
|
||||
if (values.length === 0) return resolved;
|
||||
const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return resolved;
|
||||
for (const [name, value] of values) {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = value;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
|
||||
resolved[name] = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const TOKENS: readonly TokenName[] = ["--holo-bg", "--holo-edge", "--holo-edge-strong"];
|
||||
|
||||
type StageLight = Pick<HoloTheme, "hot" | "tint" | "rim" | "bounce" | "grid">;
|
||||
|
||||
/** Cool blue light of the holo stage. */
|
||||
const HOLO_LIGHT = { hot: "#f2fbff", tint: "#7fb8e8", rim: "#4aa8ff", bounce: "#9fd8ff", grid: "#2a6fd6" } as const;
|
||||
|
||||
/** Sun: white on black, no hue anywhere on the stage (glass, lights, floor). */
|
||||
const SUN_LIGHT = { hot: "#ffffff", tint: "#8c8c8c", rim: "#d9d9d9", bounce: "#6e6e6e", grid: "#000000" } as const;
|
||||
|
||||
/** Fresh colours per theme, so no theme shares a mutable Color with another. */
|
||||
function stageLight(sun: boolean): StageLight {
|
||||
const hex = sun ? SUN_LIGHT : HOLO_LIGHT;
|
||||
return {
|
||||
background: new Color(resolveCssColor("--holo-bg")),
|
||||
edge: new Color(resolveCssColor("--holo-edge")),
|
||||
edgeStrong: new Color(resolveCssColor("--holo-edge-strong")),
|
||||
hot: new Color("#f2fbff"),
|
||||
tint: new Color("#7fb8e8"),
|
||||
rim: new Color("#4aa8ff"),
|
||||
bounce: new Color("#9fd8ff"),
|
||||
hot: new Color(hex.hot),
|
||||
tint: new Color(hex.tint),
|
||||
rim: new Color(hex.rim),
|
||||
bounce: new Color(hex.bounce),
|
||||
grid: new Color(hex.grid),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the tokens anew on every call; the scene calls it once per appearance (useHoloTheme). */
|
||||
export function readHoloTheme(appearance: HoloAppearance = currentHoloAppearance()): HoloTheme {
|
||||
const sun = appearance.startsWith("sun");
|
||||
const tokens = resolveCssColors(TOKENS, FALLBACKS[sun ? "sun" : "normal"]);
|
||||
return {
|
||||
sun,
|
||||
background: new Color(tokens["--holo-bg"]),
|
||||
edge: new Color(tokens["--holo-edge"]),
|
||||
edgeStrong: new Color(tokens["--holo-edge-strong"]),
|
||||
...stageLight(sun),
|
||||
honey: new Color("#ffb21a"),
|
||||
honeyDeep: new Color("#8a3a00"),
|
||||
fondant: new Color("#f1d79a"),
|
||||
acid: new Color("#c8f4ff"),
|
||||
grid: new Color("#2a6fd6"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes of scheme or contrast: the appearance event (this tab) and the
|
||||
* document attributes themselves (inline script, another tab, the device's
|
||||
* setting, which useAppearance applies without the event).
|
||||
*/
|
||||
function subscribeAppearance(onChange: () => void): () => void {
|
||||
window.addEventListener(APPEARANCE_EVENT, onChange);
|
||||
const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(onChange);
|
||||
observer?.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-contrast"] });
|
||||
return () => {
|
||||
window.removeEventListener(APPEARANCE_EVENT, onChange);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
const serverAppearance = (): HoloAppearance => SERVER_APPEARANCE;
|
||||
|
||||
const COLOR_KEYS = ["background", "edge", "edgeStrong", "hot", "tint", "rim", "bounce", "grid"] as const;
|
||||
|
||||
function sameHoloTheme(a: HoloTheme, b: HoloTheme): boolean {
|
||||
return a.sun === b.sun && COLOR_KEYS.every((key) => a[key].equals(b[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The holo theme of the current appearance. A new theme (and with it new
|
||||
* materials) only when scheme or contrast change; never per frame.
|
||||
*/
|
||||
export function useHoloTheme(onChange?: () => void): HoloTheme {
|
||||
const appearance = useSyncExternalStore(subscribeAppearance, currentHoloAppearance, serverAppearance);
|
||||
// Light and dark share the holo tokens: a switch between them keeps the theme (no rebuild).
|
||||
const [current, setCurrent] = useState(() => ({ appearance, theme: readHoloTheme(appearance) }));
|
||||
if (current.appearance !== appearance) {
|
||||
const next = readHoloTheme(appearance);
|
||||
setCurrent({ appearance, theme: sameHoloTheme(next, current.theme) ? current.theme : next });
|
||||
}
|
||||
const { theme } = current;
|
||||
// Frames are drawn on demand: ask for one with the new colours.
|
||||
useEffect(() => {
|
||||
onChange?.();
|
||||
}, [theme, onChange]);
|
||||
return theme;
|
||||
}
|
||||
@@ -289,7 +289,7 @@ export function stepToward(current: number, target: number, deltaSeconds: number
|
||||
}
|
||||
|
||||
/** Linear fade used at motion level `subtle` (150 ms). */
|
||||
export const SUBTLE_FADE_SECONDS = 0.15;
|
||||
export { SUBTLE_FADE_SECONDS } from "@/lib/motion/tokens";
|
||||
|
||||
export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number {
|
||||
if (seconds <= 0) return target;
|
||||
|
||||
@@ -21,7 +21,9 @@ export const SWEEP_SECONDS = 1.1;
|
||||
* material instead of rebuilding and recompiling them.
|
||||
*/
|
||||
export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolean): void {
|
||||
const { env, tier } = useSceneKit();
|
||||
const { env: studio, tier, theme } = useSceneKit();
|
||||
// Sun: the blue studio stays out of the glass; it reflects the neutral analytic studio instead.
|
||||
const env = theme.sun ? null : studio;
|
||||
const invalidate = useThree((state) => state.invalidate);
|
||||
useLayoutEffect(() => {
|
||||
setGlassEnvironment(material, env, dispersive && tier === "high" ? GLASS_DISPERSION : 0);
|
||||
@@ -29,7 +31,7 @@ export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolea
|
||||
}, [material, env, tier, dispersive, invalidate]);
|
||||
}
|
||||
|
||||
/** Travelling light pulses along the edges. */
|
||||
/** Travelling light pulses along the edges (and the canvas height the sun line width needs). */
|
||||
export function useLivingEdges(materials: ShaderMaterial[]): void {
|
||||
const { motion } = useSceneKit();
|
||||
useFrame((state) => {
|
||||
@@ -37,10 +39,18 @@ export function useLivingEdges(materials: ShaderMaterial[]): void {
|
||||
for (const material of materials) {
|
||||
setUniform(material, "uTime", state.clock.elapsedTime);
|
||||
setUniform(material, "uPulse", pulse);
|
||||
setUniform(material, "uViewHeight", state.size.height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Edges without pulses (editor drop targets): only the canvas height for the sun line width. */
|
||||
export function useEdgeViewport(materials: ShaderMaterial[]): void {
|
||||
useFrame((state) => {
|
||||
for (const material of materials) setUniform(material, "uViewHeight", state.size.height);
|
||||
});
|
||||
}
|
||||
|
||||
/** A holographic shimmer and a thin scan line run once across a part when it becomes hovered. */
|
||||
export function useHoverSweep(material: ShaderMaterial, active: boolean, height: number): void {
|
||||
const { motion } = useSceneKit();
|
||||
|
||||
@@ -10,12 +10,14 @@ import type { SceneEditor } from "./hive-scene/scene-editor";
|
||||
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
|
||||
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||
import { MOTION } from "@/lib/motion/tokens";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
|
||||
// three, fiber and friends live only in this chunk, fetched when the card is on screen.
|
||||
const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false });
|
||||
|
||||
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: 150, off: 0 };
|
||||
// Full: the stage arrives with its own glow (Beute §9.8); subtle uses the app-wide fade token.
|
||||
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: MOTION.fadeMs, off: 0 };
|
||||
const FORCED_COLORS_QUERY = "(forced-colors: active)";
|
||||
/** First context loss remounts the scene once, the second keeps the SVG for good. */
|
||||
const MAX_CONTEXT_LOSSES = 1;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GLASS_BUTTON, GLASS_PILL, GLASS_SURFACE } from "./hive-glass";
|
||||
import { HOLO_LABEL_PILL } from "./hive-scene/hive-labels";
|
||||
|
||||
const SRC = join(__dirname, "..", "..");
|
||||
const read = (relative: string) => readFileSync(join(SRC, relative), "utf8");
|
||||
@@ -26,3 +28,26 @@ describe("hive stage roots carry .holo-stage", () => {
|
||||
expect(read("components/hive/editor/editor-stack-list.tsx")).not.toMatch(/outline-holo-edge-strong/);
|
||||
});
|
||||
});
|
||||
|
||||
/** Beute spec 9.3 / T5 spec 17.6: in sun the labels grow to 16 px and nothing on the stage blurs. */
|
||||
describe("the stage in sun", () => {
|
||||
const classes = (value: string) => value.split(/\s+/);
|
||||
|
||||
it("labels are 16 px and without backdrop blur in sun", () => {
|
||||
const label = classes(HOLO_LABEL_PILL);
|
||||
expect(label).toContain("sun:text-base");
|
||||
expect(label).toContain("sun:backdrop-blur-none");
|
||||
// A solid plate instead of the see-through glass, without glow.
|
||||
expect(label).toContain("sun:bg-holo-label-bg");
|
||||
expect(label).toContain("sun:shadow-none");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["GLASS_SURFACE", GLASS_SURFACE],
|
||||
["GLASS_BUTTON", GLASS_BUTTON],
|
||||
["GLASS_PILL", GLASS_PILL],
|
||||
])("%s drops blur and glow in sun", (_name, value) => {
|
||||
expect(classes(value)).toContain("sun:backdrop-blur-none");
|
||||
expect(classes(value)).toContain("sun:shadow-none");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user