Files
Lumotia/src/lib/components/MorningTriageModal.svelte
Jake 5e3a9f9f42 fix(ui): promote modal overlay to token, lift modal above grain, dedupe FilesPage drop-zone
- 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.
2026-05-07 10:04:30 +01:00

357 lines
12 KiB
Svelte

<script lang="ts">
// @ts-nocheck
// Phase 5 morning triage. Surfaces a calm "pick up to three for today"
// modal on the first launch-of-day once the user-set trigger time has
// passed. Evidence-based "rule of 3" per Life Skills Advocate / Aspire
// Therapy ADHD routine literature: externalise the daily choice and
// cap it at 3 to protect working memory (Sweller cognitive-load theory,
// Barkley's point-of-performance principle).
//
// Copy audit: no "overdue", no "failed", no subtractive framing (RSD).
//
// Triggering: runs a lightweight check on mount and when the page
// regains focus. Only one modal per calendar day regardless of how
// many times the app is restarted.
import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { hasTauriRuntime } from '$lib/utils/runtime.js';
import { settings } from '$lib/stores/page.svelte.js';
import { toasts } from '$lib/stores/toasts.svelte.js';
interface TriageTask {
id: string;
text: string;
bucket: string;
done: boolean;
createdAt: string;
}
let open = $state(false);
let loading = $state(false);
let tasks = $state<TriageTask[]>([]);
let selected = $state<Set<string>>(new Set());
let tooManyFlash = $state(false);
let applying = $state(false);
let focusHandler: (() => void) | null = null;
// Phase 10a a11y G4: focus-trap state. The modal must keep keyboard
// focus inside its bounds while open and restore focus to the
// invoking element on close.
let dialogEl = $state<HTMLDivElement | null>(null);
let invokerEl: HTMLElement | null = null;
function todayKey(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// Parse a HH:MM string into minutes-since-midnight. Falls back to
// 08:00 on malformed input rather than throwing — rituals must never
// break the app shell.
function parseTriggerMinutes(hhmm: string | undefined): number {
if (typeof hhmm !== 'string') return 8 * 60;
const match = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
if (!match) return 8 * 60;
const h = Math.max(0, Math.min(23, parseInt(match[1], 10)));
const m = Math.max(0, Math.min(59, parseInt(match[2], 10)));
return h * 60 + m;
}
function currentMinutes(): number {
const d = new Date();
return d.getHours() * 60 + d.getMinutes();
}
function dispatchTriageFinished(mode: 'empty' | 'skipped' | 'picked') {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('magnotia:morning-triage-finished', {
detail: { date: todayKey(), mode },
}));
}
function isBeforeToday(createdAt: string): boolean {
// Task `createdAt` comes from SQLite as ISO-8601 UTC. Compare the
// local-time date portion so a task made last night locally counts
// as "yesterday" regardless of the UTC offset.
const d = new Date(createdAt);
if (Number.isNaN(d.getTime())) return false;
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return local < todayKey();
}
async function maybeShow() {
if (open || applying) return;
if (!settings.ritualsMorning) return;
if (!hasTauriRuntime()) return;
if (currentMinutes() < parseTriggerMinutes(settings.ritualsMorningTime)) return;
let lastShown: string | null = null;
try {
lastShown = await invoke<string | null>('get_last_morning_triage');
} catch {
// Non-fatal: if we can't read the sentinel, treat as never-shown.
}
if (lastShown === todayKey()) return;
loading = true;
try {
const all = await invoke<TriageTask[]>('list_tasks_cmd');
tasks = all.filter(
(t) => !t.done && t.bucket !== 'today' && isBeforeToday(t.createdAt),
);
} catch {
tasks = [];
} finally {
loading = false;
}
if (tasks.length === 0) {
// Nothing to triage — record the shown sentinel anyway so we
// don't re-check the DB every focus event today.
try { await invoke('mark_morning_triage_shown', { date: todayKey() }); } catch {}
dispatchTriageFinished('empty');
return;
}
selected = new Set();
open = true;
}
function toggle(taskId: string) {
if (selected.has(taskId)) {
const next = new Set(selected);
next.delete(taskId);
selected = next;
return;
}
if (selected.size >= 3) {
tooManyFlash = true;
setTimeout(() => { tooManyFlash = false; }, 1800);
return;
}
const next = new Set(selected);
next.add(taskId);
selected = next;
}
async function skipForToday() {
applying = true;
try {
await invoke('mark_morning_triage_shown', { date: todayKey() });
} catch (err) {
toasts.warn('Could not save triage state', String(err));
} finally {
applying = false;
open = false;
dispatchTriageFinished('skipped');
}
}
async function startTheDay() {
if (selected.size === 0) return;
applying = true;
try {
for (const id of selected) {
try {
await invoke('update_task_cmd', {
id,
patch: { bucket: 'today' },
});
} catch (err) {
// Continue the loop — surface a single toast at the end rather
// than one per failure, so the user isn't drowned in errors.
console.warn('Triage: failed to move task', id, err);
}
}
await invoke('mark_morning_triage_shown', { date: todayKey() });
} catch (err) {
toasts.warn('Could not save triage state', String(err));
} finally {
applying = false;
open = false;
dispatchTriageFinished('picked');
}
}
// Phase 10a a11y G4: collect every focusable element inside the dialog
// for the focus trap. We re-query on every Tab so dynamic content
// (loading → tasks → buttons) stays in sync.
function focusableInDialog(): HTMLElement[] {
if (!dialogEl) return [];
const sel = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(dialogEl.querySelectorAll<HTMLElement>(sel)).filter(
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null,
);
}
function handleKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') {
e.preventDefault();
skipForToday();
return;
}
if (e.key === 'Tab') {
const focusables = focusableInDialog();
if (focusables.length === 0) {
// Nothing focusable inside — keep focus on the dialog container.
e.preventDefault();
dialogEl?.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !dialogEl?.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last || !dialogEl?.contains(active)) {
e.preventDefault();
first.focus();
}
}
}
}
// Phase 10a a11y G4: when the dialog opens, remember the invoker so
// we can return focus on close, and move focus into the dialog.
$effect(() => {
if (open) {
invokerEl = (document.activeElement as HTMLElement) ?? null;
// Wait one tick for the dialog to mount before focusing.
queueMicrotask(() => {
const focusables = focusableInDialog();
(focusables[0] ?? dialogEl)?.focus();
});
} else if (invokerEl) {
// Restore focus to the element that opened the modal.
try { invokerEl.focus(); } catch {}
invokerEl = null;
}
});
onMount(() => {
maybeShow();
focusHandler = () => maybeShow();
window.addEventListener('focus', focusHandler);
});
onDestroy(() => {
if (focusHandler) window.removeEventListener('focus', focusHandler);
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!--
Overlay sits on `--color-overlay-dim` (defined in app.css @theme), warm
deep-neutral derived from --color-bg. No backdrop-blur: the brand opposes
glassmorphism as a default. z-[60] sits above the .grain overlay (z-50).
-->
<div
bind:this={dialogEl}
class="fixed inset-0 z-[60] flex items-center justify-center animate-fade-in"
style="background: var(--color-overlay-dim)"
role="dialog"
aria-modal="true"
aria-labelledby="triage-title"
tabindex="-1"
>
<div class="bg-bg-elevated border border-border rounded-2xl shadow-2xl max-w-[480px] w-[90vw] max-h-[80vh] flex flex-col">
<div class="px-6 pt-6 pb-3">
<h2 id="triage-title" class="font-display text-[22px] italic text-text">Pick up to three for today</h2>
<p class="text-[12px] text-text-secondary mt-1">
Yesterday's open items. The rest can wait.
</p>
</div>
<div class="flex-1 overflow-y-auto px-6 pb-3 min-h-0">
{#if loading}
<p class="text-[12px] text-text-tertiary py-6 text-center">Loading your list…</p>
{:else}
<ul class="flex flex-col gap-1.5">
{#each tasks as task (task.id)}
{@const picked = selected.has(task.id)}
<li>
<button
type="button"
class="w-full text-left flex items-start gap-3 px-3 py-2 rounded-lg border transition-colors
{picked
? 'bg-accent/10 border-accent text-text'
: 'bg-bg-input border-border-subtle text-text-secondary hover:border-border'}"
onclick={() => toggle(task.id)}
aria-pressed={picked}
>
<span
class="mt-0.5 w-4 h-4 rounded-sm border flex items-center justify-center flex-shrink-0
{picked ? 'bg-accent border-accent text-white' : 'border-border'}"
aria-hidden="true"
>
{#if picked}
<svg viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 12l5 5L20 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</span>
<span class="text-[13px] leading-snug">{task.text}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="px-6 pb-5 pt-2">
<p
class="text-[11px] mb-3 min-h-[16px] transition-colors
{tooManyFlash ? 'text-warning' : 'text-text-tertiary'}"
aria-live="polite"
>
{#if tooManyFlash}
Just three for today. Unpick one to swap.
{:else if selected.size > 0}
{selected.size} picked · room for {3 - selected.size} more
{:else}
Pick 1, 2, or 3.
{/if}
</p>
<div class="flex items-center justify-between gap-3">
<button
type="button"
class="px-3 py-2 text-[12px] text-text-tertiary hover:text-text"
onclick={skipForToday}
disabled={applying}
>
Skip for today
</button>
<button
type="button"
class="px-4 py-2 rounded-lg text-[12px] font-medium transition-colors
{selected.size >= 1 && !applying
? 'bg-accent text-white hover:bg-accent-hover'
: 'bg-bg-input text-text-tertiary cursor-not-allowed'}"
onclick={startTheDay}
disabled={selected.size === 0 || applying}
>
{applying ? 'Saving…' : 'Start the day'}
</button>
</div>
</div>
</div>
</div>
{/if}