@tinytars/frame 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AttachmentStrip.svelte +85 -0
- package/AttachmentViewer.svelte +116 -0
- package/Button.svelte +27 -0
- package/DictateButton.svelte +124 -0
- package/Field.svelte +36 -0
- package/FormGrid.svelte +15 -0
- package/Modal.svelte +164 -0
- package/ModalActions.svelte +13 -0
- package/PdfThumbnail.svelte +70 -0
- package/PersonaBubble.svelte +81 -0
- package/SaveStatus.svelte +20 -0
- package/SidebarGroupChevron.svelte +36 -0
- package/SidebarGroupList.svelte +119 -0
- package/SidebarLeafList.svelte +107 -0
- package/attachment-types.ts +16 -0
- package/concurrency.ts +10 -0
- package/dictate-registry.ts +16 -0
- package/filter.test.ts +42 -0
- package/filter.ts +19 -0
- package/group-filter.ts +69 -0
- package/package.json +32 -4
- package/pdf-render.ts +48 -0
- package/persisted-json.ts +17 -0
- package/sidebar-rows.ts +47 -0
- package/speech-registry.svelte.ts +50 -0
- package/theme.css +94 -0
- package/time-ago.test.ts +32 -0
- package/time-ago.ts +18 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import LeafActionMenu from "@tinytars/frame/LeafActionMenu.svelte";
|
|
4
|
+
import type { LeafMenuItem } from "@tinytars/frame/menu-items";
|
|
5
|
+
import { speechRegistry, isSpeechSupported } from "./speech-registry.svelte";
|
|
6
|
+
|
|
7
|
+
// The one persona treatment: a tinted bubble with a head row (uppercase persona tag on the
|
|
8
|
+
// left; an optional meta/date and an optional action icon, e.g. download, on the right). Colors
|
|
9
|
+
// and layout live in theme.css's global .persona-* classes so every surface shares one language.
|
|
10
|
+
type Persona = "assistant" | "owner" | "provider";
|
|
11
|
+
// In-context head actions (edit/chat/delete/etc). `danger` tints the icon as destructive on hover.
|
|
12
|
+
// These render collapsed into one LeafActionMenu, not one button per action.
|
|
13
|
+
export type BubbleAction = LeafMenuItem;
|
|
14
|
+
interface Props {
|
|
15
|
+
persona: Persona;
|
|
16
|
+
// Caller-supplied, matching how LoginScreen/Onboarding take their copy: this package has no
|
|
17
|
+
// brand name or domain vocabulary of its own to fall back to.
|
|
18
|
+
label: string;
|
|
19
|
+
meta?: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
// Pin folds into the shared LeafActionMenu (★/⋮ combined control) instead of a standalone
|
|
22
|
+
// button; pinDisplay defaults to "auto" (hover-revealed ⋮ over the star).
|
|
23
|
+
pinned?: boolean;
|
|
24
|
+
onTogglePin?: () => void;
|
|
25
|
+
pinDisplay?: "auto" | "hidden";
|
|
26
|
+
actions?: BubbleAction[];
|
|
27
|
+
children?: Snippet;
|
|
28
|
+
}
|
|
29
|
+
let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, children }: Props = $props();
|
|
30
|
+
|
|
31
|
+
// Every assistant-generated bubble gets Speak for free: reads bodyEl's own rendered text at click
|
|
32
|
+
// time rather than a prop threaded through every one of this component's call sites, so no
|
|
33
|
+
// existing caller needs to change. speechRegistry is the shared "only one thing speaks at a time"
|
|
34
|
+
// singleton (mirrors menu-registry.svelte.ts); clicking the currently-speaking bubble's own button
|
|
35
|
+
// again toggles it off (speechRegistry.speak handles that toggle internally).
|
|
36
|
+
const uid = $props.id();
|
|
37
|
+
const bubbleId = `${persona}-${uid}`;
|
|
38
|
+
const speakable = $derived(persona === "assistant" && isSpeechSupported());
|
|
39
|
+
const speaking = $derived(speechRegistry.isSpeaking(bubbleId));
|
|
40
|
+
let bodyEl: HTMLDivElement | undefined;
|
|
41
|
+
function toggleSpeak() {
|
|
42
|
+
speechRegistry.speak(bubbleId, bodyEl?.textContent ?? "");
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="persona-bubble p-{persona}" {id}>
|
|
47
|
+
<div class="persona-head">
|
|
48
|
+
<span class="persona-head-left">
|
|
49
|
+
<span class="persona-tag">{label}</span>
|
|
50
|
+
</span>
|
|
51
|
+
{#if meta || actions?.length || onTogglePin || speakable}
|
|
52
|
+
<span class="persona-head-right">
|
|
53
|
+
{#if meta}<span class="persona-meta">{meta}</span>{/if}
|
|
54
|
+
{#if speakable}
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
class="persona-speak"
|
|
58
|
+
class:speaking
|
|
59
|
+
title={speaking ? "Stop reading" : "Read aloud"}
|
|
60
|
+
aria-label={speaking ? "Stop reading" : "Read aloud"}
|
|
61
|
+
aria-pressed={speaking}
|
|
62
|
+
onclick={toggleSpeak}
|
|
63
|
+
>{speaking ? "⏸" : "🔊"}</button>
|
|
64
|
+
{/if}
|
|
65
|
+
{#if actions?.length || onTogglePin}
|
|
66
|
+
<LeafActionMenu items={actions ?? []} {pinned} {onTogglePin} {pinDisplay} />
|
|
67
|
+
{/if}
|
|
68
|
+
</span>
|
|
69
|
+
{/if}
|
|
70
|
+
</div>
|
|
71
|
+
{#if children}<div class="persona-body" bind:this={bodyEl}>{@render children()}</div>{/if}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
.persona-speak {
|
|
76
|
+
flex-shrink: 0; border: none; background: none; cursor: pointer;
|
|
77
|
+
font-size: 0.95rem; line-height: 1; padding: 0.3rem; border-radius: 6px; color: var(--muted);
|
|
78
|
+
}
|
|
79
|
+
.persona-speak:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
|
|
80
|
+
.persona-speak.speaking { color: var(--accent); }
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Replaces the repeated `{#if saved}<span class="saved">✓ saved</span>{/if}` +
|
|
3
|
+
// `{#if saveError}<p class="save-error">{saveError}</p>{/if}` pair duplicated across every leaf
|
|
4
|
+
// editor. `saved` and `error` render independently, so a file with several unrelated error
|
|
5
|
+
// sources (attach error, translate error, …) can render this once near the checkmark with
|
|
6
|
+
// `saved` + its primary error, then again standalone with just `error` for each additional one.
|
|
7
|
+
interface Props {
|
|
8
|
+
saved?: boolean;
|
|
9
|
+
error?: string | null;
|
|
10
|
+
}
|
|
11
|
+
let { saved = false, error = null }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#if saved}<span class="saved">✓ saved</span>{/if}
|
|
15
|
+
{#if error}<p class="save-error">{error}</p>{/if}
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.saved { color: var(--accent); font-size: 0.8rem; font-weight: 600; }
|
|
19
|
+
.save-error { color: var(--alert); margin: 0.25rem 0 0.75rem; }
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The expand/collapse chevron of a sidebar group row, extracted so Chat's thread list can carry an
|
|
3
|
+
// identical one.
|
|
4
|
+
//
|
|
5
|
+
// Chat cannot simply use SidebarGroupList: that component renders its children as SidebarLeafRows,
|
|
6
|
+
// which have no per-row action slot, and a thread row needs rename/pin/delete. So the header is the
|
|
7
|
+
// shareable part and the list body is not. Sharing it keeps the glyph, the aria-expanded state and
|
|
8
|
+
// — the part a test selects on — the "Collapse <label>" / "Expand <label>" aria-label identical in
|
|
9
|
+
// both places, rather than a second hand-rolled copy that drifts the first time either changes.
|
|
10
|
+
interface Props {
|
|
11
|
+
expanded: boolean;
|
|
12
|
+
label: string;
|
|
13
|
+
onToggle: () => void;
|
|
14
|
+
}
|
|
15
|
+
let { expanded, label, onToggle }: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<button
|
|
19
|
+
class="chevron"
|
|
20
|
+
aria-expanded={expanded}
|
|
21
|
+
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
|
|
22
|
+
onclick={(e) => { e.stopPropagation(); onToggle(); }}
|
|
23
|
+
>{expanded ? "▾" : "▸"}</button>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
/* These styles belong to the chevron itself, not to whoever renders it. They lived in
|
|
27
|
+
SidebarGroupList's scoped block, so extracting the button left every group chevron in the app
|
|
28
|
+
unstyled — a raw boxed <button> — while Chat's copy, which had its own :global override, stayed
|
|
29
|
+
correct. Scoped CSS only reaches a component's own markup; a shared element has to carry it. */
|
|
30
|
+
.chevron {
|
|
31
|
+
flex-shrink: 0; border: none; background: none; color: var(--muted); cursor: pointer;
|
|
32
|
+
font: inherit; font-size: 0.75rem; line-height: 1; padding: 0.4rem; width: 1.4rem;
|
|
33
|
+
text-align: center; border-radius: 6px;
|
|
34
|
+
}
|
|
35
|
+
.chevron:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
36
|
+
</style>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import SidebarLeafList from "./SidebarLeafList.svelte";
|
|
3
|
+
import type { SidebarLeafRow, SidebarGroupRow } from "./sidebar-rows";
|
|
4
|
+
import SidebarGroupChevron from "./SidebarGroupChevron.svelte";
|
|
5
|
+
import type { LeafMenuItem } from "@tinytars/frame/menu-items";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
rows: SidebarGroupRow[];
|
|
11
|
+
activeKey: string | null;
|
|
12
|
+
// Shown (in place of the rows a not-yet-established grouping would otherwise contribute) when
|
|
13
|
+
// the owning leaf's groups aren't established yet — the caller supplies the wording.
|
|
14
|
+
pendingNote?: string | null;
|
|
15
|
+
onSelect: (key: string) => void;
|
|
16
|
+
// A click on one of a group's nested children, once expanded; bubbled straight through to
|
|
17
|
+
// SidebarLeafList's own onSelect (a pure "scroll to it," never a view switch). The owning
|
|
18
|
+
// row's key comes along so a caller can activate that group as well as scroll to the child —
|
|
19
|
+
// clicking a child under a group should land you in that group too. Passing the key explicitly
|
|
20
|
+
// is what lets this survive a leaf whose current view already contains the anchor.
|
|
21
|
+
onSelectChild?: (row: SidebarLeafRow, groupKey: string) => void;
|
|
22
|
+
// Per-child capabilities, forwarded verbatim to SidebarLeafList — see its Props comment. All
|
|
23
|
+
// optional: a section that supplies none renders children exactly as before, with no menu.
|
|
24
|
+
activeChildKey?: string | null;
|
|
25
|
+
// Both receive the owning group's key: a section's All row can list a different KIND of thing
|
|
26
|
+
// than its other rows, so the capabilities may differ per group, not just per section.
|
|
27
|
+
childActions?: (row: SidebarLeafRow, groupKey: string) => LeafMenuItem[];
|
|
28
|
+
childPinFor?: (row: SidebarLeafRow, groupKey: string) => { pinned: boolean; onTogglePin: () => void } | null;
|
|
29
|
+
renamingKey?: string | null;
|
|
30
|
+
renameText?: string;
|
|
31
|
+
onCommitRename?: () => void;
|
|
32
|
+
}
|
|
33
|
+
let {
|
|
34
|
+
rows, activeKey, pendingNote = null, onSelect, onSelectChild,
|
|
35
|
+
activeChildKey = null, childActions, childPinFor,
|
|
36
|
+
renamingKey = $bindable(null), renameText = $bindable(""), onCommitRename,
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
// Component-local, not persisted: reset whenever the caller remounts this component (a caller
|
|
40
|
+
// that switches sections should remount fresh — otherwise a key reused across unrelated sections
|
|
41
|
+
// would leak expand state between them).
|
|
42
|
+
let expanded = $state(new Set(rows.filter((r) => r.defaultExpanded).map((r) => r.key)));
|
|
43
|
+
|
|
44
|
+
function toggle(key: string) {
|
|
45
|
+
if (expanded.has(key)) expanded.delete(key);
|
|
46
|
+
else expanded.add(key);
|
|
47
|
+
expanded = new Set(expanded);
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="group-list">
|
|
52
|
+
{#each rows as r (r.key)}
|
|
53
|
+
<div class="side-row">
|
|
54
|
+
{#if r.children}
|
|
55
|
+
<SidebarGroupChevron expanded={expanded.has(r.key)} label={r.label} onToggle={() => toggle(r.key)} />
|
|
56
|
+
{/if}
|
|
57
|
+
<button class="sub-item" class:active={activeKey === r.key} title={r.title} onclick={() => onSelect(r.key)}>
|
|
58
|
+
{r.label}{#if r.count !== undefined} ({r.count}){/if}
|
|
59
|
+
</button>
|
|
60
|
+
{#if r.action}
|
|
61
|
+
<button class="side-row-action" title={r.action.label} aria-label={r.action.label}
|
|
62
|
+
onclick={(e) => { e.stopPropagation(); r.action!.onClick(); }}>+</button>
|
|
63
|
+
{/if}
|
|
64
|
+
</div>
|
|
65
|
+
{#if r.children && expanded.has(r.key)}
|
|
66
|
+
<div class="group-children">
|
|
67
|
+
<SidebarLeafList
|
|
68
|
+
rows={r.children}
|
|
69
|
+
activeKey={activeChildKey}
|
|
70
|
+
onSelect={(row) => onSelectChild?.(row, r.key)}
|
|
71
|
+
actions={childActions ? (row) => childActions(row, r.key) : undefined}
|
|
72
|
+
pinFor={childPinFor ? (row) => childPinFor(row, r.key) : undefined}
|
|
73
|
+
bind:renamingKey
|
|
74
|
+
bind:renameText
|
|
75
|
+
{onCommitRename}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
{/if}
|
|
79
|
+
{/each}
|
|
80
|
+
{#if pendingNote}<p class="group-pending">ⓘ {pendingNote}</p>{/if}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
/* Distinct container class from a caller's own top-level accordion — this is a sibling list
|
|
85
|
+
below the divider, not another accordion sub-list, and reusing the accordion's class here
|
|
86
|
+
made two elements match the same selector. Row-level classes (.side-row/.sub-item) are
|
|
87
|
+
duplicated verbatim from that top-level markup so it renders identically — Svelte's CSS
|
|
88
|
+
scoping doesn't reach into child-component markup. */
|
|
89
|
+
.group-list { display: flex; flex-direction: column; gap: 0.1rem; padding: 0 0.25rem; }
|
|
90
|
+
:global(.sidebar.rail) .group-list { display: none; }
|
|
91
|
+
@media (max-width: 640px) {
|
|
92
|
+
:global(.sidebar.rail) .group-list { display: flex; }
|
|
93
|
+
}
|
|
94
|
+
.side-row { display: flex; align-items: center; gap: 0.15rem; padding: 0.1rem 0.25rem; }
|
|
95
|
+
.sub-item {
|
|
96
|
+
border: none; background: none; text-align: left; font: inherit; font-size: 0.88rem;
|
|
97
|
+
color: var(--muted); cursor: pointer; padding: 0.45rem 0.4rem; border-radius: 6px; min-height: 40px;
|
|
98
|
+
flex: 1; min-width: 0;
|
|
99
|
+
}
|
|
100
|
+
.sub-item:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
|
101
|
+
.sub-item.active { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
102
|
+
.group-pending { margin: 0.3rem 0.6rem; font-size: 0.78rem; font-style: italic; color: var(--muted); }
|
|
103
|
+
|
|
104
|
+
/* Duplicated verbatim from a caller's own .side-row-action, same reason as the row classes
|
|
105
|
+
above: child-component styles don't inherit from the parent's <style> block. */
|
|
106
|
+
.side-row-action {
|
|
107
|
+
flex-shrink: 0; border: none; background: var(--band); color: var(--accent); cursor: pointer;
|
|
108
|
+
font: inherit; font-size: 1.15rem; line-height: 1; padding: 0.4rem 0.5rem; border-radius: 6px;
|
|
109
|
+
min-height: 36px; min-width: 36px;
|
|
110
|
+
}
|
|
111
|
+
.side-row:hover .side-row-action { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
|
112
|
+
:global(.sidebar.rail) .side-row-action { display: none; }
|
|
113
|
+
@media (max-width: 640px) {
|
|
114
|
+
:global(.sidebar.rail) .side-row-action { display: inline-flex; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* A group's nested children, indented one level under its own chevron/label. */
|
|
118
|
+
.group-children { padding-left: 1.5rem; }
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// THE sidebar row. Every list of individual items in the sidebar can render through this
|
|
3
|
+
// component: a group's expanded children and a thread list alike.
|
|
4
|
+
//
|
|
5
|
+
// It used to be split: SidebarGroupList drew group rows, this drew children, and a thread list
|
|
6
|
+
// drew a hand-rolled copy of both because — and only because — a thread row needs per-row
|
|
7
|
+
// actions and a child row had none. That single missing capability bought a second All row at a
|
|
8
|
+
// different font size, an un-indented child list, a different label colour, and a rail rule that
|
|
9
|
+
// disagreed with the other two. Adding the actions here is what let that hand-rolled copy be
|
|
10
|
+
// deleted.
|
|
11
|
+
//
|
|
12
|
+
// Actions are OPTIONAL and supplied by the caller: absent means no menu, exactly as before. Which
|
|
13
|
+
// sections get which actions is a per-section decision, not something a row can assume.
|
|
14
|
+
import LeafActionMenu from "@tinytars/frame/LeafActionMenu.svelte";
|
|
15
|
+
import type { LeafMenuItem } from "@tinytars/frame/menu-items";
|
|
16
|
+
import DictateButton from "./DictateButton.svelte";
|
|
17
|
+
import type { SidebarLeafRow } from "./sidebar-rows";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
rows: SidebarLeafRow[];
|
|
23
|
+
// The row currently open, when a row click is a view switch (Chat). Null where a click is a pure
|
|
24
|
+
// scroll-to, which is every other section.
|
|
25
|
+
activeKey?: string | null;
|
|
26
|
+
onSelect: (row: SidebarLeafRow) => void;
|
|
27
|
+
// Per-row menu items, minus Pin/Unpin — LeafActionMenu prepends that itself from `pinFor`.
|
|
28
|
+
actions?: (row: SidebarLeafRow) => LeafMenuItem[];
|
|
29
|
+
// Returning null means this row cannot be pinned, so it shows no ★ and no Pin item.
|
|
30
|
+
pinFor?: (row: SidebarLeafRow) => { pinned: boolean; onTogglePin: () => void } | null;
|
|
31
|
+
// Inline rename, bindable so a caller can start one from elsewhere (Chat's header Rename action
|
|
32
|
+
// targets the currently-open thread).
|
|
33
|
+
renamingKey?: string | null;
|
|
34
|
+
renameText?: string;
|
|
35
|
+
onCommitRename?: () => void;
|
|
36
|
+
}
|
|
37
|
+
let {
|
|
38
|
+
rows, activeKey = null, onSelect, actions, pinFor,
|
|
39
|
+
renamingKey = $bindable(null), renameText = $bindable(""), onCommitRename,
|
|
40
|
+
}: Props = $props();
|
|
41
|
+
|
|
42
|
+
function menuFor(r: SidebarLeafRow): { items: LeafMenuItem[]; pin: { pinned: boolean; onTogglePin: () => void } | null } | null {
|
|
43
|
+
const pin = pinFor?.(r) ?? null;
|
|
44
|
+
const items = actions?.(r) ?? [];
|
|
45
|
+
// No capabilities at all → no trigger. A ⋮ that opens an empty menu is worse than no ⋮.
|
|
46
|
+
return pin || items.length > 0 ? { items, pin } : null;
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<div class="leaf-list">
|
|
51
|
+
{#each rows as r (r.key)}
|
|
52
|
+
{@const menu = menuFor(r)}
|
|
53
|
+
<div class="side-row" id={r.domId}>
|
|
54
|
+
{#if renamingKey === r.key}
|
|
55
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
56
|
+
<input
|
|
57
|
+
class="rename-input"
|
|
58
|
+
bind:value={renameText}
|
|
59
|
+
onblur={onCommitRename}
|
|
60
|
+
onkeydown={(e) => { if (e.key === "Enter") onCommitRename?.(); if (e.key === "Escape") renamingKey = null; }}
|
|
61
|
+
autofocus
|
|
62
|
+
/>
|
|
63
|
+
<DictateButton onResult={(text) => { renameText = renameText ? `${renameText} ${text}` : text; onCommitRename?.(); }} />
|
|
64
|
+
{:else}
|
|
65
|
+
<button class="sub-item" class:active={activeKey === r.key} title={r.label} onclick={() => onSelect(r)}>{r.label}</button>
|
|
66
|
+
{#if menu}
|
|
67
|
+
<div class="row-actions">
|
|
68
|
+
<LeafActionMenu
|
|
69
|
+
items={menu.items}
|
|
70
|
+
pinned={menu.pin?.pinned ?? false}
|
|
71
|
+
onTogglePin={menu.pin ? menu.pin.onTogglePin : undefined}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
{/if}
|
|
76
|
+
</div>
|
|
77
|
+
{/each}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<style>
|
|
81
|
+
/* Same slot a section's own .group-list occupies below the sidebar's divider — hidden on the
|
|
82
|
+
desktop icon rail, forced visible in the mobile drawer. The container class stays distinct
|
|
83
|
+
from .group-list on purpose: a caller's e2e specs can scope `.sub-item` through it. */
|
|
84
|
+
.leaf-list { display: flex; flex-direction: column; gap: 0.15rem; padding: 0 0.25rem; }
|
|
85
|
+
:global(.sidebar.rail) .leaf-list { display: none; }
|
|
86
|
+
@media (max-width: 640px) {
|
|
87
|
+
:global(.sidebar.rail) .leaf-list { display: flex; }
|
|
88
|
+
}
|
|
89
|
+
.side-row { display: flex; align-items: center; gap: 0.15rem; padding: 0.1rem 0.25rem; }
|
|
90
|
+
.sub-item {
|
|
91
|
+
border: none; background: none; text-align: left; font: inherit; font-size: 0.88rem;
|
|
92
|
+
color: var(--muted); cursor: pointer; padding: 0.45rem 0.4rem; border-radius: 6px; min-height: 40px;
|
|
93
|
+
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
94
|
+
}
|
|
95
|
+
.sub-item:hover { color: var(--fg); background: color-mix(in srgb, var(--accent) 6%, transparent); }
|
|
96
|
+
/* Matches SidebarGroupList's own .sub-item.active — the label is the highlight pill, not the row.
|
|
97
|
+
Chat used to highlight the whole row instead; one of the two had to go. */
|
|
98
|
+
.sub-item.active { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
|
99
|
+
.row-actions { display: flex; align-items: center; gap: 0.35rem; flex: none; }
|
|
100
|
+
.rename-input {
|
|
101
|
+
flex: 1; min-width: 0; font: inherit; font-size: 0.88rem; padding: 0.35rem 0.4rem;
|
|
102
|
+
border: 1px solid var(--accent); border-radius: 6px;
|
|
103
|
+
}
|
|
104
|
+
@media (max-width: 640px) {
|
|
105
|
+
.sub-item { min-height: 44px; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Domain-neutral shape for one stored file, kept structurally compatible with a caller's own
|
|
2
|
+
// attachment type so it can pass its own values straight through without either side importing
|
|
3
|
+
// the other's type — the same pattern Diagnostics.svelte's DiagnosticsLogEntry uses.
|
|
4
|
+
export interface Attachment {
|
|
5
|
+
key: string;
|
|
6
|
+
name: string;
|
|
7
|
+
mediaType: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
addedAt: string;
|
|
10
|
+
extracted?: {
|
|
11
|
+
at: string;
|
|
12
|
+
chars: number;
|
|
13
|
+
kind?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
package/concurrency.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function runWithConcurrency(items: string[], limit: number, fn: (item: string) => Promise<void>): Promise<void> {
|
|
2
|
+
let idx = 0;
|
|
3
|
+
async function worker(): Promise<void> {
|
|
4
|
+
while (idx < items.length) {
|
|
5
|
+
const i = idx++;
|
|
6
|
+
await fn(items[i]);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Only one field dictates at a time: starting a new recognition session stops whichever other
|
|
2
|
+
// field's was in progress. Plain closure-based coordinator, not $state — unlike
|
|
3
|
+
// speech-registry.svelte.ts (where every caller of the reactive registry needs to know whether IT
|
|
4
|
+
// is the one currently speaking), only the single DictateButton that's listening needs to react to
|
|
5
|
+
// being stopped, and it already gets that from its own SpeechRecognition's `onend`. Mirrors
|
|
6
|
+
// attach-controller.ts's shape (a bare singleton callback slot), not menu-registry's.
|
|
7
|
+
let activeStop: (() => void) | null = null;
|
|
8
|
+
|
|
9
|
+
export function claimDictation(stop: () => void): void {
|
|
10
|
+
activeStop?.();
|
|
11
|
+
activeStop = stop;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function releaseDictation(stop: () => void): void {
|
|
15
|
+
if (activeStop === stop) activeStop = null;
|
|
16
|
+
}
|
package/filter.test.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { filterTokens, matchesTokens, onlyIndexed } from "./filter";
|
|
3
|
+
|
|
4
|
+
describe("filterTokens", () => {
|
|
5
|
+
it("lowercases, splits on whitespace, drops empties", () => {
|
|
6
|
+
expect(filterTokens("Rosuvastatin 10mg")).toEqual(["rosuvastatin", "10mg"]);
|
|
7
|
+
expect(filterTokens(" Vitamin D ")).toEqual(["vitamin", "d"]);
|
|
8
|
+
expect(filterTokens("")).toEqual([]);
|
|
9
|
+
expect(filterTokens(" ")).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("matchesTokens", () => {
|
|
14
|
+
it("empty query matches everything", () => {
|
|
15
|
+
expect(matchesTokens("anything", [])).toBe(true);
|
|
16
|
+
expect(matchesTokens("", [])).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is case-insensitive substring match", () => {
|
|
20
|
+
expect(matchesTokens("Rosuvastatin drug", filterTokens("ROSU"))).toBe(true);
|
|
21
|
+
expect(matchesTokens("Rosuvastatin drug", filterTokens("statin"))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("requires every token to match (AND)", () => {
|
|
25
|
+
expect(matchesTokens("Rosuvastatin 10mg drug", filterTokens("rosuvastatin drug"))).toBe(true);
|
|
26
|
+
expect(matchesTokens("Rosuvastatin 10mg drug", filterTokens("rosuvastatin supplement"))).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("onlyIndexed", () => {
|
|
31
|
+
it("passes through untouched when only is undefined", () => {
|
|
32
|
+
expect(onlyIndexed(["a", "b", "c"], undefined)).toEqual(["a", "b", "c"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reduces to just the one index", () => {
|
|
36
|
+
expect(onlyIndexed(["a", "b", "c"], 1)).toEqual(["b"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns empty for an out-of-range index", () => {
|
|
40
|
+
expect(onlyIndexed(["a", "b", "c"], 5)).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/filter.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A shared view-only text filter, extracted so every caller matches the exact same behavior:
|
|
2
|
+
// lowercase, split on whitespace, AND-of-tokens substring match. Never touches saved data.
|
|
3
|
+
export function filterTokens(q: string): string[] {
|
|
4
|
+
return q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function matchesTokens(hay: string, tokens: string[]): boolean {
|
|
8
|
+
if (tokens.length === 0) return true;
|
|
9
|
+
const h = hay.toLowerCase();
|
|
10
|
+
return tokens.every((t) => h.includes(t));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Reduces a list to just one true index (a scoped search result), or passes it through
|
|
14
|
+
// untouched when `only` is undefined (the normal, unscoped render path).
|
|
15
|
+
export function onlyIndexed<T>(items: T[], only: number | undefined): T[] {
|
|
16
|
+
if (only === undefined) return items;
|
|
17
|
+
const item = items[only];
|
|
18
|
+
return item === undefined ? [] : [item];
|
|
19
|
+
}
|
package/group-filter.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What a section renders for the current sidebar group selection — ONE rule, shared by every
|
|
2
|
+
// section, because they had each invented their own and two of them got it wrong.
|
|
3
|
+
//
|
|
4
|
+
// The bug this exists to kill: two sections resolved the active group as
|
|
5
|
+
// `activeGroup && groups.some(g => g.key === activeGroup) ? activeGroup : groups[0].key`.
|
|
6
|
+
// The sidebar emits a row per group even at count 0, so selecting an EMPTY group fell through
|
|
7
|
+
// that ternary and rendered the FIRST group's cells instead — the section looked like it had
|
|
8
|
+
// ignored the click, or worse, like those cells belonged to the group you picked. A third section
|
|
9
|
+
// was worse still: it took no group at all and only ever scrolled.
|
|
10
|
+
//
|
|
11
|
+
// The rule: All (or nothing selected) shows everything; a named group shows exactly its own items,
|
|
12
|
+
// even when that is none. Rendering nothing is a correct answer, and the caller shows an empty
|
|
13
|
+
// state saying so. Never substitute another group's content.
|
|
14
|
+
// allGroupKey is caller-supplied rather than a frame-owned constant: which key means "all groups"
|
|
15
|
+
// is a per-app storage convention, not something generic UI logic should hardcode.
|
|
16
|
+
export function isAllGroup(activeGroup: string | null | undefined, allGroupKey: string): boolean {
|
|
17
|
+
return !activeGroup || activeGroup === allGroupKey;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function filterByGroup<T>(
|
|
21
|
+
items: T[],
|
|
22
|
+
activeGroup: string | null | undefined,
|
|
23
|
+
groupOf: (item: T) => string | undefined | null,
|
|
24
|
+
allGroupKey: string,
|
|
25
|
+
): T[] {
|
|
26
|
+
if (isAllGroup(activeGroup, allGroupKey)) return items;
|
|
27
|
+
return items.filter((i) => groupOf(i) === activeGroup);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The same rule as filterByGroup, one level down: a selected CHILD row narrows its section to that
|
|
32
|
+
* one item. `null` (nothing selected) shows everything, exactly as an All group does.
|
|
33
|
+
*
|
|
34
|
+
* Some sections need this because their child rows ARE their cells one-for-one, so clicking one
|
|
35
|
+
* and having the page merely scroll reads as the click being ignored, the same complaint that
|
|
36
|
+
* produced filterByGroup. Sections whose children are not their cells (a note among many notes)
|
|
37
|
+
* pass no activeLeaf and are unaffected.
|
|
38
|
+
*/
|
|
39
|
+
export function filterByLeaf<T>(
|
|
40
|
+
items: T[],
|
|
41
|
+
activeLeaf: string | null | undefined,
|
|
42
|
+
keyOf: (item: T) => string | undefined,
|
|
43
|
+
): T[] {
|
|
44
|
+
if (!activeLeaf) return items;
|
|
45
|
+
const hit = items.filter((i) => keyOf(i) === activeLeaf);
|
|
46
|
+
// A leaf key that matches nothing here (a stale selection from another section, or a row whose
|
|
47
|
+
// section does not filter) shows everything rather than an empty page — the same defensive
|
|
48
|
+
// fallback a caller's own resolvedGroup logic would want.
|
|
49
|
+
return hit.length ? hit : items;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Which group a section RENDERS, as opposed to which items it then shows.
|
|
54
|
+
*
|
|
55
|
+
* The selected key wins whenever it names a row the sidebar actually emits — including a row with
|
|
56
|
+
* nothing in it. Falling back from an empty-but-real row is the substitution filterByGroup exists
|
|
57
|
+
* to prevent, one level up: a caller's own group ladder did exactly that for a group emitted at
|
|
58
|
+
* count 0, dropping the user on the whole unfiltered list. `fallback` is for keys that name NO
|
|
59
|
+
* row: nothing selected yet, or one left over from another section or a regen that dropped a
|
|
60
|
+
* group.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveGroup(
|
|
63
|
+
activeGroup: string | null | undefined,
|
|
64
|
+
keys: Iterable<string>,
|
|
65
|
+
fallback: () => string | null,
|
|
66
|
+
): string | null {
|
|
67
|
+
if (activeGroup && new Set(keys).has(activeGroup)) return activeGroup;
|
|
68
|
+
return fallback();
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/frame",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,26 +23,51 @@
|
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"*.ts",
|
|
26
|
-
"*.svelte"
|
|
26
|
+
"*.svelte",
|
|
27
|
+
"*.css"
|
|
27
28
|
],
|
|
28
29
|
"exports": {
|
|
29
30
|
"./account-methods.svelte": "./account-methods.svelte.ts",
|
|
30
31
|
"./AccountMenu.svelte": "./AccountMenu.svelte",
|
|
31
32
|
"./anchored-menu.svelte": "./anchored-menu.svelte.ts",
|
|
32
33
|
"./attach-controller": "./attach-controller.ts",
|
|
34
|
+
"./AttachmentStrip.svelte": "./AttachmentStrip.svelte",
|
|
35
|
+
"./AttachmentViewer.svelte": "./AttachmentViewer.svelte",
|
|
33
36
|
"./AttachPicker.svelte": "./AttachPicker.svelte",
|
|
34
37
|
"./brand": "./brand.ts",
|
|
38
|
+
"./Button.svelte": "./Button.svelte",
|
|
39
|
+
"./concurrency": "./concurrency.ts",
|
|
35
40
|
"./Diagnostics.svelte": "./Diagnostics.svelte",
|
|
41
|
+
"./DictateButton.svelte": "./DictateButton.svelte",
|
|
42
|
+
"./dictate-registry": "./dictate-registry.ts",
|
|
36
43
|
"./ExportTab.svelte": "./ExportTab.svelte",
|
|
44
|
+
"./Field.svelte": "./Field.svelte",
|
|
45
|
+
"./filter": "./filter.ts",
|
|
46
|
+
"./FormGrid.svelte": "./FormGrid.svelte",
|
|
47
|
+
"./group-filter": "./group-filter.ts",
|
|
37
48
|
"./LeafActionMenu.svelte": "./LeafActionMenu.svelte",
|
|
38
49
|
"./LeafCard.svelte": "./LeafCard.svelte",
|
|
39
50
|
"./LoginScreen.svelte": "./LoginScreen.svelte",
|
|
40
51
|
"./menu-items": "./menu-items.ts",
|
|
41
52
|
"./menu-registry.svelte": "./menu-registry.svelte.ts",
|
|
53
|
+
"./Modal.svelte": "./Modal.svelte",
|
|
54
|
+
"./ModalActions.svelte": "./ModalActions.svelte",
|
|
42
55
|
"./Onboarding.svelte": "./Onboarding.svelte",
|
|
56
|
+
"./pdf-render": "./pdf-render.ts",
|
|
57
|
+
"./PdfThumbnail.svelte": "./PdfThumbnail.svelte",
|
|
58
|
+
"./persisted-json": "./persisted-json.ts",
|
|
59
|
+
"./PersonaBubble.svelte": "./PersonaBubble.svelte",
|
|
43
60
|
"./recovery-controller.svelte": "./recovery-controller.svelte.ts",
|
|
44
61
|
"./roster-session.svelte": "./roster-session.svelte.ts",
|
|
62
|
+
"./SaveStatus.svelte": "./SaveStatus.svelte",
|
|
63
|
+
"./sidebar-rows": "./sidebar-rows.ts",
|
|
64
|
+
"./SidebarGroupChevron.svelte": "./SidebarGroupChevron.svelte",
|
|
65
|
+
"./SidebarGroupList.svelte": "./SidebarGroupList.svelte",
|
|
66
|
+
"./SidebarLeafList.svelte": "./SidebarLeafList.svelte",
|
|
67
|
+
"./speech-registry.svelte": "./speech-registry.svelte.ts",
|
|
45
68
|
"./support-access.svelte": "./support-access.svelte.ts",
|
|
69
|
+
"./theme.css": "./theme.css",
|
|
70
|
+
"./time-ago": "./time-ago.ts",
|
|
46
71
|
"./vault-principals.svelte": "./vault-principals.svelte.ts",
|
|
47
72
|
"./vault-session.svelte": "./vault-session.svelte.ts",
|
|
48
73
|
"./VisibilitySettings.svelte": "./VisibilitySettings.svelte"
|
|
@@ -51,7 +76,8 @@
|
|
|
51
76
|
"typecheck": "svelte-check --tsconfig ./tsconfig.json --threshold error"
|
|
52
77
|
},
|
|
53
78
|
"dependencies": {
|
|
54
|
-
"@tinytars/vault": "^0.1.15"
|
|
79
|
+
"@tinytars/vault": "^0.1.15",
|
|
80
|
+
"pdfjs-dist": "^6.2.108"
|
|
55
81
|
},
|
|
56
82
|
"peerDependencies": {
|
|
57
83
|
"svelte": "^5.0.0"
|
|
@@ -60,6 +86,8 @@
|
|
|
60
86
|
"@tsconfig/svelte": "^5.0.8",
|
|
61
87
|
"svelte": "^5.55.5",
|
|
62
88
|
"svelte-check": "^4.4.8",
|
|
63
|
-
"typescript": "~6.0.2"
|
|
89
|
+
"typescript": "~6.0.2",
|
|
90
|
+
"vite": "^8.0.12",
|
|
91
|
+
"vitest": "^4.1.8"
|
|
64
92
|
}
|
|
65
93
|
}
|