@heyramzi/ui 2.7.0 → 2.9.0

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.
Files changed (132) hide show
  1. package/COMPONENTS.md +1 -1
  2. package/README.md +1 -1
  3. package/package.json +58 -74
  4. package/src/agents/vendor/draw.ts +4 -4
  5. package/src/ai/generation-progress/GenerationProgress.svelte +2 -2
  6. package/src/animations/README.md +1 -1
  7. package/src/animations/card-tilt.css +1 -1
  8. package/src/animations/digit-pop-in.css +1 -1
  9. package/src/animations/input-clear-dissolve.css +2 -2
  10. package/src/animations/learn-more-hover.css +2 -2
  11. package/src/animations/like-button.css +1 -1
  12. package/src/animations/plus-menu-morph.css +1 -1
  13. package/src/animations/success-check.css +5 -5
  14. package/src/canvas/headless/canvas-cleanup.test.ts +1 -1
  15. package/src/canvas/ops/ops.ts +1 -1
  16. package/src/charts/area-chart.svelte +11 -1
  17. package/src/charts/bar-chart.css +2 -1
  18. package/src/charts/bar-chart.svelte +18 -3
  19. package/src/charts/charts.css +2 -1
  20. package/src/charts/line-chart.svelte +11 -1
  21. package/src/components/data-table/data-table.ts +4 -5
  22. package/src/components/data-table/pagination.svelte +5 -3
  23. package/src/components/docs/on-this-page.svelte +82 -5
  24. package/src/components/docs/scroll-spy.svelte.ts +6 -1
  25. package/src/components/list-page/detail-header.svelte +7 -3
  26. package/src/components/list-page/empty-state.svelte +4 -2
  27. package/src/components/list-page/filter-popover.svelte +2 -2
  28. package/src/components/list-page/rail-row.svelte +5 -2
  29. package/src/components/mark/mark.svelte +2 -1
  30. package/src/components/section-card/section-card.svelte +3 -1
  31. package/src/components/section-header/section-header.svelte +5 -5
  32. package/src/components/segmented/segment.svelte +1 -1
  33. package/src/components/table-view/table-view.svelte +6 -1
  34. package/src/components/tags-input/tags-input.svelte +1 -1
  35. package/src/components/toolbar/toolbar.svelte +1 -1
  36. package/src/design/index.css +7 -5
  37. package/src/editor/anchors.ts +28 -0
  38. package/src/editor/doc-blocks.ts +168 -0
  39. package/src/editor/doc-page.css +389 -0
  40. package/src/editor/editor.svelte +47 -5
  41. package/src/editor/extensions.ts +26 -2
  42. package/src/editor/markdown.ts +27 -2
  43. package/src/editor/slash-items.ts +18 -0
  44. package/src/effects/reveal/reveal.css +1 -1
  45. package/src/icons/trades.ts +1 -1
  46. package/src/inbox/README.md +6 -4
  47. package/src/inbox/inbox.test.ts +1 -1
  48. package/src/inbox/index.ts +2 -2
  49. package/src/inbox/logic.ts +29 -5
  50. package/src/inbox/thread-inbox.svelte +219 -92
  51. package/src/inbox/types.ts +27 -1
  52. package/src/lib/initials.test.ts +25 -0
  53. package/src/lib/initials.ts +29 -0
  54. package/src/lib/theme-transition.ts +6 -2
  55. package/src/modules.test.ts +4 -64
  56. package/src/panel/README.md +1 -1
  57. package/src/record/README.md +1 -1
  58. package/src/record/info-list.svelte +5 -4
  59. package/src/record/next-up.svelte +1 -1
  60. package/src/record/project-list.svelte +1 -1
  61. package/src/record/steps-rail.svelte +3 -3
  62. package/src/shadcn/badge/badge.svelte +1 -1
  63. package/src/shadcn/card/card-title.svelte +1 -5
  64. package/src/shadcn/empty/empty-description.svelte +1 -1
  65. package/src/shadcn/progress/progress.svelte +8 -1
  66. package/src/shadcn/table/table-cell.svelte +10 -2
  67. package/src/slides/theme.css +2 -2
  68. package/src/status/README.md +1 -1
  69. package/src/status/status-dot.svelte +3 -3
  70. package/src/thread/README.md +2 -0
  71. package/src/thread/index.ts +9 -1
  72. package/src/thread/logic.ts +33 -3
  73. package/src/thread/thread-body.svelte +37 -0
  74. package/src/thread/thread-events.svelte +30 -0
  75. package/src/thread/thread.svelte +74 -59
  76. package/src/thread/types.ts +21 -0
  77. package/src/tokens/craft.css +7 -5
  78. package/src/{tasks → work}/README.md +11 -8
  79. package/src/{tasks → work}/detail/task-thread.svelte +8 -15
  80. package/src/work/docs/doc-editor-page.svelte +194 -0
  81. package/src/work/docs/doc-menu.svelte +333 -0
  82. package/src/work/docs/docs-demo.svelte +227 -0
  83. package/src/work/docs/edited-chip.svelte +125 -0
  84. package/src/work/docs/edited.ts +54 -0
  85. package/src/work/docs/index.ts +38 -0
  86. package/src/work/docs/labels.ts +70 -0
  87. package/src/work/docs/menu.ts +127 -0
  88. package/src/work/docs/outline.ts +60 -0
  89. package/src/{tasks → work}/gantt/gantt-grid.svelte +2 -2
  90. package/src/{tasks → work}/gantt/gantt-group-row.svelte +1 -1
  91. package/src/{tasks → work}/gantt/gantt.css +1 -1
  92. package/src/{tasks → work}/inbox/inbox-card.svelte +113 -50
  93. package/src/work/inbox/inbox-logic.ts +98 -0
  94. package/src/work/inbox/inbox-trace.svelte +58 -0
  95. package/src/{tasks → work}/inbox/inbox-types.ts +2 -0
  96. package/src/{tasks → work}/index.ts +0 -10
  97. package/src/{tasks → work}/kanban-board.svelte +1 -1
  98. package/src/{tasks → work}/kanban-column.svelte +1 -1
  99. package/src/{tasks → work}/task-list.svelte +2 -2
  100. package/src/{tasks → work}/time/time-ring.svelte +1 -1
  101. package/src/{tasks → work}/time/time-tracker.svelte +3 -6
  102. package/src/tasks/inbox/inbox-logic.ts +0 -21
  103. package/src/tasks/inbox/inbox-trace.svelte +0 -51
  104. /package/src/{tasks → work}/ambient.d.ts +0 -0
  105. /package/src/{tasks → work}/board-order.ts +0 -0
  106. /package/src/{tasks → work}/dates.ts +0 -0
  107. /package/src/{tasks → work}/demo.svelte +0 -0
  108. /package/src/{tasks → work}/detail/detail-demo.svelte +0 -0
  109. /package/src/{tasks → work}/detail/detail-types.ts +0 -0
  110. /package/src/{tasks → work}/detail/task-chip.svelte +0 -0
  111. /package/src/{tasks → work}/detail/task-detail.svelte +0 -0
  112. /package/src/{tasks → work}/detail/task-property.svelte +0 -0
  113. /package/src/{tasks → work}/gantt/gantt-bar.svelte +0 -0
  114. /package/src/{tasks → work}/gantt/gantt-header.svelte +0 -0
  115. /package/src/{tasks → work}/gantt/gantt-task-row.svelte +0 -0
  116. /package/src/{tasks → work}/gantt/gantt.test.ts +0 -0
  117. /package/src/{tasks → work}/gantt/logic.test.ts +0 -0
  118. /package/src/{tasks → work}/gantt/logic.ts +0 -0
  119. /package/src/{tasks → work}/inbox/inbox-draft.svelte +0 -0
  120. /package/src/{tasks → work}/inbox/inbox-statuses.ts +0 -0
  121. /package/src/{tasks → work}/inbox/index.ts +0 -0
  122. /package/src/{tasks → work}/kanban-drag.ts +0 -0
  123. /package/src/{tasks → work}/kanban-types.ts +0 -0
  124. /package/src/{tasks → work}/presets/sales.ts +0 -0
  125. /package/src/{tasks → work}/reorder.ts +0 -0
  126. /package/src/{tasks → work}/task-gantt.svelte +0 -0
  127. /package/src/{tasks → work}/task-types.ts +0 -0
  128. /package/src/{tasks → work}/time/duration.ts +0 -0
  129. /package/src/{tasks → work}/time/time-chip.svelte +0 -0
  130. /package/src/{tasks → work}/time/time-dot.svelte +0 -0
  131. /package/src/{tasks → work}/time/time-types.ts +0 -0
  132. /package/src/{tasks → work}/use-kanban-dnd.svelte.ts +0 -0
@@ -0,0 +1,333 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import {
6
+ ArrowsOutCardinal,
7
+ CaretLeft,
8
+ CaretRight,
9
+ ClockCounterClockwise,
10
+ Alarm,
11
+ Clipboard,
12
+ CopySimple,
13
+ DotsThree,
14
+ FileDashed,
15
+ PencilSimple,
16
+ Star,
17
+ Trash,
18
+ } from "phosphor-svelte";
19
+ import type { Component, Snippet } from "svelte";
20
+ import {
21
+ AlertDialog,
22
+ AlertDialogAction,
23
+ AlertDialogCancel,
24
+ AlertDialogContent,
25
+ AlertDialogDescription,
26
+ AlertDialogFooter,
27
+ AlertDialogHeader,
28
+ AlertDialogTitle,
29
+ } from "../../shadcn/alert-dialog/index.js";
30
+ import { buttonVariants } from "../../shadcn/button/index.js";
31
+ import {
32
+ Command,
33
+ CommandEmpty,
34
+ CommandGroup,
35
+ CommandInput,
36
+ CommandItem,
37
+ CommandList,
38
+ CommandSeparator,
39
+ CommandShortcut,
40
+ } from "../../shadcn/command/index.js";
41
+ import { Popover, PopoverContent, PopoverTrigger } from "../../shadcn/popover/index.js";
42
+ import { docLabels, type DocLabels } from "./labels.js";
43
+ import {
44
+ copyOptions,
45
+ deleteName,
46
+ filterMenu,
47
+ menuItems,
48
+ shortcutLabel,
49
+ shortcutTarget,
50
+ type CopyKind,
51
+ type MenuId,
52
+ } from "./menu.js";
53
+
54
+ // =========================
55
+ // TYPES
56
+ // =========================
57
+ interface Props {
58
+ /** The doc's title. An empty one reads "Untitled" in the delete confirm. */
59
+ title: string;
60
+ /** The doc's address; "Copy URL" and "title as link" need it. */
61
+ url?: string;
62
+ /** The doc as Tiptap JSON; "Copy content as Markdown" needs it. */
63
+ content?: unknown;
64
+ favorited?: boolean;
65
+ /** Every item is a callback. One that isn't passed doesn't show. */
66
+ onmove?: () => void;
67
+ onduplicate?: () => void;
68
+ onrename?: () => void;
69
+ ontemplate?: () => void;
70
+ onfavorite?: () => void;
71
+ /** Passing it shows Copy. It runs after the text is on the clipboard, for a toast. */
72
+ oncopy?: (kind: CopyKind, text: string) => void;
73
+ onremind?: () => void;
74
+ onhistory?: () => void;
75
+ /** Passing it shows Delete, behind a confirm that names the doc. */
76
+ ondelete?: () => void;
77
+ /** One line under the confirm title: "and its 3 sub-pages". */
78
+ deleteDetail?: string;
79
+ /** Fire items from the keyboard (⇧R) while no field has focus. */
80
+ shortcuts?: boolean;
81
+ open?: boolean;
82
+ labels?: Partial<DocLabels>;
83
+ /** Replaces the round `…` button. Spread `props` on the element. */
84
+ trigger?: Snippet<[Record<string, unknown>]>;
85
+ }
86
+
87
+ // =========================
88
+ // PROPS
89
+ // =========================
90
+ let {
91
+ title,
92
+ url,
93
+ content,
94
+ favorited = false,
95
+ onmove,
96
+ onduplicate,
97
+ onrename,
98
+ ontemplate,
99
+ onfavorite,
100
+ oncopy,
101
+ onremind,
102
+ onhistory,
103
+ ondelete,
104
+ deleteDetail,
105
+ shortcuts = true,
106
+ open = $bindable(false),
107
+ labels: labelOverrides,
108
+ trigger,
109
+ }: Props = $props();
110
+
111
+ // =========================
112
+ // STATE
113
+ // =========================
114
+ let query = $state("");
115
+ let page = $state<"root" | "copy">("root");
116
+ let confirming = $state(false);
117
+
118
+ // =========================
119
+ // DERIVED
120
+ // =========================
121
+ let t = $derived(docLabels(labelOverrides));
122
+ let all = $derived(
123
+ menuItems(
124
+ {
125
+ move: !!onmove,
126
+ duplicate: !!onduplicate,
127
+ rename: !!onrename,
128
+ template: !!ontemplate,
129
+ favorite: !!onfavorite,
130
+ copy: !!oncopy,
131
+ remind: !!onremind,
132
+ history: !!onhistory,
133
+ delete: !!ondelete,
134
+ },
135
+ t,
136
+ { favorited },
137
+ ),
138
+ );
139
+ let visible = $derived(filterMenu(all, query));
140
+ // Groups keep their order; a hairline sits between the ones that still have an item.
141
+ let groups = $derived.by(() => {
142
+ const byGroup = new Map<number, typeof visible>();
143
+ for (const item of visible) byGroup.set(item.group, [...(byGroup.get(item.group) ?? []), item]);
144
+ return [...byGroup.entries()].toSorted((a, b) => a[0] - b[0]).map(([, items]) => items);
145
+ });
146
+ // Built only while the copy list is showing: the Markdown of a long doc isn't free.
147
+ let copies = $derived(
148
+ page === "copy" ? filterMenu(copyOptions({ title, url, content }, t), query) : [],
149
+ );
150
+ let confirmName = $derived(deleteName(title, t));
151
+
152
+ const ICONS: Record<MenuId, Component<{ class?: string }>> = {
153
+ move: ArrowsOutCardinal,
154
+ duplicate: CopySimple,
155
+ rename: PencilSimple,
156
+ template: FileDashed,
157
+ favorite: Star,
158
+ copy: Clipboard,
159
+ remind: Alarm,
160
+ history: ClockCounterClockwise,
161
+ delete: Trash,
162
+ };
163
+
164
+ // =========================
165
+ // FUNCTIONS
166
+ // =========================
167
+ function reset() {
168
+ query = "";
169
+ page = "root";
170
+ }
171
+
172
+ function fire(id: MenuId) {
173
+ const run: Partial<Record<MenuId, (() => void) | undefined>> = {
174
+ move: onmove,
175
+ duplicate: onduplicate,
176
+ rename: onrename,
177
+ template: ontemplate,
178
+ favorite: onfavorite,
179
+ remind: onremind,
180
+ history: onhistory,
181
+ };
182
+ if (id === "copy") {
183
+ query = "";
184
+ page = "copy";
185
+ return;
186
+ }
187
+ open = false;
188
+ if (id === "delete") confirming = true;
189
+ else run[id]?.();
190
+ }
191
+
192
+ async function copy(kind: CopyKind, text: string) {
193
+ open = false;
194
+ // A blocked clipboard still reports, so the host can say the copy didn't land.
195
+ await navigator.clipboard.writeText(text).catch(() => {});
196
+ oncopy?.(kind, text);
197
+ }
198
+
199
+ function onWindowKey(event: KeyboardEvent) {
200
+ if (!shortcuts || event.defaultPrevented || event.repeat) return;
201
+ const target = event.target as HTMLElement | null;
202
+ if (target?.closest("input, textarea, select, [contenteditable='true'], [role='dialog']"))
203
+ return;
204
+ const id = shortcutTarget(event, all);
205
+ if (!id) return;
206
+ event.preventDefault();
207
+ fire(id);
208
+ }
209
+
210
+ function onListKey(event: KeyboardEvent) {
211
+ if (page !== "copy") return;
212
+ const empty = query === "";
213
+ if (event.key === "ArrowLeft" || (event.key === "Backspace" && empty)) {
214
+ if (event.key === "ArrowLeft" && !empty) return;
215
+ event.preventDefault();
216
+ reset();
217
+ }
218
+ }
219
+ </script>
220
+
221
+ <svelte:window onkeydown={onWindowKey} />
222
+
223
+ <!-- =========================== -->
224
+ <!-- MARKUP -->
225
+ <!-- =========================== -->
226
+ <Popover
227
+ bind:open
228
+ onOpenChange={(next) => {
229
+ // Reset as it opens, not as it closes: a reset mid-fade flashes the root list.
230
+ if (next) reset();
231
+ }}
232
+ >
233
+ <PopoverTrigger aria-label={t.menu}>
234
+ {#snippet child({ props })}
235
+ {#if trigger}
236
+ {@render trigger(props)}
237
+ {:else}
238
+ <button
239
+ {...props}
240
+ type="button"
241
+ class="t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-foreground/6 aria-expanded:text-foreground any-pointer-coarse:size-10"
242
+ >
243
+ <DotsThree size={18} weight="bold" aria-hidden="true" />
244
+ </button>
245
+ {/if}
246
+ {/snippet}
247
+ </PopoverTrigger>
248
+ <PopoverContent align="start" sideOffset={6} class="w-64 gap-0 overflow-hidden p-0">
249
+ <Command shouldFilter={false} onkeydown={onListKey} class="rounded-xl">
250
+ <CommandInput
251
+ bind:value={query}
252
+ placeholder={page === "copy" ? t.copy : t.filter}
253
+ aria-label={t.filter}
254
+ />
255
+ <CommandList class="max-h-[22rem] p-1.5">
256
+ {#if page === "root"}
257
+ {#each groups as items, i (i)}
258
+ {#if i > 0 && !query}<CommandSeparator class="my-1.5" />{/if}
259
+ <CommandGroup class="p-0">
260
+ {#each items as item (item.id)}
261
+ {@const Icon = ICONS[item.id]}
262
+ <CommandItem
263
+ value={item.id}
264
+ onSelect={() => fire(item.id)}
265
+ class={item.danger ? "text-destructive data-selected:text-destructive" : ""}
266
+ >
267
+ <Icon
268
+ class={item.id === "favorite" && favorited
269
+ ? "text-brand"
270
+ : item.danger
271
+ ? ""
272
+ : "text-muted-foreground"}
273
+ />
274
+ <span class="flex-1 truncate">{item.label}</span>
275
+ {#if item.shortcut}
276
+ <CommandShortcut class="text-xs tracking-normal"
277
+ >{shortcutLabel(item.shortcut)}</CommandShortcut
278
+ >
279
+ {:else if item.submenu}
280
+ <CaretRight size={12} class="text-muted-foreground" aria-hidden="true" />
281
+ {/if}
282
+ </CommandItem>
283
+ {/each}
284
+ </CommandGroup>
285
+ {/each}
286
+ {#if visible.length === 0}
287
+ <CommandEmpty class="py-6 text-center text-[13px] text-muted-foreground">
288
+ {t.noResults}
289
+ </CommandEmpty>
290
+ {/if}
291
+ {:else}
292
+ <CommandGroup class="p-0">
293
+ {#if !query}
294
+ <CommandItem value="__back" onSelect={reset} class="text-muted-foreground">
295
+ <CaretLeft size={14} aria-hidden="true" />
296
+ <span>{t.back}</span>
297
+ </CommandItem>
298
+ <CommandSeparator class="my-1.5" />
299
+ {/if}
300
+ {#each copies as option (option.kind)}
301
+ <CommandItem value={option.kind} onSelect={() => copy(option.kind, option.text)}>
302
+ <span class="truncate">{option.label}</span>
303
+ </CommandItem>
304
+ {/each}
305
+ </CommandGroup>
306
+ {#if copies.length === 0}
307
+ <CommandEmpty class="py-6 text-center text-[13px] text-muted-foreground">
308
+ {t.noResults}
309
+ </CommandEmpty>
310
+ {/if}
311
+ {/if}
312
+ </CommandList>
313
+ </Command>
314
+ </PopoverContent>
315
+ </Popover>
316
+
317
+ <AlertDialog bind:open={confirming}>
318
+ <AlertDialogContent class="sm:max-w-sm">
319
+ <AlertDialogHeader>
320
+ <AlertDialogTitle>{t.deleteTitle(confirmName)}</AlertDialogTitle>
321
+ <AlertDialogDescription>{deleteDetail ?? t.deleteDetail}</AlertDialogDescription>
322
+ </AlertDialogHeader>
323
+ <AlertDialogFooter>
324
+ <AlertDialogCancel>{t.cancel}</AlertDialogCancel>
325
+ <AlertDialogAction
326
+ class={buttonVariants({ variant: "destructive" })}
327
+ onclick={() => ondelete?.()}
328
+ >
329
+ {t.delete}
330
+ </AlertDialogAction>
331
+ </AlertDialogFooter>
332
+ </AlertDialogContent>
333
+ </AlertDialog>
@@ -0,0 +1,227 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { Bell, FileText, LinkSimple, Star } from "phosphor-svelte";
6
+ import DocEditorPage from "./doc-editor-page.svelte";
7
+ import DocMenu from "./doc-menu.svelte";
8
+ import EditedChip from "./edited-chip.svelte";
9
+ import type { CopyKind } from "./menu.js";
10
+
11
+ // =========================
12
+ // TYPES
13
+ // =========================
14
+ type Node = Record<string, unknown>;
15
+
16
+ // =========================
17
+ // CONTENT
18
+ // =========================
19
+ const text = (value: string, marks?: Node[]): Node => ({
20
+ type: "text",
21
+ text: value,
22
+ marks,
23
+ });
24
+ const p = (...parts: (string | Node)[]): Node => ({
25
+ type: "paragraph",
26
+ content: parts.map((part) => (typeof part === "string" ? text(part) : part)),
27
+ });
28
+ const h = (level: number, value: string): Node => ({
29
+ type: "heading",
30
+ attrs: { level },
31
+ content: [text(value)],
32
+ });
33
+ const task = (value: string, checked = false): Node => ({
34
+ type: "taskItem",
35
+ attrs: { checked },
36
+ content: [p(value)],
37
+ });
38
+ const cell = (type: "tableHeader" | "tableCell", value: string): Node => ({
39
+ type,
40
+ content: [p(value)],
41
+ });
42
+ const row = (type: "tableHeader" | "tableCell", ...values: string[]): Node => ({
43
+ type: "tableRow",
44
+ content: values.map((value) => cell(type, value)),
45
+ });
46
+
47
+ const content: Node = {
48
+ type: "doc",
49
+ content: [
50
+ h(2, "Why this exists"),
51
+ p(
52
+ "The weekly report used to be copied by hand from ClickUp into a sheet. It took an hour and the numbers drifted. This doc says how the sync works, what it leaves out and what's still open.",
53
+ ),
54
+ h(2, "How it works"),
55
+ p(
56
+ "Every night a job reads the tasks changed that day and writes one row per task. It never deletes a row. A task that moves lists keeps its row and gets a new ",
57
+ text("list", [{ type: "code" }]),
58
+ " value.",
59
+ ),
60
+ h(3, "What a row holds"),
61
+ {
62
+ type: "table",
63
+ content: [
64
+ row("tableHeader", "Column", "Source", "Notes"),
65
+ row("tableCell", "Task", "name", "Linked back to ClickUp"),
66
+ row("tableCell", "Owner", "assignees[0]", "First assignee only"),
67
+ row("tableCell", "Hours", "time tracked", "Summed per day"),
68
+ ],
69
+ },
70
+ h(3, "The job"),
71
+ {
72
+ type: "codeBlock",
73
+ attrs: { language: "typescript" },
74
+ content: [
75
+ text(
76
+ 'export async function syncDay(day: string) {\n const tasks = await clickup.changedOn(day);\n const rows = tasks.map(toRow);\n await sheet.upsert(rows, { key: "id" });\n}',
77
+ ),
78
+ ],
79
+ },
80
+ h(2, "Before it ships"),
81
+ {
82
+ type: "taskList",
83
+ content: [
84
+ task("Backfill the last four weeks", true),
85
+ task("Decide what a deleted task does to its row"),
86
+ task("Give finance read access to the sheet"),
87
+ ],
88
+ },
89
+ {
90
+ type: "blockquote",
91
+ content: [
92
+ p(
93
+ "A report nobody trusts is worse than no report. Row counts get checked against ClickUp every Monday.",
94
+ ),
95
+ ],
96
+ },
97
+ h(2, "Open questions"),
98
+ p(
99
+ "Subtasks are flattened into their parent today. If finance wants hours per subtask, the key changes from the task id to the pair of ids, and the backfill runs again.",
100
+ ),
101
+ ],
102
+ };
103
+
104
+ // =========================
105
+ // STATE
106
+ // =========================
107
+ let title = $state("ClickUp to Sheets");
108
+ let favorited = $state(false);
109
+ let showAuthors = $state(false);
110
+ let editedAt = $state<Date>(new Date("2026-10-21T09:12:00"));
111
+ let note = $state("");
112
+
113
+ // =========================
114
+ // DERIVED
115
+ // =========================
116
+ let latest = $state<Node>(content);
117
+
118
+ const owner = { name: "Ramzi Tabka" };
119
+
120
+ // =========================
121
+ // FUNCTIONS
122
+ // =========================
123
+ function touched(json: Record<string, unknown>) {
124
+ latest = json;
125
+ editedAt = new Date();
126
+ }
127
+
128
+ function said(what: string) {
129
+ note = what;
130
+ }
131
+
132
+ function copied(kind: CopyKind) {
133
+ said(`Copied ${kind === "link" ? "title as link" : kind}`);
134
+ }
135
+ </script>
136
+
137
+ <!-- =========================== -->
138
+ <!-- MARKUP -->
139
+ <!-- =========================== -->
140
+ <div class="flex flex-col gap-2">
141
+ <div
142
+ class="h-[min(48rem,calc(100svh-9rem))] overflow-hidden rounded-xl border border-border bg-background"
143
+ >
144
+ <DocEditorPage
145
+ bind:title
146
+ {content}
147
+ onchange={touched}
148
+ oncopylink={() => said("Copied link to section")}
149
+ >
150
+ {#snippet breadcrumb()}
151
+ <span class="flex min-w-0 items-center gap-1.5 text-muted-foreground @max-xl/doc:hidden">
152
+ <FileText size={14} aria-hidden="true" />
153
+ <span class="truncate">Reporting</span>
154
+ </span>
155
+ <span class="px-0.5 text-muted-foreground/60 @max-xl/doc:hidden" aria-hidden="true">›</span>
156
+ <span class="flex min-w-0 items-center gap-1.5 font-medium">
157
+ <FileText size={14} class="shrink-0 text-muted-foreground" aria-hidden="true" />
158
+ <span class="truncate">{title.trim() || "New document"}</span>
159
+ </span>
160
+ {/snippet}
161
+
162
+ {#snippet menu()}
163
+ <button
164
+ type="button"
165
+ aria-label={favorited ? "Remove from favorites" : "Favorite"}
166
+ aria-pressed={favorited}
167
+ onclick={() => (favorited = !favorited)}
168
+ class="t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:size-10"
169
+ >
170
+ <Star
171
+ size={15}
172
+ weight={favorited ? "fill" : "regular"}
173
+ class={favorited ? "text-brand" : ""}
174
+ />
175
+ </button>
176
+ <DocMenu
177
+ {title}
178
+ url="https://ui.heyramzi.com/work/docs"
179
+ content={latest}
180
+ {favorited}
181
+ onmove={() => said("Move to")}
182
+ onduplicate={() => said("Duplicated")}
183
+ onrename={() => said("Rename")}
184
+ ontemplate={() => said("Template created")}
185
+ onfavorite={() => (favorited = !favorited)}
186
+ oncopy={copied}
187
+ onremind={() => said("Remind me")}
188
+ onhistory={() => said("History")}
189
+ ondelete={() => said("Deleted")}
190
+ />
191
+ {/snippet}
192
+
193
+ {#snippet meta()}
194
+ <EditedChip
195
+ {editedAt}
196
+ {owner}
197
+ lastEditor={owner}
198
+ showAuthorNames={showAuthors}
199
+ onshowauthors={(next) => (showAuthors = next)}
200
+ onhistory={() => said("History")}
201
+ />
202
+ {/snippet}
203
+
204
+ {#snippet actions()}
205
+ {@const button =
206
+ "t-press inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/6 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 any-pointer-coarse:size-10"}
207
+ <button
208
+ type="button"
209
+ aria-label="Copy link"
210
+ class="{button} @max-xl/doc:hidden"
211
+ onclick={() => said("Copied URL")}
212
+ >
213
+ <LinkSimple size={15} />
214
+ </button>
215
+ <button
216
+ type="button"
217
+ aria-label="Subscribe"
218
+ class={button}
219
+ onclick={() => said("Subscribed")}
220
+ >
221
+ <Bell size={15} />
222
+ </button>
223
+ {/snippet}
224
+ </DocEditorPage>
225
+ </div>
226
+ <p class="h-4 text-xs text-muted-foreground" aria-live="polite">{note}</p>
227
+ </div>
@@ -0,0 +1,125 @@
1
+ <script lang="ts">
2
+ // =========================
3
+ // IMPORTS
4
+ // =========================
5
+ import { ClockCounterClockwise, SlidersHorizontal } from "phosphor-svelte";
6
+ import { Avatar, AvatarFallback, AvatarImage } from "../../shadcn/avatar/index.js";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "../../shadcn/popover/index.js";
8
+ import { Switch } from "../../shadcn/switch/index.js";
9
+ import { initials } from "../../lib/initials.js";
10
+ import { editStamp, editedLabel, type DateInput } from "./edited.js";
11
+ import { docLabels, type DocLabels } from "./labels.js";
12
+
13
+ // =========================
14
+ // TYPES
15
+ // =========================
16
+ type Person = { name: string; image?: string | null };
17
+
18
+ interface Props {
19
+ /** When the doc last changed. Unreadable or missing leaves just "Edited". */
20
+ editedAt?: DateInput;
21
+ owner?: Person | null;
22
+ /** The last editor. Their time falls back to `editedAt`. */
23
+ lastEditor?: (Person & { at?: DateInput }) | null;
24
+ /** The switch row shows only when this is passed. */
25
+ showAuthorNames?: boolean;
26
+ onshowauthors?: (show: boolean) => void;
27
+ /** The history button shows only when this is passed. */
28
+ onhistory?: () => void;
29
+ labels?: Partial<DocLabels>;
30
+ timeZone?: string;
31
+ now?: Date;
32
+ }
33
+
34
+ // =========================
35
+ // PROPS
36
+ // =========================
37
+ let {
38
+ editedAt,
39
+ owner = null,
40
+ lastEditor = null,
41
+ showAuthorNames = false,
42
+ onshowauthors,
43
+ onhistory,
44
+ labels: labelOverrides,
45
+ timeZone,
46
+ now,
47
+ }: Props = $props();
48
+
49
+ // =========================
50
+ // DERIVED
51
+ // =========================
52
+ let t = $derived(docLabels(labelOverrides));
53
+ let chip = $derived(editedLabel(editedAt, t.edited, { timeZone, now }));
54
+ let editorStamp = $derived(editStamp(lastEditor?.at ?? editedAt, { timeZone, now }));
55
+ // Nothing to open: the chip is a plain label rather than a button that opens an empty card.
56
+ let hasDetails = $derived(!!onshowauthors || !!owner || !!lastEditor || !!onhistory);
57
+ </script>
58
+
59
+ {#snippet person(who: Person)}
60
+ <Avatar class="size-5">
61
+ {#if who.image}<AvatarImage src={who.image} alt="" />{/if}
62
+ <AvatarFallback class="text-[9px]">{initials(who.name)}</AvatarFallback>
63
+ </Avatar>
64
+ <span class="truncate text-foreground">{who.name}</span>
65
+ {/snippet}
66
+
67
+ <!-- =========================== -->
68
+ <!-- MARKUP -->
69
+ <!-- =========================== -->
70
+ {#if hasDetails}
71
+ <Popover>
72
+ <PopoverTrigger
73
+ class="t-press inline-flex h-7 items-center gap-1.5 rounded-md bg-foreground/5 px-2 @max-xl/doc:px-1.5 text-[13px] font-medium text-foreground/90 tabular-nums transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/8 focus-visible:ring-3 focus-visible:ring-ring/50 aria-expanded:bg-foreground/8 any-pointer-coarse:h-10"
74
+ >
75
+ <SlidersHorizontal size={14} class="text-muted-foreground" aria-hidden="true" />
76
+ <!-- On a narrow page the glyph stands alone, so the title keeps the bar. -->
77
+ <span class="@max-xl/doc:sr-only">{chip}</span>
78
+ </PopoverTrigger>
79
+ <PopoverContent align="end" sideOffset={6} class="w-[21.5rem] gap-0 p-0 text-[13px]">
80
+ <div class="divide-y divide-border/70">
81
+ {#if onshowauthors}
82
+ <div class="flex items-center justify-between px-4 py-3">
83
+ <span class="text-muted-foreground">{t.showAuthorNames}</span>
84
+ <Switch
85
+ checked={showAuthorNames}
86
+ onCheckedChange={(next) => onshowauthors?.(next)}
87
+ aria-label={t.showAuthorNames}
88
+ />
89
+ </div>
90
+ {/if}
91
+ {#if owner}
92
+ <div class="flex items-center gap-3 px-4 py-3">
93
+ <span class="w-20 shrink-0 text-muted-foreground">{t.ownedBy}</span>
94
+ <span class="flex min-w-0 items-center gap-2">{@render person(owner)}</span>
95
+ </div>
96
+ {/if}
97
+ {#if lastEditor}
98
+ <div class="flex items-center gap-3 px-4 py-3">
99
+ <span class="w-20 shrink-0 text-muted-foreground">{t.lastEditBy}</span>
100
+ <span class="flex min-w-0 flex-1 items-center gap-2">{@render person(lastEditor)}</span>
101
+ {#if editorStamp}
102
+ <span class="shrink-0 text-xs text-muted-foreground tabular-nums">{editorStamp}</span>
103
+ {/if}
104
+ </div>
105
+ {/if}
106
+ {#if onhistory}
107
+ <div class="p-2">
108
+ <button
109
+ type="button"
110
+ onclick={onhistory}
111
+ class="t-press flex h-8 w-full items-center justify-center gap-2 rounded-lg bg-foreground/5 font-medium transition-colors duration-[var(--duration-fast)] ease-[var(--ease-smooth-out)] outline-none hover:bg-foreground/8 focus-visible:ring-3 focus-visible:ring-ring/50"
112
+ >
113
+ <ClockCounterClockwise size={14} aria-hidden="true" />
114
+ {t.showHistory}
115
+ </button>
116
+ </div>
117
+ {/if}
118
+ </div>
119
+ </PopoverContent>
120
+ </Popover>
121
+ {:else}
122
+ <span class="inline-flex h-7 items-center text-[13px] text-muted-foreground tabular-nums">
123
+ {chip}
124
+ </span>
125
+ {/if}