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>
This commit is contained in:
2026-05-15 13:22:13 +01:00
parent db9c50c548
commit 091780086b
3 changed files with 182 additions and 0 deletions

View File

@@ -220,6 +220,30 @@ Architectural finding: both `LumotiaStatusPill` and `LumotiaNotice` already ship
- All four tones (info / caution / danger / success) verified by the pattern itself: the visible signal is the left bar, which is `border-l-{tone}` at 100% opacity, working at any colour intensity. Caution remains a fill-only convention on cream by design. - All four tones (info / caution / danger / success) verified by the pattern itself: the visible signal is the left bar, which is `border-l-{tone}` at 100% opacity, working at any colour intensity. Caution remains a fill-only convention on cream by design.
### Phase 4a — Settings tab shell. Landed 2026-05-15.
Architectural finding: the v0.2 `SettingsPage.svelte` (2 891 LOC after Phase 4a) was already structured as eight numbered sections that align 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.
- New `LumotiaSettingsTabs.svelte` ([src/lib/ui/LumotiaSettingsTabs.svelte](../../src/lib/ui/LumotiaSettingsTabs.svelte)) implements the WAI-ARIA tabs (automatic activation) pattern. Arrow / Home / End keyboard navigation. Active tab uses the brand left-bar accent (`border-l-caution`) so it carries the same grammar as the LumotiaNotice refactor.
- `SettingsPage.svelte` gained a small reactive `isQuietware` flag, a `MutationObserver` watching `<html data-design>`, and 8 `{#if !isQuietware || activeTab === 'X'}` wrappers around the existing eight top-level `<SettingsGroup>` sections. The settings tree is unchanged; only its top-level rendering gate changed.
- v0.2 fallback behaviour: when `data-design="quietware"` is absent the tablist does not render and all eight sections stack as before. The search filter still spans every section in v0.2 mode. This is the 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 match the plan's canonical names (carving Output from Advanced and Vocabulary from Transcription).
### Phase 4b — Settings restructure to canonical tab names. Pending.
- Carve **Output** out of `7. Advanced > Output & Capture` (currently at line 2398 in the pre-Phase-4a file) into its own top-level tab.
- Carve **Vocabulary** out of `2. Transcription > Custom vocabulary` (line 1472 in the pre-Phase-4a file) into its own top-level tab.
- Rename the remainder of `2. Transcription` to be subsumed by **Output** + **Vocabulary**, or kept as legacy section pending content audit.
- Final tab order: Start Here / Models / Output / Vocabulary / Tasks / Accessibility / Privacy / Advanced (eight tabs, matching the plan).
### Phase 4c — Texture-opacity slider + high-contrast toggle. Pending.
- Texture-opacity slider in the Accessibility tab, range 0.050.08, default 0.06. Persists to existing preferences store.
- High-contrast toggle in the Accessibility tab. Toggle overrides system `prefers-contrast: more` query when set.
--- ---
## Regression diary ## Regression diary

View File

@@ -10,6 +10,7 @@
import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte"; import HotkeyRecorder from "$lib/components/HotkeyRecorder.svelte";
import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte"; import ImplementationRulesEditor from "$lib/components/ImplementationRulesEditor.svelte";
import SettingsGroup from "$lib/ui/LumotiaSettingsGroup.svelte"; import SettingsGroup from "$lib/ui/LumotiaSettingsGroup.svelte";
import SettingsTabs from "$lib/ui/LumotiaSettingsTabs.svelte";
import StatusPill from "$lib/ui/LumotiaStatusPill.svelte"; import StatusPill from "$lib/ui/LumotiaStatusPill.svelte";
import ZonePicker from "$lib/components/ZonePicker.svelte"; import ZonePicker from "$lib/components/ZonePicker.svelte";
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
@@ -1212,6 +1213,42 @@
saveTemplates(); saveTemplates();
editingTemplate = -1; editingTemplate = -1;
} }
// v0.3 Phase 4a — sectional tab nav. The full 2 791 LOC settings tree
// stays in this file; quietware mode shows ONE top-level section at a
// time via {#if !isQuietware || activeTab === 'X'} wrappers below.
// Without quietware (data-design unset on <html>) the page renders
// exactly as v0.2 did (all eight sections stacked, search filter
// active across all of them).
let isQuietware = $state(false);
let activeTab = $state("start-here");
const settingsTabs = [
{ id: "start-here", label: "Start Here" },
{ id: "transcription", label: "Transcription" },
{ id: "models", label: "Models" },
{ id: "tasks", label: "Tasks" },
{ id: "accessibility", label: "Accessibility" },
{ id: "privacy", label: "Privacy" },
{ id: "advanced", label: "Advanced" },
{ id: "help", label: "Help" },
];
let quietwareObserver: MutationObserver | null = null;
onMount(() => {
if (typeof document === "undefined") return;
const sync = () => {
isQuietware = document.documentElement.dataset.design === "quietware";
};
sync();
quietwareObserver = new MutationObserver(sync);
quietwareObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-design"],
});
});
onDestroy(() => {
quietwareObserver?.disconnect();
quietwareObserver = null;
});
</script> </script>
<div class="flex flex-col h-full overflow-y-auto animate-fade-in"> <div class="flex flex-col h-full overflow-y-auto animate-fade-in">
@@ -1253,6 +1290,18 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if isQuietware}
<!-- v0.3 Phase 4a — tab nav renders only when quietware is active.
v0.2 behaviour (stacked sections) is unchanged when the flag
is off. -->
<div class="pt-3">
<SettingsTabs
tabs={settingsTabs}
activeId={activeTab}
onSelect={(id) => (activeTab = id)}
/>
</div>
{/if}
</div> </div>
<div class="px-7 pt-5 pb-8"> <div class="px-7 pt-5 pb-8">
@@ -1298,6 +1347,7 @@
</div> </div>
</div> </div>
{#if !isQuietware || activeTab === 'start-here'}
<!-- ===================================================== <!-- =====================================================
1. Start Here — microphone, language, engine 1. Start Here — microphone, language, engine
===================================================== --> ===================================================== -->
@@ -1429,7 +1479,9 @@
</div> </div>
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'transcription'}
<!-- ===================================================== <!-- =====================================================
2. Transcription — format mode, cleanup, vocabulary 2. Transcription — format mode, cleanup, vocabulary
===================================================== --> ===================================================== -->
@@ -1837,7 +1889,9 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'models'}
<!-- ===================================================== <!-- =====================================================
3. Models — download, switch, disk-space readout 3. Models — download, switch, disk-space readout
===================================================== --> ===================================================== -->
@@ -2055,7 +2109,9 @@
</div> </div>
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'tasks'}
<!-- ===================================================== <!-- =====================================================
4. Tasks — WIP limit, energy-aware sequencing 4. Tasks — WIP limit, energy-aware sequencing
===================================================== --> ===================================================== -->
@@ -2099,7 +2155,9 @@
</div> </div>
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'accessibility'}
<!-- ===================================================== <!-- =====================================================
5. Accessibility — motion, contrast, typography 5. Accessibility — motion, contrast, typography
===================================================== --> ===================================================== -->
@@ -2112,7 +2170,9 @@
<AccessibilityControls /> <AccessibilityControls />
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'privacy'}
<!-- ===================================================== <!-- =====================================================
6. Privacy — local-only badge, AI disclosure, activation log 6. Privacy — local-only badge, AI disclosure, activation log
===================================================== --> ===================================================== -->
@@ -2197,7 +2257,9 @@
</div> </div>
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'advanced'}
<!-- ===================================================== <!-- =====================================================
7. Advanced — everything else, collapsed by default 7. Advanced — everything else, collapsed by default
===================================================== --> ===================================================== -->
@@ -2743,8 +2805,10 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
</SettingsGroup> </SettingsGroup>
{/if}
{#if !isQuietware || activeTab === 'help'}
<!-- 8. Help — first-run tutorial replay + support links. --> <!-- 8. Help — first-run tutorial replay + support links. -->
<SettingsGroup <SettingsGroup
title="Help" title="Help"
@@ -2799,6 +2863,7 @@
</div> </div>
</div> </div>
</SettingsGroup> </SettingsGroup>
{/if}
</Card> </Card>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,93 @@
<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>