3 Commits

Author SHA1 Message Date
6881aa800a v0.3 Phase 4l: semantic palette revision — Tiger Flame / Banana Cream / Sea Green / Steel Azure
Wren palette pass on 2026/05/15. Splits the role tokens away from
the prior bright/neon set toward a more grounded warm-and-cool
quartet, with explicit FILL + TEXT (signal + ink) pairs in light
mode so AA-on-cream stays cheap to consume.

Dark mode signals
  --color-info-signal     #6EA8FF  →  #064789  Steel Azure
  --color-danger-signal   #FF5A52  →  #F26430  Tiger Flame
  --color-success-signal  #00FF56  →  #519872  Sea Green
  --color-caution-signal  #FFCD00  →  #FCEC52  Banana Cream

Light mode (FILL = signal, TEXT = ink)
  --color-info-signal     #000AFF  →  #217AD5  +ink #1F73C9
  --color-danger-signal   #FF0700  →  #D55121  +ink #C54B1F
  --color-success-signal  #00D94A  →  #21D575  +ink #148348
  --color-caution-signal  #FFCD00  →  #D5A021  +ink #7E7414

Unchanged on purpose
  --color-bg              Coffee Bean #1F0812 was tried live and
                          rejected — R/G/B channels (31/8/18) read
                          as aubergine, not brown. Bg + grain held
                          at #12100E / 0.06 pending a true warm-brown
                          candidate.
  --color-*-border        Hue family unchanged from Phase 4j.
  --color-*-bg            Hand-tuned surface tints unchanged.
  HC variants             Out of scope for this pass.

Known issue
  Steel Azure #064789 on the dark bg sits around 2.06:1 — fails AA
  as text/icon. The role-token model has --color-info-ink (currently
  #9CC6FF, light blue) for that use case, but consumers reaching for
  --color-info-signal directly will read muddy. Retune of the info
  ink/signal split is owed separately. Documented inline.

npm run check: 0 errors.
2026-05-15 21:18:43 +01:00
baab7b3c12 docs: KI-07 — llama-cpp-2 v0.1.146 panics on Qwen3.5+ FGDN tensors
Discovered while spinning up a Tauri dev session on v0.3 for live UI
review. The app aborts on startup the moment DictationPage mounts and
calls load_llm_model — llama.cpp's GGML_ASSERT on the Fused Gated Delta
Net tensor-name prefix fires before the WebView paints, taking the
parent process with it. All four configured model variants
(Qwen3.5 2B/4B/9B + Qwen3.6 27B) share the architecture, so picking
a different size does not work around it.

Adds a new LLM section with KI-07 (Status / Source / Impact / Workarounds).
Four ordered workarounds, cheapest first:
  1. Park the .gguf file — autoload bails, non-LLM features work
  2. Set aiTier = "off" in lumotia_preferences before launch
  3. Bump llama-cpp-2 to a release with the upstream FGDN fix
  4. Add a non-FGDN model variant to LlmModelId

(1) unblocked tonight's live review of the Phase 5f polish. (3) is the
real resolution.
2026-05-15 20:38:01 +01:00
3810fdda43 v0.3 Phase 5f.1 polish: date groups, useful previews, quiet row actions
Acts on the polish-pass brief returned for 5f. Touches the quietware
branch only; v0.2 fallback (and compactPreviewText, used by its
row-height measurer) is left intact.

  Date groups       liveGroups derived walks `filtered` and breaks at
                    local-calendar-day boundaries. dateGroupLabel
                    formats Today / Yesterday / D MMM / D MMM YYYY.
                    Section headers are 11px mono uppercase, separated
                    by the same divide-y the rows use.

  Row hierarchy     New quietRowPreview returns a real transcript
                    snippet (item.text → item.preview → empty). The
                    title repeat in 5f was compactPreviewText returning
                    the title under the title; that helper stays for
                    v0.2's height math, the quietware row uses the new
                    one. Falls back to "No preview available" in-template.

  Row meta-line     HH:MM · duration · Dictation|File. Date moved to
                    the group header — repeating it per row wasted
                    hierarchy.

  Row actions       Open stays visible (tertiary), other actions moved
                    behind a quiet LumotiaIconButton(MoreHorizontal)
                    trigger that opens LumotiaMenu with Copy /
                    Export markdown / Move to Trash. Trigger is
                    always present (not hover-only) so keyboard
                    focus works.

  Search alignment  Dropped max-w-2xl. Search now spans the same
                    content gutter as the rows so the right edge
                    aligns with where the actions column begins —
                    intentional, not arbitrary.

  Trash rows        Same row anatomy. Restore visible. No overflow
                    menu — perm-delete-from-trash UI is deferred per
                    the existing comment (TRANSCRIPT_TRASH_RETENTION_DAYS
                    handles hard-removal). Meta-line shows
                    "Deleted …  ·  Auto-purges after 30 days".

Date-grouping formula (per CLAUDE.md rule):
  same local-calendar day as now → "Today"
  previous local-calendar day    → "Yesterday"
  same local-calendar year       → "D MMM"
  older                          → "D MMM YYYY"
Day boundaries are strict local midnight, not 24-hour windows. Items
without savedAt fall into an "undated" bucket.

npm run check: 0 errors.
2026-05-15 20:37:51 +01:00
3 changed files with 250 additions and 53 deletions

View File

@@ -83,6 +83,42 @@ Display sleep is intentionally NOT blocked — the user is dictating, not watchi
**Workaround:** N/A. Existing path works as before.
## LLM
### KI-07 — `llama-cpp-2 v0.1.146` panics on Qwen3.5+ Fused Gated Delta Net (FGDN) tensors
**Status:** Loading any of the four configured model variants (`Qwen3_5_2B_Q4`, `Qwen3_5_4B_Q4`, `Qwen3_5_9B_Q4`, `Qwen3_6_27B_Q4`) at startup aborts the whole process with a GGML assert inside the bundled `llama.cpp`:
```
llama-cpp-sys-2-0.1.146/llama.cpp/src/llama-context.cpp:487:
GGML_ASSERT(strncmp(n->name, LLAMA_TENSOR_NAME_FGDN_AR "-", prefix_len) == 0) failed
```
The assert fires during `llama_context` construction while resolving Fused Gated Delta Net support, which is the recurrent / Mamba-style attention mechanism Qwen3.5+ uses. `llama-cpp-2 v0.1.146` bundles a `llama.cpp` snapshot whose FGDN tensor-name prefix check rejects tensors as named in current Qwen3.5 GGUFs. Because the assert is in C++ via `ggml`, the panic kills the parent Tauri process — Tauri's crash handler then attaches GDB, which is what surfaces in the dev log as a stack trace ending in `tao::event_loop` rather than as a Rust panic.
All four enum variants share the same architecture family, so picking a different size does not work around it.
**Source:**
- Dep pin: [`crates/llm/Cargo.toml:24`](crates/llm/Cargo.toml#L24) (`llama-cpp-2 = "0.1.146"`)
- Model enum: [`crates/llm/src/model_manager.rs:15`](crates/llm/src/model_manager.rs#L15) (the four Qwen3.5+ variants)
- Frontend autoload trigger: [`src/lib/pages/DictationPage.svelte:274`](src/lib/pages/DictationPage.svelte#L274) (`ensureLlmModelLoaded``invoke("load_llm_model", …)`)
- Backend command: [`src-tauri/src/commands/llm.rs:94`](src-tauri/src/commands/llm.rs#L94) (`load_llm_model`)
- Upstream assert: `llama-cpp-sys-2-0.1.146/llama.cpp/src/llama-context.cpp:487`
**Impact:** App will not start in dev (and presumably release) on any machine that has a Qwen3.5+ `.gguf` present and `settings.aiTier !== "off"`. There is no UI to recover — the crash precedes the WebView paint, so the user cannot reach Settings to disable AI features or swap the model. Affects every code path that exercises the LLM (transcript cleanup, task extraction, micro-step decomposition).
**Workarounds (in order of cost):**
1. **Park the model file** so `check_llm_model` returns `downloaded: false` and autoload silently bails:
```
mv ~/.local/share/lumotia/models/llm/Qwen3.5-4B-Q4_K_M.gguf{,.crash-parked}
```
Non-LLM features (dictation, transcript history, file imports) work normally. Reverse with `mv …{.crash-parked,}`.
2. **Set `aiTier = "off"`** in `lumotia_preferences` before launch (also bypasses the autoload guard).
3. **Bump `llama-cpp-2`** to a release that includes the upstream `llama.cpp` FGDN fix. Track upstream PRs at <https://github.com/utilityai/llama-cpp-rs/releases>; verify the bundled `llama.cpp` SHA covers Gated Delta Net tensor-name handling before adopting.
4. **Add a non-FGDN model variant** to `LlmModelId` (e.g. Llama 3.2 3B Q4 — well-supported in 0.1.146 — or Qwen2.5 3B) and ship a Settings UI to switch to it. Larger change because the enum, the on-disk URL/size constants, and the Settings model picker all need entries.
**Resolution (deferred):** Option 3 is the real fix and matches the spirit of `crates/llm` (Qwen3.5+ explicitly listed in the crate description). Option 4 is the right fallback if upstream is slow — having one non-FGDN model on the enum prevents the dev-blocker class of crash entirely.
## How to add an entry
When shipping a partial implementation or known limitation, add a `KI-NN` entry here with the four standard fields: **Status**, **Source** (file:line), **Impact**, **Workaround**. Link from the affected module's doc comment back to this file by ID.

View File

@@ -186,6 +186,16 @@
Quietware — dark mode default. Warm brown-charcoal atmosphere.
============================================================ */
:root[data-design="quietware"] {
/* Phase 4l — Wren palette revision, 2026/05/15. Role signals
swapped to Tiger Flame / Banana Cream / Sea Green / Steel Azure
(dark) and the FILL/TEXT pair set (light). Coffee Bean bg
candidate #1F0812 was tested live and rejected — its R/G/B
channels (31/8/18) sit on a purple-leaning hue, reading as
aubergine in the running window, not the labelled warm brown.
Bg + grain held at the prior values pending a true coffee-brown
candidate. Steel Azure contrast on dark fails AA (see notice on
--color-info-signal below) regardless of bg choice — retune of
the info ink/signal split is owed separately. */
--color-bg: #12100E;
--color-bg-elevated: #1A1713;
--color-bg-card: #211D18;
@@ -207,25 +217,28 @@
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
/* Four-tier role tokens. Signal stays bright (the role's identity);
ink lightens for text legibility on dark; border is a middle value;
bg is a hand-tuned dark tint, not a color-mix derivation. */
--color-info-signal: #6EA8FF;
/* Four-tier role tokens. Signal carries the role's identity
(Tiger Flame / Banana Cream / Sea Green / Steel Azure under
Phase 4l); ink lightens for text legibility on dark; border is
a middle value; bg is a hand-tuned dark tint, not a color-mix.
Borders + bg tints retained from Phase 4j — they go with the
hue family rather than the specific shade. */
--color-info-signal: #064789;
--color-info-ink: #9CC6FF;
--color-info-border: #4D8DFF;
--color-info-bg: #101A2A;
--color-danger-signal: #FF5A52;
--color-danger-signal: #F26430;
--color-danger-ink: #FF9B96;
--color-danger-border: #FF5A52;
--color-danger-bg: #2A1412;
--color-success-signal: #00FF56;
--color-success-signal: #519872;
--color-success-ink: #7DFFA8;
--color-success-border: #00D94A;
--color-success-bg: #0D2415;
--color-caution-signal: #FFCD00;
--color-caution-signal: #FCEC52;
--color-caution-ink: #FFE066;
--color-caution-border: #FFCD00;
--color-caution-bg: #29210A;
@@ -419,25 +432,28 @@
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
/* Four-tier role tokens, light mode. Bg values hand-picked as
clean pale tints, not muddy color-mix products. */
--color-info-signal: #000AFF;
--color-info-ink: #0033A0;
/* Four-tier role tokens, light mode. Phase 4l — Wren palette
revision 2026/05/15. Signal = FILL (vivid), ink = TEXT-safe
darker sibling that passes 4.5:1 on cream. Saturation/lightness
targeted on the locked Banana Cream anchor #D5A021 (S 73%, L 48%).
Borders + bg tints unchanged — they go with the hue family. */
--color-info-signal: #217AD5;
--color-info-ink: #1F73C9;
--color-info-border: #3A6BFF;
--color-info-bg: #EEF3FF;
--color-danger-signal: #FF0700;
--color-danger-ink: #B3261E;
--color-danger-signal: #D55121;
--color-danger-ink: #C54B1F;
--color-danger-border: #E13A32;
--color-danger-bg: #FFF0EF;
--color-success-signal: #00D94A;
--color-success-ink: #006B2D;
--color-success-signal: #21D575;
--color-success-ink: #148348;
--color-success-border: #159947;
--color-success-bg: #EAF8EE;
--color-caution-signal: #FFCD00;
--color-caution-ink: #7A5D00;
--color-caution-signal: #D5A021;
--color-caution-ink: #7E7414;
--color-caution-border: #D9A900;
--color-caution-bg: #FFF7D6;

View File

@@ -28,10 +28,12 @@
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
// v0.3 Phase 5f — quietware History layout.
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
import LumotiaMenu from "$lib/ui/LumotiaMenu.svelte";
import LumotiaIconButton from "$lib/ui/LumotiaIconButton.svelte";
import { onMount } from "svelte";
import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte';
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag, MoreHorizontal, Copy, Trash2, FileDown } from 'lucide-svelte';
const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54;
@@ -298,6 +300,95 @@
return item.title?.trim() || "Untitled";
}
// v0.3 Phase 5f polish — quietware row helpers. Kept separate from
// compactPreviewText so the v0.2 fallback's row-height measurement
// (which reads compactPreviews) is untouched.
/** Two-line transcript snippet for the quietware row sub-line. Falls
* through item.text → item.preview → empty string ("No preview…"
* rendering is the template's job, not this helper's). Stripped of
* leading whitespace so previews don't start mid-pause. */
function quietRowPreview(item) {
const raw = (item?.text || item?.preview || "").replace(/\s+/g, " ").trim();
return raw;
}
/** Source label for the quietware row meta-line. "Dictation" for
* mic-captured items, "File" for imports. Mirrors the icon column
* but lives in the meta-line so the row scans without colour cues. */
function quietRowSourceLabel(item) {
const s = (item?.source || "").toLowerCase();
return s.includes("file") ? "File" : "Dictation";
}
/** Just the HH:MM portion of a saved-at timestamp. The date sits in
* the group header above the row, so repeating it here would waste
* hierarchy. Uses en-GB 24h to match formatTimestamp. */
function quietRowTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
/** Calendar-day label for date-group headers. Formula:
* same local-calendar day as now → "Today"
* previous local-calendar day → "Yesterday"
* same local-calendar year → "D MMM" (e.g. "12 May")
* older → "D MMM YYYY" (e.g. "12 May 2025")
* Day boundaries are strictly local midnight, not 24-hour windows. */
function dateGroupLabel(iso) {
if (!iso) return "Undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "Undated";
const now = new Date();
const sameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, now)) return "Today";
const y = new Date(now);
y.setDate(now.getDate() - 1);
if (sameDay(d, y)) return "Yesterday";
const fmt = d.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
});
return fmt;
}
/** Stable bucket key for grouping. Local-calendar YYYY-MM-DD so the
* groups break at midnight, not on UTC date math. Items with no
* savedAt fall into an "undated" bucket that surfaces last. */
function dateGroupKey(iso) {
if (!iso) return "undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "undated";
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Walk `filtered` in order, breaking at calendar-day boundaries.
* Preserves the underlying sort so newest-first stays newest-first;
* groups appear in the order their first item appears. */
let liveGroups = $derived.by(() => {
const out = [];
let current = null;
for (const item of filtered) {
const iso = item.savedAt || item.timestamp;
const key = dateGroupKey(iso);
if (!current || current.key !== key) {
current = { key, label: dateGroupLabel(iso), items: [] };
out.push(current);
}
current.items.push(item);
}
return out;
});
let compactPreviews = $derived.by(() => {
return filtered.map((item) => {
const text = compactPreviewText(item);
@@ -721,14 +812,19 @@
<!-- =================================================================
v0.3 Phase 5f — quietware History layout. Calm local archive.
Header "History" + count
Primary surface search + view tabs + list / empty state
Action dock contextual per item
Mono footer saved count · storage · local only
Header "History" + Live / Trash tabs with counts
Primary surface search + date-grouped list / empty state
Row icon · title · 2-line preview · meta-line
Open (visible) + ⋯ overflow (Copy / Export
/ Move to Trash). Trash rows show Restore
visible; permanent-delete UI is deferred
(the 30-day purge handles hard-removal,
see TRANSCRIPT_TRASH_RETENTION_DAYS).
Mono footer saved count · trash count · local only
Reuses every existing state and handler: history (store),
trashItems, viewMode, searchQuery, filtered (derived),
compactPreviewText, copyItem, removeItem, openViewer,
copyItem, exportMarkdown, removeItem, openViewer,
restoreFromTrash. Bulk actions, audio playback, tag chips
and ClearAll modal stay accessible via the v0.2 fallback —
the quietware view focuses on the core archive flow.
@@ -775,9 +871,11 @@
</div>
{/if}
<!-- Search input, mounted to the top of the surface. -->
<!-- Search input. No max-width: spans the same content gutter
as the rows below so the right edge aligns with where the
row actions column begins. Intentional, not arbitrary. -->
<div class="px-6 pt-5 pb-3 border-b border-border-subtle">
<div class="relative max-w-2xl">
<div class="relative">
<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"
@@ -791,7 +889,11 @@
</div>
<!-- Archive list. Reuses the existing derived `filtered` for
live mode and `trashItems` for trash mode. -->
live mode and `trashItems` for trash mode. Live mode
renders date-bucketed groups (Today / Yesterday / D MMM)
so the surface scans as an archive rather than a flat
table; trash stays ungrouped because the relevant axis
there is days-until-purge, not save date. -->
<div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "live"}
{#if filtered.length === 0}
@@ -805,28 +907,61 @@
</p>
</div>
{:else}
<ul class="divide-y divide-border-subtle">
{#each filtered as item (item.id)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
{#if item.source === "file"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title || item.preview?.slice(0, 80) || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
</div>
<ul class="pb-2">
{#each liveGroups as group (group.key)}
<li
class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
aria-hidden="true"
>
{group.label}
</li>
<li>
<ul class="divide-y divide-border-subtle border-y border-border-subtle">
{#each group.items as item (item.id)}
{@const previewText = quietRowPreview(item)}
{@const sourceLabel = quietRowSourceLabel(item)}
<li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
{#if sourceLabel === "File"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
{quietRowTime(item.savedAt || item.timestamp)}
{#if item.duration}
&nbsp;·&nbsp;{formatDuration(item.duration)}
{/if}
&nbsp;·&nbsp;{sourceLabel}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
<LumotiaMenu
align="end"
items={[
{ value: "copy", label: "Copy transcript", icon: Copy, onSelect: () => copyItem(item) },
{ value: "export", label: "Export markdown", icon: FileDown, onSelect: () => exportMarkdown(item) },
{ kind: "separator" as const },
{ value: "delete", label: "Move to Trash", icon: Trash2, destructive: true, onSelect: () => removeItem(item) },
]}
>
{#snippet trigger()}
<LumotiaIconButton icon={MoreHorizontal} label="More actions" size="sm" />
{/snippet}
</LumotiaMenu>
</div>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
@@ -844,21 +979,31 @@
{:else}
<ul class="divide-y divide-border-subtle">
{#each trashItems as item (item.id)}
{@const previewText = quietRowPreview(item)}
{@const sourceLabel = quietRowSourceLabel(item)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0">
<FileText size={16} aria-hidden="true" />
{#if sourceLabel === "File"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title || item.preview?.slice(0, 80) || "Untitled"}
{item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
Deleted {formatTrashTimestamp(item.deleted_at || item.createdAt)} · Auto-purges after 30 days
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-1">{item.preview || ""}</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">Deleted {formatTrashTimestamp(item.deleted_at)}</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton
size="sm"
variant={restoringId === item.id ? "secondary" : "primary"}
variant={restoringId === item.id ? "secondary" : "tertiary"}
disabled={restoringId === item.id}
onclick={() => restoreFromTrash(item.id)}
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>