feat(settings): ship search filter that opens matching groups

The settingsSearch state was declared in Phase 9c with a comment
describing the intended behaviour but no <input> bound to it. With 21
SettingsGroup instances and a 2,261-line page, finding any specific
control required scrolling and opening every group. For the
neurodivergent audience this is exactly the cognitive-load failure
the brand commits to avoiding.

Added a sticky search input at the top of the page (paired with the
"Settings" title in a single sticky header that bg-bg-matches the
page so groups don't bleed under it). Each SettingsGroup now passes
through searchMatches against its title, description, and a curated
keyword string. Parent groups inherit their children's keywords so a
search for "GPU" expands AI & Processing and the AI Assistant inner
group together.

Updated SettingsGroup to push prop changes to the DOM via $effect:
the native <details> element's open attribute is mutated by user
clicks outside Svelte's reactive graph, so a parent prop change
needs to force-sync. User-driven toggles where the prop value didn't
change keep their local state because $effect only re-runs on diff.

Verified live: baseline shows 3 groups open (Transcription, Terms &
profiles, Capture & export), "GPU" opens AI & Processing + AI
Assistant only (other 19 closed), "ritual" opens Tasks & Rituals +
Rituals only, X-button clear restores baseline.
This commit is contained in:
2026-05-07 09:13:38 +01:00
parent 7a21d0c7fc
commit 637ed89eac
2 changed files with 131 additions and 22 deletions

View File

@@ -21,6 +21,8 @@
// Plain let (not $state) — the flag is mutated only by the toggle // Plain let (not $state) — the flag is mutated only by the toggle
// handler and never has to be reactive in the template. // handler and never has to be reactive in the template.
let hasOpened = false; let hasOpened = false;
let detailsEl: HTMLDetailsElement | null = $state(null);
function handleToggle(event: Event) { function handleToggle(event: Event) {
const el = event.currentTarget as HTMLDetailsElement | null; const el = event.currentTarget as HTMLDetailsElement | null;
if (el?.open && !hasOpened) { if (el?.open && !hasOpened) {
@@ -28,9 +30,19 @@
onopen?.(); onopen?.();
} }
} }
// Push prop changes to the DOM. The native <details> open attribute
// is mutated by user interaction outside Svelte's reactive graph;
// when the parent later changes the open prop (for example because a
// search filter expanded a different group), $effect force-syncs.
// User-driven toggles where the prop value did not change keep their
// local state because $effect only re-runs on prop diff.
$effect(() => {
if (detailsEl && detailsEl.open !== open) detailsEl.open = open;
});
</script> </script>
<details {open} ontoggle={handleToggle} class="settings-group border-t border-border-subtle"> <details bind:this={detailsEl} {open} ontoggle={handleToggle} class="settings-group border-t border-border-subtle">
<summary <summary
class="flex items-start gap-2 cursor-pointer list-none py-3 px-1 rounded hover:bg-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" class="flex items-start gap-2 cursor-pointer list-none py-3 px-1 rounded hover:bg-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
> >

View File

@@ -17,7 +17,7 @@
import { toasts } from "$lib/stores/toasts.svelte.js"; import { toasts } from "$lib/stores/toasts.svelte.js";
import { clampTextLines } from "$lib/utils/textMeasure.js"; import { clampTextLines } from "$lib/utils/textMeasure.js";
import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js"; import { bodyPretextLineHeight, pretextFontShorthand } from "$lib/utils/accessibilityTypography.js";
import { Check, ChevronRight } from "lucide-svelte"; import { Check, ChevronRight, Search, X } from "lucide-svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n"; import { SUPPORTED_LOCALES, setLocale, currentLocale } from "$lib/i18n";
// Aliased because SettingsPage has its own local refreshLlmStatus // Aliased because SettingsPage has its own local refreshLlmStatus
@@ -403,9 +403,19 @@
// default (matches the prior accordion behaviour where it was the // default (matches the prior accordion behaviour where it was the
// landing section). // landing section).
// Search filter (Phase 9c). Empty string = no filter; non-empty string // Search filter. Empty string = no filter and groups use their default
// pre-opens any group whose title/keywords match (case-insensitive). // open state. Non-empty string force-opens any SettingsGroup whose
// title, description, or hand-curated keywords contain the query
// (case-insensitive). Parent groups pass their children's keywords
// through so a match on "GPU" inside AI Assistant also opens AI &
// Processing.
let settingsSearch = $state(""); let settingsSearch = $state("");
let searchActive = $derived(settingsSearch.trim().length > 0);
let searchQuery = $derived(settingsSearch.trim().toLowerCase());
function searchMatches(...terms) {
if (!searchActive) return false;
return terms.some((t) => (t || "").toString().toLowerCase().includes(searchQuery));
}
let settingsTextFont = $derived(pretextFontShorthand(prefs.accessibility, 11)); let settingsTextFont = $derived(pretextFontShorthand(prefs.accessibility, 11));
let settingsPathLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 11)); let settingsPathLineHeight = $derived(bodyPretextLineHeight(prefs.accessibility, 11));
let outputFolderPreview = $derived.by(() => { let outputFolderPreview = $derived.by(() => {
@@ -965,10 +975,37 @@
</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">
<!-- Title --> <!-- Title + sticky search. The header stays at the top of the scroll
<h2 class="font-display text-[26px] italic text-text px-7 pt-6 pb-5">Settings</h2> container so the search input is reachable from any depth. The
solid bg-bg matches the page so groups don't bleed under it. -->
<div class="sticky top-0 z-10 bg-bg pt-6 pb-3 px-7 border-b border-border-subtle">
<div class="flex items-center justify-between gap-4 mb-3">
<h2 class="font-display text-[26px] italic text-text">Settings</h2>
</div>
<div class="relative max-w-md">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
<input
type="search"
placeholder="Search settings"
bind:value={settingsSearch}
class="w-full bg-bg-input border border-border rounded-lg pl-9 pr-9 py-2 text-[13px] text-text placeholder:text-text-tertiary
focus:outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(201,133,85,0.1)]"
aria-label="Search settings"
/>
{#if searchActive}
<button
type="button"
onclick={() => (settingsSearch = "")}
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded text-text-tertiary hover:text-text hover:bg-hover"
aria-label="Clear search"
>
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
</div>
<div class="px-7 pb-8"> <div class="px-7 pt-5 pb-8">
<Card> <Card>
<!-- <!--
Phase 9c progressive-disclosure restructure. Seven top-level Phase 9c progressive-disclosure restructure. Seven top-level
@@ -983,6 +1020,7 @@
<SettingsGroup <SettingsGroup
title="Audio" title="Audio"
description="Microphone input and capture behaviour." description="Microphone input and capture behaviour."
open={searchActive ? searchMatches('Audio', 'Microphone input capture behaviour', 'mic device gain monitoring rms vad') : false}
> >
<div class="px-1 pb-2 animate-fade-in"> <div class="px-1 pb-2 animate-fade-in">
<div class="mb-6"> <div class="mb-6">
@@ -1031,8 +1069,12 @@
<SettingsGroup <SettingsGroup
title="Vocabulary" title="Vocabulary"
description="Profile-scoped terms, prompts, and templates." description="Profile-scoped terms, prompts, and templates."
open={searchActive ? searchMatches('Vocabulary', 'Profile-scoped terms prompts templates', 'Terms profiles initial prompt', 'Profiles templates rename create delete') : false}
>
<SettingsGroup
title="Terms & profiles"
open={searchActive ? searchMatches('Terms profiles initial prompt vocabulary') : true}
> >
<SettingsGroup title="Terms & profiles" open>
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[12px] text-text-tertiary mb-4"> <p class="text-[12px] text-text-tertiary mb-4">
Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear. Vocabulary is scoped to a profile — switch profiles to swap whole vocabularies. Words and phrases the AI cleanup pass should preserve exactly. Useful for medication names, place names, jargon, names of people in your support network, anything Whisper tends to mishear. Vocabulary is scoped to a profile — switch profiles to swap whole vocabularies.
@@ -1247,7 +1289,10 @@
group because both sub-sections concern dictation vocabulary group because both sub-sections concern dictation vocabulary
and per-profile content. and per-profile content.
--> -->
<SettingsGroup title="Profiles & templates"> <SettingsGroup
title="Profiles & templates"
open={searchActive ? searchMatches('Profiles templates rename create delete') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<!-- Profiles section --> <!-- Profiles section -->
<button <button
@@ -1390,7 +1435,7 @@
<SettingsGroup <SettingsGroup
title="Transcription" title="Transcription"
description="Engine selection, models, language, and output formatting." description="Engine selection, models, language, and output formatting."
open open={searchActive ? searchMatches('Transcription', 'Engine selection models language output formatting', 'whisper parakeet engine model size british english formatting filler smart raw clean tier timestamps') : true}
> >
<div class="animate-fade-in"> <div class="animate-fade-in">
@@ -1581,8 +1626,12 @@
title="AI & Processing" title="AI & Processing"
description="Local LLM tier, cleanup post-processing, and rules." description="Local LLM tier, cleanup post-processing, and rules."
onopen={onAiSectionOpen} onopen={onAiSectionOpen}
open={searchActive ? searchMatches('AI Processing', 'Local LLM tier cleanup post-processing rules', 'Post-processing remove fillers british anti-hallucination', 'AI Assistant Local LLM tier model management qwen llama prewarm gpu concurrency', 'If-then rules implementation cleanup intentions') : false}
>
<SettingsGroup
title="Post-processing"
open={searchActive ? searchMatches('Post-processing remove fillers british anti-hallucination format') : false}
> >
<SettingsGroup title="Post-processing">
<div class="animate-fade-in"> <div class="animate-fade-in">
<div class="space-y-0.5"> <div class="space-y-0.5">
<Toggle <Toggle
@@ -1599,7 +1648,11 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="AI Assistant" description="Local LLM tier and model management."> <SettingsGroup
title="AI Assistant"
description="Local LLM tier and model management."
open={searchActive ? searchMatches('AI Assistant Local LLM tier model management qwen llama prewarm gpu concurrency cleanup') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4"> <p class="text-[11px] text-text-tertiary mb-4">
Local LLM for transcript cleanup, smart task extraction, and task breakdown. Runs fully offline after the model is downloaded. Local LLM for transcript cleanup, smart task extraction, and task breakdown. Runs fully offline after the model is downloaded.
@@ -1803,7 +1856,11 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="If-then rules" description="Implementation rules for the AI cleanup pass."> <SettingsGroup
title="If-then rules"
description="Implementation rules for the AI cleanup pass."
open={searchActive ? searchMatches('If-then rules implementation cleanup intentions') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<ImplementationRulesEditor /> <ImplementationRulesEditor />
</div> </div>
@@ -1819,8 +1876,13 @@
<SettingsGroup <SettingsGroup
title="Tasks & Rituals" title="Tasks & Rituals"
description="Task surface, gamification, rituals, and nudges." description="Task surface, gamification, rituals, and nudges."
open={searchActive ? searchMatches('Tasks Rituals', 'Task surface gamification rituals nudges', 'Rituals morning triage wind-down launch login autostart', 'Tasks page gamification visuals header sparkline', 'Nudges soft notifications hourly') : false}
>
<SettingsGroup
title="Rituals"
description="Morning triage, wind-down, launch at login."
open={searchActive ? searchMatches('Rituals morning triage wind-down launch login autostart') : false}
> >
<SettingsGroup title="Rituals" description="Morning triage, wind-down, launch at login.">
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4"> <p class="text-[11px] text-text-tertiary mb-4">
All off by default. Rituals only appear when you ask for them. All off by default. Rituals only appear when you ask for them.
@@ -1905,7 +1967,11 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Tasks page" description="Gamification visuals on the Tasks header."> <SettingsGroup
title="Tasks page"
description="Gamification visuals on the Tasks header."
open={searchActive ? searchMatches('Tasks page gamification visuals header sparkline') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4"> <p class="text-[11px] text-text-tertiary mb-4">
How the Tasks page surfaces progress. Always additive. How the Tasks page surfaces progress. Always additive.
@@ -1921,7 +1987,11 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Nudges" description="Soft notifications, capped per hour."> <SettingsGroup
title="Nudges"
description="Soft notifications, capped per hour."
open={searchActive ? searchMatches('Nudges soft notifications hourly') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4"> <p class="text-[11px] text-text-tertiary mb-4">
Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Magnotia. Gentle, anticipatory reminders. Capped at 3 per hour. Never fires while you're looking at Magnotia.
@@ -1958,8 +2028,14 @@
<SettingsGroup <SettingsGroup
title="Output & Capture" title="Output & Capture"
description="Clipboard, paste, audio capture, and read-aloud." description="Clipboard, paste, audio capture, and read-aloud."
open={searchActive ? searchMatches('Output Capture', 'Clipboard paste audio capture read-aloud', 'Read aloud TTS voice rate', 'Capture export clipboard paste history dictation drafts sound cues meeting') : false}
>
<SettingsGroup
title="Read aloud"
description="System TTS voice for read-aloud features."
onopen={onReadAloudOpen}
open={searchActive ? searchMatches('Read aloud TTS voice rate system') : false}
> >
<SettingsGroup title="Read aloud" description="System TTS voice for read-aloud features." onopen={onReadAloudOpen}>
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4"> <p class="text-[11px] text-text-tertiary mb-4">
Uses your operating system's built-in voices. No audio leaves the machine. Uses your operating system's built-in voices. No audio leaves the machine.
@@ -2020,7 +2096,11 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Capture & export" description="Clipboard, paste, history, and dictation drafts." open> <SettingsGroup
title="Capture & export"
description="Clipboard, paste, history, and dictation drafts."
open={searchActive ? searchMatches('Capture export clipboard paste history dictation drafts sound cues meeting auto-paste auto-copy') : true}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<div class="space-y-0.5"> <div class="space-y-0.5">
<Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" /> <Toggle bind:checked={settings.autoCopy} label="Auto-copy to clipboard" />
@@ -2130,15 +2210,24 @@
<SettingsGroup <SettingsGroup
title="Appearance & System" title="Appearance & System"
description="Theme, accessibility, hotkey, and About." description="Theme, accessibility, hotkey, and About."
open={searchActive ? searchMatches('Appearance System', 'Theme accessibility hotkey About', 'Global hotkey toggle recording shortcut', 'Appearance Theme zone font size locale dark light', 'Accessibility Reduced motion contrast typography lexend opendyslexic atkinson bionic dyslexic', 'About Engine status diagnostic report version') : false}
>
<SettingsGroup
title="Global hotkey"
description="Toggle recording from anywhere."
open={searchActive ? searchMatches('Global hotkey toggle recording shortcut') : false}
> >
<SettingsGroup title="Global hotkey" description="Toggle recording from anywhere.">
<div class="animate-fade-in"> <div class="animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p> <p class="text-[11px] text-text-tertiary mb-4">Toggle recording from anywhere. Click to change.</p>
<HotkeyRecorder /> <HotkeyRecorder />
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Appearance" description="Theme, zone, font size, locale."> <SettingsGroup
title="Appearance"
description="Theme, zone, font size, locale."
open={searchActive ? searchMatches('Appearance Theme zone font size locale dark light british cave energy reset') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<div class="mb-6"> <div class="mb-6">
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p> <p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Theme</p>
@@ -2183,13 +2272,21 @@
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Accessibility" description="Reduced motion, contrast, typography."> <SettingsGroup
title="Accessibility"
description="Reduced motion, contrast, typography."
open={searchActive ? searchMatches('Accessibility Reduced motion contrast typography lexend opendyslexic atkinson bionic dyslexic letter spacing line height') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<AccessibilityControls /> <AccessibilityControls />
</div> </div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="About" description="Engine status and diagnostic report."> <SettingsGroup
title="About"
description="Engine status and diagnostic report."
open={searchActive ? searchMatches('About Engine status diagnostic report version privacy local telemetry') : false}
>
<div class="animate-fade-in"> <div class="animate-fade-in">
<!-- Engine status --> <!-- Engine status -->
<div class="flex items-center gap-2 mb-4"> <div class="flex items-center gap-2 mb-4">