diff --git a/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx index 157d123..7a5c5e7 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/voice/page.spec.tsx @@ -213,6 +213,13 @@ describe("VoiceSettingsPage", () => { ); }); + it("has no leftover „in Vorbereitung“ heading", () => { + renderPage(); + + expect(screen.getByRole("heading", { name: "Sprachnotizen" })).toBeInTheDocument(); + expect(document.body.textContent).not.toContain("in Vorbereitung"); + }); + it("names the retention periods in words", () => { renderPage(); diff --git a/ApiCore-Frontend/src/app/(app)/settings/voice/voice-privacy-text.ts b/ApiCore-Frontend/src/app/(app)/settings/voice/voice-privacy-text.ts index c2c6c81..c254466 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/voice/voice-privacy-text.ts +++ b/ApiCore-Frontend/src/app/(app)/settings/voice/voice-privacy-text.ts @@ -31,7 +31,7 @@ export const VOICE_PRIVACY_SECTIONS: readonly PrivacySection[] = [ }, { id: "notes", - title: "Sprachnotizen (in Vorbereitung)", + title: "Sprachnotizen", paragraphs: [ `Der Ton bleibt in deinem Cluster. Er wird nach dem Abschreiben gelöscht, spätestens nach ${VOICE_RETENTION.audio}.`, `Der abgeschriebene Text geht zur Auswertung an Anthropic. Er bleibt, bis du ihn übernimmst oder verwirfst, höchstens ${VOICE_RETENTION.content}.`, diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx index 41fd797..3e07a5a 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.spec.tsx @@ -6,6 +6,8 @@ import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StandRound } from "@/lib/rounds/round-state"; import { fetchVoiceConfig } from "@/lib/voice/api"; +import { fetchColonies } from "@/lib/colonies/api"; +import { listPacks } from "@/lib/apiaries/offline-pack"; import type { VoiceRecorderProps } from "@/components/voice/voice-recorder"; import NewVoiceNotePage from "./page"; @@ -76,6 +78,11 @@ vi.mock("@/components/voice/voice-recorder", () => ({ }, })); +vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({ + ...(await importOriginal()), + listPacks: vi.fn().mockResolvedValue([]), +})); + const CONFIG = { recordingEnabled: true, extractionEnabled: true, @@ -111,6 +118,22 @@ describe("NewVoiceNotePage", () => { expect(screen.getByText("Sprich in ganzen Sätzen, nur du.")).toBeInTheDocument(); }); + it("names the colony from the offline packs when the colony list cannot be loaded", async () => { + query = new URLSearchParams({ colonyId: "c-9", date: "2026-10-14" }); + vi.mocked(fetchColonies).mockRejectedValueOnce(new TypeError("Failed to fetch")); + vi.mocked(listPacks).mockResolvedValueOnce([ + { + apiaryId: "a-1", + ownerUserId: "user-1", + savedAt: "2026-10-15T08:00:00.000Z", + pack: { colonies: [{ id: "c-9", code: "V-02", status: "ACTIVE" }] }, + }, + ] as never); + renderPage(); + + expect(await screen.findByText("V-02 · 14.10.")).toBeInTheDocument(); + }); + const daysAgo = (days: number) => toLocalIsoDate(new Date(Date.now() - days * 86_400_000)); it.each([ diff --git a/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx index 8c78977..0e823de 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/new/page.tsx @@ -6,7 +6,9 @@ import { useSearchParams } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Mic } from "lucide-react"; import { fetchColonies } from "@/lib/colonies/api"; -import { useAuth } from "@/lib/auth/auth-context"; +import { useAuth, useCurrentUserId } from "@/lib/auth/auth-context"; +import { OFFLINE_PACKS_QUERY_KEY, listPacks, livingColoniesFromPacks } from "@/lib/apiaries/offline-pack"; +import { isNetworkError } from "@/lib/http/fetch-with-timeout"; import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates"; import { useStandRound } from "@/lib/rounds/use-stand-round"; import type { StandRound } from "@/lib/rounds/round-state"; @@ -119,7 +121,17 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu const locked = busy || saved; const [savedRound, setSavedRound] = useState(null); const stand = useStandRound(roundApiaryId, colonyId); - const { data: colonies } = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); + const userId = useCurrentUserId(); + const server = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() }); + // Without network the loaded stands know the colony's code (never the shared cache: pack colonies are partial). + const isOffline = server.data === undefined && isNetworkError(server.error ?? server.failureReason); + const packs = useQuery({ + queryKey: [...OFFLINE_PACKS_QUERY_KEY, userId, "living-colonies"], + queryFn: async () => livingColoniesFromPacks(await listPacks(userId ?? "")), + enabled: isOffline && userId !== null && colonyId !== "", + networkMode: "always", + }); + const colonies = server.data ?? packs.data; const colonyCode = colonies?.find((colony) => colony.id === colonyId)?.code; const heading = colonyId ? `${colonyCode ?? "Volk"} · ${shortDay(date)}` : "Volk wählen"; diff --git a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx index 7f42721..e76a981 100644 --- a/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/hive-editor.tsx @@ -68,8 +68,9 @@ function neighbourIds(state: HiveState, id: string): string[] { return [...(after[0] ? [after[0]] : []), ...(before[0] ? [before[0]] : [])]; } +/** The row button in the stack list (the SVG shapes carry the same data attribute). */ function rowControl(id: string): HTMLElement | null { - return document.querySelector(`[data-part-id="${CSS.escape(id)}"]`); + return document.querySelector(`button[data-part-id="${CSS.escape(id)}"]`); } /** The history button that still works, else the stack heading. */ diff --git a/ApiCore-Frontend/src/components/hive/hive-svg.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-svg.spec.tsx index 074198f..0478f22 100644 --- a/ApiCore-Frontend/src/components/hive/hive-svg.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-svg.spec.tsx @@ -74,12 +74,10 @@ describe("HiveSvg", () => { expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0); }); - it("makes parts buttons with tabIndex -1 when onSelectPart is given", () => { + it("selects parts by pointer when onSelectPart is given (the outline is the keyboard path)", () => { const onSelectPart = vi.fn(); const { container } = renderSvg(); const group = container.querySelector('g[data-part-id="b2"]')!; - expect(group).toHaveAttribute("role", "button"); - expect(group).toHaveAttribute("tabindex", "-1"); fireEvent.click(group); expect(onSelectPart).toHaveBeenCalledWith("b2"); }); @@ -121,12 +119,22 @@ describe("HiveSvg", () => { const onSelectFrame = vi.fn(); const { container } = renderSvg(, store); const frame = container.querySelector("[data-frame-slot='2']")!; - expect(frame).toHaveAttribute("role", "button"); - expect(frame).toHaveAttribute("tabindex", "-1"); fireEvent.click(frame); expect(onSelectFrame).toHaveBeenCalledWith("b1", 2); }); + it("stays a pure image with handlers: no focusable or interactive descendants (axe nested-interactive)", () => { + const store = createHiveViewStore(); + store.openBox("b1"); + const { container } = renderSvg( + , + store, + ); + const image = container.querySelector("[role=img]")!; + expect(image).not.toBeNull(); + expect(image.querySelectorAll("[tabindex], [role=button]")).toHaveLength(0); + }); + it("uses currentColor based strokes and no hard-coded colours", () => { const { container } = renderSvg(); expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i); diff --git a/ApiCore-Frontend/src/components/hive/hive-svg.tsx b/ApiCore-Frontend/src/components/hive/hive-svg.tsx index 8bb09f0..46b9bd0 100644 --- a/ApiCore-Frontend/src/components/hive/hive-svg.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-svg.tsx @@ -1,7 +1,7 @@ "use client"; import { useId } from "react"; -import type { KeyboardEvent, ReactNode } from "react"; +import type { ReactNode } from "react"; import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels"; import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout"; import type { FrameType } from "@/lib/hive/types"; @@ -107,11 +107,9 @@ const HOVER_COLOR = "data-[hovered=true]:text-holo-edge-strong print:data-[hover const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]"; -function activate(event: KeyboardEvent, action: () => void): void { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - action(); -} +// The is one image (role="img"): its shapes take pointer input only and +// are neither focusable nor buttons (axe nested-interactive). The keyboard and +// screen-reader path to every part and frame is the outline tree next to it. export interface HiveSvgProps { layout: HiveLayout; @@ -141,10 +139,7 @@ function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShape ]; const interactive = onSelect ? { - role: "button" as const, - tabIndex: -1, onClick: () => onSelect(part.id), - onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)), onPointerEnter: () => onHover(part.id), onPointerLeave: () => onHover(null), } @@ -187,10 +182,7 @@ function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }: const ref = { boxId: frame.boxId, slot: frame.slot }; const interactive = onSelect ? { - role: "button" as const, - tabIndex: -1, onClick: () => onSelect(frame.boxId, frame.slot), - onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)), onPointerEnter: () => onHover(ref), onPointerLeave: () => onHover(null), } diff --git a/ApiCore-Frontend/src/components/voice/voice-capture-box.spec.tsx b/ApiCore-Frontend/src/components/voice/voice-capture-box.spec.tsx index 64ff054..4974283 100644 --- a/ApiCore-Frontend/src/components/voice/voice-capture-box.spec.tsx +++ b/ApiCore-Frontend/src/components/voice/voice-capture-box.spec.tsx @@ -120,6 +120,13 @@ describe("VoiceCaptureBox", () => { expect(details?.querySelector("b")).toBeNull(); }); + it("a READY note keeps the transcript in the page and says the panel can be opened", () => { + renderBox(); + + expect(screen.getByText("Königin gesehen, vier Brutwaben.")).toBeInTheDocument(); + expect(screen.getByText(/zum Lesen öffnen/)).toBeInTheDocument(); + }); + it("lists what could not be assigned without a button", () => { renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] }); diff --git a/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx b/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx index 27efc1b..0bee0d5 100644 --- a/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx +++ b/ApiCore-Frontend/src/components/voice/voice-capture-box.tsx @@ -132,9 +132,12 @@ export function VoiceCaptureBox({ ) : null} {capture.transcript ? ( -
+
- Gesagt wurde: +
Gesagt wurde: + + (zum Lesen öffnen) +

{capture.transcript}