Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
bab8f56
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
885551e
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
26055ed
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
2d678ee
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
cb92397
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
1ce6638
test(ui): pin tooltip locale and reduced-motion resilience
seonghobae Sep 9, 2026
8cb4eea
fix(ui): bound tooltip expansion and reduced motion
seonghobae Sep 9, 2026
14aa73b
docs(ui): record disabled tooltip interaction boundary
seonghobae Sep 9, 2026
e31f32e
docs(ui): doctor disabled tooltip accessibility boundary
seonghobae Sep 9, 2026
c3ae336
test(ui): verify Base UI tooltip trigger forwarding
seonghobae Sep 9, 2026
fc5ba90
docs(ui): trace Base UI trigger forwarding evidence
seonghobae Sep 9, 2026
99d4768
repair(ui): return formatter prerequisite to canonical owner
seonghobae Sep 9, 2026
0d877be
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 9, 2026
7b4378f
chore(ui): restore tooltip lane single-writer boundary
seonghobae Sep 10, 2026
8ab0bb9
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 10, 2026
3ebcacc
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 10, 2026
3810949
chore(ui): adopt canonical formatter prerequisite
seonghobae Sep 10, 2026
c73246f
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 10, 2026
0bb7c31
docs(ui): record tooltip reference security boundary
seonghobae Sep 10, 2026
4e7f0ef
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 10, 2026
95eedd1
docs(ui): restore tooltip security boundary
seonghobae Sep 11, 2026
1692ef2
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
ed8c5de
docs(ui): restore tooltip security boundary
seonghobae Sep 11, 2026
ee94034
docs(ui): pin tooltip security-note preservation
seonghobae Sep 11, 2026
1018c0a
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
83dd4f9
docs(tooltip): restore reviewed security boundary
seonghobae Sep 11, 2026
0496859
docs(tooltip): restore regeneration guardrail
seonghobae Sep 11, 2026
e329c9f
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
37eb72d
docs(security): restore tooltip trust boundary
seonghobae Sep 11, 2026
fe530c2
docs(palette): preserve tooltip security boundary
seonghobae Sep 11, 2026
65bd7f5
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
5e240de
docs(security): restore Tooltip trust-boundary evidence
seonghobae Sep 11, 2026
c9e5f26
docs(governance): preserve Tooltip security regeneration note
seonghobae Sep 11, 2026
096db0d
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
a4a0200
docs(security): structure Tooltip trust-boundary statement
seonghobae Sep 11, 2026
105ee5b
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
1257889
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 11, 2026
3d92e71
🎨 Palette: 접근성 향상을 위한 툴팁 개선
seonghobae Sep 19, 2026
a1b6855
🎨 Palette: 접근성 향상을 위한 툴팁 개선
seonghobae Sep 20, 2026
353fb66
🎨 Palette: 접근성 향상을 위한 툴팁 개선
seonghobae Sep 20, 2026
c30c478
ui: consolidate App tooltip deltas into canonical owner
seonghobae Sep 21, 2026
c64959c
test(ui): lock unavailable Save help contract
seonghobae Sep 21, 2026
c53d303
fix(ui): describe unavailable Save without native title
seonghobae Sep 21, 2026
5e88aef
docs(ui): trace unavailable Save accessibility contract
seonghobae Sep 21, 2026
ba88931
docs(ui): ground Save description in WAI-ARIA
seonghobae Sep 21, 2026
6b88c72
test(ui): absorb App tooltip title evidence
seonghobae Sep 22, 2026
5589cbd
🎨 Palette: Add tooltips explaining disabled Settings and Help buttons
seonghobae Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2024-05-19 - Replace HTML disabled with aria-disabled="true" for Accessible Tooltips
**Learning:** Native HTML `disabled` attributes completely hide elements from screen readers and block all pointer/hover events, preventing tooltips from functioning for disabled elements.
**Action:** Replace `disabled` with `aria-disabled="true"`, enforce block click handlers via `e.preventDefault()`, and add a title tooltip directly to the element to maintain full tooltip accessibility and keyboard focus support for visually impaired and mouse users.
## 2026-09-09 - Accessible Tooltips on Disabled Elements
**Learning:** `aria-disabled="true"` exposes unavailable state but does not suppress activation. Icon-only controls therefore need an accessible name independent of the tooltip, and unavailable activation must still be blocked in code.
**Action:** Keep unavailable icon buttons focusable with `aria-disabled="true"`, give the trigger an `aria-label` matching the Tooltip text, and route pointer or keyboard-generated clicks through `preventUnavailableAction`. Use the design-system Tooltip only to explain the unavailable state; do not log or expose user-controlled values through tooltip copy.

## 2026-09-11 - Preserve doctoring security boundaries during regeneration
**Learning:** This Tooltip slice cites external MDN, W3C, and Base UI documentation. Two later branch regenerations removed the reviewed `Security Notes` paragraph even though the runtime trust/network boundary had not changed, repeatedly reopening the same governance finding.
**Action:** When updating `docs/doctoring/sidebar-disabled-tooltips.md`, preserve its `Security Notes` section whenever external references remain. The note must keep those URLs documentation-only and state that this Tooltip slice adds no runtime fetch/navigation, subprocess, IPC, updater, model-download, credential, or trust-boundary path. If that security boundary truly changes, update the note and tests/review evidence explicitly rather than deleting it as formatting or generated-copy cleanup.
4 changes: 2 additions & 2 deletions apps/desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ describe("App", () => {
for (const name of ["Import", "Export"]) {
const navButton = within(primaryNav).getByRole("button", { name });
expect(navButton).toHaveAttribute("aria-disabled", "true");
expect(navButton).toHaveAttribute("title", "Coming soon");
expect(navButton).not.toHaveAttribute("title");
expect(navButton).not.toBeDisabled();
}
fireEvent.click(within(primaryNav).getByRole("button", { name: "Import" }));
Expand All @@ -236,7 +236,7 @@ describe("App", () => {
for (const name of ["Import", "Export"]) {
const navButton = within(compactNav).getByRole("button", { name: `${name} compact view` });
expect(navButton).toHaveAttribute("aria-disabled", "true");
expect(navButton).toHaveAttribute("title", "Coming soon");
expect(navButton).not.toHaveAttribute("title");
expect(navButton).not.toBeDisabled();
}
fireEvent.click(within(compactNav).getByRole("button", { name: "Import compact view" }));
Expand Down
188 changes: 122 additions & 66 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
X,
type LucideIcon,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
SUPPORTED_AUDIO_FORMATS,
type AnalysisJobRequest,
Expand Down Expand Up @@ -565,25 +566,43 @@ export function App() {
const { label, enabled, active, title } = navButtonState(item);
const { icon: Icon, view } = item;

return (
const buttonClass = `flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`;

const buttonContent = (
<>
<Icon className="size-5" aria-hidden="true" />
{label}
</>
);

return enabled ? (
<button
key={item.labelKey}
type="button"
aria-current={active ? "page" : undefined}
aria-disabled={enabled ? undefined : true}
title={title}
onClick={enabled && view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={`flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white shadow-[0_12px_30px_rgba(37,99,235,0.32)]"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
onClick={view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={buttonClass}
>
<Icon className="size-5" aria-hidden="true" />
{label}
{buttonContent}
</button>
) : (
<Tooltip key={item.labelKey}>
<TooltipTrigger
type="button"
aria-disabled="true"
onClick={blockInactiveNavActivation}
className={buttonClass}
>
{buttonContent}
</TooltipTrigger>
{title && <TooltipContent>{title}</TooltipContent>}
</Tooltip>
);
})}
</nav>
Expand Down Expand Up @@ -611,26 +630,30 @@ export function App() {
</div>

<div className="flex items-center justify-between text-slate-400">
<button
type="button"
aria-disabled={true}
aria-label={t("settingsComingSoon")}
title={t("settingsComingSoon")}
onClick={preventUnavailableAction}
className="inline-flex cursor-not-allowed items-center justify-center rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<Settings className="size-5" aria-hidden="true" />
</button>
<button
type="button"
aria-disabled={true}
aria-label={t("helpComingSoon")}
title={t("helpComingSoon")}
onClick={preventUnavailableAction}
className="inline-flex cursor-not-allowed items-center justify-center rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<CircleHelp className="size-5" aria-hidden="true" />
</button>
<Tooltip>
<TooltipTrigger
type="button"
aria-disabled="true"
aria-label={t("settingsComingSoon")}
onClick={preventUnavailableAction}
className="inline-flex cursor-not-allowed items-center justify-center rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<Settings className="size-5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t("settingsComingSoon")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
type="button"
aria-disabled="true"
aria-label={t("helpComingSoon")}
onClick={preventUnavailableAction}
className="inline-flex cursor-not-allowed items-center justify-center rounded-xl p-2 text-slate-600 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
>
<CircleHelp className="size-5" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t("helpComingSoon")}</TooltipContent>
</Tooltip>
</div>
</div>
</aside>
Expand All @@ -641,26 +664,45 @@ export function App() {
const { label, enabled, active, title } = navButtonState(item);
const { icon: Icon, view } = item;

return (
const buttonClass = `inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`;

const buttonContent = (
<>
<Icon className="size-4" aria-hidden="true" />
{label}
</>
);

return enabled ? (
<button
key={item.labelKey}
type="button"
aria-current={active ? "page" : undefined}
aria-label={`${label} ${t("compactViewSuffix")}`}
aria-disabled={enabled ? undefined : true}
title={title}
onClick={enabled && view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={`inline-flex min-h-10 shrink-0 items-center gap-2 rounded-xl px-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 ${
active
? "bg-blue-600/70 text-white"
: enabled
? "text-slate-200 hover:bg-white/5"
: "cursor-not-allowed text-slate-500 opacity-70"
}`}
onClick={view ? () => handleNavSelect(view) : blockInactiveNavActivation}
className={buttonClass}
>
<Icon className="size-4" aria-hidden="true" />
{label}
{buttonContent}
</button>
) : (
<Tooltip key={item.labelKey}>
<TooltipTrigger
type="button"
aria-label={`${label} ${t("compactViewSuffix")}`}
aria-disabled="true"
onClick={blockInactiveNavActivation}
className={buttonClass}
>
{buttonContent}
</TooltipTrigger>
{title && <TooltipContent>{title}</TooltipContent>}
</Tooltip>
);
})}
</nav>
Expand Down Expand Up @@ -709,15 +751,17 @@ export function App() {
aria-describedby={selectionError && selectionErrorSource === "youtube" ? "selection-error" : undefined}
/>
{youtubeUrl && !analysisInFlight && !isStarting && !isImporting ? (
<button
type="button"
onClick={handleClearYoutubeUrl}
className="absolute right-1 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-slate-400 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-label={t("clearYoutubeUrl")}
title={t("clearYoutubeUrl")}
>
<X className="size-4" aria-hidden="true" />
</button>
<Tooltip>
<TooltipTrigger
type="button"
onClick={handleClearYoutubeUrl}
className="absolute right-1 top-1/2 inline-flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-slate-400 transition hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300"
aria-label={t("clearYoutubeUrl")}
>
<X className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t("clearYoutubeUrl")}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
Expand Down Expand Up @@ -756,17 +800,29 @@ export function App() {
{t("saveProject")}
</Button>
) : (
<Button
aria-disabled="true"
title={t("saveRequiresAnalysis")}
onClick={preventUnavailableAction}
variant="outline"
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100"
aria-label={t("saveProject")}
>
<Save className="mr-2 size-4" aria-hidden="true" />
{t("saveProject")}
</Button>
<>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-disabled="true"
aria-describedby="save-project-unavailable-reason"
onClick={preventUnavailableAction}
variant="outline"
className="min-h-11 border-white/10 bg-white/5 font-semibold text-slate-100"
aria-label={t("saveProject")}
/>
}
>
<Save className="mr-2 size-4" aria-hidden="true" />
{t("saveProject")}
</TooltipTrigger>
<TooltipContent>{t("saveRequiresAnalysis")}</TooltipContent>
</Tooltip>
<span id="save-project-unavailable-reason" className="sr-only">
{t("saveRequiresAnalysis")}
</span>
</>
)}
<Button
onClick={handleStartAnalysis}
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/App.unavailableSave.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { App } from "./App";

vi.mock("./features/score/pdfjs", () => ({
configureScorePdfWorker: vi.fn(),
loadScorePdf: vi.fn(() => ({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }),
destroy: vi.fn(() => Promise.resolve())
}))
}));

it("keeps unavailable Save focusable without native-title-only help", () => {
const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("en-US");

try {
render(<App />);

const saveButton = screen.getByRole("button", { name: /save project/i });
expect(saveButton).toHaveAttribute("aria-disabled", "true");
expect(saveButton).not.toHaveAttribute("disabled");
expect(saveButton).not.toHaveAttribute("title");

const descriptionId = saveButton.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(document.getElementById(descriptionId!)).toHaveTextContent(
"Analyze a song to enable saving"
);

fireEvent.click(saveButton);
expect(saveButton).toHaveAttribute("aria-disabled", "true");
} finally {
languageSpy.mockRestore();
}
});

it("keeps App icon tooltip triggers free of native title fallbacks", () => {
const languageSpy = vi.spyOn(window.navigator, "language", "get").mockReturnValue("en-US");

try {
render(<App />);

const settingsButton = screen.getByRole("button", { name: /settings coming soon/i });
const helpButton = screen.getByRole("button", { name: /help coming soon/i });
expect(settingsButton).not.toHaveAttribute("title");
expect(helpButton).not.toHaveAttribute("title");

const youtubeInput = screen.getByRole("textbox", { name: /youtube url/i });
fireEvent.change(youtubeInput, {
target: { value: "https://youtube.com/watch?v=abc123DEF45" }
});

const clearButton = screen.getByRole("button", { name: /clear youtube url/i });
expect(clearButton).not.toHaveAttribute("title");
} finally {
languageSpy.mockRestore();
}
});
51 changes: 51 additions & 0 deletions apps/desktop/src/components/ui/tooltip.resilience.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"

describe("Tooltip resilience contract", () => {
it("keeps Base UI trigger button semantics and native props", () => {
const onClick = vi.fn()

render(
<Tooltip>
<TooltipTrigger
type="button"
aria-disabled="true"
aria-label="Settings coming soon"
className="focus-contract"
onClick={onClick}
>
Settings
</TooltipTrigger>
<TooltipContent>Settings coming soon</TooltipContent>
</Tooltip>
)

const trigger = screen.getByRole("button", { name: "Settings coming soon" })
expect(trigger.tagName).toBe("BUTTON")
expect(trigger).toHaveAttribute("type", "button")
expect(trigger).toHaveAttribute("aria-disabled", "true")
expect(trigger).toHaveClass("focus-contract")

fireEvent.click(trigger)
expect(onClick).toHaveBeenCalledTimes(1)
})

it("bounds expanded locale copy and disables decorative motion when requested", () => {
render(
<Tooltip open>
<TooltipTrigger aria-label="Settings coming soon">Settings</TooltipTrigger>
<TooltipContent>
Einstellungen sind in dieser Version noch nicht verfügbar und werden später bereitgestellt.
</TooltipContent>
</Tooltip>
)

const tooltipContent = document.querySelector('[data-slot="tooltip-content"]')
expect(tooltipContent).toBeTruthy()
expect(tooltipContent).toHaveClass("max-w-[min(20rem,calc(100vw-2rem))]")
expect(tooltipContent).toHaveClass("break-words")
expect(tooltipContent).toHaveClass("motion-reduce:transition-none")
})
})
4 changes: 2 additions & 2 deletions apps/desktop/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ function TooltipContent({
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"bg-primary text-primary-foreground w-fit rounded-md px-3 py-1.5 text-xs text-balance",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity",
"bg-primary text-primary-foreground w-fit max-w-[min(20rem,calc(100vw-2rem))] break-words rounded-md px-3 py-1.5 text-xs text-balance",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 transition-opacity motion-reduce:transition-none",
className
)}
{...props}
Expand Down
Loading