From 35ee065acf17244665eb8d5f2add68fb00fc2826 Mon Sep 17 00:00:00 2001 From: Leonard Roussard Date: Sat, 30 May 2026 02:24:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?style(ui):=20full-product=20Emil=20design?= =?UTF-8?q?=20pass=20=E2=80=94=20tokens,=20focus,=20motion,=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation + product-wide polish following Emil Kowalski's design engineering principles. Visual/interaction only — no behavior changes. Tokens (index.css): elevation scale (shadow-card/popover/overlay), z-index scale, macOS window-control colors, modal/fade keyframes. Shared components: Button gains focus-visible ring, press feedback and a link variant; Modal gains role/aria-modal, focus trap + restore, scroll lock and an entrance animation; Card uses the elevation token (+optional interactive lift); Badge/SearchableSelect/PeriodSelector/ModelCard get focus-visible, ease-out and motion-reduce; dropdowns share the popover shadow + pop-in. Shell & pages: sidebar/onboarding/profiles no longer shift layout on active states (constant font-weight); transition-all replaced with named properties; focus-visible added to nav, list items, tabs and footer buttons; inline accent text-buttons routed through Button link variant. dupe-ignore: register the new "link" variant string (matches existing ghost/secondary entries). Co-Authored-By: Claude Opus 4.8 (1M context) --- .doctor/dupe-ignore | 1 + src/index.css | 50 +++++++++++++++ src/layout/sidebar.tsx | 26 +++++--- src/onboarding/onboarding-layout.tsx | 14 ++-- src/onboarding/steps/step-microphone.tsx | 2 +- src/profiles/list/profile-list-item.tsx | 4 +- src/profiles/llm/llm-model-selector.tsx | 4 +- src/settings/about/about-section.tsx | 15 +++-- src/settings/general/general-section.tsx | 8 ++- src/settings/llm/llm-model-download-row.tsx | 2 +- src/settings/speech/speech-model-modal.tsx | 2 +- src/shared/components/badge/badge.tsx | 2 +- .../components/button/button-size-styles.ts | 2 + .../button/button-variant-styles.ts | 3 + src/shared/components/button/button.tsx | 16 +++-- src/shared/components/card.tsx | 7 +- .../components/modal-focusable-selector.ts | 5 ++ src/shared/components/modal.tsx | 64 ++++++++++++++++--- .../components/model-card/model-card.tsx | 4 +- src/shared/components/period-selector.tsx | 4 +- .../searchable-select/searchable-select.tsx | 11 ++-- src/transcripts/transcript-item.tsx | 4 +- src/widget/idle-bar.tsx | 2 +- 23 files changed, 201 insertions(+), 51 deletions(-) create mode 100644 src/shared/components/modal-focusable-selector.ts diff --git a/.doctor/dupe-ignore b/.doctor/dupe-ignore index f160f98..c858103 100644 --- a/.doctor/dupe-ignore +++ b/.doctor/dupe-ignore @@ -23,6 +23,7 @@ MAGIC:ghost MAGIC:secondary MAGIC:accent MAGIC:default +MAGIC:link # Config key — only 2 files, not worth extracting MAGIC:sttModel diff --git a/src/index.css b/src/index.css index 6c7fd36..492b269 100644 --- a/src/index.css +++ b/src/index.css @@ -16,9 +16,30 @@ --color-warning: #f59e0b; --color-recording: #ef4444; --color-border: #e5e5e0; + + /* macOS window-control colors (was hardcoded in the sidebar). */ + --color-mac-close: #ff5f57; + --color-mac-min: #febc2e; + --color-mac-max: #28c840; + + /* Elevation scale — each pairs a hairline ring with a soft shadow so cards + and overlays blend on any background instead of showing a harsh border. + Maps to `shadow-card` / `shadow-popover` / `shadow-overlay` utilities. */ + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 0.5px rgba(0, 0, 0, 0.03); + --shadow-popover: 0 8px 28px -6px rgba(0, 0, 0, 0.16), 0 0 0 0.5px rgba(0, 0, 0, 0.04); + --shadow-overlay: 0 16px 48px -12px rgba(0, 0, 0, 0.22), 0 0 0 0.5px rgba(0, 0, 0, 0.05); } @layer base { + /* Fixed z-index scale — reference with `z-[var(--z-modal)]` etc. so stacking + order is intentional instead of a scatter of arbitrary values. */ + :root { + --z-dropdown: 100; + --z-modal: 200; + --z-tooltip: 300; + --z-toast: 400; + } + html, body { height: 100%; @@ -67,6 +88,35 @@ transform-origin: top; } + /* Backdrop fade + centered dialog entrance. */ + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes modal-in { + from { + opacity: 0; + transform: scale(0.97); + } + to { + opacity: 1; + transform: scale(1); + } + } + + .animate-fade-in { + animation: fade-in 0.15s ease-out; + } + + .animate-modal-in { + animation: modal-in 0.18s cubic-bezier(0.16, 1, 0.3, 1); + } + /* Respect the user's motion preference everywhere by default. */ @media (prefers-reduced-motion: reduce) { *, diff --git a/src/layout/sidebar.tsx b/src/layout/sidebar.tsx index f7fc58a..e88095c 100644 --- a/src/layout/sidebar.tsx +++ b/src/layout/sidebar.tsx @@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { open } from "@tauri-apps/plugin-shell"; import cn from "../shared/lib/utils/cn"; +import Button from "../shared/components/button/button"; import appVersion from "../shared/constants/app-version"; import useUpdateCheck from "../shared/hooks/use-update-check"; import sidebarNavItems from "./sidebar-nav-items"; @@ -17,12 +18,16 @@ export default function Sidebar() { >
cn( - "flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] transition-colors duration-150", + // font-medium is constant across states — only color/bg change, + // so the active item never shifts the layout. + "flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-medium", + "transition-colors duration-150 ease-out motion-reduce:transition-none", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40", isActive - ? "bg-accent-subtle text-accent font-medium" + ? "bg-accent-subtle text-accent" : "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary" ) } @@ -55,14 +64,15 @@ export default function Sidebar() {
-
v{appVersion}
+
v{appVersion}
{updateAvailable && ( - + )}
diff --git a/src/onboarding/onboarding-layout.tsx b/src/onboarding/onboarding-layout.tsx index b3d6bdc..ecd2661 100644 --- a/src/onboarding/onboarding-layout.tsx +++ b/src/onboarding/onboarding-layout.tsx @@ -50,7 +50,7 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) if (!steps) { return (
- Loading... + Loading…
); } @@ -84,8 +84,10 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) {step.label} @@ -115,7 +117,7 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) @@ -131,11 +133,11 @@ export default function OnboardingLayout({ onComplete }: OnboardingLayoutProps) +
Report an issue - +
diff --git a/src/settings/general/general-section.tsx b/src/settings/general/general-section.tsx index f925574..3991ac8 100644 --- a/src/settings/general/general-section.tsx +++ b/src/settings/general/general-section.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import Card from "../../shared/components/card"; import Toggle from "../../shared/components/toggle"; +import Button from "../../shared/components/button/button"; import { setConfig, enableAutostart, @@ -53,12 +54,13 @@ export default function GeneralSection() {
- +
diff --git a/src/settings/llm/llm-model-download-row.tsx b/src/settings/llm/llm-model-download-row.tsx index 91776a8..639a7ef 100644 --- a/src/settings/llm/llm-model-download-row.tsx +++ b/src/settings/llm/llm-model-download-row.tsx @@ -80,7 +80,7 @@ export default function LlmModelDownloadRow({
diff --git a/src/settings/speech/speech-model-modal.tsx b/src/settings/speech/speech-model-modal.tsx index 6af55cd..6c7576d 100644 --- a/src/settings/speech/speech-model-modal.tsx +++ b/src/settings/speech/speech-model-modal.tsx @@ -120,7 +120,7 @@ export default function SpeechModelModal({ > {downloading && ( )} diff --git a/src/shared/components/badge/badge.tsx b/src/shared/components/badge/badge.tsx index af97278..0ab18ec 100644 --- a/src/shared/components/badge/badge.tsx +++ b/src/shared/components/badge/badge.tsx @@ -11,7 +11,7 @@ export default function Badge({ children, variant = "default" }: BadgeProps) { return ( diff --git a/src/shared/components/button/button-size-styles.ts b/src/shared/components/button/button-size-styles.ts index 857928c..649a30f 100644 --- a/src/shared/components/button/button-size-styles.ts +++ b/src/shared/components/button/button-size-styles.ts @@ -1,4 +1,6 @@ const buttonSizeStyles: Record = { + // No box chrome — for variant="link" sitting inside flowing text. + inline: "text-sm", sm: "h-8 px-3 text-[13px] rounded-lg", md: "h-9 px-4 text-sm rounded-lg", lg: "h-11 px-6 text-sm rounded-lg", diff --git a/src/shared/components/button/button-variant-styles.ts b/src/shared/components/button/button-variant-styles.ts index 61407a1..3c9fe02 100644 --- a/src/shared/components/button/button-variant-styles.ts +++ b/src/shared/components/button/button-variant-styles.ts @@ -5,6 +5,9 @@ const buttonVariantStyles: Record = { "bg-bg-secondary text-text-primary border border-border hover:bg-bg-tertiary", destructive: "bg-error text-white hover:bg-error/90 active:bg-error/80", ghost: "text-text-secondary hover:bg-bg-secondary active:bg-bg-tertiary", + // Inline text action (e.g. "Change", "Update available"). No box — pairs + // with `size="inline"` so it sits in flowing text without button chrome. + link: "text-accent hover:text-accent-hover hover:underline underline-offset-2 rounded-sm", }; export default buttonVariantStyles; diff --git a/src/shared/components/button/button.tsx b/src/shared/components/button/button.tsx index aaa09d2..66854cf 100644 --- a/src/shared/components/button/button.tsx +++ b/src/shared/components/button/button.tsx @@ -4,8 +4,8 @@ import buttonVariantStyles from "./button-variant-styles"; import buttonSizeStyles from "./button-size-styles"; interface ButtonProps extends ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "destructive" | "ghost"; - size?: "sm" | "md" | "lg"; + variant?: "primary" | "secondary" | "destructive" | "ghost" | "link"; + size?: "inline" | "sm" | "md" | "lg"; } export default function Button({ @@ -16,12 +16,20 @@ export default function Button({ children, ...props }: ButtonProps) { + const isLink = variant === "link"; return (
+
-

About

+

About

Version diff --git a/src/settings/data/data-section.tsx b/src/settings/data/data-section.tsx index bdcde17..3515178 100644 --- a/src/settings/data/data-section.tsx +++ b/src/settings/data/data-section.tsx @@ -54,7 +54,7 @@ export default function DataSection() { return (
-

Data

+

Data

Transcript retention diff --git a/src/settings/debug-section.tsx b/src/settings/debug-section.tsx index 92ebd88..89e0cd2 100644 --- a/src/settings/debug-section.tsx +++ b/src/settings/debug-section.tsx @@ -11,7 +11,7 @@ export default function DebugSection() { return (
-

Debugging

+

Debugging

diff --git a/src/settings/general/general-section.tsx b/src/settings/general/general-section.tsx index 3991ac8..3bd004e 100644 --- a/src/settings/general/general-section.tsx +++ b/src/settings/general/general-section.tsx @@ -43,7 +43,7 @@ export default function GeneralSection() { return (
-

General

+

General

@@ -71,6 +71,7 @@ export default function GeneralSection() {
@@ -78,6 +79,7 @@ export default function GeneralSection() {
Launch at startup diff --git a/src/settings/llm/llm-section.tsx b/src/settings/llm/llm-section.tsx index 9ec4ba9..0e604d6 100644 --- a/src/settings/llm/llm-section.tsx +++ b/src/settings/llm/llm-section.tsx @@ -45,7 +45,7 @@ export default function LlmSection() { return (
-

LLM Refactoring

+

LLM Refactoring

diff --git a/src/settings/settings-page.tsx b/src/settings/settings-page.tsx index 3c8dd27..8d4522f 100644 --- a/src/settings/settings-page.tsx +++ b/src/settings/settings-page.tsx @@ -1,3 +1,4 @@ +import PageHeader from "../shared/components/page-header"; import GeneralSection from "./general/general-section"; import SpeechSection from "./speech/speech-section"; import LlmSection from "./llm/llm-section"; @@ -9,7 +10,10 @@ import DebugSection from "./debug-section"; export default function SettingsPage() { return (
-

Settings

+ diff --git a/src/settings/speech/speech-section.tsx b/src/settings/speech/speech-section.tsx index 95686ac..5a76c24 100644 --- a/src/settings/speech/speech-section.tsx +++ b/src/settings/speech/speech-section.tsx @@ -87,7 +87,7 @@ export default function SpeechSection() { return (
-

Speech Recognition

+

Speech Recognition

diff --git a/src/settings/widget/widget-section.tsx b/src/settings/widget/widget-section.tsx index a43cea0..0128aca 100644 --- a/src/settings/widget/widget-section.tsx +++ b/src/settings/widget/widget-section.tsx @@ -28,11 +28,12 @@ export default function WidgetSection() { return (
-

Widget

+

Widget

Show widget { setVisible(v); diff --git a/src/shared/components/modal-focusable-selector.ts b/src/shared/components/modal-focusable-selector.ts index 6e7d222..84d9913 100644 --- a/src/shared/components/modal-focusable-selector.ts +++ b/src/shared/components/modal-focusable-selector.ts @@ -1,5 +1,6 @@ -// Tabbable elements inside the modal — used by the focus trap. +// Tabbable elements inside the modal — used by the focus trap. Disabled form +// controls can't receive focus, so they're excluded to keep the trap accurate. const modalFocusableSelector = - 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; + 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; export default modalFocusableSelector; diff --git a/src/shared/components/modal.tsx b/src/shared/components/modal.tsx index b8ba302..a6bdb29 100644 --- a/src/shared/components/modal.tsx +++ b/src/shared/components/modal.tsx @@ -11,6 +11,12 @@ interface ModalProps { export default function Modal({ open, onClose, title, children }: ModalProps) { const panelRef = useRef(null); + // Keep the latest onClose without making it an effect dependency — otherwise + // a non-memoized onClose would re-run the effect on every render and bounce + // focus back to the trigger while the dialog is still open. + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + useEffect(() => { if (!open) return; @@ -21,14 +27,19 @@ export default function Modal({ open, onClose, title, children }: ModalProps) { const { overflow } = document.body.style; document.body.style.overflow = "hidden"; - // Move focus into the dialog. + // Move focus into the dialog: try the first focusable control, then fall + // back to the panel itself (tabIndex=-1) if that target wasn't focusable + // (e.g. hidden), so the trap always holds. const panel = panelRef.current; const focusables = panel?.querySelectorAll(FOCUSABLE); - (focusables?.[0] ?? panel)?.focus(); + focusables?.[0]?.focus(); + if (panel && !panel.contains(document.activeElement)) { + panel.focus(); + } function handleKey(e: KeyboardEvent) { if (e.key === "Escape") { - onClose(); + onCloseRef.current(); return; } // Trap Tab within the dialog. @@ -55,7 +66,7 @@ export default function Modal({ open, onClose, title, children }: ModalProps) { document.body.style.overflow = overflow; previouslyFocused?.focus(); }; - }, [open, onClose]); + }, [open]); if (!open) return null; diff --git a/src/shared/components/page-header.tsx b/src/shared/components/page-header.tsx new file mode 100644 index 0000000..7af0821 --- /dev/null +++ b/src/shared/components/page-header.tsx @@ -0,0 +1,24 @@ +import { type ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + description?: string; + /** Optional actions rendered on the right (buttons, selectors). */ + actions?: ReactNode; +} + +export default function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/shared/components/toggle.tsx b/src/shared/components/toggle.tsx index 505f12b..d7efa82 100644 --- a/src/shared/components/toggle.tsx +++ b/src/shared/components/toggle.tsx @@ -4,10 +4,12 @@ interface ToggleProps { checked: boolean; onChange: (checked: boolean) => void; label?: string; + /** Accessible name when the switch has no visible `label` (external label). */ + ariaLabel?: string; disabled?: boolean; } -export default function Toggle({ checked, onChange, label, disabled }: ToggleProps) { +export default function Toggle({ checked, onChange, label, ariaLabel, disabled }: ToggleProps) { return (