Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,18 @@ jobs:
# documenting and survives a runner restructure.)
- name: Validate plugin/marketplace manifests
run: node test/manifest.test.js
# Static guard: worker/server and every injected chrome module must parse.
# (overlay.js is deleted — the monolith was split into these modules.)
# Static guard: worker/server, the shell boot builder, and the only script
# injected into author HTML (frame-probe) must parse. The provider UI
# itself is a React app under shell/src; its built runtime is checked by
# the `shell runtime` job below.
- name: Syntax check
run: |
node --check worker/worker.js
node --check server/server.js
node --check server/chrome.js
node --check server/shell.js
node --check server/frame-probe.js
node --check server/manage.js
node --check server/runtime-assets.js
node --check bin/tdoc-bundle
bash -n bin/tdoc-publish
bash -n bin/tdoc-pull
bash -n bin/tdoc-new
Expand All @@ -68,3 +70,28 @@ jobs:
# the dangerous classes (quoting/word-splitting/injection), not lint nits.
- name: ShellCheck CLIs
run: shellcheck --severity=warning --exclude=SC2034 bin/tdoc-publish bin/tdoc-doctor bin/tdoc-pull bin/tdoc-new bin/tdoc-unpublish bin/tdoc-update

shell-runtime:
name: shell runtime
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '20'
cache: npm
# The React provider UI (shell/src) is built by Vite into server/runtime/
# and the output is COMMITTED: skill users run server/server.js straight
# from the checkout, and bin/tdoc-bundle embeds the same bytes into the
# Worker — neither runs npm install. Vite's output is content-hashed and
# deterministic, so rebuilding must reproduce the committed bytes exactly.
# A diff here means someone edited shell/src without `npm run build:shell`.
- run: npm ci
- run: npm run build:shell
- name: Committed runtime matches shell/src
run: |
git add -N server/runtime
git diff --exit-code --stat server/runtime || {
echo "server/runtime is stale — run npm run build:shell and commit the result" >&2
exit 1
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ vercel/node_modules/
*.swo

# Node
node_modules
node_modules/
npm-debug.log
yarn-error.log
Expand Down
40 changes: 39 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,47 @@ Install the optional browser dep with `npm i -D playwright && npx playwright ins

`npm test` must be green before any commit to `main`.

## The provider UI is a React app — build it, then commit the build

The top bar, comment layer, dialogs, sign-in, and the `/me` Docs Hub live in
`shell/src` (React + Vite, headless primitives from `@base-ui/react` wrapped in
`shell/src/ui/`). Author HTML never sees React: it renders only inside the
sandboxed `/frame` iframe, where `server/frame-probe.js` is the sole script.

```bash
npm install # once — React, Vite, and the Base UI primitives
npm run dev:shell # Vite dev server for shell/src
npm run build:shell # writes server/runtime/{manifest.json, shell.<hash>.js, shell.<hash>.css}
```

Chrome changes are checked **against the version they replace**, not by eye
alone. `test/visual/` holds two Playwright harnesses that run the old and new
implementation side by side on the same fixture doc and write composite
screenshots per scene (bar, menus, dialogs, comment card, dark mode, phone
drawer, owner Share panel, /me):

```bash
git worktree add /tmp/tdoc-main origin/main
node test/visual/local-compare.js /tmp/cmp /tmp/tdoc-main . # Local Studio
SKILL_DIR=/tmp/tdoc-main OUT_DIR=/tmp/wb-old node bin/tdoc-bundle # hosted: bundle both
SKILL_DIR=$PWD OUT_DIR=/tmp/wb-new node bin/tdoc-bundle
node test/visual/hosted-compare.mjs /tmp/cmp-hosted /tmp/wb-old/_worker.bundled.js /tmp/wb-new/_worker.bundled.js
```

The rule for a migration or restyle: match the old chrome where it can be
matched; where it cannot, the new one still has to look finished.

`server/runtime/` is **committed on purpose**: skill users run
`server/server.js` straight from the checkout and `bin/tdoc-bundle` embeds the
same bytes into the Worker — neither runs `npm install`. The Vite output is
content-hashed and deterministic, so after any edit under `shell/src` run
`npm run build:shell` and commit the result together with the source. CI's
`shell runtime` job rebuilds and fails on a byte diff. See `IMPLEMENTATION.md`
for the runtime boundary and component layers.

## Hard rule: run tests before every push

The skill ships JS that runs in users' browsers and a worker that runs on Cloudflare, both deployed on every `/tdoc publish`. Run `npm test` before pushing; for overlay or worker changes also run the matching gated suite via `npm run test:all`. Doc-only changes still need a `grep` for stale references (counts, command names, version numbers).
The skill ships JS that runs in users' browsers and a worker that runs on Cloudflare, both deployed on every `/tdoc publish`. Run `npm test` before pushing; for shell (`shell/src`, `server/frame-probe.js`) or worker changes also run the matching gated suite via `npm run test:all`. Doc-only changes still need a `grep` for stale references (counts, command names, version numbers).

## AGENTS.md

Expand Down
219 changes: 74 additions & 145 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
@@ -1,145 +1,74 @@
# ARTIFACT-SHELL — INTEGRATED IMPLEMENTATION BLUEPRINT

Produced by a multi-agent design workflow (5 agents, code-grounded to real
line numbers). Branch `feat/artifact-shell-iframe`. Execute steps in order;
each is a single commit with its own verification. Boundary test
(`test/artifact-shell.test.js`) stays green at every step, flipping from
`?shell=1` to plain `/d/` only at Step 6.

## Milestones
- **Milestone 1 (this PR, mergeable): Steps 1–6** — server-side full shell at
1:1 chrome parity; single-path cut on the local server. Keeps `?shell=1` as
scaffolding until Step 6, then the doc route is shell-only.
- **Milestone 2: Step 7** — worker.js parity (published path) + bundle/CSP/
widget reconciliation + existing-doc bake migration rollout.

## Two shared contracts (freeze before writing code)

### Contract 1 — `TDOC_CHROME` global (`server/chrome.js`, Step 1)
Inlined nonced `<script>`, parses before any consumer. Pure strings + pure
functions; no `document`/`fetch`/`window.__TDOC__` at load. Markup byte-identical
to today's overlay output. Handlers are NOT in the module — each consumer wires
its own.
```
TDOC_CHROME = {
CSS, // chrome-only CSS (NO content-side selectors)
escapeHtml(s), avatarHtml(...),
buildBar({mode,slug,version,versions,originalSlug,isLanding,isCatalog}),
buildIdentity({identity,isPublished,canSeeMyDocs,isCatalog,inboxUnreadN,inboxMenuLabel}),
buildComposer({anchor,needsSignIn}),
buildCard(comment,...), buildPin(...), buildClusterPop(...),
buildFooter(), buildOldverStrip({version,latestVersion,latestUrl}), buildReanchorBanner(),
}
```

### Contract 2 — postMessage protocol (probe Step 4, shell Step 5)
Envelope: outbound `{source:'tdoc-frame'}`; inbound validated by
`e.source===window.parent`+`d.source==='tdoc-shell'` (probe) /
`e.source===frameWin()`+`d.source==='tdoc-frame'` (shell). Opaque origin ⇒
identity+shape validation only.

frame→shell: `tdoc:ready{height,metrics}`, `tdoc:selection{kind,text?,context_before,context_after,label?,aid?,fingerprint?,selector?,fallback,rect,placeAbove?}`, `tdoc:cleared`, `tdoc:layout{rows[],metrics,scrollY,height}`, `tdoc:metrics{metrics,height,scrollY}`, `tdoc:scroll{scrollY,height}`, `tdoc:anchorClick{id}`, `tdoc:docMarkdown{markdown,requestId}`.
shell→frame: `tdoc:anchors{comments[]}`, `tdoc:setActive{id,scroll}`, `tdoc:scrollTo{docY}`, `tdoc:composerClosed{}`, `tdoc:copyDoc{includeComments,requestId}`, `tdoc:theme{...}`.
`Row = {id,kind,anchored,docY,elKey?,elTop?,elHeight?}`; `Metrics = {articleTop,articleHeight,articleLeft,articleRight,articleWidth}` (page coords).

---

## Step 1 — Extract the shared chrome module (no visual change)
New `server/chrome.js` (Contract 1), extracted verbatim from overlay.js:
escapeHtml (`909-911`), bar assembly (`926-1006`), identity (`1492-1503`,`1531`),
composer (`3380-3387`), card (`buildCard` `2059+`), footer (`1648-1654`),
oldver strip (`1019`), reanchor banner (`1031-1035`), pin/cluster markup. Closure
vars → explicit params.
- overlay.js: move content-side CSS (`::highlight` `663-673`, `.tdoc-anchor-mark*`
`674-681`, hover-outline/pill/marquee `682-709`) into the content zone; wrap
chrome CSS `410-866` in `TDOC_CHROME_CSS_START/END`; replace `const css`
(`242-868`) with `TDOC_CHROME.CSS + content/reader/catalog CSS`; replace
bar/identity/composer/card/footer markup with `TDOC_CHROME.*` calls; keep ALL
handlers (`1046-1260`,`1504-1532`) unchanged.
- server.js: `CHROME_PATH` beside `OVERLAY_PATH` (`15`); inline chrome.js nonced
before overlay in `injectOverlay` (`432-472`,`445`).
- worker.js: `const CHROME_JS = ` placeholder (`16`); prepend in `injectOverlayCfg`
(`1070-1080`,`1077`).
- bin/tdoc-bundle: read chrome.js; `__TDOC_CHROME_JS__` replace mirroring
OVERLAY_JS (`51-58`) fail-closed; fold `chrome_sha` into build/bundleSha.
Verify: browser-bundles-parse (add chrome.js `24`), no-drift, ui.test (/me,/ still
overlay path — unchanged), boundary green. Manual: /me on :7900 identical.

## Step 2 — Shell consumes the module for static chrome (visible 1:1 bar early)
server.js `shellDocument` (`291`): compute real mode/identity/versions like
injectOverlay (not literal `mode:'shell'` `300`); inline chrome.js nonced before
shellScript (`330`); delete hand-rolled `<style>` (`304-324`) → `TDOC_CHROME.CSS`.
`shellScript` (`337`): bar/footer/identity via `TDOC_CHROME.build*`; wire
shell-safe handlers (theme, version nav, copy-menu toggle, share/publish/
duplicate/download, ⋯ menu, sign-in/out/inbox, drawer) copied from overlay
`1046-1260`,`1504-1532`,`1608-1643`. Mode table: local→Publish; published→Share+
Duplicate+Download+⋯; fork→Download+⋯; site→suppressed.
Verify: boundary adds bar-parity asserts (logo/version/Copy) still `?shell=1`.

## Step 3 — Model-B bake tooling + fixtures + SKILL (independent)
New `bin/tdoc-bake`: slice reader template `overlay.js:276-408`, inject after
`<head>` as `<style id="tdoc-reader-baked" data-tdoc-bake="v1">`, idempotent,
`--root/--dry-run/--force`, viewport ensure, non-zero exit on unparseable. Bake +
commit `test/fixtures/tdocs`. SKILL.md: supersede "trust injected template"
(`284-285`,`754-777`,`831`,`842`,`848-849`); add `<style>` reference block from
the marker slice. New `test/skill-template-sync.test.js` (byte-match). Bake
idempotency test.

## Step 4 — Probe: port the real content-DOM engine (Contract 2 frame side)
frame-probe.js: replace union `selectionRect` (`32-42`) with
`selectionEndRect`+`endRectOnLine`+`clientRectNearPoint`+`firstVisibleClientRect`
(overlay `3960/232/223/211`) — fixes "歪". Replace `context` (`43-49`) with
`getContext`/`collectTextNodes` (`3496-3511`); add `captureFallbackPosition`
(`3472`); pending highlight (`3338`). Resolve+highlight: port `findTextRange`
(+`rangeFromNormalizedOffsets`,`normalizeNeedle`), `matchByAid`/`findElement`,
`rebuildSharedHighlights` (`891`); emit `tdoc:layout{rows,metrics}` via
`commentY`(`2354`)+`getArticleMetrics`(`2299`)+`gutterGeometry`(`2331-2350`);
stamp elKey. Hit-test `findCommentAtPoint`(`2908`) → `tdoc:anchorClick`. Commands
in: setActive/composerClosed/theme/copyDoc(`htmlToMarkdown` `4174`). rAF-throttle
resize `tdoc:layout`.

## Step 5 — Shell wiring parity (Contract 2 shell side)
shellScript: `shell.state` mirror; measure BAR from `offsetHeight` (not 48);
port `layoutPins`(`2407` pure)/renderPins/positionFloatingCard/buildCard/
openClusterPopover/evaluateLayout/repositionCards/setActiveComment with edits
(document→shell doc; Y=`BAR+docY−frameScrollY` fixed; rects→rowById; setActive→
`tdoc:setActive`). Composer submit POSTs; close→composerClosed. Copy→copyDoc→
append comments→clipboard(execCommand fallback). Theme paints shell + posts
`tdoc:theme`. Re-anchor via next `tdoc:selection`. scroll→cheap replace;
layout→full evaluateLayout.
Verify: ui.test comment gestures (frame gesture + shell chrome), pins-layout green.

## Step 6 — Single-path cut (server) + flip tests
server.js: delete `?shell=1` gate (`778-783`) → doc route always `shellDocument`;
delete `injectOverlay` (`432-472`) + call (`785`); keep `/frame` (`735-766`).
`cspHeader` (`241-243`): add `frame-src 'self'`. Flip tests to plain `/d/`+`/frame`:
artifact-shell (`33`→plain), csp-headers (`frame-src`, author-inertness→/frame),
csp-xss (author inertness inside child frame; comment E2E drag-in-frame),
widget-island (`148` /frame), ui.test (content→`frame.evaluate`).

## Step 7 — Worker parity + bundle/CSP/widget (Milestone 2)
bin/tdoc-bundle: add FRAME_PROBE_JS read+placeholder (fail-closed); fold probe_sha.
worker.js: `FRAME_PROBE_JS` placeholder (`16`); `frameCspHeader` after
widgetCspHeader (`1035`); `/frame` route before doc route (`~3215`) mirroring
server `735-766` PLUS `enforceDocAccess` gate (private-doc leak); rewrite
`serveDocVersion` tail (`1206-1212`) → `shellDocument(...)` with published cfg;
carry SIGNIN_JS+ONBOARD_JS nonced; copy shellDocument/shellScript verbatim
(matches existing cspHeader duplication model); add `frame-src 'self'` to
`cspHeader` (`1022-1024`). Delete `injectOverlay` (`1103-1138`); rework 3 callers
(/me catalog inline-content shell variant; fork drop-overlay-boot [recommended]
or fork-only rename; keep readerCss for /export). **Widget CSP fix (highest
impact):** drop `frame-ancestors 'self'` from `widgetCspHeader` (`1033-1035`) —
widget is now grandchild of an opaque-origin frame so `'self'` never matches and
silently breaks ALL islands; rely on Sec-Fetch-Dest + `sandbox allow-scripts`.
Tests: csp-headers source-scan (`146-167`), browser-bundles-parse (add
frame-probe.js `24`), widget-island (`219-225`).

## Top risks (see workflow output for the full table)
Coordinate translation (measure real BAR; Y=BAR+docY−frameScrollY, shell body
never scrolls) · selection "歪" (port selectionEndRect) · sign-in across origin
(shellDocument computes real cfg; ship SIGNIN_JS nonced) · Copy-needs-doc-text
(probe owns htmlToMarkdown) · highlight color drift (one shared ::highlight
constant) · CSP frame-src (both files) · **widget frame-ancestors silent breakage**
(drop it) · dark mode (paint shell + post tdoc:theme) · /frame private-doc leak
(worker enforceDocAccess) · three-way template drift (skill-template-sync test) ·
bundle silent no-op (fail-closed guards + bundleSha).
# Provider UI Implementation

The provider-owned reader UI is a React application built by Vite. Author HTML
remains framework-independent and is rendered only in the sandboxed document
frame.

## Runtime boundary

- `server/shell.js` emits an empty React root, structured boot JSON, and the
content-hashed runtime asset tags. It contains no product UI markup.
- `shell/src/main.jsx` selects the document shell, Docs Hub, neutral landing,
or status page from the boot discriminator.
- `server/frame-probe.js` is the only runtime installed in author HTML. It owns
selection, anchor discovery, copy extraction, theme application, and the
`postMessage` bridge. It has no React dependency.
- Local and Worker document routes use the same shell builder and the same
`/d/:slug/v/:version/frame` isolation boundary.
- Remote storage remains the source of truth. React receives only provider-
enforced capabilities and data; author HTML never defines access policy.

## Component layers

Reusable headless primitives live under `shell/src/ui/`:

- `AppDialog`: Base UI dialog, portal, focus management, Escape, and backdrop.
The popup carries chrome.css's `.tdoc-modal` class, so every dialog keeps the
legacy modal's type, spacing, buttons, and dark-mode treatment.
- `AppMenu` / `AppMenuItem`: Base UI anchored menus and keyboard behavior,
styled to the legacy `.tdoc-menu` metrics (13px rows, 7px/10px padding).
- `SegmentedControl`: option sets used by access policy controls (`.tdoc-seg`).

`server/chrome.css` stays the single source of truth for how the reader chrome
looks; `shell/src/ui/ui.css` only adds what portal-rendered primitives need on
top of it (positioning, resets for controls that used to be spans). Visual
parity with the pre-React chrome is checked with the side-by-side harnesses in
`test/visual/` (see CONTRIBUTING.md).

Provider features build on those primitives:

- `TopBar`: theme, identity, sign-in, notifications, and site navigation.
- `document/`: toolbar, dialogs, comments, pin layers, access management, and
pure API/model modules.
- `hooks/`: comments, frame bridge, notification, and Docs Hub state boundaries.
Every session mutation runs through the hook so a failure is always a toast
and a 401 always reaches the sign-in path — page components never catch.
- `DocsHub`: page orchestrator over `useDocsHub` and the shared row components
in `docs-hub/rows.jsx` (`DocRow`, `FolderRow`, `RowMenu` on `AppMenu`).
- `SignInDialog` / `OnboardingDialog`: reusable cross-surface flows.

`document-shell.jsx` is the page-level orchestrator. It coordinates feature
hooks and components but does not build HTML strings or contain server policy.

## Build and deployment

`npm run build:shell` writes a Vite manifest plus hashed JavaScript and CSS to
`server/runtime/`. `server/runtime-assets.js` resolves those assets for the
local server. `bin/tdoc-bundle` embeds the same bytes in the Worker and replaces
placeholders through callbacks so minified dollar-prefixed sequences are not
interpreted as `String.replace` replacement tokens.

The Worker serves the hashed runtime paths directly. CSP nonces cover boot and
module tags; the author frame has its own sandbox CSP. Widget islands remain a
separate destination-gated nested-frame route.

## Verification

- `npm test`: policy, storage, boot-data, bundling, and behavior suites.
- `test/artifact-shell.test.js`: end-to-end shell/frame/comment boundary.
- `test/responsive.test.js`: desktop through phone layout invariants.
- `test/ui.test.js`: React primitives, document actions, and Docs Hub smoke.

TypeScript is intentionally deferred. The current API, model, hook, and
component boundaries are the migration units; conversion should not change the
runtime protocol or server boot shapes.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ Same document snapshots; different roles (see [AGENTS.md](AGENTS.md)):
hosted comments. **Source of truth.** Document delete/unpublish/management
targets remote storage via skill + API, not a particular local port UI.

Overlay code is bundled into the hosted runtime at publish time from the skill
checkout; local overlay edits only affect Local Studio until the next publish
redeploy. Published pages also expose the bundled runtime provenance at
The reader shell (a React app under `shell/src`, prebuilt into
`server/runtime/`) is bundled into the hosted runtime at publish time from the
skill checkout; local shell edits only affect Local Studio until the next
publish redeploy. Published pages also expose the bundled runtime provenance at
`/api/runtime` and in `window.__TDOC__.runtime`.

## How comments work
Expand Down
Loading