Skip to content

πŸ• Window Globals tab β€” surface configured window.* values in the panel#18

Open
jjpaulino wants to merge 6 commits into
masterfrom
feat/window-globals-tab
Open

πŸ• Window Globals tab β€” surface configured window.* values in the panel#18
jjpaulino wants to merge 6 commits into
masterfrom
feat/window-globals-tab

Conversation

@jjpaulino
Copy link
Copy Markdown
Member

Summary

Adds a new Globals panel tab that surfaces user-configured top-level window.* values (e.g. nymGtmPage, dataLayer) as syntax-highlighted JSON. Configured per install via a new Window globals section on the Options page; both objects and arrays render identically.

Reads happen on initial tab open and on explicit Refresh (per-row or tab-level). No ambient polling β€” zero measurable cost on the host page when the tab isn't open.

Design write-up: docs/specs/2026-05-19-window-globals-tab.md.

What's in this PR (commit-by-commit)

  1. feat: add windowGlobals preference + parseWindowGlobal helper β€” pure types/parser slice. UserPreferences.windowGlobals: readonly string[], plus a parseWindowGlobal that accepts both nymGtmPage and window.nymGtmPage shapes and returns tagged failure reasons (empty / dotted-path / bracketed / invalid-identifier) so the Options UI can show a hint per failure mode. 18 new unit tests (parser + storage round-trip).
  2. feat: add page-bridge for reading window globals from main world β€” content scripts run in an isolated world; this commit adds the bridge that injects a one-time <script> into the host page's main world. Persistent listener + correlation-ID-routed Promises so concurrent Refreshes can't cross-talk. Forged messages and bad-source iframes are dropped. Times out at 1s with a clean bridge-unavailable fallback for strict-CSP / Trusted-Types pages instead of hanging forever. 8 new tests.
  3. feat(options): add 'Window globals' editor section β€” add/remove rows, inline per-row validation that only shows after the user has typed something, draft state kept separate from persisted state so invalid rows don't make valid siblings disappear, single-shot hydration on the first prefsLoaded transition (re-hydrating on every change would clobber the row the user is typing).
  4. feat(panel): add Globals tab with refresh + collapsible cards β€” the actual tab. Each configured global is one <details> card (collapsed by default), with a one-line shape summary on the header (object Β· 14 keys, array Β· 7 items), a per-row Refresh button, a Copy-pretty-printed-JSON button, and a tab-level "Refresh all". Body is only mounted once expanded so huge dataLayer payloads don't pay the syntax-highlight cost until requested. Reuses the .cs-jsonld-* class family so the visual language matches the SEO tab's JSON-LD viewer. 12 new format-helper tests.
  5. docs: document the Window Globals tab in README + PRIVACY β€” Highlights bullet, Usage table row, full Configuration subsection (input contract, refresh model, every failure message the panel can show). PRIVACY paragraph spelling out the page-bridge mechanism (only configured keys, only on user action).

Failure handling (surfaced inline in each row)

Reason Card border tint Message shown
undefined warn (yellow) (not defined on this page)
not-serializable danger (red) (value is not JSON-serializable β€” likely a function or Symbol)
serialize-error danger (could not serialize: <underlying message>)
access-error danger (reading the global threw: <underlying message>)
bridge-unavailable warn (could not read from this page β€” strict CSP or page never responded)

undefined and bridge-unavailable get the neutral warn tint because neither is actually a bug β€” just a missing global or a strict-CSP page.

Why inline-string bridge (vs chrome.scripting MAIN-world or a separate WAR file)

  • chrome.scripting.executeScript({ world: 'MAIN' }) requires Firefox 128+; our strict_min_version is 121, so it would silently no-op on older Firefox.
  • A separate web_accessible_resource means an extra Vite build entry, hashed filenames the content script has to resolve via chrome.runtime.getURL, and divergent WAR semantics between Chromium and Firefox. For ~30 lines of bridge code it's the wrong trade.
  • CSP / Trusted-Types edge cases are caught and surfaced as bridge-unavailable rather than a silent hang.

Permissions

None added. The bridge runs entirely inside the existing content-script context β€” no scripting permission, no host changes.

Test plan

  • npm run validate β€” typecheck + lint + format:check + 210 tests, all green
  • npm run release:dry:both β€” both Chromium and Firefox zips build cleanly
  • Verified the bundled content script chunk contains the clay-slip:globals:request bridge protocol
  • Verified the Firefox manifest still has the gecko id + strict_min_version: 121.0
  • Local smoke-test on a real Clay page (reviewer): load unpacked, add nymGtmPage in Options, open Globals tab, expand the row, verify JSON renders, click Refresh, verify the bridge re-reads
  • Strict-CSP smoke-test (reviewer, optional): visit a page with script-src 'self', expect bridge-unavailable placeholder per row, not a stuck spinner

Screenshots

TODO before merge:

  1. Options page β€” the new "Window globals" section with a row entered.
  2. Globals tab β€” happy path β€” two configured globals, one expanded showing the JSON tree.
  3. Globals tab β€” failure modes β€” at least one (not defined on this page) row so reviewers see the neutral-warn tint.

Made with Cursor

jjpaulino and others added 6 commits May 19, 2026 00:14
User-approved design for a new panel tab that surfaces
user-configured window.* globals (e.g. window.dataLayer,
window.nymGtmPage). Designed around a manual-refresh model
so there is zero ambient cost on the host page.

Spec covers: page-bridge architecture (content script ↔
main world via injected <script> + postMessage with
correlation IDs), top-level path syntax (strip optional
`window.` prefix, validate as JS identifier), Options-page
config UI, tab rendering (collapsible sections all closed
by default, JsonPreview reuse for arrays/objects, muted
'(not defined)' placeholder), error handling (undefined,
circular refs, CSP-blocked bridges), cross-browser parity
(Chromium + Firefox, no new permissions), and the test
plan (~12-15 new tests, ~190 total).

Explicitly out of scope for v1: dot-paths/nested access,
polling/dataLayer.push wrapping, per-section truncation,
last-read snapshot caching.

Implementation begins on this branch after review.

Co-authored-by: Cursor <cursoragent@cursor.com>
First slice of the Window Globals tab work. Pure types + parser +
storage wiring only β€” no UI, no page-bridge yet. Lays the contract
the rest of the feature builds on.

- UserPreferences gains `windowGlobals: readonly string[]`, defaults to [].
- `parseWindowGlobal` accepts both `nymGtmPage` and `window.nymGtmPage`
  shapes, strips a single leading `window.`, validates the remainder
  as a plain JS identifier. Returns a tagged result with one of four
  reason codes so the Options page can show a specific inline hint
  per failure mode (empty / dotted-path / bracketed / invalid-identifier).
- `parseResultMessage` maps each reason to user-facing copy; returns
  null on success so call sites can `?? ''` instead of branching.
- `normalizeWindowGlobals` is the defense-in-depth normalizer used
  both at save time and at read time so hand-edited storage never
  surfaces as ghost sections in the panel.

Tests: 15 for the parser (every accept + reject path, plus the
`window.window.foo` double-prefix case), 3 new round-trip tests
for the storage layer (default, explicit, partial-update sibling
preservation). All 22 green.

Next: page-bridge for reading values out of the host page's main
world, then the Options UI, then the Globals tab itself.

Co-authored-by: Cursor <cursoragent@cursor.com>
Content scripts run in an isolated world β€” `window.nymGtmPage` from a
content script returns undefined even when the page has it defined.
This adds the bridge layer the new Globals tab needs.

- `installPageBridge()` is idempotent: first call injects a <script>
  element whose textContent is an inlined IIFE that installs a single
  persistent listener on the page-world `window`. Subsequent calls
  no-op. The <script> tag self-removes after execution β€” the listener
  it set up lives on regardless, which is what we actually need.
- `readGlobals(keys)` posts a `clay-slip:globals:request` with a
  correlation ID and resolves the Promise from the matching
  `clay-slip:globals:result` reply.
- Per-key failure shapes are explicit, not collapsed into a single
  "error" code: `undefined` (key not on window), `not-serializable`
  (top-level function), `serialize-error` (circular structures),
  `access-error` (throwing getter), `bridge-unavailable` (CSP /
  Trusted Types blocked injection, or response timed out at 1s).
- Concurrent reads are correlation-ID-routed β€” spamming Refresh
  can't cross-talk results between calls.
- Forged messages get dropped via `event.source !== window` and
  unknown-correlation-ID checks, so a malicious iframe can't inject
  fake values into an in-flight read.

Why inline-string + persistent listener (vs `chrome.scripting`
with `world: 'MAIN'` or a separate WAR file): MAIN world needs
Firefox 128+, our `strict_min_version` is 121, and a separate
WAR entry would mean a Vite build entry, hashed filenames, and
`chrome.runtime.getURL` resolution for ~30 lines of code. Inline-
string is dramatically simpler. CSP / Trusted Types edge cases
are caught and surfaced as `bridge-unavailable` rather than a
silent hang.

Tests (8/8 green): empty-keys fast path, single-key round-trip,
each failure-reason variant propagated verbatim, concurrent reads
without cross-talk, forged-correlation-ID rejection, timeout
fallback to `bridge-unavailable`, cross-source message rejection,
and `installPageBridge` idempotence.

Next: Options UI for managing the global list, then the Globals tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
New section above Workflow on the Options page. Lets users add /
remove top-level window.* keys to surface in the upcoming Globals
panel tab. Mirrors the Site host mappings UX so there's nothing
new to learn.

UX details:

- Two-column row (input + remove). Simpler than the site-host
  mappings 5-col layout because each entry is a single identifier.
- Placeholder shows both accepted shapes ("nymGtmPage  or
  window.dataLayer") so users discover the prefix-stripping
  behavior without reading the help paragraph.
- Inline validation under each row, only shown after the user
  has typed something β€” empty rows look "ready", not "broken".
- Each parse failure surfaces a reason-specific hint (dotted-
  path vs bracketed vs invalid-identifier vs empty), so users
  know whether they made a typo or hit a v1 limitation.
- Drafts state is kept separate from persisted state β€” invalid
  rows hold their editor slot but contribute nothing to storage,
  so fixing one row doesn't make sibling rows disappear.
- Hydration from chrome.storage is deliberately single-shot on
  the first prefsLoaded transition. Re-hydrating on every prefs
  change would clobber the row the user is typing (each keystroke
  persists β†’ mutates prefs β†’ would re-fire the effect). Cross-
  window sync isn't wired in this page anyway.
- The "+ Add" button appends a draft row without immediately
  persisting (an empty draft contributes nothing), so spamming
  Add doesn't pollute storage.

Help paragraph documents the v1 limits up front: no nested paths,
no array indices, and a heads-up that functions/symbols can't be
JSON-serialized (so they'll show as "not serializable").

CSS: new .options-globals-* classes for the 2-col layout, plus
.options-row-error for the per-row inline hint and aria-invalid
styling so screen readers + sighted users get the same signal.

Typecheck clean, lint clean. Next: panel-side Globals tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
The reason the previous three commits exist. Adds the user-facing
'Globals' tab as the last entry in the panel's tab strip, rendering
the configured window.* globals as a collapsible card per key.

Tab behavior:

- Empty state when no globals are configured points the user at
  the Options page (no other way to populate the tab).
- Each configured global is one <details> card, collapsed by
  default. Cards reuse the .cs-jsonld-* class family so the
  visual language matches the SEO tab's JSON-LD viewer.
- Card header shows `window.<key>` + a one-line shape summary
  (e.g. "object Β· 14 keys", "array Β· 7 items") so users can
  scan the tab without expanding everything.
- Two icon buttons per card: a per-row "Re-read" (refresh)
  and a "Copy pretty-printed JSON" β€” the latter reparses the
  bridge's compact JSON.stringify output before copying so
  what lands on the clipboard is human-readable.
- A tab-level "Refresh all" button next to the section title.
  Posts an info toast so the user gets feedback even if every
  row was already cached.
- Body is only mounted once the user expands the row β€” defers
  the syntax-highlight regex pass for huge payloads (some
  dataLayer setups carry 10k+ entries) until requested.

Data flow (matches the spec):

- On mount, fire one readGlobals(prefs.windowGlobals); seed the
  results map. Subsequent prefs changes diff the configured
  list and only fetch the new keys (the user adding one global
  in Options shouldn't refetch the world).
- Per-row Refresh fetches just that key.
- Tab-level "Refresh all" clears the fetched-set and re-reads
  every key. No ambient polling anywhere.
- Failure shapes are surfaced explicitly per row instead of a
  blanket "could not read":
    undefined         β†’ "(not defined on this page)"
    not-serializable  β†’ flags function/Symbol globals
    serialize-error   β†’ embeds the underlying JS message
    access-error      β†’ embeds the throwing-getter message
    bridge-unavailable β†’ CSP-blocked / timed-out fallback
  Failure border tints follow severity β€” undefined/bridge-unavailable
  get the neutral warn color (neither is actually a bug), the
  other three get the alarming danger color.

Code hygiene:

- failureMessage + summarizeValue extracted to globals-format.ts
  so the user-facing copy can be unit-tested without spinning
  up React Testing Library just for one component.
- New `refresh` SVG icon added to Icon.tsx (circular-arrow glyph
  legible at 14px).
- New .cs-section-header rule for "title + trailing action" rows;
  reusable beyond this tab.
- New "globals" PanelTab in the store + a count badge in the
  Tabs strip that mirrors the existing Notes-tab badge pattern.

Tests: 12 new for globals-format (distinct messages per failure
reason, null-vs-object disambiguation, plural correctness, primitive
fallback). All 31 Window-Globals-scope tests pass together. tsc + lint
clean.

Next: README + spec back-link, then validate + ship.
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds the user-facing documentation for the feature and the lint /
prettier touch-ups from the validate pass.

README:
- New Highlights bullet between SEO and Recently viewed.
- New Usage table row ("Read a window global").
- New Configuration subsection with the input contract
  ("nymGtmPage" or "window.nymGtmPage", top-level only), the
  refresh model (manual; no ambient polling), and every failure
  message the panel can show β€” so users searching the README for
  "(not defined on this page)" / "(could not read)" find an
  explanation without digging into the code. Back-links the
  spec document for the full design rationale.

PRIVACY:
- Updated date to 2026-05-19.
- Added a paragraph to "What the extension reads from the page"
  spelling out the page-bridge mechanism: only the keys the user
  explicitly configured, only when the Globals tab is opened or
  refreshed, JSON-stringified and posted back to the panel,
  nothing else.
- Added `windowGlobals` to the storage.sync description.

Lint / format housekeeping from the validate run:
- Dropped a setResults-in-effect call in GlobalsTab that was
  pruning stale entries on `configured` change. The render path
  already only iterates `configured`, so removed keys are simply
  not displayed β€” the in-state pruning was tidiness, not
  correctness. Net: no setState in the configured-keys effect,
  which keeps the React-19 `set-state-in-effect` rule happy.
- One scoped eslint-disable on the Options hydration effect: it
  IS a genuine async hydration from chrome.storage.sync, with no
  derive-during-render equivalent. Comment explains why.
- Prettier sweep across the four files it flagged.

Full validate (typecheck + lint + format:check + 210 tests) passes.
release:dry:both builds and zips both Chromium and Firefox cleanly;
spot-checked that the bundled content script chunk contains the
clay-slip:globals:request bridge protocol and that the Firefox
manifest still has the gecko id + strict_min_version 121.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant