Commit Graph

975 Commits

Author SHA1 Message Date
Tommy D. Rossi b7a2811caf release: playwriter@0.1.0 playwriter@0.1.0 2026-04-16 12:01:54 +02:00
Tommy D. Rossi 6b1ecb729b chore: update playwright submodule for shared dialog race fix
Record the Playwright fork commit that moves shared JavaScript dialog race handling into Dialog.close().

This follows the previous root commit that added the playwriter package version bump, changelog entry, and integration repro test. With this submodule pointer update, the repository now fully captures both layers of the change:
- playwriter root: user-facing release notes + regression coverage
- playwright fork: abstraction-level dialog close fix

Submodule commit included:
- 0dcc58955 fix: ignore duplicate auto-close races for shared dialogs
2026-04-14 10:57:52 +02:00
Tommy D. Rossi 9c2bac37d3 fix: handle shared dialog auto-close races in dialog abstraction
Move the JavaScript dialog race fix down into the Playwright fork instead of teaching the relay about a specific CDP error.

When multiple connectOverCDP clients share the same Playwriter tab, they all observe the same dialog and may all attempt the automatic best-effort close. Chrome accepts the first Page.handleJavaScriptDialog and rejects later attempts with "No dialog is showing". That follow-up failure should not crash the process because the dialog is already closed and the cleanup goal was achieved.

This commit:
- updates @xmorse/playwright-core Dialog.close() to ignore benign duplicate close races
- bumps playwright-core to 1.59.9 and documents the behavior in its changelog
- bumps playwriter to 0.0.112 and documents the user-visible fix
- adds a relay integration test that attaches two CDP clients to the same tab, opens an alert, and verifies both clients remain usable without an unhandled rejection

Why this abstraction is better:
- Dialog.close() is already the best-effort cleanup boundary
- the relay no longer needs protocol-specific knowledge of Page.handleJavaScriptDialog failures
- the behavior also protects any other shared-target setup that triggers the same close race

Validation:
- pnpm playwright:build
- pnpm typecheck (playwriter)
- pnpm test --run relay-core.test.ts -t "should ignore duplicate dialog dismissals from multiple CDP clients"
2026-04-14 10:57:03 +02:00
Tommy D. Rossi 19b0959585 fix: wrap all ghost cursor code in try/catch so it never crashes main logic
Every cursor code path that touches page.evaluate, DOM manipulation, or
runs inside a Playwright callback is now wrapped so errors stay cosmetic:

- onMouseAction callback: outer try/catch prevents cursor errors from
  propagating to Playwright's input dispatch (would break page.click)
- show()/hide(): swallow errors from closed/navigating pages
- enableGhostCursor/disableGhostCursor: same
- applyGhostCursorMouseAction: same
- Browser DOMContentLoaded auto-enable: inner try/catch
2026-04-13 09:26:26 +02:00
Tommy D. Rossi f7499bd1d9 fix: two-element cursor split so move and press transitions don't interfere
The cursor DOM is now outer (translate, move timing) + inner (scale+opacity,
press timing). Previously setPressed() overrode transitionDuration to 140ms
which retargeted in-flight 1500ms move transitions, causing clicks to
'sprint' the cursor to the target.

Also:
- Fix toast animation jump when positioned above anchor (toolbar.ts):
  keyframes now use --toast-transform CSS variable so translateY(-100%)
  base transform is included during animation, not just after.
- Remove stale navigation-rehydration entry from CHANGELOG (was cut in
  the simplification pass but the changelog entry remained).
2026-04-13 09:21:25 +02:00
Tommy D. Rossi 7455abbf5a feat: always-on ghost cursor injected by extension on tab attach
The Chrome extension now injects the ghost-cursor-client.js bundle into
every attached tab via CDP Page.addScriptToEvaluateOnNewDocument +
Runtime.evaluate. The cursor auto-enables on load (top frame only, skips
iframes) and stays on the last spot Playwright clicked or moved to.

Key changes:

- Extension injects cursor bundle via ?raw vite import, inlined into
  background.js at build time. Torn down on detachTab.
- RecordingGhostCursorController renamed to GhostCursorController with
  attachToPage/detachFromPage instead of enableForRecording/disableForRecording.
  Single instance on PlaywrightExecutor, wired in setupPageListeners.
- Recording no longer toggles the cursor on/off.
- 5s idle debounce: cursor fades out (600ms) after no activity, wakes on
  next action by teleporting to the new target and fading back in (140ms).
- Animation polish (Emil Kowalski guidelines): easeInOutCubic for moves,
  dedicated 140ms press duration with strong ease-out, subtler 0.95 press
  scale, transform-origin anchored to cursor hotspot.
- Slower cinematic defaults: 220ms min, 1500ms max, 1.2 px/ms.
- Test: verify cursor element exists on attached tab without explicit enable.
2026-04-13 09:10:34 +02:00
Tommy D. Rossi 8ee26ffcb4 update @modelcontextprotocol/sdk to 1.29.0 and hono to 4.12.12 2026-04-13 09:00:31 +02:00
Tommy D. Rossi b9c96dba25 feat(toolbar): wrap clipboard in natural-language prompt + bash-safe \u0027
Clipboard now copies a prompt-style message instead of a bare JS snippet:

    see the element I pinned in the playwriter tab `playwriter -e '<code>'`

The user pastes this into a chat with their agent. The agent reads it as
"user pinned an element, here is the code to run" and executes it with
their own session (via `-s <id>` or `PLAYWRITER_SESSION`). Makes the
intent obvious when the clipboard lands in a chat window — the agent
sees the goal plus the exact code without needing instructions.

Also fixes a latent bash-safety bug in `buildInspectionCode`. The code
is wrapped in bash `'...'` so single quotes are the only character we
cannot have inside it. `JSON.stringify` does NOT escape literal `'`, so
element text containing an apostrophe (e.g. "Don't save", aria labels
like "I'm loading", URLs with `?q=what's+new`) leaked a literal `'` into
the code and broke the outer bash wrapper.

Fix: replace every `'` in the JSON-stringified URL and summary with
`\u0027`. This is a valid JSON escape that the JS engine parses back to
`'`, so the string value is unchanged at runtime, but the generated
code is now guaranteed to contain zero literal single quotes regardless
of page content.

Single vs double bash quotes comparison:

    '...'   literal everything except '          ← 1 problem char
    "..."   $foo expands, `cmd` runs,            ← 4 problem chars
            \ escapes, " terminates

Our code is full of " (JSON strings) and can contain $ from element
text, so double quotes are a non-starter. Single quotes with the
\u0027 substitution is the simplest robust solution.

Verified with hostile input:
- url with "what's+new" in query → 0 literal ' in code
- summary with "Don't save 'now'" → 0 literal ' in code
- JS eval of the escaped code reproduces the original strings

Bumps extension to 0.0.85. Bundle 72.79 kB → 72.90 kB (+110 bytes for
the new prose prefix and the two .replace calls).
2026-04-11 16:47:45 +02:00
Tommy D. Rossi 9e33e21484 refactor(toolbar): drop session plumbing, copy raw eval code on pin-click
The previous implementation fetched the CLI session list from the relay via
a new GET /extension/sessions endpoint, plumbed it into the MAIN-world
toolbar via chrome.scripting.executeScript args, cached it in a closure,
and baked `playwriter -s <id> -e '<code>'` into the clipboard. The
picked session id was fragile: sessions[0] is the oldest by insertion
order, which is almost never the session the agent is actually using.
The agent also has its own active session already — guessing one is
the wrong job for a browser extension.

Drop all of it. The toolbar now copies only the JS eval code. The agent
wraps it in their own `playwriter -s <session> -e '<paste>'` using
whichever session they already have (or they create a new one). The
code is JSON.stringify'd so it contains no single quotes and slots
into any shell quoting strategy the agent prefers.

Removed:
- `GET /extension/sessions` on the relay (playwriter/src/cdp-relay.ts)
- `fetchSessionSummary` helper in background.ts
- `ToolbarConfig` interface + import + args plumbing
- Async wrapping around `chrome.scripting.executeScript` in both
  injection sites (attachTab + webNavigation.onDOMContentLoaded)
- `currentSessions` / `nextSuggested` closure vars in toolbar.ts
- `window.__playwriterUpdateSessions` setter
- `pickSessionId()` helper
- `shellSingleQuote()` helper (agent does their own shell quoting)
- Re-init update routing through `window.__playwriterUpdateSessions`

`onClick` now just:

    copyText(buildInspectionCode(n, url, summary))
    showToast(`Copied inspection code (pin #${n})`)

The eval code shape is unchanged:

    state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0];
    console.log(SUMMARY + "\n\nouterHTML:\n" +
      await state.page.evaluate(n => globalThis["playwriterPinnedElem"+n]?.outerHTML, N))

Bundle drops from 74.35 kB to 72.79 kB (-1.56 kB). Bumps extension to
0.0.84 and playwriter to 0.0.108 with CHANGELOG entries in both packages.
2026-04-11 14:45:42 +02:00
Tommy D. Rossi 2cbb943b00 feat(toolbar): copy full playwriter -s X -e inspection command on pin-click
The in-page toolbar's pin button used to copy a plain
`see playwriter reference: globalThis.playwriterPinnedElem1` string that
still required the agent to wrap it in their own `-e` call. It now copies
a complete, ready-to-run shell command:

    playwriter -s 1 -e 'state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0]; console.log(SUMMARY+"\n\nouterHTML:\n"+await state.page.evaluate(n=>globalThis["playwriterPinnedElem"+n]?.outerHTML,N))'

Two `;`-separated statements: pick the right page by URL with first-page
fallback, assign state.page for follow-up calls, log a pre-baked element
summary plus the current outerHTML of `globalThis.playwriterPinnedElemN`
fetched via one `page.evaluate`. No `getCleanHTML`, no
`getLocatorStringForElement`, no ElementHandle dance, no locator round-
trips — the generated JS is runnable against any playwriter version.

Everything the eval code doesn't strictly need a Playwright sandbox for is
pre-computed at pin time in MAIN world (synchronous DOM access) and baked
into the command via `JSON.stringify`:

- page URL from `location.href`
- element summary: tag, id, classes, role, aria-label, name, href, type,
  truncated text, bounding rect, visibility
- pin index

Session-id plumbing:

- New public `GET /extension/sessions` endpoint on the relay returns
  `{ sessions: [{ id, stateKeys }], nextSuggested }`. It sits outside the
  `/cli/*` privileged middleware, which rejects the cross-site
  `Sec-Fetch-Site` that chrome-extension fetches to 127.0.0.1 send. It
  exposes the same data as `playwriter session list`, so no new attack
  surface.
- `background.ts` `fetchSessionSummary()` calls the endpoint with a
  1500ms timeout and falls back to `{ sessions: [], nextSuggested: '1' }`
  on any failure (relay down, old relay without the endpoint, timeout,
  malformed JSON). The extension stays backward-compatible with older
  relays.
- Both toolbar injection sites (`attachTab` + `webNavigation.onDOMContentLoaded`
  re-inject) now fetch the summary and pass it as
  `chrome.scripting.executeScript({ args: [summary] })`.
- The guarded re-init path in `initPlaywriterToolbar` now calls
  `window.__playwriterUpdateSessions?.(config)` so re-injects refresh the
  closure cache instead of becoming no-ops.
- `pickSessionId()` prefers the first existing session id, falling back
  to `nextSuggested` (typically "1") so the agent stays in their current
  state when one exists.

Toolbar runtime behavior does not depend on the relay: sessions are
cached in closure at inject time, clipboard writes stay offline, and
all failure modes surface as visible `undefined` in the command's
output rather than exceptions or timeouts.

Bumps extension to 0.0.83 and playwriter to 0.0.107 with CHANGELOG
entries in both packages.
2026-04-11 14:29:20 +02:00
Tommy D. Rossi 365e5ba3ca fix: restore toolbar size to previous (26px buttons, 3px padding) 2026-04-11 13:23:24 +02:00
Tommy D. Rossi f5c513df1c fix: logo icon visibility in active state, toast font/padding 2026-04-11 13:02:01 +02:00
Tommy D. Rossi 794ff593e0 fix: further reduce toolbar size 2026-04-11 13:00:00 +02:00
Tommy D. Rossi 1c44c7ae99 fix: reduce toolbar padding and button size 2026-04-11 12:54:33 +02:00
Tommy D. Rossi 0dfb73879d feat: polish toolbar pin-element UX
- Deactivate pin mode after each click (toggle off after pin)
- Green flash outline: 3px → 1px to match overlay edge thickness
- Clipboard text prefixed: 'see playwriter reference: globalThis.playwriterPinnedElemN'
  so both user and AI agent understand the string's purpose at a glance
- Replace clipboard icon with Playwriter logo-square SVG (inlined)
2026-04-11 12:53:59 +02:00
Tommy D. Rossi af0f4aabe5 chore(deps): bump goke to ^6.6.0 and update --direct flag handling
goke 6.6.0 normalizes optional-value flags from 'string | boolean'
to 'string | undefined', with '' meaning 'flag present, no value'.
Update the --direct [endpoint] handler to match the new shape:
  - directEndpoint is just 'options.direct || null'
  - bare --direct check uses === '' instead of === true
2026-04-11 10:00:21 +02:00
Tommy D. Rossi 389e3cf23a chore(deps): bump goke to ^6.5.2 2026-04-11 09:32:15 +02:00
Tommy D. Rossi be1778f8b3 fix: resolve remaining pw reference and strict cast errors in toolbar
- Replace (window as Record<string, Element>)[name] with a typed template literal
  index signature on the Window interface: [key: playwriterPinnedElem${number}]: Element
- Return type of allocatePinName narrowed to playwriterPinnedElem${number} so
  the assignment window[name] = target type-checks without any casts
- Fix stray pw reference missed during declare global refactor
2026-04-10 21:25:24 +02:00
Tommy D. Rossi f96004d1d7 refactor: use declare global to augment Window instead of interface cast 2026-04-10 21:22:47 +02:00
Tommy D. Rossi 240e211c13 refactor: replace as any with PlaywriterWindow interface in toolbar
Declare PlaywriterWindow inside the function body (interfaces are TypeScript-only
and stripped at compile time, so safe inside the serialized func injection).
Cast window once to pw = window as PlaywriterWindow and use typed property access
throughout. Dynamic element assignment uses Record<string, Element>.
2026-04-10 21:21:22 +02:00
Tommy D. Rossi 023ed8da04 feat: add in-page floating toolbar with pin-element mode (extension v0.0.82)
Injects a floating toolbar into the top-right corner of every Playwriter-connected
tab via chrome.scripting.executeScript({ world: 'MAIN', func: initPlaywriterToolbar }).

- Toolbar uses a closed Shadow DOM for full style isolation from page CSS
- Matches mesurer.ibelick.com visual language: white pill background, mesurer's
  exact shadow, 32x32 buttons with rounded-[8px], #0d99ff active state
- Selection overlay uses four 1px edge divs + color-mix fill (same technique as
  mesurer's measurement-box) instead of a CSS outline — thinner and cleaner

Pin element mode (clipboard icon button):
- Toggle activates crosshair cursor + hover overlay tracking the element under cursor
- Click assigns the element to globalThis.playwriterPinnedElemN and copies
  'globalThis.playwriterPinnedElemN' to clipboard — identical format to right-click menu
- Esc exits pin mode; × button hides toolbar for the session
- Uses window.__playwriterPinCount as a shared MAIN-world counter so toolbar and
  right-click context menu never produce conflicting element indices

background.ts changes:
- Inject toolbar in attachTab() after debugger attaches
- Destroy toolbar in detachTab() (fire-and-forget scripting call)
- Re-inject via chrome.webNavigation.onDOMContentLoaded after hard navigations
- Right-click handler now reads window.__playwriterPinCount via Runtime.evaluate
  instead of the Zustand store's pinnedCount field

Removed pinnedCount from TabInfo — counter now lives entirely in MAIN world.
No new manifest permissions needed (scripting + webNavigation already present).
2026-04-10 21:17:12 +02:00
Tommy D. Rossi 3cf892c891 Create .agentignore 2026-04-10 20:00:26 +02:00
Tommy D. Rossi b6e241f487 fix: restore playwriter serve startup on clean installs
Closes #84\n\nThe CLI started importing browser-start helpers at module load time, which\npulled @xmorse/playwright-core into generic startup paths like\nplaywriter --help and playwriter serve. In clean installs that immediately\nreached the vendored extract-zip runtime, which still uses CommonJS\nrequire('get-stream'), and crashed with ERR_REQUIRE_ESM once the fork had\nget-stream v9 installed.\n\nThis change fixes the failure from both sides:\n- lazily load browser-start-only modules so normal CLI startup/help paths\n  do not pull playwright-core unless browser start is actually invoked\n- bump the playwright submodule to @xmorse/playwright-core 1.59.8, pinning\n  get-stream back to ^5.2.0 so the vendored unzip runtime stays compatible\n- add a black-box regression test that runs root help and serve help\n  through vite-node\n- update versions, changelogs, lockfile, and local memory notes\n\nVerified with pnpm typecheck, a direct require of playwright-core, and\npnpm vitest --run src/cli-help.test.ts.\n\nNote: lintcn still reports unrelated pre-existing warnings/errors in\ncli.ts, cdp-relay.ts, aria-snapshot.ts, and mcp.ts that were not part of\nthis fix.
2026-04-08 11:55:40 +02:00
Tommy D. Rossi f673205c97 chore: update pnpm lockfile playwriter@0.0.105 2026-04-07 22:55:14 +02:00
Tommy D. Rossi 2839ee4305 release: block extension publish until cli is on npm
Add a root release guard that fetches the latest published `playwriter` version from npm and compares it against the local workspace CLI version before packaging the extension.

If the local CLI is newer, the release exits early with `extension must be released with matching cli version released`, preventing a Chrome Web Store upload that depends on an unpublished relay/CLI version.
2026-04-07 21:55:10 +02:00
Tommy D. Rossi 7e633d5ac0 fix: prevent cross-browser extension takeover loops
Persist a per-install fallback identity for the extension so Chromium-family browsers without chrome.identity profile data no longer collapse to browser-only relay keys and replace each other.

Also stop replaced workers from reclaiming the relay slot while the replacement connection is merely idle, add a regression test that launches a second Chromium context against the same relay, and bump the shipped package versions/changelogs for the extension and playwriter packages.
extension@0.0.81
2026-04-07 21:43:46 +02:00
Tommy D. Rossi 91c69b54cf remove extension-outdated version check — extension auto-updates 2026-04-07 11:55:17 +02:00
Tommy D. Rossi 129fff896c feat: auto-relocate popup windows into source tab's window
Chrome popup windows (window.open with features, OAuth login popups)
could not be controlled by Playwriter because chrome.debugger does not
attach to separate popup windows. Agents had to work around this with
cmd+click (`{ modifiers: ['Meta'] }`) to coerce new tabs instead.

The extension now listens for chrome.windows.onCreated with
type === 'popup' and relocates the tab(s) into the source tab's window,
then auto-attaches Playwriter so the new tab appears in context.pages().

Gating: the popup is only relocated when the source tab is already
Playwriter-connected. Otherwise the popup is left alone so unrelated
sites keep their normal Chrome popup behavior. Source tab is discovered
via chrome.webNavigation.onCreatedNavigationTarget because
chrome.tabs.Tab.openerTabId is unreliable for window.open popups on
Chromium 145 (leaves it null).

Focus is not stolen — the relocated tab is appended at the end of the
source tab's strip (index: -1) but the user's active tab stays active.

Executor changes
- Replaced setupPopupDetection with setupNewPageLogging. Warning
  message changed from "Popup window detected ... cannot be controlled"
  to "New page opened from current page (index N, initial url: ...)"
  pointing the agent at the controllable tab.
- Added MIN_EXTENSION_PLAYWRITER_VERSION check so agents running with
  old extensions see a targeted warning to update via chrome://extensions.
- enqueueWarning is now called from warnIfExtensionOutdated so the LLM
  sees version-skew messages in its warning stream, not just stdout.

Extension changes
- New `webNavigation` permission for the source-tab correlation map.
- connectTab now verifies `chrome.tabs.get(tabId)` before writing an
  `error` state, preventing dead tabs from leaking into store.tabs /
  badge counts / group sync when a tab closes mid-attach.
- popupSourceTabMap entries are cleaned up both on a 10s timeout and
  when chrome.tabs.onRemoved fires, capping memory for plain-new-tab
  cases that never trigger windows.onCreated.

Tests
- popup-relocation.test.ts verifies both paths: when a Playwriter tab
  is connected the popup is relocated into the main window and appears
  in context.pages(); when no tab is connected the popup-type window
  remains (extension does not interfere).

Skill docs
- Removed section #10's cmd+click workaround; added a note about
  auto-relocation under "working with pages".

Versions
- extension 0.0.79 -> 0.0.80
- playwriter 0.0.103 -> 0.0.104
2026-04-05 14:22:21 +02:00
Tommy D. Rossi adc7125b47 fix: skip auto-printing Playwright handles in CLI execute output (#82)
When a single-expression code snippet auto-returns a Playwright object
(e.g. `await page.goto(url)` returning a Response), the CLI previously
called util.inspect() at depth 4 on the handle, producing 400K+ chars
that included every process.env variable (API keys, tokens, secrets).

Even with the playwright-core util.inspect fix (@xmorse/playwright-core@1.59.7),
these handles are useless output — they're programmatic references with no
display value. Agents and users get nothing meaningful from seeing:

  [return value] Response@response@abc123 { request: Channel<...>, ... }

Fix: add isPlaywrightChannelOwner() duck-type check in the executor return
value path. Handles are silently skipped (duck: _type + _guid + _connection).
The existing 'Code executed successfully (no output)' fallback applies when
there's no other output.

To see data, users must explicitly:
  return response.url()      → prints the URL string
  return response.status()   → prints 200
  console.log(response)      → prints safe summary via playwright-core

Also includes:
- Regression test (channel-owner-inspect.test.ts) for inspect handler
- isPlaywrightChannelOwner() unit tests (executor.unit.test.ts)
- Stale color-scheme snapshot fix (relay-core.test.ts)
- MEMORY.md with learnings (Node util.inspect bypass of Proxy traps)
playwriter@0.0.103
2026-04-05 11:57:30 +02:00
Tommy D. Rossi 0ad5219fd8 Add 'vs Built-in Chrome CDP' comparison to README and website
Chrome's --remote-debugging-port shows a persistent automation infobar
that agents cannot dismiss, interrupting users mid-workflow. Playwriter
runs silently with no blocking dialogs, enabling fully autonomous agent
operation without user intervention.

Added to both README.md (markdown table + blockquote) and
website/src/content/index.mdx (ComparisonTable component).
2026-04-03 12:32:57 +02:00
Tommy D. Rossi 5daea69581 test: add mdx-processor tests + use github-slugger for heading dedup
17 tests covering processMdx (frontmatter, headings, image srcs,
dedup, edge cases) and rewriteMdxImages (markdown→JSX conversion,
mixed paragraphs, JSX attr injection, code block safety, frontmatter
preservation).

Replaced hand-rolled slugify with github-slugger — duplicate headings
now get -1 postfix matching GitHub behavior (usage, usage-1).
2026-04-01 17:11:05 +02:00
Tommy D. Rossi c8f5c7b69f fix: AST-based image rewriting instead of unsafe replaceAll
Rewrites image nodes by mutating the mdast tree directly, then serializes
back to MDX with mdast-util-to-markdown + mdast-util-mdx.

Fixes from oracle review:
- Markdown images converted to JSX PixelatedImage with width/height/placeholder
- No more global string replaceAll that could corrupt code blocks/prose
- Each image node handled individually via AST traversal
- Old cache format guard: cached.mdx must be truthy
- Per-image try/catch: one bad image logs warning, doesn't fail build
- Paragraph containing single image → replaced with JSX block element

New deps: mdast-util-to-markdown, mdast-util-mdx, mdast-util-frontmatter
2026-04-01 16:34:30 +02:00
Tommy D. Rossi 66eb0097ff refactor: replace hand-rolled JSONC parser with tiny-jsonc regex approach
Ported from tiny-jsonc: regex captures strings as $1 and matches
comments as non-captured groups, so replacement keeps only strings
intact. Fast path tries JSON.parse first (zero overhead for valid JSON),
slow path strips comments + trailing commas.

Renamed strip-jsonc.ts → lib/jsonc.ts, exports parseJsonc().
2026-04-01 15:54:20 +02:00
Tommy D. Rossi 95c438c900 fix: address 7 oracle review findings
1. JSONC parser: character-by-character string-aware stripper — no longer
   corrupts URLs like https://... inside JSON string values
2. pagesDir: thread resolved prefix into virtual:holocron-config as
   pagesDirPrefix, used by loadMdxContent instead of hardcoded /pages
3. Stale images: re-resolve image SHAs on cached pages — if image content
   changed but MDX didn't, rewrites are refreshed and files recopied
4. configPath option: wired through readConfig + resolveConfigPath + HMR
5. Windows: use url.fileURLToPath(import.meta.url) instead of .pathname
6. Listener leak: process.once('beforeExit') instead of process.on
7. Normalizer guards: Array.isArray checks on tabs, groups, pages, anchors
   before casting — prevents crash on malformed configs
2026-04-01 15:48:15 +02:00
Tommy D. Rossi ad72046b22 feat: single-file image cache + relative image resolution
Image cache rewritten:
- Single dist/holocron-images.json instead of per-image JSON files
- Git blob SHA for invalidation (deterministic, works across CI)
- In-memory Record during build, flushed once on process exit
- Concurrent requests for same image deduplicated via inflight promises

Relative images in MDX:
- Images next to MDX files (./screenshot.png) are now supported
- During sync: detected, copied to public/_holocron/images/<hash>-<name>
- Content hash in filename for dedup + cache busting
- imageRewrites stored on NavPage, applied at render time via replaceAll
- Works like mintlify: images anywhere in repo, not just public/
2026-04-01 15:33:38 +02:00
Tommy D. Rossi 99519eb144 fix: normalize all config types to eliminate unions
readConfig() now collapses every docs.json variant into one canonical shape.
Consuming code (app-factory, sync) accesses fields directly with no type
discrimination.

- navigation: handle {tab,href} (→anchor), {tab,pages} (→group-wrapped),
  {tab,groups} (→kept), root {groups}, root {pages}, array formats
- anchors: merge from both global.anchors and root navigation.anchors
- navbar labels: TYPE_LABELS map (github→GitHub, discord→Discord) instead
  of broken capitalize()
- favicon: string|{light,dark} → always {light,dark}
- logo: string|{light,dark,href} → always {light,dark,href?}
- Navigation type: NavTab[] only (was NavTab[]|NavGroup[])
- findFirstPageInTab: typed with NavTab/NavPage/isNavPage (was ad-hoc)
- removed unused isConfigNavGroup import from sync.ts
- removed getTabs, isNavTabArray, normalizeNavigation exports
2026-04-01 15:04:23 +02:00
Tommy D. Rossi d1344f7d31 feat: add @holocron.so/vite package — drop-in Mintlify replacement as Vite plugin
Holocron is a documentation website framework that reads MDX files + a
holocron.jsonc (or docs.json) config file and generates a full docs site
via a single Vite plugin.

**Architecture:**
- Vite plugin wraps spiceflow, tailwind, tsconfig-paths
- Virtual modules for lazy MDX loading (import.meta.glob) and config
- SHA-based cache in dist/holocron-cache.json for instant rebuilds
- Editorial components copied from website/ (same design system)

**Config (docs.json compatible):**
- navigation with tabs, groups, pages, global.anchors
- navbar.links for header, anchors rendered as tab bar items
- Schema saved locally from https://mintlify.com/docs.json

**Core modules:**
- config.ts — JSONC reader, type normalization for 3 navigation formats
- navigation.ts — enriched tree types + getTabs/findPage/flattenForSidebar
- lib/sync.ts — MDX→cache with git blob SHA deduplication
- lib/mdx-processor.ts — frontmatter + heading extraction via safe-mdx
- app-factory.tsx — Spiceflow app with layout, catch-all page, search API
2026-04-01 14:34:37 +02:00
Tommy D. Rossi 1a8daa3b2f simpler max width. support for full page width 2026-04-01 12:21:41 +02:00
Tommy D. Rossi 2a2db6be25 fix: correct test assertions for screenshot format and color scheme
- snapshot-tools.test.ts: fix wrong assertion expecting 'jpg' when screenshot returns 'png' (contradicted the mimeType assertion on the same screenshot)
- relay-core.test.ts: update inline snapshot for color scheme test to match actual system preference (light mode)
2026-04-01 12:16:31 +02:00
Tommy D. Rossi 612473b578 add justify-between to editorial grid layout
Distribute the three grid columns (TOC, content, aside) with
justify-content: space-between on the hero and content grids.
The gap-x is preserved as minimum spacing. When max-width is
increased later, sidebars will push toward screen edges while
content stays centered.
2026-04-01 11:47:51 +02:00
Tommy D. Rossi 9864c1487b fix: resolve publicDir via spiceflow virtual module for Vercel compat
The website was using `import.meta.dirname` with relative paths to
resolve `publicDir` and `cacheDir`, which broke on Vercel because the
deployment directory structure differs from source layout:

- Source: `src/pages/index.tsx` → `../../public` → `website/public/`
- Vercel: `rsc/index.js` → `../../public` → `/var/public` (ENOENT)

Now uses spiceflow's `publicDir` export (from `virtual:spiceflow-dirs`)
which resolves correctly in both dev and Vercel deployments.

Also makes cache writes optional with try/catch since Vercel's
filesystem is read-only — images regenerate in-memory on each request.

- **spiceflow** bumped to `1.18.0-rsc.22` (adds `publicDir`/`distDir`)
- **image-cache.ts** wraps `mkdirSync`/`writeFileSync` in try/catch
- **index.tsx** imports `publicDir` from spiceflow instead of computing it
2026-03-31 02:43:00 +02:00
Tommy D. Rossi 7d54c4b6a0 fix: handle chrome-extension:// iframes from other extensions (#18)
Chrome's chrome.debugger.attach API refuses to attach to tabs containing
chrome-extension:// iframes from other extensions (LastPass, SurfingKeys,
password managers, etc). This causes the extension to immediately disconnect
after clicking the icon.

Two-layer fix:

1. Pre-attachment (removeRestrictedIframes): when chrome.debugger.attach fails
   with the chrome-extension:// error, use chrome.scripting.executeScript to
   remove restricted iframes from the DOM, then retry attachment. Requires
   new 'scripting' permission.

2. Runtime filtering (onDebuggerEvent): when Target.attachedToTarget arrives
   for a restricted child target (injected after debugger attached), filter it
   out immediately — don't track, don't forward to relay, detach the child
   target. Defense-in-depth against runtime re-injection by other extensions'
   content scripts.

Test: loads a fixture extension alongside playwriter, creates a page with a
chrome-extension:// iframe, enables the debugger, and verifies it stays
connected and can execute JS.

Closes #18
2026-03-29 23:26:26 +02:00
Tommy D. Rossi d82f82f978 add server-side automatic image placeholder generation with JSON cache
Previously, placeholder images for PixelatedImage had to be manually generated
by running a separate script (generate-placeholders.ts) before each build, and
passed explicitly as static Vite imports. The placeholder prop was never actually
wired up in the MDX content, so the fade-in effect wasn't working.

This replaces the entire approach with on-demand server-side processing:

**Architecture**

index.tsx is now a server component (no more 'use client'). During each render,
it calls buildImageManifest() which walks the mdast AST, finds all image srcs
(both <PixelatedImage src=...> JSX nodes and markdown ![alt](url) nodes), and
for each local public/ image:

1. Checks .cache/images/<name>.json — if present and mtime matches, returns cached data
2. On cache miss: reads dimensions with image-size, generates 64px-wide placeholder
   PNG with sharp, encodes as base64 data URI, writes JSON cache

Placeholder injection uses two mechanisms:
- mdxComponents mapping: wraps PixelatedImage with PixelatedImageWithPlaceholder
  that injects placeholder + dimensions from the manifest via closure. This handles
  JSX elements like <PixelatedImage src=...> in MDX, including when nested inside
  other components like <Bleed>. renderNode is NOT called for nested JSX children
  in safe-mdx, so the component mapping is the correct injection point.
- renderNode: converts markdown images ![alt](url) to PixelatedImage with placeholder

**Cache format** (.cache/images/, gitignored)

  { mtime: 1234567890, width: 1280, height: 800, placeholder: 'data:image/png;base64,...' }

Invalidation is mtime-based — any change to the source image regenerates the cache.
On warm runs (no source change), the manifest is built entirely from fast JSON reads.

**Files**

- website/src/lib/image-cache.ts (new): buildImageManifest(), collectImageSrcs(),
  getJsxAttr(), getAttrStringValue() — pure server utilities, no React
- website/src/pages/index.tsx: remove 'use client', make IndexPage async, move
  rendering logic inside component body, add PixelatedImageWithPlaceholder wrapper
- website/src/components/markdown.tsx: update PixelatedImage JSDoc to reflect
  the new auto-injection pattern
- website/package.json: add image-size dep, simplify build script (no more
  generate-placeholders.ts pre-step)
- deleted: website/scripts/generate-placeholders.ts
- deleted: website/src/assets/placeholders/placeholder-*.png (6 files)
2026-03-29 16:44:27 +02:00
Tommy D. Rossi 962e4f83b5 move flattenTocTree and TOC types from markdown.tsx to toc-tree.ts
markdown.tsx is a 'use client' module. Any function exported from it cannot
be called in a server component (RSC boundary). flattenTocTree and the TOC
type definitions (TocNodeType, VisualLevel, TocTreeNode, FlatTocItem) are
pure functions with no hooks or browser APIs, so they belong in toc-tree.ts
which has no 'use client' directive.

Changes:
- toc-tree.ts: add all TOC types and flattenTocTree (self-contained, no imports
  from markdown.tsx to break the circular dep)
- markdown.tsx: re-export the types from toc-tree.ts for backward compatibility,
  remove the now-duplicated definitions
- toc-tree.test.ts: update import to come from toc-tree.ts directly; also update
  6 stale inline snapshots (prefix spacing normalization from the prefix fix in
  addPrefixes — '└ ' → '└─ ')
2026-03-29 16:39:07 +02:00
Tommy D. Rossi 22ee8b3cf4 rename CSS variable --code-bg to --border-subtle
--code-bg was being used for both code block backgrounds and border colors,
which created semantic confusion. The usages in TocLink hover background,
SectionRow aside border, and SidebarBanner border are really border/surface
accents, not code backgrounds.

Rename to --border-subtle with adjusted values:
- light: rgba(0,0,0,0.02) → rgba(0,0,0,0.08) (more visible border)
- dark:  rgba(255,255,255,0.05) → rgba(255,255,255,0.12) (more visible border)

Note: --code-bg as a name still exists as a concept in the codebase where it
refers to actual code block backgrounds, those usages are separate.
2026-03-29 16:38:20 +02:00
Tommy D. Rossi 7a11505cbf self-host Inter and JetBrainsMono fonts instead of fetching from GitHub
Inter variable font v4.1 (roman, 344KB woff2) and JetBrainsMono Nerd Font
Mono v3.3.0 (regular, 2.3MB ttf) are now served from /fonts/ in public.

Removes runtime dependency on raw.githubusercontent.com for font loading.
2026-03-29 14:05:11 +02:00
Tommy D. Rossi 19d575c7e3 fix(ci): use pnpm/action-setup@v4 instead of @master
@master was installing pnpm 11.0.0-beta.3 instead of 10.18.1, which
doesn't support onlyBuiltDependencies from pnpm-workspace.yaml.
2026-03-29 14:01:48 +02:00
Tommy D. Rossi 778b332320 fix(ci): use --allow-build flags and add debug output 2026-03-29 14:00:37 +02:00
Tommy D. Rossi ab514dc1f2 fix(ci): pass onlyBuiltDependencies directly via CLI config flags
Config from pnpm-workspace.yaml is not being read in CI. Pass build
dependency settings directly on the command line as --config flags.
2026-03-29 13:59:18 +02:00
Tommy D. Rossi 59ce3b8416 fix(ci): pin pnpm to 10.18.1 to match local version
pnpm v10 early versions may not read onlyBuiltDependencies from
pnpm-workspace.yaml correctly. Pinning to 10.18.1 which is confirmed
to work locally.
2026-03-29 13:57:20 +02:00