@the-portland-company/shell 0.58.0 → 0.60.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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.59.0 — 2026-09-30
2
+
3
+ ### Fixed — parent nav row stays active on child routes when the 'tabs' nav preference strips children (`src/app-chrome/frame.tsx`, DevNotes ebb036a1)
4
+
1
5
  ## 0.58.0 — 2026-09-30
2
6
 
3
7
  ### Fixed — Meta build-metadata row renders for every viewer (`src/app-chrome/index.tsx`)
@@ -1380,10 +1380,68 @@ function specOverlayFetchJson(path) {
1380
1380
  return res.json();
1381
1381
  });
1382
1382
  }
1383
+ function currentChromeTheme() {
1384
+ const root = document.documentElement;
1385
+ if (root.classList.contains("dark") || root.getAttribute("data-theme") === "dark") return "dark";
1386
+ if (root.classList.contains("light") || root.getAttribute("data-theme") === "light") return "light";
1387
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
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"
1397
+ });
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());
1437
+ }
1438
+ }
1383
1439
  function SpecOverlayAction({ orgId } = {}) {
1384
1440
  const [access, setAccess] = react.useState(() => readSpecOverlayAccessCache());
1385
1441
  const [status, setStatus] = react.useState(null);
1386
1442
  const [open, setOpen] = react.useState(false);
1443
+ const [capture, setCapture] = react.useState({ state: "idle" });
1444
+ const [frameKey, setFrameKey] = react.useState(0);
1387
1445
  const href = useLocationHref();
1388
1446
  react.useEffect(() => {
1389
1447
  if (access) return;
@@ -1411,6 +1469,32 @@ function SpecOverlayAction({ orgId } = {}) {
1411
1469
  }, 300);
1412
1470
  return () => window.clearTimeout(timer);
1413
1471
  }, [access?.allowed, href, orgId]);
1472
+ const onCaptureMyView = async () => {
1473
+ if (!status || !status.matched) return;
1474
+ const before = status.finished_at;
1475
+ setCapture({ state: "busy" });
1476
+ setOpen(false);
1477
+ 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)}`;
1482
+ 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) {
1486
+ setStatus(next);
1487
+ setFrameKey((k) => k + 1);
1488
+ setCapture({ state: "idle" });
1489
+ return;
1490
+ }
1491
+ }
1492
+ setCapture({ state: "error", message: "Compare did not finish in 3 min." });
1493
+ } catch (e) {
1494
+ setOpen(true);
1495
+ setCapture({ state: "error", message: e instanceof Error ? e.message : String(e) });
1496
+ }
1497
+ };
1414
1498
  if (!access?.allowed) return null;
1415
1499
  const badgeLabel = status && status.matched ? `${Math.round(status.mismatch_pct)}%` : status ? "\u2014" : void 0;
1416
1500
  const badgeTone = status && status.matched ? status.passed ? "default" : "danger" : "default";
@@ -1497,15 +1581,28 @@ function SpecOverlayAction({ orgId } = {}) {
1497
1581
  className: "text-[color:var(--color-accent)] hover:underline",
1498
1582
  children: "Compare tab \u2197"
1499
1583
  }
1584
+ ),
1585
+ /* @__PURE__ */ jsxRuntime.jsx(
1586
+ "button",
1587
+ {
1588
+ type: "button",
1589
+ disabled: capture.state === "busy",
1590
+ onClick: onCaptureMyView,
1591
+ 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"
1594
+ }
1500
1595
  )
1501
1596
  ] }),
1597
+ 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,
1502
1598
  /* @__PURE__ */ jsxRuntime.jsx(
1503
1599
  "iframe",
1504
1600
  {
1505
1601
  src: status.overlay_url,
1506
1602
  title: "Spec overlay",
1507
1603
  className: "h-full w-full flex-1 border-0"
1508
- }
1604
+ },
1605
+ frameKey
1509
1606
  )
1510
1607
  ] }) : status && !status.matched ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "p-4 text-sm text-[color:var(--muted)]", children: [
1511
1608
  /* @__PURE__ */ jsxRuntime.jsx("p", { children: "No compare target covers this page." }),
@@ -4080,6 +4177,21 @@ function FrameBody({
4080
4177
  [data?.contactsItem, data?.petitionsItem, navMode, activeOrgId]
4081
4178
  );
4082
4179
  const navItems = core?.nav ?? derivedNavItems;
4180
+ const navIsActive = react.useMemo(() => {
4181
+ const childHrefs = /* @__PURE__ */ new Map();
4182
+ for (const item of navItems) {
4183
+ if (!item.href || !item.children?.length) continue;
4184
+ const hrefs = [];
4185
+ for (const child of item.children) {
4186
+ if (child.href) hrefs.push(child.href);
4187
+ for (const leaf of child.children ?? []) {
4188
+ if (leaf.href) hrefs.push(leaf.href);
4189
+ }
4190
+ }
4191
+ childHrefs.set(item.href, hrefs);
4192
+ }
4193
+ return (pathname2, href) => isNavItemActive(pathname2, href) || (childHrefs.get(href) ?? []).some((h) => isNavItemActive(pathname2, h));
4194
+ }, [navItems]);
4083
4195
  const derivedBreadcrumbs = react.useMemo(
4084
4196
  () => deriveBreadcrumbs(navItems, pathname, { search, origin: meta.origin }),
4085
4197
  [navItems, pathname, search, meta.origin]
@@ -4112,6 +4224,7 @@ function FrameBody({
4112
4224
  // (which strips children) would leave one childless parent row —
4113
4225
  // flat apps ignore the stripping.
4114
4226
  items: navDisplay === "tabs" && meta.navShape !== "flat" ? navItems.map(({ children: _sub, ...item }) => item) : navItems,
4227
+ isActive: navIsActive,
4115
4228
  flatten: meta.navShape === "flat"
4116
4229
  },
4117
4230
  // The app's own workspace is NOT the organization — it is a per-app