@the-portland-company/shell 0.60.1 → 0.62.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.61.0 — 2026-09-30
2
+
3
+ ### Changed
4
+
5
+ - **Spec drawer shows YOUR view on open.** Opening the drawer now rasterizes the current page in the browser (`modern-screenshot`, DOM → PNG, viewport size, drawer excluded) and sends it as the LIVE side of the compare — your org, data, theme and full frame. No click and no screen-share prompt (the 0.60.0 `getDisplayMedia` path is removed). Status is requested with `&theme=` so Specs renders the design in your theme; the active org name is sent for the label. "Recapture" re-runs it.
6
+
1
7
  ## 0.60.1 — 2026-09-30
2
8
 
3
9
  ### Fixed
@@ -1386,57 +1386,59 @@ function currentChromeTheme() {
1386
1386
  if (root.classList.contains("light") || root.getAttribute("data-theme") === "light") return "light";
1387
1387
  return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
1388
1388
  }
1389
- async function captureMyView(status, orgId) {
1390
- const md = navigator.mediaDevices;
1391
- const stream = await md.getDisplayMedia({
1392
- video: { displaySurface: "browser" },
1393
- audio: false,
1394
- preferCurrentTab: true,
1395
- selfBrowserSurface: "include",
1396
- surfaceSwitching: "exclude"
1389
+ var SPEC_OVERLAY_ROOT_ATTR = "data-spec-overlay-root";
1390
+ async function captureMyView(status, orgId, orgName) {
1391
+ const { domToCanvas } = await import('modern-screenshot');
1392
+ const w = window.innerWidth;
1393
+ const h = window.innerHeight;
1394
+ const theme = currentChromeTheme();
1395
+ const bg = getComputedStyle(document.body).backgroundColor || (theme === "dark" ? "#0b0b0c" : "#ffffff");
1396
+ const full = await domToCanvas(document.body, {
1397
+ width: w,
1398
+ height: h,
1399
+ scale: 1,
1400
+ backgroundColor: bg,
1401
+ // Cross-origin images that refuse CORS render blank instead of failing.
1402
+ fetch: { requestInit: { mode: "cors", credentials: "omit" }, placeholderImage: "" },
1403
+ // Drop the Spec drawer and everything fixed (sidebar, toasts, popovers):
1404
+ // the design is the page body only, so chrome would only add noise.
1405
+ filter: (node) => !(node instanceof Element) || !node.hasAttribute(SPEC_OVERLAY_ROOT_ATTR) && getComputedStyle(node).position !== "fixed",
1406
+ style: { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` }
1397
1407
  });
1398
- try {
1399
- const video = document.createElement("video");
1400
- video.muted = true;
1401
- video.srcObject = stream;
1402
- await video.play();
1403
- await new Promise((r) => setTimeout(r, 600));
1404
- const w = window.innerWidth;
1405
- const h = window.innerHeight;
1406
- const canvas = document.createElement("canvas");
1407
- canvas.width = w;
1408
- canvas.height = h;
1409
- const ctx = canvas.getContext("2d");
1410
- if (!ctx) throw new Error("canvas unavailable");
1411
- ctx.drawImage(video, 0, 0, w, h);
1412
- const blob = await new Promise((r) => canvas.toBlob(r, "image/png"));
1413
- if (!blob) throw new Error("frame encode failed");
1414
- let crop = null;
1415
- const el = status.live_selector ? document.querySelector(status.live_selector) : null;
1416
- if (el) {
1417
- const b = el.getBoundingClientRect();
1418
- const x = Math.max(0, Math.round(b.left));
1419
- const y = Math.max(0, Math.round(b.top));
1420
- crop = { x, y, width: Math.min(w - x, Math.round(b.width)), height: Math.min(h - y, Math.round(b.height)) };
1421
- }
1422
- const form = new FormData();
1423
- form.append("image", blob, "live.png");
1424
- form.append(
1425
- "meta",
1426
- JSON.stringify({ url: window.location.href, theme: currentChromeTheme(), org_id: orgId ?? null, crop })
1427
- );
1428
- const res = await fetch(status.capture_url ?? `${SPECS_ORIGIN}/api/overlay/capture`, {
1429
- method: "POST",
1430
- credentials: "include",
1431
- headers: { "X-Spec-Capture": "1" },
1432
- body: form
1433
- });
1434
- if (!res.ok) throw new Error(`capture ${res.status}: ${(await res.text()).slice(0, 160)}`);
1435
- } finally {
1436
- stream.getTracks().forEach((t) => t.stop());
1408
+ const el = (status.live_selector ? document.querySelector(status.live_selector) : null) ?? document.querySelector("[data-shell-content]");
1409
+ let region = { x: 0, y: 0, width: w, height: h };
1410
+ if (el) {
1411
+ const r = el.getBoundingClientRect();
1412
+ const x = Math.max(0, Math.round(r.left));
1413
+ const y = Math.max(0, Math.round(r.top));
1414
+ region = { x, y, width: Math.min(w - x, Math.round(r.width)), height: Math.min(h - y, Math.round(r.height)) };
1437
1415
  }
1416
+ const out = document.createElement("canvas");
1417
+ out.width = region.width;
1418
+ out.height = region.height;
1419
+ const ctx = out.getContext("2d");
1420
+ if (!ctx) throw new Error("page raster failed");
1421
+ ctx.drawImage(full, region.x, region.y, region.width, region.height, 0, 0, region.width, region.height);
1422
+ const blob = await new Promise((resolve) => out.toBlob(resolve, "image/png"));
1423
+ if (!blob) throw new Error("page raster failed");
1424
+ const form = new FormData();
1425
+ form.append("image", blob, "live.png");
1426
+ form.append(
1427
+ "meta",
1428
+ JSON.stringify({ url: window.location.href, theme, org_id: orgId ?? null, org_name: orgName ?? null, crop: null })
1429
+ );
1430
+ const res = await fetch(status.capture_url ?? `${SPECS_ORIGIN}/api/overlay/capture`, {
1431
+ method: "POST",
1432
+ credentials: "include",
1433
+ headers: { "X-Spec-Capture": "1" },
1434
+ body: form
1435
+ });
1436
+ if (!res.ok) throw new Error(`capture ${res.status}: ${(await res.text()).slice(0, 160)}`);
1438
1437
  }
1439
- function SpecOverlayAction({ orgId } = {}) {
1438
+ function specOverlayStatusPath() {
1439
+ return `/api/overlay/status?url=${encodeURIComponent(window.location.href)}&theme=${currentChromeTheme()}`;
1440
+ }
1441
+ function SpecOverlayAction({ orgId, orgName } = {}) {
1440
1442
  const [access, setAccess] = react.useState(() => readSpecOverlayAccessCache());
1441
1443
  const [status, setStatus] = react.useState(null);
1442
1444
  const [open, setOpen] = react.useState(false);
@@ -1464,37 +1466,41 @@ function SpecOverlayAction({ orgId } = {}) {
1464
1466
  react.useEffect(() => {
1465
1467
  if (!access?.allowed || typeof window === "undefined") return;
1466
1468
  const timer = window.setTimeout(() => {
1467
- const url = `/api/overlay/status?url=${encodeURIComponent(window.location.href)}`;
1468
- specOverlayFetchJson(url).then((data) => setStatus(data)).catch(() => setStatus(null));
1469
+ specOverlayFetchJson(specOverlayStatusPath()).then((data) => setStatus(data)).catch(() => setStatus(null));
1469
1470
  }, 300);
1470
1471
  return () => window.clearTimeout(timer);
1471
1472
  }, [access?.allowed, href, orgId]);
1472
- const onCaptureMyView = async () => {
1473
- if (!status || !status.matched) return;
1474
- const before = status.finished_at;
1473
+ const captureRun = react.useRef(0);
1474
+ const runCapture = async (s) => {
1475
+ const run = ++captureRun.current;
1476
+ const before = s.live_source === "viewer" ? s.finished_at : null;
1475
1477
  setCapture({ state: "busy" });
1476
- setOpen(false);
1477
1478
  try {
1478
- await new Promise((r) => setTimeout(r, 250));
1479
- await captureMyView(status, orgId);
1480
- setOpen(true);
1481
- const url = `/api/overlay/status?url=${encodeURIComponent(window.location.href)}`;
1479
+ await new Promise((r) => setTimeout(r, 150));
1480
+ await captureMyView(s, orgId, orgName);
1482
1481
  for (let i = 0; i < 45; i++) {
1483
- await new Promise((r) => setTimeout(r, 4e3));
1484
- const next = await specOverlayFetchJson(url).catch(() => null);
1485
- if (next && next.matched && next.finished_at !== before) {
1482
+ await new Promise((r) => setTimeout(r, 3e3));
1483
+ if (run !== captureRun.current) return;
1484
+ const next = await specOverlayFetchJson(specOverlayStatusPath()).catch(() => null);
1485
+ if (next && next.matched && next.live_source === "viewer" && next.finished_at !== before) {
1486
1486
  setStatus(next);
1487
1487
  setFrameKey((k) => k + 1);
1488
1488
  setCapture({ state: "idle" });
1489
1489
  return;
1490
1490
  }
1491
1491
  }
1492
- setCapture({ state: "error", message: "Compare did not finish in 3 min." });
1492
+ setCapture({ state: "error", message: "Compare did not finish in 2 min." });
1493
1493
  } catch (e) {
1494
- setOpen(true);
1495
- setCapture({ state: "error", message: e instanceof Error ? e.message : String(e) });
1494
+ if (run === captureRun.current) setCapture({ state: "error", message: e instanceof Error ? e.message : String(e) });
1496
1495
  }
1497
1496
  };
1497
+ react.useEffect(() => {
1498
+ if (!open || !status || !status.matched) return;
1499
+ void runCapture(status);
1500
+ return () => {
1501
+ captureRun.current++;
1502
+ };
1503
+ }, [open, status && status.matched ? status.target_id : null]);
1498
1504
  if (!access?.allowed) return null;
1499
1505
  const badgeLabel = status && status.matched ? `${Math.round(status.mismatch_pct)}%` : status ? "\u2014" : void 0;
1500
1506
  const badgeTone = status && status.matched ? status.passed ? "default" : "danger" : "default";
@@ -1534,6 +1540,7 @@ function SpecOverlayAction({ orgId } = {}) {
1534
1540
  "div",
1535
1541
  {
1536
1542
  className: "fixed inset-0 z-50 flex justify-end bg-black/40",
1543
+ ...{ [SPEC_OVERLAY_ROOT_ATTR]: "" },
1537
1544
  role: "dialog",
1538
1545
  "aria-modal": "true",
1539
1546
  "aria-label": "Spec overlay",
@@ -1587,13 +1594,17 @@ function SpecOverlayAction({ orgId } = {}) {
1587
1594
  {
1588
1595
  type: "button",
1589
1596
  disabled: capture.state === "busy",
1590
- onClick: onCaptureMyView,
1597
+ onClick: () => void runCapture(status),
1591
1598
  className: "ml-auto rounded-md border border-[color:var(--line)] px-2 py-1 text-[color:var(--foreground)] hover:bg-[color:var(--panel)] disabled:opacity-60",
1592
- title: "Compare the design against this tab exactly as you see it (your org, data and theme).",
1593
- children: capture.state === "busy" ? "Comparing\u2026" : "Capture my view"
1599
+ title: "Your view is captured when the drawer opens. Recapture after changing the page.",
1600
+ children: capture.state === "busy" ? "Capturing your view\u2026" : "Recapture"
1594
1601
  }
1595
1602
  )
1596
1603
  ] }),
1604
+ capture.state === "busy" ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 border-b border-[color:var(--line)] px-4 py-2 text-xs text-[color:var(--muted)]", children: [
1605
+ /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3 w-3 rounded-full" }),
1606
+ "Capturing your view and comparing\u2026"
1607
+ ] }) : null,
1597
1608
  capture.state === "error" ? /* @__PURE__ */ jsxRuntime.jsx("p", { className: "border-b border-[color:var(--line)] px-4 py-2 text-xs text-[color:var(--chrome-danger)]", children: capture.message }) : null,
1598
1609
  /* @__PURE__ */ jsxRuntime.jsx(
1599
1610
  "iframe",
@@ -3318,7 +3329,7 @@ function NavAppShell({
3318
3329
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "lg:pl-[240px]", children: [
3319
3330
  /* @__PURE__ */ jsxRuntime.jsx(NavTopBar, { ...topBar, onOpenNav: () => setNavOpen(true), navOpen }),
3320
3331
  betaBanner ? /* @__PURE__ */ jsxRuntime.jsx(NavBetaBanner, { ...betaBanner }) : null,
3321
- contentWrapper === "bare" ? children : /* @__PURE__ */ jsxRuntime.jsx("main", { className: "px-6 py-7", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mx-auto max-w-7xl", children }) })
3332
+ contentWrapper === "bare" ? /* @__PURE__ */ jsxRuntime.jsx("div", { "data-shell-content": "", children }) : /* @__PURE__ */ jsxRuntime.jsx("main", { "data-shell-content": "", className: "px-6 py-7", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "mx-auto max-w-7xl", children }) })
3322
3333
  ] })
3323
3334
  ]
3324
3335
  }
@@ -4673,7 +4684,7 @@ function ChromeTopBarActions() {
4673
4684
  children: /* @__PURE__ */ jsxRuntime.jsx(ChromeDevNotesMenu, {})
4674
4685
  }
4675
4686
  ),
4676
- /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId }),
4687
+ /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId, orgName: state.orgs.find((o) => o.id === state.orgId)?.name ?? null }),
4677
4688
  /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId }),
4678
4689
  /* @__PURE__ */ jsxRuntime.jsx(NotificationsAction, { orgId: state.orgId })
4679
4690
  ] });