@glasshome/widget-cli 0.13.6 → 0.14.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.
package/bin/cli.ts CHANGED
@@ -29,6 +29,13 @@ Commands:
29
29
  --re-auth discards stored credentials and re-runs device approval
30
30
  preview [name...] Screenshot widgets' examples (light + dark) into preview/
31
31
  --isolate gives every render its own browser process
32
+ --sizes <WxH,...|grid> renders at pixel sizes into preview/sweep/, with a
33
+ contact sheet per example (grid: common dashboard sizes)
34
+ --theme light|dark, --example <n,...> narrow the render
35
+ --config <json> merges over the example's config
36
+ --service <domain.service|entity_id|json> changes demo state first (repeatable)
37
+ --at <iso time> sets the clock, --click <selector> taps before the shot
38
+ --eval <expr> prints an expression per render (root = widget shadow root)
32
39
  validate [name] Validate all widgets or a specific one
33
40
  publish [hub-url] Build and publish a widget to Hub
34
41
  login [hub-url] Authenticate with GlassHome Hub
@@ -46,6 +53,8 @@ Examples:
46
53
  glasshome-widget connect http://localhost:3333
47
54
  glasshome-widget preview
48
55
  glasshome-widget preview clock
56
+ glasshome-widget preview light --sizes 150x156,340x242 --theme dark
57
+ glasshome-widget preview clock --at 2026-06-15T21:00:00 --config '{"style":"analog"}'
49
58
  glasshome-widget validate
50
59
  glasshome-widget validate clock
51
60
  glasshome-widget info
@@ -72,6 +81,14 @@ const { values: flags, positionals } = parseArgs({
72
81
  to: { type: "string" },
73
82
  "re-auth": { type: "boolean" },
74
83
  isolate: { type: "boolean" },
84
+ sizes: { type: "string" },
85
+ theme: { type: "string" },
86
+ example: { type: "string" },
87
+ at: { type: "string" },
88
+ config: { type: "string" },
89
+ service: { type: "string", multiple: true },
90
+ click: { type: "string" },
91
+ eval: { type: "string" },
75
92
  dry: { type: "boolean" },
76
93
  help: { type: "boolean", short: "h" },
77
94
  },
@@ -144,8 +161,19 @@ switch (effectiveCommand) {
144
161
  case "preview": {
145
162
  const widgetDir = resolveWidgetDir();
146
163
  await notifySdkUpdate(widgetDir);
147
- const { runPreview } = await import("../src/commands/preview");
148
- await runPreview(widgetDir, args, flags.isolate === true);
164
+ const { runPreview, runSweepPreview, wantsSweep } = await import("../src/commands/preview");
165
+ const sweep = {
166
+ sizes: flags.sizes,
167
+ theme: str(flags.theme),
168
+ example: str(flags.example),
169
+ at: str(flags.at),
170
+ config: str(flags.config),
171
+ service: flags.service?.flatMap((s) => str(s) ?? []),
172
+ click: str(flags.click),
173
+ eval: str(flags.eval),
174
+ };
175
+ if (wantsSweep(sweep)) await runSweepPreview(widgetDir, args, sweep);
176
+ else await runPreview(widgetDir, args, flags.isolate === true);
149
177
  break;
150
178
  }
151
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.13.6",
3
+ "version": "0.14.1",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,11 +28,12 @@
28
28
  "@fontsource-variable/caveat": "^5.2.8",
29
29
  "@fontsource-variable/geist": "^5.2.9",
30
30
  "@fontsource-variable/geist-mono": "^5.2.8",
31
- "@glasshome/sync-layer": "0.8.2",
32
- "@glasshome/widget-contract": "0.2.1",
33
- "@glasshome/widget-sdk": "1.16.0",
31
+ "@glasshome/sync-layer": "0.9.0",
32
+ "@glasshome/widget-contract": "0.3.0",
33
+ "@glasshome/widget-sdk": "1.17.1",
34
34
  "@iconify-json/lucide": "^1.2.0",
35
35
  "@iconify-json/mdi": "^1.2.3",
36
+ "@iconify/utils": "^3.1.4",
36
37
  "@tailwindcss/vite": "^4.1.13",
37
38
  "open": "^11.0.0",
38
39
  "picocolors": "^1.1.1",
@@ -1,6 +1,7 @@
1
1
  import { log } from "@clack/prompts";
2
2
  import color from "picocolors";
3
3
  import { runPreview as capturePreview } from "../preview/capture";
4
+ import { type Box, DEFAULT_SIZES, runSweep, type Theme } from "../preview/sweep";
4
5
  import { withQuietStdout } from "../utils/quiet";
5
6
 
6
7
  /**
@@ -12,7 +13,7 @@ import { withQuietStdout } from "../utils/quiet";
12
13
  * detect it first and give an actionable install hint rather than a raw
13
14
  * module-not-found when it is absent.
14
15
  */
15
- export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
16
+ async function requirePlaywright(): Promise<void> {
16
17
  try {
17
18
  await import("playwright");
18
19
  } catch {
@@ -20,10 +21,14 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
20
21
  log.info(`Install them with:\n ${color.bold("bun add -d playwright && bunx playwright install chromium")}`);
21
22
  process.exit(1);
22
23
  }
24
+ }
23
25
 
24
- const progress = (m: string) => {
25
- process.stdout.write(`${color.gray("│")} ${color.dim(m)}\n`);
26
- };
26
+ const progress = (m: string) => {
27
+ process.stdout.write(`${color.gray("│")} ${color.dim(m)}\n`);
28
+ };
29
+
30
+ export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
31
+ await requirePlaywright();
27
32
 
28
33
  log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
29
34
 
@@ -92,3 +97,86 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
92
97
  // idle forever after the verdict instead of exiting. Leave deliberately.
93
98
  process.exit(hangs.length || integrity.length ? 1 : 0);
94
99
  }
100
+
101
+ export interface SweepFlags {
102
+ sizes?: string | boolean;
103
+ theme?: string;
104
+ example?: string;
105
+ at?: string;
106
+ config?: string;
107
+ service?: string[];
108
+ click?: string;
109
+ eval?: string;
110
+ }
111
+
112
+ export function wantsSweep(flags: SweepFlags): boolean {
113
+ return Object.values(flags).some((v) => v !== undefined && !(Array.isArray(v) && v.length === 0));
114
+ }
115
+
116
+ function fail(message: string): never {
117
+ log.error(message);
118
+ process.exit(1);
119
+ }
120
+
121
+ function parseSizes(value: string | boolean | undefined): Box[] {
122
+ if (value === undefined) return [];
123
+ if (value === "grid") return DEFAULT_SIZES;
124
+ return String(value)
125
+ .split(",")
126
+ .map((s) => {
127
+ const m = s.trim().match(/^(\d+)x(\d+)$/);
128
+ if (!m) fail(`--sizes takes grid, or WIDTHxHEIGHT pairs in pixels like 150x156,340x242 (got "${s}")`);
129
+ return { width: Number(m[1]), height: Number(m[2]) };
130
+ });
131
+ }
132
+
133
+ function parseThemes(value: string | undefined): Theme[] {
134
+ if (!value) return ["light", "dark"];
135
+ const themes = value.split(",");
136
+ for (const t of themes) if (t !== "light" && t !== "dark") fail(`--theme is light, dark or both (got "${t}")`);
137
+ return themes as Theme[];
138
+ }
139
+
140
+ function parseConfig(value: string | undefined): Record<string, unknown> | undefined {
141
+ if (!value) return undefined;
142
+ try {
143
+ return JSON.parse(value);
144
+ } catch {
145
+ fail(`--config must be a JSON object (got ${value})`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Render widgets at chosen pixel sizes, with the config, demo state or clock
151
+ * overridden, into `<project>/preview/sweep/`, plus a contact sheet per example
152
+ * and theme when several sizes are rendered.
153
+ */
154
+ export async function runSweepPreview(cwd: string, names: string[], flags: SweepFlags): Promise<void> {
155
+ await requirePlaywright();
156
+ const at = flags.at ? new Date(flags.at) : undefined;
157
+ if (at && Number.isNaN(at.getTime())) fail(`--at takes an ISO time like 2026-06-15T21:00:00 (got "${flags.at}")`);
158
+ const examples = flags.example ? flags.example.split(",").map(Number) : [];
159
+ if (examples.some((n) => !Number.isInteger(n) || n < 0)) fail("--example takes example numbers, starting at 0");
160
+
161
+ const summary = await withQuietStdout(() =>
162
+ runSweep({
163
+ projectDir: cwd,
164
+ only: names,
165
+ themes: parseThemes(flags.theme),
166
+ examples,
167
+ sizes: parseSizes(flags.sizes),
168
+ at,
169
+ config: parseConfig(flags.config),
170
+ services: flags.service ?? [],
171
+ click: flags.click,
172
+ evaluate: flags.eval,
173
+ onProgress: progress,
174
+ onResult: (line) => process.stdout.write(`${line}\n`),
175
+ }),
176
+ ).catch((err: unknown) => fail(err instanceof Error ? err.message : String(err)));
177
+
178
+ const sheets = summary.sheets ? ` and ${summary.sheets} contact sheet(s)` : "";
179
+ log.success(`Rendered ${summary.shots} image(s)${sheets} into ${color.cyan("preview/sweep/")}`);
180
+ if (summary.failures.length) log.warn(summary.failures.map((f) => ` · ${f}`).join("\n"));
181
+ process.exit(summary.failures.length ? 1 : 0);
182
+ }
@@ -1,9 +1,5 @@
1
- import { copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
3
- import { buildWidgets } from "@glasshome/widget-sdk/vite";
4
- import tailwindcss from "@tailwindcss/vite";
5
- import { createServer } from "vite";
6
- import solid from "vite-plugin-solid";
1
+ import { mkdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
7
3
  import {
8
4
  freezeClock,
9
5
  hashWidgetArtifacts,
@@ -14,6 +10,7 @@ import {
14
10
  withRenderTimeout,
15
11
  withSharedBrowser,
16
12
  } from "./constraints";
13
+ import { serveHarness } from "./serve";
17
14
 
18
15
  const THEMES = ["light", "dark"] as const;
19
16
 
@@ -45,37 +42,17 @@ export interface PreviewOptions {
45
42
 
46
43
  // Filenames become CDN path segments, so keep them to [a-z0-9-]: an example
47
44
  // labelled "Solar, battery and EV" must not put a comma in a URL.
48
- function slug(s: string): string {
45
+ export function slug(s: string): string {
49
46
  return s
50
47
  .toLowerCase()
51
48
  .replace(/[^a-z0-9]+/g, "-")
52
49
  .replace(/^-|-$/g, "");
53
50
  }
54
51
 
55
- // Every ancestor of the project dir, so vite's fs guard allows the harness to
56
- // reach node_modules hoisted anywhere up the workspace (bun hoists widget-cli's
57
- // deps to the project root, which may be several levels above a widget project).
58
- function ancestors(dir: string): string[] {
59
- const out: string[] = [];
60
- let d = dir;
61
- for (;;) {
62
- out.push(d);
63
- const parent = dirname(d);
64
- if (parent === d) break;
65
- d = parent;
66
- }
67
- return out;
68
- }
69
-
70
52
  /**
71
53
  * Build every widget, serve the harness, and screenshot each authored example
72
54
  * in light and dark under the render worker's constraints (frozen clock, DNS
73
55
  * blackhole, per-render timeout, verify-before-execute hash pin).
74
- *
75
- * The vite root is a temp dir created UNDER the project so that: the harness's
76
- * `../dist/*.js` glob resolves to `<projectDir>/dist`, and every bare import
77
- * (@glasshome/*, iconify, solid) resolves from the project's own node_modules —
78
- * a single solid/ui instance, which the built bundle's external imports require.
79
56
  */
80
57
  export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary> {
81
58
  const projectDir = resolve(opts.projectDir);
@@ -83,57 +60,16 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
83
60
  const isolate = opts.isolate ?? false;
84
61
  const progress = opts.onProgress ?? (() => {});
85
62
 
86
- const distDir = resolve(projectDir, "dist");
87
63
  const outDir = resolve(projectDir, "preview");
88
- const harnessSrc = resolve(import.meta.dirname, "harness");
89
- // Temp vite root under the project: keeps node_modules resolution and the
90
- // `../dist` glob pointed at the project, and is removed when the run ends.
91
- const tempRoot = resolve(projectDir, ".glasshome-preview");
92
-
93
- // 1. Build the widget bundles so authored examples land in dist/<name>.js.
94
- progress(only.length ? `Building ${only.join(", ")}...` : "Building widgets...");
95
- process.chdir(projectDir);
96
- await buildWidgets({
97
- srcDir: "src",
98
- outDir: "dist",
99
- ...(only.length ? { only } : {}),
100
- plugins: [solid({ solid: { delegateEvents: false } })],
101
- });
102
-
103
- const widgetNames = readdirSync(distDir)
104
- .filter((f) => f.endsWith(".js"))
105
- .map((f) => f.slice(0, -3))
106
- .filter((n) => (only.length ? only.includes(n) : true))
107
- .sort();
108
-
109
- // 2. Stage the harness into the temp root and serve it.
110
- rmSync(tempRoot, { recursive: true, force: true });
111
- mkdirSync(tempRoot, { recursive: true });
112
- copyFileSync(resolve(harnessSrc, "harness.tsx"), resolve(tempRoot, "harness.tsx"));
113
- copyFileSync(resolve(harnessSrc, "index.html"), resolve(tempRoot, "index.html"));
64
+ const server = await serveHarness(projectDir, only, progress);
65
+ const { base, origin, distDir, widgets: widgetNames } = server;
114
66
 
115
67
  const skipped: string[] = [];
116
68
  let shot = 0;
117
69
  const failures: Failure[] = [];
118
70
  const shotLists = new Map<string, ShotListEntry[]>();
119
71
 
120
- const server = await createServer({
121
- root: tempRoot,
122
- configFile: false,
123
- // delegateEvents: false — widgets mount in closed shadow roots where Solid's
124
- // document-level event delegation cannot see the target (matches the widgets
125
- // build and dash mount). tailwindcss() compiles @glasshome/ui/styles so the
126
- // app theme tokens land on :root exactly as they do in dash.
127
- plugins: [tailwindcss(), solid({ solid: { delegateEvents: false } })],
128
- server: { fs: { allow: [tempRoot, ...ancestors(projectDir)] } },
129
- });
130
-
131
72
  try {
132
- await server.listen();
133
- const base = server.resolvedUrls?.local[0];
134
- if (!base) throw new Error("vite dev server has no local url");
135
- const origin = new URL(base).origin;
136
-
137
73
  mkdirSync(outDir, { recursive: true });
138
74
 
139
75
  // 3. Enumerate each widget's shot list (one locked browser for the whole pass).
@@ -250,9 +186,7 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
250
186
  if (isolate) await runAll(null);
251
187
  else await withSharedBrowser((shared) => runAll(shared));
252
188
  } finally {
253
- await server.close().catch(() => {});
254
- // Temp root is throwaway staging; never leave it in the project tree.
255
- rmSync(tempRoot, { recursive: true, force: true });
189
+ await server.close();
256
190
  }
257
191
 
258
192
  return { shots: shot, widgets: shotLists.size, skipped, failures };
@@ -44,6 +44,10 @@ export const NO_EGRESS_ARGS = [
44
44
  "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost",
45
45
  ];
46
46
 
47
+ /** `CHROMIUM_PATH` points at a system Chromium where Playwright's own build is absent or cannot run. */
48
+ const launchLocked = () =>
49
+ chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined, args: NO_EGRESS_ARGS });
50
+
47
51
  /**
48
52
  * Record (do not intercept) every non-local request the page attempts.
49
53
  *
@@ -153,6 +157,9 @@ function applyNixLdOnce(): void {
153
157
  * it never returns, so the next shot's recycle hangs on it forever. Race the
154
158
  * graceful close against a short deadline, then SIGKILL the process outright.
155
159
  */
160
+ // A still shows the settled state: the theme zeroes transitions under reduced motion.
161
+ const CONTEXT_OPTIONS = { deviceScaleFactor: 2, reducedMotion: "reduce" } as const;
162
+
156
163
  async function hardClose(browser: Browser): Promise<void> {
157
164
  // `process()` exists at runtime on a launched Chromium but is absent from the
158
165
  // base Browser type; narrow it here rather than at the call site.
@@ -181,13 +188,12 @@ export async function withSharedBrowser<T>(fn: (browser: SharedBrowser) => Promi
181
188
  // eventually wedges; recycling keeps cumulative state bounded.
182
189
  const PAGES_PER_BROWSER = 8;
183
190
 
184
- const launch = () => chromium.launch({ args: NO_EGRESS_ARGS });
185
- let browser = await launch();
191
+ let browser = await launchLocked();
186
192
  let served = 0;
187
193
 
188
194
  const recycle = async (): Promise<void> => {
189
195
  await hardClose(browser);
190
- browser = await launch();
196
+ browser = await launchLocked();
191
197
  served = 0;
192
198
  };
193
199
 
@@ -199,14 +205,14 @@ export async function withSharedBrowser<T>(fn: (browser: SharedBrowser) => Promi
199
205
  }
200
206
  served++;
201
207
  try {
202
- const context = await browser.newContext({ deviceScaleFactor: 2 });
208
+ const context = await browser.newContext(CONTEXT_OPTIONS);
203
209
  return await context.newPage();
204
210
  } catch {
205
211
  // Lost between the liveness check and the context: relaunch once so the
206
212
  // crash costs one render rather than the rest of the pass.
207
213
  await recycle();
208
214
  served = 1;
209
- const context = await browser.newContext({ deviceScaleFactor: 2 });
215
+ const context = await browser.newContext(CONTEXT_OPTIONS);
210
216
  return context.newPage();
211
217
  }
212
218
  };
@@ -229,9 +235,9 @@ export async function withFreshBrowser<T>(fn: (page: Page, browser: Browser) =>
229
235
  // point nix-ld at them so the bundled browser launches unmodified.
230
236
  applyNixLdOnce();
231
237
 
232
- const browser = await chromium.launch({ args: NO_EGRESS_ARGS });
238
+ const browser = await launchLocked();
233
239
  try {
234
- const context = await browser.newContext({ deviceScaleFactor: 2 });
240
+ const context = await browser.newContext(CONTEXT_OPTIONS);
235
241
  const page = await context.newPage();
236
242
  return await fn(page, browser);
237
243
  } finally {
@@ -1,10 +1,11 @@
1
1
  import type { ReactiveWidgetContext, WidgetDefinition } from "@glasshome/widget-sdk";
2
2
  import { instantiateWidget } from "@glasshome/widget-sdk/host";
3
- import { claimHostApi, getEntityView } from "@glasshome/sync-layer";
3
+ import { callService, claimHostApi, getEntityView } from "@glasshome/sync-layer";
4
4
  import { byDomain, useAreas, useEntities } from "@glasshome/sync-layer/solid";
5
5
  import type { EntityDataAdapter } from "@glasshome/ui/solid";
6
- import { provideEntityData } from "@glasshome/ui/solid";
6
+ import { provideEntityData, provideIcons } from "@glasshome/ui/solid";
7
7
  import type { IconifyJSON } from "@iconify/types";
8
+ import { getIconData } from "@iconify/utils";
8
9
  // `?url` + runtime fetch, NOT a JSON import. Vite turns an imported JSON file
9
10
  // into an ES module — a multi-megabyte object literal the JS engine has to
10
11
  // evaluate on every page load, which cost ~200s per render. Fetching the same
@@ -43,6 +44,18 @@ async function preloadIcons(): Promise<void> {
43
44
  ]);
44
45
  addCollection(mdi);
45
46
  addCollection(lucide);
47
+ const collections: Record<string, IconifyJSON> = { mdi, lucide };
48
+ provideIcons({
49
+ bundled: {},
50
+ load: async (names) =>
51
+ Object.fromEntries(
52
+ names.map((name) => {
53
+ const [prefix = "", ...rest] = name.split(":");
54
+ const set = collections[prefix];
55
+ return [name, set ? (getIconData(set, rest.join(":")) ?? null) : null];
56
+ }),
57
+ ),
58
+ });
46
59
  }
47
60
 
48
61
  // No network fallback. Dash repoints this provider at its own /iconify/ proxy;
@@ -162,6 +175,10 @@ async function main(): Promise<void> {
162
175
  document.documentElement.dataset.harnessExamples = JSON.stringify(
163
176
  examples.map((e) => ({ label: e.label, size: e.size })),
164
177
  );
178
+ document.documentElement.dataset.harnessBounds = JSON.stringify({
179
+ min: def.manifest.minSize,
180
+ max: def.manifest.maxSize,
181
+ });
165
182
  const example = examples[exIndex];
166
183
  if (!example) {
167
184
  document.title = "harness-error: no example";
@@ -179,10 +196,17 @@ async function main(): Promise<void> {
179
196
  const hostApi = claimHostApi();
180
197
  if (!hostApi) throw new Error("harness: host API already claimed");
181
198
  await hostApi.loadDemoData();
199
+ for (const call of params.getAll("svc")) {
200
+ const [service = "", entityId, data] = call.split("|");
201
+ const [domain = "", name = ""] = service.split(".");
202
+ await callService(domain, name, JSON.parse(data ?? "{}"), { entity_id: entityId });
203
+ }
182
204
 
183
205
  // Background and padding are the renderer's job: the preview is the widget
184
206
  // alone, tight and transparent (omitBackground on capture).
185
- const { width, height } = tilePx(example.size);
207
+ const { width, height } = params.has("pw")
208
+ ? { width: Number(params.get("pw")), height: Number(params.get("ph")) }
209
+ : tilePx(example.size);
186
210
  const stage = document.getElementById("stage");
187
211
  if (!stage) return;
188
212
  stage.style.width = `${width}px`;
@@ -193,8 +217,14 @@ async function main(): Promise<void> {
193
217
  dimensions: () => ({ width, height }),
194
218
  };
195
219
 
196
- mount(stage, def, example.config as Record<string, unknown>, ctx, css);
220
+ const config = {
221
+ ...(example.config as Record<string, unknown>),
222
+ ...JSON.parse(params.get("cfg") ?? "{}"),
223
+ };
224
+ mount(stage, def, config, ctx, css);
197
225
 
226
+ // fonts.ready settles before the shadow root's text is laid out, so a cold page captures the fallback face.
227
+ await Promise.all([...document.fonts].map((face) => face.load().catch(() => undefined)));
198
228
  await document.fonts.ready;
199
229
  // Signal to the capture driver that mount + fonts settled.
200
230
  document.documentElement.setAttribute("data-harness-ready", "1");
@@ -0,0 +1,94 @@
1
+ import { copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { buildWidgets } from "@glasshome/widget-sdk/vite";
4
+ import tailwindcss from "@tailwindcss/vite";
5
+ import { createServer } from "vite";
6
+ import solid from "vite-plugin-solid";
7
+
8
+ export interface HarnessServer {
9
+ base: string;
10
+ origin: string;
11
+ distDir: string;
12
+ widgets: string[];
13
+ close: () => Promise<void>;
14
+ }
15
+
16
+ // Every ancestor of the project dir, so vite's fs guard allows the harness to
17
+ // reach node_modules hoisted anywhere up the workspace (bun hoists widget-cli's
18
+ // deps to the project root, which may be several levels above a widget project).
19
+ function ancestors(dir: string): string[] {
20
+ const out: string[] = [];
21
+ let d = dir;
22
+ for (;;) {
23
+ out.push(d);
24
+ const parent = dirname(d);
25
+ if (parent === d) break;
26
+ d = parent;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Build the widget bundles and serve the render harness over them.
33
+ *
34
+ * The vite root is a temp dir created UNDER the project so that: the harness's
35
+ * `../dist/*.js` glob resolves to `<projectDir>/dist`, and every bare import
36
+ * (@glasshome/*, iconify, solid) resolves from the project's own node_modules —
37
+ * a single solid/ui instance, which the built bundle's external imports require.
38
+ */
39
+ export async function serveHarness(
40
+ projectDir: string,
41
+ only: string[],
42
+ progress: (message: string) => void,
43
+ ): Promise<HarnessServer> {
44
+ const distDir = resolve(projectDir, "dist");
45
+ const harnessSrc = resolve(import.meta.dirname, "harness");
46
+ const tempRoot = resolve(projectDir, ".glasshome-preview");
47
+
48
+ progress(only.length ? `Building ${only.join(", ")}...` : "Building widgets...");
49
+ process.chdir(projectDir);
50
+ await buildWidgets({
51
+ srcDir: "src",
52
+ outDir: "dist",
53
+ ...(only.length ? { only } : {}),
54
+ plugins: [solid({ solid: { delegateEvents: false } })],
55
+ });
56
+
57
+ const widgets = readdirSync(distDir)
58
+ .filter((f) => f.endsWith(".js"))
59
+ .map((f) => f.slice(0, -3))
60
+ .filter((n) => (only.length ? only.includes(n) : true))
61
+ .sort();
62
+
63
+ rmSync(tempRoot, { recursive: true, force: true });
64
+ mkdirSync(tempRoot, { recursive: true });
65
+ copyFileSync(resolve(harnessSrc, "harness.tsx"), resolve(tempRoot, "harness.tsx"));
66
+ copyFileSync(resolve(harnessSrc, "index.html"), resolve(tempRoot, "index.html"));
67
+
68
+ const server = await createServer({
69
+ root: tempRoot,
70
+ configFile: false,
71
+ // delegateEvents: false — widgets mount in closed shadow roots where Solid's
72
+ // document-level event delegation cannot see the target (matches the widgets
73
+ // build and dash mount). tailwindcss() compiles @glasshome/ui/styles so the
74
+ // app theme tokens land on :root exactly as they do in dash.
75
+ plugins: [tailwindcss(), solid({ solid: { delegateEvents: false } })],
76
+ server: { fs: { allow: [tempRoot, ...ancestors(projectDir)] } },
77
+ });
78
+
79
+ const close = async () => {
80
+ await server.close().catch(() => {});
81
+ // Temp root is throwaway staging; never leave it in the project tree.
82
+ rmSync(tempRoot, { recursive: true, force: true });
83
+ };
84
+
85
+ try {
86
+ await server.listen();
87
+ const base = server.resolvedUrls?.local[0];
88
+ if (!base) throw new Error("vite dev server has no local url");
89
+ return { base, origin: new URL(base).origin, distDir, widgets, close };
90
+ } catch (err) {
91
+ await close();
92
+ throw err;
93
+ }
94
+ }
@@ -0,0 +1,244 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { Page } from "playwright";
4
+ import { slug } from "./capture";
5
+ import {
6
+ freezeClock,
7
+ settleAnimations,
8
+ watchEgress,
9
+ withRenderTimeout,
10
+ withSharedBrowser,
11
+ } from "./constraints";
12
+ import { serveHarness } from "./serve";
13
+
14
+ export type Theme = "light" | "dark";
15
+
16
+ export interface Box {
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export interface SweepOptions {
22
+ projectDir: string;
23
+ only: string[];
24
+ themes: Theme[];
25
+ /** Example indexes to render; all when empty. */
26
+ examples: number[];
27
+ /** Pixel boxes to render; each example's own size when empty. */
28
+ sizes: Box[];
29
+ at?: Date;
30
+ config?: Record<string, unknown>;
31
+ /** `domain.service|entity_id|json`, replayed against the demo home before mount. */
32
+ services: string[];
33
+ click?: string;
34
+ evaluate?: string;
35
+ onProgress?: (message: string) => void;
36
+ onResult?: (line: string) => void;
37
+ }
38
+
39
+ export interface SweepSummary {
40
+ shots: number;
41
+ sheets: number;
42
+ outDir: string;
43
+ failures: string[];
44
+ }
45
+
46
+ interface Example {
47
+ label?: string;
48
+ size: { w: number; h: number };
49
+ }
50
+
51
+ interface Bounds {
52
+ min: { w: number; h: number };
53
+ max: { w: number; h: number };
54
+ }
55
+
56
+ export const DEFAULT_SIZES: Box[] = [70, 156, 242, 328].flatMap((height) =>
57
+ [84, 150, 200, 270, 340, 420].map((width) => ({ width, height })),
58
+ );
59
+
60
+ // Inverse of the harness's tilePx height: rows * 70 + (rows - 1) * 16.
61
+ const rowsFor = (height: number) => Math.round((height + 16) / 86);
62
+
63
+ // Roots stay closed as in dash, so a handler that cannot fire there fails here too; --eval and --click reach in through this.
64
+ function keepShadowRoots(): void {
65
+ const attach = Element.prototype.attachShadow;
66
+ Element.prototype.attachShadow = function (init) {
67
+ const root = attach.call(this, init);
68
+ Object.defineProperty(this, "__widgetRoot", { value: root });
69
+ return root;
70
+ };
71
+ }
72
+
73
+ export async function runSweep(opts: SweepOptions): Promise<SweepSummary> {
74
+ const progress = opts.onProgress ?? (() => {});
75
+ const report = opts.onResult ?? (() => {});
76
+ const projectDir = resolve(opts.projectDir);
77
+ const outDir = resolve(projectDir, "preview", "sweep");
78
+ const server = await serveHarness(projectDir, opts.only, progress);
79
+ const failures: string[] = [];
80
+ let shots = 0;
81
+ let sheets = 0;
82
+
83
+ const query = (widget: string, ex: number, theme: Theme, box?: Box) => {
84
+ const q = new URLSearchParams({ widget, ex: String(ex), theme });
85
+ if (box) {
86
+ q.set("pw", String(box.width));
87
+ q.set("ph", String(box.height));
88
+ }
89
+ if (opts.config) q.set("cfg", JSON.stringify(opts.config));
90
+ for (const s of opts.services) q.append("svc", s);
91
+ return `${server.base}?${q}`;
92
+ };
93
+
94
+ const render = async (page: Page, url: string, file: string): Promise<unknown> => {
95
+ watchEgress(page, server.origin);
96
+ await page.addInitScript(keepShadowRoots);
97
+ if (opts.at) await page.clock.install({ time: opts.at });
98
+ else await freezeClock(page);
99
+ await page.goto(url, { waitUntil: "domcontentloaded" });
100
+ await page.waitForSelector("html[data-harness-ready='1']", {
101
+ state: "attached",
102
+ timeout: 20_000,
103
+ });
104
+ await settleAnimations(page);
105
+ if (opts.click) {
106
+ // Playwright's actionability checks wait on animation frames, which a frozen clock never runs.
107
+ await page.clock.resume();
108
+ const at = await page.evaluate((selector) => {
109
+ const stage = document.getElementById("stage") as { __widgetRoot?: ShadowRoot } | null;
110
+ const box = stage?.__widgetRoot?.querySelector(selector)?.getBoundingClientRect();
111
+ return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
112
+ }, opts.click);
113
+ if (!at) throw new Error(`--click: nothing matches ${opts.click} inside the widget`);
114
+ await page.mouse.click(at.x, at.y);
115
+ await page.waitForTimeout(1_500);
116
+ }
117
+ await page.locator("#stage").screenshot({ path: file, omitBackground: true });
118
+ if (!opts.evaluate) return undefined;
119
+ return page.evaluate((code) => {
120
+ const root = (document.getElementById("stage") as { __widgetRoot?: ShadowRoot } | null)
121
+ ?.__widgetRoot;
122
+ return new Function("root", `return (${code});`)(root);
123
+ }, opts.evaluate);
124
+ };
125
+
126
+ try {
127
+ mkdirSync(outDir, { recursive: true });
128
+ await withSharedBrowser(async (browser) => {
129
+ for (const widget of server.widgets) {
130
+ const probe = await browser.newPage();
131
+ let examples: Example[] = [];
132
+ let bounds: Bounds | undefined;
133
+ try {
134
+ await probe.goto(query(widget, 0, "light"), { waitUntil: "domcontentloaded" });
135
+ await probe.waitForSelector("html[data-harness-bounds]", {
136
+ state: "attached",
137
+ timeout: 30_000,
138
+ });
139
+ examples = JSON.parse((await probe.getAttribute("html", "data-harness-examples")) ?? "[]");
140
+ bounds = JSON.parse((await probe.getAttribute("html", "data-harness-bounds")) ?? "null");
141
+ } finally {
142
+ await probe.context().close();
143
+ }
144
+ if (!examples.length || !bounds) {
145
+ failures.push(`${widget}: no examples to render`);
146
+ continue;
147
+ }
148
+ const fits = (box: Box) => {
149
+ const rows = rowsFor(box.height);
150
+ return bounds !== undefined && rows >= bounds.min.h && rows <= bounds.max.h;
151
+ };
152
+
153
+ const indexes = opts.examples.length ? opts.examples : examples.map((_, i) => i);
154
+ for (const ex of indexes) {
155
+ const example = examples[ex];
156
+ if (!example) {
157
+ failures.push(`${widget}: no example ${ex}`);
158
+ continue;
159
+ }
160
+ const label = slug(example.label ?? `example-${ex}`);
161
+ const boxes = opts.sizes.length ? opts.sizes.filter(fits) : [undefined];
162
+ for (const theme of opts.themes) {
163
+ progress(`Rendering ${widget} / ${label} / ${theme}...`);
164
+ const shot: { box: Box | undefined; file: string }[] = [];
165
+ for (const box of boxes) {
166
+ const size = box ? `-${box.width}x${box.height}` : "";
167
+ const file = resolve(outDir, `${widget}-${label}-${theme}${size}.png`);
168
+ const page = await browser.newPage();
169
+ try {
170
+ const result = await withRenderTimeout(`${widget} ${label} ${theme}${size}`, () =>
171
+ render(page, query(widget, ex, theme, box), file),
172
+ );
173
+ shots++;
174
+ shot.push({ box, file });
175
+ if (opts.evaluate) {
176
+ report(`${widget} ${label} ${theme}${size}: ${JSON.stringify(result)}`);
177
+ }
178
+ } catch (err) {
179
+ failures.push(`${widget} ${label} ${theme}${size}: ${(err as Error).message}`);
180
+ await browser.recycle().catch(() => {});
181
+ } finally {
182
+ await page
183
+ .context()
184
+ .close()
185
+ .catch(() => {});
186
+ }
187
+ }
188
+ if (shot.length > 1) {
189
+ const page = await browser.newPage();
190
+ try {
191
+ await writeSheet(page, theme, shot, resolve(outDir, `${widget}-${label}-${theme}.png`));
192
+ sheets++;
193
+ } finally {
194
+ await page
195
+ .context()
196
+ .close()
197
+ .catch(() => {});
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+ });
204
+ } finally {
205
+ await server.close();
206
+ }
207
+
208
+ return { shots, sheets, outDir, failures };
209
+ }
210
+
211
+ // One row per height, widths left to right, each shot at its CSS size.
212
+ async function writeSheet(
213
+ page: Page,
214
+ theme: Theme,
215
+ shots: { box: Box | undefined; file: string }[],
216
+ path: string,
217
+ ): Promise<void> {
218
+ const rows = new Map<number, { box: Box; file: string }[]>();
219
+ for (const s of shots) {
220
+ if (!s.box) continue;
221
+ rows.set(s.box.height, [...(rows.get(s.box.height) ?? []), { box: s.box, file: s.file }]);
222
+ }
223
+ const background =
224
+ theme === "light"
225
+ ? "linear-gradient(135deg,#e9edf3,#cfd8e3)"
226
+ : "linear-gradient(135deg,#1b2230,#0d1117)";
227
+ const body = [...rows.keys()]
228
+ .sort((a, b) => a - b)
229
+ .map((height) => {
230
+ const cells = (rows.get(height) ?? [])
231
+ .sort((a, b) => a.box.width - b.box.width)
232
+ .map(
233
+ ({ box, file }) =>
234
+ `<figure><img width="${box.width}" height="${box.height}" src="data:image/png;base64,${readFileSync(file).toString("base64")}"><figcaption>${box.width}x${box.height}</figcaption></figure>`,
235
+ )
236
+ .join("");
237
+ return `<div class="row">${cells}</div>`;
238
+ })
239
+ .join("");
240
+ await page.setContent(
241
+ `<style>body{margin:0}#sheet{display:inline-block;padding:20px 20px 4px;background:${background};font:12px sans-serif;color:#888}.row{display:flex;gap:16px;align-items:flex-start;margin-bottom:16px}figure{margin:0}img{display:block}</style><div id="sheet">${body}</div>`,
242
+ );
243
+ await page.locator("#sheet").screenshot({ path });
244
+ }