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>
94 lines
3.0 KiB
Svelte
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>
|