@hueest/xray 0.1.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.
package/dist/core.js ADDED
@@ -0,0 +1,230 @@
1
+ import { a as ROOT_ATTR, i as NODE_CLASS, o as ROOT_CLASS, r as LEAF_CLASS, s as SPEC_GATE, t as BASE_CSS, u as emptyPlate } from "./plate-BuzRMPx4.js";
2
+ import { i as captureRegime, o as classify, r as capture, t as CaptureTooLargeError } from "./serialize-Bq6yJnNV.js";
3
+ import { n as regimeFor } from "./breakpoints-CBNlh7lQ.js";
4
+ //#region src/chunk.ts
5
+ /**
6
+ * Serialize a merged plate's RenderNode tree into the shipped `Plate` (ADR
7
+ * 0008). This is the framework-neutral half of rendering: a skeleton is static,
8
+ * text-free DOM, so the structure is flattened to HTML strings here, at build,
9
+ * once — instead of every adapter walking a tree on the latency-critical first
10
+ * paint. The adapter only injects the strings and mounts a child plate at each
11
+ * stitch (see the React adapters). No escaping risk: bones carry only the
12
+ * content-addressed class tokens (ADR 0007), never user content.
13
+ */
14
+ function nodeClass(node) {
15
+ return NODE_CLASS + (node.leaf ? ` ${LEAF_CLASS} ${LEAF_CLASS}-${node.leaf}` : "") + (node.cls ? ` ${node.cls}` : "");
16
+ }
17
+ /**
18
+ * Whether this subtree holds a stitch (`ref`) anywhere — a stitched subtree
19
+ * can't collapse to a flat string, since a `ref` resolves to a child plate at
20
+ * render time and must stay a mount point.
21
+ */
22
+ function hasStitch(node) {
23
+ return node.ref !== void 0 || (node.kids?.some(hasStitch) ?? false);
24
+ }
25
+ /** Serialize a stitch-free subtree to one HTML string (caller guarantees no `ref` below). */
26
+ function toHtml(node) {
27
+ const kids = node.kids ? node.kids.map(toHtml).join("") : "";
28
+ return `<div class="${nodeClass(node)}">${kids}</div>`;
29
+ }
30
+ /**
31
+ * Serialize a node list into chunks: each maximal stitch-free run becomes one
32
+ * HTML `string`; a `ref` node becomes `{ r }`; a node that isn't itself a stitch
33
+ * but holds one deeper stays a real element (`{ c, k }`) and recurses. Mirrors
34
+ * the element shape the runtime renderer would otherwise build per node.
35
+ */
36
+ function toChunks(nodes) {
37
+ const out = [];
38
+ let buf = "";
39
+ const flush = () => {
40
+ if (buf) {
41
+ out.push(buf);
42
+ buf = "";
43
+ }
44
+ };
45
+ for (const node of nodes) {
46
+ if (!hasStitch(node)) {
47
+ buf += toHtml(node);
48
+ continue;
49
+ }
50
+ flush();
51
+ if (node.ref !== void 0) out.push({ r: node.ref });
52
+ else out.push({
53
+ c: nodeClass(node),
54
+ k: toChunks(node.kids ?? [])
55
+ });
56
+ }
57
+ flush();
58
+ return out;
59
+ }
60
+ /**
61
+ * Turn a merged plate (RenderNode tree) into the shipped plate (chunks). The
62
+ * root's own content classes travel as `rootCls` (the adapter puts them on the
63
+ * root element); its children become `chunks`. A capture-less plate has null
64
+ * chunks and renders the fallback.
65
+ */
66
+ function serializePlate(merged) {
67
+ const { v, name, tree, css } = merged;
68
+ if (!tree) return {
69
+ v,
70
+ name,
71
+ chunks: null,
72
+ css
73
+ };
74
+ const chunks = toChunks(tree.kids ?? []);
75
+ return tree.cls ? {
76
+ v,
77
+ name,
78
+ chunks,
79
+ rootCls: tree.cls,
80
+ css
81
+ } : {
82
+ v,
83
+ name,
84
+ chunks,
85
+ css
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/merge.ts
90
+ /**
91
+ * Turn the per-view captures in a plate file into the one plate a Skeleton
92
+ * renders.
93
+ *
94
+ * Each view is kept whole — its own subtree, its own ids, its own rules —
95
+ * and shown only across the viewport range it owns, via `@media display:none`
96
+ * gates. We do NOT merge the views' trees into one. An earlier design (ADR
97
+ * 0004) did, sharing common structure and forking the rest, but aligning two
98
+ * genuinely-different DOM trees (mobile stacked vs desktop two-column) without
99
+ * stable keys proved unreliable: every heuristic mis-paired some nodes and
100
+ * remapped one view's rules onto another's elements, corrupting both. A lone
101
+ * capture is pixel-faithful; keeping each view lone preserves that. The cost
102
+ * is a larger plate (roughly the sum of the views) — bounded by the plate
103
+ * size cap — in exchange for correctness.
104
+ *
105
+ * Pure data-in/data-out — runs in node (plugin load) and in tests.
106
+ */
107
+ function mergeViews(file) {
108
+ const views = file.views;
109
+ if (views.length === 0) return emptyPlate(file.name);
110
+ if (views.length === 1) {
111
+ const [only] = views;
112
+ if (!only) return emptyPlate(file.name);
113
+ const { tree, css } = classify(only.rules, only.tree, file.name);
114
+ return {
115
+ v: 1,
116
+ name: file.name,
117
+ tree,
118
+ css
119
+ };
120
+ }
121
+ const starts = views.map((r) => regimeFor(r.width, file.breakpoints).min ?? 0);
122
+ let nextId = 1;
123
+ const rootKids = [];
124
+ const rules = [];
125
+ views.forEach((view, i) => {
126
+ const idMap = /* @__PURE__ */ new Map();
127
+ const kids = (view.tree.kids ?? []).map((kid) => cloneWithIds(kid, idMap, () => nextId++));
128
+ rootKids.push(...kids);
129
+ rules.push(...remapRules(view.rules, idMap));
130
+ const lo = i === 0 ? 0 : starts[i] ?? 0;
131
+ const hi = i === views.length - 1 ? Number.POSITIVE_INFINITY : starts[i + 1] ?? 0;
132
+ for (const kid of kids) {
133
+ if (lo > 0) rules.push(gate(kid.id, `(max-width: ${lo - .02}px)`));
134
+ if (hi !== Number.POSITIVE_INFINITY) rules.push(gate(kid.id, `(min-width: ${hi}px)`));
135
+ }
136
+ });
137
+ const { tree, css } = classify(rules, {
138
+ id: 0,
139
+ kids: rootKids
140
+ }, file.name);
141
+ return {
142
+ v: 1,
143
+ name: file.name,
144
+ tree,
145
+ css
146
+ };
147
+ }
148
+ function gate(id, condition) {
149
+ return {
150
+ ids: [id],
151
+ decls: ["display: none !important"],
152
+ media: [condition],
153
+ spec: SPEC_GATE,
154
+ order: id
155
+ };
156
+ }
157
+ /** Deep-clone a captured subtree into fresh ids, recording the mapping. */
158
+ function cloneWithIds(node, idMap, allocate) {
159
+ const id = allocate();
160
+ idMap.set(node.id, id);
161
+ const kids = node.kids?.map((kid) => cloneWithIds(kid, idMap, allocate));
162
+ return {
163
+ id,
164
+ ...node.leaf ? { leaf: node.leaf } : {},
165
+ ...node.ref !== void 0 ? { ref: node.ref } : {},
166
+ ...kids ? { kids } : {}
167
+ };
168
+ }
169
+ /** Distinct plate names referenced by the tree's stitches, in first-seen order. */
170
+ function collectRefs(tree) {
171
+ const names = [];
172
+ const seen = /* @__PURE__ */ new Set();
173
+ const visit = (node) => {
174
+ if (node.ref !== void 0 && !seen.has(node.ref)) {
175
+ seen.add(node.ref);
176
+ names.push(node.ref);
177
+ }
178
+ for (const kid of node.kids ?? []) visit(kid);
179
+ };
180
+ if (tree) visit(tree);
181
+ return names;
182
+ }
183
+ function remapRules(rules, idMap) {
184
+ return rules.map((rule) => ({
185
+ ...rule,
186
+ ids: rule.ids.map((id) => idMap.get(id) ?? id)
187
+ }));
188
+ }
189
+ /** Fold a fresh capture into the plate file: union breakpoints, one capture per view. */
190
+ function addCapture(file, name, capture, breakpoints) {
191
+ const allBreakpoints = [...new Set([...file?.breakpoints ?? [], ...breakpoints])].toSorted((a, b) => a - b);
192
+ const spanKey = (width) => JSON.stringify(regimeFor(width, allBreakpoints));
193
+ const views = [...file?.views ?? [], capture];
194
+ const byView = /* @__PURE__ */ new Map();
195
+ for (const view of views) byView.set(spanKey(view.width), view);
196
+ return {
197
+ v: 1,
198
+ name,
199
+ breakpoints: allBreakpoints,
200
+ views: [...byView.values()].toSorted((a, b) => a.width - b.width)
201
+ };
202
+ }
203
+ //#endregion
204
+ //#region src/core.ts
205
+ /**
206
+ * Capture a live DOM subtree straight to a renderable Plate — `capture` followed
207
+ * by `serializePlate` in one call. `roots` are the top-level elements to capture
208
+ * (a component's rendered roots). Pass the result to a `<Skeleton plate>`.
209
+ */
210
+ function captureElement(roots, options) {
211
+ return serializePlate(capture(roots, options));
212
+ }
213
+ /**
214
+ * Render a stitch-free Plate to a self-contained HTML string — the
215
+ * framework-neutral equivalent of `<Skeleton plate loading />`, for when you
216
+ * don't want to pull a framework adapter (demos, plain DOM, server strings).
217
+ * Drop it in with `el.innerHTML = renderPlateHtml(plate)` or React's
218
+ * `dangerouslySetInnerHTML`. The string is self-contained: it carries BASE_CSS
219
+ * + the plate's scoped rules in its own `<style>`. Stitched plates (a nested
220
+ * `<Skeleton>`) need an adapter to mount their child plates, so they throw here.
221
+ */
222
+ function renderPlateHtml(plate) {
223
+ const chunks = plate.chunks;
224
+ if (!chunks) return "";
225
+ if (chunks.length !== 1 || typeof chunks[0] !== "string") throw new Error("xray: renderPlateHtml renders stitch-free plates only — use a framework adapter for stitched plates");
226
+ const className = plate.rootCls ? `${ROOT_CLASS} ${plate.rootCls}` : ROOT_CLASS;
227
+ return `<div ${ROOT_ATTR}="${plate.name}" class="${className}" aria-hidden="true" aria-busy="true"><style>${BASE_CSS}\n${plate.css}</style>${chunks[0]}</div>`;
228
+ }
229
+ //#endregion
230
+ export { CaptureTooLargeError, addCapture, capture, captureElement, captureRegime, collectRefs, emptyPlate, mergeViews, renderPlateHtml, serializePlate };
package/dist/hud.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ //#region src/hud.d.ts
2
+ /**
3
+ * The opt-in dev HUD (Astro-dev-toolbar-style): a small fixed badge. For each
4
+ * plate it shows every detected width band — captured ones lit, the rest dim —
5
+ * sourced from the committed plate files (via `__XRAY__.coverage`), not the
6
+ * session, so it reflects what's actually on disk.
7
+ *
8
+ * It is also the controller for capture-all-views (ADR 0010): it probes for the
9
+ * companion extension (which announces itself via its content script) and shows
10
+ * a "Capture all views" button when present, or a one-line promotion when not.
11
+ * The sweep itself lives in the dev client (`__XRAY__.captureAllViews`).
12
+ */
13
+ interface HudHandle {
14
+ dispose: () => void;
15
+ }
16
+ declare function installXrayHud(): HudHandle;
17
+ //#endregion
18
+ export { HudHandle, installXrayHud };
package/dist/hud.js ADDED
@@ -0,0 +1,165 @@
1
+ //#region src/hud.ts
2
+ function isExtensionHello(value) {
3
+ return typeof value === "object" && value !== null && "source" in value && value.source === "xray-ext" && "kind" in value && value.kind === "hello";
4
+ }
5
+ /**
6
+ * A labelled checkbox bound to a sticky toggle store; reads the store on build
7
+ * so a console/URL flip stays in sync with the box at the next render.
8
+ */
9
+ function toggleRow(store, text) {
10
+ const label = document.createElement("label");
11
+ label.className = "toggle";
12
+ const checkbox = document.createElement("input");
13
+ checkbox.type = "checkbox";
14
+ checkbox.checked = store.get();
15
+ checkbox.addEventListener("change", () => {
16
+ store.set(checkbox.checked);
17
+ });
18
+ label.append(checkbox, document.createTextNode(text));
19
+ return label;
20
+ }
21
+ /**
22
+ * Expand a plate's coverage into the full set of width bands, flagging which are
23
+ * captured. The breakpoints partition the width axis; a band is covered when a
24
+ * captured span matches it (spans align to band boundaries, ADR 0004).
25
+ */
26
+ function bandsOf({ breakpoints, spans }) {
27
+ const sorted = [...breakpoints].toSorted((a, b) => a - b);
28
+ const bands = [];
29
+ for (let i = 0; i <= sorted.length; i++) bands.push({
30
+ min: i === 0 ? void 0 : sorted[i - 1],
31
+ max: i === sorted.length ? void 0 : sorted[i]
32
+ });
33
+ return bands.map((band) => ({
34
+ label: `[${band.min ?? 0},${band.max ?? "∞"})`,
35
+ covered: spans.some((span) => span.min === band.min && span.max === band.max)
36
+ }));
37
+ }
38
+ function installXrayHud() {
39
+ const host = document.createElement("div");
40
+ host.setAttribute("data-xray-hud", "");
41
+ const shadow = host.attachShadow({ mode: "open" });
42
+ const style = document.createElement("style");
43
+ style.textContent = `
44
+ .hud {
45
+ position: fixed; bottom: 12px; right: 12px; z-index: 2147483647;
46
+ background: #1c1c1f; color: #e4e4e7; border: 1px solid #3f3f46;
47
+ border-radius: 8px; padding: 8px 10px; max-width: 340px;
48
+ font: 11px/1.5 ui-monospace, monospace; opacity: 0.92;
49
+ }
50
+ .title { font-weight: 700; margin-bottom: 2px; color: #a1a1aa }
51
+ .plate { display: flex; gap: 8px; justify-content: space-between }
52
+ .bands { text-align: right }
53
+ .band.covered { color: #4ade80 }
54
+ .band.missing { color: #52525b }
55
+ .toggle { display: flex; gap: 6px; align-items: center; cursor: pointer; user-select: none }
56
+ .toggle input { accent-color: #4ade80; margin: 0 }
57
+ .action {
58
+ display: block; width: 100%; margin: 6px 0 2px; padding: 5px 8px;
59
+ border: 0; border-radius: 5px; background: #4ade80; color: #0a0a0a;
60
+ font: inherit; font-weight: 700; cursor: pointer;
61
+ }
62
+ .action:disabled { opacity: 0.6; cursor: default }
63
+ .promo { margin: 6px 0 2px; color: #a1a1aa }
64
+ .promo a { color: #4ade80 }
65
+ .status { color: #a1a1aa }
66
+ `;
67
+ const panel = document.createElement("div");
68
+ panel.className = "hud";
69
+ shadow.append(style, panel);
70
+ let extensionPresent = false;
71
+ let sweeping = false;
72
+ let sweepStatus = "";
73
+ const render = () => {
74
+ panel.textContent = "";
75
+ const coverage = globalThis.__XRAY__?.coverage?.get();
76
+ const count = coverage?.size ?? 0;
77
+ const title = document.createElement("div");
78
+ title.className = "title";
79
+ title.textContent = count === 0 ? "xray" : `xray — ${count} plate${count > 1 ? "s" : ""}`;
80
+ panel.append(title);
81
+ const capture = globalThis.__XRAY__?.capture;
82
+ if (capture) panel.append(toggleRow(capture, "Capture"));
83
+ const lightbox = globalThis.__XRAY__?.lightbox;
84
+ if (lightbox) panel.append(toggleRow(lightbox, "Light Box"));
85
+ if (globalThis.__XRAY__?.captureAllViews) if (extensionPresent) {
86
+ const button = document.createElement("button");
87
+ button.className = "action";
88
+ button.type = "button";
89
+ button.disabled = sweeping;
90
+ button.textContent = sweeping ? "Capturing all views…" : "Capture all views";
91
+ button.addEventListener("click", () => {
92
+ startSweep();
93
+ });
94
+ panel.append(button);
95
+ if (sweepStatus) {
96
+ const status = document.createElement("div");
97
+ status.className = "status";
98
+ status.textContent = sweepStatus;
99
+ panel.append(status);
100
+ }
101
+ } else {
102
+ const promo = document.createElement("div");
103
+ promo.className = "promo";
104
+ promo.textContent = "Install the xray extension to capture all views in one click.";
105
+ panel.append(promo);
106
+ }
107
+ for (const [name, plateCoverage] of coverage ?? []) {
108
+ const row = document.createElement("div");
109
+ row.className = "plate";
110
+ const label = document.createElement("span");
111
+ label.textContent = name;
112
+ const bands = document.createElement("span");
113
+ bands.className = "bands";
114
+ for (const band of bandsOf(plateCoverage)) {
115
+ const chip = document.createElement("span");
116
+ chip.className = `band ${band.covered ? "covered" : "missing"}`;
117
+ chip.textContent = band.label;
118
+ bands.append(chip, document.createTextNode(" "));
119
+ }
120
+ row.append(label, bands);
121
+ panel.append(row);
122
+ }
123
+ };
124
+ async function startSweep() {
125
+ if (sweeping) return;
126
+ sweeping = true;
127
+ sweepStatus = "";
128
+ render();
129
+ try {
130
+ await globalThis.__XRAY__?.captureAllViews?.();
131
+ sweepStatus = "Done — coverage updated.";
132
+ } catch (error) {
133
+ sweepStatus = `Failed: ${error instanceof Error ? error.message : String(error)}`;
134
+ } finally {
135
+ sweeping = false;
136
+ render();
137
+ }
138
+ }
139
+ const onHudMessage = (event) => {
140
+ if (event.source !== window) return;
141
+ if (isExtensionHello(event.data) && !extensionPresent) {
142
+ extensionPresent = true;
143
+ render();
144
+ }
145
+ };
146
+ window.addEventListener("message", onHudMessage);
147
+ window.postMessage({
148
+ source: "xray",
149
+ kind: "whois"
150
+ }, "*");
151
+ const unsubscribeCoverage = globalThis.__XRAY__?.coverage?.subscribe(render);
152
+ render();
153
+ const mount = () => {
154
+ if (document.body) document.body.append(host);
155
+ else document.addEventListener("DOMContentLoaded", () => document.body.append(host));
156
+ };
157
+ mount();
158
+ return { dispose() {
159
+ window.removeEventListener("message", onHudMessage);
160
+ unsubscribeCoverage?.();
161
+ host.remove();
162
+ } };
163
+ }
164
+ //#endregion
165
+ export { installXrayHud };