fix: Feinschliff nach Browserprüfung (Sprachnotiz-Text, offline Volk, Beute-SVG)
- „Gesagt wurde:“ zeigt an, dass der Text aufklappbar ist - /voice/new nennt offline das Volk aus den Offline-Paketen - Einstellungen: Überschrift „Sprachnotizen“ ohne „in Vorbereitung“ - Beute-SVG (role=img) ohne fokussierbare Kinder (axe nested- interactive); Tastaturweg bleibt die Gliederung. Dabei behoben: der Editor fokussierte nach dem Entfernen die SVG-Form statt der Listenzeile. Gates: Frontend lint, typecheck, 3110 Tests, build. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
6c33a6de3b
commit
1763d953b6
9 files changed
+76
-23
No files matched your search
@@ -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", () => {
|
it("names the retention periods in words", () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const VOICE_PRIVACY_SECTIONS: readonly PrivacySection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "notes",
|
id: "notes",
|
||||||
title: "Sprachnotizen (in Vorbereitung)",
|
title: "Sprachnotizen",
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
`Der Ton bleibt in deinem Cluster. Er wird nach dem Abschreiben gelöscht, spätestens nach ${VOICE_RETENTION.audio}.`,
|
`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}.`,
|
`Der abgeschriebene Text geht zur Auswertung an Anthropic. Er bleibt, bis du ihn übernimmst oder verwirfst, höchstens ${VOICE_RETENTION.content}.`,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { StandRound } from "@/lib/rounds/round-state";
|
import type { StandRound } from "@/lib/rounds/round-state";
|
||||||
import { fetchVoiceConfig } from "@/lib/voice/api";
|
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 type { VoiceRecorderProps } from "@/components/voice/voice-recorder";
|
||||||
import NewVoiceNotePage from "./page";
|
import NewVoiceNotePage from "./page";
|
||||||
|
|
||||||
@@ -76,6 +78,11 @@ vi.mock("@/components/voice/voice-recorder", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/apiaries/offline-pack", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/apiaries/offline-pack")>()),
|
||||||
|
listPacks: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
recordingEnabled: true,
|
recordingEnabled: true,
|
||||||
extractionEnabled: true,
|
extractionEnabled: true,
|
||||||
@@ -111,6 +118,22 @@ describe("NewVoiceNotePage", () => {
|
|||||||
expect(screen.getByText("Sprich in ganzen Sätzen, nur du.")).toBeInTheDocument();
|
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));
|
const daysAgo = (days: number) => toLocalIsoDate(new Date(Date.now() - days * 86_400_000));
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Mic } from "lucide-react";
|
import { ArrowRight, Mic } from "lucide-react";
|
||||||
import { fetchColonies } from "@/lib/colonies/api";
|
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 { toLocalIsoDate, todayLocalIsoDate } from "@/lib/dates";
|
||||||
import { useStandRound } from "@/lib/rounds/use-stand-round";
|
import { useStandRound } from "@/lib/rounds/use-stand-round";
|
||||||
import type { StandRound } from "@/lib/rounds/round-state";
|
import type { StandRound } from "@/lib/rounds/round-state";
|
||||||
@@ -119,7 +121,17 @@ function VoiceCaptureForm({ initialColonyId, initialDate, roundApiaryId }: Captu
|
|||||||
const locked = busy || saved;
|
const locked = busy || saved;
|
||||||
const [savedRound, setSavedRound] = useState<StandRound | null>(null);
|
const [savedRound, setSavedRound] = useState<StandRound | null>(null);
|
||||||
const stand = useStandRound(roundApiaryId, colonyId);
|
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 colonyCode = colonies?.find((colony) => colony.id === colonyId)?.code;
|
||||||
|
|
||||||
const heading = colonyId ? `${colonyCode ?? "Volk"} · ${shortDay(date)}` : "Volk wählen";
|
const heading = colonyId ? `${colonyCode ?? "Volk"} · ${shortDay(date)}` : "Volk wählen";
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ function neighbourIds(state: HiveState, id: string): string[] {
|
|||||||
return [...(after[0] ? [after[0]] : []), ...(before[0] ? [before[0]] : [])];
|
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 {
|
function rowControl(id: string): HTMLElement | null {
|
||||||
return document.querySelector<HTMLElement>(`[data-part-id="${CSS.escape(id)}"]`);
|
return document.querySelector<HTMLElement>(`button[data-part-id="${CSS.escape(id)}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The history button that still works, else the stack heading. */
|
/** The history button that still works, else the stack heading. */
|
||||||
|
|||||||
@@ -74,12 +74,10 @@ describe("HiveSvg", () => {
|
|||||||
expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0);
|
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 onSelectPart = vi.fn();
|
||||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
|
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
|
||||||
const group = container.querySelector('g[data-part-id="b2"]')!;
|
const group = container.querySelector('g[data-part-id="b2"]')!;
|
||||||
expect(group).toHaveAttribute("role", "button");
|
|
||||||
expect(group).toHaveAttribute("tabindex", "-1");
|
|
||||||
fireEvent.click(group);
|
fireEvent.click(group);
|
||||||
expect(onSelectPart).toHaveBeenCalledWith("b2");
|
expect(onSelectPart).toHaveBeenCalledWith("b2");
|
||||||
});
|
});
|
||||||
@@ -121,12 +119,22 @@ describe("HiveSvg", () => {
|
|||||||
const onSelectFrame = vi.fn();
|
const onSelectFrame = vi.fn();
|
||||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
|
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
|
||||||
const frame = container.querySelector("[data-frame-slot='2']")!;
|
const frame = container.querySelector("[data-frame-slot='2']")!;
|
||||||
expect(frame).toHaveAttribute("role", "button");
|
|
||||||
expect(frame).toHaveAttribute("tabindex", "-1");
|
|
||||||
fireEvent.click(frame);
|
fireEvent.click(frame);
|
||||||
expect(onSelectFrame).toHaveBeenCalledWith("b1", 2);
|
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(
|
||||||
|
<HiveSvg layout={layout} onSelectPart={vi.fn()} onSelectFrame={vi.fn()} />,
|
||||||
|
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", () => {
|
it("uses currentColor based strokes and no hard-coded colours", () => {
|
||||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||||
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
|
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useId } from "react";
|
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 { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||||
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||||
import type { FrameType } from "@/lib/hive/types";
|
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]";
|
const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]";
|
||||||
|
|
||||||
function activate(event: KeyboardEvent, action: () => void): void {
|
// The <svg> is one image (role="img"): its shapes take pointer input only and
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
// are neither focusable nor buttons (axe nested-interactive). The keyboard and
|
||||||
event.preventDefault();
|
// screen-reader path to every part and frame is the outline tree next to it.
|
||||||
action();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HiveSvgProps {
|
export interface HiveSvgProps {
|
||||||
layout: HiveLayout;
|
layout: HiveLayout;
|
||||||
@@ -141,10 +139,7 @@ function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShape
|
|||||||
];
|
];
|
||||||
const interactive = onSelect
|
const interactive = onSelect
|
||||||
? {
|
? {
|
||||||
role: "button" as const,
|
|
||||||
tabIndex: -1,
|
|
||||||
onClick: () => onSelect(part.id),
|
onClick: () => onSelect(part.id),
|
||||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)),
|
|
||||||
onPointerEnter: () => onHover(part.id),
|
onPointerEnter: () => onHover(part.id),
|
||||||
onPointerLeave: () => onHover(null),
|
onPointerLeave: () => onHover(null),
|
||||||
}
|
}
|
||||||
@@ -187,10 +182,7 @@ function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }:
|
|||||||
const ref = { boxId: frame.boxId, slot: frame.slot };
|
const ref = { boxId: frame.boxId, slot: frame.slot };
|
||||||
const interactive = onSelect
|
const interactive = onSelect
|
||||||
? {
|
? {
|
||||||
role: "button" as const,
|
|
||||||
tabIndex: -1,
|
|
||||||
onClick: () => onSelect(frame.boxId, frame.slot),
|
onClick: () => onSelect(frame.boxId, frame.slot),
|
||||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)),
|
|
||||||
onPointerEnter: () => onHover(ref),
|
onPointerEnter: () => onHover(ref),
|
||||||
onPointerLeave: () => onHover(null),
|
onPointerLeave: () => onHover(null),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ describe("VoiceCaptureBox", () => {
|
|||||||
expect(details?.querySelector("b")).toBeNull();
|
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", () => {
|
it("lists what could not be assigned without a button", () => {
|
||||||
renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] });
|
renderBox({ unclear: ["Etwas zur Futtermenge, Menge nicht genannt"] });
|
||||||
|
|
||||||
|
|||||||
@@ -132,9 +132,12 @@ export function VoiceCaptureBox({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{capture.transcript ? (
|
{capture.transcript ? (
|
||||||
<details open={textOnly} className="rounded-lg border border-border bg-card p-3">
|
<details open={textOnly} className="group rounded-lg border border-border bg-card p-3">
|
||||||
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-medium">
|
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-medium">
|
||||||
Gesagt wurde:
|
<span>Gesagt wurde:</span>
|
||||||
|
<span className="ml-2 font-normal text-muted-foreground group-open:hidden">
|
||||||
|
(zum Lesen öffnen)
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm select-text">{capture.transcript}</p>
|
<p className="mt-2 whitespace-pre-wrap break-words text-sm select-text">{capture.transcript}</p>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
Reference in new issue
Block a user