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
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"
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
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).
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.
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).
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.
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.
- 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)
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
- 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
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>.
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).
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.
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.
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.
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
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)
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).
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
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().
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
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/
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
- 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)
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.
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
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
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  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  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)
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 — '└ ' → '└─ ')
--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.
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.