From 7f43290e8627726c8566bf9f0391b8a24246dc9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 12 Jul 2026 21:25:49 +0900 Subject: [PATCH 1/3] fix: associate youtube input errors --- .Jules/palette.md | 4 ++++ apps/desktop/src/App.test.tsx | 8 ++++++-- apps/desktop/src/App.tsx | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.Jules/palette.md b/.Jules/palette.md index 5c1c1698..27a2ff6b 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -37,3 +37,7 @@ ## 2026-07-02 - Inline clear buttons preserve focus **Learning:** Inline clear buttons often unmount immediately after clearing state, which can drop keyboard focus to the document body. **Action:** Move focus back to the owning input before clearing state, and cover the behavior with a DOM focus test. + +## 2026-07-12 - Accessible Form Validation Feedback +**Learning:** Visual proximity is not enough when an inline error appears near a form input; screen reader users need the failing input to reference the error programmatically. +**Action:** Add a stable `id` to the error container and set `aria-invalid` plus `aria-describedby` on the input while the error is active, then cover the association in DOM tests. diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 88a7abe3..df9645f6 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1168,13 +1168,17 @@ describe("App", () => { it("rejects malformed YouTube URL", async () => { render(); - const input = screen.getByPlaceholderText(/YouTube URL.../i); + const input = screen.getByRole("textbox", { name: /YouTube URL/i }); fireEvent.change(input, { target: { value: "not-a-url" } }); const button = screen.getByRole("button", { name: /Import YouTube/i }); fireEvent.click(button); await waitFor(() => { - expect(screen.getByText(/Failed to import YouTube URL./i)).toBeTruthy(); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent(/Failed to import YouTube URL./i); + expect(alert).toHaveAttribute("id", "selection-error"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(input).toHaveAttribute("aria-describedby", "selection-error"); }); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index afe103b2..4ada458e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -692,6 +692,8 @@ export function App() { disabled={analysisInFlight || isStarting || isImporting} className="h-10 w-full border-0 bg-transparent pr-9 text-slate-100 placeholder:text-slate-500 focus-visible:ring-cyan-300" aria-label={t("youtubeUrlAriaLabel")} + aria-invalid={selectionError ? true : undefined} + aria-describedby={selectionError ? "selection-error" : undefined} /> {youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? ( - ); - })} +

- {t("localFirstDetail")} + Your rehearsal map stays on this device. Project files stay local. YouTube only leaves the app when you choose import.

- - + + Settings coming soon + + + + Help coming soon + +
-
- -
); } diff --git a/apps/desktop/src/components/ui/accordion.tsx b/apps/desktop/src/components/ui/accordion.tsx deleted file mode 100644 index 752e33a0..00000000 --- a/apps/desktop/src/components/ui/accordion.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client" - -import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" - -/** Render a vertically stacked set of collapsible sections. */ -function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { - return ( - - ) -} - -/** Render one collapsible accordion item. */ -function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { - return ( - - ) -} - -/** Render the clickable header that toggles an item open or closed. */ -function AccordionTrigger({ - className, - children, - ...props -}: AccordionPrimitive.Trigger.Props) { - return ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - - ) -} - -/** Render the collapsible content panel of an item. */ -function AccordionContent({ - className, - children, - ...props -}: AccordionPrimitive.Panel.Props) { - return ( - -
{children}
-
- ) -} - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/desktop/src/components/ui/breadcrumb.tsx b/apps/desktop/src/components/ui/breadcrumb.tsx deleted file mode 100644 index 76061293..00000000 --- a/apps/desktop/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react" -import { ChevronRight, MoreHorizontal } from "lucide-react" - -import { cn } from "@/lib/utils" - -/** Render the breadcrumb navigation landmark. */ -function Breadcrumb(props: React.ComponentProps<"nav">) { - return