@marver-design/marver 0.7.0 → 0.8.1

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 (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -1,101 +1,10 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
1
+ import { t as diffEvents } from "./events-BMtBvvgU.mjs";
2
+ import { appendEvents, listBoards, readLog } from "./comments-BZBKhKRO.mjs";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- //#region src/shared/events.ts
6
- /** Events the other side lacks, by id - the sync payload in either direction. */
7
- function diffEvents(mine, theirIds) {
8
- const have = new Set(theirIds);
9
- return mine.filter((e) => !have.has(e.id));
10
- }
11
- /** Replay a board's events into current thread state. Deterministic: replay order is
12
- * (ts, id) so two stores holding the same event SET always derive the same state.
13
- * TWO passes - creates first, then everything else - so a reply whose author's clock
14
- * ran ahead of the creator's still lands instead of being dropped forever. */
15
- function replay(events) {
16
- const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
17
- const threads = /* @__PURE__ */ new Map();
18
- for (const ev of ordered) {
19
- if (ev.type !== "create") continue;
20
- if (!ev.commentId || threads.has(ev.commentId)) continue;
21
- threads.set(ev.commentId, {
22
- id: ev.commentId,
23
- board: ev.board,
24
- nodeKey: ev.nodeKey,
25
- frame: ev.frame,
26
- anchor: ev.anchor,
27
- author: ev.author,
28
- body: ev.body,
29
- ts: ev.ts,
30
- resolved: false,
31
- replies: [],
32
- reactions: {}
33
- });
34
- }
35
- for (const ev of ordered) switch (ev.type) {
36
- case "reply": {
37
- const t = ev.parentId ? threads.get(ev.parentId) : void 0;
38
- if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
39
- t.replies.push({
40
- id: ev.commentId,
41
- author: ev.author,
42
- body: ev.body,
43
- ts: ev.ts
44
- });
45
- break;
46
- }
47
- case "edit": {
48
- if (!ev.commentId || ev.body === void 0) break;
49
- const t = threads.get(ev.commentId);
50
- if (t) {
51
- t.body = ev.body;
52
- break;
53
- }
54
- for (const th of threads.values()) {
55
- const r = th.replies.find((r) => r.id === ev.commentId);
56
- if (r) {
57
- r.body = ev.body;
58
- break;
59
- }
60
- }
61
- break;
62
- }
63
- case "resolve": {
64
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
65
- if (t) {
66
- t.resolved = true;
67
- if (ev.addressedIn) t.addressedIn = ev.addressedIn;
68
- }
69
- break;
70
- }
71
- case "reopen": {
72
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
73
- if (t) {
74
- t.resolved = false;
75
- t.addressedIn = void 0;
76
- }
77
- break;
78
- }
79
- case "react": {
80
- const t = ev.commentId ? threads.get(ev.commentId) : void 0;
81
- const who = ev.author?.email;
82
- if (!t || !who || !ev.emoji) break;
83
- const users = t.reactions[ev.emoji] ??= [];
84
- const at = users.indexOf(who);
85
- if (at === -1) users.push(who);
86
- else {
87
- users.splice(at, 1);
88
- if (!users.length) delete t.reactions[ev.emoji];
89
- }
90
- break;
91
- }
92
- }
93
- return [...threads.values()];
94
- }
95
- //#endregion
96
5
  //#region src/server/sync.ts
97
6
  /**
98
- * Dev ↔ published comment sync (SPEC-M3 §2) - one merge rule, run from the dev side.
7
+ * Dev ↔ published comment sync - one merge rule, run from the dev side.
99
8
  *
100
9
  * The published serve is the canonical home; the repo's design/comments/ is the
101
10
  * mirror the agent reads. Each exchange is: pull remote events → union into local
@@ -105,13 +14,6 @@ function replay(events) {
105
14
  * Credentials: design/.local/collab.json {url, token} written by `comments connect`.
106
15
  * The token is a server session held by the DEV PROCESS only - pages never see it.
107
16
  */
108
- var sync_exports = /* @__PURE__ */ __exportAll({
109
- connect: () => connect,
110
- connectClaim: () => connectClaim,
111
- loadCollab: () => loadCollab,
112
- saveCollab: () => saveCollab,
113
- syncOnce: () => syncOnce
114
- });
115
17
  const collabFile = (root) => join(root, "design", ".local", "collab.json");
116
18
  function loadCollab(root) {
117
19
  try {
@@ -148,7 +50,7 @@ async function syncOnce(root, collab) {
148
50
  const pulled = appendEvents(dir, board, remote).length;
149
51
  let pushed = 0;
150
52
  if (rights[board] === "comment") {
151
- const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id));
53
+ const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id)).filter((e) => !e.agent);
152
54
  for (let i = 0; i < missing.length; i += 100) {
153
55
  const r = await fetch(`${base}/__mv/api/comments/${board}`, {
154
56
  method: "POST",
@@ -245,4 +147,4 @@ async function connectClaim(root, url, invite, profile, canvasPassword) {
245
147
  });
246
148
  }
247
149
  //#endregion
248
- export { sync_exports as a, syncOnce as i, connectClaim as n, replay as o, loadCollab as r, connect as t };
150
+ export { connect, connectClaim, loadCollab, syncOnce };
@@ -0,0 +1,97 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ //#region src/server/jam/activity.ts
5
+ function createActivity(ttlMs = 9e4) {
6
+ const m = /* @__PURE__ */ new Map();
7
+ const cbs = [];
8
+ const frames = () => [...new Set([...m.keys()].map((k) => k.slice(k.indexOf("|") + 1)))];
9
+ const emit = () => {
10
+ const f = frames();
11
+ for (const cb of cbs) cb(f);
12
+ };
13
+ return {
14
+ mark(frame, ttl, src = "cli") {
15
+ if (!frame) return;
16
+ m.set(`${src}|${frame}`, Date.now() + (ttl ?? ttlMs));
17
+ emit();
18
+ },
19
+ clear(frame, src = "cli") {
20
+ if (m.delete(`${src}|${frame}`)) emit();
21
+ },
22
+ clearAll(src = "cli") {
23
+ let changed = false;
24
+ for (const k of [...m.keys()]) if (k.startsWith(`${src}|`)) {
25
+ m.delete(k);
26
+ changed = true;
27
+ }
28
+ if (changed) emit();
29
+ },
30
+ active: frames,
31
+ sweep() {
32
+ const now = Date.now();
33
+ let changed = false;
34
+ for (const [k, until] of m) if (until <= now) {
35
+ m.delete(k);
36
+ changed = true;
37
+ }
38
+ if (changed) emit();
39
+ },
40
+ onChange(fn) {
41
+ cbs.push(fn);
42
+ return () => {
43
+ const i = cbs.indexOf(fn);
44
+ if (i !== -1) cbs.splice(i, 1);
45
+ };
46
+ }
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region src/server/work.ts
51
+ /**
52
+ * The working-state rail for CHAT-driven agents (the jam daemon has its own in-process
53
+ * path into the same set). A coding agent that just accepted a request creates the frame
54
+ * files first, then `npx marver work start <frame...>` - the canvas shows the live
55
+ * working shimmer before a single component exists. `marver work done` clears it.
56
+ *
57
+ * Transport: the dev server writes design/.local/dev.json ({port, token}) at boot; the
58
+ * CLI reads it to find the server and authenticate. The token makes the endpoint
59
+ * unreachable from a drive-by browser page (which cannot read local files), while any
60
+ * process that can read the repo - the owner's own tools - is trusted by definition.
61
+ * Presence itself NEVER touches disk (activity.ts).
62
+ */
63
+ /** One per process - the dev server, the jam daemon, and the API all share it. */
64
+ const workActivity = createActivity();
65
+ /** The longest a CLI mark may glow unrefreshed - a forgotten `done` self-heals. */
66
+ const WORK_TTL_MAX = 18e5;
67
+ const WORK_TTL_DEFAULT = 6e5;
68
+ const infoPath = (root) => join(root, "design", ".local", "dev.json");
69
+ /** Written once per boot (dev.ts), removed on close - the CLI's discovery + credential. */
70
+ function writeDevInfo(root, port) {
71
+ const token = randomBytes(24).toString("base64url");
72
+ mkdirSync(join(root, "design", ".local"), { recursive: true });
73
+ writeFileSync(infoPath(root), JSON.stringify({
74
+ port,
75
+ token,
76
+ ts: Date.now()
77
+ }, null, 2) + "\n");
78
+ return token;
79
+ }
80
+ function removeDevInfo(root) {
81
+ try {
82
+ rmSync(infoPath(root), { force: true });
83
+ } catch {}
84
+ }
85
+ function readDevInfo(root) {
86
+ try {
87
+ const v = JSON.parse(readFileSync(infoPath(root), "utf8"));
88
+ return typeof v?.port === "number" && typeof v?.token === "string" ? {
89
+ port: v.port,
90
+ token: v.token
91
+ } : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ //#endregion
97
+ export { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo, removeDevInfo, workActivity, writeDevInfo };
@@ -0,0 +1,76 @@
1
+ import { n as NAME } from "./cli.mjs";
2
+ import { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo } from "./work-CLrmY-vQ.mjs";
3
+ //#region src/cli/work.ts
4
+ /**
5
+ * `marver work` - the coding agent's hand on the canvas working state.
6
+ *
7
+ * work start <scene/frame ...> [--ttl <minutes>] mark frames actively working
8
+ * work done <scene/frame ...> | --all clear the glow
9
+ * work list what is glowing right now
10
+ *
11
+ * The intended choreography (taught in design/AGENTS.md): on accepting a request,
12
+ * create the frame FILES first (name, meta, a minimal skeleton), pin them on the
13
+ * right board, `work start` them - the human sees the request land on the canvas
14
+ * within seconds - build (independent frames in parallel, one subagent each), then
15
+ * `work done`. Marks self-expire (default 10 min, max 30) so a crashed agent can
16
+ * never leave a frame glowing forever.
17
+ */
18
+ async function workCommand(root, action, frames, opts) {
19
+ const info = readDevInfo(root);
20
+ if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
21
+ const call = async (method, body) => {
22
+ let res;
23
+ try {
24
+ res = await fetch(`http://localhost:${info.port}/__mv/api/work`, {
25
+ method,
26
+ headers: {
27
+ "content-type": "application/json",
28
+ "x-mv-work": info.token
29
+ },
30
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
31
+ });
32
+ } catch {
33
+ throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
34
+ }
35
+ const data = await res.json().catch(() => ({}));
36
+ if (res.status === 403) throw new Error(`the dev server refused the token - it likely restarted; try again (design/.local/dev.json is re-read each run)`);
37
+ if (!res.ok) throw new Error(String(data?.error ?? `work ${action} failed (${res.status})`));
38
+ if (!Array.isArray(data?.frames)) throw new Error(`port ${info.port} did not answer like \`${NAME} dev\` - is it still running?`);
39
+ return data;
40
+ };
41
+ switch (action) {
42
+ case "start": {
43
+ if (!frames.length) throw new Error("name the frames: work start <scene/frame ...>");
44
+ const min = Number(opts.ttl);
45
+ const ttlMs = Number.isFinite(min) && min > 0 ? Math.min(Math.max(min * 6e4, 1e4), WORK_TTL_MAX) : WORK_TTL_DEFAULT;
46
+ const { frames: active } = await call("POST", {
47
+ frames,
48
+ on: true,
49
+ ttlMs
50
+ });
51
+ const pretty = ttlMs >= 6e4 ? `${Math.round(ttlMs / 6e4)} min` : `${Math.round(ttlMs / 1e3)} s`;
52
+ console.log(`working: ${active.join(", ")} (auto-expires in ${pretty} - re-run to extend, \`work done\` to clear)`);
53
+ return;
54
+ }
55
+ case "done": {
56
+ if (!frames.length && !opts.all) throw new Error("name the frames (or --all): work done <scene/frame ...>");
57
+ const { frames: active } = await call("POST", opts.all ? {
58
+ on: false,
59
+ all: true
60
+ } : {
61
+ frames,
62
+ on: false
63
+ });
64
+ console.log(active.length ? `still working: ${active.join(", ")}` : "nothing working - all clear");
65
+ return;
66
+ }
67
+ case "list": {
68
+ const { frames: active } = await call("GET");
69
+ console.log(active.length ? active.join("\n") : "nothing working");
70
+ return;
71
+ }
72
+ default: throw new Error(`unknown action "${action}" - use start · done · list`);
73
+ }
74
+ }
75
+ //#endregion
76
+ export { workCommand };
package/package.json CHANGED
@@ -1,10 +1,24 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "Apache-2.0",
8
+ "author": "Nic Touron (https://github.com/TNEP4)",
9
+ "keywords": [
10
+ "design",
11
+ "canvas",
12
+ "agent",
13
+ "prototyping",
14
+ "design-tool",
15
+ "react",
16
+ "vite",
17
+ "tailwind"
18
+ ],
19
+ "bugs": {
20
+ "url": "https://github.com/TNEP4/marver/issues"
21
+ },
8
22
  "bin": {
9
23
  "marver": "dist/cli.mjs"
10
24
  },
@@ -55,6 +69,7 @@
55
69
  "engines": {
56
70
  "node": ">=22.18.0"
57
71
  },
72
+ "packageManager": "pnpm@11.0.8",
58
73
  "homepage": "https://marver.design",
59
74
  "repository": {
60
75
  "type": "git",
@@ -4,7 +4,7 @@ export const NAME = 'marver'
4
4
  export const PKG = '@marver-design/marver' // registry identity; bin stays `marver`
5
5
  export const ROUTE = '/__mv'
6
6
 
7
- /** Content-frame natural widths (SPEC-026): Doc layout -> own-size width.
7
+ /** Content-frame natural widths: Doc layout -> own-size width.
8
8
  * Shared by the Doc primitive (measurement messages) and the server-side
9
9
  * manifest scan (defaultSize for content frames) - one source, no drift. */
10
10
  export const CONTENT_WIDTH: Record<string, number> = { document: 760, wide: 1280 }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Diagram block - first-class Mermaid (SPEC-026).
2
+ * The Diagram block - first-class Mermaid.
3
3
  * Mermaid is marver's dependency behind a dynamic import: a workspace with no
4
4
  * Diagram loads no mermaid bytes. Source-level theme overrides (%%{init}%%,
5
5
  * yaml frontmatter) are stripped so the marver palette always holds; the
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Content-frame block primitives (SPEC-026): Doc, Row, Col, Space, Md, Diagram, Img.
2
+ * Content-frame block primitives: Doc, Row, Col, Space, Md, Diagram, Img.
3
3
  * One vocabulary at two scales: boards arrange frames in rows/columns with space
4
4
  * units; these primitives do the same for blocks INSIDE a frame - as CSS flex,
5
- * deliberately NOT the SPEC-024 lane grammar (shared vocabulary, not semantics).
5
+ * deliberately NOT the board lane-flow grammar (shared vocabulary, not semantics).
6
6
  *
7
7
  * Doc owns the measurement protocol: it reports its content height so the shell
8
8
  * can give the frame a natural size (the shell alone owns node dimensions).
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Markdown rendering for the Md block (SPEC-026 security section).
2
+ * Markdown rendering for the Md block.
3
3
  * Raw HTML is inert (escaped, never parsed). Links and images have SEPARATE
4
4
  * policies: links may be goto:/http(s)/mailto (external ones open a new tab,
5
5
  * never navigate the iframe); images are local-only - design/assets/ paths.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The marver diagram identity (SPEC-026): the full Apple system palette (HIG),
2
+ * The marver diagram identity: the full Apple system palette (HIG),
3
3
  * every value the HIG's own light/dark pair - never inverted. Structure comes
4
4
  * from the systemGray ramp, emphasis from blue+purple, series diversity from
5
5
  * the 12-color set.
@@ -43,7 +43,7 @@ export function themeVars(dark: boolean): Record<string, string> {
43
43
  const purple = dark ? ACCENT.purple.dark : ACCENT.purple.light
44
44
  // Nodes are ACCENTED by default - a plain flowchart only ever touches
45
45
  // primaryColor, so a gray primary meant every default diagram rendered
46
- // gray-on-gray (Nic's dogfood catch, 2026-08-13). Structure (lines, clusters,
46
+ // gray-on-gray. Structure (lines, clusters,
47
47
  // labels) stays grayscale; the shapes carry the marver blue.
48
48
  const blueWash = dark ? '#123A5C' : '#E4F0FF'
49
49
  const purpleWash = dark ? '#3A1440' : '#F8E4FC'
@@ -12,7 +12,7 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
12
12
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
13
13
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
14
14
 
15
- // SPEC-M5: the shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
15
+ // The shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
16
16
  // are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
17
17
  // degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
18
18
  const _attachShadow = Element.prototype.attachShadow
@@ -1,4 +1,4 @@
1
- // Shared frame-inspect controller (SPEC-M3 §5/§7 + prototype-review #4/#5).
1
+ // Shared frame-inspect controller.
2
2
  //
3
3
  // Laser outlines, comment-pick, anchor capture/resolve, and a persistent element
4
4
  // lock ("highlight") - the review machinery shared by the canvas frame bridge
@@ -52,6 +52,9 @@ const DEPTH_HUE = (() => {
52
52
 
53
53
  export function createInspect({ post, getId, onModeChange }) {
54
54
  let laserOn = false, pickOn = false
55
+ // quiet pick (⇧L laser comment off): clicks still anchor comments, but NO hover outline,
56
+ // label, or lock lighting inside the artwork - the shell decides, the frame obeys
57
+ let pickQuiet = false
55
58
  let hoverEl = null, labelEl = null
56
59
  let lockedEl = null, highlightAnchor = null // the persistent outline (#4 lock / #5 open)
57
60
  let copiedTimer = null, copySeq = 0, copyEl = null
@@ -172,11 +175,19 @@ body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-of
172
175
  label.style.top = y + 'px'
173
176
  }
174
177
 
178
+ // the pointer LEAVING this frame takes its hover lighting with it: each frame is its own
179
+ // document, so once the cursor is gone no mousemove ever fires here again - without this,
180
+ // the last hovered outline + label fossilize on every frame the cursor crossed.
181
+ // (relatedTarget null = left the window/iframe, not just moved between elements)
182
+ on(document, 'mouseout', (e) => { if (!e.relatedTarget) clearHover() })
183
+ on(window, 'blur', clearHover)
184
+
175
185
  // hover-follow: the label chases the cursor while a mode is on - BUT a locked
176
186
  // element (#4 composing / #5 thread open) freezes it: the lit element is the one
177
187
  // being discussed, not whatever the pointer grazes.
178
188
  on(document, 'mousemove', (e) => {
179
189
  if (!modeActive() || lockedEl) return
190
+ if (pickOn && pickQuiet) return // quiet pick: no hover visuals, clicks still land
180
191
  const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
181
192
  if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
182
193
  clearHover()
@@ -245,7 +256,8 @@ body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-of
245
256
  if (pickOn) {
246
257
  // #4: lock the chosen element the instant it is picked - no round-trip flicker.
247
258
  // The shell re-confirms via sh:highlight-anchor once the draft is staged.
248
- applyLock(el, hueOf(el))
259
+ // Quiet pick (⇧L off) anchors WITHOUT lighting the element.
260
+ if (!pickQuiet) applyLock(el, hueOf(el))
249
261
  clearHover()
250
262
  post({ type: 'sh:picked', id: getId(), anchor: anchorBundle(el, e) })
251
263
  } else {
@@ -299,7 +311,8 @@ body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-of
299
311
  if (!m || typeof m !== 'object') return
300
312
  if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); onModeChange?.(laserOn, pickOn) }
301
313
  else if (m.type === 'sh:pick') {
302
- pickOn = !!m.on; applyModes()
314
+ pickOn = !!m.on; pickQuiet = !!m.quiet; applyModes()
315
+ if (pickQuiet) clearHover() // a hover lit before the toggle landed must not linger
303
316
  // leaving comment mode drops a provisional self-lock the shell never confirmed;
304
317
  // a shell-owned highlight (highlightAnchor set, e.g. #5) survives
305
318
  if (!pickOn && !highlightAnchor) clearLock()
@@ -1,4 +1,4 @@
1
- // SPEC-M5 slice 1: the DOM-snapshot serializer. Turns a live, same-origin frame Document into a
1
+ // The DOM-snapshot serializer. Turns a live, same-origin frame Document into a
2
2
  // self-contained STATIC html string: post-render DOM + full inlined CSS (styleSheets +
3
3
  // adoptedStyleSheets), JS stripped. Rendered in a `sandbox="allow-same-origin"` (NO allow-scripts)
4
4
  // iframe srcdoc, it reflows / theme-flips / device-sweeps with the browser's own layout engine and
@@ -19,7 +19,7 @@ export interface SerializeResult {
19
19
 
20
20
  const STRIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT'])
21
21
 
22
- /** Absolutize relative url() in a sheet's cssText against that sheet's href (codex P1: consolidating
22
+ /** Absolutize relative url() in a sheet's cssText against that sheet's href (consolidating
23
23
  * rules into one <style> moves the url() base from each sheet to the srcdoc base). */
24
24
  function absolutizeUrls(css: string, sheetHref: string | null): string {
25
25
  if (!sheetHref) return css