@heyramzi/ui 2.22.1 → 2.24.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 (163) hide show
  1. package/COMPONENTS.md +3 -6
  2. package/dist/ai/confirmation/index.js +3 -6
  3. package/dist/ai/index.js +1 -5
  4. package/dist/canvas/editor/rebase.js +26 -2
  5. package/dist/charts/dashboard/index.js +1 -3
  6. package/dist/charts/primitives/chart-utils.js +14 -0
  7. package/dist/components/chip/index.js +1 -0
  8. package/dist/components/language-flag/flags.js +5 -0
  9. package/dist/components/language-flag/index.js +1 -0
  10. package/dist/components/list-page/index.js +1 -3
  11. package/dist/components/work-parts/index.js +1 -1
  12. package/dist/editor/doc-blocks.js +2 -1
  13. package/dist/editor/safe-url.js +26 -0
  14. package/dist/editor/serializer.js +10 -4
  15. package/dist/editor/slash-query.js +10 -0
  16. package/dist/effects/animated-beam/path-calculator.svelte.js +13 -6
  17. package/dist/lib/dates.js +41 -0
  18. package/dist/lib/panel.js +2 -2
  19. package/dist/lib/tone.js +13 -0
  20. package/dist/record/format.js +2 -18
  21. package/dist/record/logic.js +3 -6
  22. package/dist/status/index.js +1 -1
  23. package/dist/triage/index.js +4 -0
  24. package/dist/triage/types.js +12 -0
  25. package/dist/work/dates.js +2 -3
  26. package/dist/work/detail/detail-types.js +0 -8
  27. package/dist/work/docs/edited.js +8 -7
  28. package/dist/work/inbox/inbox-logic.js +5 -20
  29. package/dist/work/index.js +0 -2
  30. package/dist/work/time/duration.js +2 -3
  31. package/package.json +7 -21
  32. package/scripts/sync-shadcn.mjs +4 -6
  33. package/src/ai/confirmation/Confirmation.svelte +2 -2
  34. package/src/ai/confirmation/{ConfirmationRequest.svelte → ConfirmationState.svelte} +6 -3
  35. package/src/ai/confirmation/confirmation-context.svelte.ts +2 -2
  36. package/src/ai/confirmation/confirmation-parts.test.svelte +17 -0
  37. package/src/ai/confirmation/confirmation-parts.test.ts +23 -0
  38. package/src/ai/confirmation/index.ts +6 -21
  39. package/src/ai/index.ts +3 -13
  40. package/src/ai/typing-dots/TypingDots.svelte +32 -12
  41. package/src/ai/typing-dots/typing-dots.test.ts +36 -0
  42. package/src/animations/modal.css +4 -1
  43. package/src/canvas/editor/rebase.test.ts +37 -0
  44. package/src/canvas/editor/rebase.ts +33 -1
  45. package/src/canvas/ui/canvas-ui.css +44 -1
  46. package/src/charts/README.md +2 -2
  47. package/src/charts/area-chart.svelte +5 -12
  48. package/src/charts/bar-chart.svelte +6 -14
  49. package/src/charts/chart-tokens.css +19 -0
  50. package/src/charts/dashboard/data-table.svelte +21 -21
  51. package/src/charts/dashboard/index.ts +1 -3
  52. package/src/charts/dashboard/rating-chip.svelte +9 -11
  53. package/src/charts/dashboard/stat-tile.svelte +4 -5
  54. package/src/charts/donut-chart.svelte +1 -1
  55. package/src/charts/gauge-chart.svelte +1 -1
  56. package/src/charts/line-chart.svelte +5 -12
  57. package/src/charts/number-chart.svelte +1 -1
  58. package/src/charts/primitives/chart-utils.test.ts +17 -0
  59. package/src/charts/primitives/chart-utils.ts +16 -0
  60. package/src/charts/ring-chart.svelte +2 -2
  61. package/src/charts/sparkline.svelte +1 -1
  62. package/src/charts/stat-card.svelte +1 -1
  63. package/src/charts/time-series-chart.svelte +9 -5
  64. package/src/components/chip/count-chip.svelte +35 -0
  65. package/src/components/chip/index.ts +1 -0
  66. package/src/components/data-table/data-table.ts +1 -1
  67. package/src/components/data-table/row-context-menu.svelte +3 -4
  68. package/src/components/data-table/sortable-head.svelte +45 -10
  69. package/src/components/language-flag/flags.ts +5 -0
  70. package/src/components/language-flag/index.ts +1 -0
  71. package/src/components/language-flag/language-flag.svelte +3 -11
  72. package/src/components/list-page/empty-state.svelte +90 -12
  73. package/src/components/list-page/filter-popover.svelte +2 -5
  74. package/src/components/list-page/grouped-list.svelte +2 -4
  75. package/src/components/list-page/index.ts +1 -3
  76. package/src/components/section-header/section-header.svelte +2 -6
  77. package/src/components/segmented/segment.svelte +13 -19
  78. package/src/components/segmented/segmented-filter.svelte +1 -2
  79. package/src/components/table-view/table-view.svelte +31 -45
  80. package/src/components/table-view/table-view.test.ts +6 -3
  81. package/src/components/work-parts/group-head.svelte +1 -1
  82. package/src/components/work-parts/index.ts +1 -1
  83. package/src/editor/doc-blocks.ts +2 -1
  84. package/src/editor/editor.svelte +7 -5
  85. package/src/editor/safe-url.ts +32 -0
  86. package/src/editor/serializer.test.ts +58 -0
  87. package/src/editor/serializer.ts +9 -6
  88. package/src/editor/slash-query.test.ts +19 -0
  89. package/src/editor/slash-query.ts +11 -0
  90. package/src/effects/animated-beam/path-calculator.svelte.ts +19 -5
  91. package/src/effects/marquee/Marquee.css +6 -3
  92. package/src/effects/marquee/Marquee.svelte +6 -2
  93. package/src/inbox/thread-inbox.svelte +6 -4
  94. package/src/lib/dates.test.ts +63 -0
  95. package/src/lib/dates.ts +63 -0
  96. package/src/lib/panel.svelte +116 -0
  97. package/src/lib/panel.ts +2 -2
  98. package/src/lib/tone.ts +15 -0
  99. package/src/modules.test.ts +13 -1
  100. package/src/panel/side-panel.svelte +4 -4
  101. package/src/record/format.ts +3 -15
  102. package/src/record/logic.ts +2 -6
  103. package/src/record/record-stats.svelte +4 -3
  104. package/src/record/steps-rail.svelte +7 -4
  105. package/src/shadcn/alert-dialog/alert-dialog-overlay.svelte +1 -1
  106. package/src/shadcn/button/button.svelte +1 -1
  107. package/src/shadcn/button/button.test.ts +2 -2
  108. package/src/shadcn/command/command-dialog.svelte +5 -10
  109. package/src/shadcn/dialog/dialog-overlay.svelte +1 -1
  110. package/src/shadcn/input/input.svelte +2 -2
  111. package/src/shadcn/sheet/sheet-overlay.svelte +1 -1
  112. package/src/status/index.ts +1 -1
  113. package/src/status/status-pill.svelte +2 -8
  114. package/src/thread/thread.svelte +3 -6
  115. package/src/tokens/craft.css +30 -0
  116. package/src/triage/README.md +28 -0
  117. package/src/triage/index.ts +10 -0
  118. package/src/triage/triage-plan.svelte +132 -0
  119. package/src/triage/triage-queue.svelte +48 -0
  120. package/src/triage/triage-task.svelte +95 -0
  121. package/src/triage/types.ts +35 -0
  122. package/src/work/README.md +0 -1
  123. package/src/work/dates.ts +3 -3
  124. package/src/work/demo.svelte +9 -1
  125. package/src/work/detail/detail-demo.svelte +30 -27
  126. package/src/work/detail/detail-types.ts +1 -29
  127. package/src/work/detail/task-detail.svelte +18 -17
  128. package/src/work/docs/doc-menu.svelte +2 -1
  129. package/src/work/docs/edited.ts +4 -7
  130. package/src/work/inbox/inbox-card.svelte +4 -9
  131. package/src/work/inbox/inbox-logic.ts +4 -16
  132. package/src/work/index.ts +1 -3
  133. package/src/work/kanban-column.svelte +5 -3
  134. package/src/work/time/duration.ts +2 -3
  135. package/src/work/time/time-chip.svelte +1 -3
  136. package/dist/ai/thinking-dots/index.js +0 -3
  137. package/dist/components/icon/index.js +0 -1
  138. package/dist/components/locale-switch/index.js +0 -1
  139. package/dist/icons/trades.js +0 -49
  140. package/dist/status/pill-tint.js +0 -13
  141. package/dist/work/presets/sales.js +0 -39
  142. package/src/ai/confirmation/ConfirmationAccepted.svelte +0 -23
  143. package/src/ai/confirmation/ConfirmationFailed.svelte +0 -23
  144. package/src/ai/confirmation/ConfirmationRejected.svelte +0 -23
  145. package/src/ai/thinking-dots/ThinkingDots.svelte +0 -42
  146. package/src/ai/thinking-dots/index.ts +0 -5
  147. package/src/charts/dashboard/dashboard-empty.svelte +0 -55
  148. package/src/charts/dashboard/data-panel.svelte +0 -81
  149. package/src/charts/dashboard/data-sort-head.svelte +0 -102
  150. package/src/components/icon/icon.svelte +0 -23
  151. package/src/components/icon/index.ts +0 -1
  152. package/src/components/list-page/detail-empty.svelte +0 -45
  153. package/src/components/list-page/detail-panel.svelte +0 -82
  154. package/src/components/list-page/rail-empty.svelte +0 -44
  155. package/src/components/locale-switch/index.ts +0 -1
  156. package/src/components/locale-switch/locale-switch.svelte +0 -59
  157. package/src/components/work-parts/count-chip.svelte +0 -20
  158. package/src/icons/TradeIcon.svelte +0 -16
  159. package/src/icons/trades.ts +0 -89
  160. package/src/status/pill-tint.ts +0 -16
  161. package/src/work/detail/task-property.svelte +0 -17
  162. package/src/work/detail/task-thread.svelte +0 -221
  163. package/src/work/presets/sales.ts +0 -16
@@ -3,22 +3,14 @@
3
3
  // IMPORTS
4
4
  // =========================
5
5
  import { cn } from "../../lib/utils.js";
6
-
7
- // =========================
8
- // CONSTANTS
9
- // =========================
10
- // The host serves these files from /flags. Pass `flags` for another set or path.
11
- const DEFAULT_FLAGS: Record<string, string> = {
12
- en: "/flags/US.svg",
13
- fr: "/flags/FR.svg",
14
- };
6
+ import { LANGUAGE_FLAGS } from "./flags.js";
15
7
 
16
8
  // =========================
17
9
  // PROPS
18
10
  // =========================
19
11
  interface Props {
20
12
  language: string;
21
- /** Language code to image URL. */
13
+ /** Language code to image URL, for another set or path. */
22
14
  flags?: Record<string, string>;
23
15
  size?: "sm" | "md";
24
16
  showLabel?: boolean;
@@ -27,7 +19,7 @@
27
19
 
28
20
  let {
29
21
  language,
30
- flags = DEFAULT_FLAGS,
22
+ flags = LANGUAGE_FLAGS,
31
23
  size = "sm",
32
24
  showLabel = false,
33
25
  class: className,
@@ -2,8 +2,10 @@
2
2
  // =========================
3
3
  // IMPORTS
4
4
  // =========================
5
+ import type { Snippet } from "svelte";
5
6
  import { Button } from "../../shadcn/button/index.js";
6
7
  import * as Empty from "../../shadcn/empty/index.js";
8
+ import { cn } from "../../lib/utils.js";
7
9
  import type { PhosphorIcon, IconWeight } from "../../lib/icon-types.js";
8
10
 
9
11
  // =========================
@@ -12,25 +14,93 @@
12
14
  interface Props {
13
15
  icon?: PhosphorIcon;
14
16
  iconWeight?: IconWeight;
17
+ /** Replaces the muted colour of a bare icon. Ignored by the tile, which stays foreground. */
18
+ iconClass?: string;
15
19
  title: string;
16
20
  description?: string;
17
21
  actionLabel?: string;
18
22
  actionHref?: string;
19
23
  onAction?: () => void;
24
+ /** Several ways out, drawn in one row after the single action. */
25
+ actions?: Snippet;
26
+ /** `card` fills the white panel; `dashed` frames a whole dashboard section. */
27
+ surface?: "plain" | "card" | "dashed";
28
+ /** `sm` fits a 280px rail: a bare icon and one line of small type. */
29
+ size?: "default" | "sm";
30
+ class?: string;
20
31
  }
21
32
 
33
+ /** A null `icon` draws the icon in a white tile. */
34
+ type Look = {
35
+ root: string;
36
+ icon: string | null;
37
+ header: string;
38
+ title: string;
39
+ description: string;
40
+ };
41
+
22
42
  // =========================
23
43
  // PROPS
24
44
  // =========================
25
45
  let {
26
46
  icon: Icon,
27
47
  iconWeight,
48
+ iconClass,
28
49
  title,
29
50
  description,
30
51
  actionLabel,
31
52
  actionHref,
32
53
  onAction,
54
+ actions,
55
+ surface = "plain",
56
+ size = "default",
57
+ class: className,
33
58
  }: Props = $props();
59
+
60
+ // =========================
61
+ // CONSTANTS
62
+ // =========================
63
+ // A white tile, not a brand tint: the accent marks the one action (DESIGN.md rule 1).
64
+ const PAGE: Look = {
65
+ root: "py-16",
66
+ icon: null,
67
+ header: "max-w-md",
68
+ title: "font-heading text-base font-semibold text-balance [overflow-wrap:anywhere]",
69
+ description: "",
70
+ };
71
+
72
+ const RAIL: Look = {
73
+ root: "gap-2 px-6 py-12",
74
+ icon: "size-6",
75
+ header: "max-w-none",
76
+ title: "",
77
+ description: "max-w-56 text-xs leading-relaxed",
78
+ };
79
+
80
+ // A dashed box stands in for a whole dashboard section, so it takes a heading's size.
81
+ const SECTION: Look = {
82
+ root: "p-10 text-wrap",
83
+ icon: "size-10",
84
+ header: "max-w-md gap-1.5",
85
+ title: "font-heading text-lg font-semibold tracking-normal",
86
+ description: "text-sm",
87
+ };
88
+
89
+ // A framed box sizes as a block, so a caller's `m-4` insets it instead of pushing it 32px wide.
90
+ const SURFACE: Record<NonNullable<Props["surface"]>, string> = {
91
+ plain: "",
92
+ card: "w-auto flex-initial bg-card",
93
+ dashed: "w-auto flex-initial border-2 border-border",
94
+ };
95
+
96
+ // =========================
97
+ // DERIVED
98
+ // =========================
99
+ let look = $derived(size === "sm" ? RAIL : surface === "dashed" ? SECTION : PAGE);
100
+ // Dashboard empties carry their own `t-enter`, and a rail empty flips on every filter keystroke.
101
+ let reveal = $derived(look === PAGE);
102
+ let heading = $derived(look === SECTION);
103
+ let hasAction = $derived(Boolean(actionLabel && (actionHref || onAction)));
34
104
  </script>
35
105
 
36
106
  <!-- =========================== -->
@@ -38,29 +108,37 @@
38
108
  <!-- =========================== -->
39
109
  <!-- transitions-dev texts reveal: the parts arrive in reading order, so the page reads as
40
110
  loaded and empty. Reduced motion keeps the fade. -->
41
- <Empty.Root class="t-empty py-16">
42
- <Empty.Header class="max-w-md">
43
- {#if Icon}
44
- <!-- A white tile, not a brand tint: the accent marks the one action (DESIGN.md
45
- rule 1), and the button below is it. -->
111
+ <Empty.Root class={cn(reveal && "t-empty", look.root, SURFACE[surface], className)}>
112
+ {#if Icon && look.icon}
113
+ <Icon
114
+ weight={iconWeight}
115
+ class={cn(look.icon, iconClass ?? "text-muted-foreground")}
116
+ aria-hidden="true"
117
+ />
118
+ {/if}
119
+ <Empty.Header class={look.header}>
120
+ {#if Icon && !look.icon}
46
121
  <Empty.Media class="mb-3 size-12 rounded-xl bg-card text-foreground shadow-card">
47
122
  <Icon weight={iconWeight} class="size-6" />
48
123
  </Empty.Media>
49
124
  {/if}
50
- <Empty.Title class="font-heading text-base font-semibold text-balance [overflow-wrap:anywhere]"
51
- >{title}</Empty.Title
125
+ <Empty.Title
126
+ class={look.title}
127
+ role={heading ? "heading" : undefined}
128
+ aria-level={heading ? 2 : undefined}>{title}</Empty.Title
52
129
  >
53
130
  {#if description}
54
- <Empty.Description>{description}</Empty.Description>
131
+ <Empty.Description class={look.description}>{description}</Empty.Description>
55
132
  {/if}
56
133
  </Empty.Header>
57
- {#if actionLabel && (actionHref || onAction)}
58
- <Empty.Content>
59
- {#if actionHref}
134
+ {#if hasAction || actions}
135
+ <Empty.Content class="max-w-md flex-row flex-wrap justify-center gap-2">
136
+ {#if hasAction && actionHref}
60
137
  <Button href={actionHref}>{actionLabel}</Button>
61
- {:else}
138
+ {:else if hasAction}
62
139
  <Button onclick={onAction}>{actionLabel}</Button>
63
140
  {/if}
141
+ {@render actions?.()}
64
142
  </Empty.Content>
65
143
  {/if}
66
144
  </Empty.Root>
@@ -8,6 +8,7 @@
8
8
  import { cn } from "../../lib/utils.js";
9
9
  import { Button } from "../../shadcn/button/index.js";
10
10
  import { toolbarTriggerClass } from "../toolbar/toolbar-trigger.js";
11
+ import CountChip from "../chip/count-chip.svelte";
11
12
  import type { PhosphorIcon } from "../../lib/icon-types.js";
12
13
 
13
14
  // phosphor-svelte v2 (resolved here) and v3 (what consumers run) differ only in
@@ -70,11 +71,7 @@
70
71
  {@render trailing()}
71
72
  {/if}
72
73
  {#if hasCount}
73
- <span
74
- class="ml-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-brand/12 px-1 text-xs leading-none font-semibold text-brand tabular-nums dark:text-brand-light"
75
- >
76
- {count.toLocaleString()}
77
- </span>
74
+ <CountChip {count} size="sm" tone="brand" class="ml-0.5" />
78
75
  {/if}
79
76
  </Popover.Trigger>
80
77
  <Popover.Content class={cn("p-2", width, contentClass)} {align}>
@@ -6,6 +6,7 @@
6
6
  import { SvelteSet } from "svelte/reactivity";
7
7
  import { CaretDown, Plus } from "phosphor-svelte";
8
8
  import { cn } from "../../lib/utils.js";
9
+ import CountChip from "../chip/count-chip.svelte";
9
10
 
10
11
  // =========================
11
12
  // TYPES
@@ -110,10 +111,7 @@
110
111
  />
111
112
  {@render header(group, { collapsed: isCollapsed })}
112
113
  {#if getCount}
113
- <span
114
- class="rounded-full bg-foreground/6 px-1.5 text-xs leading-5 font-medium text-muted-foreground tabular-nums"
115
- >{getCount(group)}</span
116
- >
114
+ <CountChip count={getCount(group)} />
117
115
  {/if}
118
116
  </button>
119
117
  {#if actions}
@@ -1,6 +1,5 @@
1
- export { default as DetailEmpty } from "./detail-empty.svelte";
2
1
  export { default as DetailHeader } from "./detail-header.svelte";
3
- export { default as DetailPanel } from "./detail-panel.svelte";
2
+ export { default as DetailPanel } from "../../lib/panel.svelte";
4
3
  export { default as DetailRow } from "./detail-row.svelte";
5
4
  export { default as DetailStack } from "./detail-stack.svelte";
6
5
  export { default as EmptyState } from "./empty-state.svelte";
@@ -9,7 +8,6 @@ export { default as PrevNext } from "./prev-next.svelte";
9
8
  export { stepTo } from "./step.js";
10
9
  export { default as NoticeBar } from "./notice-bar.svelte";
11
10
  export { default as RailBody } from "./rail-body.svelte";
12
- export { default as RailEmpty } from "./rail-empty.svelte";
13
11
  export { default as RailHead } from "./rail-head.svelte";
14
12
  export { default as RailRow } from "./rail-row.svelte";
15
13
  export { default as SectionLabel } from "../section-header/section-label.svelte";
@@ -4,6 +4,7 @@
4
4
  // =========================
5
5
  import type { Snippet } from "svelte";
6
6
  import SectionLabel from "./section-label.svelte";
7
+ import CountChip from "../chip/count-chip.svelte";
7
8
 
8
9
  // =========================
9
10
  // TYPES
@@ -36,12 +37,7 @@
36
37
  <svelte:element this={`h${level}`} class="flex items-center gap-2">
37
38
  <SectionLabel class="min-w-0 [overflow-wrap:anywhere]">{title}</SectionLabel>
38
39
  {#if count !== undefined}
39
- <span
40
- class="shrink-0 rounded-full bg-foreground/6 px-1.5 py-0.5 text-xs leading-none font-medium tabular-nums text-muted-foreground"
41
- aria-label={`${count.toLocaleString()} ${countLabel}`}
42
- >
43
- {count.toLocaleString()}
44
- </span>
40
+ <CountChip {count} size="sm" aria-label={`${count.toLocaleString()} ${countLabel}`} />
45
41
  {/if}
46
42
  </svelte:element>
47
43
  {#if description}
@@ -30,8 +30,8 @@
30
30
  // IMPORTS
31
31
  // =========================
32
32
  import { cn } from "../../lib/utils.js";
33
- import { Icon } from "../icon/index.js";
34
33
  import { Mark } from "../mark/index.js";
34
+ import CountChip from "../chip/count-chip.svelte";
35
35
 
36
36
  // =========================
37
37
  // TYPES
@@ -47,7 +47,7 @@
47
47
  // =========================
48
48
  // PROPS
49
49
  // =========================
50
- let { label, count, icon, image, done = false, badge, active = false }: Props = $props();
50
+ let { label, count, icon: Icon, image, done = false, badge, active = false }: Props = $props();
51
51
 
52
52
  // =========================
53
53
  // DERIVED
@@ -69,8 +69,8 @@
69
69
  auto && "group-data-[state=active]/segment:opacity-100",
70
70
  )}
71
71
  />
72
- {:else if icon}
73
- <Icon {icon} size={16} class="shrink-0" />
72
+ {:else if Icon}
73
+ <Icon size={16} class="shrink-0" />
74
74
  {/if}
75
75
 
76
76
  {label}
@@ -80,21 +80,15 @@
80
80
  {/if}
81
81
 
82
82
  {#if count != null}
83
- <span
84
- class={cn(
85
- // WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill.
86
-
87
- // WHY min-width equals height with 4px padding: `min-w-5 px-1.5` made a digit
88
- // 20 by 18, so `rounded-full` drew an ellipse.
89
- "inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-xs leading-none font-medium tabular-nums",
90
- "bg-foreground/8 text-muted-foreground",
91
- on && "bg-brand/14 text-brand",
92
- auto &&
93
- "group-data-[state=active]/segment:bg-brand/14 group-data-[state=active]/segment:text-brand",
94
- )}
95
- >
96
- {count.toLocaleString()}
97
- </span>
83
+ <!-- WHY 16px, not 18: inside a 22px segment, 18 read as a chip nested in the pill. -->
84
+ <CountChip
85
+ {count}
86
+ size="sm"
87
+ tone={on ? "brand" : "neutral"}
88
+ class={auto
89
+ ? "group-data-[state=active]/segment:bg-brand/12 group-data-[state=active]/segment:text-brand dark:group-data-[state=active]/segment:text-brand-light"
90
+ : undefined}
91
+ />
98
92
  {:else if done}
99
93
  <span
100
94
  class={cn(
@@ -13,7 +13,6 @@
13
13
  splitSegments,
14
14
  } from "./collapse.js";
15
15
  import { segmentedTriggerClass } from "./segmented.js";
16
- import { Icon } from "../icon/index.js";
17
16
  import { Mark } from "../mark/index.js";
18
17
  import { CaretDown } from "phosphor-svelte";
19
18
  import { Picker } from "../picker/index.js";
@@ -162,7 +161,7 @@
162
161
  )}
163
162
  />
164
163
  {:else if markOnly && option.icon}
165
- <Icon icon={option.icon} size={16} class="shrink-0" />
164
+ <option.icon size={16} class="shrink-0" />
166
165
  {:else}
167
166
  <Segment
168
167
  label={option.label}
@@ -11,12 +11,12 @@
11
11
  // =========================
12
12
  // IMPORTS
13
13
  // =========================
14
- import { CaretDown, CaretUp, CaretUpDown } from "phosphor-svelte";
14
+ import { CaretDown } from "phosphor-svelte";
15
15
  import { cn } from "../../lib/utils.js";
16
16
  import * as Table from "../../shadcn/table/index.js";
17
17
  import * as ContextMenu from "../../shadcn/context-menu/index.js";
18
18
  import { Checkbox } from "../../shadcn/checkbox/index.js";
19
- import { TableSkeleton } from "../data-table/index.js";
19
+ import { SortableHead, TableSkeleton } from "../data-table/index.js";
20
20
  import { toggleSort, type SortState } from "../data-table/data-table.js";
21
21
 
22
22
  // =========================
@@ -174,12 +174,6 @@
174
174
  return value === null || value === undefined ? "" : String(value);
175
175
  }
176
176
 
177
- function ariaSort(column: TableColumn<Row>): "ascending" | "descending" | "none" | undefined {
178
- if (!column.sortable) return undefined;
179
- if (sort?.key !== column.key) return "none";
180
- return sort.direction === "asc" ? "ascending" : "descending";
181
- }
182
-
183
177
  function toggleRow(row: Row, on: boolean) {
184
178
  const next = new Set(selected);
185
179
  if (on) next.add(rowKey(row));
@@ -358,43 +352,35 @@
358
352
  </Table.Head>
359
353
  {/if}
360
354
  {#each columns as column (column.key)}
361
- <Table.Head
362
- class={cn(
363
- "whitespace-normal",
364
- column.numeric && "text-end",
365
- column.sortable && "h-8 p-0",
366
- column.hideBelow && HIDE[column.hideBelow],
367
- column.sticky && "bg-card sticky left-0 z-20",
368
- column.headClass ?? column.class,
369
- )}
370
- style={column.width ? `width: ${column.width}` : undefined}
371
- aria-sort={ariaSort(column)}
372
- >
373
- {#if column.sortable}
374
- {@const dir = sort?.key === column.key ? sort.direction : null}
375
- <button
376
- type="button"
377
- class={cn(
378
- "t-press transition-colors duration-quick ease-smooth-out hover:bg-foreground/4 flex h-8 w-full items-center gap-1 px-2 text-xs font-medium outline-none focus-visible:shadow-(--ring-focus-inset)",
379
- column.numeric ? "justify-end" : "justify-start",
380
- )}
381
- onclick={() => (sort = toggleSort(sort, column.key as keyof Row & string))}
382
- >
383
- {#if head}{@render head(column)}{:else}{column.label}{/if}
384
- {#if dir === "asc"}
385
- <CaretUp class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
386
- {:else if dir === "desc"}
387
- <CaretDown class="text-foreground size-3.5" weight="bold" aria-hidden="true" />
388
- {:else}
389
- <CaretUpDown class="text-muted-foreground size-3.5" aria-hidden="true" />
390
- {/if}
391
- </button>
392
- {:else if head}
393
- {@render head(column)}
394
- {:else}
395
- {column.label}
396
- {/if}
397
- </Table.Head>
355
+ {@const headClass = cn(
356
+ "whitespace-normal",
357
+ column.numeric && "text-end",
358
+ column.hideBelow && HIDE[column.hideBelow],
359
+ column.sticky && "bg-card sticky left-0 z-20",
360
+ column.headClass ?? column.class,
361
+ )}
362
+ {@const headStyle = column.width ? `width: ${column.width}` : undefined}
363
+ {#if column.sortable}
364
+ <!-- A sticky head keeps its card under the hover tint, or rows show through it. -->
365
+ <SortableHead
366
+ key={column.key as keyof Row & string}
367
+ {sort}
368
+ onSort={(key) => (sort = toggleSort(sort, key))}
369
+ numeric={column.numeric}
370
+ class={cn(
371
+ headClass,
372
+ column.sticky &&
373
+ "hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,var(--color-card))]",
374
+ )}
375
+ style={headStyle}
376
+ >
377
+ {#if head}{@render head(column)}{:else}{column.label}{/if}
378
+ </SortableHead>
379
+ {:else}
380
+ <Table.Head class={headClass} style={headStyle}>
381
+ {#if head}{@render head(column)}{:else}{column.label}{/if}
382
+ </Table.Head>
383
+ {/if}
398
384
  {/each}
399
385
  {#if rowHref}
400
386
  <th class="w-0 p-0"><span class="sr-only">Open</span></th>
@@ -64,14 +64,17 @@ describe("TableView extensions", () => {
64
64
  { key: "total", label: "Total", numeric: true, sortable: true },
65
65
  ];
66
66
 
67
- it("sorts from a head button and sets aria-sort", () => {
67
+ it("sorts from a head, by click or Enter, and sets aria-sort", () => {
68
68
  const { target, done } = render({ columns: sortable, sort: { key: "name", direction: "asc" } });
69
- const heads = target.querySelectorAll("thead th");
69
+ const heads = target.querySelectorAll<HTMLElement>("thead th");
70
70
  expect(heads[0].getAttribute("aria-sort")).toBe("ascending");
71
71
  expect(heads[1].getAttribute("aria-sort")).toBe("none");
72
- (heads[1].querySelector("button") as HTMLButtonElement).click();
72
+ heads[1].click();
73
73
  flushSync();
74
74
  expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("ascending");
75
+ heads[1].dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
76
+ flushSync();
77
+ expect(target.querySelectorAll("thead th")[1].getAttribute("aria-sort")).toBe("descending");
75
78
  done();
76
79
  });
77
80
 
@@ -3,7 +3,7 @@
3
3
  import { Plus } from "phosphor-svelte";
4
4
  import { cn } from "../../lib/utils.js";
5
5
  import { Button } from "../../shadcn/button/index.js";
6
- import CountChip from "./count-chip.svelte";
6
+ import CountChip from "../chip/count-chip.svelte";
7
7
 
8
8
  interface Props {
9
9
  label: string;
@@ -1,4 +1,4 @@
1
- export { default as CountChip } from "./count-chip.svelte";
1
+ export { default as CountChip } from "../chip/count-chip.svelte";
2
2
  export { default as GroupHead } from "./group-head.svelte";
3
3
  export { default as PropertyRow } from "./property-row.svelte";
4
4
  export { default as WorkCard } from "./work-card.svelte";
@@ -5,6 +5,7 @@
5
5
  import { Extension } from "@tiptap/core";
6
6
  import { Plugin, PluginKey } from "@tiptap/pm/state";
7
7
  import { Decoration, DecorationSet } from "@tiptap/pm/view";
8
+ import { copyText } from "../components/copy/clipboard.js";
8
9
  import { anchorIds } from "./anchors.js";
9
10
 
10
11
  const LINK_ICON = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0A54.86 54.86 0 0 1 240 88.23ZM109 185.66l-11 11A38.41 38.41 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 54.63 0 8 8 0 0 0 11.31-11.31 54.65 54.65 0 0 0-77.3 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34Z"/></svg>`;
@@ -59,7 +60,7 @@ export const HeadingAnchors = Extension.create<HeadingAnchorsOptions>({
59
60
  button.addEventListener("click", () => {
60
61
  const { origin, pathname, search } = window.location;
61
62
  const url = `${origin}${pathname}${search}#${id}`;
62
- void navigator.clipboard?.writeText(url).catch(() => {});
63
+ void copyText(url);
63
64
  button.dataset.copied = "true";
64
65
  setTimeout(() => delete button.dataset.copied, 1400);
65
66
  options.oncopy?.(url, id);
@@ -8,6 +8,7 @@
8
8
  import "./doc-page.css";
9
9
  import { buildExtensions } from "./extensions.js";
10
10
  import { buildSlashItems, type SlashItem } from "./slash-items.js";
11
+ import { SLASH_WINDOW, slashQueryAt } from "./slash-query.js";
11
12
  import { parseYoutubeUrl } from "./youtube-url.js";
12
13
  import EditorToolbar from "./editor-toolbar.svelte";
13
14
  import SlashMenu from "./slash-menu.svelte";
@@ -238,7 +239,8 @@
238
239
  content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
239
240
  editorProps: {
240
241
  handleKeyDown: (_view, event) => {
241
- if (showSlashMenu) {
242
+ // No items means no menu on screen, so Enter and the arrows stay the editor's.
243
+ if (showSlashMenu && slashItems.length > 0) {
242
244
  if (event.key === "ArrowDown") {
243
245
  event.preventDefault();
244
246
  slashSelectedIndex = (slashSelectedIndex + 1) % slashItems.length;
@@ -294,11 +296,11 @@
294
296
  onchange?.(e.getJSON());
295
297
 
296
298
  const { from } = e.state.selection;
297
- const textBefore = e.state.doc.textBetween(Math.max(0, from - 20), from, "\n");
298
- const slashMatch = textBefore.match(/\/([a-zA-Z0-9 ]*)$/);
299
+ const textBefore = e.state.doc.textBetween(Math.max(0, from - SLASH_WINDOW), from, "\n");
300
+ const query = slashQueryAt(textBefore);
299
301
 
300
- if (slashMatch) {
301
- slashQuery = slashMatch[1];
302
+ if (query !== null) {
303
+ slashQuery = query;
302
304
  slashMenuPos = getCaretCoords();
303
305
  showSlashMenu = true;
304
306
  } else if (showSlashMenu) {
@@ -0,0 +1,32 @@
1
+ const SAFE_SCHEME = /^(?:https?:|mailto:|tel:)/i;
2
+ const ANY_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
3
+ // WHY no SVG: an SVG can carry script, and pasted images are stored as these data: URLs.
4
+ const SAFE_IMAGE_DATA = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,/i;
5
+
6
+ // WHY: browsers drop tabs, newlines and control bytes from a URL, so "java\tscript:" runs as javascript:.
7
+ function clean(raw: string): string {
8
+ return [...raw]
9
+ .filter((c) => c.charCodeAt(0) > 31 && c.charCodeAt(0) !== 127)
10
+ .join("")
11
+ .trim();
12
+ }
13
+
14
+ // The URL when it's safe to put in an href, else null.
15
+ export function safeUrl(raw: string | null | undefined): string | null {
16
+ if (!raw) return null;
17
+ const href = clean(raw);
18
+ // Relative URLs and same-page anchors carry no scheme, so they are safe.
19
+ if (href.startsWith("#") || href.startsWith("/") || href.startsWith(".")) return href;
20
+ if (SAFE_SCHEME.test(href)) return href;
21
+ if (!ANY_SCHEME.test(href)) return href;
22
+ return null; // javascript:, data:, vbscript:, ...
23
+ }
24
+
25
+ // The URL when it's safe to put in an img src, else null.
26
+ export function safeImageUrl(raw: string | null | undefined): string | null {
27
+ const url = safeUrl(raw);
28
+ if (url !== null) return url;
29
+ if (!raw) return null;
30
+ const src = clean(raw);
31
+ return SAFE_IMAGE_DATA.test(src) ? src : null;
32
+ }
@@ -87,6 +87,64 @@ describe("renderTiptapContent", () => {
87
87
  });
88
88
  });
89
89
 
90
+ function linkDoc(href: string) {
91
+ return {
92
+ type: "doc",
93
+ content: [
94
+ {
95
+ type: "paragraph",
96
+ content: [{ type: "text", text: "click", marks: [{ type: "link", attrs: { href } }] }],
97
+ },
98
+ ],
99
+ };
100
+ }
101
+
102
+ function imageDoc(src: string) {
103
+ return { type: "doc", content: [{ type: "image", attrs: { src } }] };
104
+ }
105
+
106
+ describe("unsafe URLs", () => {
107
+ it("keeps the text and drops a javascript: href", () => {
108
+ const html = renderTiptapContent(linkDoc("javascript:alert(1)"));
109
+ expect(html).toContain("click");
110
+ expect(html).not.toContain("javascript:");
111
+ expect(html).not.toContain("href=");
112
+ });
113
+
114
+ it("drops a javascript: href hidden by a tab inside the scheme", () => {
115
+ const html = renderTiptapContent(linkDoc("java\tscript:alert(1)"));
116
+ expect(html).toContain("click");
117
+ expect(html).not.toContain("javascript:");
118
+ expect(html).not.toContain("href=");
119
+ });
120
+
121
+ it.each(["data:text/html,x", "vbscript:x"])("drops the %s href", (href) => {
122
+ expect(renderTiptapContent(linkDoc(href))).not.toContain("href=");
123
+ });
124
+
125
+ it.each(["https://example.com/a?b=1", "/docs", "#top", "mailto:a@example.com"])(
126
+ "keeps the %s href",
127
+ (href) => {
128
+ expect(renderTiptapContent(linkDoc(href))).toContain(`href="${href}"`);
129
+ },
130
+ );
131
+
132
+ it("drops an image with a javascript: src", () => {
133
+ expect(renderTiptapContent(imageDoc("javascript:alert(1)"))).not.toContain("<img");
134
+ });
135
+
136
+ it.each(["https://example.com/a.png", "data:image/png;base64,AAAA"])(
137
+ "keeps an image with the %s src",
138
+ (src) => {
139
+ expect(renderTiptapContent(imageDoc(src))).toContain("<img");
140
+ },
141
+ );
142
+
143
+ it("drops an image with an SVG data: src", () => {
144
+ expect(renderTiptapContent(imageDoc("data:image/svg+xml;base64,AAAA"))).not.toContain("<img");
145
+ });
146
+ });
147
+
90
148
  describe("youtube node", () => {
91
149
  function youtubeDoc(attrs: Record<string, unknown>) {
92
150
  return { type: "doc", content: [{ type: "youtube", attrs }] };