Compare commits
7 Commits
claude/aut
...
749403697a
| Author | SHA1 | Date | |
|---|---|---|---|
| 749403697a | |||
| eb3d2f90ab | |||
| 6469663c25 | |||
| 22d554e85b | |||
| fa734c869f | |||
| d089fdb37f | |||
| 2da0a5bab8 |
39
src/app.css
39
src/app.css
@@ -50,13 +50,19 @@
|
||||
/* Text — warm hierarchy */
|
||||
--color-text: #f0ece4;
|
||||
--color-text-secondary: #9a9486;
|
||||
--color-text-tertiary: #716b60;
|
||||
/* Phase 10a a11y P1: lifted from #716b60 to #8c8678 to clear AA 4.5:1 on
|
||||
the dark surfaces this token is used on. Used as both decorative and
|
||||
body-grade label, so the token has to meet AA. */
|
||||
--color-text-tertiary: #8c8678;
|
||||
|
||||
/* Accent — warm amber/copper */
|
||||
--color-accent: #e8a87c;
|
||||
--color-accent-hover: #d4976a;
|
||||
--color-accent-subtle: #e8a87c10;
|
||||
--color-accent-glow: #e8a87c25;
|
||||
/* Accent — warm amber/copper.
|
||||
Phase 10a a11y P2: dark accent darkened from #e8a87c to #c98555 so the
|
||||
"Browse Files" filled button (white text on accent fill) clears AA in
|
||||
dark theme. Subtle / hover / glow recomputed off the new base. */
|
||||
--color-accent: #c98555;
|
||||
--color-accent-hover: #b8754a;
|
||||
--color-accent-subtle: #c9855510;
|
||||
--color-accent-glow: #c9855525;
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #7ec89a;
|
||||
@@ -109,15 +115,22 @@
|
||||
|
||||
--color-text: #1a1816;
|
||||
--color-text-secondary: #5c574d;
|
||||
--color-text-tertiary: #8a8578;
|
||||
/* Phase 10a a11y P1: darkened from #8a8578 to #6b6557 to clear AA 4.5:1
|
||||
on cream/card surfaces in light theme. */
|
||||
--color-text-tertiary: #6b6557;
|
||||
|
||||
--color-accent: #b87a4a;
|
||||
--color-accent-hover: #a06b3e;
|
||||
--color-accent-subtle: #b87a4a10;
|
||||
--color-accent-glow: #b87a4a20;
|
||||
/* Phase 10a a11y P2: darkened from #b87a4a to #a06a3e so white text on
|
||||
accent fill (selected pills, primary buttons) clears AA. */
|
||||
--color-accent: #a06a3e;
|
||||
--color-accent-hover: #8a5a32;
|
||||
--color-accent-subtle: #a06a3e10;
|
||||
--color-accent-glow: #a06a3e20;
|
||||
|
||||
--color-success: #3d8a5a;
|
||||
--color-danger: #c44d4d;
|
||||
/* Phase 10a a11y G3: darkened success from #3d8a5a to #2f7549 so success
|
||||
text on light surfaces clears AA. Phase 10a a11y FR3 / D4: darkened
|
||||
danger from #c44d4d to #a83838 for the same reason. */
|
||||
--color-success: #2f7549;
|
||||
--color-danger: #a83838;
|
||||
--color-warning: #b89a3e;
|
||||
|
||||
--color-sidebar: #f5f2ed;
|
||||
|
||||
@@ -28,10 +28,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Layout-transition perf fix: previously animated `width` + `min-width` which
|
||||
triggers layout on every frame. Replaced with a CSS-grid track-size
|
||||
transition (`grid-template-columns`) on a wrapping container; the inner
|
||||
flex column lives in the single track. This keeps the parent flex row in
|
||||
charge of width allocation but moves the animated property onto a track
|
||||
spec, which the browser can collapse without re-running our layout work.
|
||||
-->
|
||||
<div
|
||||
class="grid h-full {collapsed ? 'grid-cols-[48px]' : 'grid-cols-[210px]'}"
|
||||
style="transition: grid-template-columns var(--duration-ui)"
|
||||
>
|
||||
<aside
|
||||
class="flex flex-col bg-sidebar border-r border-border-subtle h-full overflow-hidden
|
||||
{collapsed ? 'w-[48px] min-w-[48px]' : 'w-[210px] min-w-[210px]'}"
|
||||
style="transition: width var(--duration-ui), min-width var(--duration-ui)"
|
||||
class="flex flex-col bg-sidebar border-r border-border-subtle h-full overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Logo + toggle -->
|
||||
<div class="flex items-center {collapsed ? 'justify-center px-0 pt-4 pb-1' : 'px-5 pt-4 pb-1'} relative">
|
||||
@@ -165,3 +175,4 @@
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
<p class="text-[10px] font-medium text-text-tertiary uppercase tracking-wider pt-2">If</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={triggerKind}
|
||||
>
|
||||
<option value="time_of_day">time of day</option>
|
||||
@@ -141,7 +141,7 @@
|
||||
{#if triggerKind === "time_of_day"}
|
||||
<input
|
||||
type="time"
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={triggerTime}
|
||||
/>
|
||||
{/if}
|
||||
@@ -158,7 +158,7 @@
|
||||
{#if surfaceEnabled}
|
||||
<div class="flex flex-wrap gap-2 pl-1">
|
||||
<select
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
class="bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={surfaceTarget}
|
||||
>
|
||||
<option value="inbox">Inbox</option>
|
||||
@@ -168,7 +168,7 @@
|
||||
</select>
|
||||
{#if surfaceTarget === "task"}
|
||||
<select
|
||||
class="min-w-[220px] bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
|
||||
class="min-w-[220px] bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent"
|
||||
bind:value={surfaceTaskId}
|
||||
>
|
||||
<option value="">Choose task…</option>
|
||||
@@ -195,7 +195,7 @@
|
||||
id="rule-speak-line"
|
||||
type="text"
|
||||
maxlength="240"
|
||||
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text placeholder:text-text-tertiary focus:border-accent focus:outline-none"
|
||||
class="w-full bg-bg border border-border rounded-lg px-3 py-2 text-[12px] text-text placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="Optional line, e.g. time to plan the day"
|
||||
bind:value={speakLine}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
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();
|
||||
@@ -171,13 +176,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -193,11 +257,19 @@
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if open}
|
||||
<!--
|
||||
Pure-black-white taste fix: replaced `bg-black/50` overlay with the brand
|
||||
deep-neutral `#1a1816` at 50% alpha. TODO: promote this colour to a
|
||||
`--color-overlay` token in app.css so the value isn't inlined per-modal.
|
||||
-->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
bind:this={dialogEl}
|
||||
class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm animate-fade-in"
|
||||
style="background: rgba(26, 24, 22, 0.5)"
|
||||
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">
|
||||
|
||||
@@ -10,13 +10,27 @@
|
||||
title: string;
|
||||
description?: string;
|
||||
open?: boolean;
|
||||
onopen?: () => void;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { title, description = "", open = false, children }: Props = $props();
|
||||
let { title, description = "", open = false, onopen, children }: Props = $props();
|
||||
|
||||
// Fires once on the first transition from closed → open. Used by
|
||||
// sections that lazy-load expensive data (e.g. TTS voice enumeration).
|
||||
// Plain let (not $state) — the flag is mutated only by the toggle
|
||||
// handler and never has to be reactive in the template.
|
||||
let hasOpened = false;
|
||||
function handleToggle(event: Event) {
|
||||
const el = event.currentTarget as HTMLDetailsElement | null;
|
||||
if (el?.open && !hasOpened) {
|
||||
hasOpened = true;
|
||||
onopen?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<details {open} class="settings-group border-t border-border-subtle">
|
||||
<details {open} ontoggle={handleToggle} class="settings-group border-t border-border-subtle">
|
||||
<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"
|
||||
>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-1.5 text-[12px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="Add task..."
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm
|
||||
ease-[cubic-bezier(0.34,1.56,0.64,1)]
|
||||
ease-[cubic-bezier(0.16,1,0.3,1)]
|
||||
{checked ? 'translate-x-[16px]' : 'translate-x-0'}"
|
||||
style="transition: transform var(--duration-ui) cubic-bezier(0.34, 1.56, 0.64, 1)"
|
||||
style="transition: transform var(--duration-ui) cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
></span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="Add a task…"
|
||||
class="flex-1 px-3 py-2 rounded-lg bg-bg-input border border-border-subtle text-text text-[13px]
|
||||
placeholder:text-text-tertiary focus:border-accent focus:outline-none"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
data-no-transition
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
<Card classes="h-full flex flex-col">
|
||||
<textarea
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
||||
resize-none placeholder:text-text-tertiary"
|
||||
placeholder="Transcribed text will appear here..."
|
||||
bind:value={fileTranscript}
|
||||
data-no-transition
|
||||
|
||||
@@ -581,7 +581,7 @@
|
||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||
<Search size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-tertiary"
|
||||
placeholder="Search all transcripts... (try tag:meetings)"
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
@@ -682,7 +682,7 @@
|
||||
the click off the row-expand toggle. -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 flex-shrink-0 accent-accent cursor-pointer opacity-50 hover:opacity-100 {selected.has(item.id) ? 'opacity-100' : ''}"
|
||||
class="w-3.5 h-3.5 flex-shrink-0 accent-accent cursor-pointer opacity-70 hover:opacity-100 {selected.has(item.id) ? 'opacity-100' : ''}"
|
||||
aria-label={`Select transcript ${item.title || item.id}`}
|
||||
checked={selected.has(item.id)}
|
||||
onclick={(e) => { e.stopPropagation(); toggleSelected(item.id); }}
|
||||
@@ -786,7 +786,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent mb-3"
|
||||
text-text placeholder:text-text-tertiary focus:border-accent mb-3"
|
||||
placeholder="Name this transcript..."
|
||||
value={item.title || ""}
|
||||
oninput={(e) => { item.title = e.target.value; }}
|
||||
@@ -828,7 +828,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="bg-bg-input border border-border rounded-full px-2 py-0.5 text-[10px]
|
||||
text-text placeholder:text-text-tertiary focus:outline-none focus:border-accent w-[110px]"
|
||||
text-text placeholder:text-text-tertiary focus:border-accent w-[110px]"
|
||||
placeholder="+ add tag"
|
||||
onkeydown={(e) => handleAddTagKey(e, item)}
|
||||
data-no-transition
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -333,8 +333,8 @@
|
||||
<button
|
||||
class="text-[10px] px-2 py-0.5 rounded-md
|
||||
{checked
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'text-text-tertiary hover:text-text-secondary'}"
|
||||
? 'bg-accent/25 text-accent border border-accent/30'
|
||||
: 'text-text-secondary hover:text-text'}"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={opt.value ? `Set current energy to ${energyLabel(opt.value)}` : 'Clear current energy'}
|
||||
@@ -346,8 +346,8 @@
|
||||
<button
|
||||
class="flex items-center gap-1 btn-md rounded-lg text-[10px]
|
||||
{settings.matchMyEnergy
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'text-text-tertiary hover:bg-hover hover:text-text border border-transparent'}"
|
||||
? 'bg-accent/25 text-accent border border-accent/30'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text border border-transparent'}"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
onclick={toggleMatchMyEnergy}
|
||||
aria-pressed={settings.matchMyEnergy}
|
||||
@@ -377,7 +377,7 @@
|
||||
<Search size={14} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-[12px] text-text placeholder:text-text-tertiary"
|
||||
placeholder="Search tasks..."
|
||||
bind:value={searchQuery}
|
||||
data-no-transition
|
||||
@@ -395,7 +395,7 @@
|
||||
<Plus size={16} class="text-text-tertiary flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary focus:outline-none"
|
||||
class="flex-1 bg-transparent text-[13px] text-text placeholder:text-text-tertiary"
|
||||
placeholder="Add a task to {activeListName}{activeBucket !== 'all' ? ` (${activeBucket})` : ''}... (Enter to save)"
|
||||
bind:value={quickInput}
|
||||
onkeydown={handleQuickAdd}
|
||||
@@ -458,11 +458,20 @@
|
||||
|
||||
<!-- Main content: sidebar + tasks -->
|
||||
<div class="flex flex-1 min-h-0 px-7 pb-4 gap-3">
|
||||
<!--
|
||||
Layout-transition perf fix: was animating `width` directly. Now wraps
|
||||
the list sidebar in a CSS-grid container whose `grid-template-columns`
|
||||
track-size transitions between collapsed/expanded widths. Same visual
|
||||
effect, single transitioning property the browser can handle as a
|
||||
track-spec change rather than a width re-flow each frame.
|
||||
-->
|
||||
<!-- List sidebar -->
|
||||
<div
|
||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden
|
||||
{sidebarCollapsed ? 'w-[40px] min-w-[40px]' : 'w-[160px] min-w-[160px]'}"
|
||||
style="transition: width var(--duration-ui)"
|
||||
class="grid {sidebarCollapsed ? 'grid-cols-[40px]' : 'grid-cols-[160px]'}"
|
||||
style="transition: grid-template-columns var(--duration-ui)"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col bg-bg-elevated rounded-2xl border border-border-subtle overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Collapse toggle -->
|
||||
<button
|
||||
@@ -485,7 +494,7 @@
|
||||
<input
|
||||
bind:this={editingInputEl}
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text focus:outline-none"
|
||||
class="w-full bg-bg-input border border-accent rounded px-2 py-1 text-[10px] text-text"
|
||||
bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === "Enter") finishRenaming(); if (e.key === "Escape") { editingListId = null; } }}
|
||||
onblur={finishRenaming}
|
||||
@@ -545,7 +554,7 @@
|
||||
bind:this={newListInputEl}
|
||||
type="text"
|
||||
class="w-full bg-bg-input border border-border rounded px-2 py-1 text-[10px] text-text
|
||||
placeholder:text-text-tertiary focus:outline-none focus:border-accent"
|
||||
placeholder:text-text-tertiary focus:border-accent"
|
||||
placeholder="List name..."
|
||||
bind:value={newListName}
|
||||
onkeydown={handleCreateList}
|
||||
@@ -561,6 +570,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
Reference in New Issue
Block a user