@heyramzi/tasks 0.4.0 → 0.5.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 (42) hide show
  1. package/README.md +36 -9
  2. package/package.json +12 -2
  3. package/src/ambient.d.ts +3 -6
  4. package/src/demo.svelte +14 -19
  5. package/src/inbox/inbox-card.svelte +4 -3
  6. package/src/inbox/inbox-draft.svelte +2 -2
  7. package/src/inbox/inbox-statuses.ts +7 -9
  8. package/src/inbox/inbox-trace.svelte +2 -1
  9. package/src/inbox/inbox.svelte +1 -1
  10. package/src/index.ts +2 -7
  11. package/src/kanban-board.svelte +3 -3
  12. package/src/kanban-column.svelte +1 -1
  13. package/src/side-panel.svelte +204 -0
  14. package/src/status-badge.svelte +21 -15
  15. package/src/status-dot.svelte +2 -2
  16. package/src/status-icon.svelte +2 -2
  17. package/src/status-types.ts +1 -3
  18. package/src/task-gantt.svelte +4 -5
  19. package/src/task-list.svelte +3 -3
  20. package/src/task-types.ts +0 -10
  21. package/src/time/duration.ts +1 -1
  22. package/src/time/time-chip.svelte +4 -4
  23. package/src/time/time-dot.svelte +1 -1
  24. package/src/time/time-ring.svelte +2 -2
  25. package/src/time/time-tracker.svelte +6 -9
  26. package/src/icons/board-icon.svelte +0 -11
  27. package/src/icons/chevron-down.svg +0 -1
  28. package/src/icons/chevron-right.svg +0 -1
  29. package/src/icons/clock.svg +0 -1
  30. package/src/icons/close.svg +0 -1
  31. package/src/icons/gantt-icon.svelte +0 -11
  32. package/src/icons/list-icon.svelte +0 -16
  33. package/src/icons/milestone-flag.svg +0 -1
  34. package/src/icons/play.svg +0 -1
  35. package/src/icons/spinner.svg +0 -1
  36. package/src/inbox/icons/call.svg +0 -1
  37. package/src/inbox/icons/email.svg +0 -1
  38. package/src/inbox/icons/form.svg +0 -1
  39. package/src/inbox/icons/slack.svg +0 -1
  40. package/src/lib/cn.ts +0 -11
  41. package/src/task-view-switch.svelte +0 -118
  42. package/src/tone.ts +0 -54
package/README.md CHANGED
@@ -6,11 +6,12 @@ Status badges, dots and icons come from one map too. Live at
6
6
  [ui.heyramzi.com/tasks](https://ui.heyramzi.com/tasks).
7
7
 
8
8
  Every view knows nothing about your data. You pass items and a few accessors,
9
- and it hands back a change to persist. No runtime dependencies: `svelte` is the
10
- only peer. This package was `@heyramzi/board` up to 0.2.
9
+ and it hands back a change to persist. It builds on `@heyramzi/ui` and
10
+ `phosphor-svelte`, which are peers, plus `svelte`. This package was
11
+ `@heyramzi/board` up to 0.2.
11
12
 
12
13
  ```
13
- pnpm add @heyramzi/tasks
14
+ pnpm add @heyramzi/tasks @heyramzi/ui phosphor-svelte
14
15
  ```
15
16
 
16
17
  ## Board
@@ -194,10 +195,34 @@ It opens scrolled to today, a third of the way in. Call `scrollToToday()` on a
194
195
 
195
196
  ## View switch
196
197
 
197
- `TaskViewSwitch` is the segmented control the demo uses to flip between views,
198
- with a pill that slides to the active option. Pass `views`
199
- (`{ id, label, icon? }[]`) and `bind:value`. `BoardIcon`, `ListIcon` and
200
- `GanttIcon` are exported for it.
198
+ There is no switch in this package. The demo flips between views with
199
+ `SegmentedFilter` from `@heyramzi/ui/components/segmented` and the Phosphor
200
+ icons `Kanban`, `ListBullets` and `ChartBarHorizontal`. Phosphor has no gantt
201
+ icon, so `ChartBarHorizontal` stands in.
202
+
203
+ ## Side panel
204
+
205
+ `SidePanel` is a sheet for one record: a title, a description, status chips, doors
206
+ out to other tools (`openIn`), a copy-a-prompt `handoff`, a strip of `stats`, then
207
+ the body and an optional pinned `footer`. You pass the tools; the panel knows none.
208
+ Import it from `@heyramzi/tasks` or alone from `@heyramzi/tasks/side-panel`.
209
+
210
+ ```svelte
211
+ <SidePanel
212
+ bind:open
213
+ title="Pricing page"
214
+ description="Ada, due Friday"
215
+ badges={[{ label: "Review", tone: "warning" }]}
216
+ openIn={[{ tool: github, href: issueUrl, label: "Open the issue on GitHub" }]}
217
+ stats={[{ label: "Logged", value: "6h 30m", sub: "of 8h" }]}
218
+ >
219
+ ...
220
+ </SidePanel>
221
+ ```
222
+
223
+ `tool` is an `OpenInTool` from `@heyramzi/ui/components/open-in`:
224
+ `{ name, action, icon }`. `bare` hands the whole panel to the body, and `width`
225
+ runs from `sm` to `2xl`.
201
226
 
202
227
  ## Time tracking
203
228
 
@@ -258,11 +283,13 @@ exported helpers.
258
283
  - `StatusBadge`, `StatusDot`, `StatusIcon`: chips, dots and icons from one config.
259
284
  A badge also takes its text as children, for a caller that already has the word.
260
285
  - `defineStatuses`: builds a status registry from a plain map.
261
- - `TONE_CLASS`, `TONE_COLOR`, `TONE_ICON`, `TONE_TEXT`, `insetWellClass`: the severity scale.
286
+ - `SidePanel`: a sheet for one record, above.
262
287
  - `salesStatuses`: an example pipeline map.
263
288
 
264
289
  ## Styling
265
290
 
291
+ The severity scale (`TONE_CLASS`, `TONE_TEXT`, `TONE_ICON`, `TONE_COLOR`) lives in
292
+ `@heyramzi/ui/lib/tone.js`, and `insetWellClass` in `@heyramzi/ui/lib/panel.js`.
266
293
  The accent tone reads `--tone-accent` and falls back to `--color-primary`. Set
267
294
  `--tone-accent` when your primary is a neutral and your accent is a brand color.
268
295
 
@@ -275,7 +302,7 @@ same values when they aren't. Everything goes still under
275
302
 
276
303
  ## Demo
277
304
 
278
- `src/demo.svelte` puts one task list behind a Board, List and Gantt switch, with
305
+ `src/demo.svelte` puts one task list behind a Board, List and Gantt filter, with
279
306
  sorting, a Days and Weeks scale and a status badge strip.
280
307
 
281
308
  The fractional ordering in `board-order.ts` is the CC0 algorithm from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/tasks",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Svelte 5 task views: a drag-and-drop board, a grouped list and a gantt timeline from one config, plus status badges, dots and icons.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -74,6 +74,11 @@
74
74
  "types": "./src/inbox/index.ts",
75
75
  "default": "./src/inbox/index.ts"
76
76
  },
77
+ "./side-panel": {
78
+ "svelte": "./src/side-panel.svelte",
79
+ "types": "./src/side-panel.svelte",
80
+ "default": "./src/side-panel.svelte"
81
+ },
77
82
  "./presets/sales": {
78
83
  "svelte": "./src/presets/sales.ts",
79
84
  "types": "./src/presets/sales.ts",
@@ -85,13 +90,18 @@
85
90
  },
86
91
  "./demo.svelte": "./src/demo.svelte"
87
92
  },
93
+ "dependencies": {
94
+ "@heyramzi/utils": "^1.3.3"
95
+ },
88
96
  "peerDependencies": {
89
97
  "@heyramzi/ui": "*",
98
+ "phosphor-svelte": "^3.1.0",
90
99
  "svelte": "^5.0.0"
91
100
  },
92
101
  "devDependencies": {
93
- "@heyramzi/ui": "7.0.0",
102
+ "@heyramzi/ui": "8.0.0",
94
103
  "@types/node": "^26.6.4",
104
+ "phosphor-svelte": "^3.1.0",
95
105
  "svelte-check": "^4.7.6",
96
106
  "typescript": "~7.0.2"
97
107
  },
package/src/ambient.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- // Vite's ?raw import hands back the file as a string. Declared here so svelte-check passes
2
- // without pulling in vite/client.
3
- declare module "*.svg?raw" {
4
- const svg: string;
5
- export default svg;
6
- }
1
+ // A kit component pulls its stylesheet in with a side-effect import. Vite handles it at
2
+ // build time; this declaration lets svelte-check accept it without pulling in vite/client.
3
+ declare module "*.css";
package/src/demo.svelte CHANGED
@@ -2,12 +2,10 @@
2
2
  import KanbanBoard from "./kanban-board.svelte";
3
3
  import TaskList from "./task-list.svelte";
4
4
  import TaskGantt from "./task-gantt.svelte";
5
- import TaskViewSwitch from "./task-view-switch.svelte";
5
+ import { SegmentedFilter } from "@heyramzi/ui/components/segmented";
6
+ import { ChartBarHorizontal, Kanban, ListBullets } from "phosphor-svelte";
6
7
  import StatusBadge from "./status-badge.svelte";
7
8
  import StatusDot from "./status-dot.svelte";
8
- import BoardIcon from "./icons/board-icon.svelte";
9
- import ListIcon from "./icons/list-icon.svelte";
10
- import GanttIcon from "./icons/gantt-icon.svelte";
11
9
  import { defineStatuses } from "./define-statuses.js";
12
10
  import { addDays, dayNumber, todayIso } from "./dates.js";
13
11
  import { salesStatuses } from "./presets/sales.js";
@@ -63,12 +61,16 @@
63
61
  ]);
64
62
 
65
63
  const views = [
66
- { id: "board", label: "Board", icon: BoardIcon },
67
- { id: "list", label: "List", icon: ListIcon },
68
- { id: "gantt", label: "Gantt", icon: GanttIcon },
69
- ];
70
-
71
- let view = $state("board");
64
+ { value: "board", label: "Board", icon: Kanban },
65
+ { value: "list", label: "List", icon: ListBullets },
66
+ { value: "gantt", label: "Gantt", icon: ChartBarHorizontal },
67
+ ] as const;
68
+ const scales = [
69
+ { value: "days", label: "Days" },
70
+ { value: "weeks", label: "Weeks" },
71
+ ] as const;
72
+
73
+ let view = $state<(typeof views)[number]["value"]>("board");
72
74
  let sortKey = $state("manual");
73
75
  let scale = $state<"days" | "weeks">("days");
74
76
 
@@ -172,21 +174,14 @@
172
174
  <div class="flex flex-col gap-6 rounded-xl border border-border bg-background p-4">
173
175
  <div class="flex h-[600px] flex-col gap-3">
174
176
  <div class="flex flex-wrap items-center gap-2">
175
- <TaskViewSwitch {views} bind:value={view} label="Task view" />
177
+ <SegmentedFilter options={views} bind:value={view} ariaLabel="Task view" block={false} />
176
178
  <div class="ml-auto flex items-center gap-3">
177
179
  <label class="flex cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground">
178
180
  <input type="checkbox" bind:checked={tracking} class="size-3.5 accent-[var(--tone-accent,var(--color-primary))]" />
179
181
  Time tracking
180
182
  </label>
181
183
  {#if view === "gantt"}
182
- <TaskViewSwitch
183
- views={[
184
- { id: "days", label: "Days" },
185
- { id: "weeks", label: "Weeks" },
186
- ]}
187
- bind:value={scale}
188
- label="Timeline scale"
189
- />
184
+ <SegmentedFilter options={scales} bind:value={scale} ariaLabel="Timeline scale" block={false} />
190
185
  {:else}
191
186
  <label for="tasks-sort" class="text-xs font-medium text-muted-foreground">Sort</label>
192
187
  <select
@@ -4,10 +4,10 @@
4
4
  import { PixelLoader } from "@heyramzi/ui/ai/loading-state";
5
5
  import { Button } from "@heyramzi/ui/shadcn/button";
6
6
  import { Textarea } from "@heyramzi/ui/shadcn/textarea";
7
- import { cn } from "../lib/cn.js";
7
+ import { cn } from "@heyramzi/utils";
8
8
  import StatusBadge from "../status-badge.svelte";
9
9
  import StatusDot from "../status-dot.svelte";
10
- import { TONE_TEXT } from "../tone.js";
10
+ import { TONE_TEXT } from "@heyramzi/ui/lib/tone.js";
11
11
  import InboxDraftEditor from "./inbox-draft.svelte";
12
12
  import { canUndo, isOpen, startDraft } from "./inbox-logic.js";
13
13
  import {
@@ -37,6 +37,7 @@
37
37
  let open = $derived(isOpen(item));
38
38
  let level = $derived(confidenceLevel(item.confidence.score));
39
39
  let tone = $derived(inboxConfidence.tone(level));
40
+ let SourceIcon = $derived(SOURCE_ICON[item.trace.source.type]);
40
41
 
41
42
  let pickedDraft = $state<string>();
42
43
  let edits = $state<InboxDraftEdits>({});
@@ -185,7 +186,7 @@
185
186
 
186
187
  <div class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 pl-6 text-xs">
187
188
  <span class="inline-flex items-center gap-1.5">
188
- <span class="size-3.5 [&>svg]:size-full" aria-hidden="true">{@html SOURCE_ICON[item.trace.source.type]}</span>
189
+ <SourceIcon size={14} aria-hidden="true" />
189
190
  {SOURCE_LABEL[item.trace.source.type]}, {item.trace.source.at}
190
191
  </span>
191
192
  <span class={cn("inline-flex items-center gap-1.5 tabular-nums", TONE_TEXT[tone])}>
@@ -2,8 +2,8 @@
2
2
  import { Input } from "@heyramzi/ui/shadcn/input";
3
3
  import { Textarea } from "@heyramzi/ui/shadcn/textarea";
4
4
  import { ToggleGroup, ToggleGroupItem } from "@heyramzi/ui/shadcn/toggle-group";
5
- import { cn } from "../lib/cn.js";
6
- import { insetWellClass } from "../tone.js";
5
+ import { cn } from "@heyramzi/utils";
6
+ import { insetWellClass } from "@heyramzi/ui/lib/panel.js";
7
7
  import type { InboxDraft, InboxDraftEdits } from "./inbox-types.js";
8
8
 
9
9
  type Props = {
@@ -1,8 +1,6 @@
1
1
  import { defineStatuses } from "../define-statuses.js";
2
- import callIcon from "./icons/call.svg?raw";
3
- import emailIcon from "./icons/email.svg?raw";
4
- import formIcon from "./icons/form.svg?raw";
5
- import slackIcon from "./icons/slack.svg?raw";
2
+ import { ClipboardText, EnvelopeSimple, Hash, Phone } from "phosphor-svelte";
3
+ import type { PhosphorIcon } from "@heyramzi/ui/lib/icon-types.js";
6
4
  import type { InboxSourceType } from "./inbox-types.js";
7
5
 
8
6
  /** The lifecycle of an item. The dot and the badge both read this one map. */
@@ -35,11 +33,11 @@ export function confidenceLevel(score: number): "high" | "medium" | "low" {
35
33
  return "low";
36
34
  }
37
35
 
38
- export const SOURCE_ICON: Record<InboxSourceType, string> = {
39
- call: callIcon,
40
- email: emailIcon,
41
- slack: slackIcon,
42
- form: formIcon,
36
+ export const SOURCE_ICON: Record<InboxSourceType, PhosphorIcon> = {
37
+ call: Phone,
38
+ email: EnvelopeSimple,
39
+ slack: Hash,
40
+ form: ClipboardText,
43
41
  };
44
42
 
45
43
  export const SOURCE_LABEL: Record<InboxSourceType, string> = {
@@ -8,13 +8,14 @@
8
8
  let { trace }: Props = $props();
9
9
 
10
10
  let count = $derived(trace.evidence.length);
11
+ let SourceIcon = $derived(SOURCE_ICON[trace.source.type]);
11
12
  </script>
12
13
 
13
14
  <!-- WHY the shared Reasoning and not a local disclosure: it is already the
14
15
  collapsed-by-default "how the agent got here" pattern, with the same chevron. -->
15
16
  <Reasoning>
16
17
  <ReasoningTrigger class="w-auto">
17
- <span class="size-3.5 shrink-0 [&>svg]:size-full" aria-hidden="true">{@html SOURCE_ICON[trace.source.type]}</span>
18
+ <SourceIcon size={14} class="shrink-0" aria-hidden="true" />
18
19
  <span>
19
20
  Trace: {SOURCE_LABEL[trace.source.type]}, {trace.source.at}, {count}
20
21
  {count === 1 ? "quote" : "quotes"}
@@ -6,7 +6,7 @@
6
6
  import { Kbd, KbdGroup } from "@heyramzi/ui/shadcn/kbd";
7
7
  import { Tabs, TabsList, TabsTrigger } from "@heyramzi/ui/shadcn/tabs";
8
8
  import { ToggleGroup, ToggleGroupItem } from "@heyramzi/ui/shadcn/toggle-group";
9
- import { cn } from "../lib/cn.js";
9
+ import { cn } from "@heyramzi/utils";
10
10
  import StatusDot from "../status-dot.svelte";
11
11
  import InboxCard from "./inbox-card.svelte";
12
12
  import { flatten, groupByProject, inLane, isTyping, laneOf } from "./inbox-logic.js";
package/src/index.ts CHANGED
@@ -17,28 +17,23 @@ export { default as StatusBadge } from "./status-badge.svelte";
17
17
  export { default as StatusDot } from "./status-dot.svelte";
18
18
  export { default as StatusIcon } from "./status-icon.svelte";
19
19
  export { defineStatuses } from "./define-statuses.js";
20
- export { TONE_CLASS, TONE_COLOR, TONE_ICON, TONE_TEXT, insetWellClass } from "./tone.js";
21
20
  export type {
22
21
  StatusDef,
23
22
  StatusDotVariant,
24
23
  StatusId,
25
24
  StatusMap,
26
25
  Statuses,
27
- Tone,
28
26
  } from "./status-types.js";
27
+ export { default as SidePanel } from "./side-panel.svelte";
28
+ export type { PanelStat, SidePanelWidth } from "./side-panel.svelte";
29
29
  export { default as TaskList } from "./task-list.svelte";
30
30
  export { default as TaskGantt } from "./task-gantt.svelte";
31
- export { default as TaskViewSwitch } from "./task-view-switch.svelte";
32
- export { default as BoardIcon } from "./icons/board-icon.svelte";
33
- export { default as ListIcon } from "./icons/list-icon.svelte";
34
- export { default as GanttIcon } from "./icons/gantt-icon.svelte";
35
31
  export { addDays, dayNumber, isoFromDay, todayIso } from "./dates.js";
36
32
  export type {
37
33
  GanttChange,
38
34
  GanttLabelContext,
39
35
  TaskListGroupContext,
40
36
  TaskListRowContext,
41
- TaskView,
42
37
  } from "./task-types.js";
43
38
  export { default as TimeTracker } from "./time/time-tracker.svelte";
44
39
  export { default as TimeChip } from "./time/time-chip.svelte";
@@ -7,9 +7,9 @@
7
7
  import KanbanColumn from "./kanban-column.svelte";
8
8
  import { liftDragImage } from "./kanban-drag.js";
9
9
  import { boardOrderAt } from "./board-order.js";
10
- import { cn } from "./lib/cn.js";
10
+ import { cn } from "@heyramzi/utils";
11
11
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
12
- import chevronDownIcon from "./icons/chevron-down.svg?raw";
12
+ import { CaretDown } from "phosphor-svelte";
13
13
  import type {
14
14
  KanbanCardContext,
15
15
  KanbanColumn as KanbanColumnDef,
@@ -447,7 +447,7 @@
447
447
  "size-2.5 transition-transform duration-[var(--duration-fast,160ms)] ease-[var(--ease-smooth-out,ease-out)] [&>svg]:size-full",
448
448
  !railOpen && "-rotate-90",
449
449
  )}
450
- aria-hidden="true">{@html chevronDownIcon}</span
450
+ aria-hidden="true"><CaretDown weight="bold" /></span
451
451
  >
452
452
  {hiddenLabel}
453
453
  </button>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
3
  import type { HTMLAttributes } from "svelte/elements";
4
- import { cn } from "./lib/cn.js";
4
+ import { cn } from "@heyramzi/utils";
5
5
 
6
6
  type Props = {
7
7
  /** Any CSS color. Only shows while a card is held over the column. */
@@ -0,0 +1,204 @@
1
+ <script lang="ts" module>
2
+ /** How wide the panel opens from `sm` (a short form) to `2xl` (a reader). Under
3
+ the `sm` breakpoint every panel is full width. */
4
+ export type SidePanelWidth = "sm" | "md" | "lg" | "xl" | "2xl";
5
+
6
+ /** One number the record is judged on. They sit in the header, in the order given. */
7
+ export type PanelStat = {
8
+ label: string;
9
+ value: string;
10
+ /** A quiet note beside the value: the rate behind a count, or what the figure is. */
11
+ sub?: string;
12
+ /** One severity vocabulary: a count that should be zero is `bad`. */
13
+ tone?: "default" | "muted" | "good" | "bad";
14
+ };
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ // =========================
19
+ // IMPORTS
20
+ // =========================
21
+ import type { Snippet } from "svelte";
22
+ import * as Sheet from "@heyramzi/ui/shadcn/sheet";
23
+ import { OpenIn, type OpenInTool } from "@heyramzi/ui/components/open-in";
24
+ import { CopyButton } from "@heyramzi/ui/components/copy";
25
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
26
+ import { cn } from "@heyramzi/utils";
27
+ import StatusBadge from "./status-badge.svelte";
28
+
29
+ // =========================
30
+ // PROPS
31
+ // =========================
32
+ type Props = {
33
+ open: boolean;
34
+ /** Names the dialog for screen readers and heads the panel. */
35
+ title: string;
36
+ /** Merged onto the title. A short path is set `font-mono`, a subject is not. */
37
+ titleClass?: string;
38
+ /** The facts the record is filed under, read out under the title. */
39
+ description?: string;
40
+ /** Status chips, left of the action row. */
41
+ badges?: { label: string; tone: Tone }[];
42
+ /** Doors out to another tool. `text` replaces the tool's name on the button. */
43
+ openIn?: { tool: OpenInTool; href: string; label: string; text?: string }[];
44
+ /** A prompt for an AI about this record, copied on click. */
45
+ handoff?: { value: () => string | Promise<string>; label: string; copiedLabel: string };
46
+ /** The record's numbers, as a strip under the badges. Same place in every panel. */
47
+ stats?: PanelStat[];
48
+ /** Anything else the record can do, drawn last in the action row. */
49
+ actions?: Snippet;
50
+ /** Pinned actions. The body scrolls above it; the caller aligns the row. */
51
+ footer?: Snippet;
52
+ children: Snippet;
53
+ width?: SidePanelWidth;
54
+ /** The body brings its own header: the title is read out but not drawn, and
55
+ the body is neither padded nor scrolled by the panel. */
56
+ bare?: boolean;
57
+ /** Fires once when the panel closes, from the close button, Escape or the overlay. */
58
+ onClose?: () => void;
59
+ };
60
+ let {
61
+ open = $bindable(),
62
+ title,
63
+ titleClass,
64
+ description,
65
+ badges = [],
66
+ openIn = [],
67
+ handoff,
68
+ stats = [],
69
+ actions,
70
+ footer,
71
+ children,
72
+ width = "sm",
73
+ bare = false,
74
+ onClose,
75
+ }: Props = $props();
76
+
77
+ // =========================
78
+ // STATE
79
+ // =========================
80
+ let panel = $state<HTMLElement | null>(null);
81
+
82
+ // =========================
83
+ // DERIVED
84
+ // =========================
85
+ let hasRow = $derived(badges.length > 0 || openIn.length > 0 || Boolean(handoff || actions));
86
+ const STAT_TONE = {
87
+ default: "",
88
+ muted: "text-muted-foreground",
89
+ good: "text-success",
90
+ bad: "text-destructive",
91
+ } as const;
92
+ const WIDTH: Record<SidePanelWidth, string> = {
93
+ sm: "sm:max-w-sm",
94
+ md: "sm:max-w-md",
95
+ lg: "sm:max-w-xl",
96
+ xl: "sm:max-w-2xl",
97
+ "2xl": "sm:max-w-3xl",
98
+ };
99
+
100
+ // =========================
101
+ // HANDLERS
102
+ // =========================
103
+ // WHY focus goes to the panel unless a text field comes first: the dialog otherwise
104
+ // opens on its first control, which draws a ring on a record nobody has touched.
105
+ // A form that opens on its field keeps the default, since that is where you type.
106
+ const FIRST_CONTROL =
107
+ 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])';
108
+ function onOpenAutoFocus(event: Event) {
109
+ if (!panel || panel.querySelector(FIRST_CONTROL)?.matches("input, textarea, select")) return;
110
+ event.preventDefault();
111
+ panel.focus();
112
+ }
113
+ </script>
114
+
115
+ <!-- =========================== -->
116
+ <!-- MARKUP -->
117
+ <!-- =========================== -->
118
+ <Sheet.Root
119
+ bind:open
120
+ onOpenChange={(next) => {
121
+ if (!next) onClose?.();
122
+ }}
123
+ >
124
+ <Sheet.Content
125
+ side="right"
126
+ bind:ref={panel}
127
+ tabindex={-1}
128
+ class={cn("flex w-full flex-col gap-0 p-0", WIDTH[width], bare && "overflow-hidden")}
129
+ {onOpenAutoFocus}
130
+ >
131
+ {#if bare}
132
+ <Sheet.Header class="sr-only">
133
+ <Sheet.Title>{title}</Sheet.Title>
134
+ </Sheet.Header>
135
+ {@render children()}
136
+ {:else}
137
+ <Sheet.Header class="gap-1 border-b border-border">
138
+ <!-- WHY the right padding: the close button sits over this corner and
139
+ a long title runs straight under it. -->
140
+ <Sheet.Title class={cn("pr-9 text-base leading-snug", titleClass)}>{title}</Sheet.Title>
141
+ {#if description}
142
+ <Sheet.Description>{description}</Sheet.Description>
143
+ {/if}
144
+ {#if hasRow}
145
+ <div class="flex items-center justify-between gap-3 pt-1.5">
146
+ <div class="flex min-w-0 flex-wrap items-center gap-2">
147
+ {#each badges as badge (`${badge.tone}:${badge.label}`)}
148
+ <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge>
149
+ {/each}
150
+ </div>
151
+ <div class="flex shrink-0 items-center gap-1">
152
+ {#each openIn as door (door.href)}
153
+ <OpenIn
154
+ tool={door.tool}
155
+ href={door.href}
156
+ title={door.label}
157
+ label={door.text}
158
+ variant="button"
159
+ size="sm"
160
+ />
161
+ {/each}
162
+ {#if handoff}
163
+ <CopyButton
164
+ agent
165
+ value={handoff.value}
166
+ label={handoff.label}
167
+ copiedLabel={handoff.copiedLabel}
168
+ />
169
+ {/if}
170
+ {@render actions?.()}
171
+ </div>
172
+ </div>
173
+ {/if}
174
+ {#if stats.length > 0}
175
+ <dl class="flex flex-wrap gap-x-6 gap-y-3 pt-3">
176
+ {#each stats as stat (stat.label)}
177
+ <div class="min-w-0">
178
+ <dt class="text-xs text-muted-foreground">{stat.label}</dt>
179
+ <dd class={cn("text-sm font-medium tabular-nums", STAT_TONE[stat.tone ?? "default"])}>
180
+ {stat.value}
181
+ {#if stat.sub}
182
+ <span class="text-xs font-normal text-muted-foreground">{stat.sub}</span>
183
+ {/if}
184
+ </dd>
185
+ </div>
186
+ {/each}
187
+ </dl>
188
+ {/if}
189
+ </Sheet.Header>
190
+
191
+ <!-- WHY the body pads and spaces its sections itself: a panel that lets the
192
+ caller choose drifts, one with a grey band and one with a white page. -->
193
+ <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
194
+ {@render children()}
195
+ </div>
196
+
197
+ {#if footer}
198
+ <Sheet.Footer class="mt-0 flex-row items-center justify-end border-t border-border">
199
+ {@render footer()}
200
+ </Sheet.Footer>
201
+ {/if}
202
+ {/if}
203
+ </Sheet.Content>
204
+ </Sheet.Root>
@@ -1,11 +1,13 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
- import { cn } from "./lib/cn.js";
4
- import { TONE_CLASS } from "./tone.js";
5
- import type { Tone, Statuses } from "./status-types.js";
6
- import spinnerIcon from "./icons/spinner.svg?raw";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn } from "@heyramzi/utils";
5
+ import { Badge } from "@heyramzi/ui/shadcn/badge";
6
+ import { Spinner } from "@heyramzi/ui/shadcn/spinner";
7
+ import { TONE_COLOR, type Tone } from "@heyramzi/ui/lib/tone.js";
8
+ import type { Statuses } from "./status-types.js";
7
9
 
8
- type Props = {
10
+ type Props = Omit<HTMLAttributes<HTMLSpanElement>, "class" | "children"> & {
9
11
  /** A registry from `defineStatuses`. Supplies the label and tone. */
10
12
  statuses?: Statuses;
11
13
  /** The status id to look up in `statuses`. Leave it out when you pass `children`. */
@@ -14,6 +16,8 @@
14
16
  label?: string;
15
17
  /** Override the registry tone. */
16
18
  tone?: Tone;
19
+ /** Shows a dot in the tone's colour before the label. */
20
+ dot?: boolean;
17
21
  /** Shows a spinner: a background job is still working on the record. */
18
22
  busy?: boolean;
19
23
  /** Capitalizes the first letter of each word. Turn off for a name or a number. */
@@ -28,30 +32,32 @@
28
32
  status,
29
33
  label,
30
34
  tone,
35
+ dot = false,
31
36
  busy = false,
32
37
  capitalize = true,
33
38
  children,
34
39
  class: className,
40
+ ...restProps
35
41
  }: Props = $props();
36
42
 
37
43
  let resolvedTone = $derived(tone ?? (statuses && status ? statuses.tone(status) : "neutral"));
38
44
  let resolvedLabel = $derived(label ?? (statuses && status ? statuses.label(status) : (status ?? "")));
39
45
  </script>
40
46
 
41
- <span
42
- class={cn(
43
- "inline-flex h-5 shrink-0 items-center justify-center gap-1 rounded-full border px-2 text-xs font-medium whitespace-nowrap",
44
- capitalize && "capitalize",
45
- TONE_CLASS[resolvedTone],
46
- className,
47
- )}
48
- >
47
+ <Badge tone={resolvedTone} class={cn(capitalize && "capitalize", className)} {...restProps}>
48
+ {#if dot}
49
+ <span
50
+ class="size-1.5 shrink-0 rounded-full"
51
+ style:background-color={TONE_COLOR[resolvedTone]}
52
+ aria-hidden="true"
53
+ ></span>
54
+ {/if}
49
55
  {#if busy}
50
- <span class="size-3 animate-spin [&>svg]:size-full" aria-hidden="true">{@html spinnerIcon}</span>
56
+ <Spinner class="size-3" aria-hidden="true" />
51
57
  {/if}
52
58
  {#if children}
53
59
  {@render children()}
54
60
  {:else}
55
61
  {resolvedLabel}
56
62
  {/if}
57
- </span>
63
+ </Badge>
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
- import { cn } from "./lib/cn.js";
3
- import { TONE_COLOR } from "./tone.js";
2
+ import { cn } from "@heyramzi/utils";
3
+ import { TONE_COLOR } from "@heyramzi/ui/lib/tone.js";
4
4
  import type { StatusDotVariant, Statuses } from "./status-types.js";
5
5
 
6
6
  interface Props {
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Component } from "svelte";
3
3
  import StatusDot from "./status-dot.svelte";
4
- import { cn } from "./lib/cn.js";
5
- import { TONE_ICON } from "./tone.js";
4
+ import { cn } from "@heyramzi/utils";
5
+ import { TONE_ICON } from "@heyramzi/ui/lib/tone.js";
6
6
  import type { Statuses } from "./status-types.js";
7
7
 
8
8
  type Props = {
@@ -1,7 +1,5 @@
1
1
  import type { Component } from "svelte";
2
-
3
- /** The one severity scale the kit ships. */
4
- export type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "destructive";
2
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
5
3
 
6
4
  /**
7
5
  * The shape the dot can take. `progress` needs a `progress` value between 0 and 1.
@@ -1,11 +1,10 @@
1
1
  <script lang="ts" generics="T">
2
2
  import type { Snippet } from "svelte";
3
- import { cn } from "./lib/cn.js";
3
+ import { cn } from "@heyramzi/utils";
4
4
  import { dayNumber, isoFromDay, todayIso, weekday } from "./dates.js";
5
5
  import type { KanbanColumn as KanbanColumnDef } from "./kanban-types.js";
6
6
  import type { GanttChange, GanttLabelContext } from "./task-types.js";
7
- import chevronIcon from "./icons/chevron-right.svg?raw";
8
- import flagIcon from "./icons/milestone-flag.svg?raw";
7
+ import { CaretRight, FlagPennant } from "phosphor-svelte";
9
8
 
10
9
  type Props<T> = {
11
10
  items: T[];
@@ -534,7 +533,7 @@
534
533
  class="-ml-1 grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,150ms)] hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
535
534
  >
536
535
  <span class="g-chevron size-3.5 [&>svg]:size-full" class:g-chevron-open={row.open} aria-hidden="true">
537
- {@html chevronIcon}
536
+ <CaretRight weight="bold" />
538
537
  </span>
539
538
  </button>
540
539
  {:else}
@@ -567,7 +566,7 @@
567
566
  onpointerenter={() => (hoveredId = row.id)}
568
567
  onpointerleave={() => (hoveredId = hoveredId === row.id ? null : hoveredId)}
569
568
  >
570
- <span class="size-5 [&>svg]:size-full" aria-hidden="true">{@html flagIcon}</span>
569
+ <span class="size-5 [&>svg]:size-full" aria-hidden="true"><FlagPennant weight="fill" /></span>
571
570
  </button>
572
571
  {:else if !row.own}
573
572
  <!-- A parent with no dates of its own: the span of what is below it. Not draggable. -->
@@ -4,9 +4,9 @@
4
4
  import { cubicOut } from "svelte/easing";
5
5
  import { liftDragImage } from "./kanban-drag.js";
6
6
  import { boardOrderAt } from "./board-order.js";
7
- import { cn } from "./lib/cn.js";
7
+ import { cn } from "@heyramzi/utils";
8
8
  import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
9
- import chevronIcon from "./icons/chevron-right.svg?raw";
9
+ import { CaretRight } from "phosphor-svelte";
10
10
  import type { KanbanColumn as KanbanColumnDef, KanbanMove, KanbanSortKey } from "./kanban-types.js";
11
11
  import type { TaskListGroupContext, TaskListRowContext } from "./task-types.js";
12
12
 
@@ -228,7 +228,7 @@
228
228
  class="-ml-1 grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,150ms)] hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
229
229
  >
230
230
  <span class="tl-chevron size-3.5 [&>svg]:size-full" class:tl-chevron-open={open} aria-hidden="true">
231
- {@html chevronIcon}
231
+ <CaretRight weight="bold" />
232
232
  </span>
233
233
  </button>
234
234
  {/if}
package/src/task-types.ts CHANGED
@@ -1,5 +1,3 @@
1
- import type { Component } from "svelte";
2
-
3
1
  /** Second argument handed to the list row snippet. */
4
2
  export type TaskListRowContext = {
5
3
  groupId: string;
@@ -25,14 +23,6 @@ export type GanttChange = {
25
23
  end: string;
26
24
  };
27
25
 
28
- /** One option in the view switch. */
29
- export type TaskView = {
30
- id: string;
31
- label: string;
32
- /** Any Svelte component that takes `size` and `class`, e.g. `BoardIcon`. */
33
- icon?: Component<{ size?: number; class?: string }>;
34
- };
35
-
36
26
  /** Second argument handed to the gantt label snippet. */
37
27
  export type GanttLabelContext = {
38
28
  /** 0 for a top-level task. Only grows when `getParent` is set. */
@@ -1,4 +1,4 @@
1
- import type { Tone } from "../status-types.js";
1
+ import type { Tone } from "@heyramzi/ui/lib/tone.js";
2
2
 
3
3
  /** "90", "45m", "1h", "1.5h", "1h30", "1h 30m" and "1:30" to whole minutes. Null when it isn't a duration. */
4
4
  export function parseMinutes(text: string): number | null {
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import { cn } from "../lib/cn.js";
3
- import { TONE_CLASS, TONE_TEXT } from "../tone.js";
2
+ import { cn } from "@heyramzi/utils";
3
+ import { TONE_CLASS, TONE_TEXT } from "@heyramzi/ui/lib/tone.js";
4
4
  import { budgetTone, formatMinutes } from "./duration.js";
5
5
  import TimeDot from "./time-dot.svelte";
6
6
  import TimeRing from "./time-ring.svelte";
7
- import clockIcon from "../icons/clock.svg?raw";
7
+ import { Clock } from "phosphor-svelte";
8
8
 
9
9
  type Props = {
10
10
  /** Minutes logged on the task. */
@@ -53,7 +53,7 @@
53
53
  {:else if estimate}
54
54
  <TimeRing value={minutes} max={estimate} size={12} stroke={6} />
55
55
  {:else}
56
- <span class="size-3 shrink-0 [&>svg]:size-full" aria-hidden="true">{@html clockIcon}</span>
56
+ <span class="size-3 shrink-0 [&>svg]:size-full" aria-hidden="true"><Clock /></span>
57
57
  {/if}
58
58
  <span aria-hidden="true">
59
59
  {formatMinutes(minutes)}{#if estimate}<span class={cn("font-normal", tone === "neutral" ? "text-muted-foreground/70" : TONE_TEXT[tone])}> / {formatMinutes(estimate)}</span>{/if}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { cn } from "../lib/cn.js";
2
+ import { cn } from "@heyramzi/utils";
3
3
 
4
4
  type Props = {
5
5
  /** Pulses while true. The dot takes its color from the text around it. */
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from "svelte";
3
- import { cn } from "../lib/cn.js";
4
- import { TONE_TEXT } from "../tone.js";
3
+ import { cn } from "@heyramzi/utils";
4
+ import { TONE_TEXT } from "@heyramzi/ui/lib/tone.js";
5
5
  import { budgetTone } from "./duration.js";
6
6
 
7
7
  type Props = {
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" generics="T">
2
2
  import type { Snippet } from "svelte";
3
- import { cn } from "../lib/cn.js";
4
- import { TONE_TEXT } from "../tone.js";
3
+ import { cn } from "@heyramzi/utils";
4
+ import { TONE_TEXT } from "@heyramzi/ui/lib/tone.js";
5
5
  import {
6
6
  budgetTone,
7
7
  dayLabel,
@@ -15,8 +15,7 @@
15
15
  import type { TimeLabels, TimeLogInput, TimeRunning, TimeStopInfo } from "./time-types.js";
16
16
  import TimeDot from "./time-dot.svelte";
17
17
  import TimeRing from "./time-ring.svelte";
18
- import closeIcon from "../icons/close.svg?raw";
19
- import playIcon from "../icons/play.svg?raw";
18
+ import { Play, Stop, X } from "phosphor-svelte";
20
19
 
21
20
  type Props<T> = {
22
21
  /** Every entry logged on the task. Order within a day is kept. */
@@ -255,11 +254,9 @@
255
254
  class:is-running={!!running}
256
255
  >
257
256
  {#if running}
258
- <svg viewBox="0 0 24 24" class="size-4.5" fill="currentColor" aria-hidden="true">
259
- <rect x="6" y="6" width="12" height="12" rx="2.5" />
260
- </svg>
257
+ <Stop weight="fill" class="size-4.5" aria-hidden="true" />
261
258
  {:else}
262
- <span class="size-5 translate-x-px [&>svg]:size-full" aria-hidden="true">{@html playIcon}</span>
259
+ <span class="size-5 translate-x-px [&>svg]:size-full" aria-hidden="true"><Play weight="fill" /></span>
263
260
  {/if}
264
261
  </button>
265
262
  </div>
@@ -404,7 +401,7 @@
404
401
  aria-label={labels.deleteEntry(formatMinutes(getMinutes(entry)))}
405
402
  class="time-delete -my-1 -me-1 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50"
406
403
  >
407
- <span class="size-3.5 [&>svg]:size-full" aria-hidden="true">{@html closeIcon}</span>
404
+ <span class="size-3.5 [&>svg]:size-full" aria-hidden="true"><X weight="bold" /></span>
408
405
  </button>
409
406
  {/if}
410
407
  </li>
@@ -1,11 +0,0 @@
1
- <script lang="ts">
2
- let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
- </script>
4
-
5
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
- <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
7
- <line x1="4" y1="3" x2="4" y2="11"></line>
8
- <line x1="8" y1="3" x2="8" y2="7.5"></line>
9
- <line x1="12" y1="3" x2="12" y2="13"></line>
10
- </g>
11
- </svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 4.75V8l2.25 1.5"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8"/></svg>
@@ -1,11 +0,0 @@
1
- <script lang="ts">
2
- let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
- </script>
4
-
5
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
- <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
7
- <line x1="2" y1="4" x2="8" y2="4"></line>
8
- <line x1="5" y1="8" x2="12" y2="8"></line>
9
- <line x1="9" y1="12" x2="14" y2="12"></line>
10
- </g>
11
- </svg>
@@ -1,16 +0,0 @@
1
- <script lang="ts">
2
- let { size = 16, class: className }: { size?: number; class?: string } = $props();
3
- </script>
4
-
5
- <svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" class={className}>
6
- <g stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
7
- <line x1="6" y1="4" x2="14" y2="4"></line>
8
- <line x1="6" y1="8" x2="14" y2="8"></line>
9
- <line x1="6" y1="12" x2="14" y2="12"></line>
10
- </g>
11
- <g fill="currentColor">
12
- <circle cx="2.75" cy="4" r="1"></circle>
13
- <circle cx="2.75" cy="8" r="1"></circle>
14
- <circle cx="2.75" cy="12" r="1"></circle>
15
- </g>
16
- </svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2v12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" fill="none"/><path d="M4.75 2.75h8l-2 3 2 3h-8z" fill="currentColor"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.6v12.8a1 1 0 0 0 1.5.86l10.6-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6Z"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2.5h2l1 3-1.5 1a7 7 0 0 0 4 4l1-1.5 3 1v2a1.5 1.5 0 0 1-1.5 1.5A10.5 10.5 0 0 1 2 4a1.5 1.5 0 0 1 1.5-1.5Z"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3.5" width="12" height="9" rx="1.5"/><path d="m2.5 4.5 5.5 4 5.5-4"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="2" width="10" height="12" rx="1.5"/><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3"/></svg>
@@ -1 +0,0 @@
1
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 2.5 5.5 13.5M10.5 2.5l-1 11M3 6h10.5M2.5 10H13"/></svg>
package/src/lib/cn.ts DELETED
@@ -1,11 +0,0 @@
1
- export type ClassValue = string | false | null | undefined;
2
-
3
- /** Join class values, dropping empties. Deliberately dependency-free. */
4
- export function cn(...values: ClassValue[]): string {
5
- let out = "";
6
- for (const value of values) {
7
- if (!value) continue;
8
- out = out ? out + " " + value : value;
9
- }
10
- return out;
11
- }
@@ -1,118 +0,0 @@
1
- <script lang="ts">
2
- import { cn } from "./lib/cn.js";
3
- import type { TaskView } from "./task-types.js";
4
-
5
- type Props = {
6
- views: TaskView[];
7
- /** The active view id. Bindable. */
8
- value: string;
9
- onchange?: (id: string) => void;
10
- label?: string;
11
- class?: string;
12
- };
13
-
14
- let {
15
- views,
16
- value = $bindable(),
17
- onchange,
18
- label = "View",
19
- class: className,
20
- }: Props = $props();
21
-
22
- let track = $state<HTMLDivElement>();
23
- let pill = $state<{ x: number; width: number } | null>(null);
24
- // The pill's first position is written without a transition, or it slides in from 0.
25
- let placed = $state(false);
26
-
27
- function measure(): void {
28
- const button = track?.querySelector<HTMLElement>(`[data-view="${CSS.escape(value)}"]`);
29
- if (!button) return;
30
- pill = { x: button.offsetLeft, width: button.offsetWidth };
31
- }
32
-
33
- $effect(() => {
34
- void value;
35
- void views.length;
36
- measure();
37
- if (!placed) requestAnimationFrame(() => (placed = true));
38
- });
39
-
40
- $effect(() => {
41
- if (!track) return;
42
- const observer = new ResizeObserver(() => measure());
43
- observer.observe(track);
44
- return () => observer.disconnect();
45
- });
46
-
47
- function select(id: string): void {
48
- if (id === value) return;
49
- value = id;
50
- onchange?.(id);
51
- }
52
-
53
- function onKeyDown(e: KeyboardEvent): void {
54
- if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
55
- e.preventDefault();
56
- const at = views.findIndex((view) => view.id === value);
57
- const next = views[(at + (e.key === "ArrowRight" ? 1 : -1) + views.length) % views.length];
58
- select(next.id);
59
- track?.querySelector<HTMLElement>(`[data-view="${CSS.escape(next.id)}"]`)?.focus();
60
- }
61
- </script>
62
-
63
- <div
64
- bind:this={track}
65
- role="tablist"
66
- aria-label={label}
67
- tabindex="-1"
68
- onkeydown={onKeyDown}
69
- class={cn(
70
- "task-view-switch relative inline-flex shrink-0 items-center gap-0.5 rounded-lg bg-muted/70 p-0.5 dark:bg-muted/40",
71
- className,
72
- )}
73
- >
74
- {#if pill}
75
- <span
76
- aria-hidden="true"
77
- class="tvs-pill absolute inset-y-0.5 left-0 rounded-md bg-background shadow-sm ring-1 ring-foreground/5 dark:bg-foreground/10"
78
- class:tvs-pill-placed={placed}
79
- style:width="{pill.width}px"
80
- style:transform="translateX({pill.x}px)"
81
- ></span>
82
- {/if}
83
- {#each views as view (view.id)}
84
- {@const active = view.id === value}
85
- <button
86
- type="button"
87
- role="tab"
88
- data-view={view.id}
89
- aria-selected={active}
90
- tabindex={active ? 0 : -1}
91
- onclick={() => select(view.id)}
92
- class={cn(
93
- "relative z-10 inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium outline-none transition-colors duration-[var(--duration-quick,150ms)] focus-visible:ring-2 focus-visible:ring-ring/60",
94
- active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
95
- )}
96
- >
97
- {#if view.icon}
98
- {@const Icon = view.icon}
99
- <Icon size={14} class="shrink-0" />
100
- {/if}
101
- {view.label}
102
- </button>
103
- {/each}
104
- </div>
105
-
106
- <style>
107
- .tvs-pill-placed {
108
- transition:
109
- transform var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
110
- width var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
111
- }
112
-
113
- @media (prefers-reduced-motion: reduce) {
114
- .tvs-pill-placed {
115
- transition: none;
116
- }
117
- }
118
- </style>
package/src/tone.ts DELETED
@@ -1,54 +0,0 @@
1
- import type { Tone } from "./status-types.js";
2
-
3
- /**
4
- * The severity scale, as Tailwind classes for a chip and as CSS colors for the
5
- * dot. Tokens are plain CSS variables, so a host app can retheme them. The
6
- * accent tone reads `--tone-accent` and falls back to `--color-primary`, for a
7
- * host whose primary is a neutral and whose accent is a brand color.
8
- */
9
-
10
- export const TONE_CLASS: Record<Tone, string> = {
11
- neutral: "border-border bg-muted/40 text-muted-foreground",
12
- accent:
13
- "border-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_30%,transparent)] bg-[color-mix(in_oklab,var(--tone-accent,var(--color-primary))_10%,transparent)] text-[color:var(--tone-accent,var(--color-primary))]",
14
- info: "border-info/30 bg-info/10 text-info-800 dark:text-info-400",
15
- success: "border-success/30 bg-success/10 text-success-800 dark:text-success-400",
16
- warning: "border-warning/30 bg-warning/10 text-warning-800 dark:text-warning-400",
17
- destructive: "border-destructive/30 bg-destructive/10 text-destructive",
18
- };
19
-
20
- /** The same scale as text, for a verdict inside a line of muted facts. */
21
- export const TONE_TEXT: Record<Tone, string> = {
22
- neutral: "text-muted-foreground",
23
- accent: "text-[color:var(--tone-accent,var(--color-primary))]",
24
- info: "text-info-800 dark:text-info-400",
25
- success: "text-success-800 dark:text-success-400",
26
- warning: "text-warning-800 dark:text-warning-400",
27
- destructive: "text-destructive",
28
- };
29
-
30
- /** The same scale as icon color. */
31
- export const TONE_ICON: Record<Tone, string> = {
32
- neutral: "text-muted-foreground",
33
- accent: "text-[color:var(--tone-accent,var(--color-primary))]",
34
- info: "text-info",
35
- success: "text-success",
36
- warning: "text-warning",
37
- destructive: "text-destructive",
38
- };
39
-
40
- /** The scale as CSS colors, for the SVG dot. */
41
- export const TONE_COLOR: Record<Tone, string> = {
42
- neutral: "var(--color-muted-foreground)",
43
- accent: "var(--tone-accent,var(--color-primary))",
44
- info: "var(--color-info)",
45
- success: "var(--color-success)",
46
- warning: "var(--color-warning)",
47
- destructive: "var(--color-destructive)",
48
- };
49
-
50
- /**
51
- * The inset well for a control block inside a panel. A visible step
52
- * at the same depth, for filter rows and grouped controls.
53
- */
54
- export const insetWellClass = "rounded-lg border border-border bg-muted/40 p-2";