v0.3 Phase 4f/5a/5b: token architecture + skeleton + browser-preview reclass

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>
This commit is contained in:
2026-05-15 14:12:33 +01:00
parent dab9c7b5a6
commit 2fcb5aff78
5 changed files with 227 additions and 34 deletions

View File

@@ -29,6 +29,10 @@
const prefs = getPreferences();
const tauriRuntimeAvailable = hasTauriRuntime();
const browserPreviewMessage = "You're viewing Lumotia in a normal browser. Local transcription only works in the Tauri desktop app window.";
// v0.3 Phase 5b — shorter calmer copy used for the info-classed
// notice when the same condition surfaces in the UI. Same physical
// limitation, friendlier framing per round-4 feedback.
const browserPreviewSlim = "You're in a browser preview. Local transcription only works in the Lumotia desktop app.";
let transcript = $state("");
let segments = $state([]);
@@ -1127,31 +1131,40 @@
</div>
{/if}
<!-- Error — Sub-task D: structured copy with technical detail behind <details> -->
<!-- Error — Sub-task D: structured copy with technical detail behind <details>.
v0.3 Phase 5b: browser-preview state is reclassified info (not error)
because it is an environment limitation, not a failed action. Other
errors continue to render as danger with technical details affordance. -->
{#if error}
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
<div class="px-4 py-3 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger space-y-2">
<div class="flex items-start gap-2">
{#if transcriptionFailed}
<LumotiaStatusPill status="failed-safely" />
{:else}
<LumotiaStatusPill status="needs-review" />
{#if error === browserPreviewMessage}
<LumotiaNotice tone="info" slim dismissible onDismiss={() => { error = ""; errorDetail = ""; }}>
{browserPreviewSlim}
</LumotiaNotice>
{:else}
<div class="px-4 py-3 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger space-y-2">
<div class="flex items-start gap-2">
{#if transcriptionFailed}
<LumotiaStatusPill status="failed-safely" />
{:else}
<LumotiaStatusPill status="needs-review" />
{/if}
<p class="m-0">{error}</p>
</div>
{#if errorDetail}
<details class="text-[11px] text-danger/70">
<summary class="cursor-pointer select-none hover:text-danger">Technical details</summary>
<pre class="mt-1 whitespace-pre-wrap break-words">{errorDetail}</pre>
</details>
{/if}
<p class="m-0">{error}</p>
<div>
<button
class="text-[11px] underline hover:no-underline text-danger/80 hover:text-danger"
onclick={() => { error = ""; errorDetail = ""; }}
>Dismiss</button>
</div>
</div>
{#if errorDetail}
<details class="text-[11px] text-danger/70">
<summary class="cursor-pointer select-none hover:text-danger">Technical details</summary>
<pre class="mt-1 whitespace-pre-wrap break-words">{errorDetail}</pre>
</details>
{/if}
<div>
<button
class="text-[11px] underline hover:no-underline text-danger/80 hover:text-danger"
onclick={() => { error = ""; errorDetail = ""; }}
>Dismiss</button>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -32,6 +32,9 @@
title?: string;
dismissible?: boolean;
onDismiss?: () => void;
/** Slim variant collapses to a single line with no title block.
For ambient page-level notices (eg. "You're in browser preview"). */
slim?: boolean;
children?: Snippet;
classes?: string;
}
@@ -41,6 +44,7 @@
title,
dismissible = false,
onDismiss,
slim = false,
children,
classes = "",
}: Props = $props();
@@ -85,13 +89,13 @@
<div
role={ariaRole}
class="px-4 py-3 rounded-r-lg rounded-l-sm border border-l-4
text-[13px] flex items-start gap-2.5
class="rounded-r-lg rounded-l-sm border border-l-4 text-[13px] flex gap-2.5
{slim ? 'px-3 py-2 items-center' : 'px-4 py-3 items-start'}
{tonePal.bar} {tonePal.border} {tonePal.bg} {classes}"
>
<ToneIcon size={16} class="mt-0.5 shrink-0 {tonePal.iconColor}" aria-hidden="true" />
<ToneIcon size={slim ? 14 : 16} class="shrink-0 {slim ? '' : 'mt-0.5'} {tonePal.iconColor}" aria-hidden="true" />
<div class="flex-1 min-w-0 leading-relaxed text-text">
{#if title}
{#if title && !slim}
<p class="font-semibold text-text mb-1">{title}</p>
{/if}
{#if children}{@render children()}{/if}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
// v0.3 Phase 5a — shared page skeleton.
//
// Six-zone layout used by every quietware page so structure stays
// consistent across Dictation / Files / Tasks / History / Settings.
// Sidebar lives in AppChrome above this — the skeleton owns the
// right-of-sidebar column only.
//
// +----------------------------+
// | header | page title + status cluster
// |----------------------------|
// | notice (slim, optional) | one-line ambient state
// |----------------------------|
// | |
// | primary work surface | the page's centre of attention
// | |
// |----------------------------|
// | action bar (optional) | post-capture / per-row actions
// |----------------------------|
// | metadata (mono, tiny) | hotkey hint · backend · model
// +----------------------------+
//
// Every snippet is optional except primary. Pages compose the
// skeleton; the skeleton does not impose copy or controls.
import type { Snippet } from "svelte";
interface Props {
header?: Snippet;
notice?: Snippet;
primary: Snippet;
actionBar?: Snippet;
metadata?: Snippet;
/** Right rail / drawer — opt-in narrow column to the right of the primary surface. */
rightRail?: Snippet;
/** Drop the primary surface's outer padding for full-bleed content (eg. transcript canvas). */
primaryBleed?: boolean;
classes?: string;
}
let {
header,
notice,
primary,
actionBar,
metadata,
rightRail,
primaryBleed = false,
classes = "",
}: Props = $props();
</script>
<div class="flex flex-col h-full min-h-0 bg-bg {classes}">
{#if header}
<div class="shrink-0 px-7 pt-5 pb-3 border-b border-border-subtle">
{@render header()}
</div>
{/if}
{#if notice}
<div class="shrink-0 px-7 pt-3">
{@render notice()}
</div>
{/if}
<div class="flex-1 min-h-0 flex {rightRail ? 'gap-5' : ''} px-7 pt-5 pb-3">
<div class="flex-1 min-w-0 {primaryBleed ? '' : 'bg-bg-card border border-border-subtle rounded-lg overflow-hidden'}">
{@render primary()}
</div>
{#if rightRail}
<aside class="w-72 shrink-0 hidden lg:flex flex-col gap-3">
{@render rightRail()}
</aside>
{/if}
</div>
{#if actionBar}
<div class="shrink-0 px-7 py-3 border-t border-border-subtle bg-bg-elevated/60">
{@render actionBar()}
</div>
{/if}
{#if metadata}
<div class="shrink-0 px-7 py-2 text-[11px] font-mono text-text-tertiary">
{@render metadata()}
</div>
{/if}
</div>