Files
Lumotia/src/lib/ui/LumotiaSettingsTabs.svelte
Jake 091780086b v0.3 Phase 4a: Settings tab shell
Discovers that v0.2 SettingsPage.svelte was already structured as
eight numbered sections aligning almost 1:1 with the v0.3 plan. The
cheapest path to "one section visible at a time" is therefore a
tab-shell overlay rather than a file split. Behaviour stays identical
to v0.2 when quietware is off; with quietware on, only the active
tab's section renders.

Changes.

  - NEW: src/lib/ui/LumotiaSettingsTabs.svelte. Tab list following
    the WAI-ARIA "tabs (automatic activation)" pattern. Arrow / Home /
    End keyboard navigation. Active tab uses the brand left-bar
    accent (border-l-caution) — same grammar as the LumotiaNotice
    refactor from Phase 3.

  - MODIFIED: src/lib/pages/SettingsPage.svelte. Added an isQuietware
    reactive flag, a MutationObserver watching <html data-design>,
    an activeTab state, and eight {#if !isQuietware || activeTab ===
    'X'} wrappers around the existing top-level SettingsGroup
    sections. The settings tree is unchanged; only its top-level
    rendering gate changed.

  - v0.2 fallback: when data-design="quietware" is absent the
    tablist does not render and all eight sections stack as before.
    The search filter spans every section in v0.2 mode. Smallest-
    possible behavioural change for "one section at a time".

Tab IDs in this commit map 1:1 to existing v0.2 section names
(start-here / transcription / models / tasks / accessibility /
privacy / advanced / help). Phase 4b will restructure to the plan's
canonical names (carving Output from Advanced and Vocabulary from
Transcription).

Phases 4b and 4c are logged as pending in the plan doc:
  4b. Restructure to canonical tab names (Output, Vocabulary).
  4c. Texture-opacity slider + high-contrast toggle in Accessibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:22:13 +01:00

94 lines
3.0 KiB
Svelte

<script lang="ts">
// v0.3 Phase 4a — sectional tab nav for the Settings page.
//
// Renders a horizontal tab list with keyboard navigation per the
// WAI-ARIA Authoring Practices "tabs (automatic activation)" pattern:
//
// ArrowLeft -> previous tab
// ArrowRight -> next tab
// Home -> first tab
// End -> last tab
// Tab -> moves focus out of the tablist to the active panel
//
// Active tab styled with the brand caution-bar feel (left-bar accent)
// so the same grammar shows up across the design system. Inactive
// tabs are neutral and gain hover/focus rings.
//
// Used by SettingsPage when html[data-design="quietware"] is set so
// the 2 791 LOC settings monolith renders one section at a time.
// Without quietware, the tablist does not render at all (the
// SettingsPage falls back to its v0.2 stacked-scroll layout).
import type { Snippet } from "svelte";
export interface Tab {
id: string;
label: string;
}
interface Props {
tabs: Tab[];
activeId: string;
onSelect: (id: string) => void;
ariaLabel?: string;
classes?: string;
}
let {
tabs,
activeId,
onSelect,
ariaLabel = "Settings sections",
classes = "",
}: Props = $props();
let tablistEl: HTMLDivElement | undefined = $state();
function focusTabAt(index: number) {
if (!tablistEl) return;
const buttons = tablistEl.querySelectorAll<HTMLButtonElement>('button[role="tab"]');
const target = buttons[index];
if (target) target.focus();
}
function handleKey(e: KeyboardEvent, index: number) {
let next = -1;
if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
else if (e.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
if (next < 0) return;
e.preventDefault();
onSelect(tabs[next].id);
queueMicrotask(() => focusTabAt(next));
}
</script>
<div
bind:this={tablistEl}
role="tablist"
aria-label={ariaLabel}
class="flex flex-wrap gap-1 border-b border-border-subtle pb-2 {classes}"
>
{#each tabs as tab, i (tab.id)}
{@const isActive = tab.id === activeId}
<button
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`settings-panel-${tab.id}`}
id={`settings-tab-${tab.id}`}
tabindex={isActive ? 0 : -1}
onclick={() => onSelect(tab.id)}
onkeydown={(e) => handleKey(e, i)}
class="px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors
border-l-2 border-l-transparent
text-text-secondary hover:text-text hover:bg-hover
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-info focus-visible:ring-offset-2 focus-visible:ring-offset-bg
aria-selected:text-text aria-selected:bg-bg-elevated
aria-selected:border-l-caution"
>
{tab.label}
</button>
{/each}
</div>