@wildwinter/app-shell 0.54.0 → 0.56.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.
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ var ws = require('ws');
4
+
5
+ // src/live-link.ts
6
+
7
+ // src/util.ts
8
+ function isLocalOrigin(origin) {
9
+ if (origin === void 0 || origin === "") return true;
10
+ let host;
11
+ try {
12
+ host = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(origin) ? origin : `http://${origin}`).hostname;
13
+ } catch {
14
+ return false;
15
+ }
16
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
17
+ }
18
+
19
+ // src/live-link.ts
20
+ function createLiveLinkServer(opts) {
21
+ const { port } = opts;
22
+ let wss = null;
23
+ let client = null;
24
+ let connected = null;
25
+ let clientBuild = null;
26
+ const status = () => {
27
+ if (!wss) return { state: "off" };
28
+ if (!client || !connected) return { state: "listening", port };
29
+ return {
30
+ ...protocol.describe(),
31
+ state: "connected",
32
+ port,
33
+ build: connected.build,
34
+ ...connected.project !== void 0 ? { project: connected.project } : {}
35
+ };
36
+ };
37
+ const push = () => opts.onStatus(status());
38
+ const pushStatus = () => {
39
+ if (connected) push();
40
+ };
41
+ const protocol = opts.protocol({ pushStatus });
42
+ const forget = () => {
43
+ connected = null;
44
+ clientBuild = null;
45
+ protocol.reset();
46
+ };
47
+ const handleMessage = (raw) => {
48
+ let msg;
49
+ try {
50
+ msg = JSON.parse(raw);
51
+ } catch {
52
+ return;
53
+ }
54
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) return;
55
+ const m = msg;
56
+ if (m["t"] !== "hello") {
57
+ if (connected) protocol.message(m);
58
+ return;
59
+ }
60
+ clientBuild = typeof m["build"] === "string" ? m["build"] : null;
61
+ const current = opts.currentBuildHash();
62
+ const build = current === null ? "unknown" : clientBuild === current ? "match" : "stale";
63
+ const project = typeof m["project"] === "string" ? m["project"] : void 0;
64
+ connected = { build, ...project !== void 0 ? { project } : {} };
65
+ protocol.hello({ build, ...project !== void 0 ? { project } : {}, msg: m });
66
+ push();
67
+ };
68
+ return {
69
+ isOn: () => wss !== null,
70
+ status,
71
+ pushStatus,
72
+ pushBundle(build, data) {
73
+ if (!client || !connected) return;
74
+ if (clientBuild === build) return;
75
+ try {
76
+ client.send(JSON.stringify({ t: "bundle", v: 1, build, data }));
77
+ } catch {
78
+ }
79
+ if (connected.build !== "stale") {
80
+ connected = { ...connected, build: "stale" };
81
+ push();
82
+ }
83
+ },
84
+ start() {
85
+ if (wss) return;
86
+ try {
87
+ wss = new ws.WebSocketServer({
88
+ host: "127.0.0.1",
89
+ port,
90
+ verifyClient: (info) => isLocalOrigin(info.origin)
91
+ }, () => push());
92
+ } catch (e) {
93
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
94
+ wss = null;
95
+ return;
96
+ }
97
+ wss.on("error", (e) => {
98
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
99
+ if (!client) {
100
+ const s = wss;
101
+ wss = null;
102
+ try {
103
+ s?.close();
104
+ } catch {
105
+ }
106
+ }
107
+ });
108
+ wss.on("connection", (ws) => {
109
+ if (client) {
110
+ try {
111
+ client.close();
112
+ } catch {
113
+ }
114
+ }
115
+ client = ws;
116
+ forget();
117
+ ws.on("message", (data) => handleMessage(data.toString()));
118
+ ws.on("close", () => {
119
+ if (client === ws) {
120
+ client = null;
121
+ forget();
122
+ push();
123
+ }
124
+ });
125
+ ws.on("error", () => {
126
+ });
127
+ push();
128
+ });
129
+ },
130
+ stop() {
131
+ try {
132
+ client?.close();
133
+ } catch {
134
+ }
135
+ client = null;
136
+ forget();
137
+ const s = wss;
138
+ wss = null;
139
+ try {
140
+ s?.close();
141
+ } catch {
142
+ }
143
+ push();
144
+ }
145
+ };
146
+ }
147
+
148
+ exports.createLiveLinkServer = createLiveLinkServer;
149
+ //# sourceMappingURL=live-link.cjs.map
150
+ //# sourceMappingURL=live-link.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/util.ts","../src/live-link.ts"],"names":["WebSocketServer"],"mappings":"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","file":"live-link.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The small idioms both apps wrote by hand and never gave a name: the plural\n// suffix (about forty sites), the debounce (eight copies), \"is the focus in a\n// field\" (four copies that disagreed about <select>), the count with grouping\n// separators, clipboard copy with the `.copied` revert (three timings), and\n// relative time (two wordings), and which connection to a live link is this\n// machine's own (isLocalOrigin, two copies). One answer each, here.\n//\n// DOM-free except where the idiom is about the DOM (isEditableTarget,\n// copyWithFeedback), so main-process code can import the rest.\n// ---------------------------------------------------------------------------\n\n/** \"1 link\" / \"3 links\". The plural form defaults to `noun + \"s\"`; pass the\n * irregular one (\"entry\", \"entries\") when that is wrong. */\nexport function plural(n: number, noun: string, pluralForm = `${noun}s`): string {\n return `${n} ${n === 1 ? noun : pluralForm}`;\n}\n\nexport interface Debounced<A extends unknown[]> {\n (...args: A): void;\n /** Drop the pending call. */\n cancel(): void;\n /** Run the pending call now, if there is one. */\n flush(): void;\n}\n\n/** Trailing-edge debounce: the call runs `ms` after the LAST call. */\nexport function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number): Debounced<A> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let pending: A | undefined;\n const run = (): void => {\n timer = undefined;\n if (pending === undefined) return;\n const args = pending;\n pending = undefined;\n fn(...args);\n };\n const debounced = ((...args: A): void => {\n pending = args;\n if (timer !== undefined) clearTimeout(timer);\n timer = setTimeout(run, ms);\n }) as Debounced<A>;\n debounced.cancel = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; pending = undefined; };\n debounced.flush = () => { if (timer !== undefined) clearTimeout(timer); run(); };\n return debounced;\n}\n\n/**\n * Is the keyboard's focus in a field? The one agreed answer: an input, a\n * textarea, a select, or anything contenteditable (the attribute is read as\n * well as `isContentEditable`, which jsdom does not implement). A window-level\n * shortcut checks this before acting, so a bare letter typed into a field\n * stays a letter.\n */\nexport function isEditableTarget(t: EventTarget | null | undefined): boolean {\n if (!(t instanceof Element)) return false;\n if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t instanceof HTMLSelectElement) return true;\n if (t instanceof HTMLElement && t.isContentEditable === true) return true;\n const attr = t.getAttribute(\"contenteditable\");\n return attr !== null && attr !== \"false\";\n}\n\n/** A count for reading: grouping separators in the user's locale. */\nexport function formatCount(n: number): string {\n return n.toLocaleString();\n}\n\nconst copyTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>();\n\n/**\n * Copy `text` to the clipboard and light `el` with `.copied` for `ms` (the\n * family's 1000). A second click restarts the timer rather than stacking a\n * second revert under the first. Resolves when the clipboard write has.\n */\nexport function copyWithFeedback(el: Element, text: string, ms = 1000): Promise<void> {\n const write = typeof navigator !== \"undefined\" && navigator.clipboard\n ? navigator.clipboard.writeText(text).catch(() => {})\n : Promise.resolve();\n el.classList.add(\"copied\");\n const prior = copyTimers.get(el);\n if (prior !== undefined) clearTimeout(prior);\n copyTimers.set(el, setTimeout(() => { el.classList.remove(\"copied\"); copyTimers.delete(el); }, ms));\n return write;\n}\n\n/**\n * How long ago, in one wording: \"just now\", \"4 minutes ago\", \"an hour ago\",\n * \"3 hours ago\", \"yesterday\", \"5 days ago\", and past a week the date itself.\n * Coarse on purpose: the question a reader has is whether the thing is from\n * this sitting, not how many seconds ago it was. A future date is \"just now\".\n */\nexport function relativeTime(date: Date | number | string, now: number = Date.now()): string {\n const then = date instanceof Date ? date.getTime() : new Date(date).getTime();\n if (!Number.isFinite(then)) return \"\";\n const mins = Math.floor((now - then) / 60000);\n if (mins < 1) return \"just now\";\n if (mins < 60) return `${plural(mins, \"minute\")} ago`;\n const hours = Math.floor(mins / 60);\n if (hours === 1) return \"an hour ago\";\n if (hours < 24) return `${hours} hours ago`;\n const days = Math.floor(hours / 24);\n if (days === 1) return \"yesterday\";\n if (days < 7) return `${days} days ago`;\n return new Date(then).toLocaleDateString(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" });\n}\n\n/**\n * Whether a WebSocket upgrade's Origin is this machine, so a game may take a\n * live link's slot (Patterpad's Live Link and Storyletter's, which listen on\n * loopback). A web page in any browser can open a socket to localhost, and the\n * browser stamps the upgrade with the page's Origin, so the Origin is the check:\n *\n * - no Origin: Unity's ClientWebSocket, Godot and Node send none;\n * - a loopback host, literally `localhost`, `127.0.0.1` or `[::1]`, with or\n * without a scheme and port: Unreal's libwebsockets sends the bare address\n * (`127.0.0.1`), and a browser game on a local dev server sends\n * `http://localhost:5173`;\n * - anything else is refused: another site, `null` (a file:// page sends it,\n * but so does a sandboxed frame on any site), and a name that only resolves\n * to loopback (`127.0.0.1.nip.io`), since the Origin names the domain, not\n * the address.\n */\nexport function isLocalOrigin(origin: string | undefined): boolean {\n if (origin === undefined || origin === \"\") return true;\n let host: string;\n try {\n // A browser sends scheme://host[:port]; Unreal sends a bare host, which URL would misread as a scheme.\n host = new URL(/^[a-z][a-z0-9+.-]*:\\/\\//i.test(origin) ? origin : `http://${origin}`).hostname;\n } catch { return false; }\n return host === \"localhost\" || host === \"127.0.0.1\" || host === \"[::1]\";\n}\n","// ---------------------------------------------------------------------------\n// The live link server: the loopback WebSocket server, in the main process,\n// that a running game connects to. Patterpad's debug-link.ts and Storyletter's\n// live-link.ts were this file twice (the family parity review of 2026-10, item\n// 14), so the parts that were the same are here once and each app plugs in its\n// own protocol:\n//\n// - loopback only, and refused to any Origin that isn't this machine\n// (isLocalOrigin), so a web page can't take the slot;\n// - one game at a time: a new connection replaces the old;\n// - nothing is honoured before a hello, which names the build the game runs,\n// judged against the project's (match / stale / unknown);\n// - the status off -> listening -> connected, or error, pushed to the app;\n// - pushBundle, gated on the build the game reported, and the build shown as\n// stale until the game re-hellos with the one it was sent;\n// - a bind failure (the port is taken) leaves the server off, not dead \"on\".\n//\n// What the game sends after its hello (Patterpad's frames, Storyletter's\n// boards and traces), and what the app adds to the connected status, is the\n// protocol's. Main-process code: `ws` is an optional peer the app supplies.\n// The chip that shows the status is link-status.ts.\n// ---------------------------------------------------------------------------\n\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { isLocalOrigin } from \"./util.js\";\n\n/** How the game's build compares with the project's. */\nexport type LiveLinkBuild = \"match\" | \"stale\" | \"unknown\";\n\n/** One parsed message from the game: a JSON object with its `t`. */\nexport type LiveLinkMessage = Record<string, unknown>;\n\n/** The server's status, with the protocol's own fields on `connected`. */\nexport type LiveLinkServerStatus<X extends object = object> =\n | { state: \"off\" }\n | { state: \"error\"; message: string }\n | { state: \"listening\"; port: number }\n | ({ state: \"connected\"; port: number; project?: string; build: LiveLinkBuild } & X);\n\n/** A hello, as the server read it. */\nexport interface LiveLinkHello {\n /** The game's build judged against the project's. */\n build: LiveLinkBuild;\n /** What the game said it is, if it did. */\n project?: string;\n /** The whole message, for the protocol's own fields (flows, boxes). */\n msg: LiveLinkMessage;\n}\n\n/** An app's half of the link. */\nexport interface LiveLinkProtocol<X extends object = object> {\n /** A hello landed: a run begins, or a pushed bundle took. The status is\n * pushed after it returns. */\n hello(hello: LiveLinkHello): void;\n /** Any other message, once the game has said hello. Call `pushStatus` when\n * it changes what the status shows. */\n message(msg: LiveLinkMessage): void;\n /** The game went away, was replaced, or the server stopped: forget the run. */\n reset(): void;\n /** The protocol's fields on a connected status. */\n describe(): X;\n}\n\nexport interface LiveLinkServerOptions<X extends object = object> {\n /** Each app has its own, so both editors can listen on one machine. */\n port: number;\n /** The project's current compiled hash, read at each hello; null with no\n * project open, which makes the build \"unknown\". */\n currentBuildHash: () => string | null;\n /** Make the protocol. `pushStatus` sends the status again while a game is\n * connected (before that, nothing the protocol adds is shown). */\n protocol: (link: { pushStatus(): void }) => LiveLinkProtocol<X>;\n /** The latest status, for the app's windows and menu. */\n onStatus: (status: LiveLinkServerStatus<X>) => void;\n}\n\nexport interface LiveLinkServer<X extends object = object> {\n start(): void;\n stop(): void;\n isOn(): boolean;\n status(): LiveLinkServerStatus<X>;\n /** Send the status again while a game is connected: something the protocol\n * shows changed outside a message (the app followed another flow). */\n pushStatus(): void;\n /** Push a freshly compiled bundle to the connected game. Nothing happens when\n * no game has said hello, or when it already runs this build. The game\n * applies it and re-hellos with the new build; until then it shows stale. */\n pushBundle(build: string, data: string): void;\n}\n\nexport function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X> {\n const { port } = opts;\n let wss: WebSocketServer | null = null;\n let client: WebSocket | null = null;\n // Set by a hello: nothing else is honoured until it is.\n let connected: { project?: string; build: LiveLinkBuild } | null = null;\n let clientBuild: string | null = null; // the build the game reported: gates pushBundle\n\n const status = (): LiveLinkServerStatus<X> => {\n if (!wss) return { state: \"off\" };\n if (!client || !connected) return { state: \"listening\", port };\n return {\n ...protocol.describe(),\n state: \"connected\", port, build: connected.build,\n ...(connected.project !== undefined ? { project: connected.project } : {}),\n };\n };\n const push = (): void => opts.onStatus(status());\n const pushStatus = (): void => { if (connected) push(); };\n const protocol = opts.protocol({ pushStatus });\n\n const forget = (): void => { connected = null; clientBuild = null; protocol.reset(); };\n\n const handleMessage = (raw: string): void => {\n let msg: unknown;\n try { msg = JSON.parse(raw); } catch { return; }\n if (msg === null || typeof msg !== \"object\" || Array.isArray(msg)) return;\n const m = msg as LiveLinkMessage;\n if (m[\"t\"] !== \"hello\") {\n if (connected) protocol.message(m);\n return;\n }\n clientBuild = typeof m[\"build\"] === \"string\" ? m[\"build\"] : null;\n const current = opts.currentBuildHash();\n const build: LiveLinkBuild = current === null ? \"unknown\" : clientBuild === current ? \"match\" : \"stale\";\n const project = typeof m[\"project\"] === \"string\" ? m[\"project\"] : undefined;\n connected = { build, ...(project !== undefined ? { project } : {}) };\n protocol.hello({ build, ...(project !== undefined ? { project } : {}), msg: m });\n push();\n };\n\n return {\n isOn: () => wss !== null,\n status,\n pushStatus,\n pushBundle(build: string, data: string): void {\n if (!client || !connected) return; // nothing connected, or no hello yet\n if (clientBuild === build) return; // the game already runs this build\n try { client.send(JSON.stringify({ t: \"bundle\", v: 1, build, data })); } catch { /* socket went away */ }\n // Until the game re-hellos with the new build it is on the old one, and\n // the chip says so rather than keep the match it had at the hello.\n if (connected.build !== \"stale\") { connected = { ...connected, build: \"stale\" }; push(); }\n },\n start(): void {\n if (wss) return;\n try {\n // Loopback only: only processes on this machine can reach it. And not\n // a web page on it: any site can open a WebSocket to localhost, and the\n // browser stamps the upgrade with the page's Origin.\n wss = new WebSocketServer({\n host: \"127.0.0.1\", port,\n verifyClient: (info: { origin?: string }) => isLocalOrigin(info.origin),\n }, () => push()); // \"listening\" once actually bound\n } catch (e) {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n wss = null;\n return;\n }\n wss.on(\"error\", (e) => {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n // A bind failure leaves no server behind: the next click starts again\n // rather than finding a dead one \"on\".\n if (!client) { const s = wss; wss = null; try { s?.close(); } catch { /* already closing */ } }\n });\n wss.on(\"connection\", (ws) => {\n // One game at a time: a new connection replaces the old.\n if (client) { try { client.close(); } catch { /* gone */ } }\n client = ws; forget();\n ws.on(\"message\", (data) => handleMessage(data.toString()));\n ws.on(\"close\", () => { if (client === ws) { client = null; forget(); push(); } });\n ws.on(\"error\", () => { /* a flaky client must not take the editor down */ });\n push();\n });\n },\n stop(): void {\n try { client?.close(); } catch { /* gone */ }\n client = null; forget();\n const s = wss; wss = null;\n try { s?.close(); } catch { /* already closing */ }\n push();\n },\n };\n}\n"]}
@@ -0,0 +1,71 @@
1
+ /** How the game's build compares with the project's. */
2
+ type LiveLinkBuild = "match" | "stale" | "unknown";
3
+ /** One parsed message from the game: a JSON object with its `t`. */
4
+ type LiveLinkMessage = Record<string, unknown>;
5
+ /** The server's status, with the protocol's own fields on `connected`. */
6
+ type LiveLinkServerStatus<X extends object = object> = {
7
+ state: "off";
8
+ } | {
9
+ state: "error";
10
+ message: string;
11
+ } | {
12
+ state: "listening";
13
+ port: number;
14
+ } | ({
15
+ state: "connected";
16
+ port: number;
17
+ project?: string;
18
+ build: LiveLinkBuild;
19
+ } & X);
20
+ /** A hello, as the server read it. */
21
+ interface LiveLinkHello {
22
+ /** The game's build judged against the project's. */
23
+ build: LiveLinkBuild;
24
+ /** What the game said it is, if it did. */
25
+ project?: string;
26
+ /** The whole message, for the protocol's own fields (flows, boxes). */
27
+ msg: LiveLinkMessage;
28
+ }
29
+ /** An app's half of the link. */
30
+ interface LiveLinkProtocol<X extends object = object> {
31
+ /** A hello landed: a run begins, or a pushed bundle took. The status is
32
+ * pushed after it returns. */
33
+ hello(hello: LiveLinkHello): void;
34
+ /** Any other message, once the game has said hello. Call `pushStatus` when
35
+ * it changes what the status shows. */
36
+ message(msg: LiveLinkMessage): void;
37
+ /** The game went away, was replaced, or the server stopped: forget the run. */
38
+ reset(): void;
39
+ /** The protocol's fields on a connected status. */
40
+ describe(): X;
41
+ }
42
+ interface LiveLinkServerOptions<X extends object = object> {
43
+ /** Each app has its own, so both editors can listen on one machine. */
44
+ port: number;
45
+ /** The project's current compiled hash, read at each hello; null with no
46
+ * project open, which makes the build "unknown". */
47
+ currentBuildHash: () => string | null;
48
+ /** Make the protocol. `pushStatus` sends the status again while a game is
49
+ * connected (before that, nothing the protocol adds is shown). */
50
+ protocol: (link: {
51
+ pushStatus(): void;
52
+ }) => LiveLinkProtocol<X>;
53
+ /** The latest status, for the app's windows and menu. */
54
+ onStatus: (status: LiveLinkServerStatus<X>) => void;
55
+ }
56
+ interface LiveLinkServer<X extends object = object> {
57
+ start(): void;
58
+ stop(): void;
59
+ isOn(): boolean;
60
+ status(): LiveLinkServerStatus<X>;
61
+ /** Send the status again while a game is connected: something the protocol
62
+ * shows changed outside a message (the app followed another flow). */
63
+ pushStatus(): void;
64
+ /** Push a freshly compiled bundle to the connected game. Nothing happens when
65
+ * no game has said hello, or when it already runs this build. The game
66
+ * applies it and re-hellos with the new build; until then it shows stale. */
67
+ pushBundle(build: string, data: string): void;
68
+ }
69
+ declare function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X>;
70
+
71
+ export { type LiveLinkBuild, type LiveLinkHello, type LiveLinkMessage, type LiveLinkProtocol, type LiveLinkServer, type LiveLinkServerOptions, type LiveLinkServerStatus, createLiveLinkServer };
@@ -0,0 +1,71 @@
1
+ /** How the game's build compares with the project's. */
2
+ type LiveLinkBuild = "match" | "stale" | "unknown";
3
+ /** One parsed message from the game: a JSON object with its `t`. */
4
+ type LiveLinkMessage = Record<string, unknown>;
5
+ /** The server's status, with the protocol's own fields on `connected`. */
6
+ type LiveLinkServerStatus<X extends object = object> = {
7
+ state: "off";
8
+ } | {
9
+ state: "error";
10
+ message: string;
11
+ } | {
12
+ state: "listening";
13
+ port: number;
14
+ } | ({
15
+ state: "connected";
16
+ port: number;
17
+ project?: string;
18
+ build: LiveLinkBuild;
19
+ } & X);
20
+ /** A hello, as the server read it. */
21
+ interface LiveLinkHello {
22
+ /** The game's build judged against the project's. */
23
+ build: LiveLinkBuild;
24
+ /** What the game said it is, if it did. */
25
+ project?: string;
26
+ /** The whole message, for the protocol's own fields (flows, boxes). */
27
+ msg: LiveLinkMessage;
28
+ }
29
+ /** An app's half of the link. */
30
+ interface LiveLinkProtocol<X extends object = object> {
31
+ /** A hello landed: a run begins, or a pushed bundle took. The status is
32
+ * pushed after it returns. */
33
+ hello(hello: LiveLinkHello): void;
34
+ /** Any other message, once the game has said hello. Call `pushStatus` when
35
+ * it changes what the status shows. */
36
+ message(msg: LiveLinkMessage): void;
37
+ /** The game went away, was replaced, or the server stopped: forget the run. */
38
+ reset(): void;
39
+ /** The protocol's fields on a connected status. */
40
+ describe(): X;
41
+ }
42
+ interface LiveLinkServerOptions<X extends object = object> {
43
+ /** Each app has its own, so both editors can listen on one machine. */
44
+ port: number;
45
+ /** The project's current compiled hash, read at each hello; null with no
46
+ * project open, which makes the build "unknown". */
47
+ currentBuildHash: () => string | null;
48
+ /** Make the protocol. `pushStatus` sends the status again while a game is
49
+ * connected (before that, nothing the protocol adds is shown). */
50
+ protocol: (link: {
51
+ pushStatus(): void;
52
+ }) => LiveLinkProtocol<X>;
53
+ /** The latest status, for the app's windows and menu. */
54
+ onStatus: (status: LiveLinkServerStatus<X>) => void;
55
+ }
56
+ interface LiveLinkServer<X extends object = object> {
57
+ start(): void;
58
+ stop(): void;
59
+ isOn(): boolean;
60
+ status(): LiveLinkServerStatus<X>;
61
+ /** Send the status again while a game is connected: something the protocol
62
+ * shows changed outside a message (the app followed another flow). */
63
+ pushStatus(): void;
64
+ /** Push a freshly compiled bundle to the connected game. Nothing happens when
65
+ * no game has said hello, or when it already runs this build. The game
66
+ * applies it and re-hellos with the new build; until then it shows stale. */
67
+ pushBundle(build: string, data: string): void;
68
+ }
69
+ declare function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X>;
70
+
71
+ export { type LiveLinkBuild, type LiveLinkHello, type LiveLinkMessage, type LiveLinkProtocol, type LiveLinkServer, type LiveLinkServerOptions, type LiveLinkServerStatus, createLiveLinkServer };
@@ -0,0 +1,134 @@
1
+ import { isLocalOrigin } from './chunk-QVACRWG7.js';
2
+ import { WebSocketServer } from 'ws';
3
+
4
+ function createLiveLinkServer(opts) {
5
+ const { port } = opts;
6
+ let wss = null;
7
+ let client = null;
8
+ let connected = null;
9
+ let clientBuild = null;
10
+ const status = () => {
11
+ if (!wss) return { state: "off" };
12
+ if (!client || !connected) return { state: "listening", port };
13
+ return {
14
+ ...protocol.describe(),
15
+ state: "connected",
16
+ port,
17
+ build: connected.build,
18
+ ...connected.project !== void 0 ? { project: connected.project } : {}
19
+ };
20
+ };
21
+ const push = () => opts.onStatus(status());
22
+ const pushStatus = () => {
23
+ if (connected) push();
24
+ };
25
+ const protocol = opts.protocol({ pushStatus });
26
+ const forget = () => {
27
+ connected = null;
28
+ clientBuild = null;
29
+ protocol.reset();
30
+ };
31
+ const handleMessage = (raw) => {
32
+ let msg;
33
+ try {
34
+ msg = JSON.parse(raw);
35
+ } catch {
36
+ return;
37
+ }
38
+ if (msg === null || typeof msg !== "object" || Array.isArray(msg)) return;
39
+ const m = msg;
40
+ if (m["t"] !== "hello") {
41
+ if (connected) protocol.message(m);
42
+ return;
43
+ }
44
+ clientBuild = typeof m["build"] === "string" ? m["build"] : null;
45
+ const current = opts.currentBuildHash();
46
+ const build = current === null ? "unknown" : clientBuild === current ? "match" : "stale";
47
+ const project = typeof m["project"] === "string" ? m["project"] : void 0;
48
+ connected = { build, ...project !== void 0 ? { project } : {} };
49
+ protocol.hello({ build, ...project !== void 0 ? { project } : {}, msg: m });
50
+ push();
51
+ };
52
+ return {
53
+ isOn: () => wss !== null,
54
+ status,
55
+ pushStatus,
56
+ pushBundle(build, data) {
57
+ if (!client || !connected) return;
58
+ if (clientBuild === build) return;
59
+ try {
60
+ client.send(JSON.stringify({ t: "bundle", v: 1, build, data }));
61
+ } catch {
62
+ }
63
+ if (connected.build !== "stale") {
64
+ connected = { ...connected, build: "stale" };
65
+ push();
66
+ }
67
+ },
68
+ start() {
69
+ if (wss) return;
70
+ try {
71
+ wss = new WebSocketServer({
72
+ host: "127.0.0.1",
73
+ port,
74
+ verifyClient: (info) => isLocalOrigin(info.origin)
75
+ }, () => push());
76
+ } catch (e) {
77
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
78
+ wss = null;
79
+ return;
80
+ }
81
+ wss.on("error", (e) => {
82
+ opts.onStatus({ state: "error", message: e instanceof Error ? e.message : String(e) });
83
+ if (!client) {
84
+ const s = wss;
85
+ wss = null;
86
+ try {
87
+ s?.close();
88
+ } catch {
89
+ }
90
+ }
91
+ });
92
+ wss.on("connection", (ws) => {
93
+ if (client) {
94
+ try {
95
+ client.close();
96
+ } catch {
97
+ }
98
+ }
99
+ client = ws;
100
+ forget();
101
+ ws.on("message", (data) => handleMessage(data.toString()));
102
+ ws.on("close", () => {
103
+ if (client === ws) {
104
+ client = null;
105
+ forget();
106
+ push();
107
+ }
108
+ });
109
+ ws.on("error", () => {
110
+ });
111
+ push();
112
+ });
113
+ },
114
+ stop() {
115
+ try {
116
+ client?.close();
117
+ } catch {
118
+ }
119
+ client = null;
120
+ forget();
121
+ const s = wss;
122
+ wss = null;
123
+ try {
124
+ s?.close();
125
+ } catch {
126
+ }
127
+ push();
128
+ }
129
+ };
130
+ }
131
+
132
+ export { createLiveLinkServer };
133
+ //# sourceMappingURL=live-link.js.map
134
+ //# sourceMappingURL=live-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/live-link.ts"],"names":[],"mappings":"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","file":"live-link.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The live link server: the loopback WebSocket server, in the main process,\n// that a running game connects to. Patterpad's debug-link.ts and Storyletter's\n// live-link.ts were this file twice (the family parity review of 2026-10, item\n// 14), so the parts that were the same are here once and each app plugs in its\n// own protocol:\n//\n// - loopback only, and refused to any Origin that isn't this machine\n// (isLocalOrigin), so a web page can't take the slot;\n// - one game at a time: a new connection replaces the old;\n// - nothing is honoured before a hello, which names the build the game runs,\n// judged against the project's (match / stale / unknown);\n// - the status off -> listening -> connected, or error, pushed to the app;\n// - pushBundle, gated on the build the game reported, and the build shown as\n// stale until the game re-hellos with the one it was sent;\n// - a bind failure (the port is taken) leaves the server off, not dead \"on\".\n//\n// What the game sends after its hello (Patterpad's frames, Storyletter's\n// boards and traces), and what the app adds to the connected status, is the\n// protocol's. Main-process code: `ws` is an optional peer the app supplies.\n// The chip that shows the status is link-status.ts.\n// ---------------------------------------------------------------------------\n\nimport { WebSocketServer, type WebSocket } from \"ws\";\nimport { isLocalOrigin } from \"./util.js\";\n\n/** How the game's build compares with the project's. */\nexport type LiveLinkBuild = \"match\" | \"stale\" | \"unknown\";\n\n/** One parsed message from the game: a JSON object with its `t`. */\nexport type LiveLinkMessage = Record<string, unknown>;\n\n/** The server's status, with the protocol's own fields on `connected`. */\nexport type LiveLinkServerStatus<X extends object = object> =\n | { state: \"off\" }\n | { state: \"error\"; message: string }\n | { state: \"listening\"; port: number }\n | ({ state: \"connected\"; port: number; project?: string; build: LiveLinkBuild } & X);\n\n/** A hello, as the server read it. */\nexport interface LiveLinkHello {\n /** The game's build judged against the project's. */\n build: LiveLinkBuild;\n /** What the game said it is, if it did. */\n project?: string;\n /** The whole message, for the protocol's own fields (flows, boxes). */\n msg: LiveLinkMessage;\n}\n\n/** An app's half of the link. */\nexport interface LiveLinkProtocol<X extends object = object> {\n /** A hello landed: a run begins, or a pushed bundle took. The status is\n * pushed after it returns. */\n hello(hello: LiveLinkHello): void;\n /** Any other message, once the game has said hello. Call `pushStatus` when\n * it changes what the status shows. */\n message(msg: LiveLinkMessage): void;\n /** The game went away, was replaced, or the server stopped: forget the run. */\n reset(): void;\n /** The protocol's fields on a connected status. */\n describe(): X;\n}\n\nexport interface LiveLinkServerOptions<X extends object = object> {\n /** Each app has its own, so both editors can listen on one machine. */\n port: number;\n /** The project's current compiled hash, read at each hello; null with no\n * project open, which makes the build \"unknown\". */\n currentBuildHash: () => string | null;\n /** Make the protocol. `pushStatus` sends the status again while a game is\n * connected (before that, nothing the protocol adds is shown). */\n protocol: (link: { pushStatus(): void }) => LiveLinkProtocol<X>;\n /** The latest status, for the app's windows and menu. */\n onStatus: (status: LiveLinkServerStatus<X>) => void;\n}\n\nexport interface LiveLinkServer<X extends object = object> {\n start(): void;\n stop(): void;\n isOn(): boolean;\n status(): LiveLinkServerStatus<X>;\n /** Send the status again while a game is connected: something the protocol\n * shows changed outside a message (the app followed another flow). */\n pushStatus(): void;\n /** Push a freshly compiled bundle to the connected game. Nothing happens when\n * no game has said hello, or when it already runs this build. The game\n * applies it and re-hellos with the new build; until then it shows stale. */\n pushBundle(build: string, data: string): void;\n}\n\nexport function createLiveLinkServer<X extends object = object>(opts: LiveLinkServerOptions<X>): LiveLinkServer<X> {\n const { port } = opts;\n let wss: WebSocketServer | null = null;\n let client: WebSocket | null = null;\n // Set by a hello: nothing else is honoured until it is.\n let connected: { project?: string; build: LiveLinkBuild } | null = null;\n let clientBuild: string | null = null; // the build the game reported: gates pushBundle\n\n const status = (): LiveLinkServerStatus<X> => {\n if (!wss) return { state: \"off\" };\n if (!client || !connected) return { state: \"listening\", port };\n return {\n ...protocol.describe(),\n state: \"connected\", port, build: connected.build,\n ...(connected.project !== undefined ? { project: connected.project } : {}),\n };\n };\n const push = (): void => opts.onStatus(status());\n const pushStatus = (): void => { if (connected) push(); };\n const protocol = opts.protocol({ pushStatus });\n\n const forget = (): void => { connected = null; clientBuild = null; protocol.reset(); };\n\n const handleMessage = (raw: string): void => {\n let msg: unknown;\n try { msg = JSON.parse(raw); } catch { return; }\n if (msg === null || typeof msg !== \"object\" || Array.isArray(msg)) return;\n const m = msg as LiveLinkMessage;\n if (m[\"t\"] !== \"hello\") {\n if (connected) protocol.message(m);\n return;\n }\n clientBuild = typeof m[\"build\"] === \"string\" ? m[\"build\"] : null;\n const current = opts.currentBuildHash();\n const build: LiveLinkBuild = current === null ? \"unknown\" : clientBuild === current ? \"match\" : \"stale\";\n const project = typeof m[\"project\"] === \"string\" ? m[\"project\"] : undefined;\n connected = { build, ...(project !== undefined ? { project } : {}) };\n protocol.hello({ build, ...(project !== undefined ? { project } : {}), msg: m });\n push();\n };\n\n return {\n isOn: () => wss !== null,\n status,\n pushStatus,\n pushBundle(build: string, data: string): void {\n if (!client || !connected) return; // nothing connected, or no hello yet\n if (clientBuild === build) return; // the game already runs this build\n try { client.send(JSON.stringify({ t: \"bundle\", v: 1, build, data })); } catch { /* socket went away */ }\n // Until the game re-hellos with the new build it is on the old one, and\n // the chip says so rather than keep the match it had at the hello.\n if (connected.build !== \"stale\") { connected = { ...connected, build: \"stale\" }; push(); }\n },\n start(): void {\n if (wss) return;\n try {\n // Loopback only: only processes on this machine can reach it. And not\n // a web page on it: any site can open a WebSocket to localhost, and the\n // browser stamps the upgrade with the page's Origin.\n wss = new WebSocketServer({\n host: \"127.0.0.1\", port,\n verifyClient: (info: { origin?: string }) => isLocalOrigin(info.origin),\n }, () => push()); // \"listening\" once actually bound\n } catch (e) {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n wss = null;\n return;\n }\n wss.on(\"error\", (e) => {\n opts.onStatus({ state: \"error\", message: e instanceof Error ? e.message : String(e) });\n // A bind failure leaves no server behind: the next click starts again\n // rather than finding a dead one \"on\".\n if (!client) { const s = wss; wss = null; try { s?.close(); } catch { /* already closing */ } }\n });\n wss.on(\"connection\", (ws) => {\n // One game at a time: a new connection replaces the old.\n if (client) { try { client.close(); } catch { /* gone */ } }\n client = ws; forget();\n ws.on(\"message\", (data) => handleMessage(data.toString()));\n ws.on(\"close\", () => { if (client === ws) { client = null; forget(); push(); } });\n ws.on(\"error\", () => { /* a flaky client must not take the editor down */ });\n push();\n });\n },\n stop(): void {\n try { client?.close(); } catch { /* gone */ }\n client = null; forget();\n const s = wss; wss = null;\n try { s?.close(); } catch { /* already closing */ }\n push();\n },\n };\n}\n"]}
package/dist/tokens.css CHANGED
@@ -1,6 +1,6 @@
1
1
  /* src/tokens.css */
2
2
  :root {
3
- color-scheme: light dark;
3
+ color-scheme: var(--color-scheme, light dark);
4
4
  --ok: light-dark(#3f7d49, #79bd80);
5
5
  --on-accent: light-dark(#ffffff, #1b1916);
6
6
  --chrome-size: 14px;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tokens.css"],"sourcesContent":["/* ============================================================================\n Design-token GRAMMAR - the app-agnostic layer (design-language.md section 4).\n\n The shared half of \"two palettes, one grammar\": the scales that are IDENTICAL\n across the editor family - elevation, motion, radius, scrollbar chrome. Each\n app supplies its own colour + font VALUES (its palette) over this grammar in\n its own theme.css. Do not add app-specific colours here.\n\n SCALE. Chrome is sized in px off a 14px base; the reading surface alone\n scales with the root (\"Chrome has its own scale\", design-language.md\n section 4, 2026-09-16). Every size in the shell's stylesheets (font-size,\n padding, gap, margin, width, height) is a pixel value: the navigator, the\n inspector, the bars, the dialogs and the controls in them read the same in\n every app of the family whatever the reading size is. `em` survives only\n where a rule deliberately scales with its own font; line-heights stay\n unitless. The values are what Storyletter rendered at 0.39.0 (rem x 16, to\n the nearest half pixel), so the conversion moved nothing there and brought\n Patterpad, whose chrome had grown 7 to 10 percent with its reading root,\n back to the same size. No `rem` in a chrome rule; the test suite greps.\n ============================================================================ */\n\n:root {\n color-scheme: light dark;\n\n /* The semantic set's third member, and the only COLOUR in this file.\n\n It is here because it kept not being anywhere. An app's palette owns\n --danger and --warn; neither owned \"all is well\", so a real need - the live\n debug link, an in-sync shard, a saved document - was met with a raw green\n literal that could not theme-shift (surface-audit-shell.md F2). A default\n in the grammar layer means the shell's own widgets may say it, and an app\n that has not thought about green yet still gets a green that works on both\n grounds. Every palette should override it in its theme.css, as it does the\n other two: this is a floor, not a decision. */\n --ok: light-dark(#3f7d49, #79bd80);\n\n /* The ink that sits ON the accent: a primary button's label, a selected\n row's text. Every palette overrides it (a light tan accent on a dark\n palette wants dark ink, not white); this floor only stops the shell's own\n primary buttons from falling back to a raw #fff literal, which is what\n they did until 0.39.0 and which read white-on-tan on Night and Slate. */\n --on-accent: light-dark(#ffffff, #1b1916);\n\n /* The chrome base. `.shell-dialog`, `.pane-shell` and `.swin-head` set it as\n their font-size, so unstyled text inside them lands at 14px regardless of\n the app's root; a rule that wants the base by name reads it here. Not a\n scale factor: the sizes below it are literal px, on purpose. */\n --chrome-size: 14px;\n\n /* The one scrim behind every modal in the family (confirm, settings,\n identity, about, notes). Four recipes had grown across five files. */\n --scrim: color-mix(in oklab, var(--ink) 28%, transparent);\n\n /* Elevation: three tiers by how far a surface floats (resting cards,\n floating menus/popovers, full overlays). Theme-agnostic black so it\n deepens naturally on the dark palettes. */\n --shadow-1: 0 1px 3px rgba(0, 0, 0, 0.05);\n --shadow-2: 0 6px 20px rgba(0, 0, 0, 0.14);\n --shadow-3: 0 16px 48px rgba(0, 0, 0, 0.28);\n\n /* Motion: one easing curve + a small duration scale, plus the shared\n panel/backdrop/screen enter-exit vocabulary. Nothing pops. */\n --ease-standard: cubic-bezier(0.2, 0, 0, 1);\n --dur-quick: 120ms;\n --dur-base: 150ms;\n --dur-settle: 200ms;\n --dur-slow: 1s;\n --dur-panel: 260ms;\n --dur-exit: 170ms;\n --anim-panel-in: panel-in var(--dur-panel) var(--ease-standard);\n --anim-backdrop-in: backdrop-in var(--dur-settle) var(--ease-standard);\n --anim-screen-in: screen-in var(--dur-panel) var(--ease-standard);\n --anim-panel-out: panel-out var(--dur-exit) var(--ease-standard) forwards;\n --anim-backdrop-out: backdrop-out var(--dur-exit) var(--ease-standard) forwards;\n /* The long-job strip's indeterminate band (job.css draws the keyframes). */\n --anim-job-band: job-band 1.4s var(--ease-standard) infinite;\n\n /* Corner radius: four steps cover every surface. */\n --radius-sm: 6px;\n --radius-md: 10px;\n --radius-lg: 14px;\n --radius-pill: 999px;\n\n /* Scrollbar thumb: a translucent ink, so it adapts per palette. */\n --scroll-thumb: color-mix(in oklab, var(--ink) 20%, transparent);\n --scroll-thumb-hover: color-mix(in oklab, var(--ink) 40%, transparent);\n}\n\n/* A drawn icon (`iconNode` / `iconHtml` in icons.ts stamp `data-icon`). A\n block, so it never sits on a text baseline with descender space under it;\n no flex shrink, so a crowded row squeezes the text and not the picture; and\n no pointer events, so a click lands on the button and never on the drawing\n (a handler comparing `event.target` to its button would otherwise miss).\n Colour is inherited: every drawing is currentColor. */\n[data-icon] { display: block; flex: none; pointer-events: none; }\n\n/* The family's keyboard focus ring: one rule, zero specificity (:where), so a\n component may draw its own and win without !important. Until 0.39.0\n Patterpad's main window had no ring at all and Storyletter carried five\n copies of this line. Mouse focus stays quiet; only :focus-visible draws. */\n:where(button, [role=\"button\"], input, select, textarea, summary, a[href], [tabindex]):focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\n/* The tracked uppercase caption, kept for exactly two jobs: table column heads\n and screenplay character cues, where uppercase is the domain's own\n convention (design-language.md, \"Captions are words, not overlines\"). A\n section caption anywhere else is sentence-case text at label size, weight\n 600, in the UI face, which is what the shell's own captions now are. */\n.overline { font: 600 10.5px/1 var(--font-ui); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }\n\n* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }\n::-webkit-scrollbar { width: 11px; height: 11px; }\n::-webkit-scrollbar-track { background: transparent; }\n::-webkit-scrollbar-corner { background: transparent; }\n::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: var(--radius-sm); border: 3px solid transparent; background-clip: padding-box; }\n::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }\n\n@keyframes panel-in { from { opacity: 0; transform: translateY(-9px) scale(0.965); } to { opacity: 1; transform: none; } }\n@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }\n@keyframes screen-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }\n@keyframes panel-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-6px) scale(0.97); } }\n@keyframes backdrop-out { from { opacity: 1; } to { opacity: 0; } }\n\n@media (prefers-reduced-motion: reduce) {\n :root {\n --anim-panel-in: none; --anim-backdrop-in: none; --anim-screen-in: none;\n --anim-panel-out: none; --anim-backdrop-out: none; --anim-job-band: none;\n }\n}\n\n/* A floating layer from createFloating (floating.ts) carries this attribute, because its class is\n the caller's. Never a window-drag region, even over one: Electron gives a click in a drag area to\n the OS before the page sees it (dialog.css has the whole story, 2026-09-28). */\n[data-shell-layer], [data-shell-layer] * { -webkit-app-region: no-drag; }\n"],"mappings":";AAqBA;AACE,gBAAc,MAAM;AAYpB,QAAM,WAAW,OAAO,EAAE;AAO1B,eAAa,WAAW,OAAO,EAAE;AAMjC,iBAAe;AAIf,WAAS,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AAK7C,cAAY,EAAE,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AACpC,cAAY,EAAE,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AACrC,cAAY,EAAE,KAAK,KAAK,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AAItC,mBAAiB,aAAa,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE;AACzC,eAAa;AACb,cAAY;AACZ,gBAAc;AACd,cAAY;AACZ,eAAa;AACb,cAAY;AACZ,mBAAiB,SAAS,IAAI,aAAa,IAAI;AAC/C,sBAAoB,YAAY,IAAI,cAAc,IAAI;AACtD,oBAAkB,UAAU,IAAI,aAAa,IAAI;AACjD,oBAAkB,UAAU,IAAI,YAAY,IAAI,iBAAiB;AACjE,uBAAqB,aAAa,IAAI,YAAY,IAAI,iBAAiB;AAEvE,mBAAiB,SAAS,KAAK,IAAI,iBAAiB;AAGpD,eAAa;AACb,eAAa;AACb,eAAa;AACb,iBAAe;AAGf,kBAAgB,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AACpD,wBAAsB,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AAC5D;AAQA,CAAC;AAAa,WAAS;AAAO,QAAM;AAAM,kBAAgB;AAAM;AAMhE,OAAO,QAAQ,CAAC,cAAgB,OAAO,QAAQ,UAAU,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU;AACpF,WAAS,IAAI,MAAM,IAAI;AACvB,kBAAgB;AAClB;AAOA,CAAC;AAAW,QAAM,IAAI,MAAM,CAAC,EAAE,IAAI;AAAY,kBAAgB;AAAW,kBAAgB;AAAQ,SAAO,IAAI;AAAU;AAEvH;AAAI,mBAAiB;AAAM,mBAAiB,IAAI,gBAAgB;AAAa;AAC7E;AAAsB,SAAO;AAAM,UAAQ;AAAM;AACjD;AAA4B,cAAY;AAAa;AACrD;AAA6B,cAAY;AAAa;AACtD;AAA4B,cAAY,IAAI;AAAiB,iBAAe,IAAI;AAAc,UAAQ,IAAI,MAAM;AAAa,mBAAiB;AAAa;AAC3J,yBAAyB;AAAS,cAAY,IAAI;AAAuB,mBAAiB;AAAa;AAEvG,WAAW;AAAW;AAAO,aAAS;AAAG,eAAW,WAAW,MAAM,MAAM;AAAQ;AAAE;AAAK,aAAS;AAAG,eAAW;AAAM;AAAE;AACzH,WAAW;AAAc;AAAO,aAAS;AAAG;AAAE;AAAK,aAAS;AAAG;AAAE;AACjE,WAAW;AAAY;AAAO,aAAS;AAAG,eAAW,WAAW;AAAM;AAAE;AAAK,aAAS;AAAG,eAAW;AAAM;AAAE;AAC5G,WAAW;AAAY;AAAO,aAAS;AAAG,eAAW;AAAM;AAAE;AAAK,aAAS;AAAG,eAAW,WAAW,MAAM,MAAM;AAAO;AAAE;AACzH,WAAW;AAAe;AAAO,aAAS;AAAG;AAAE;AAAK,aAAS;AAAG;AAAE;AAElE,QAAO,wBAAyB;AAC9B;AACE,qBAAiB;AAAM,wBAAoB;AAAM,sBAAkB;AACnE,sBAAkB;AAAM,yBAAqB;AAAM,qBAAiB;AACtE;AACF;AAKA,CAAC;AAAmB,CAAC,kBAAkB;AAAI,sBAAoB;AAAS;","names":[]}
1
+ {"version":3,"sources":["../src/tokens.css"],"sourcesContent":["/* ============================================================================\n Design-token GRAMMAR - the app-agnostic layer (design-language.md section 4).\n\n The shared half of \"two palettes, one grammar\": the scales that are IDENTICAL\n across the editor family - elevation, motion, radius, scrollbar chrome. Each\n app supplies its own colour + font VALUES (its palette) over this grammar in\n its own theme.css. Do not add app-specific colours here.\n\n SCALE. Chrome is sized in px off a 14px base; the reading surface alone\n scales with the root (\"Chrome has its own scale\", design-language.md\n section 4, 2026-09-16). Every size in the shell's stylesheets (font-size,\n padding, gap, margin, width, height) is a pixel value: the navigator, the\n inspector, the bars, the dialogs and the controls in them read the same in\n every app of the family whatever the reading size is. `em` survives only\n where a rule deliberately scales with its own font; line-heights stay\n unitless. The values are what Storyletter rendered at 0.39.0 (rem x 16, to\n the nearest half pixel), so the conversion moved nothing there and brought\n Patterpad, whose chrome had grown 7 to 10 percent with its reading root,\n back to the same size. No `rem` in a chrome rule; the test suite greps.\n ============================================================================ */\n\n:root {\n /* Native controls (an unticked checkbox, a radio, a scrollbar, a select's\n pop-up, a date field) and every light-dark() below follow this, so it must\n follow the palette the author chose, not the OS: a light palette under a\n dark OS drew every unticked checkbox as a dark filled square. Every palette\n block in an app's theme.css declares `--color-scheme: light` or `dark`;\n with no palette chosen the fallback follows the OS, as before. */\n color-scheme: var(--color-scheme, light dark);\n\n /* The semantic set's third member, and the only COLOUR in this file.\n\n It is here because it kept not being anywhere. An app's palette owns\n --danger and --warn; neither owned \"all is well\", so a real need - the live\n debug link, an in-sync shard, a saved document - was met with a raw green\n literal that could not theme-shift (surface-audit-shell.md F2). A default\n in the grammar layer means the shell's own widgets may say it, and an app\n that has not thought about green yet still gets a green that works on both\n grounds. Every palette should override it in its theme.css, as it does the\n other two: this is a floor, not a decision. */\n --ok: light-dark(#3f7d49, #79bd80);\n\n /* The ink that sits ON the accent: a primary button's label, a selected\n row's text. Every palette overrides it (a light tan accent on a dark\n palette wants dark ink, not white); this floor only stops the shell's own\n primary buttons from falling back to a raw #fff literal, which is what\n they did until 0.39.0 and which read white-on-tan on Night and Slate. */\n --on-accent: light-dark(#ffffff, #1b1916);\n\n /* The chrome base. `.shell-dialog`, `.pane-shell` and `.swin-head` set it as\n their font-size, so unstyled text inside them lands at 14px regardless of\n the app's root; a rule that wants the base by name reads it here. Not a\n scale factor: the sizes below it are literal px, on purpose. */\n --chrome-size: 14px;\n\n /* The one scrim behind every modal in the family (confirm, settings,\n identity, about, notes). Four recipes had grown across five files. */\n --scrim: color-mix(in oklab, var(--ink) 28%, transparent);\n\n /* Elevation: three tiers by how far a surface floats (resting cards,\n floating menus/popovers, full overlays). Theme-agnostic black so it\n deepens naturally on the dark palettes. */\n --shadow-1: 0 1px 3px rgba(0, 0, 0, 0.05);\n --shadow-2: 0 6px 20px rgba(0, 0, 0, 0.14);\n --shadow-3: 0 16px 48px rgba(0, 0, 0, 0.28);\n\n /* Motion: one easing curve + a small duration scale, plus the shared\n panel/backdrop/screen enter-exit vocabulary. Nothing pops. */\n --ease-standard: cubic-bezier(0.2, 0, 0, 1);\n --dur-quick: 120ms;\n --dur-base: 150ms;\n --dur-settle: 200ms;\n --dur-slow: 1s;\n --dur-panel: 260ms;\n --dur-exit: 170ms;\n --anim-panel-in: panel-in var(--dur-panel) var(--ease-standard);\n --anim-backdrop-in: backdrop-in var(--dur-settle) var(--ease-standard);\n --anim-screen-in: screen-in var(--dur-panel) var(--ease-standard);\n --anim-panel-out: panel-out var(--dur-exit) var(--ease-standard) forwards;\n --anim-backdrop-out: backdrop-out var(--dur-exit) var(--ease-standard) forwards;\n /* The long-job strip's indeterminate band (job.css draws the keyframes). */\n --anim-job-band: job-band 1.4s var(--ease-standard) infinite;\n\n /* Corner radius: four steps cover every surface. */\n --radius-sm: 6px;\n --radius-md: 10px;\n --radius-lg: 14px;\n --radius-pill: 999px;\n\n /* Scrollbar thumb: a translucent ink, so it adapts per palette. */\n --scroll-thumb: color-mix(in oklab, var(--ink) 20%, transparent);\n --scroll-thumb-hover: color-mix(in oklab, var(--ink) 40%, transparent);\n}\n\n/* A drawn icon (`iconNode` / `iconHtml` in icons.ts stamp `data-icon`). A\n block, so it never sits on a text baseline with descender space under it;\n no flex shrink, so a crowded row squeezes the text and not the picture; and\n no pointer events, so a click lands on the button and never on the drawing\n (a handler comparing `event.target` to its button would otherwise miss).\n Colour is inherited: every drawing is currentColor. */\n[data-icon] { display: block; flex: none; pointer-events: none; }\n\n/* The family's keyboard focus ring: one rule, zero specificity (:where), so a\n component may draw its own and win without !important. Until 0.39.0\n Patterpad's main window had no ring at all and Storyletter carried five\n copies of this line. Mouse focus stays quiet; only :focus-visible draws. */\n:where(button, [role=\"button\"], input, select, textarea, summary, a[href], [tabindex]):focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\n/* The tracked uppercase caption, kept for exactly two jobs: table column heads\n and screenplay character cues, where uppercase is the domain's own\n convention (design-language.md, \"Captions are words, not overlines\"). A\n section caption anywhere else is sentence-case text at label size, weight\n 600, in the UI face, which is what the shell's own captions now are. */\n.overline { font: 600 10.5px/1 var(--font-ui); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }\n\n* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }\n::-webkit-scrollbar { width: 11px; height: 11px; }\n::-webkit-scrollbar-track { background: transparent; }\n::-webkit-scrollbar-corner { background: transparent; }\n::-webkit-scrollbar-thumb { background: var(--scroll-thumb); border-radius: var(--radius-sm); border: 3px solid transparent; background-clip: padding-box; }\n::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }\n\n@keyframes panel-in { from { opacity: 0; transform: translateY(-9px) scale(0.965); } to { opacity: 1; transform: none; } }\n@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }\n@keyframes screen-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }\n@keyframes panel-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-6px) scale(0.97); } }\n@keyframes backdrop-out { from { opacity: 1; } to { opacity: 0; } }\n\n@media (prefers-reduced-motion: reduce) {\n :root {\n --anim-panel-in: none; --anim-backdrop-in: none; --anim-screen-in: none;\n --anim-panel-out: none; --anim-backdrop-out: none; --anim-job-band: none;\n }\n}\n\n/* A floating layer from createFloating (floating.ts) carries this attribute, because its class is\n the caller's. Never a window-drag region, even over one: Electron gives a click in a drag area to\n the OS before the page sees it (dialog.css has the whole story, 2026-09-28). */\n[data-shell-layer], [data-shell-layer] * { -webkit-app-region: no-drag; }\n"],"mappings":";AAqBA;AAOE,gBAAc,IAAI,cAAc,EAAE,MAAM;AAYxC,QAAM,WAAW,OAAO,EAAE;AAO1B,eAAa,WAAW,OAAO,EAAE;AAMjC,iBAAe;AAIf,WAAS,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AAK7C,cAAY,EAAE,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AACpC,cAAY,EAAE,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AACrC,cAAY,EAAE,KAAK,KAAK,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE;AAItC,mBAAiB,aAAa,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE;AACzC,eAAa;AACb,cAAY;AACZ,gBAAc;AACd,cAAY;AACZ,eAAa;AACb,cAAY;AACZ,mBAAiB,SAAS,IAAI,aAAa,IAAI;AAC/C,sBAAoB,YAAY,IAAI,cAAc,IAAI;AACtD,oBAAkB,UAAU,IAAI,aAAa,IAAI;AACjD,oBAAkB,UAAU,IAAI,YAAY,IAAI,iBAAiB;AACjE,uBAAqB,aAAa,IAAI,YAAY,IAAI,iBAAiB;AAEvE,mBAAiB,SAAS,KAAK,IAAI,iBAAiB;AAGpD,eAAa;AACb,eAAa;AACb,eAAa;AACb,iBAAe;AAGf,kBAAgB,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AACpD,wBAAsB,UAAU,GAAG,KAAK,EAAE,IAAI,OAAO,GAAG,EAAE;AAC5D;AAQA,CAAC;AAAa,WAAS;AAAO,QAAM;AAAM,kBAAgB;AAAM;AAMhE,OAAO,QAAQ,CAAC,cAAgB,OAAO,QAAQ,UAAU,SAAS,CAAC,CAAC,OAAO,CAAC,UAAU;AACpF,WAAS,IAAI,MAAM,IAAI;AACvB,kBAAgB;AAClB;AAOA,CAAC;AAAW,QAAM,IAAI,MAAM,CAAC,EAAE,IAAI;AAAY,kBAAgB;AAAW,kBAAgB;AAAQ,SAAO,IAAI;AAAU;AAEvH;AAAI,mBAAiB;AAAM,mBAAiB,IAAI,gBAAgB;AAAa;AAC7E;AAAsB,SAAO;AAAM,UAAQ;AAAM;AACjD;AAA4B,cAAY;AAAa;AACrD;AAA6B,cAAY;AAAa;AACtD;AAA4B,cAAY,IAAI;AAAiB,iBAAe,IAAI;AAAc,UAAQ,IAAI,MAAM;AAAa,mBAAiB;AAAa;AAC3J,yBAAyB;AAAS,cAAY,IAAI;AAAuB,mBAAiB;AAAa;AAEvG,WAAW;AAAW;AAAO,aAAS;AAAG,eAAW,WAAW,MAAM,MAAM;AAAQ;AAAE;AAAK,aAAS;AAAG,eAAW;AAAM;AAAE;AACzH,WAAW;AAAc;AAAO,aAAS;AAAG;AAAE;AAAK,aAAS;AAAG;AAAE;AACjE,WAAW;AAAY;AAAO,aAAS;AAAG,eAAW,WAAW;AAAM;AAAE;AAAK,aAAS;AAAG,eAAW;AAAM;AAAE;AAC5G,WAAW;AAAY;AAAO,aAAS;AAAG,eAAW;AAAM;AAAE;AAAK,aAAS;AAAG,eAAW,WAAW,MAAM,MAAM;AAAO;AAAE;AACzH,WAAW;AAAe;AAAO,aAAS;AAAG;AAAE;AAAK,aAAS;AAAG;AAAE;AAElE,QAAO,wBAAyB;AAC9B;AACE,qBAAiB;AAAM,wBAAoB;AAAM,sBAAkB;AACnE,sBAAkB;AAAM,yBAAqB;AAAM,qBAAiB;AACtE;AACF;AAKA,CAAC;AAAmB,CAAC,kBAAkB;AAAI,sBAAoB;AAAS;","names":[]}