@the-portland-company/shell 0.60.0 → 0.61.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,21 @@
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
+
7
+ ## 0.60.1 — 2026-09-30
8
+
9
+ ### Fixed
10
+
11
+ - **Meta footer contrast (WCAG 2.2 AA).** The commit / version / date / author rows inherited `--muted` (#718096, 4.02:1 on the light rail) and, since 0.58.0 renders Meta for every viewer, tripped Petitions' contrast regression gate (+4 fails per route). New token `--sidebar-meta-text` (light #696D72 = 5.2:1, dark #A3A3A3 = 7.2:1) scoped to the Meta block (`data-shell-meta`); separators and the env chip unchanged.
12
+
13
+ ## 0.60.0 — 2026-09-30
14
+
15
+ ### Added
16
+
17
+ - **Spec overlay: Capture my view** — compare the design against this tab (own org/theme) (#79).
18
+
1
19
  ## 0.59.0 — 2026-09-30
2
20
 
3
21
  ### Fixed — parent nav row stays active on child routes when the 'tabs' nav preference strips children (`src/app-chrome/frame.tsx`, DevNotes ebb036a1)
@@ -1386,57 +1386,51 @@ 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 { domToBlob } = 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 blob = await domToBlob(document.body, {
1397
+ width: w,
1398
+ height: h,
1399
+ scale: 1,
1400
+ backgroundColor: bg,
1401
+ type: "image/png",
1402
+ // Cross-origin images that refuse CORS render blank instead of failing.
1403
+ fetch: { requestInit: { mode: "cors", credentials: "omit" }, placeholderImage: "" },
1404
+ filter: (node) => !(node instanceof Element && node.hasAttribute(SPEC_OVERLAY_ROOT_ATTR)),
1405
+ style: { transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)` }
1397
1406
  });
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());
1407
+ if (!blob) throw new Error("page raster failed");
1408
+ let crop = null;
1409
+ const el = status.live_selector ? document.querySelector(status.live_selector) : null;
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
+ crop = { x, y, width: Math.min(w - x, Math.round(r.width)), height: Math.min(h - y, Math.round(r.height)) };
1437
1415
  }
1416
+ const form = new FormData();
1417
+ form.append("image", blob, "live.png");
1418
+ form.append(
1419
+ "meta",
1420
+ JSON.stringify({ url: window.location.href, theme, org_id: orgId ?? null, org_name: orgName ?? null, crop })
1421
+ );
1422
+ const res = await fetch(status.capture_url ?? `${SPECS_ORIGIN}/api/overlay/capture`, {
1423
+ method: "POST",
1424
+ credentials: "include",
1425
+ headers: { "X-Spec-Capture": "1" },
1426
+ body: form
1427
+ });
1428
+ if (!res.ok) throw new Error(`capture ${res.status}: ${(await res.text()).slice(0, 160)}`);
1429
+ }
1430
+ function specOverlayStatusPath() {
1431
+ return `/api/overlay/status?url=${encodeURIComponent(window.location.href)}&theme=${currentChromeTheme()}`;
1438
1432
  }
1439
- function SpecOverlayAction({ orgId } = {}) {
1433
+ function SpecOverlayAction({ orgId, orgName } = {}) {
1440
1434
  const [access, setAccess] = react.useState(() => readSpecOverlayAccessCache());
1441
1435
  const [status, setStatus] = react.useState(null);
1442
1436
  const [open, setOpen] = react.useState(false);
@@ -1464,37 +1458,41 @@ function SpecOverlayAction({ orgId } = {}) {
1464
1458
  react.useEffect(() => {
1465
1459
  if (!access?.allowed || typeof window === "undefined") return;
1466
1460
  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));
1461
+ specOverlayFetchJson(specOverlayStatusPath()).then((data) => setStatus(data)).catch(() => setStatus(null));
1469
1462
  }, 300);
1470
1463
  return () => window.clearTimeout(timer);
1471
1464
  }, [access?.allowed, href, orgId]);
1472
- const onCaptureMyView = async () => {
1473
- if (!status || !status.matched) return;
1474
- const before = status.finished_at;
1465
+ const captureRun = react.useRef(0);
1466
+ const runCapture = async (s) => {
1467
+ const run = ++captureRun.current;
1468
+ const before = s.live_source === "viewer" ? s.finished_at : null;
1475
1469
  setCapture({ state: "busy" });
1476
- setOpen(false);
1477
1470
  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)}`;
1471
+ await new Promise((r) => setTimeout(r, 150));
1472
+ await captureMyView(s, orgId, orgName);
1482
1473
  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) {
1474
+ await new Promise((r) => setTimeout(r, 3e3));
1475
+ if (run !== captureRun.current) return;
1476
+ const next = await specOverlayFetchJson(specOverlayStatusPath()).catch(() => null);
1477
+ if (next && next.matched && next.live_source === "viewer" && next.finished_at !== before) {
1486
1478
  setStatus(next);
1487
1479
  setFrameKey((k) => k + 1);
1488
1480
  setCapture({ state: "idle" });
1489
1481
  return;
1490
1482
  }
1491
1483
  }
1492
- setCapture({ state: "error", message: "Compare did not finish in 3 min." });
1484
+ setCapture({ state: "error", message: "Compare did not finish in 2 min." });
1493
1485
  } catch (e) {
1494
- setOpen(true);
1495
- setCapture({ state: "error", message: e instanceof Error ? e.message : String(e) });
1486
+ if (run === captureRun.current) setCapture({ state: "error", message: e instanceof Error ? e.message : String(e) });
1496
1487
  }
1497
1488
  };
1489
+ react.useEffect(() => {
1490
+ if (!open || !status || !status.matched) return;
1491
+ void runCapture(status);
1492
+ return () => {
1493
+ captureRun.current++;
1494
+ };
1495
+ }, [open, status && status.matched ? status.target_id : null]);
1498
1496
  if (!access?.allowed) return null;
1499
1497
  const badgeLabel = status && status.matched ? `${Math.round(status.mismatch_pct)}%` : status ? "\u2014" : void 0;
1500
1498
  const badgeTone = status && status.matched ? status.passed ? "default" : "danger" : "default";
@@ -1534,6 +1532,7 @@ function SpecOverlayAction({ orgId } = {}) {
1534
1532
  "div",
1535
1533
  {
1536
1534
  className: "fixed inset-0 z-50 flex justify-end bg-black/40",
1535
+ ...{ [SPEC_OVERLAY_ROOT_ATTR]: "" },
1537
1536
  role: "dialog",
1538
1537
  "aria-modal": "true",
1539
1538
  "aria-label": "Spec overlay",
@@ -1587,13 +1586,17 @@ function SpecOverlayAction({ orgId } = {}) {
1587
1586
  {
1588
1587
  type: "button",
1589
1588
  disabled: capture.state === "busy",
1590
- onClick: onCaptureMyView,
1589
+ onClick: () => void runCapture(status),
1591
1590
  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"
1591
+ title: "Your view is captured when the drawer opens. Recapture after changing the page.",
1592
+ children: capture.state === "busy" ? "Capturing your view\u2026" : "Recapture"
1594
1593
  }
1595
1594
  )
1596
1595
  ] }),
1596
+ 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: [
1597
+ /* @__PURE__ */ jsxRuntime.jsx(ValueSkeleton, { className: "h-3 w-3 rounded-full" }),
1598
+ "Capturing your view and comparing\u2026"
1599
+ ] }) : null,
1597
1600
  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
1601
  /* @__PURE__ */ jsxRuntime.jsx(
1599
1602
  "iframe",
@@ -2486,39 +2489,46 @@ function NavSidebar({
2486
2489
  }
2487
2490
  )
2488
2491
  ] }, l.label)) }) : null,
2489
- footer.envLabel ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-1.5 flex flex-col items-center gap-0.5 font-[family-name:var(--font-plex-mono)] text-[10px] text-[color:var(--muted)]", children: [
2490
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
2491
- /* @__PURE__ */ jsxRuntime.jsx(
2492
- "span",
2493
- {
2494
- className: [
2495
- "rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
2496
- envChipClasses(footer.envLabel)
2497
- ].join(" "),
2498
- children: footer.envLabel
2499
- }
2500
- ),
2501
- footer.commit ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2502
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }),
2503
- /* @__PURE__ */ jsxRuntime.jsx("span", { children: footer.commit.slice(-4) })
2504
- ] }) : null,
2505
- footer.version ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2506
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }),
2507
- /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
2508
- "v",
2509
- footer.version
2510
- ] })
2511
- ] }) : null
2512
- ] }),
2513
- footer.commitDate || footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
2514
- footer.commitDate ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: new Date(footer.commitDate).toLocaleDateString(void 0, {
2515
- month: "short",
2516
- day: "numeric"
2517
- }) }) : null,
2518
- footer.commitDate && footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }) : null,
2519
- footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: footer.commitAuthor.split(/\s+/)[0] }) : null
2520
- ] }) : null
2521
- ] }) : null
2492
+ footer.envLabel ? /* @__PURE__ */ jsxRuntime.jsxs(
2493
+ "div",
2494
+ {
2495
+ "data-shell-meta": true,
2496
+ className: "mt-1.5 flex flex-col items-center gap-0.5 font-[family-name:var(--font-plex-mono)] text-[10px] text-[color:var(--sidebar-meta-text,var(--sidebar-text))]",
2497
+ children: [
2498
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
2499
+ /* @__PURE__ */ jsxRuntime.jsx(
2500
+ "span",
2501
+ {
2502
+ className: [
2503
+ "rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
2504
+ envChipClasses(footer.envLabel)
2505
+ ].join(" "),
2506
+ children: footer.envLabel
2507
+ }
2508
+ ),
2509
+ footer.commit ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2510
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }),
2511
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: footer.commit.slice(-4) })
2512
+ ] }) : null,
2513
+ footer.version ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
2514
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }),
2515
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
2516
+ "v",
2517
+ footer.version
2518
+ ] })
2519
+ ] }) : null
2520
+ ] }),
2521
+ footer.commitDate || footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-1.5", children: [
2522
+ footer.commitDate ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: new Date(footer.commitDate).toLocaleDateString(void 0, {
2523
+ month: "short",
2524
+ day: "numeric"
2525
+ }) }) : null,
2526
+ footer.commitDate && footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-[color:var(--muted)]", children: "|" }) : null,
2527
+ footer.commitAuthor ? /* @__PURE__ */ jsxRuntime.jsx("span", { children: footer.commitAuthor.split(/\s+/)[0] }) : null
2528
+ ] }) : null
2529
+ ]
2530
+ }
2531
+ ) : null
2522
2532
  ] }) : null
2523
2533
  ]
2524
2534
  }
@@ -4666,7 +4676,7 @@ function ChromeTopBarActions() {
4666
4676
  children: /* @__PURE__ */ jsxRuntime.jsx(ChromeDevNotesMenu, {})
4667
4677
  }
4668
4678
  ),
4669
- /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId }),
4679
+ /* @__PURE__ */ jsxRuntime.jsx(SpecOverlayAction, { orgId: state.orgId, orgName: state.orgs.find((o) => o.id === state.orgId)?.name ?? null }),
4670
4680
  /* @__PURE__ */ jsxRuntime.jsx(SupportAction, { orgId: state.orgId }),
4671
4681
  /* @__PURE__ */ jsxRuntime.jsx(NotificationsAction, { orgId: state.orgId })
4672
4682
  ] });