dsh-mobilecode 0.11.8 → 0.12.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/README.md +52 -0
- package/lib/client.js +251 -1
- package/lib/config-matrix.js +202 -0
- package/lib/index.js +656 -3
- package/lib/openpencil.js +139 -0
- package/lib/png-decode.js +86 -0
- package/lib/preview-gallery.js +309 -0
- package/lib/reference-workspace.js +283 -0
- package/lib/skill.js +1 -1
- package/lib/visual-compare.js +196 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -374,6 +374,51 @@ say *why* instead of "something failed".
|
|
|
374
374
|
- Tolerant internal callers (boot polling, IME checks) opt back into
|
|
375
375
|
best-effort with an explicit `.catch(() => "")`.
|
|
376
376
|
|
|
377
|
+
## Design workbench (0.12 — native Android UI design & verification)
|
|
378
|
+
|
|
379
|
+
Reusable capabilities for the loop *explore visual directions → approve a
|
|
380
|
+
design → implement native Compose → render → compare → test on Android →
|
|
381
|
+
refine*. App-specific decisions (learning flow, approved directions) live in
|
|
382
|
+
the app project, never hardcoded here.
|
|
383
|
+
|
|
384
|
+
- **Reference workspace (`preview_gallery`, `config_matrix`, `design_bridge`
|
|
385
|
+
tools + `/api/dsh-mobilecode/refs|preview|matrix|openpencil/*` routes)** —
|
|
386
|
+
import PNG/JPEG/WebP design references with metadata (project, screen,
|
|
387
|
+
state, provenance, approval status); associate device captures (reuses the
|
|
388
|
+
existing screencap path; originals stored verbatim, alignment transforms
|
|
389
|
+
kept separate); persisted under `~/.dsh/mobilecode/reference/` across
|
|
390
|
+
reloads. Header-only decompression fences (no pixel decode server-side, no
|
|
391
|
+
new dependency).
|
|
392
|
+
- **Visual diff (`POST /refs/diff`)** — two modes, never conflated:
|
|
393
|
+
`regression` (any changed pixel fails against a recorded native baseline)
|
|
394
|
+
and `design-reference` (structure comparison, always `needs_review` — the
|
|
395
|
+
tool ran; that is not a visual pass). Client canvas downsamples; the server
|
|
396
|
+
reports concrete changed-region clusters; envelopes persist as evidence
|
|
397
|
+
files. No quality scores anywhere.
|
|
398
|
+
- **Compose preview gallery** — discovers `@PreviewTest` composables of a
|
|
399
|
+
Gradle project and renders them through **official Compose Preview
|
|
400
|
+
Screenshot Testing** (`com.android.compose.screenshot`, pinned
|
|
401
|
+
0.0.1-alpha16, host layoutlib — works on AGP 8.5+ without toolchain
|
|
402
|
+
migration; compose-ai-tools needs AGP 8.13 and is documented as a blocked
|
|
403
|
+
alternative). Failed/stale renders are never labeled current; host-rendered
|
|
404
|
+
≠ device-verified (see `docs/design/render-adapter-decision.md`).
|
|
405
|
+
- **Configuration matrix (`config_matrix` tool)** — bounded (1..8) risk-based
|
|
406
|
+
matrices over real device settings (wm size/density, font scale): every
|
|
407
|
+
case reads back its ACTUAL configuration, captures evidence, and the device
|
|
408
|
+
is restored to its found state (including pre-existing overrides) on
|
|
409
|
+
success, failure and cancellation. Physical devices are refused without
|
|
410
|
+
explicit approval.
|
|
411
|
+
- **OpenPencil bridge (`design_bridge`, optional)** — file-mode CLI adapter:
|
|
412
|
+
inspect `.fig` docs, export a frame to PNG into the reference workspace,
|
|
413
|
+
read design variables. Path-confined (realpath + junction-aware); when the
|
|
414
|
+
CLI is absent everything degrades to honest `not_run` — the workspace stays
|
|
415
|
+
fully usable with manual imports. OpenPencil exports are web-oriented
|
|
416
|
+
(PNG/JSX/HTML); no native Compose export is claimed.
|
|
417
|
+
- **Panel** — the Devices drawer gains two cards: **Reference compare**
|
|
418
|
+
(import / capture / side-by-side / opacity overlay / swipe slider / diff)
|
|
419
|
+
and **Preview gallery** (discovery, thumbnails, stale badges, per-entry
|
|
420
|
+
render).
|
|
421
|
+
|
|
377
422
|
## How it works
|
|
378
423
|
|
|
379
424
|
- `lib/device-build.js` — port of `mobilecode/packages/core/src/device-build.ts`:
|
|
@@ -395,6 +440,13 @@ say *why* instead of "something failed".
|
|
|
395
440
|
Pure + injectable clock/RNG, so `test/mesh-hub.mjs` covers it offline.
|
|
396
441
|
- `sdk/mesh-client.mjs` — dependency-free `fetch` client (join/link/send/
|
|
397
442
|
poll/inbox/leave) for game code running inside the emulators.
|
|
443
|
+
- Design workbench modules: `lib/reference-workspace.js` (reference/capture
|
|
444
|
+
store + header-only image fences), `lib/visual-compare.js` (pure RGBA diff
|
|
445
|
+
core — the server never pixel-decodes), `lib/preview-gallery.js` (@PreviewTest
|
|
446
|
+
discovery + Gradle render driver with timeout tree-kill),
|
|
447
|
+
`lib/config-matrix.js` (bounded matrix runner, snapshot→apply→confirm→
|
|
448
|
+
capture→restore on every path), `lib/openpencil.js` (optional CLI bridge,
|
|
449
|
+
realpath/junction-confined).
|
|
398
450
|
- `lib/setup.js` — settings store (~/.dsh/mobilecode/settings.json), the plugin
|
|
399
451
|
doctor (health checks + auto-fix), and the detached PaddleOCR installer
|
|
400
452
|
(writes an install script to disk and spawns it via cmd.exe, so the install
|
package/lib/client.js
CHANGED
|
@@ -198,7 +198,7 @@ window.__ModuleLoader__.load({
|
|
|
198
198
|
// Shown on both stream captions so a refresh visibly proves which client
|
|
199
199
|
// bundle the browser is actually running. Kept in lockstep with
|
|
200
200
|
// package.json by test/client.mjs.
|
|
201
|
-
const MC_VERSION = "0.11.
|
|
201
|
+
const MC_VERSION = "0.11.9";
|
|
202
202
|
// The panel shows cards for platforms the host can actually drive; the
|
|
203
203
|
// server reports its OS in info.os (iOS tooling is darwin-only).
|
|
204
204
|
const fallbackPlatforms = (os) => (os === "darwin" ? ["ios", "android"] : ["android"]);
|
|
@@ -354,6 +354,253 @@ window.__ModuleLoader__.load({
|
|
|
354
354
|
const readDir = () => { try { return localStorage.getItem("dsh-mobilecode-directory") ?? ""; } catch { return ""; } };
|
|
355
355
|
const writeDir = (value) => { try { localStorage.setItem("dsh-mobilecode-directory", value); } catch { /* private mode */ } };
|
|
356
356
|
|
|
357
|
+
function RefCompareSection({ directory }) {
|
|
358
|
+
const [project, setProject] = useState(() => {
|
|
359
|
+
const base = String(directory || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
|
|
360
|
+
return base || "trachtenberg";
|
|
361
|
+
});
|
|
362
|
+
const [screen, setScreen] = useState("practice");
|
|
363
|
+
const [stateId, setStateId] = useState("default");
|
|
364
|
+
const [refs, setRefs] = useState([]);
|
|
365
|
+
const [activeId, setActiveId] = useState(null);
|
|
366
|
+
const [opacity, setOpacity] = useState(55);
|
|
367
|
+
const [swipe, setSwipe] = useState(50);
|
|
368
|
+
const [error, setError] = useState("");
|
|
369
|
+
const [busy, setBusy] = useState("");
|
|
370
|
+
const [pair, setPair] = useState(null);
|
|
371
|
+
const [pairVersion, setPairVersion] = useState(0);
|
|
372
|
+
|
|
373
|
+
const loadRefs = async (proj) => {
|
|
374
|
+
try {
|
|
375
|
+
const data = await apiGet("/refs?project=" + encodeURIComponent(proj ?? project));
|
|
376
|
+
setRefs(data.records ?? []);
|
|
377
|
+
setActiveId(data.active?.id ?? null);
|
|
378
|
+
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
379
|
+
};
|
|
380
|
+
const loadPair = async () => {
|
|
381
|
+
try {
|
|
382
|
+
const data = await apiGet("/refs/compare?project=" + encodeURIComponent(project) +
|
|
383
|
+
"&screen=" + encodeURIComponent(screen) + "&stateId=" + encodeURIComponent(stateId));
|
|
384
|
+
setPair(data);
|
|
385
|
+
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
386
|
+
};
|
|
387
|
+
useEffect(() => { loadRefs(project); }, [project]);
|
|
388
|
+
useEffect(() => { loadPair(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, [project, screen, stateId, activeId, pairVersion]);
|
|
389
|
+
|
|
390
|
+
const run = async (name, fn) => {
|
|
391
|
+
setBusy(name); setError("");
|
|
392
|
+
try { await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
393
|
+
finally { setBusy(""); }
|
|
394
|
+
};
|
|
395
|
+
const importFile = async (file) => {
|
|
396
|
+
if (!file) return;
|
|
397
|
+
const data = await new Promise((resolve, reject) => {
|
|
398
|
+
const reader = new FileReader();
|
|
399
|
+
reader.onload = () => resolve(reader.result);
|
|
400
|
+
reader.onerror = () => reject(reader.error);
|
|
401
|
+
reader.readAsDataURL(file);
|
|
402
|
+
});
|
|
403
|
+
await run("import", async () => {
|
|
404
|
+
await streamPost("/refs/import", { project, screen, stateId, filename: file.name, data, provenance: "manual-import", intendedUse: "compare" });
|
|
405
|
+
await loadRefs(project); setPairVersion((v) => v + 1);
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
const capture = async () => {
|
|
409
|
+
await run("capture", async () => {
|
|
410
|
+
await streamPost("/refs/capture", { project, screen, stateId });
|
|
411
|
+
await loadRefs(project); setPairVersion((v) => v + 1);
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
const setActive = async (id) => {
|
|
415
|
+
await run("active", async () => {
|
|
416
|
+
await streamPost("/refs/active", { id });
|
|
417
|
+
setActiveId(id); setPairVersion((v) => v + 1);
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
const remove = async (id) => {
|
|
421
|
+
await run("remove", async () => {
|
|
422
|
+
await streamPost("/refs/remove", { id });
|
|
423
|
+
await loadRefs(project); setPairVersion((v) => v + 1);
|
|
424
|
+
});
|
|
425
|
+
};
|
|
426
|
+
const [diff, setDiff] = useState(null);
|
|
427
|
+
const decodeFrame = async (src, maxDim = 480) => {
|
|
428
|
+
const img = new Image();
|
|
429
|
+
img.crossOrigin = "anonymous";
|
|
430
|
+
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error("image decode failed: " + src)); img.src = src; });
|
|
431
|
+
if (img.naturalWidth * img.naturalHeight > 24_000_000) throw new Error("image exceeds 24MP decode guard");
|
|
432
|
+
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
|
|
433
|
+
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
|
434
|
+
const hgt = Math.max(1, Math.round(img.naturalHeight * scale));
|
|
435
|
+
const canvas = document.createElement("canvas");
|
|
436
|
+
canvas.width = w; canvas.height = hgt;
|
|
437
|
+
const ctx2d = canvas.getContext("2d", { willReadFrequently: true });
|
|
438
|
+
ctx2d.drawImage(img, 0, 0, w, hgt);
|
|
439
|
+
return { width: w, height: hgt, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, rgba: ctx2d.getImageData(0, 0, w, hgt).data };
|
|
440
|
+
};
|
|
441
|
+
const runDiff = async (fullRes) => {
|
|
442
|
+
if (!refSrc || !capSrc) { setError("need both a reference and a capture to diff"); return; }
|
|
443
|
+
await run("diff", async () => {
|
|
444
|
+
// Regression needs NATIVE frames: decode both at full resolution and
|
|
445
|
+
// require equal original dimensions — the server re-checks them against
|
|
446
|
+
// the stored records before any "passed" is possible. Different aspects
|
|
447
|
+
// stay triage-only (downsampled, needs_review) — never a silent stretch.
|
|
448
|
+
const sameSize = fullRes === true;
|
|
449
|
+
const [a, b] = sameSize
|
|
450
|
+
? await Promise.all([decodeFrame(refSrc, Infinity), decodeFrame(capSrc, Infinity)])
|
|
451
|
+
: await Promise.all([decodeFrame(refSrc, 480), decodeFrame(capSrc, 480)]);
|
|
452
|
+
if (a.width !== b.width || a.height !== b.height) throw new Error("frame sizes differ (" + a.width + "x" + a.height + " vs " + b.width + "x" + b.height + ") — aspects differ; only downsampled triage applies, record a transform for aligned comparison");
|
|
453
|
+
const toB64 = (f) => { let s = ""; const bytes = f.rgba; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); };
|
|
454
|
+
const r = await streamPost("/refs/diff", {
|
|
455
|
+
a: { width: a.width, height: a.height, rgba: toB64(a) },
|
|
456
|
+
b: { width: b.width, height: b.height, rgba: toB64(b) },
|
|
457
|
+
mode: sameSize ? "regression" : "design-reference", project, screen, stateId,
|
|
458
|
+
referenceId: pair?.reference?.id, captureId: pair?.capture?.id,
|
|
459
|
+
});
|
|
460
|
+
setDiff(r);
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const imgUrl = (record) => record ? API_BASE + "/refs/image?id=" + encodeURIComponent(record.id) : null;
|
|
465
|
+
const refSrc = imgUrl(pair?.reference ?? refs.find((r) => r.id === activeId));
|
|
466
|
+
const capSrc = imgUrl(pair?.capture);
|
|
467
|
+
// native regression only makes sense between IDENTICAL source sizes
|
|
468
|
+
const sizeMatch = !!pair?.reference && !!pair?.capture &&
|
|
469
|
+
pair.reference.width === pair.capture.width && pair.reference.height === pair.capture.height;
|
|
470
|
+
const box = { position: "relative", width: "100%", aspectRatio: "9/19.5", background: "#0b0f14", borderRadius: 8, overflow: "hidden" };
|
|
471
|
+
const fit = { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain" };
|
|
472
|
+
|
|
473
|
+
return h("div", { className: "mc-card", style: { borderTop: "1px solid var(--mc-border, #2a3340)" } },
|
|
474
|
+
h("div", { className: "mc-card-head" }, h("span", null, "Reference compare"),
|
|
475
|
+
h("span", { className: "sp" }), h("span", { style: { opacity: 0.6, fontSize: 11 } }, busy || "slice")),
|
|
476
|
+
h("div", { className: "mc-row" },
|
|
477
|
+
h("input", { className: "mc-input", value: project, placeholder: "project", onChange: (e) => setProject(e.target.value), style: { flex: 1 } }),
|
|
478
|
+
),
|
|
479
|
+
h("div", { className: "mc-row" },
|
|
480
|
+
h("input", { className: "mc-input", value: screen, placeholder: "screen", onChange: (e) => setScreen(e.target.value), style: { flex: 1 } }),
|
|
481
|
+
h("input", { className: "mc-input", value: stateId, placeholder: "state", onChange: (e) => setStateId(e.target.value), style: { flex: 1 } }),
|
|
482
|
+
),
|
|
483
|
+
h("div", { className: "mc-row" },
|
|
484
|
+
h("label", { className: "mc-btn" },
|
|
485
|
+
busy !== "" ? "Importing…" : "Import reference",
|
|
486
|
+
h("input", { type: "file", accept: "image/png,image/jpeg,image/webp", style: { display: "none" }, onChange: (e) => importFile(e.target.files?.[0]) }),
|
|
487
|
+
),
|
|
488
|
+
h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: capture }, "Capture screen"),
|
|
489
|
+
h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc, title: "reduced-resolution structure triage — needs_review only, can never pass regression", onClick: () => runDiff(false) }, "Diff (triage)"),
|
|
490
|
+
h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc || !sizeMatch, title: sizeMatch ? "full-resolution native comparison — regression verdict" : "source sizes differ — a native regression comparison is invalid between different configurations", onClick: () => runDiff(true) }, "Regression (full-res)"),
|
|
491
|
+
),
|
|
492
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
493
|
+
h("div", { style: { position: "relative" }, onMouseMove: undefined },
|
|
494
|
+
(refSrc || capSrc)
|
|
495
|
+
? h("div", { style: box },
|
|
496
|
+
refSrc && h("img", { src: refSrc, style: fit, alt: "reference" }),
|
|
497
|
+
capSrc && h("img", { src: capSrc, style: { ...fit, clipPath: "inset(0 " + (100 - swipe) + "% 0 0)", opacity: opacity / 100 } },
|
|
498
|
+
/* capture overlaid on reference */),
|
|
499
|
+
h("div", { style: { position: "absolute", left: swipe + "%", top: 0, bottom: 0, width: 1, background: "#fff" } }),
|
|
500
|
+
)
|
|
501
|
+
: h("div", { className: "mc-empty" }, "Import a reference and capture the screen to compare."),
|
|
502
|
+
(refSrc || capSrc) && h("div", { className: "mc-row", style: { marginTop: 6, gap: 8 } },
|
|
503
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: 4, fontSize: 11 } },
|
|
504
|
+
"opacity", h("input", { type: "range", min: 0, max: 100, value: opacity, onChange: (e) => setOpacity(Number(e.target.value)) })),
|
|
505
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: 4, fontSize: 11 } },
|
|
506
|
+
"swipe", h("input", { type: "range", min: 0, max: 100, value: swipe, onChange: (e) => setSwipe(Number(e.target.value)) })),
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
h("div", { className: "mc-status" }, h("span", { style: { fontSize: 11, opacity: 0.7 } },
|
|
510
|
+
"reference " + (refSrc ? "✓" : "—") + " · capture " + (capSrc ? "✓" : "—") +
|
|
511
|
+
(pair?.reference && pair?.capture ? " · sources " + pair.reference.width + "x" + pair.reference.height + " vs " + pair.capture.width + "x" + pair.capture.height + (sizeMatch ? " (full-res regression available)" : " (triage only)") : "") +
|
|
512
|
+
" · persisted in ~/.dsh/mobilecode/reference (survives reloads)")),
|
|
513
|
+
diff && h("div", { style: { border: "1px solid var(--mc-border, #2a3340)", borderRadius: 8, padding: 8, fontSize: 11 } },
|
|
514
|
+
h("div", null, h("b", null, "diff · " + (diff.mode ?? "—") + " · " + (diff.comparisonSpace ?? "—") + " · " + diff.checkStatus),
|
|
515
|
+
diff.diff ? h("span", { style: { opacity: 0.7 } }, " " + diff.diff.changePct + "% changed (" + diff.diff.changedPixels + "/" + diff.diff.totalPixels + " px @ threshold L1>" + diff.diff.threshold + "; " + diff.diff.clusterFilter.droppedPixels + " px in sub-minimum clusters counted in verdict)")
|
|
516
|
+
: h("span", { style: { opacity: 0.7 } }, " " + (diff.reason ?? ""))),
|
|
517
|
+
(diff.diff?.clusters ?? []).slice(0, 4).map((c, i) => h("div", { key: c.x + "," + c.y, style: { opacity: 0.85 } },
|
|
518
|
+
"region cmp " + c.x + "," + c.y + " " + c.width + "×" + c.height + " (" + c.pixels + " px)" +
|
|
519
|
+
(diff.regionsA?.[i] ? " · ref-original " + diff.regionsA[i].x + "," + diff.regionsA[i].y + " " + diff.regionsA[i].width + "×" + diff.regionsA[i].height : "") +
|
|
520
|
+
(diff.regionsB?.[i] ? " · cap-original " + diff.regionsB[i].x + "," + diff.regionsB[i].y + " " + diff.regionsB[i].width + "×" + diff.regionsB[i].height : ""))),
|
|
521
|
+
(diff.env?.masks ?? []).length > 0 && h("div", { style: { opacity: 0.85 } }, "masks: " + diff.env.masks.map((m) => m.reason).join(" | ")),
|
|
522
|
+
h("div", { style: { opacity: 0.6 } }, diff.note ?? ""),
|
|
523
|
+
(diff.limitations ?? []).map((l) => h("div", { key: l, style: { opacity: 0.6 } }, "· " + l)),
|
|
524
|
+
diff.evidenceFile && h("div", { style: { opacity: 0.6 } }, "evidence: " + diff.evidenceFile)),
|
|
525
|
+
refs.length > 0 && h("div", { style: { marginTop: 6, display: "flex", flexWrap: "wrap", gap: 6 } },
|
|
526
|
+
refs.map((record) => h("span", {
|
|
527
|
+
key: record.id,
|
|
528
|
+
title: (record.provenance ?? "") + " · " + record.importedAt,
|
|
529
|
+
onClick: () => setActive(record.id),
|
|
530
|
+
style: {
|
|
531
|
+
border: "1px solid " + (record.id === activeId ? "#5aa2ff" : "#2a3340"),
|
|
532
|
+
borderRadius: 6, padding: "2px 8px", fontSize: 11, cursor: "pointer",
|
|
533
|
+
background: record.id === activeId ? "rgba(90,162,255,0.15)" : "transparent",
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
(record.kind === "capture" ? "📷 " : "🖼 ") + record.screen + "/" + record.stateId,
|
|
537
|
+
record.id === activeId && h("button", {
|
|
538
|
+
style: { border: 0, background: "none", cursor: "pointer", marginLeft: 6, color: "#ff7a7a" },
|
|
539
|
+
onClick: (e) => { e.stopPropagation(); remove(record.id); },
|
|
540
|
+
}, "✕"))),
|
|
541
|
+
),
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function PreviewGallerySection({ directory }) {
|
|
546
|
+
const [entries, setEntries] = useState([]);
|
|
547
|
+
const [warnings, setWarnings] = useState([]);
|
|
548
|
+
const [gradle, setGradle] = useState(false);
|
|
549
|
+
const [busy, setBusy] = useState("");
|
|
550
|
+
const [error, setError] = useState("");
|
|
551
|
+
const [note, setNote] = useState("");
|
|
552
|
+
|
|
553
|
+
const load = async (dir) => {
|
|
554
|
+
const target = dir ?? directory;
|
|
555
|
+
if (!target) { setEntries([]); setGradle(false); return; }
|
|
556
|
+
try {
|
|
557
|
+
const data = await apiGet("/preview/list?directory=" + encodeURIComponent(target));
|
|
558
|
+
setGradle(!!data.gradle);
|
|
559
|
+
setEntries(data.entries ?? []);
|
|
560
|
+
setWarnings(data.warnings ?? []);
|
|
561
|
+
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
562
|
+
};
|
|
563
|
+
useEffect(() => { load(); }, [directory]);
|
|
564
|
+
|
|
565
|
+
const renderOne = async (entry) => {
|
|
566
|
+
setBusy(entry.fqClass + "." + entry.method); setError(""); setNote("");
|
|
567
|
+
try {
|
|
568
|
+
const r = await streamPost("/preview/render", { directory, class: entry.fqClass, method: entry.method });
|
|
569
|
+
if (r.status === "passed") setNote("rendered " + entry.method + (r.provenance?.stale ? " (still stale?)" : ""));
|
|
570
|
+
else setError(entry.method + ": " + (r.failureKind ?? r.status) + " — " + (r.tail ?? []).slice(-2).join(" | "));
|
|
571
|
+
await load();
|
|
572
|
+
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
|
573
|
+
finally { setBusy(""); }
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return h("div", { className: "mc-card" },
|
|
577
|
+
h("div", { className: "mc-card-head" },
|
|
578
|
+
h("span", null, "Preview gallery"),
|
|
579
|
+
h("span", { className: "sp" }),
|
|
580
|
+
h("button", { className: "mc-btn", onClick: () => load() }, "Refresh"),
|
|
581
|
+
),
|
|
582
|
+
!gradle && h("div", { className: "mc-empty" }, "Pick a Gradle project directory to discover @PreviewTest composables."),
|
|
583
|
+
gradle && entries.length === 0 && h("div", { className: "mc-empty" }, "No @PreviewTest previews yet — apply the screenshot adapter (docs/design/render-adapter-decision.md)."),
|
|
584
|
+
error !== "" && h("div", { className: "mc-error" }, error),
|
|
585
|
+
note !== "" && h("div", { className: "mc-status" }, h("span", { style: { fontSize: 11 } }, note)),
|
|
586
|
+
warnings.map((w) => h("div", { className: "mc-error", key: w }, "discovery: " + w)),
|
|
587
|
+
entries.length > 0 && h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: 8 } },
|
|
588
|
+
entries.map((entry) => h("div", { key: entry.fqClass + "." + entry.method, style: { border: "1px solid var(--mc-border, #2a3340)", borderRadius: 8, padding: 6, fontSize: 11 } },
|
|
589
|
+
entry.rendered
|
|
590
|
+
? h("img", { src: API_BASE + "/preview/image?directory=" + encodeURIComponent(directory) + "&class=" + encodeURIComponent(entry.fqClass) + "&method=" + encodeURIComponent(entry.method), alt: entry.previewName, style: { width: "100%", borderRadius: 4, display: "block" } })
|
|
591
|
+
: h("div", { style: { height: 150, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 } }, "not rendered"),
|
|
592
|
+
h("div", { style: { marginTop: 4, fontWeight: 600 } }, entry.previewName),
|
|
593
|
+
entry.rendered && entry.freshness !== "fresh_tracked_inputs" && h("span", { style: { color: "#ffb020" } },
|
|
594
|
+
entry.freshness === "unknown" ? "freshness unknown (no manifest)" : entry.freshness === "last_run_failed" ? "last render failed" : "stale — tracked inputs changed"),
|
|
595
|
+
h("button", { className: "mc-btn", disabled: busy !== "", style: { width: "100%", marginTop: 4 }, onClick: () => renderOne(entry) },
|
|
596
|
+
busy === entry.fqClass + "." + entry.method ? "rendering…" : "Render"),
|
|
597
|
+
)),
|
|
598
|
+
),
|
|
599
|
+
entries.length > 0 && h("div", { className: "mc-status" },
|
|
600
|
+
h("span", { style: { fontSize: 11, opacity: 0.7 } }, "host-rendered (layoutlib) — NOT device-verified · failed/stale renders never labeled current")),
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
357
604
|
function MobileCodePanel({ controller }) {
|
|
358
605
|
const [directory, setDirectory] = useState(readDir);
|
|
359
606
|
const [info, setInfo] = useState(null);
|
|
@@ -533,6 +780,8 @@ window.__ModuleLoader__.load({
|
|
|
533
780
|
),
|
|
534
781
|
info?.servers?.length === 0 && info?.builds?.length === 0 && (info?.deviceCount ?? 0) === 0 &&
|
|
535
782
|
h("div", { className: "mc-empty" }, "Nothing running yet — pick a project directory and press Run app, or boot an AVD in Device 1 above."),
|
|
783
|
+
h(RefCompareSection, { directory: info?.directory ?? "" }),
|
|
784
|
+
h(PreviewGallerySection, { directory: info?.directory ?? "" }),
|
|
536
785
|
),
|
|
537
786
|
h("div", { className: "mc-footer" },
|
|
538
787
|
h("span", null, "dsh-mobilecode · polls every 2s"),
|
|
@@ -1607,3 +1856,4 @@ window.__ModuleLoader__.load({
|
|
|
1607
1856
|
return module.exports;
|
|
1608
1857
|
}
|
|
1609
1858
|
});
|
|
1859
|
+
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — bounded configuration matrix runner (T7m/Feature F).
|
|
3
|
+
*
|
|
4
|
+
* Drives REAL Android configuration (wm size/density overrides + font scale)
|
|
5
|
+
* through the existing adb plumbing, captures evidence through the
|
|
6
|
+
* reference-workspace capture store, and RESTORES the device to the state it
|
|
7
|
+
* was FOUND in — including pre-existing overrides — on success, failure and
|
|
8
|
+
* cancellation paths.
|
|
9
|
+
*
|
|
10
|
+
* Honesty rules:
|
|
11
|
+
* - requested config ≠ actual config: every case queries the device
|
|
12
|
+
* (wm size / wm density / settings get font_scale) and reports what it
|
|
13
|
+
* ACTUALLY ended up with;
|
|
14
|
+
* - scaling the browser pane is NOT an Android configuration change and is
|
|
15
|
+
* never represented here;
|
|
16
|
+
* - physical devices are refused without an explicit approval flag;
|
|
17
|
+
* - font behavior is read back from the platform — no linear px↔sp formula.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { tmpdir } from "node:os"
|
|
21
|
+
import * as DeviceBuild from "./device-build.js"
|
|
22
|
+
|
|
23
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
24
|
+
|
|
25
|
+
/** emulator-* serials are safe to reconfigure; physical hardware is not, without approval. */
|
|
26
|
+
export function isEmulatorSerial(serial) {
|
|
27
|
+
return typeof serial === "string" && serial.startsWith("emulator-")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Parse `wm size`/`wm density` output into effective value + whether an override was in place.
|
|
31
|
+
* Keys are prefixed per-kind (sizeOverridden/densityOverridden) so spreading the two parses
|
|
32
|
+
* cannot clobber each other's override flag (audit P4-1: both previously used `overridden`). */
|
|
33
|
+
export function parseDisplaySetting(out, kind) {
|
|
34
|
+
const text = String(out ?? "")
|
|
35
|
+
const flag = kind === "size" ? "sizeOverridden" : "densityOverridden"
|
|
36
|
+
const override = kind === "size"
|
|
37
|
+
? /Override size:\s*(\d+)x(\d+)/.exec(text)
|
|
38
|
+
: /Override density:\s*(\d+)/.exec(text)
|
|
39
|
+
if (override) {
|
|
40
|
+
return kind === "size"
|
|
41
|
+
? { [flag]: true, widthPx: Number(override[1]), heightPx: Number(override[2]) }
|
|
42
|
+
: { [flag]: true, densityDpi: Number(override[1]) }
|
|
43
|
+
}
|
|
44
|
+
const physical = kind === "size"
|
|
45
|
+
? /Physical size:\s*(\d+)x(\d+)/.exec(text)
|
|
46
|
+
: /Physical density:\s*(\d+)/.exec(text)
|
|
47
|
+
if (!physical) return {}
|
|
48
|
+
return kind === "size"
|
|
49
|
+
? { [flag]: false, widthPx: Number(physical[1]), heightPx: Number(physical[2]) }
|
|
50
|
+
: { [flag]: false, densityDpi: Number(physical[1]) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readConfig(serial) {
|
|
54
|
+
const [sizeOut, densityOut, fontOut] = await Promise.all([
|
|
55
|
+
DeviceBuild.adbRun(serial, ["shell", "wm", "size"]).catch(() => ""),
|
|
56
|
+
DeviceBuild.adbRun(serial, ["shell", "wm", "density"]).catch(() => ""),
|
|
57
|
+
DeviceBuild.adbRun(serial, ["shell", "settings", "get", "system", "font_scale"]).catch(() => ""),
|
|
58
|
+
])
|
|
59
|
+
const fontRaw = String(fontOut ?? "").trim()
|
|
60
|
+
const fontScale = fontRaw === "" || fontRaw === "null" ? 1 : Number(fontRaw)
|
|
61
|
+
return {
|
|
62
|
+
...parseDisplaySetting(sizeOut, "size"),
|
|
63
|
+
...parseDisplaySetting(densityOut, "density"),
|
|
64
|
+
fontScale: Number.isFinite(fontScale) ? fontScale : 1,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Put one device setting and VERIFY it took effect by reading back (bounded retries). */
|
|
69
|
+
async function setVerified(serial, apply, read, expect, approx = false) {
|
|
70
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
71
|
+
await apply()
|
|
72
|
+
await sleep(approx ? 600 : 300)
|
|
73
|
+
const actual = await read()
|
|
74
|
+
const ok = approx ? Math.abs(actual - expect) < 0.01 : actual === expect
|
|
75
|
+
if (ok) return { ok: true, actual }
|
|
76
|
+
}
|
|
77
|
+
return { ok: false, actual: await read() }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function applyCase(serial, c) {
|
|
81
|
+
if (c.dims) {
|
|
82
|
+
const r = await setVerified(serial,
|
|
83
|
+
() => DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${c.dims[0]}x${c.dims[1]}`]),
|
|
84
|
+
async () => { const s = await readConfig(serial); return s.widthPx },
|
|
85
|
+
c.dims[0])
|
|
86
|
+
if (!r.ok) return { ok: false, step: "size", ...r }
|
|
87
|
+
}
|
|
88
|
+
if (c.density) {
|
|
89
|
+
const r = await setVerified(serial,
|
|
90
|
+
() => DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(c.density)]),
|
|
91
|
+
async () => { const s = await readConfig(serial); return s.densityDpi },
|
|
92
|
+
c.density)
|
|
93
|
+
if (!r.ok) return { ok: false, step: "density", ...r }
|
|
94
|
+
}
|
|
95
|
+
if (c.fontScale !== undefined) {
|
|
96
|
+
const r = await setVerified(serial,
|
|
97
|
+
() => DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(c.fontScale)]),
|
|
98
|
+
async () => { const s = await readConfig(serial); return s.fontScale },
|
|
99
|
+
c.fontScale, true)
|
|
100
|
+
if (!r.ok) return { ok: false, step: "font_scale", ...r }
|
|
101
|
+
}
|
|
102
|
+
await sleep(700) // reflow settle before capture
|
|
103
|
+
return { ok: true }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Restore to the FOUND state: re-apply pre-existing overrides, else reset. */
|
|
107
|
+
export async function restoreConfig(serial, snapshot) {
|
|
108
|
+
const problems = []
|
|
109
|
+
if (snapshot.widthPx !== undefined) {
|
|
110
|
+
try {
|
|
111
|
+
if (snapshot.sizeOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${snapshot.widthPx}x${snapshot.heightPx}`])
|
|
112
|
+
else await DeviceBuild.adbRun(serial, ["shell", "wm", "size", "reset"])
|
|
113
|
+
} catch (e) { problems.push(`size restore failed: ${e}`) }
|
|
114
|
+
}
|
|
115
|
+
if (snapshot.densityDpi !== undefined) {
|
|
116
|
+
try {
|
|
117
|
+
if (snapshot.densityOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(snapshot.densityDpi)])
|
|
118
|
+
else await DeviceBuild.adbRun(serial, ["shell", "wm", "density", "reset"])
|
|
119
|
+
} catch (e) { problems.push(`density restore failed: ${e}`) }
|
|
120
|
+
}
|
|
121
|
+
try { await DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(snapshot.fontScale ?? 1)]) } catch (e) { problems.push(`font_scale restore failed: ${e}`) }
|
|
122
|
+
await sleep(700)
|
|
123
|
+
const after = await readConfig(serial)
|
|
124
|
+
const ok = problems.length === 0 &&
|
|
125
|
+
(snapshot.widthPx === undefined || (after.widthPx === snapshot.widthPx && after.heightPx === snapshot.heightPx)) &&
|
|
126
|
+
(snapshot.densityDpi === undefined || after.densityDpi === snapshot.densityDpi) &&
|
|
127
|
+
Math.abs((after.fontScale ?? 1) - (snapshot.fontScale ?? 1)) < 0.01
|
|
128
|
+
return { ok, problems, actualAfterRestore: after, snapshotWas: snapshot }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Run a bounded matrix (1..8 cases — explicit expansion is the user's call,
|
|
133
|
+
* no uncontrolled Cartesian product). Evidence goes through the capture store.
|
|
134
|
+
*/
|
|
135
|
+
export async function runMatrix({ serial, workspace, project, screen, cases, signal }) {
|
|
136
|
+
if (!isEmulatorSerial(serial)) {
|
|
137
|
+
return { status: "blocked", reason: "physical-device reconfiguration requires explicit approval; refusing", serial, reports: [] }
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(cases) || cases.length === 0 || cases.length > 8) {
|
|
140
|
+
return { status: "blocked", reason: "matrix must declare 1..8 risk-based cases", reports: [] }
|
|
141
|
+
}
|
|
142
|
+
const before = await readConfig(serial)
|
|
143
|
+
// parse keys are already per-kind (sizeOverridden/densityOverridden) — no re-derivation
|
|
144
|
+
const snapshot = { ...before }
|
|
145
|
+
const reports = []
|
|
146
|
+
const warnings = []
|
|
147
|
+
let restoreResult = { ok: false, problems: ["not run"], actualAfterRestore: {} }
|
|
148
|
+
try {
|
|
149
|
+
for (const c of cases) {
|
|
150
|
+
if (signal?.aborted) { reports.push({ id: c.id, status: "not_run", reason: "cancelled before case" }); continue }
|
|
151
|
+
try {
|
|
152
|
+
const applied = await applyCase(serial, c)
|
|
153
|
+
if (!applied.ok) {
|
|
154
|
+
reports.push({ id: c.id, status: "needs_review", step: applied.step, requested: c, actual: await readConfig(serial), warning: "setting did not verify — device reports a different actual configuration" })
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
const actual = await readConfig(serial)
|
|
158
|
+
const png = await DeviceBuild.screenCapture(serial, tmpdir())
|
|
159
|
+
const evidence = png ? workspace.capture({ project, screen, stateId: c.id, pngPath: png, device: { serial, ...actual } }) : undefined
|
|
160
|
+
reports.push({
|
|
161
|
+
id: c.id,
|
|
162
|
+
requested: { dims: c.dims, density: c.density, fontScale: c.fontScale },
|
|
163
|
+
actual,
|
|
164
|
+
evidenceId: evidence?.id,
|
|
165
|
+
evidenceFile: evidence?.file,
|
|
166
|
+
status: evidence ? "captured" : "failed",
|
|
167
|
+
...(evidence ? {} : { error: "no screenshot produced" }),
|
|
168
|
+
})
|
|
169
|
+
} catch (error) {
|
|
170
|
+
reports.push({ id: c.id, status: "failed", error: String(error instanceof Error ? error.message : error) })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
restoreResult = await restoreConfig(serial, snapshot)
|
|
175
|
+
if (!restoreResult.ok) warnings.push(`device settings NOT fully restored — actual after restore: ${JSON.stringify(restoreResult.actualAfterRestore)} problems: ${restoreResult.problems.join("; ")}`)
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
status: signal?.aborted ? "blocked" : "completed",
|
|
179
|
+
serial,
|
|
180
|
+
snapshotBefore: snapshot,
|
|
181
|
+
reports,
|
|
182
|
+
// audit P4-4: restore travels as a real envelope field, not an array property
|
|
183
|
+
// (reports.restore was dropped by JSON serialization)
|
|
184
|
+
restore: restoreResult,
|
|
185
|
+
warnings,
|
|
186
|
+
limitations: ["theme light/dark and orientation rows are app-owned capabilities — this runner covers size/density/font-scale only", "font scale read back from the platform; no px↔sp formula applied"],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Risk-based starter matrix for a phone app from the device's actual state. */
|
|
191
|
+
export function starterCases(actual) {
|
|
192
|
+
const density = actual.densityDpi ?? 420
|
|
193
|
+
const d = density / 160
|
|
194
|
+
const heightPx = actual.heightPx ?? 2400
|
|
195
|
+
const widthPx = actual.widthPx ?? Math.round(411 * d)
|
|
196
|
+
const narrowW = Math.round(320 * d)
|
|
197
|
+
const narrowH = Math.round((heightPx * narrowW) / widthPx)
|
|
198
|
+
return [
|
|
199
|
+
{ id: "narrow-320dp-fs1.0", dims: [narrowW, narrowH], density, fontScale: 1 },
|
|
200
|
+
{ id: "base-fs2.0", dims: [widthPx, heightPx], density, fontScale: 2 },
|
|
201
|
+
]
|
|
202
|
+
}
|