Test commit for Jake's round-6 spec. Subtle 1-px inset shadow in a
softened complementary hue, applied to LumotiaNotice only. Other
test surfaces (Button.destructive, status pills, record ring, active
nav) reserved for after evaluation.
Tokens (quietware scope only, v0.2 surface unchanged):
--counterline-width: 1px
Raw complementary hues per Jake's table:
--role-info-counterline var(--color-accent) /* copper for blue */
--role-danger-counterline #6EA8FF /* soft blue for red */
--role-success-counterline #B891FF /* soft violet for green */
--role-caution-counterline #3A6BFF /* soft blue for yellow */
Tinted per-mode via color-mix:
Dark mode: 32% opacity (slightly stronger against dark surface)
Light mode: 22% opacity (whisper-quiet against cream)
HC mode: all transparent, --counterline-width: 0
Per-usage notice tokens emitted:
--notice-{role}-counterline
LumotiaNotice applies via Tailwind arbitrary value:
shadow-[inset_0_0_0_var(--counterline-width,0)_var(--notice-{tone}-counterline,transparent)]
HC contract zeroes both the width and the colour token so the 2-px
HC border carries the tactile detail instead.
Honest evaluation of the visible result.
At 1px and 22%/32% opacity the counterline is implemented
correctly but reads as whisper-quiet to the point of being almost
invisible at standard viewing zoom. Doesn't look noisy (the risk
Jake flagged), but also doesn't add perceptible tactility.
Pending Jake's call:
(a) raise opacity to ~45% dark / ~35% light for visibility,
(b) keep at current opacity for the quietness,
(c) revert and proceed Phase 5c.
Phase 5c proceeds either way; this commit does NOT block the
layout migration.
Verified.
- npm run check: 0 errors, 0 warnings across 5707 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-5 feedback flagged the muddy result of forcing one --color-caution
to be both bright yellow (signal) and accessible text (ochre). The
single-token-per-role model collapsed the visible identity. Caution
notices ended up reading as brown/khaki surfaces because color-mix(
in oklab, var(--color-caution) 8%, transparent) over cream produced
muddy results whatever the source hex.
New architecture: four-tier per-role tokens, hand-tuned -bg surfaces,
plus usage-specific notice tokens on top.
Tier model — each semantic role publishes four tokens:
signal Brand-true colour. Left bars, large icons, dots, focus rings.
Looks like the role at a glance.
ink Accessible text variant. Used ONLY when text itself must
carry the role colour. WCAG AA 4.5:1 on its surface.
border Middle value. Visible chip/notice outline; not text-safe.
bg Hand-tuned pale tint surface. NOT a color-mix derivation.
Example, light-mode caution:
--color-caution-signal #FFCD00 (bright yellow)
--color-caution-ink #7A5D00 (ochre, for small text only)
--color-caution-border #D9A900 (mid gold)
--color-caution-bg #FFF7D6 (clean pale yellow tint)
--color-{role} retained as a backward-compat alias pointing at -signal.
v0.2 callers keep working; new code reaches for the specific tier.
Usage layer — components subscribe to usage-specific tokens, not
role-tier tokens directly:
--notice-{role}-bar left bar, signal-tier
--notice-{role}-icon signal in dark, ink in light, signal in HC
--notice-{role}-border outer border colour
--notice-{role}-bg hand-tuned pale surface
LumotiaNotice reads these via Tailwind arbitrary-value classes
(bg-[var(--notice-info-bg)] etc). v0.2 fallback values defined at
:root (outside quietware blocks) using color-mix from v0.2 --color-
{role} tokens so the Notice keeps rendering sensibly when quietware
is off.
HC contract — every -bg token forces to transparent. HC notices
render text + icon + 2-px border + left bar, no body tint. Brand
atmosphere steps fully aside.
Per Jake's round-5 spec: yellow stays yellow as the visible signal,
ochre only appears as small-text ink, and brown/khaki never
represents the caution role's primary visual identity again. Same
discipline applies to red/blue/green — each role has a clean
hand-tuned -bg pale tint per theme.
Verified — visible result.
Light caution notice: bright #FFCD00 bar + signal icon + ink-darkened
small icon + pale #FFF7D6 surface + #D9A900 border. Clean,
semantically clear, no mud.
Dark caution notice: bright #FFCD00 bar + signal icon + dark warm
#29210A surface + bright yellow border. Reads as yellow at a glance.
HC modes: no body tint, strong bordered notices, semantic colours
preserved as primary identifier alongside icon + label.
npm run check: 0 errors, 0 warnings across 5707 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small phases land together for Jake's round-4 feedback. Each is
focused and reviewable. Larger Dictation/Settings/Tasks/History page
migrations are deferred to focused future sittings.
Phase 4f — semantic source / derived token architecture.
Added --semantic-{red,blue,green,yellow}-source raw-palette tokens.
These encode the role's hue identity from Jake's color.adobe.com
palette (#FF0700 / #FFCD00 / #00FF56 / #000AFF) and stay constant
across all themes. The existing --color-{danger,caution,success,info}
tokens are now documented as theme-tuned derivations meeting WCAG
AA in context. Source tokens make the brand-to-runtime relationship
traceable in code.
Added --color-{role}-bg subtle-fill tokens derived via
color-mix(in oklab, var(--color-{role}) 8%, transparent). Used by
Notice backgrounds, hover tints and focus halos. HC mode forces
-bg tokens to transparent so HC notices read as text + icon +
2-px border + left bar with no body tint.
Phase 5a — page skeleton primitive + slim notice variant.
NEW src/lib/ui/LumotiaPageSkeleton.svelte. Six-zone layout:
header (optional)
notice (optional, slim ambient state)
primary work surface (required, primaryBleed for full-bleed)
right rail (optional, lg-screen drawer)
action bar (optional, bottom)
metadata (optional, mono micro-footer)
Every zone except primary is opt-in. Foundation for the
page-skeleton sweep without forcing any page migration yet.
LumotiaNotice gained a slim prop. When true: padding collapses
(px-3 py-2 vs px-4 py-3), icon shrinks (14 vs 16), title block is
suppressed. For ambient page-level notices like browser-preview.
Phase 5b — browser-preview reclassification.
src/lib/pages/DictationPage.svelte: the browser-preview state used
to render as a red error block. That is an environment limitation,
not a failed action. Reclassified as a slim info notice with
friendlier copy:
"You're in a browser preview. Local transcription only works in
the Lumotia desktop app."
Other error states (transcription failed, needs review) still
render through the original danger-tinted block with the
technical-details affordance preserved. Only the browser-preview
path branches into LumotiaNotice tone="info" slim.
The larger Dictation page restructure (header recomposition,
Template/Extract Tasks demotion to bottom action bar, empty-state
typography) is reserved for a focused Phase 5c sitting since
DictationPage.svelte is 1 262 LOC of state-heavy code.
Verified.
- npm run check: 0 errors, 0 warnings across 5707 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-4 feedback (2026-05-15) pulled the system away from the
color.adobe.com primaries toward muted-Material values, separated
brand accent from semantic caution, and reframed high-contrast as a
behavioural rendering contract rather than a palette override.
Two phases land together because the palette revision and the HC
contract are inherently linked — HC mode needs both palette overrides
AND behavioural-token overrides to deliver "different rendering
contract" semantics.
Phase 4d palette revision.
Dark mode (was wine-aubergine #2A1620, now brown-charcoal #12100E):
--color-bg #12100E
--color-bg-elevated #1A1713
--color-bg-card #211D18
--color-bg-input #181510
--color-sidebar #0F0E0C
--color-border #4A4035
--color-border-subtle #2C261F
Brand accent (new — was unset, fell through to v0.2 amber that
competed with semantic yellow):
Dark: --color-accent #C97845 (copper)
Light: --color-accent #9D5F32 (deeper copper)
Semantic colours (muted-Material, both modes):
Dark: danger #FF8A8A caution #F2C94C success #79D59B info #8AB4F8
Light: danger #B3261E caution #7A5D00 success #1B6B3A info #2457A6
Light mode work surface (slight paper tone, not pure white):
--color-bg-card #FFFDF8 (was #FFFFFF)
--color-bg-elevated #F6F1EA
Caution fill-only-on-cream rule retires. Ochre #7A5D00 passes AA
as foreground text on cream under the muted palette, so the single-
token-per-role convention applies across all four semantic colours
in both modes.
LumotiaNotice opacity rebalance:
Background tint 10% -> 8%
Outer border neutral subtle -> role at 40%
Icon --color-text -> role colour
Left bar role at 100% (unchanged)
Title / body --color-text (unchanged)
Phase 4e HC rendering contract.
Behavioural tokens published alongside palette:
--grain-opacity 0.06 dark / 0.07 light / 0 in HC
--shadow-strength 1 default / 0 in HC
--border-width-control 1px default / 2px in HC
--focus-ring-width 2px default / 3px in HC
--panel-radius 8px (reserved for future tightening)
--quietware-texture-opacity retained as a legacy alias pointing at
--grain-opacity so Phase 4c slider keeps working through the rename.
HC palette: pure-black or pure-white base, the other for borders,
single strong-focus blue #005FCC replaces accent. Brand atmosphere
steps aside. Selector splits via :not([data-theme="light"]) so
HC-light and HC-dark variants diverge cleanly.
Verified.
- npm run check: 0 errors, 0 warnings across 5706 files.
- Plan doc updated with full before/after tables, behavioural-token
contract table, and Phase 4c / 4d / 4e log sections.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three additions land together so the slider is meaningful from day
one (instead of recording a preference that nothing consumes yet).
Static grain overlay.
- src/design-system/v0.3-quietware-tokens.css adds a fixed-position
body::after element that ships an SVG feTurbulence noise tile
repeated across the viewport. Opacity reads from
--quietware-texture-opacity. mix-blend-mode: overlay produces the
soft notebook-paper feel without obscuring content.
- pointer-events: none so clicks pass through. Active only under
html[data-design="quietware"]. High-contrast forces opacity to 0
via the existing HC palette block.
QuietwareAccessibilityControls component.
- NEW: src/lib/components/QuietwareAccessibilityControls.svelte.
Two controls, both quietware-only:
1. High-contrast toggle wires html[data-contrast="high"].
Overrides the OS prefers-contrast: more query when explicitly
on. Built on LumotiaToggle so it inherits the accessible
label + description pattern.
2. Texture-intensity slider, range 0.05 to 0.08, step 0.005,
default 0.06. Sets --quietware-texture-opacity inline on
<html>, overriding the per-mode default in the tokens CSS.
Disabled while high-contrast is on. Reset button restores
0.06.
- Persistence: localStorage keys
lumotia:quietware:high-contrast
lumotia:quietware:texture-opacity
so choices survive dev rebuilds and full restarts. Full
integration with the central Preferences store is a follow-up;
localStorage keeps Phase 4c contained.
SettingsPage Accessibility section.
- Imports the new QuietwareAccessibilityControls component.
- Renders {#if isQuietware} <QuietwareAccessibilityControls />
{/if} immediately after the existing AccessibilityControls block.
- Search filter copy extended to cover the new control vocabulary.
Verified.
- npm run check: 0 errors, 0 warnings across 5706 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Phase 3 audit found that both LumotiaStatusPill and LumotiaNotice
already shipped in v0.2 Phase 5. Phase 3 reduced to an audit-and-adapt
pass.
LumotiaStatusPill ($lib/components/StatusPill.svelte) is already
correct for quietware. Pattern: neutral pill background + neutral
label text + role colour confined to a 6x6 px dot. Colour is
supplementary, label is always perceivable. Works unchanged across
all three quietware modes. No changes shipped.
LumotiaNotice ($lib/ui/LumotiaNotice.svelte) refactored to the
left-bar accent pattern. Previously the role colour rendered on the
icon outline and on the entire surround border at 30% opacity; in
quietware light mode that left the bright #FFCD00 caution colour
faded against cream paper. New pattern:
- 4-px solid LEFT border in the tone colour, 100% opacity.
- Soft tone-tinted background (10% opacity) for ambient cue.
- Subtle neutral outer border on the other three sides.
- Icon, title and body text all use --color-text.
Visible signal lives on the large bar (works at any contrast level);
foreground text uses the always-legible neutral. Matches Material
Design "banner with leading accent" and IBM Carbon "inline
notification" guidance. ARIA semantics (role="alert" for danger,
role="status" for the rest) preserved.
Design-system-v2 preview route already imports both primitives. The
updated Notice renders correctly with the existing preview content;
no additional wiring required.
Caution remains a fill-only convention on cream by design. Any
darkened yellow reads as olive ("looks like poop", per Jake), so the
fill-only rule stays in force and this pattern delivers it cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SettingsPage is 2 791 LOC with ~95 form controls. The plan called for
section-by-section migration with selective Formsnap; the wrapper
aliases land Phase 4 made that unnecessary on the per-section level.
Because LumotiaCard / LumotiaToggle / LumotiaSettingsGroup /
LumotiaStatusPill keep the exact prop API of the underlying components,
all that's needed to migrate every existing markup site is repointing
the four local import names:
Card → $lib/ui/LumotiaCard.svelte
Toggle → $lib/ui/LumotiaToggle.svelte
SettingsGroup → $lib/ui/LumotiaSettingsGroup.svelte
StatusPill → $lib/ui/LumotiaStatusPill.svelte
The 90+ <Card>, <Toggle>, <SettingsGroup>, <StatusPill> usages compile
unchanged because the local symbols still resolve to compatible
components. Existing IA is preserved verbatim — section ordering,
SegmentedButton bindings, HotkeyRecorder, ZonePicker, ModelDownloader,
and the Phase 3 KI-05 theme bindings all stay in place.
Formsnap is intentionally NOT pulled into SettingsPage in v0.2. The
form here is a wide tree of independent settings; Superforms +
Formsnap would force a heavyweight schema layer for no observable
validation win.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This is the page Settings will inherit grammar from, so it's worth a
slightly fuller sweep than the earlier ones — without touching the
recording state machine, the SVG VisualTimer, the waveform bars, the
transcript textarea, ModelDownloader, or SpeakerButton, which are all
bespoke per the §6.3 do-not-wrap list.
- StatusPill import → LumotiaStatusPill (all use sites swapped)
- PostCaptureCard import → LumotiaPostCaptureCard
- Card import → LumotiaCard
- EmptyState import → LumotiaEmptyState
- LumotiaNotice import added; the inline `liveWarning` panel now
uses LumotiaNotice tone=caution
The danger-tinted error block (lines ~1130) was left verbatim — it
already nests a StatusPill (now LumotiaStatusPill), a <details>
disclosure, and a Dismiss button in a structure LumotiaNotice's
single-icon contract doesn't model cleanly. Phase 7's primary
output for this page is import-level coherence; behavioural
identity stays untouched.
Bespoke surfaces preserved: recording controls, VisualTimer, waveform,
transcript surface (bionic action + accessibility typography), all
hotkey wiring, all live-session state.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Targeted migration on a 1 225-LOC page. The FTS5 search input stays a
plain <input> (LumotiaCombobox needs an options list; free-text search
doesn't fit the API cleanly enough to justify a rewrite for v0.2).
Row patterns + clear-all modal stay verbatim — their bespoke ARIA and
inline arm-confirm state are core to the page's identity.
- Card import → LumotiaCard (4 use sites bulk-swapped)
- EmptyState import → LumotiaEmptyState (4 use sites)
- LumotiaButton import added for selective use in follow-up sweeps
Bespoke surfaces left verbatim: VirtualSegmentList, audio player,
clear-all type-the-word modal, tag-chip filter bar.
Per-page gate: npm run check (0/0/5704 files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrated the two ad-hoc buttons to wrappers; everything else (display-
only reflective copy, the open-loops list, the Newport shutdown
template) stays verbatim since the page is intentionally low-grammar.
- Back-arrow button → LumotiaIconButton (icon=ArrowLeft, size=sm)
- "Close" button → LumotiaButton variant=primary
Bespoke: none on this page (no recording state, no transcript surface).
Per-page gate: npm run check (0/0/5704 files). Vitest / browser-mode /
e2e baselines stay green (no behaviour change).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/routes/+layout.svelte was 537 LOC of mixed runtime, chrome and
overlay concerns. Split into three single-purpose shells under
src/lib/shell/, with +layout.svelte reduced to ~28 LOC of pure
composition.
AppRuntime (no DOM beyond <svelte:window>):
- Global hotkey dual backend (evdev / tauri-plugin-global-shortcut)
- 120ms hotkey debounce (sacred behaviour §5 #2)
- PREFERENCES_CHANGED_EVENT listener (sacred §5 #4)
- KI-05 one-shot legacy-theme migration
- Sidebar hotkeys: [ toggle, Ctrl+K, Ctrl+, (sacred §5 #10)
- Wind-down tray listener
- Meeting auto-capture poller
- Global frontend error capture
- Nudge bus + implementation intentions lifecycle
- Font-size CSS var $effect
- Window resize → sidebar auto-collapse
- Onboarding/first-run check + update check + LLM status warm-up
AppChrome (the visual shell):
- Titlebar (OS-aware via customChrome helper)
- Sidebar (recording-state-aware — sacred §5 #1 stays in
Sidebar.svelte verbatim)
- Main slot
- TaskSidebar conditional rail
AppOverlays (mounted-once globals):
- ToastViewport
- FocusTimer
- MorningTriageModal
- ResizeHandles (OS-gated)
src/lib/utils/customChrome.svelte.ts holds the single source of truth
for useCustomChrome, a module-level $state both AppChrome and
AppOverlays subscribe to. Each only ever sees one loadOsInfo() call
between them.
Secondary windows still escape via their own +layout@.svelte; the
defensive isSecondaryWindow check in +layout.svelte stays so a
direct /float, /viewer, /preview navigation through the root layout
also drops the chrome.
Phase 6 per-page gate green: npm run check (0/0/5704 files),
npm test, npm run test:browser (3/3), npm run test:e2e (16/16).
No regressions in the Phase 1 smoke baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six thin alias wrappers, same prop APIs as the underlying components.
Lets pages migrate imports from $lib/components/* to $lib/ui/* one
file at a time without touching markup, and gives Phase 5+ a place
to tighten grammar without churning every call site.
LumotiaCard → Card.svelte
LumotiaStatusPill → StatusPill.svelte
LumotiaToggle → Toggle.svelte (forwards bind:checked, bind:loading)
LumotiaSettingsGroup → SettingsGroup.svelte (typed Props for svelte-check)
LumotiaEmptyState → EmptyState.svelte
LumotiaPostCaptureCard → PostCaptureCard.svelte
Per the plan, the underlying components in src/lib/components/ are
untouched. They get retired during the per-page migrations in Phase 7
once no consumer remains.
LumotiaSettingsGroup mirrors the underlying Props interface explicitly
because Svelte 5's spread-into-typed-component caught a real missing-
`title` error during svelte-check. The mirrored interface keeps call
sites type-safe when importing via $lib/ui/.
Phase 4 per-phase gate green: npm run check (0/0/4135 files),
npm test (all green), npm run test:e2e (16/16), npm run
guard:no-skeleton (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive token grammar (no renames, no replacements):
- --color-caution (dark #e8be4a, light #a08a1f) becomes the canonical
name for tuned-amber notice surfaces in the new wrapper grammar
- --color-warning is kept as a CSS var() alias of --color-caution so
every existing text-warning / bg-warning call site stays valid
- --color-info (dark #7a9ec0, light #3d6a8a) is the soft blue-grey
signal for the new LumotiaNotice info variant
- --color-accent-environment (dark #8fae9a, light #4a7058) is an
optional sage/moss support token for empty-state illustrations
and environment-neutral status dots. NOT a brand swap — amber/
copper --color-accent stays primary
Mirrored in src/design-system/colors_and_type.css (the buildless
preview pages bypass Tailwind so the duplication is intentional).
KI-05 resolved in the same commit, per the plan:
- src/lib/types/app.ts: drop `theme` from SettingsState
- src/lib/stores/page.svelte.ts: drop `theme: "Dark"` from defaults
- src/routes/+layout.svelte: drop the migration $effect, add a
one-shot migrateLegacyTheme() on mount that copies any historical
lumotia_settings.theme into preferences.theme and strips the
legacy field. Idempotent — subsequent loads short-circuit
- src/routes/{float,viewer,preview}/+layout@.svelte: drop the same
$effect; secondary windows inherit theme via PREFERENCES_CHANGED_EVENT
- src/lib/pages/SettingsPage.svelte: both SegmentedButton bindings
(quick-settings row at :1272, Appearance group at :2606) now use
Svelte 5 function bindings ({ get, set }) backed by prefs.theme
and updatePreferences. No SegmentedButton API change
Phase 3 per-page gate green: npm run check (0/0/4129 files),
npm test (13/13), npm run test:e2e (16/16). No regressions in
the Phase 1 smoke baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 — docs/release/v0.2-frontend-overhaul.md as single source of
truth for the v0.2 frontend coherence pass. Records hard rules,
tooling pins, sacred-behaviour contract list, wrapper catalogue,
per-page migration order, verification matrix, KI-05 plan, and the
explicit "DO NOT add Skeleton" line.
Phase 1 — tooling baseline. All exact-pinned per the plan:
- @playwright/test@1.60.0 + playwright@1.60.0 + @axe-core/playwright@4.11.3
- rollup-plugin-visualizer@7.0.1 (wired behind ANALYZE=1)
- @vitest/browser@4.1.6 + @vitest/browser-playwright@4.1.6 (provider)
- vitest-browser-svelte@2.1.1 (runes-aware Svelte 5 bridge)
- cargo-nextest installed globally
New configs: playwright.config.ts (frontend-only, dev:frontend webServer,
900x700 + 1440x900 projects, visual baselines deferred), vitest.browser.config.js
(separate from jsdom suite). New scripts: test:e2e, test:e2e:ui,
test:browser, analyze, test:rust:fast, guard:no-skeleton.
guard-no-skeleton.mjs walks package.json + package-lock + src/ for any
@skeletonlabs reference and exits 1 if found — locks in the no-Skeleton
hard rule for any future agent.
Smoke baseline (tests/e2e/smoke.spec.ts): 16 tests passing across two
viewports — app loads without Tauri runtime, keyboard nav, axe scan
(color-contrast deferred to Phase 7 per docs/release/v0.1-contrast-audit.md),
light/dark theme cycle, all three sensory zones (cave/energy/reset).
10 screenshots emitted to test-results/ as non-failing artefacts.
Surfaced + fixed two browser-preview bugs while landing the baseline:
- src/lib/utils/osInfo.ts: FALLBACK_BROWSER_INFO was evaluated at SSR
module-load (navigator undefined → os: 'unknown'), and the UA check
ran before navigator.platform — so Playwright's Windows-UA Chromium
on a Linux runner detected as Windows, useCustomChrome went true,
Titlebar mounted and tripped Tauri-only APIs. Now lazy-built per
call; platform is the primary signal, UA only a fallback.
- src/lib/components/Titlebar.svelte: defensive hasTauriRuntime() guard
on every handler and the $effect. Titlebar should not crash if any
future code path mounts it without Tauri.
.gitignore: reports/, .playwright/, test-results/, playwright-report/.
Per-phase gate green: npm run check (0/0), npm test (0/0), npm run
test:e2e (16/16), npm run guard:no-skeleton (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B.10 audit of commit 5ba761a (focusTimer rehydrate startTick
invariant; Race-10). The commit landed a comment-only change at
src/lib/stores/focusTimer.svelte.ts lines 204-208:
// startTick() is REQUIRED here: tick() is the only thing that
// observes `now >= completionFlashUntil` and calls clear(). Without
// it, the completion flash would stay visible until the user
// interacts with the app. stopTick() runs via clear() once the
// flash window elapses.
A future edit could silently drop the startTick() call (the commit
acknowledged this with the comment) and the regression would only
surface on a real session: the user reopens Lumotia after a closed
expired timer, sees the completion flash, and waits for it to clear.
It never does. They click somewhere → clear() fires from the click
handler. Visible bug, but no automated gate.
The 5ba761a commit could not add a test at the time it landed because
vitest wasn't wired in the workspace — vitest scaffold landed in
commit 206ac62 the next day as Phase A.5. Now that vitest exists
(jsdom environment, .svelte.ts rune transformer, fake timers via
vi.useFakeTimers — see vite.config.js test block), the invariant is
straightforwardly testable.
Fix: new src/lib/stores/focusTimer.test.ts. The single test
`auto-clears the completion flash after the 3s window via the tick loop`:
1. Seeds localStorage with a timer started 60 s ago that lasted only
30 s — already-expired by 30 s when rehydrate runs.
2. Calls focusTimer.rehydrate(). Asserts the flash is now visible
and the timer is reported active.
3. vi.advanceTimersByTime(3_500) — pushes wall-clock past the
completionFlashUntil mark, drives the setInterval ticks.
4. Asserts showingCompletionFlash is false, active is false,
remainingMs is 0, and localStorage has been wiped.
If a future edit removes the startTick() call on the already-expired
branch of rehydrate(), step (3) won't run any ticks, the flash won't
clear, and step (4) fires the regression assertion. The test pins the
invariant the comment alone could not.
Verification:
* npm run test → 13/13 (was 12, +1 from this commit). Both test
files pass: localStorageMigration.test.ts (unchanged, 12 tests)
and the new focusTimer.test.ts (1 test).
* npm run check → 0 errors / 0 warnings across 4015 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First frontend unit test framework on Lumotia. Pinned exact versions for
supply-chain hygiene (matches the rust-toolchain.toml discipline from the
27661c8 hygiene pass and the npm audit signatures pre-flight from e4d56b8):
- vitest 4.1.6 (compatible with vite 6, supports vite 6/7/8)
- jsdom 29.1.1
Installed with `npm install --save-dev --save-exact --ignore-scripts` per
the install discipline documented in the README — the --ignore-scripts
flag blocks the postinstall vector that npm worms (Shai-Hulud,
mini-Shai-Hulud) rely on.
vite.config.js:
- Switched defineConfig import to vitest/config (superset of vite/config;
production builds ignore the `test` key).
- test.environment = "jsdom" so storage-shim tests drive real browser APIs.
- test.include scoped to src/**/*.{test,spec}.{ts,js} — colocated with
source, mirrors the Rust #[cfg(test)] sibling pattern.
- test.exclude blocks src-tauri/ (owned by cargo test).
- restoreMocks + clearMocks + unstubAllGlobals on so module-level state
can't leak between tests.
src/lib/utils/localStorageMigration.test.ts — 12 tests:
migrateLocalStorageKey:
- copies value + removes old when only old exists
- removes old + keeps new when both exist (lumotia is authoritative)
- no-op when only new exists
- no-op when neither exists
- idempotent (second call after first migrates nothing)
- preserves the value's exact bytes (no JSON round-trip)
- preserves empty-string values (distinct from null)
- survives DOMException / quota errors without re-raising
- no-op when localStorage is undefined (SSR-safe)
migrateLocalStorageKeys:
- processes pairs in order
- per-pair failure does not strand remaining pairs (resilience)
- empty pairs list is a clean no-op
package.json:
- "test": "vitest run" (one-shot, CI-friendly)
- "test:watch": "vitest" (dev loop)
README: documents `npm run test` alongside `cargo test --workspace` and
`npm run check` in the Testing section.
Verification:
- npm run test: 12/12 pass
- npm run check: 0 errors, 0 warnings (the new .ts test type-checks clean
against jsconfig.json's strict typescript settings)
Adds a Live | Trash toggle to the HistoryPage header. The Trash tab
lists soft-deleted transcripts via the new list_trashed_transcripts
Tauri command and offers per-row Restore via restore_transcript.
Permanently-delete-from-trash is intentionally deferred — the 30-day
startup purge (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs)
handles hard-removal until that UI lands. The retention policy is
surfaced in the panel header: Trashed items are kept for 30 days,
then permanently deleted on next app start.
Design notes:
- View toggle is a tablist of two pill buttons (Live | Trash); the
aria-selected attribute reflects the active mode.
- Switching to Trash triggers an effect that loads the list once.
Switching back to Live discards the trash data so stale rows
don't reappear on toggle.
- Live-mode header controls (Starred, Tag all untagged, Clear All)
are hidden in Trash mode and replaced with a Refresh button.
- Restore drops the row from the in-memory trash list rather than
splicing into history; the live store reloads from SQLite on its
own initialisation path, so we avoid drifting the in-memory shape
from the canonical source.
- The created timestamp is shown rather than the deletion
timestamp; deleted_at is not currently in the TranscriptDto and
expanding the DTO is out of scope for this fix.
- Audio files may already have been removed by delete_transcript's
best-effort filesystem cleanup, so restored text + metadata may
surface without playable audio (documented in the storage-layer
restore_transcript contract).
TODO(test): no Svelte component test framework wired in the repo
(vitest not installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior clearAll UX was a 4-second inline arm-confirm: one click on
Clear All morphed into Confirm/Cancel pills; a second click within
the window wiped every transcript. With the soft-delete backend
(commit 15b74db) now under it, the data-loss class is partially
closed — items land in Trash, not /dev/null — but an absent-minded
double-tap still soft-deletes the entire history at once.
Fix: replace the inline arm-confirm with a modal that requires the
user to type the word DELETE (case-sensitive, exact) before the
Confirm button activates. The single-transcript and bulk-selection
delete flows keep their lighter arm-confirm pattern; only the
all-at-once nuke is gated by the modal.
Modal details:
- autofocuses the input on open
- Enter submits when the word matches
- Escape closes; click-outside closes (when not in flight)
- Confirm button disabled until input == DELETE
- Clearing... spinner state while the SQLite soft-delete loop runs
- retention policy (kept for 30 days) surfaced in the body copy
- dialog role, aria-labelledby, aria-describedby, labelled input,
tabindex=-1 on the card; backdrop carries role=presentation to
keep the click-outside-to-close affordance out of the AT tree
clearAllArmed / armClearAll / disarmClearAll and their announcement
fragment are removed; bulkDeleteArmed (selection-subset) keeps the
arm-confirm pattern unchanged.
TODO(test): no Svelte component test framework wired in the repo
(vitest not installed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tauri command `write_text_file_cmd` took an arbitrary `path: String`
and flowed it straight into `tokio::fs::write`, with no main-window
guard, no canonicalisation, and no scope check. A compromised webview
could write anywhere the process had write access — overwriting shell
init files, dropping a runner into `~/.config/autostart`, etc. The
in-file comment "the dialog already constrains the user's choice"
described an intended invariant the IPC surface never enforced.
This change:
- adds `ensure_main_window(&window)?` so only the main webview can
invoke the command;
- canonicalises the requested path's parent (rejecting nonexistent
parents and resolving symlinks) before joining the filename;
- asserts the canonical target sits inside an allowlisted base
(app data, app local data, downloads, documents, desktop), so a
`"../../etc/passwd"` payload — even one obtained by symlink trickery
in the chosen save dir — is refused with a clear error.
Six unit tests cover: outside-allowlist rejection, path-traversal
rejection, nested inside-allowlist acceptance, plain inside-allowlist
acceptance, nonexistent-parent rejection, and the pure
`is_inside_any_base` prefix check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock the invariant: the already-expired branch of rehydrate() must call
startTick() so the tick loop observes now >= completionFlashUntil and
auto-clears the flash. The call was already present; this commit adds
the inline comment so future edits cannot silently drop it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move both Tauri listen() calls inside the try block with let-declared
unlisten handles. A throw on the second listen() previously leaked the
first subscription and the finally block could itself throw on an
undefined unlistenParakeet, masking the original error. Finally now
guards each handle before invoking it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Magnotia->Lumotia rebrand commit 26c7307 ran a too-greedy
s/magnotia/lumotia/g across comments that already had the correct
legacy/target distinction. The commit author caught one case in
src-tauri/src/lib.rs ("magnotia era" -> "lumotia era" restored) but
missed four others, plus a duplicated word in a Cargo description
from an earlier two-name sed.
Fixed sites:
* crates/storage/src/database.rs — migrate_legacy_setting_keys docstring
* src/lib/utils/localStorageMigration.ts — file-level docstring
* src/lib/utils/settingsMigrations.ts — historical-key comment
* crates/cloud-providers/Cargo.toml — description duplication
* src-tauri/src/lib.rs — data-dir migration log + doc
None of the underlying code paths were wrong; the lies were
docstring-only. Risk: future maintainer reading any of these
comments at incident time could invert the migration direction or
conclude no migration ran.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0 QC found two real blockers in the baseline commits:
1. crates/llm/src/lib.rs:215 — early-break guard was inverted. The
guard fired when grammar.is_some() (where grammar already enforces
shape, making the check redundant) and was disabled for
grammar.is_none() (the content-tags path that actually needs it).
Flipped to grammar.is_none() so the JSON-envelope short-circuit
fires for free-form JSON generation as the commit message
implied.
2. src/lib/pages/FilesPage.svelte — handleExport() success path had
no toast, asymmetric with DictationPage which does. Added the
toasts import and a toasts.success() call after the native save
so both export surfaces give consistent feedback.
cargo test -p magnotia-llm --lib: 21 pass. npm run check: 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DictationPage + FilesPage handleExport() now use Tauri save() +
write_text_file_cmd when in the desktop runtime; browser blob path
remains as fallback when hasTauriRuntime() is false. saveMarkdown.ts
surfaces dialog errors via toasts. Adds txt to the extension map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an async onChange handler rejects, the toggle snapped back to its
previous value silently — screen reader users had no signal that the
action failed, only that the toggle suddenly returned to its original
state. Now aria-invalid="true" exposes the failure on the role=switch
button for 1.5s after rejection, so assistive tech announces the error.
Self-clears via setTimeout; no API change for callers.
Hard-coded rgba(214,132,80,X) shadows were tied to the dark-theme accent
and stayed off-hue in light mode (where the accent is a darker #a3683a).
Six tokens added to app.css @theme and mirrored in the light-theme block
plus design-system/colors_and_type.css:
--shadow-accent-sm 0 0 8px 0.25 alpha Toggle on-state
--shadow-accent-md 0 4px 16px 0.3 ModelDownloader card
--shadow-accent-lg 0 4px 20px 0.3 DictationPage record button
--shadow-accent-glow 0 0 8px 0.4 progress-bar glow
--shadow-accent-pill 0 1px 4px 0.3 SegmentedButton pill
--shadow-accent-raised 0 2px 8px 0.2 FilesPage browse tile
Migrated seven sites: Toggle, SegmentedButton, ModelDownloader (x2),
FilesPage (x2), DictationPage record button, SettingsPage locale pill.
Focus-ring shadows (0_0_0_3px_rgba(...,0.1)) left alone — handled in
parallel via the --accent-shadow-focus token.
@font-face: app.css and colors_and_type.css both declare the same five
fonts. Duplication is unavoidable because preview/*.html loads the
design-system file directly without Vite, so it cannot share the
runtime declarations. font-weight ranges and font-style reconciled to
match exactly (Atkinson 200-800 not 400-700, JetBrains gains font-style:
normal); src URLs intentionally differ (root-absolute vs relative).
Comments added to both copies explaining the contract.
Record button bumped from 40x40 to 48x48 (WCAG 2.5.5 AA requires 44x44; 48 chosen as forgivable oversize for the most-touched control). Inner stop-square and record-circle bumped 14x14 to 16x16, Loader2 16 to 18, to keep the visual ratio.
Recording-status sr-only live region now announces both transitions: "Recording started" on start and "Recording stopped" on stop, with the stop message clearing after 1200ms so the live region does not hold stale text. Transition tracked via a plain let prevRecording (not $state) since it is bookkeeping and must not retrigger the effect. aria-atomic="true" added so the whole region re-reads on update.
Brand guidelines (docs/brand/magnotia-brand-guidelines.md §4) say "Never go
below 12px for any text" and "tertiary text must be ≥18px bold or ≥24px
regular". Audit found 246 sub-12px className sites and 166 cases of
text-text-tertiary paired with body sizes.
Bumped text-[9/10/11px] → text-[12px] across 26 .svelte files in src/lib
and src/routes. Promoted text-text-tertiary → text-text-secondary at body
sizes (12-14px) where context allowed; left placeholder pseudo-states,
ternary-conditional inactive states, and line-through "done" states alone
(8 residual pairings, all decorative).
Affects neurodivergent users on 1366×768 budget hardware most directly.
svelte-check: 0 errors, 0 warnings.
Reduces first-run cognitive load. Save / Copy / Clear / Export now appear only
once the user has a transcript or is recording, sliding in via animate-fade-in
(brand --duration-ui + ease-out-quart). Template and Extract Tasks remain
always-visible because they shape transcription rather than acting on completed
content.
Empty state now leads with the catchphrase as a brand statement
(font-display italic 28px) with the hotkey hint demoted to body-font tertiary.
EmptyState gains an optional headline prop; callers without it render unchanged.
Phase 10b colour push: theme reads as Magnotia, not "subdued generic dark".
Same lightness band as Phase 10a (AA preserved), more chroma across accent,
status tokens, and zone surfaces.
Dark accent: #c98555 -> #d68450 (subtle/hover/glow recomputed).
Light accent: #a06a3e -> #a3683a.
Dark status: success #7ec89a -> #5fc28a, danger #e87171 -> #e85f5f,
warning #e8c86e -> #e8be4a.
Light status: success #2f7549 -> #1f7344, danger #a83838 -> #b32626,
warning #b89a3e -> #a08a1f.
Sensory zones pushed further apart so each zone reads as its own
environment: cave cools toward blue-grey, energy warms toward red-orange,
reset cools toward green. AA on body text verified for every Card surface
in both themes.
Stale accent rgba (201,133,85) replaced with new (214,132,80) across nine
files. --shadow-accent in colors_and_type.css mirrored.
No secondary tint token added: Magnotia's identity is amber-as-the-only-
meaningful-colour. Cheaper to add a second meaningful colour later if a
real need appears than to dilute the discipline now.
Default surfaces, text tokens, and borders untouched per scope.
Six changes, one coherent polish pass on SettingsPage:
1. Autostart toggle dedupe. The 27-line custom button that mirrored
Toggle's markup to dodge a bind:checked race is gone. Toggle's new
async onChange + snap-back covers the OS round-trip; setLaunchAtLogin
re-throws on failure so Toggle reverts checked. autostartSyncing
state retired.
2. AI Assistant Default/Advanced split. Cleanup preset and GPU
concurrency moved into a nested SettingsGroup title="Advanced",
closed by default. The Assistant page now leads with four
first-decision controls (tier, model picker, status, prewarm) and
parks the tuning knobs behind a disclosure. Search keywords cascade
from the Advanced group up to AI Assistant up to AI & Processing.
3. Vocabulary disclosure consistency. The hand-rolled Profiles and
Templates sub-panels (with their own ChevronRight buttons +
showProfiles/showTemplates state) are now nested SettingsGroups.
The count badges live in the description slot ("3 profiles ·
custom vocabulary..."). showProfiles, showTemplates, and the
ChevronRight import retired.
4. Model-download ETA in Settings. Both whisper and LLM download
chips now show "{percent}% · {bytes} / {total} · {duration} left",
reusing formatDuration from utils/time.ts. New formatBytes +
etaSecondsFromPercent helpers in SettingsPage; bytes pulled from
the existing emit payload (DownloadProgress snake_case for whisper,
done/total for the LLM event). FirstRunPage already does the
timestamp-based ETA; we mirror its shape.
5. Privacy badge in sticky header. Single chip "100% local · no
telemetry · no cloud" added next to the Settings heading using
bg-accent-subtle + text-text-tertiary. The longer About list
stays put as the deep-dive.
6. SettingsGroup icons on the seven top-level groups. Mic, BookOpen,
Type, Sparkles, SquareCheck, Clipboard, Sliders — paired with the
existing literal text titles per the icon-with-label brand rule.
Inner nested groups stay icon-free.
Verification: npm run check returns 0 errors, 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both props default to current behaviour, so all existing call sites render
unchanged. Subtle uses bg-bg-elevated for quieter empty-state contexts;
danger uses bg-danger/5 + border-danger/30 for calm caution panels (not
red-alert loud, matching brand). Raised layers shadow-md on top of the
existing low-contrast shadow for floating or detached surfaces.
Title bumped to font-semibold so it scans first; description gets tracking-wide
plus mt-1 for cleaner rhythm. Chevron alignment shifted from mt-0.5 to mt-1 to
match the new spacing. Optional `icon` prop renders a Lucide component between
the chevron and the title block at 16x16, text-tertiary, no colour change on
open. Layout is preserved exactly when no icon is passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the shared Toggle with three optional props while keeping the
existing bind:checked path untouched for current call sites.
- disabled: dims the control, sets aria-disabled, and early-returns on click.
- loading: forces a Loader2 spinner inside the thumb, applies cursor-wait
and aria-busy, and blocks interaction. Cursor-wait wins over disabled's
not-allowed when both are set, matching loading-precedence guidance.
- onChange(next): when supplied, replaces the immediate flip. Promise
returns drive an internal pending flag so the toggle renders loading
while the handler resolves; on resolve checked flips, on reject the
toggle snaps back. Synchronous handlers flip immediately after the call.
Brand motion preserved: 150ms duration, cubic-bezier(0.2, 0.8, 0.2, 1),
no transition-all.
- Add --color-overlay-dim token to app.css @theme (rgba(15,14,12,0.7) dark, rgba(26,24,22,0.55) light); mirror as --overlay-dim in design-system/colors_and_type.css.
- MorningTriageModal: replace inlined rgba scrim with var(--color-overlay-dim), drop backdrop-blur-sm (brand opposes glassmorphism by default), bump container to z-[60] so it sits above the .grain z-50 overlay.
- FilesPage: collapse two near-identical drop-zone blocks into one bordered region with conditional inner content; preserves isDragOver hover behaviour and aria region.
Two scoped fixes to HotkeyRecorder:
- Append "Esc to cancel." to the recording prompt so the existing Escape
handler is discoverable. srStatus already mentions Escape; no harmonisation
needed.
- Replace animate-pulse-warm on the captured state with a static
bg-success/10 + border-success/40 flash. pulse-warm is the
recording-active keyframe; reusing it for the saved state muddled the
visual vocabulary. The global * transition rule already animates
background-color and border-color over --duration-ui (150ms) with the
brand cubic-bezier ease, so no new keyframe is needed and prefers-reduced-motion
is honoured automatically. Also swapped transition-all for transition-colors
to comply with the no-transition-all brand-motion rule.