@heyramzi/ui 2.22.1 → 2.23.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 (47) hide show
  1. package/dist/canvas/editor/rebase.js +26 -2
  2. package/dist/editor/safe-url.js +26 -0
  3. package/dist/editor/serializer.js +10 -4
  4. package/dist/editor/slash-query.js +10 -0
  5. package/dist/triage/index.js +4 -0
  6. package/dist/triage/types.js +12 -0
  7. package/package.json +7 -1
  8. package/scripts/sync-shadcn.mjs +4 -6
  9. package/src/animations/modal.css +4 -1
  10. package/src/canvas/editor/rebase.test.ts +37 -0
  11. package/src/canvas/editor/rebase.ts +33 -1
  12. package/src/canvas/ui/canvas-ui.css +32 -1
  13. package/src/charts/area-chart.svelte +2 -2
  14. package/src/charts/bar-chart.svelte +2 -2
  15. package/src/charts/chart-tokens.css +19 -0
  16. package/src/charts/dashboard/stat-tile.svelte +1 -1
  17. package/src/charts/donut-chart.svelte +1 -1
  18. package/src/charts/gauge-chart.svelte +1 -1
  19. package/src/charts/line-chart.svelte +2 -2
  20. package/src/charts/number-chart.svelte +1 -1
  21. package/src/charts/ring-chart.svelte +2 -2
  22. package/src/charts/sparkline.svelte +1 -1
  23. package/src/charts/stat-card.svelte +1 -1
  24. package/src/charts/time-series-chart.svelte +1 -1
  25. package/src/editor/editor.svelte +7 -5
  26. package/src/editor/safe-url.ts +32 -0
  27. package/src/editor/serializer.test.ts +58 -0
  28. package/src/editor/serializer.ts +9 -6
  29. package/src/editor/slash-query.test.ts +19 -0
  30. package/src/editor/slash-query.ts +11 -0
  31. package/src/modules.test.ts +13 -1
  32. package/src/shadcn/alert-dialog/alert-dialog-overlay.svelte +1 -1
  33. package/src/shadcn/button/button.svelte +1 -1
  34. package/src/shadcn/button/button.test.ts +2 -2
  35. package/src/shadcn/command/command-dialog.svelte +5 -10
  36. package/src/shadcn/dialog/dialog-overlay.svelte +1 -1
  37. package/src/shadcn/input/input.svelte +2 -2
  38. package/src/shadcn/sheet/sheet-overlay.svelte +1 -1
  39. package/src/tokens/craft.css +30 -0
  40. package/src/triage/README.md +28 -0
  41. package/src/triage/index.ts +10 -0
  42. package/src/triage/triage-plan.svelte +132 -0
  43. package/src/triage/triage-queue.svelte +48 -0
  44. package/src/triage/triage-task.svelte +95 -0
  45. package/src/triage/types.ts +35 -0
  46. package/src/work/kanban-column.svelte +5 -3
  47. package/src/work/time/time-chip.svelte +1 -3
@@ -151,7 +151,10 @@ function patchEdgeOps(before, edge) {
151
151
  function edgeOps(base, current) {
152
152
  const baseEdges = new Map(base.edges.map((e) => [e.id, e]));
153
153
  const currentIds = new Set(current.edges.map((e) => e.id));
154
- const removed = base.edges.filter((e) => !currentIds.has(e.id)).map((e) => ({
154
+ const currentNodeIds = new Set(current.nodes.map((n) => n.id));
155
+ const removedNodeIds = new Set(base.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id));
156
+ // WHY: removeNode already takes the node's edges, and removeEdge throws on a missing id.
157
+ const removed = base.edges.filter((e) => !currentIds.has(e.id)).filter((e) => !removedNodeIds.has(String(e.source)) && !removedNodeIds.has(String(e.target))).map((e) => ({
155
158
  kind: "removeEdge",
156
159
  edgeId: e.id
157
160
  }));
@@ -238,11 +241,32 @@ function parentsFirst(nodes) {
238
241
  return sortParentsFirst(wrapped).map(({ node }) => node);
239
242
  }
240
243
  /**
244
+ * Drops removals the server already made, so a delete on both sides merges. Only
245
+ * removeNode and removeEdge: an edit to a node the server deleted must still throw.
246
+ */
247
+ function withoutServerRemovals(serverDoc, ops) {
248
+ const serverNodeIds = new Set(serverDoc.nodes.map((n) => n.id));
249
+ const serverEdges = new Map(serverDoc.edges.map((e) => [e.id, e]));
250
+ const removedNodeIds = new Set();
251
+ return ops.filter((op) => {
252
+ if (op.kind === "removeNode") {
253
+ if (!serverNodeIds.has(op.nodeId)) return false;
254
+ removedNodeIds.add(op.nodeId);
255
+ return true;
256
+ }
257
+ if (op.kind === "removeEdge") {
258
+ const edge = serverEdges.get(op.edgeId);
259
+ return edge !== undefined && !removedNodeIds.has(String(edge.source)) && !removedNodeIds.has(String(edge.target));
260
+ }
261
+ return true;
262
+ });
263
+ }
264
+ /**
241
265
  * Replay local changes (lastSaved -> local) on the server document. Returns
242
266
  * null when impossible, like a patched node deleted, so the save error shows.
243
267
  */
244
268
  export function rebaseDiagram(serverDoc, lastSaved, local) {
245
- const ops = diffToOps(lastSaved, local);
269
+ const ops = withoutServerRemovals(serverDoc, diffToOps(lastSaved, local));
246
270
  const res = tryCatch(() => applyOps({
247
271
  nodes: serverDoc.nodes,
248
272
  edges: serverDoc.edges
@@ -0,0 +1,26 @@
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
+ // WHY: browsers drop tabs, newlines and control bytes from a URL, so "java\tscript:" runs as javascript:.
6
+ function clean(raw) {
7
+ return [...raw].filter((c) => c.charCodeAt(0) > 31 && c.charCodeAt(0) !== 127).join("").trim();
8
+ }
9
+ // The URL when it's safe to put in an href, else null.
10
+ export function safeUrl(raw) {
11
+ if (!raw) return null;
12
+ const href = clean(raw);
13
+ // Relative URLs and same-page anchors carry no scheme, so they are safe.
14
+ if (href.startsWith("#") || href.startsWith("/") || href.startsWith(".")) return href;
15
+ if (SAFE_SCHEME.test(href)) return href;
16
+ if (!ANY_SCHEME.test(href)) return href;
17
+ return null;
18
+ }
19
+ // The URL when it's safe to put in an img src, else null.
20
+ export function safeImageUrl(raw) {
21
+ const url = safeUrl(raw);
22
+ if (url !== null) return url;
23
+ if (!raw) return null;
24
+ const src = clean(raw);
25
+ return SAFE_IMAGE_DATA.test(src) ? src : null;
26
+ }
@@ -1,3 +1,4 @@
1
+ import { safeImageUrl, safeUrl } from "./safe-url.js";
1
2
  // WHY our own slugify: the website's uses NFKD, which strips accents from French
2
3
  // headings. The vibe-kit utils one doesn't, so accented anchors would differ.
3
4
  function slugify(input, maxLength = 60) {
@@ -99,7 +100,7 @@ function numString(attrs, key) {
99
100
  return value === undefined ? undefined : String(value);
100
101
  }
101
102
  function renderImage(node) {
102
- const src = strAttr(node.attrs, "src");
103
+ const src = safeImageUrl(strAttr(node.attrs, "src"));
103
104
  if (!src) return "";
104
105
  const attrs = renderAttributes({
105
106
  alt: strAttr(node.attrs, "alt") ?? "",
@@ -237,10 +238,15 @@ const MARK_TAGS = {
237
238
  };
238
239
  function wrapMark(content, mark) {
239
240
  if (mark.type === "link") {
241
+ // WHY: stored docs come from teammates, clients and CLIs that skip the editor's link filter.
242
+ const href = safeUrl(strAttr(mark.attrs, "href"));
243
+ if (!href) return content;
244
+ const target = strAttr(mark.attrs, "target");
245
+ const rel = strAttr(mark.attrs, "rel") || (target ? "noopener noreferrer" : undefined);
240
246
  const attrs = renderAttributes({
241
- href: strAttr(mark.attrs, "href") ?? "",
242
- target: strAttr(mark.attrs, "target"),
243
- rel: strAttr(mark.attrs, "rel")
247
+ href,
248
+ target,
249
+ rel
244
250
  });
245
251
  return `<a${attrs}>${content}</a>`;
246
252
  }
@@ -0,0 +1,10 @@
1
+ // One character of context, the slash, and a query of up to 20.
2
+ export const SLASH_WINDOW = 22;
3
+ /**
4
+ * The slash command typed just before the caret, or null when there isn't one. The slash has to
5
+ * start the text or follow whitespace, so `TCP/IP` and `and/or` stay plain text.
6
+ */
7
+ export function slashQueryAt(textBefore) {
8
+ const match = textBefore.match(/(?:^|\s)\/([a-zA-Z0-9 ]{0,20})$/);
9
+ return match ? match[1] : null;
10
+ }
@@ -0,0 +1,4 @@
1
+ export { default as TriagePlan } from "../../src/triage/triage-plan.svelte";
2
+ export { default as TriageTask } from "../../src/triage/triage-task.svelte";
3
+ export { default as TriageQueue } from "../../src/triage/triage-queue.svelte";
4
+ export { DEFAULT_TRIAGE_PLAN_LABELS, DEFAULT_TRIAGE_TASK_LABELS } from "./types.js";
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_TRIAGE_PLAN_LABELS = {
2
+ approve: "Approve",
3
+ pick: "Pick another task",
4
+ dismiss: "Dismiss",
5
+ taskLabel: "Task",
6
+ useTask: "Use this task",
7
+ cancel: "Cancel"
8
+ };
9
+ export const DEFAULT_TRIAGE_TASK_LABELS = {
10
+ undo: "Undo",
11
+ openTask: "Open task"
12
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyramzi/ui",
3
- "version": "2.22.1",
3
+ "version": "2.23.0",
4
4
  "description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
5
5
  "homepage": "https://ui.heyramzi.com",
6
6
  "license": "MIT",
@@ -225,6 +225,12 @@
225
225
  "node": "./dist/inbox/index.js",
226
226
  "default": "./src/inbox/index.ts"
227
227
  },
228
+ "./triage": {
229
+ "svelte": "./src/triage/index.ts",
230
+ "types": "./src/triage/index.ts",
231
+ "node": "./dist/triage/index.js",
232
+ "default": "./src/triage/index.ts"
233
+ },
228
234
  "./work": {
229
235
  "svelte": "./src/work/index.ts",
230
236
  "types": "./src/work/index.ts",
@@ -141,24 +141,22 @@ const publicImports = (demo) =>
141
141
  .join("\n");
142
142
 
143
143
  /* The section's list, generated: a primitive reaches the page by being synced,
144
- * never by being typed into an array. */
144
+ * never by being typed into an array. Names and words only: the site's navigation reads this
145
+ * list on every page, and each demo loads from `./<name>.svelte` on its own page. */
145
146
  async function writeManifest(demos) {
146
147
  const rows = demos.map(
147
148
  (d) =>
148
- ` { name: ${JSON.stringify(d.name)}, title: ${JSON.stringify(d.title)}, note: ${JSON.stringify(d.note)}, path: ${JSON.stringify(d.imports)}, Comp: ${pascal(d.name)}Demo },`,
149
+ ` { name: ${JSON.stringify(d.name)}, title: ${JSON.stringify(d.title)}, note: ${JSON.stringify(d.note)}, path: ${JSON.stringify(d.imports)} },`,
149
150
  );
150
151
  const text = [
151
152
  "/* Generated by scripts/sync-shadcn.mjs. Do not edit: the demos are upstream's",
152
- " * own, and the sync rewrites this list. */",
153
- 'import type { Component } from "svelte";',
154
- ...demos.map((d) => `import ${pascal(d.name)}Demo from "./${d.name}.svelte";`),
153
+ " * own, and the sync rewrites this list. Each demo loads from `./<name>.svelte`. */",
155
154
  "",
156
155
  "export type ShadcnDemo = {",
157
156
  " name: string;",
158
157
  " title: string;",
159
158
  " note: string;",
160
159
  " path: string;",
161
- " Comp: Component;",
162
160
  "};",
163
161
  "",
164
162
  "export const SHADCN_DEMOS: ShadcnDemo[] = [",
@@ -7,7 +7,8 @@
7
7
  --modal-scale: var(--scale-large, 0.96);
8
8
  --modal-scale-close: var(--scale-large, 0.96);
9
9
  --modal-ease: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
10
- --modal-backdrop-color: rgb(0 0 0 / 0.5);
10
+ --modal-backdrop-color: var(--scrim, rgb(0 0 0 / 0.32));
11
+ --modal-backdrop-blur: var(--scrim-blur, 2px);
11
12
  }
12
13
 
13
14
  /* === Upstream: class-based toggle === */
@@ -54,6 +55,8 @@
54
55
  /* WHY: pair .t-modal-backdrop with the modal so the overlay fades in sync. */
55
56
  .t-modal-backdrop {
56
57
  background: var(--modal-backdrop-color);
58
+ -webkit-backdrop-filter: blur(var(--modal-backdrop-blur));
59
+ backdrop-filter: blur(var(--modal-backdrop-blur));
57
60
  opacity: 0;
58
61
  transition: opacity var(--modal-open-dur) var(--modal-ease);
59
62
  }
@@ -62,6 +62,15 @@ describe("diffToOps", () => {
62
62
  const replayed = applyOps({ nodes: [], edges: [] }, diffToOps(base, current));
63
63
  expect(replayed.nodes).toHaveLength(2);
64
64
  });
65
+
66
+ it("replays the delete of a connected node", () => {
67
+ const base = doc([n("a"), n("b")], [{ id: "e1", source: "a", target: "b" }]);
68
+ const current = doc([n("a")]);
69
+
70
+ const replayed = applyOps({ nodes: base.nodes, edges: base.edges }, diffToOps(base, current));
71
+ expect(replayed.nodes).toHaveLength(1);
72
+ expect(replayed.edges).toHaveLength(0);
73
+ });
65
74
  });
66
75
 
67
76
  describe("rebaseDiagram", () => {
@@ -186,6 +195,34 @@ describe("rebaseDiagram", () => {
186
195
  const local = doc([n("a"), n("b", { data: { label: "edited" } })]);
187
196
  expect(rebaseDiagram(serverDoc, lastSaved, local)).toBeNull();
188
197
  });
198
+
199
+ describe("deletes", () => {
200
+ const connected = doc([n("a"), n("b")], [{ id: "e1", source: "a", target: "b" }]);
201
+
202
+ it("merges a local delete of a connected node", () => {
203
+ const server = doc([n("a"), n("b"), n("c")], [{ id: "e1", source: "a", target: "b" }]);
204
+ const local = doc([n("a")]);
205
+
206
+ const rebased = expectDefined(rebaseDiagram(server, connected, local), "rebased doc");
207
+ expect(rebased.nodes.map((node) => node.id)).toEqual(["a", "c"]);
208
+ expect(rebased.edges).toEqual([]);
209
+ });
210
+
211
+ it("merges when both sides deleted the same node", () => {
212
+ const server = doc([n("a"), n("c")]);
213
+ const local = doc([n("a")]);
214
+
215
+ const rebased = expectDefined(rebaseDiagram(server, connected, local), "rebased doc");
216
+ expect(rebased.nodes.map((node) => node.id)).toEqual(["a", "c"]);
217
+ });
218
+
219
+ it("merges when both sides deleted the same edge", () => {
220
+ const both = doc([n("a"), n("b")]);
221
+
222
+ const rebased = expectDefined(rebaseDiagram(both, connected, both), "rebased doc");
223
+ expect(rebased.edges).toHaveLength(0);
224
+ });
225
+ });
189
226
  });
190
227
 
191
228
  describe("a new edge keeps everything it was drawn with", () => {
@@ -164,8 +164,14 @@ function patchEdgeOps(before: DocEdge, edge: DocEdge): CanvasOp[] {
164
164
  function edgeOps(base: DiagramDoc, current: DiagramDoc): CanvasOp[] {
165
165
  const baseEdges = new Map(base.edges.map((e) => [e.id, e]));
166
166
  const currentIds = new Set(current.edges.map((e) => e.id));
167
+ const currentNodeIds = new Set(current.nodes.map((n) => n.id));
168
+ const removedNodeIds = new Set(
169
+ base.nodes.filter((n) => !currentNodeIds.has(n.id)).map((n) => n.id),
170
+ );
171
+ // WHY: removeNode already takes the node's edges, and removeEdge throws on a missing id.
167
172
  const removed: CanvasOp[] = base.edges
168
173
  .filter((e) => !currentIds.has(e.id))
174
+ .filter((e) => !removedNodeIds.has(String(e.source)) && !removedNodeIds.has(String(e.target)))
169
175
  .map((e) => ({ kind: "removeEdge", edgeId: e.id }));
170
176
  const upserted = current.edges.flatMap((edge) => {
171
177
  const before = baseEdges.get(edge.id);
@@ -255,6 +261,32 @@ function parentsFirst(nodes: Record<string, unknown>[]): Record<string, unknown>
255
261
  return sortParentsFirst(wrapped).map(({ node }) => node);
256
262
  }
257
263
 
264
+ /**
265
+ * Drops removals the server already made, so a delete on both sides merges. Only
266
+ * removeNode and removeEdge: an edit to a node the server deleted must still throw.
267
+ */
268
+ function withoutServerRemovals(serverDoc: DiagramDoc, ops: CanvasOp[]): CanvasOp[] {
269
+ const serverNodeIds = new Set(serverDoc.nodes.map((n) => n.id));
270
+ const serverEdges = new Map(serverDoc.edges.map((e) => [e.id, e]));
271
+ const removedNodeIds = new Set<string>();
272
+ return ops.filter((op) => {
273
+ if (op.kind === "removeNode") {
274
+ if (!serverNodeIds.has(op.nodeId)) return false;
275
+ removedNodeIds.add(op.nodeId);
276
+ return true;
277
+ }
278
+ if (op.kind === "removeEdge") {
279
+ const edge = serverEdges.get(op.edgeId);
280
+ return (
281
+ edge !== undefined &&
282
+ !removedNodeIds.has(String(edge.source)) &&
283
+ !removedNodeIds.has(String(edge.target))
284
+ );
285
+ }
286
+ return true;
287
+ });
288
+ }
289
+
258
290
  /**
259
291
  * Replay local changes (lastSaved -> local) on the server document. Returns
260
292
  * null when impossible, like a patched node deleted, so the save error shows.
@@ -264,7 +296,7 @@ export function rebaseDiagram(
264
296
  lastSaved: DiagramDoc,
265
297
  local: DiagramDoc,
266
298
  ): RawCanvasData | null {
267
- const ops = diffToOps(lastSaved, local);
299
+ const ops = withoutServerRemovals(serverDoc, diffToOps(lastSaved, local));
268
300
  const res = tryCatch(() => applyOps({ nodes: serverDoc.nodes, edges: serverDoc.edges }, ops));
269
301
  if (!res.ok) return null;
270
302
  const nodes = applyNodeFieldPatches(res.value.nodes, nodeFieldPatches(lastSaved, local));
@@ -43,6 +43,37 @@
43
43
  transform: translateX(-50%);
44
44
  }
45
45
 
46
+ /* A phone has no room for a side rail: at 390px a rail on the left covers the
47
+ board xyflow just fitted. The rail lies flat along the bottom edge instead,
48
+ with its style bar stacked above it. */
49
+ @media (max-width: 639px) {
50
+ .svelte-flow .svelte-flow__panel.cv-dock.left.center {
51
+ top: auto;
52
+ bottom: 0;
53
+ left: 50%;
54
+ transform: translateX(-50%);
55
+ }
56
+
57
+ .cv-dock > .cv-row {
58
+ flex-direction: column-reverse;
59
+ }
60
+
61
+ .cv-dock .cv-rail {
62
+ flex-direction: row;
63
+ }
64
+ }
65
+
66
+ /* xyflow's credit is #999 on a half-white wash, which turns grey on grey on a
67
+ dark board. It takes the chrome's surface and muted ink in both themes. */
68
+ .svelte-flow .svelte-flow__attribution {
69
+ border-top-left-radius: var(--radius-sm, 4px);
70
+ background: var(--cv-surface);
71
+ }
72
+
73
+ .svelte-flow .svelte-flow__attribution a {
74
+ color: var(--cv-ink-muted);
75
+ }
76
+
46
77
  /* Beside the selection: StylePanel writes the spot, and a phone keeps its own row above the pill. */
47
78
  @media (min-width: 640px) {
48
79
  .svelte-flow .svelte-flow__panel.cv-dock.cv-style-dock.is-placed {
@@ -115,7 +146,7 @@
115
146
  }
116
147
 
117
148
  .cv-btn:active:not(:disabled) {
118
- scale: 0.94;
149
+ scale: 0.96;
119
150
  }
120
151
 
121
152
  /* One active style for a tool, a size, a brush and an open popover. */
@@ -70,7 +70,7 @@
70
70
  xKey,
71
71
  yKey,
72
72
  series: seriesInput,
73
- color = "var(--chart-1)",
73
+ color = "var(--chart-series-1)",
74
74
  height = 240,
75
75
  stacked = false,
76
76
  smooth = true,
@@ -119,7 +119,7 @@
119
119
  ? seriesInput.map((s, i) => ({
120
120
  key: s.key,
121
121
  label: s.label ?? s.key,
122
- color: s.color ?? `var(--chart-${(i % 5) + 1})`,
122
+ color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
123
123
  }))
124
124
  : yKey
125
125
  ? [{ key: yKey, label: yKey, color }]
@@ -55,7 +55,7 @@
55
55
  xKey,
56
56
  yKey,
57
57
  series,
58
- color = "var(--chart-1)",
58
+ color = "var(--chart-series-1)",
59
59
  colorOf,
60
60
  height = 240,
61
61
  orientation = "vertical",
@@ -161,7 +161,7 @@
161
161
  {#if showFigure}
162
162
  <Figure
163
163
  value={active ? valueOf(active) : total}
164
- color="var(--chart-1)"
164
+ color="var(--chart-series-1)"
165
165
  eyebrow={active ? formatX(active[xKey]) : "Total"}
166
166
  format={formatValue}
167
167
  size="md"
@@ -16,6 +16,25 @@
16
16
  .hr-ring,
17
17
  .hr-gauge
18
18
  ) {
19
+ /* The categorical series palette. The brand leads, then hues spaced round the
20
+ wheel so neighbours never share a family (blue and orange first, the pair that
21
+ survives colour blindness). Each hue is a cat token pulled 15% toward the text
22
+ colour: darker on a light card, lighter on a dark one, so every series clears
23
+ 3:1 against the card in both themes (lowest: teal, 3.6:1 light, 6.2:1 dark). */
24
+ --chart-series-1: var(--color-brand, oklch(0.28 0 0));
25
+ --chart-series-2: color-mix(in oklab, var(--color-cat-blue) 85%, var(--color-foreground));
26
+ --chart-series-3: color-mix(in oklab, var(--color-cat-orange) 85%, var(--color-foreground));
27
+ --chart-series-4: color-mix(in oklab, var(--color-cat-green) 85%, var(--color-foreground));
28
+ --chart-series-5: color-mix(in oklab, var(--color-cat-pink) 85%, var(--color-foreground));
29
+ --chart-series-6: color-mix(in oklab, var(--color-cat-teal) 85%, var(--color-foreground));
30
+ /* The palette's `--chart-N` names point here inside a chart, so a series passed as
31
+ `var(--chart-3)` gets a hue, not the brand's grey highlight. */
32
+ --chart-1: var(--chart-series-1);
33
+ --chart-2: var(--chart-series-2);
34
+ --chart-3: var(--chart-series-3);
35
+ --chart-4: var(--chart-series-4);
36
+ --chart-5: var(--chart-series-5);
37
+ --chart-6: var(--chart-series-6);
19
38
  --chart-label: var(--muted-foreground, oklch(0.5 0.01 286));
20
39
  --chart-grid: color-mix(in srgb, var(--border, oklch(0.5 0.01 286)) 50%, transparent);
21
40
  --chart-ease-out: var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
@@ -159,7 +159,7 @@
159
159
  data={points}
160
160
  xKey="i"
161
161
  yKey="v"
162
- color={color ?? "var(--chart-1)"}
162
+ color={color ?? "var(--chart-series-1)"}
163
163
  height={48}
164
164
  bare
165
165
  />
@@ -130,7 +130,7 @@
130
130
  cornerRadius={3}
131
131
  fill={colorKey
132
132
  ? String((arc.data as TData)[colorKey])
133
- : `var(--chart-${(i % 5) + 1})`}
133
+ : `var(--chart-series-${(i % 6) + 1})`}
134
134
  class="hr-donut-arc"
135
135
  data-hovered={isHovered ? "" : undefined}
136
136
  style="opacity: {dimmed ? 0.4 : 1}; translate: {dx}px {dy}px"
@@ -90,7 +90,7 @@
90
90
  endAngle = 405,
91
91
  useGradient = false,
92
92
  activeGradient,
93
- activeFill = "var(--chart-1)",
93
+ activeFill = "var(--chart-series-1)",
94
94
  inactiveFill,
95
95
  activeFillOpacity = 1,
96
96
  inactiveFillOpacity = 0.12,
@@ -68,7 +68,7 @@
68
68
  xKey,
69
69
  yKey,
70
70
  series: seriesInput,
71
- color = "var(--chart-1)",
71
+ color = "var(--chart-series-1)",
72
72
  height = 240,
73
73
  smooth = true,
74
74
  edgeFade = true,
@@ -104,7 +104,7 @@
104
104
  ? seriesInput.map((s, i) => ({
105
105
  key: s.key,
106
106
  label: s.label ?? s.key,
107
- color: s.color ?? `var(--chart-${(i % 5) + 1})`,
107
+ color: s.color ?? `var(--chart-series-${(i % 6) + 1})`,
108
108
  }))
109
109
  : yKey
110
110
  ? [{ key: yKey, label: yKey, color }]
@@ -97,7 +97,7 @@
97
97
  deltaThreshold = 0.5,
98
98
  share,
99
99
  shareLabel,
100
- color = "var(--chart-1)",
100
+ color = "var(--chart-series-1)",
101
101
  size = "md",
102
102
  caption,
103
103
  negative = false,
@@ -16,7 +16,7 @@
16
16
  value: number;
17
17
  /** Denominator for the progress arc. */
18
18
  maxValue: number;
19
- /** CSS color or var. Falls back to --chart-1..5. */
19
+ /** CSS color or var. Falls back to --chart-series-1..6. */
20
20
  color?: string;
21
21
  };
22
22
 
@@ -82,7 +82,7 @@
82
82
  index: i,
83
83
  radius,
84
84
  progress,
85
- color: d.color ?? `var(--chart-${(i % 5) + 1})`,
85
+ color: d.color ?? `var(--chart-series-${(i % 6) + 1})`,
86
86
  };
87
87
  }),
88
88
  );
@@ -54,7 +54,7 @@
54
54
  data: input,
55
55
  xKey,
56
56
  yKey,
57
- color = "var(--chart-1)",
57
+ color = "var(--chart-series-1)",
58
58
  height = 44,
59
59
  smooth = true,
60
60
  endCap = true,
@@ -81,7 +81,7 @@
81
81
  trend,
82
82
  caption,
83
83
  figureOnly = false,
84
- color = "var(--chart-1)",
84
+ color = "var(--chart-series-1)",
85
85
  height = 150,
86
86
  smooth = true,
87
87
  edgeFade = true,
@@ -62,7 +62,7 @@
62
62
  data,
63
63
  label = "Value",
64
64
  label2,
65
- color = "var(--chart-1)",
65
+ color = "var(--chart-series-1)",
66
66
  color2 = "var(--muted-foreground)",
67
67
  formatValue = (v) => v.toLocaleString(),
68
68
  formatValue2,
@@ -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
+ }