@heyramzi/ui 3.7.0 → 3.8.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/COMPONENTS.md +1 -0
  2. package/dist/canvas/pen/node-name.svelte.js +29 -6
  3. package/dist/components/file-drop-zone/files.js +111 -0
  4. package/dist/components/file-drop-zone/index.js +2 -0
  5. package/package.json +13 -1
  6. package/scripts/lib/svelte-modules.mjs +38 -0
  7. package/scripts/svelte-dts.mjs +2 -0
  8. package/scripts/svelte-modules.mjs +14 -0
  9. package/scripts/sync-shadcn.mjs +0 -1
  10. package/src/ai/chat-window/reveal.svelte.js +106 -0
  11. package/src/ai/confirmation/confirmation-context.svelte.js +12 -0
  12. package/src/ai/reasoning/reasoning-context.svelte.js +12 -0
  13. package/src/canvas/editor/dispatch.svelte.js +160 -0
  14. package/src/canvas/lod/lod-mode.svelte.js +24 -0
  15. package/src/canvas/lod/render-all.svelte.js +42 -0
  16. package/src/canvas/pen/node-name.svelte.js +48 -0
  17. package/src/canvas/pen/node-name.svelte.ts +30 -6
  18. package/src/components/copy/copied.svelte.js +34 -0
  19. package/src/components/docs/scroll-spy.svelte.js +76 -0
  20. package/src/components/file-drop-zone/file-drop-zone.svelte +348 -0
  21. package/src/components/file-drop-zone/file-drop-zone.svelte.d.ts +43 -0
  22. package/src/components/file-drop-zone/file-drop-zone.test.ts +148 -0
  23. package/src/components/file-drop-zone/files.test.ts +105 -0
  24. package/src/components/file-drop-zone/files.ts +144 -0
  25. package/src/components/file-drop-zone/index.ts +14 -0
  26. package/src/consumer-prebundle.test.ts +126 -0
  27. package/src/effects/animated-beam/path-calculator.svelte.js +54 -0
  28. package/src/onboarding/onboarding.svelte.js +19 -0
  29. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  30. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  31. package/src/shadcn/calendar/calendar.svelte +40 -30
  32. package/src/shadcn/calendar/calendar.svelte.d.ts +0 -1
  33. package/src/shadcn/hooks/is-mobile.svelte.js +7 -0
  34. package/src/shadcn/hooks/use-highlighted-code.svelte.js +65 -0
  35. package/src/shadcn/input/input.svelte +1 -1
  36. package/src/shadcn/input/input.svelte.d.ts +1 -1
  37. package/src/shadcn/input-group/input-group-input.svelte.d.ts +1 -1
  38. package/src/shadcn/input-otp/input-otp-group.svelte +1 -0
  39. package/src/shadcn/input-otp/input-otp-slot.svelte +5 -1
  40. package/src/shadcn/input-otp/input-otp.svelte +2 -0
  41. package/src/shadcn/input-otp/input-otp.svelte.d.ts +1 -1
  42. package/src/shadcn/sidebar/context.svelte.js +63 -0
  43. package/src/shadcn/sidebar/sidebar-input.svelte.d.ts +1 -1
  44. package/src/slides/deck-controller.svelte.js +254 -0
  45. package/src/work/kanban-board-state.svelte.js +205 -0
  46. package/src/work/kanban-rail-state.svelte.js +85 -0
  47. package/src/work/use-kanban-dnd.svelte.js +129 -0
@@ -0,0 +1,148 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
2
+ import { flushSync, mount, unmount } from "svelte";
3
+ import FileDropZone from "./file-drop-zone.svelte";
4
+
5
+ // jsdom has no DataTransfer and its input.files takes only a FileList, so the stub returns an
6
+ // array and each input records what the zone wrote.
7
+ class DataTransferStub {
8
+ readonly list: File[] = [];
9
+ readonly items = { add: (file: File) => this.list.push(file) };
10
+ get files() {
11
+ return this.list;
12
+ }
13
+ }
14
+
15
+ beforeEach(() => {
16
+ (globalThis as { DataTransfer?: unknown }).DataTransfer = DataTransferStub;
17
+ });
18
+
19
+ afterEach(() => {
20
+ delete (globalThis as { DataTransfer?: unknown }).DataTransfer;
21
+ document.body.innerHTML = "";
22
+ });
23
+
24
+ const pdf = (name: string, size = 10) =>
25
+ new File([new Uint8Array(size)], name, { type: "application/pdf", lastModified: 1 });
26
+
27
+ function render(props: Record<string, unknown> = {}) {
28
+ const target = document.createElement("div");
29
+ document.body.appendChild(target);
30
+ const app = mount(FileDropZone, { target, props: { name: "justificatif", ...props } });
31
+ flushSync();
32
+ const input = target.querySelector<HTMLInputElement>("input[type=file]");
33
+ if (!input) throw new Error("no file input");
34
+ let written: File[] = [];
35
+ Object.defineProperty(input, "files", {
36
+ configurable: true,
37
+ get: () => written,
38
+ set: (value: File[]) => {
39
+ written = [...value];
40
+ },
41
+ });
42
+ const fire = (type: string, files: File[]) => {
43
+ const event = new Event(type, { bubbles: true, cancelable: true });
44
+ Object.defineProperty(event, "dataTransfer", {
45
+ value: { types: ["Files"], files, dropEffect: "none" },
46
+ });
47
+ target.querySelector("[data-slot=file-drop-zone-target]")?.dispatchEvent(event);
48
+ flushSync();
49
+ };
50
+ const drop = (files: File[]) => {
51
+ fire("dragenter", files);
52
+ fire("drop", files);
53
+ };
54
+ const names = () =>
55
+ [...target.querySelectorAll("[data-file-name]")].map((row) =>
56
+ row.getAttribute("data-file-name"),
57
+ );
58
+ return {
59
+ target,
60
+ input,
61
+ drop,
62
+ fire,
63
+ names,
64
+ written: () => written.map((file) => file.name),
65
+ done: () => void unmount(app),
66
+ };
67
+ }
68
+
69
+ describe("FileDropZone", () => {
70
+ it("speaks French with no setup", () => {
71
+ const { target, done } = render({ accept: ".pdf", maxFileSize: 10 * 1024 * 1024 });
72
+ const text = target.textContent ?? "";
73
+ expect(text).toContain("Glissez votre fichier ici");
74
+ expect(text).toContain("ou cliquez pour le choisir");
75
+ expect(text).toContain("PDF, 10 Mo maximum");
76
+ done();
77
+ });
78
+
79
+ it("writes dropped files into the real input, so a form post carries them", () => {
80
+ const { input, drop, names, written, done } = render({ multiple: true });
81
+ drop([pdf("bail.pdf"), pdf("quittance.pdf")]);
82
+ expect(names()).toEqual(["bail.pdf", "quittance.pdf"]);
83
+ expect(written()).toEqual(["bail.pdf", "quittance.pdf"]);
84
+ expect(input.name).toBe("justificatif");
85
+ done();
86
+ });
87
+
88
+ it("shows the drag state while a file hovers", () => {
89
+ const { target, fire, done } = render();
90
+ fire("dragenter", [pdf("bail.pdf")]);
91
+ expect(target.textContent).toContain("Déposez votre fichier");
92
+ fire("dragleave", [pdf("bail.pdf")]);
93
+ expect(target.textContent).toContain("Glissez votre fichier ici");
94
+ done();
95
+ });
96
+
97
+ it("refuses the wrong format and a file too heavy, and says so", () => {
98
+ const rejected: string[] = [];
99
+ const { target, drop, names, done } = render({
100
+ accept: ".pdf",
101
+ maxFileSize: 100,
102
+ multiple: true,
103
+ onFileRejected: (list: { reason: string }[]) => rejected.push(...list.map((r) => r.reason)),
104
+ });
105
+ const photo = new File(["x"], "photo.png", { type: "image/png" });
106
+ drop([photo, pdf("gros.pdf", 500), pdf("ok.pdf")]);
107
+ expect(names()).toEqual(["ok.pdf"]);
108
+ expect(rejected).toEqual(["type", "size"]);
109
+ expect(target.textContent).toContain("photo.png : format non accepté");
110
+ expect(target.textContent).toContain("gros.pdf : fichier trop lourd");
111
+ done();
112
+ });
113
+
114
+ it("stops at maxFiles", () => {
115
+ const { target, drop, names, done } = render({ multiple: true, maxFiles: 2 });
116
+ drop([pdf("a.pdf"), pdf("b.pdf"), pdf("c.pdf")]);
117
+ expect(names()).toEqual(["a.pdf", "b.pdf"]);
118
+ expect(target.textContent).toContain("2 fichiers au maximum");
119
+ done();
120
+ });
121
+
122
+ it("removes a file from the list and from the input", () => {
123
+ const { target, drop, names, written, done } = render({ multiple: true });
124
+ drop([pdf("a.pdf"), pdf("b.pdf")]);
125
+ target.querySelector<HTMLButtonElement>('[aria-label="Retirer a.pdf"]')?.click();
126
+ flushSync();
127
+ expect(names()).toEqual(["b.pdf"]);
128
+ expect(written()).toEqual(["b.pdf"]);
129
+ done();
130
+ });
131
+
132
+ it("puts the one file in the zone's place, and brings the zone back on removal", () => {
133
+ const { target, drop, done } = render();
134
+ drop([pdf("bail.pdf")]);
135
+ expect(target.querySelector("[data-slot=file-drop-zone-target]")).toBeNull();
136
+ target.querySelector<HTMLButtonElement>('[aria-label="Retirer bail.pdf"]')?.click();
137
+ flushSync();
138
+ expect(target.querySelector("[data-slot=file-drop-zone-target]")).not.toBeNull();
139
+ done();
140
+ });
141
+
142
+ it("ignores drops while disabled", () => {
143
+ const { drop, names, done } = render({ disabled: true });
144
+ drop([pdf("bail.pdf")]);
145
+ expect(names()).toEqual([]);
146
+ done();
147
+ });
148
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import {
3
+ checkFiles,
4
+ describeAccept,
5
+ describeLimits,
6
+ fileKind,
7
+ formatSize,
8
+ matchesAccept,
9
+ removeFile,
10
+ } from "./files.js";
11
+
12
+ const file = (name: string, type: string, size = 10) =>
13
+ new File([new Uint8Array(size)], name, { type, lastModified: 1 });
14
+
15
+ describe("matchesAccept", () => {
16
+ it("takes extensions, exact types and groups, in any case", () => {
17
+ expect(matchesAccept(file("Bail.PDF", ""), ".pdf")).toBe(true);
18
+ expect(matchesAccept(file("a.bin", "application/pdf"), "application/pdf")).toBe(true);
19
+ expect(matchesAccept(file("photo.heic", "image/heic"), "image/*")).toBe(true);
20
+ expect(matchesAccept(file("releve.csv", "text/csv"), ".pdf,image/*")).toBe(false);
21
+ });
22
+
23
+ it("lets everything through without an accept string", () => {
24
+ expect(matchesAccept(file("x.zip", "application/zip"), undefined)).toBe(true);
25
+ expect(matchesAccept(file("x.zip", "application/zip"), " ")).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("checkFiles", () => {
30
+ it("turns away the wrong type before the size", () => {
31
+ const big = file("gros.csv", "text/csv", 5000);
32
+ const result = checkFiles([big], [], { accept: ".pdf", maxFileSize: 100 });
33
+ expect(result.rejected).toEqual([{ file: big, reason: "type" }]);
34
+ });
35
+
36
+ it("turns away a file over the size limit", () => {
37
+ const big = file("gros.pdf", "application/pdf", 5000);
38
+ const small = file("petit.pdf", "application/pdf", 50);
39
+ const result = checkFiles([big, small], [], {
40
+ accept: ".pdf",
41
+ maxFileSize: 100,
42
+ multiple: true,
43
+ });
44
+ expect(result.files).toEqual([small]);
45
+ expect(result.rejected).toEqual([{ file: big, reason: "size" }]);
46
+ });
47
+
48
+ it("stops at maxFiles, counting what's already there", () => {
49
+ const a = file("a.pdf", "application/pdf");
50
+ const b = file("b.pdf", "application/pdf");
51
+ const c = file("c.pdf", "application/pdf");
52
+ const result = checkFiles([b, c], [a], { multiple: true, maxFiles: 2 });
53
+ expect(result.files).toEqual([a, b]);
54
+ expect(result.accepted).toEqual([b]);
55
+ expect(result.rejected).toEqual([{ file: c, reason: "count" }]);
56
+ });
57
+
58
+ it("replaces the one file without multiple, and keeps it when the new one is refused", () => {
59
+ const a = file("a.pdf", "application/pdf");
60
+ const b = file("b.pdf", "application/pdf");
61
+ expect(checkFiles([b], [a], { accept: ".pdf" }).files).toEqual([b]);
62
+ expect(checkFiles([file("x.png", "image/png")], [a], { accept: ".pdf" }).files).toEqual([a]);
63
+ });
64
+
65
+ it("ignores the same file picked twice", () => {
66
+ const a = file("a.pdf", "application/pdf");
67
+ const again = file("a.pdf", "application/pdf");
68
+ const result = checkFiles([again], [a], { multiple: true });
69
+ expect(result.files).toEqual([a]);
70
+ expect(result.rejected).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("removeFile", () => {
75
+ it("drops that one file and keeps the order", () => {
76
+ const a = file("a.pdf", "application/pdf");
77
+ const b = file("b.pdf", "application/pdf");
78
+ const c = file("c.pdf", "application/pdf");
79
+ expect(removeFile([a, b, c], b)).toEqual([a, c]);
80
+ });
81
+ });
82
+
83
+ describe("words", () => {
84
+ it("writes sizes the French way", () => {
85
+ expect(formatSize(820)).toBe("820 o");
86
+ expect(formatSize(12 * 1024)).toBe("12 Ko");
87
+ expect(formatSize(1.4 * 1024 * 1024)).toBe("1,4 Mo");
88
+ expect(formatSize(10 * 1024 * 1024)).toBe("10 Mo");
89
+ });
90
+
91
+ it("names the accepted types once each", () => {
92
+ expect(describeAccept(".pdf,application/pdf")).toBe("PDF");
93
+ expect(describeAccept("image/*,.csv")).toBe("Images, CSV");
94
+ expect(describeLimits(".pdf", 10 * 1024 * 1024)).toBe("PDF, 10 Mo maximum");
95
+ expect(describeLimits(undefined, undefined)).toBe("");
96
+ });
97
+
98
+ it("picks an icon kind from the type or the name", () => {
99
+ expect(fileKind(file("a.pdf", ""))).toBe("pdf");
100
+ expect(fileKind(file("a.jpg", "image/jpeg"))).toBe("image");
101
+ expect(fileKind(file("a.csv", ""))).toBe("csv");
102
+ expect(fileKind(file("a.xlsx", ""))).toBe("sheet");
103
+ expect(fileKind(file("a.txt", "text/plain"))).toBe("other");
104
+ });
105
+ });
@@ -0,0 +1,144 @@
1
+ /** Why a file didn't make it into the list. */
2
+ export type RejectReason = "type" | "size" | "count";
3
+
4
+ export interface Rejection {
5
+ file: File;
6
+ reason: RejectReason;
7
+ }
8
+
9
+ export interface CheckOptions {
10
+ /** The input's `accept` string: extensions (`.pdf`), MIME types (`application/pdf`) or groups (`image/*`). */
11
+ accept?: string;
12
+ /** Largest file, in bytes. */
13
+ maxFileSize?: number;
14
+ /** Most files the list holds. */
15
+ maxFiles?: number;
16
+ /** Without it, a new file replaces the one already there. */
17
+ multiple?: boolean;
18
+ }
19
+
20
+ export type FileKind = "pdf" | "image" | "csv" | "sheet" | "other";
21
+
22
+ const tokens = (accept: string | undefined) =>
23
+ (accept ?? "")
24
+ .split(",")
25
+ .map((token) => token.trim().toLowerCase())
26
+ .filter(Boolean);
27
+
28
+ /** Whether a file passes the `accept` string, the way the browser's picker reads it. */
29
+ export function matchesAccept(file: File, accept: string | undefined): boolean {
30
+ const list = tokens(accept);
31
+ if (list.length === 0) return true;
32
+ const name = file.name.toLowerCase();
33
+ const type = file.type.toLowerCase();
34
+ return list.some((token) => {
35
+ if (token.startsWith(".")) return name.endsWith(token);
36
+ if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
37
+ return type === token;
38
+ });
39
+ }
40
+
41
+ // The same file picked twice is one file: name, size and date match.
42
+ const same = (a: File, b: File) =>
43
+ a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
44
+
45
+ /**
46
+ * Sorts a drop, paste or pick into the next list and the refusals.
47
+ * A file is refused for its first fault: type, then size, then room.
48
+ */
49
+ export function checkFiles(
50
+ incoming: readonly File[],
51
+ current: readonly File[],
52
+ { accept, maxFileSize, maxFiles, multiple = false }: CheckOptions,
53
+ ): { files: File[]; accepted: File[]; rejected: Rejection[] } {
54
+ const room = multiple ? (maxFiles ?? Infinity) : 1;
55
+ const kept = multiple ? [...current] : [];
56
+ const accepted: File[] = [];
57
+ const rejected: Rejection[] = [];
58
+ for (const file of incoming) {
59
+ if (!matchesAccept(file, accept)) rejected.push({ file, reason: "type" });
60
+ else if (maxFileSize !== undefined && file.size > maxFileSize)
61
+ rejected.push({ file, reason: "size" });
62
+ else if (kept.some((other) => same(other, file))) continue;
63
+ else if (kept.length >= room) rejected.push({ file, reason: "count" });
64
+ else {
65
+ kept.push(file);
66
+ accepted.push(file);
67
+ }
68
+ }
69
+ return { files: accepted.length > 0 || multiple ? kept : [...current], accepted, rejected };
70
+ }
71
+
72
+ /** Drops one file from the list. */
73
+ export const removeFile = (files: readonly File[], file: File): File[] =>
74
+ files.filter((other) => other !== file);
75
+
76
+ const UNITS = ["o", "Ko", "Mo", "Go"] as const;
77
+
78
+ /** A byte count the way a French reader says it: "820 o", "12 Ko", "1,4 Mo". */
79
+ export function formatSize(bytes: number, locale = "fr-FR"): string {
80
+ let value = bytes;
81
+ let unit = 0;
82
+ while (value >= 1024 && unit < UNITS.length - 1) {
83
+ value /= 1024;
84
+ unit += 1;
85
+ }
86
+ const digits = unit === 0 || value >= 10 ? 0 : 1;
87
+ const number = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
88
+ return `${number} ${UNITS[unit]}`;
89
+ }
90
+
91
+ const GROUPS: Record<string, string> = {
92
+ "image/": "Images",
93
+ "audio/": "Audio",
94
+ "video/": "Vidéos",
95
+ "text/": "Texte",
96
+ };
97
+
98
+ const MIME_NAMES: Record<string, string> = {
99
+ "application/pdf": "PDF",
100
+ "text/csv": "CSV",
101
+ "application/vnd.ms-excel": "XLS",
102
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
103
+ "application/msword": "DOC",
104
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
105
+ "application/zip": "ZIP",
106
+ "application/json": "JSON",
107
+ };
108
+
109
+ /** The `accept` string as a reader names it: ".pdf,application/pdf" is "PDF", "image/*" is "Images". */
110
+ export function describeAccept(accept: string | undefined): string {
111
+ const names: string[] = [];
112
+ for (const token of tokens(accept)) {
113
+ const name = token.startsWith(".")
114
+ ? token.slice(1).toUpperCase()
115
+ : token.endsWith("/*")
116
+ ? (GROUPS[token.slice(0, -1)] ?? token)
117
+ : (MIME_NAMES[token] ?? token.split("/").pop()?.toUpperCase() ?? token);
118
+ if (name === "JPEG" && names.includes("JPG")) continue;
119
+ if (!names.includes(name)) names.push(name);
120
+ }
121
+ return names.join(", ");
122
+ }
123
+
124
+ /** The muted line under the title: "PDF, 10 Mo maximum". Empty when nothing limits the pick. */
125
+ export function describeLimits(
126
+ accept: string | undefined,
127
+ maxFileSize: number | undefined,
128
+ ): string {
129
+ const types = describeAccept(accept);
130
+ const size = maxFileSize === undefined ? "" : `${formatSize(maxFileSize)} maximum`;
131
+ return [types, size].filter(Boolean).join(", ");
132
+ }
133
+
134
+ /** Which icon a file gets. */
135
+ export function fileKind(file: File): FileKind {
136
+ const name = file.name.toLowerCase();
137
+ const type = file.type.toLowerCase();
138
+ if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
139
+ if (type.startsWith("image/")) return "image";
140
+ if (type === "text/csv" || name.endsWith(".csv")) return "csv";
141
+ if (/\.(xlsx?|ods)$/.test(name) || type.includes("spreadsheet") || type.includes("excel"))
142
+ return "sheet";
143
+ return "other";
144
+ }
@@ -0,0 +1,14 @@
1
+ export { default as FileDropZone } from "./file-drop-zone.svelte";
2
+ export {
3
+ checkFiles,
4
+ describeAccept,
5
+ describeLimits,
6
+ fileKind,
7
+ formatSize,
8
+ matchesAccept,
9
+ removeFile,
10
+ type CheckOptions,
11
+ type FileKind,
12
+ type RejectReason,
13
+ type Rejection,
14
+ } from "./files.js";
@@ -0,0 +1,126 @@
1
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ rmSync,
11
+ symlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join, relative } from "node:path";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import { afterAll, describe, expect, it } from "vite-plus/test";
19
+ import { writeSvelteModules } from "../scripts/lib/svelte-modules.mjs";
20
+
21
+ // A consumer's dev server prebundles this package, types still in each `.svelte.ts`. 3.6.0's
22
+ // reveal.svelte.ts killed Locameublé's dev server at startup on `import type`.
23
+
24
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
25
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
26
+ scripts: Record<string, string>;
27
+ exports: Record<string, string | string[] | Record<string, string | string[]>>;
28
+ };
29
+
30
+ /** The Vite core a consumer gets beside vite-plugin-svelte; this package only depends on vite-plus. */
31
+ type Vite = {
32
+ resolveConfig(config: object, command: "serve", mode: string): Promise<unknown>;
33
+ optimizeDeps(
34
+ config: unknown,
35
+ force: boolean,
36
+ ): Promise<{ optimized: Record<string, { file: string }> }>;
37
+ };
38
+ const plugin = createRequire(import.meta.url).resolve("@sveltejs/vite-plugin-svelte");
39
+ const vite: Vite = await import(pathToFileURL(createRequire(plugin).resolve("vite")).href);
40
+
41
+ function walk(dir: string, out: string[] = []): string[] {
42
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
43
+ const p = join(dir, e.name);
44
+ if (e.isDirectory()) walk(p, out);
45
+ else out.push(p);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** The tarball's shape: `files` as prepack leaves them, under a consumer's node_modules. */
51
+ async function consumer(): Promise<{ dir: string; ui: string }> {
52
+ const dir = mkdtempSync(join(tmpdir(), "ui-consumer-"));
53
+ const nm = join(dir, "node_modules");
54
+ const ui = join(nm, "@heyramzi/ui");
55
+ mkdirSync(ui, { recursive: true });
56
+ cpSync(join(root, "src"), join(ui, "src"), { recursive: true });
57
+ cpSync(join(root, "package.json"), join(ui, "package.json"));
58
+ await writeSvelteModules(join(ui, "src"));
59
+ for (const name of readdirSync(join(root, "node_modules"))) {
60
+ const from = join(root, "node_modules", name);
61
+ const scoped = name.startsWith("@") ? readdirSync(from).map((s) => join(name, s)) : [name];
62
+ if (name.startsWith("@")) mkdirSync(join(nm, name));
63
+ for (const dep of scoped)
64
+ symlinkSync(realpathSync(join(root, "node_modules", dep)), join(nm, dep));
65
+ }
66
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "consumer", type: "module" }));
67
+ return { dir, ui };
68
+ }
69
+
70
+ /** Every subpath a Svelte app can import, wildcards expanded against the tarball's files. */
71
+ function browserEntries(ui: string): string[] {
72
+ const out = new Set<string>();
73
+ for (const [key, value] of Object.entries(pkg.exports)) {
74
+ if (typeof value !== "object" || Array.isArray(value)) continue;
75
+ const target = value.svelte;
76
+ if (typeof target !== "string") continue;
77
+ if (!key.includes("*")) {
78
+ out.add(`@heyramzi/ui${key.slice(1)}`);
79
+ continue;
80
+ }
81
+ const [head, tail] = target.split("*");
82
+ for (const file of walk(join(ui, head))) {
83
+ const rel = relative(join(ui, head), file);
84
+ if (!rel.endsWith(tail) || /\.(test|spec)\./.test(rel)) continue;
85
+ out.add(`@heyramzi/ui${key.slice(1).replace("*", rel.slice(0, -tail.length))}`);
86
+ }
87
+ }
88
+ return [...out].toSorted();
89
+ }
90
+
91
+ const made: string[] = [];
92
+ afterAll(() => {
93
+ for (const dir of made) rmSync(dir, { recursive: true, force: true });
94
+ });
95
+
96
+ describe("a consumer's dev server", () => {
97
+ it("writes the type-free modules on every pack and removes them after", () => {
98
+ expect(pkg.scripts.prepack).toContain("node scripts/svelte-modules.mjs");
99
+ expect(pkg.scripts.postpack).toContain("node scripts/svelte-modules.mjs --clean");
100
+ });
101
+
102
+ it("prebundles every Svelte subpath, chat windows included", async () => {
103
+ const { dir, ui } = await consumer();
104
+ made.push(dir);
105
+ const include = browserEntries(ui);
106
+ expect(include).toContain("@heyramzi/ui/ai/chat-window");
107
+ expect(include).toContain("@heyramzi/ui/shadcn/hooks/use-highlighted-code.svelte");
108
+ expect(include).toContain("@heyramzi/ui/canvas/editor/dispatch.svelte.js");
109
+
110
+ const config = await vite.resolveConfig(
111
+ {
112
+ root: dir,
113
+ configFile: false,
114
+ logLevel: "error",
115
+ plugins: [svelte({ configFile: false })],
116
+ optimizeDeps: { include, noDiscovery: true },
117
+ },
118
+ "serve",
119
+ "development",
120
+ );
121
+ const { optimized } = await vite.optimizeDeps(config, true);
122
+ const chat = optimized["@heyramzi/ui/ai/chat-window"];
123
+ expect(chat && existsSync(chat.file)).toBe(true);
124
+ expect(readFileSync(chat.file, "utf8")).toContain("REVEAL_BEATS");
125
+ }, 120_000);
126
+ });
@@ -0,0 +1,54 @@
1
+ function anchorX(rect, containerLeft, anchor, offset) {
2
+ const left = rect.left - containerLeft;
3
+ if (anchor === "left") return left + offset;
4
+ if (anchor === "right") return left + rect.width + offset;
5
+ return left + rect.width / 2 + offset;
6
+ }
7
+ export function usePathCalculator() {
8
+ let pathD = $state("");
9
+ let svgDimensions = $state({
10
+ width: 0,
11
+ height: 0
12
+ });
13
+ function calculatePath(containerRef, fromRef, toRef, curvature, startXOffset, startYOffset, endXOffset, endYOffset, fromAnchor = "center", toAnchor = "center") {
14
+ if (!containerRef || !fromRef || !toRef) return;
15
+ const containerRect = containerRef.getBoundingClientRect();
16
+ const rectA = fromRef.getBoundingClientRect();
17
+ const rectB = toRef.getBoundingClientRect();
18
+ // A scaled ancestor shrinks every rect, but the SVG draws in the container's own pixels.
19
+ // Divide the scale out, or the beams stop short of their nodes.
20
+ const scaleX = containerRef.offsetWidth ? containerRect.width / containerRef.offsetWidth : 1;
21
+ const scaleY = containerRef.offsetHeight ? containerRect.height / containerRef.offsetHeight : 1;
22
+ const local = (rect) => new DOMRect((rect.left - containerRect.left) / scaleX, (rect.top - containerRect.top) / scaleY, rect.width / scaleX, rect.height / scaleY);
23
+ const a = local(rectA);
24
+ const b = local(rectB);
25
+ svgDimensions = {
26
+ width: containerRect.width / scaleX,
27
+ height: containerRect.height / scaleY
28
+ };
29
+ const startX = anchorX(a, 0, fromAnchor, startXOffset);
30
+ const startY = a.top + a.height / 2 + startYOffset;
31
+ const endX = anchorX(b, 0, toAnchor, endXOffset);
32
+ const endY = b.top + b.height / 2 + endYOffset;
33
+ // A cubic with horizontal handles, not a quadratic through a raised midpoint.
34
+ // The quadratic put its single control point at `midY` minus `curvature`, which for a fan into one node lands past the far end:
35
+ // the top beam dove below the hub and crossed the middle beam before arriving, and three beams bundled into a lens for the last stretch.
36
+ // Handles along the axis of travel make each beam leave and arrive perpendicular to its anchor, so a fan converges at the anchor and nowhere else.
37
+ // `curvature` still bows the middle, and zero still draws a straight line between two anchors at the same height.
38
+ const span = Math.abs(endX - startX);
39
+ const handle = Math.max(span * .5, 24);
40
+ const direction = endX >= startX ? 1 : -1;
41
+ const c1x = startX + handle * direction;
42
+ const c2x = endX - handle * direction;
43
+ pathD = `M ${startX},${startY} C ${c1x},${startY - curvature} ${c2x},${endY - curvature} ${endX},${endY}`;
44
+ }
45
+ return {
46
+ get pathD() {
47
+ return pathD;
48
+ },
49
+ get svgDimensions() {
50
+ return svgDimensions;
51
+ },
52
+ calculatePath
53
+ };
54
+ }
@@ -0,0 +1,19 @@
1
+ class Onboarding {
2
+ /** True when the current page has a tour, so a menu item can hide itself. */
3
+ hasTour = $state(false);
4
+ /** True while a tour is on screen. */
5
+ running = $state(false);
6
+ #replay = null;
7
+ /** The provider registers itself here. Returns the undo for cleanup. */
8
+ bind(replay) {
9
+ this.#replay = replay;
10
+ return () => {
11
+ if (this.#replay === replay) this.#replay = null;
12
+ };
13
+ }
14
+ /** Restarts this page's tour, or the one named. Writes no seen-state. False when nothing started. */
15
+ replay(tourId) {
16
+ return this.#replay ? this.#replay(tourId) : Promise.resolve(false);
17
+ }
18
+ }
19
+ export const onboarding = new Onboarding();
@@ -26,7 +26,7 @@
26
26
  value={monthItem.value}
27
27
  selected={value !== undefined
28
28
  ? monthItem.value === value
29
- : monthItem.value === selectedMonthItem.value}
29
+ : monthItem.value === selectedMonthItem?.value}
30
30
  >
31
31
  {monthItem.label}
32
32
  </option>
@@ -36,7 +36,7 @@
36
36
  class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
37
37
  aria-hidden="true"
38
38
  >
39
- {monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
39
+ {monthItems.find((item) => item.value === value)?.label || selectedMonthItem?.label}
40
40
  <CaretDown aria-hidden="true" class="size-4" />
41
41
  </span>
42
42
  {/snippet}
@@ -25,7 +25,7 @@
25
25
  value={yearItem.value}
26
26
  selected={value !== undefined
27
27
  ? yearItem.value === value
28
- : yearItem.value === selectedYearItem.value}
28
+ : yearItem.value === selectedYearItem?.value}
29
29
  >
30
30
  {yearItem.label}
31
31
  </option>
@@ -35,7 +35,7 @@
35
35
  class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
36
36
  aria-hidden="true"
37
37
  >
38
- {yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
38
+ {yearItems.find((item) => item.value === value)?.label || selectedYearItem?.label || value}
39
39
  <CaretDown aria-hidden="true" class="size-4" />
40
40
  </span>
41
41
  {/snippet}