diff --git a/docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md b/docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md index f95a14f6cc8..92122b6d95d 100644 --- a/docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md +++ b/docs/design/2026-07-14-web-shell-readonly-daemon-transcript.md @@ -137,7 +137,7 @@ Notes: Example: ```tsx -import { WebShellTranscript } from '@qwen-code/web-shell'; +import { WebShellTranscript } from '@qwen-code/web-shell/transcript'; import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; export function HistoryView({ diff --git a/docs/verification/export-html-runtime-size/README.md b/docs/verification/export-html-runtime-size/README.md new file mode 100644 index 00000000000..ddcf87aef68 --- /dev/null +++ b/docs/verification/export-html-runtime-size/README.md @@ -0,0 +1,309 @@ +# `/export html` runtime size — verification plan (#11031 / PR #11038) + +**Audience:** an agent (or person) on a machine that can run `npm install`, vite/esbuild +builds, vitest and Playwright. The machine that wrote the change could not, so **every +number and every test result below is unverified** — that is the whole point of this +document. + +This file lives on the PR branch itself, so the only thing you need to be handed is the +branch name: + +```bash +git clone --depth=1 --branch issue-11031 https://github.com/QwenLM/qwen-code.git +# then read docs/verification/export-html-runtime-size/README.md +``` + +Commit your findings next to it as `results.md` (the `abort-controller-refactor/` +package in this directory is the shape to follow) and/or reply on the PR. + +- PR: (branch `issue-11031`) +- Issue: +- Commits under test: + - `9515e5b78d` — PR author's original fix (transcript-only subpath entry + byte budget) + - `a9ff4f485b` — follow-up: strip Shiki, drop CodeMirror, structural guard, CSS-key fix + +Read §6 first if you only have time for one thing: two constants in the repo are +**known to be stale** and must be replaced with measured values. + +--- + +## 1. What changed and why it needs measuring + +| Change | Where | What it should do | +| ------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Transcript-only entry `@qwen-code/web-shell/transcript` | `packages/web-shell/client/transcript.ts` | Keeps `App`, daemon providers and app chrome out of the export | +| Shiki resolved to a stub in the document build | `packages/web-templates/src/export-html/build.mjs`, `src/document-shiki-stub.ts` | Removes ~9.7 MB of pre-minify input that document mode never executes | +| Composer-tag getters moved out of the CodeMirror module | `packages/web-shell/client/utils/composerTag.ts` | Removes ~1 MB of CodeMirror from the transcript graph | +| Structural guard on the document bundle | `FORBIDDEN_DOCUMENT_INPUTS` in `build.mjs` | Fails the build if Shiki / the web-shell package root / CodeMirror reappear | +| Per-entry CSS injection key | `packages/web-shell/vite.lib.config.ts`, `client/shadowDom.ts` | Stops the transcript stylesheet from suppressing the full one | + +The two size claims in the table are **estimates read off the issue's triage comment**, +not measurements. Steps 3 and 4 turn them into facts. + +Rationale for stubbing Shiki (verify this premise, see §5.3): `CodeBlock` in +`packages/web-shell/client/components/messages/Markdown.tsx` returns before touching the +highlighter when `renderMode === 'document'`, and its render branch always emits a plain +`
` in that mode. The export's CSP (`script-src 'nonce-…'`, no `'wasm-unsafe-eval'`)
+would also block Shiki's Oniguruma WASM engine from starting.
+
+---
+
+## 2. Setup
+
+```bash
+cd 
+git log --oneline -2          # expect a9ff4f485b (or later) on top of 9515e5b78d
+npm install                   # runs every workspace build as postinstall; ~25 min
+```
+
+If `npm install` did not build the workspaces, build the two that matter, in this order
+(`web-templates` resolves `@qwen-code/web-shell/transcript` to `web-shell`'s `dist/`, so
+web-shell must be built first):
+
+```bash
+npm run build --workspace=@qwen-code/web-shell
+npm run build --workspace=@qwen-code/web-templates
+```
+
+`web-shell`'s build now runs **three** vite passes (app, lib, lib `--mode transcript`)
+plus `tsc`. Note the wall-clock time — if the third pass adds more than ~90 s, say so;
+it is a cost the PR pays on every CI build.
+
+---
+
+## 3. Primary measurement — export size
+
+```bash
+cd packages/web-templates
+EXPORT_HTML_METAFILE=/tmp/document-metafile.json node src/export-html/build.mjs
+```
+
+The build prints three lines that matter:
+
+```
+Document export top inputs (pre-minify bytes):  , ...
+Document export runtime is  bytes
+```
+
+Then measure the generated templates (the actual `/export html` file is the template
+plus a small data envelope):
+
+> **Changed by #9812 (merged 2026-09-05).** The renderer is no longer inlined into
+> `document.html`. That file is now a small template that loads a version-pinned,
+> SRI-protected `export-transcript-document.js` from unpkg, and the legacy
+> `index.html` renderer is gone. **Measure the asset, not the template** — it is the
+> download every reader of an exported file now pays before the transcript renders.
+
+```bash
+wc -c src/export-html/dist/export-transcript-document.js   # the renderer asset — the number that matters
+gzip -9 -c src/export-html/dist/export-transcript-document.js | wc -c
+wc -c src/export-html/dist/document.html                   # template only; now small
+```
+
+**Record all of these.** Known reference points, all from the PR author's machine:
+
+All rows below were taken **before #9812**, when the runtime was still inlined, so
+`document.html` raw ≈ the runtime. Compare them against the new
+`export-transcript-document.js` asset, not against the new `document.html`.
+
+| Revision                      | `document.html` raw |      gzip | inline runtime |
+| ----------------------------- | ------------------: | --------: | -------------: |
+| `main` (before any fix)       |          19,525,807 | 4,775,943 |     19,523,259 |
+| `9515e5b78d` (PR as reviewed) |          17,966,485 | 4,512,650 |     17,963,937 |
+| `a9ff4f485b` (with follow-up) |               **?** |     **?** |          **?** |
+| legacy renderer `index.html`  |             311,854 |         — |              — |
+
+To get the middle row on your own machine for a like-for-like delta:
+
+```bash
+git stash list; git -C ../.. checkout 9515e5b78d -- .   # or: git checkout 9515e5b78d && rebuild
+```
+
+Cleanest is a second clone at `9515e5b78d`, built the same way — the numbers above came
+from a different machine and a different `node_modules`.
+
+### What "good" looks like
+
+There is no target number, only a direction: the follow-up commit should remove Shiki
+and CodeMirror entirely, so **expect a multi-MB drop**, not a few hundred KB. If the
+drop is under ~2 MB, something did not take effect — check the top-inputs line for
+`shiki` / `@shikijs` / `codemirror` (they should be absent) and report it.
+
+---
+
+## 4. Input breakdown — where the remaining bytes are
+
+```bash
+node -e '
+const m = require("/tmp/document-metafile.json");
+const by = new Map();
+for (const [k, v] of Object.entries(m.inputs)) {
+  const p = k.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//);
+  const key = p ? p[1] : "first-party";
+  by.set(key, (by.get(key) ?? 0) + v.bytes);
+}
+for (const [k, v] of [...by].sort((a,b)=>b[1]-a[1]).slice(0,25))
+  console.log(String(v).padStart(10), k);
+'
+```
+
+**Report this whole table.** It is the input to the open product decision in §7, and it
+is the first thing anyone will want when the next size regression lands.
+
+Expected shape after the follow-up: `mermaid`, `echarts`/`zrender`, `lucide-react`,
+`react-markdown` + `remark-*`/`rehype-*`, `katex`. Expected **absent**: `shiki`,
+`@shikijs/*`, `codemirror`, `@codemirror/*`, `vaul`, `@qwen-code/web-shell/dist/index.js`.
+
+---
+
+## 5. Correctness checks
+
+### 5.1 Tests
+
+```bash
+# web-shell — the moved module, the shadow-DOM reader, the new build assertions
+npx vitest run --config vitest.config.ts \
+  client/build-artifact.test.ts \
+  client/utils/composerTag.test.ts \
+  client/hooks/useComposerCore.test.ts \
+  client/hooks/useComposerCore.dom.test.tsx \
+  client/hooks/useComposerCore.mobile.dom.test.tsx \
+  client/components/messages/UserMessage.test.tsx \
+  client/components/messages/Markdown.test.ts \
+  client/components/messages/Markdown.coldHighlight.test.ts \
+  client/components/messages/Markdown.mermaid.test.ts \
+  client/shadowDom.test.ts \
+  client/index.test.tsx
+npm run typecheck --workspace=@qwen-code/web-shell
+
+# cli — the export formatter and document envelope
+npx vitest run \
+  src/ui/utils/export/formatters/html.test.ts \
+  src/ui/utils/export/export-transcript-document.test.ts
+npm run typecheck --workspace=@qwen-code/qwen-code
+```
+
+`client/build-artifact.test.ts` reads `packages/web-shell/dist/*.js`, so it only means
+anything **after** a web-shell build. Its three new cases are the ones to watch:
+they assert `dist/transcript.js` has no `@codemirror/` / `codemirror` / `vaul`, still
+carries `react-markdown` and `WebShellTranscript`, and that the two entries inject their
+stylesheets under distinct `data-qwen-web-shell-entry` keys.
+
+If a `not.toContain` assertion fails, **do not relax it** — it means a module graph
+reopened. Report the failing string and, from the metafile, what pulled it back in.
+
+### 5.2 Rendering parity (the important one)
+
+Removing Shiki and CodeMirror must be invisible in the rendered output. Reproduce the
+PR's own parity method:
+
+1. Take one export envelope containing a user message, an assistant markdown message
+   with a fenced code block (with a language tag), a KaTeX expression, a list, and a
+   mermaid diagram.
+2. Inject it into `document.html` built at `9515e5b78d` and at `a9ff4f485b`.
+3. Open both over `file://` in headless Chromium (Playwright is already a devDependency
+   in `packages/web-shell`).
+4. Compare: `document.body.dataset.renderComplete === 'true'`, the metadata sidebar,
+   theme toggle, expand/collapse, the code block **and its language label**, the KaTeX
+   output, the mermaid diagram, and full-page screenshots (md5).
+
+**Mermaid is the one to watch.** Unlike Shiki, mermaid _is_ used in document mode
+(`Markdown.tsx` has an explicit `documentMode` branch around `mermaid.render`), and
+nothing in these commits should have touched it. If a mermaid diagram renders on
+`9515e5b78d` but not on `a9ff4f485b`, that is a blocking regression — report it with the
+console output.
+
+Also confirm the **console is clean**: no `shiki is not bundled into /export html
+documents` error. That string appearing means document mode reached the highlighter
+after all and the premise in §5.3 is wrong.
+
+### 5.3 Premise check — is Shiki really dead in document mode?
+
+Independent of the screenshots, confirm the claim directly on `9515e5b78d`
+(i.e. _before_ the stub, where the real Shiki is still bundled):
+
+- Open an export containing a fenced code block with a language tag.
+- Inspect the code block's DOM. It should be a plain `
` with no per-token
+  `` markup.
+- Check the console for a CSP violation mentioning WebAssembly.
+
+If instead you find highlighted tokens, **stop and report** — the Shiki stub would then
+be a behaviour change, not dead-code removal, and the follow-up commit needs reverting
+in part.
+
+### 5.4 The interactive app still works
+
+`shadowDom.ts` and `UserMessage.tsx`/`useComposerCore.ts` are on the live app path:
+
+```bash
+npm run test:e2e:smoke --workspace=@qwen-code/web-shell
+```
+
+Manual smoke, if the harness is available: open the web shell, type an `@` mention (the
+CodeMirror composer and its tag chips), send it, and confirm the sent user message
+renders its tag chips with the right labels — those chips go through the three getters
+that moved.
+
+---
+
+## 6. Two constants that are known stale — please fix
+
+`packages/web-templates/src/export-html/build.mjs`:
+
+```js
+const DOCUMENT_RUNTIME_WARNING_BYTES = 18_500_000;
+const MAX_DOCUMENT_RUNTIME_BYTES = 19_000_000;
+```
+
+These were set against the `9515e5b78d` baseline of 17,963,937 and are now far above the
+real size. Once you have the measured `Document export runtime is N bytes` from §3:
+
+- `MAX_DOCUMENT_RUNTIME_BYTES` → roughly `N * 1.05`, rounded up to a readable number
+- `DOCUMENT_RUNTIME_WARNING_BYTES` → roughly `N * 1.02`
+
+and update the comment block above them with the measured baseline and the commit it was
+measured at. A cap that sits 6 % above the truth is a ratchet; one that sits 40 % above
+it is decoration.
+
+---
+
+## 7. Open decision to inform, not to make
+
+After Shiki and CodeMirror are gone, the remaining payload is dominated by **mermaid
+(~6 MB) and echarts (~4.3 MB)** — both genuinely used by document mode. Removing them
+(rendering diagrams and charts as their source fences in exports) is the only remaining
+way to approach the legacy renderer's 311,854 bytes without the CDN/release-asset
+direction the issue proposed, which collides with the export's deliberate
+offline/self-contained/nonce-only CSP design.
+
+That is a product call, not a build fix. What is needed from this run is just the §4
+table: the real cost of each, measured, so the call can be made on numbers.
+
+---
+
+## 8. What to report back
+
+Write it to `docs/verification/export-html-runtime-size/results.md` on the branch (and
+commit it — that is what makes the next person's job cheap), then summarise on the PR.
+
+1. The filled-in row of the §3 table, plus the delta against `9515e5b78d`.
+2. The §4 top-25 input table verbatim.
+3. Pass/fail for every command in §5.1, with output for anything red.
+4. §5.2 parity result: identical / different (with screenshots or a diff).
+5. §5.3 premise: confirmed dead / **not** dead.
+6. The two values you chose in §6, and whether you pushed that change.
+7. Anything in the PR description at
+    that your numbers contradict — the
+   current description's before/after table and its claim that the editor chrome and
+   daemon SDK client "no longer ship" are both known to be wrong and need rewriting.
+
+### Known-wrong claims in the current PR description
+
+- "the remaining interactive-shell dead weight (App, daemon SDK client, editor/terminal
+  chrome, app-only stylesheet portion) no longer ships" — **CodeMirror did still ship**
+  at `9515e5b78d` (via `UserMessage → hooks/useComposerCore`); the follow-up commit is
+  what removes it.
+- The **daemon React SDK still ships** even after the follow-up. It is reachable from
+  the transcript at five points, including `client/hooks/useMessages.ts`, which
+  `WebShellTranscript` imports directly. Roughly 236 KB; deliberately left alone.
+- The before/after size table is stale as of `a9ff4f485b`.
diff --git a/eslint.config.js b/eslint.config.js
index 7a9f5282330..59e613533d6 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -419,6 +419,8 @@ export default tseslint.config(
       'packages/*/scripts/**/*.js',
       'packages/*/scripts/**/*.mjs',
       'packages/*/build.mjs',
+      // web-templates' export-html template build scripts also run with `node`.
+      'packages/*/src/export-html/*.mjs',
       // Verification reproducer scripts under docs/ also run with `node`.
       'docs/**/*.mjs',
       // Plan C CDP-tunnel acceptance harness (issue #5626) runs with `node`.
diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md
index d03c9771b9e..16466ff8e50 100644
--- a/packages/web-shell/README.md
+++ b/packages/web-shell/README.md
@@ -181,9 +181,12 @@ export function App() {
 审批或 session mutation。浏览器宿主可以逐行解析 JSONL,再通过 SDK 的 opt-in facade
 投影:
 
+> 只渲染 transcript 的宿主请从 `@qwen-code/web-shell/transcript` 子路径导入。包根会连带
+> `App`、daemon providers 和编辑器/终端相关代码,不要依赖 tree shaking 把它们摇掉。
+
 ```tsx
 import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript';
-import { WebShellTranscript } from '@qwen-code/web-shell';
+import { WebShellTranscript } from '@qwen-code/web-shell/transcript';
 
 const records = jsonl
   .split(/\r?\n/)
diff --git a/packages/web-shell/client/adapters/localizedMessages.ts b/packages/web-shell/client/adapters/localizedMessages.ts
new file mode 100644
index 00000000000..50bdecfdcc6
--- /dev/null
+++ b/packages/web-shell/client/adapters/localizedMessages.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Leaf module for projecting transcript blocks into localized messages.
+ *
+ * This lives outside `hooks/useMessages.ts` on purpose. That module is a daemon
+ * consumer — it value-imports `useConnection` / `useTranscriptBlocks` /
+ * `useWorkspace` from the `daemon-react-sdk` barrel — so importing this one
+ * function from it dragged the daemon provider stack into the read-only
+ * transcript entry, which advertises the opposite. Same treatment as
+ * `utils/composerTag.ts`: the pure projection lives in a module with no daemon
+ * or editor imports, and `useMessages.ts` re-exports it for existing callers.
+ *
+ * Keep this module free of React hooks and of anything reaching
+ * `@qwen-code/web-shell/daemon-react-sdk`. `client/build-artifact.test.ts`
+ * asserts that `dist/transcript.js` carries no daemon provider code.
+ */
+
+import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
+import { transcriptBlocksToDaemonMessages } from './transcriptToMessages';
+import type { Message } from './types';
+
+export type Translator = (
+  key: string,
+  vars?: Record,
+) => string;
+
+export function transcriptBlocksToLocalizedMessages(
+  blocks: readonly DaemonTranscriptBlock[],
+  t: Translator,
+  safeToolProjection = false,
+): Message[] {
+  return transcriptBlocksToDaemonMessages(blocks, {
+    safeToolProjection,
+    includeSourceIdentity: true,
+    labels: {
+      promptCancelled: t('request.cancelled'),
+      branchSuccess: (name) => t('branch.success', { name }),
+      modelStreamInterrupted: t('error.modelStreamInterrupted'),
+      loopDetected: t('error.loopDetected'),
+    },
+  });
+}
diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts
index 85bf021e5ed..6a49cdc197b 100644
--- a/packages/web-shell/client/build-artifact.test.ts
+++ b/packages/web-shell/client/build-artifact.test.ts
@@ -5,11 +5,16 @@ import postcss, { type Rule } from 'postcss';
 
 const DIST_DIR = resolve(__dirname, '../dist');
 const DIST_PATH = resolve(DIST_DIR, 'index.js');
+const TRANSCRIPT_DIST_PATH = resolve(DIST_DIR, 'transcript.js');
 
 function readBundle(): string {
   return readFileSync(DIST_PATH, 'utf8');
 }
 
+function readTranscriptBundle(): string {
+  return readFileSync(TRANSCRIPT_DIST_PATH, 'utf8');
+}
+
 function readPackageJavascript(): string {
   return readdirSync(DIST_DIR)
     .filter((fileName) => fileName.endsWith('.js'))
@@ -17,10 +22,8 @@ function readPackageJavascript(): string {
     .join('\n');
 }
 
-function readInjectedCss(): string {
-  const match = readBundle().match(
-    /^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");/,
-  );
+function readInjectedCss(bundle = readBundle()): string {
+  const match = bundle.match(/^const __qwenWebShellCss=("(?:[^"\\]|\\.)*");/);
   if (!match?.[1]) throw new Error('Injected component CSS not found');
   return JSON.parse(match[1]) as string;
 }
@@ -303,3 +306,102 @@ describe('build artifact — package boundary', () => {
     expect(hasInlineFont).toBe(true);
   });
 });
+
+describe('build artifact — transcript entry (#11031)', () => {
+  // `@qwen-code/web-shell/transcript` exists so the self-contained
+  // `/export html` document renderer can bundle the read-only transcript
+  // without the interactive shell. Nothing here is enforced by tree shaking:
+  // the package root injects its stylesheet as a top-level side effect, which
+  // no bundler can drop, so the boundary has to be a real entry point.
+  it('does not pull the editor stack into the transcript entry', () => {
+    // Import specifiers of externals survive minification verbatim, so their
+    // absence is a reliable signal that the module never entered the graph.
+    // The signal lives in the JS only: injectCssModules (vite.lib.config.ts)
+    // prepends the stylesheet as a single-line `__qwenWebShellCss` constant,
+    // and Tailwind v4 compiles classes from every scanned source file rather
+    // than this entry's graph, so the CSS carries e.g. drawer.tsx's
+    // `data-[vaul-drawer-direction=…]` selectors even though no transcript JS
+    // imports vaul. Guard the JS remainder; if the injection shape changes
+    // the replace() is a no-op and these checks fail loudly, not falsely.
+    const js = readTranscriptBundle().replace(
+      /^const __qwenWebShellCss=[^\n]*\n/,
+      '',
+    );
+    expect(js).not.toContain('@codemirror/');
+    expect(js).not.toContain('"codemirror"');
+    expect(js).not.toContain('vaul');
+  });
+
+  it('keeps the transcript entry a fraction of the interactive entry', () => {
+    // The daemon hook runtime is NOT absent from this entry — MessageList
+    // renders McpStatusMessage, TasksStatusMessage and the artifact turn
+    // outputs, and those call the strict useDaemonActions /
+    // useDaemonWorkspace hooks, so the provider guards ship. Asserting their
+    // absence would assert something this entry does not deliver (see the
+    // docblock in client/transcript.ts and #11100).
+    //
+    // What the entry does deliver is a bounded payload, so bound it. The JS
+    // remainder measured 1,140,948 bytes at 1d94060f5 (a reviewer's local
+    // build of this branch), against 7,021,715 for dist/index.js in the same
+    // build. The ceiling is that measurement plus headroom; re-measure and
+    // lower it if the entry gets leaner.
+    const js = readTranscriptBundle().replace(
+      /^const __qwenWebShellCss=[^\n]*\n/,
+      '',
+    );
+    expect(js.length).toBeLessThan(1_300_000);
+  });
+
+  it('still carries what a transcript actually renders', () => {
+    const bundle = readTranscriptBundle();
+    expect(bundle).toContain('react-markdown');
+    expect(bundle).toContain('WebShellTranscript');
+  });
+
+  it('injects its stylesheet under its own entry key', () => {
+    // Separate rollup runs produce different stylesheets per entry, so the
+    // injection guard is keyed per entry — a shared key would let whichever
+    // entry loaded first suppress the other's rules.
+    expect(readBundle()).toContain('data-qwen-web-shell-entry="index"');
+    expect(readTranscriptBundle()).toContain(
+      'data-qwen-web-shell-entry="transcript"',
+    );
+    // Both keep the shared marker that shadow-root style adoption reads.
+    expect(readTranscriptBundle()).toContain(
+      's.dataset.qwenWebShell="component"',
+    );
+  });
+
+  it('keeps KaTeX border overrides after Tailwind preflight', () => {
+    for (const bundle of [readBundle(), readTranscriptBundle()]) {
+      const rules: Rule[] = [];
+      postcss
+        .parse(readInjectedCss(bundle))
+        .walkRules((rule) => rules.push(rule));
+      const preflightIndex = rules.findIndex(
+        (rule) =>
+          rule.selector.includes('[data-web-shell-shadcn]') &&
+          rule.selector.includes('::backdrop') &&
+          rule.nodes.some(
+            (node) =>
+              node.type === 'decl' &&
+              node.prop === 'border-color' &&
+              node.value === 'var(--border)',
+          ),
+      );
+      const katexIndex = rules.findIndex(
+        (rule) =>
+          rule.selector.includes('.katex *') &&
+          rule.nodes.some(
+            (node) =>
+              node.type === 'decl' &&
+              node.prop === 'border-color' &&
+              node.value === 'currentColor',
+          ),
+      );
+
+      expect(preflightIndex).toBeGreaterThanOrEqual(0);
+      expect(katexIndex).toBeGreaterThan(preflightIndex);
+    }
+  });
+});
diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx
index 67bffe86ee5..f1641cd4db1 100644
--- a/packages/web-shell/client/components/WebShellTranscript.tsx
+++ b/packages/web-shell/client/components/WebShellTranscript.tsx
@@ -1,5 +1,5 @@
-import 'katex/dist/katex.min.css';
 import '../styles/globals.css';
+import 'katex/dist/katex.min.css';
 import {
   useLayoutEffect,
   useMemo,
@@ -30,7 +30,7 @@ import {
   normalizeLanguage,
   type WebShellLanguage,
 } from '../i18n';
-import { transcriptBlocksToLocalizedMessages } from '../hooks/useMessages';
+import { transcriptBlocksToLocalizedMessages } from '../adapters/localizedMessages';
 import { WebShellPortalRootContext } from '../portalRoot';
 import { computeTodoDetails, computeTodoTimeline } from '../utils/todos';
 import {
diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx
index 5418603ce41..c622b63fd70 100644
--- a/packages/web-shell/client/components/messages/UserMessage.tsx
+++ b/packages/web-shell/client/components/messages/UserMessage.tsx
@@ -13,7 +13,10 @@ import { CalendarClockIcon, PencilIcon, RefreshCwIcon } from 'lucide-react';
 import { FileTypeIcon } from '../FileTypeIcon';
 import { describeCron } from '../dialogs/scheduledTasksSchedule';
 import {
+  getComposerTagDisplay,
   getComposerTagIconUrl,
+  getComposerTagLabel,
+  getComposerTagValue,
   getComposerTagViewModel,
   isBuiltinComposerTagIconUrl,
   isPreviewableFileComposerTag,
@@ -31,11 +34,6 @@ import type {
 } from '../../customization';
 import type { AttachmentPreviewRequest } from '../../adapters/messageTypes';
 import type { ImageTabSource } from '../artifacts/ArtifactPanel';
-import {
-  getComposerTagDisplay,
-  getComposerTagLabel,
-  getComposerTagValue,
-} from '../../hooks/useComposerCore';
 import { useI18n } from '../../i18n';
 import { useTranscriptRenderMode } from '../../transcriptRenderMode';
 import { cssUrlVar } from '../../utils/cssUrlVar';
diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts
index d84d3b62026..21657cbaa86 100644
--- a/packages/web-shell/client/hooks/useComposerCore.ts
+++ b/packages/web-shell/client/hooks/useComposerCore.ts
@@ -78,12 +78,23 @@ import { isEditableTarget } from '../utils/dom';
 import { cssUrlValue } from '../utils/cssUrlVar';
 import {
   createInputAnnotationsFromComposerTags,
+  getComposerTagDisplay,
   getComposerTagIconUrl,
+  getComposerTagLabel,
   getComposerTagSerialized,
+  getComposerTagValue,
   isBuiltinComposerTagIconUrl,
   isPreviewableFileComposerTag,
   parseUserMessageContentSafely,
 } from '../utils/composerTag';
+// Re-exported for existing importers; the definitions moved to
+// utils/composerTag.ts so read-only consumers can reach them without pulling
+// CodeMirror in (#11031).
+export {
+  getComposerTagDisplay,
+  getComposerTagLabel,
+  getComposerTagValue,
+} from '../utils/composerTag';
 import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
 import { isSafeImageSrc } from '../components/messages/Markdown';
 import type {
@@ -480,18 +491,6 @@ function serializeComposerTags(tags: readonly WebShellComposerTag[]): string {
   return tags.map(serializeComposerTag).join('\n');
 }
 
-export function getComposerTagLabel(tag: WebShellComposerTag): string {
-  return tag.label?.trim() ?? '';
-}
-
-export function getComposerTagValue(tag: WebShellComposerTag): string {
-  return tag.value?.trim() ?? '';
-}
-
-export function getComposerTagDisplay(tag: WebShellComposerTag): string {
-  return getComposerTagValue(tag) || getComposerTagLabel(tag) || tag.id;
-}
-
 export function buildComposerPrompt(
   text: string,
   tags: readonly WebShellComposerTag[],
diff --git a/packages/web-shell/client/hooks/useMessages.ts b/packages/web-shell/client/hooks/useMessages.ts
index 8bbecca63eb..9233a13f467 100644
--- a/packages/web-shell/client/hooks/useMessages.ts
+++ b/packages/web-shell/client/hooks/useMessages.ts
@@ -11,7 +11,10 @@ import {
   useTranscriptBlocks,
   useWorkspace,
 } from '@qwen-code/web-shell/daemon-react-sdk';
-import { transcriptBlocksToDaemonMessages } from '../adapters/transcriptToMessages';
+import {
+  transcriptBlocksToLocalizedMessages,
+  type Translator,
+} from '../adapters/localizedMessages';
 import type { Message } from '../adapters/types';
 import {
   isActiveToolStatus,
@@ -20,10 +23,13 @@ import {
   projectTerminalBackgroundAgentTool,
 } from '../adapters/toolClassification';
 
-type Translator = (
-  key: string,
-  vars?: Record,
-) => string;
+// Re-exported for existing callers. The projection itself lives in a leaf module
+// so the read-only transcript entry does not pull this file's daemon imports —
+// see adapters/localizedMessages.ts.
+export {
+  transcriptBlocksToLocalizedMessages,
+  type Translator,
+} from '../adapters/localizedMessages';
 
 const BACKGROUND_AGENT_RECONCILIATION_RETRY_BASE_MS = 3_000;
 const BACKGROUND_AGENT_RECONCILIATION_RETRY_MAX_MS = 60_000;
@@ -66,23 +72,6 @@ interface ReconciliationRound {
   succeeded: ReadonlyArray;
 }
 
-export function transcriptBlocksToLocalizedMessages(
-  blocks: readonly DaemonTranscriptBlock[],
-  t: Translator,
-  safeToolProjection = false,
-): Message[] {
-  return transcriptBlocksToDaemonMessages(blocks, {
-    safeToolProjection,
-    includeSourceIdentity: true,
-    labels: {
-      promptCancelled: t('request.cancelled'),
-      branchSuccess: (name) => t('branch.success', { name }),
-      modelStreamInterrupted: t('error.modelStreamInterrupted'),
-      loopDetected: t('error.loopDetected'),
-    },
-  });
-}
-
 function reuseUnchangedProjectedPrefix(
   previous: MessageProjection | undefined,
   blocks: readonly DaemonTranscriptBlock[],
diff --git a/packages/web-shell/client/shadowDom.ts b/packages/web-shell/client/shadowDom.ts
index bd26a043bdd..9feaf8f7c11 100644
--- a/packages/web-shell/client/shadowDom.ts
+++ b/packages/web-shell/client/shadowDom.ts
@@ -47,10 +47,20 @@ const packageStyleSheetCache = new WeakMap<
 >();
 
 function getWebShellStyleText(document: Document): string {
-  const injectedStyle = document.querySelector(
-    'style[data-qwen-web-shell="component"]',
-  );
-  if (injectedStyle?.textContent) return injectedStyle.textContent;
+  // The lib build injects one tag per component entry (`index` and
+  // `transcript`, see vite.lib.config.ts). A host that imports both gets two
+  // tags whose rules overlap byte-for-byte, so concatenate every match
+  // instead of taking the first — picking one would drop the editor/dialog
+  // rules whenever the transcript entry happened to load first.
+  const injected = Array.from(
+    document.querySelectorAll(
+      'style[data-qwen-web-shell="component"]',
+    ),
+  )
+    .map((style) => style.textContent ?? '')
+    .filter(Boolean)
+    .join('\n');
+  if (injected) return injected;
 
   return Array.from(
     document.querySelectorAll('style[data-vite-dev-id]'),
diff --git a/packages/web-shell/client/transcript.ts b/packages/web-shell/client/transcript.ts
new file mode 100644
index 00000000000..bcb3a08c23b
--- /dev/null
+++ b/packages/web-shell/client/transcript.ts
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Transcript-only entrypoint for `@qwen-code/web-shell`.
+ *
+ * Exposes the read-only transcript renderer without the interactive shell:
+ * no `App`, no composer, no editor/terminal chrome. Bundlers that only render
+ * transcripts (for example the `/export html` document renderer in
+ * `@qwen-code/web-templates`) must import this subpath instead of the package
+ * root, so the interactive runtime is not pulled into their output.
+ *
+ * Known residual: this is not free of the daemon React runtime. `MessageList`
+ * renders `McpStatusMessage`, `TasksStatusMessage` and the artifact turn
+ * outputs, and those call the strict `useDaemonActions` / `useDaemonWorkspace`
+ * hooks, so their provider guards are in `dist/transcript.js`. Decoupling them
+ * is #11100. `client/build-artifact.test.ts` therefore bounds this entry's size
+ * rather than asserting a symbol is absent.
+ *
+ * Do not rely on importing the package root or incidental tree shaking to
+ * keep this payload small; see
+ * `docs/design/2026-07-14-chat-record-daemon-transcript-block-projection.md`.
+ *
+ * @example
+ * ```tsx
+ * import { WebShellTranscript } from '@qwen-code/web-shell/transcript';
+ * ```
+ */
+
+export { WebShellTranscript } from './components/WebShellTranscript';
+export type { WebShellTranscriptProps } from './components/WebShellTranscript';
diff --git a/packages/web-shell/client/utils/composerTag.ts b/packages/web-shell/client/utils/composerTag.ts
index 8e49f87a95c..7f59583bfdf 100644
--- a/packages/web-shell/client/utils/composerTag.ts
+++ b/packages/web-shell/client/utils/composerTag.ts
@@ -24,6 +24,28 @@ export type ComposerTagContentSegment =
   | { type: 'text'; text: string }
   | { type: 'reference'; tag: WebShellComposerTag };
 
+/**
+ * Composer-tag display getters.
+ *
+ * These live here rather than in `hooks/useComposerCore.ts` on purpose: that
+ * module imports the whole CodeMirror editor at top level, and read-only
+ * consumers (`UserMessage`, and through it the `@qwen-code/web-shell/transcript`
+ * entry that `/export html` bundles) need nothing but these three string
+ * getters. Importing them from `useComposerCore` dragged ~1 MB of CodeMirror
+ * into every exported HTML file (#11031).
+ */
+export function getComposerTagLabel(tag: WebShellComposerTag): string {
+  return tag.label?.trim() ?? '';
+}
+
+export function getComposerTagValue(tag: WebShellComposerTag): string {
+  return tag.value?.trim() ?? '';
+}
+
+export function getComposerTagDisplay(tag: WebShellComposerTag): string {
+  return getComposerTagValue(tag) || getComposerTagLabel(tag) || tag.id;
+}
+
 export function isPreviewableFileComposerTag(
   tag: WebShellComposerTag,
 ): tag is WebShellComposerTag & { kind: 'file'; value: string } {
diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json
index 7396cbd0669..97b250eb6c1 100644
--- a/packages/web-shell/package.json
+++ b/packages/web-shell/package.json
@@ -13,6 +13,10 @@
     "./daemon-react-sdk": {
       "types": "./dist/types/daemon-react-sdk.d.ts",
       "import": "./dist/daemon-react-sdk.js"
+    },
+    "./transcript": {
+      "types": "./dist/types/transcript.d.ts",
+      "import": "./dist/transcript.js"
     }
   },
   "files": [
@@ -21,7 +25,7 @@
   ],
   "scripts": {
     "dev": "vite",
-    "build": "vite build && vite build --config vite.lib.config.ts && tsc -p tsconfig.lib.json",
+    "build": "vite build && vite build --config vite.lib.config.ts && vite build --config vite.lib.config.ts --mode transcript && tsc -p tsconfig.lib.json",
     "lint": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx",
     "lint:fix": "cd ../.. && eslint packages/web-shell --ext .ts,.tsx --fix",
     "format:check": "cd ../.. && prettier --experimental-cli --check packages/web-shell",
diff --git a/packages/web-shell/tsconfig.json b/packages/web-shell/tsconfig.json
index 47e708bec77..b7d9a6b15d3 100644
--- a/packages/web-shell/tsconfig.json
+++ b/packages/web-shell/tsconfig.json
@@ -7,6 +7,7 @@
     "baseUrl": ".",
     "paths": {
       "@qwen-code/web-shell/daemon-react-sdk": ["./client/daemon-react-sdk.ts"],
+      "@qwen-code/web-shell/transcript": ["./client/transcript.ts"],
       "@/*": ["./client/*"]
     },
     "strict": true,
diff --git a/packages/web-shell/vite.config.ts b/packages/web-shell/vite.config.ts
index 62c1942db8b..8049e691c53 100644
--- a/packages/web-shell/vite.config.ts
+++ b/packages/web-shell/vite.config.ts
@@ -56,6 +56,10 @@ export default defineConfig(({ command }) => ({
         __dirname,
         './client/daemon-react-sdk.ts',
       ),
+      '@qwen-code/web-shell/transcript': resolve(
+        __dirname,
+        './client/transcript.ts',
+      ),
       '@': resolve(__dirname, './client'),
       ...(command === 'serve'
         ? {
diff --git a/packages/web-shell/vite.lib.config.ts b/packages/web-shell/vite.lib.config.ts
index 77abb39fb14..a12c8e70d5b 100644
--- a/packages/web-shell/vite.lib.config.ts
+++ b/packages/web-shell/vite.lib.config.ts
@@ -108,20 +108,48 @@ function injectCssModules(): Plugin {
       const escapedCss = JSON.stringify(css);
       for (const item of Object.values(bundle)) {
         if (item.type !== 'chunk') continue;
-        if (!item.facadeModuleId?.endsWith('/client/index.tsx')) {
+        // Every entry that renders components must carry the scoped
+        // stylesheet. The transcript entry is consumed on its own by the
+        // `/export html` document build, so it cannot inherit the CSS from
+        // the root entry.
+        //
+        // The two entries are built in separate rollup runs and therefore
+        // carry *different* stylesheets (the transcript one is a subset), so
+        // the injection guard is keyed per entry via
+        // `data-qwen-web-shell-entry`. A single shared key would let whichever
+        // entry loads first win: a host that imports both
+        // `@qwen-code/web-shell` and `@qwen-code/web-shell/transcript` would
+        // silently lose the editor/dialog rules if the transcript entry ran
+        // first. Injection stays idempotent per entry. Overlapping rules must
+        // retain the same relative order in both stylesheets because equal-
+        // specificity declarations still depend on cascade order. Both tags
+        // keep `data-qwen-web-shell="component"` so shadow-root style adoption
+        // (client/shadowDom.ts) still finds them; that reader concatenates
+        // every match rather than taking the first.
+        const entry = item.facadeModuleId?.endsWith('/client/transcript.ts')
+          ? 'transcript'
+          : item.facadeModuleId?.endsWith('/client/index.tsx')
+            ? 'index'
+            : undefined;
+        if (!entry) {
           continue;
         }
         item.code =
           `const __qwenWebShellCss=${escapedCss};\n` +
-          `if(typeof document!=="undefined"&&!document.querySelector('style[data-qwen-web-shell="component"]')){` +
-          `const s=document.createElement("style");s.dataset.qwenWebShell="component";s.textContent=__qwenWebShellCss;try{document.head.appendChild(s);}catch(e){console.warn("[qwen-web-shell] CSS injection blocked by CSP:",e);}}\n` +
+          `if(typeof document!=="undefined"&&!document.querySelector('style[data-qwen-web-shell-entry="${entry}"]')){` +
+          `const s=document.createElement("style");s.dataset.qwenWebShell="component";s.dataset.qwenWebShellEntry="${entry}";s.textContent=__qwenWebShellCss;try{document.head.appendChild(s);}catch(e){console.warn("[qwen-web-shell] CSS injection blocked by CSP:",e);}}\n` +
           item.code;
       }
     },
   };
 }
 
-export default defineConfig({
+// The transcript entry is built in its own rollup run (`--mode transcript`)
+// so it only carries the CSS reachable from the read-only transcript
+// renderer. Built alongside the root entry, it would inherit the full
+// component stylesheet (editor, sidebar, …) that the `/export html`
+// document renderer inlines into every exported file (#11031).
+export default defineConfig(({ mode }) => ({
   plugins: [react(), tailwindcss(), injectCssModules()],
   resolve: {
     alias: {
@@ -129,6 +157,10 @@ export default defineConfig({
         __dirname,
         './client/daemon-react-sdk.ts',
       ),
+      '@qwen-code/web-shell/transcript': resolve(
+        __dirname,
+        './client/transcript.ts',
+      ),
       '@': resolve(__dirname, './client'),
     },
   },
@@ -138,10 +170,13 @@ export default defineConfig({
   build: {
     emptyOutDir: false,
     lib: {
-      entry: {
-        index: 'client/index.tsx',
-        'daemon-react-sdk': 'client/daemon-react-sdk.ts',
-      },
+      entry:
+        mode === 'transcript'
+          ? { transcript: 'client/transcript.ts' }
+          : {
+              index: 'client/index.tsx',
+              'daemon-react-sdk': 'client/daemon-react-sdk.ts',
+            },
       formats: ['es'],
       fileName: (_format, entryName) => `${entryName}.js`,
     },
@@ -183,4 +218,4 @@ export default defineConfig({
   define: {
     __WEB_SHELL_VERSION__: JSON.stringify(pkg.version),
   },
-});
+}));
diff --git a/packages/web-shell/vitest.config.ts b/packages/web-shell/vitest.config.ts
index 310e5061156..3b77f9b84d4 100644
--- a/packages/web-shell/vitest.config.ts
+++ b/packages/web-shell/vitest.config.ts
@@ -9,6 +9,10 @@ export default defineConfig({
         __dirname,
         './client/daemon-react-sdk.ts',
       ),
+      '@qwen-code/web-shell/transcript': resolve(
+        __dirname,
+        './client/transcript.ts',
+      ),
       '@': resolve(__dirname, './client'),
     },
   },
diff --git a/packages/web-templates/src/export-html/build.mjs b/packages/web-templates/src/export-html/build.mjs
index 47712bad44f..8461cdaead5 100644
--- a/packages/web-templates/src/export-html/build.mjs
+++ b/packages/web-templates/src/export-html/build.mjs
@@ -1,4 +1,5 @@
 import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
+import process from 'node:process';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { createHash } from 'node:crypto';
@@ -19,6 +20,86 @@ const documentTemplateModulePath = join(
 );
 const exportTranscriptMaxBlocks = 1_000;
 const exportTranscriptMaxEnvelopeBytes = 32 * 1024 * 1024;
+// Since #9812 the renderer is no longer inlined into each export: the document
+// loads one version-pinned, SRI-protected asset from unpkg. That moved the cost
+// rather than removing it — the same bytes are now downloaded the first time
+// anyone opens an exported file, on a path that must fail closed, so the size
+// still needs a ceiling. Mirrors the hard bundle-size assertions in
+// packages/sdk-typescript/scripts/build.js.
+// Before #11031 was fixed, the document entry imported the @qwen-code/web-shell
+// package root and pulled the full interactive shell into that asset:
+// 19,523,259 runtime bytes.
+//
+// A byte cap alone is a weak ratchet — it only catches growth, and only once
+// it is large. The structural guard below (FORBIDDEN_DOCUMENT_INPUTS) is the
+// real one: it names the module graphs that must never reach an export and
+// fails with the reason. Keep both.
+//
+// Re-measure and lower these two constants after any change to the document
+// entry's dependencies:
+//   cd packages/web-templates && node src/export-html/build.mjs
+// (the build prints `Document export runtime is N bytes`.)
+//
+// Last measured at 7,275,173 bytes, with the echarts stub below in place, by a
+// reviewer building this branch locally (PR #11038). The prior CI measurement
+// on the same branch without that stub was 8,456,076. Re-measure and lower
+// these two again after any change to the document entry's dependencies.
+const DOCUMENT_RUNTIME_WARNING_BYTES = 7_300_000;
+const MAX_DOCUMENT_RUNTIME_BYTES = 7_400_000;
+
+// Modules that must not be reachable from the document entry, checked against
+// the esbuild metafile inputs after the bundle is produced.
+const FORBIDDEN_DOCUMENT_INPUTS = [
+  {
+    pattern: /(^|\/)node_modules\/(shiki|@shikijs)\//,
+    why:
+      'Shiki is unreachable in document mode (CodeBlock renders plain 
) ' +
+      'and its Oniguruma WASM engine is blocked by the export CSP; it is ' +
+      'resolved to src/document-shiki-stub.ts by the strip plugin below.',
+  },
+  {
+    pattern: /web-shell\/dist\/index\.js$/,
+    why:
+      'The @qwen-code/web-shell package root drags the interactive shell ' +
+      '(App, daemon providers, editor/terminal chrome) into every export. ' +
+      'Import @qwen-code/web-shell/transcript instead (#11031).',
+  },
+  {
+    pattern: /(^|\/)node_modules\/(echarts|zrender)\//,
+    why:
+      'The chart runtime is only reachable through the `?? () => import("echarts")` ' +
+      'default inside @datafe-open/markdown-chart-echarts, which Web Shell never ' +
+      'takes (MarkdownChartRenderer always passes a loadECharts). IIFE output ' +
+      'cannot code-split, so that dead dynamic import was flattened in; it is ' +
+      'resolved to src/document-echarts-stub.ts by the strip plugin below.',
+  },
+  {
+    pattern: /(^|\/)node_modules\/(codemirror|@codemirror)\//,
+    why:
+      'A read-only export has no composer. CodeMirror last reached it through ' +
+      'three composer-tag getters that UserMessage imported from ' +
+      'hooks/useComposerCore.ts; they now live in utils/composerTag.ts, which ' +
+      'is editor-free. Import from there, not from the composer hook.',
+  },
+];
+
+// `shiki` and `@shikijs/*` are replaced wholesale rather than marked external:
+// the renderer asset is a single IIFE bundle, so an external specifier would
+// simply fail to resolve in the browser. See src/document-shiki-stub.ts for why
+// this is dead code in an export.
+const documentShikiStub = join(srcDir, 'document-shiki-stub.ts');
+const documentEchartsStub = join(srcDir, 'document-echarts-stub.ts');
+const stripDocumentDeadModules = {
+  name: 'strip-document-dead-modules',
+  setup(build) {
+    build.onResolve({ filter: /^(shiki|@shikijs)(\/|$)/ }, () => ({
+      path: documentShikiStub,
+    }));
+    build.onResolve({ filter: /^echarts(\/|$)/ }, () => ({
+      path: documentEchartsStub,
+    }));
+  },
+};
 const { version: exportTranscriptRendererPackageVersion } = JSON.parse(
   await readFile(
     join(assetsDir, '..', '..', '..', '..', 'package.json'),
@@ -33,6 +114,8 @@ const documentBuildResult = await build({
   bundle: true,
   minify: true,
   write: false,
+  metafile: true,
+  plugins: [stripDocumentDeadModules],
   outfile: join(assetsDistDir, 'export-transcript-document.js'),
   platform: 'browser',
   format: 'iife',
@@ -60,6 +143,65 @@ const documentCssBundle = documentBuildResult.outputFiles.find((file) =>
 if (!documentJsBundle || !documentCssBundle) {
   throw new Error('Failed to generate document export bundles.');
 }
+// Re-measuring the budget should not require editing this file. The size line
+// below says *how much*; this says *what of*, which is the question a
+// regression actually raises.
+const documentInputs = documentBuildResult.metafile.inputs;
+const inputBytesByPackage = new Map();
+for (const [input, { bytes }] of Object.entries(documentInputs)) {
+  const match = input.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)\//);
+  const key = match ? match[1] : 'first-party';
+  inputBytesByPackage.set(key, (inputBytesByPackage.get(key) ?? 0) + bytes);
+}
+const topInputs = [...inputBytesByPackage]
+  .sort(([, left], [, right]) => right - left)
+  .slice(0, 8)
+  .map(([name, bytes]) => `${name} ${bytes}`)
+  .join(', ');
+console.log(`Document export top inputs (pre-minify bytes): ${topInputs}`);
+if (process.env.EXPORT_HTML_METAFILE) {
+  await writeFile(
+    process.env.EXPORT_HTML_METAFILE,
+    JSON.stringify(documentBuildResult.metafile),
+  );
+  console.log(
+    `Document export metafile written to ${process.env.EXPORT_HTML_METAFILE}`,
+  );
+}
+
+const forbiddenInputs = Object.keys(documentInputs)
+  .map((input) => ({
+    input,
+    rule: FORBIDDEN_DOCUMENT_INPUTS.find(({ pattern }) => pattern.test(input)),
+  }))
+  .filter((entry) => entry.rule);
+if (forbiddenInputs.length > 0) {
+  const reasons = [...new Set(forbiddenInputs.map(({ rule }) => rule.why))];
+  const examples = forbiddenInputs.slice(0, 5).map(({ input }) => `  ${input}`);
+  throw new Error(
+    `The document export bundle reached ${forbiddenInputs.length} forbidden input(s):\n` +
+      `${examples.join('\n')}\n` +
+      `${reasons.map((why) => `- ${why}`).join('\n')}`,
+  );
+}
+
+const documentRuntimeBytes =
+  Buffer.byteLength(documentJsBundle.text) +
+  Buffer.byteLength(documentCssBundle.text);
+console.log(`Document export runtime is ${documentRuntimeBytes} bytes`);
+if (documentRuntimeBytes > MAX_DOCUMENT_RUNTIME_BYTES) {
+  throw new Error(
+    `Document export runtime is ${documentRuntimeBytes} bytes; expected <= ${MAX_DOCUMENT_RUNTIME_BYTES}. ` +
+      'Every reader of an exported file downloads this asset before the ' +
+      'transcript renders; import only what the transcript needs ' +
+      '(see packages/web-shell/client/transcript.ts) or raise the budget deliberately.',
+  );
+}
+if (documentRuntimeBytes > DOCUMENT_RUNTIME_WARNING_BYTES) {
+  console.warn(
+    `Document export runtime exceeds the ${DOCUMENT_RUNTIME_WARNING_BYTES}-byte warning threshold`,
+  );
+}
 const rendererBuildId = createHash('sha256')
   .update(documentJsBundle.contents)
   .digest('hex')
diff --git a/packages/web-templates/src/export-html/src/document-echarts-stub.ts b/packages/web-templates/src/export-html/src/document-echarts-stub.ts
new file mode 100644
index 00000000000..a2a4c3ddebc
--- /dev/null
+++ b/packages/web-templates/src/export-html/src/document-echarts-stub.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Build-time replacement for `echarts` in the `/export html` document bundle.
+ *
+ * `@datafe-open/markdown-chart-echarts` ends `createEChartsRenderer` with
+ *
+ *     const loadECharts = options.loadECharts ?? (async () => await import('echarts'));
+ *
+ * so the chart runtime is only ever fetched through that default. Web Shell never
+ * reaches it: `MarkdownChartRenderer.tsx` always passes a `loadECharts`, because
+ * `adaptLegacyRuntimeLoader` returns a function that throws
+ * `'Chart runtime is unavailable.'` when no `loadEcharts` prop was supplied — and
+ * no call site in `packages/web-shell/client/` supplies one. The `??` fallback is
+ * therefore dead in this repository, but a bundler cannot prove that, and the
+ * export build is esbuild `format: 'iife'` with a single outfile, which cannot
+ * code-split: the dynamic import is flattened straight into the renderer. It cost
+ * 3,841,596 pre-minify bytes of `echarts` plus 624,992 of `zrender`, for a code
+ * path that can only ever throw.
+ *
+ * `packages/web-templates/src/export-html/build.mjs` resolves `echarts` to this
+ * module and then asserts, from the esbuild metafile, that no echarts or zrender
+ * input reached the bundle.
+ *
+ * Nothing calls this at runtime. If exported transcripts should ever render charts,
+ * the fix is to give the renderer a real runtime deliberately (see #11091) rather
+ * than to make this stub work — and to re-measure the budget, because doing so puts
+ * those megabytes back.
+ */
+export function init(): never {
+  throw new Error(
+    'echarts is not bundled into /export html documents; chart blocks do not render in document mode.',
+  );
+}
+
+export default { init };
diff --git a/packages/web-templates/src/export-html/src/document-main.tsx b/packages/web-templates/src/export-html/src/document-main.tsx
index 9a7b051ea78..1059a1a7220 100644
--- a/packages/web-templates/src/export-html/src/document-main.tsx
+++ b/packages/web-templates/src/export-html/src/document-main.tsx
@@ -3,7 +3,11 @@ import { Component, useEffect, useRef, useState, type ReactNode } from 'react';
 import { flushSync } from 'react-dom';
 import { createRoot } from 'react-dom/client';
 import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
-import { WebShellTranscript } from '@qwen-code/web-shell';
+// Transcript-only subpath: the package root also pulls in the interactive
+// shell (App, daemon providers, editor chrome), which a read-only export
+// never uses. Keep this import narrow so the inlined runtime stays small
+// (see https://github.com/QwenLM/qwen-code/issues/11031).
+import { WebShellTranscript } from '@qwen-code/web-shell/transcript';
 
 declare const __EXPORT_TRANSCRIPT_RENDERER_VERSION__: string;
 declare const __EXPORT_TRANSCRIPT_MAX_BLOCKS__: number;
diff --git a/packages/web-templates/src/export-html/src/document-shiki-stub.ts b/packages/web-templates/src/export-html/src/document-shiki-stub.ts
new file mode 100644
index 00000000000..9a70efaa5e9
--- /dev/null
+++ b/packages/web-templates/src/export-html/src/document-shiki-stub.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2026 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Build-time replacement for `shiki` in the `/export html` document bundle.
+ *
+ * The document renderer runs `WebShellTranscript` with `renderMode="document"`,
+ * and `CodeBlock` in `packages/web-shell/client/components/messages/Markdown.tsx`
+ * returns before it ever touches the highlighter in that mode — it renders every
+ * fence as a plain `
`. Shiki is therefore unreachable code in an export, yet
+ * it is a static import of `codeHighlighter.ts`, so esbuild cannot drop it: it
+ * was the single largest input in the inlined runtime (~9.7 MB of pre-minify
+ * sources, all of Shiki's bundled grammars and themes plus the inlined Oniguruma
+ * WASM). The export CSP (`script-src 'nonce-…'`, no `'wasm-unsafe-eval'`) would
+ * block that WASM engine from starting anyway.
+ *
+ * `packages/web-templates/src/export-html/build.mjs` resolves `shiki` and
+ * `@shikijs/*` to this module and then asserts, from the esbuild metafile, that
+ * no Shiki input reached the bundle.
+ *
+ * Nothing calls this at runtime. If document mode ever needs real highlighting,
+ * delete the `stripDocumentDeadModules` plugin in `build.mjs` instead of making
+ * this stub work — and re-measure the runtime budget, because doing so puts
+ * those megabytes back into every exported file.
+ */
+export function createHighlighter(): Promise {
+  return Promise.reject(
+    new Error(
+      'shiki is not bundled into /export html documents; code blocks render as plain text in document mode.',
+    ),
+  );
+}