docs(quick-261010-iwu): Blog Editor live preview pane

This commit is contained in:
2026-10-10 13:59:10 +02:00
parent ea955b39ae
commit ea42f04ae0
3 changed files with 520 additions and 1 deletions

View File

@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
Phase: 16.2 (Plugin-provided field types and the Blog Editor formwidget) — EXECUTING
Plan: 6 of 6
Status: Ready to execute
Last activity: 2026-10-10 — Phase 16.2 execution started
Last activity: 2026-10-10 - Completed quick task 261010-iwu: Blog Editor live preview pane
Progress: [██████████] 100%
@@ -734,6 +734,7 @@ Recent decisions affecting current work:
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
## Deferred Items

View File

@@ -0,0 +1,371 @@
---
phase: quick-261010-iwu
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
- ../sm-journal-plugin/assets/dist/blogeditor.js
- ../sm-journal-plugin/assets/dist/blogeditor.css
- ../sm-journal-plugin/tests/blogeditor/preview.test.ts
- ../sm-journal-plugin/README.md
autonomous: true
requirements: [QUICK-261010-iwu]
estimate:
tokens: 70000
raw_tokens: 70000
tasks: 3
confidence: low
must_haves:
truths:
- "The Blog Editor toolbar has a Preview toggle button (data-command preview) labelled 'Preview' for ui-locale en and 'Podgląd' for ui-locale pl through labelsFor. It is off by default with aria-pressed 'false', and each element instance has its own state. Pressing it sets aria-pressed 'true' and shows a preview pane that splits the editor 50/50, a grid with repeat(auto-fit, minmax(min(100%, 360px), 1fr)) columns. When the element is narrower than about 720px, the pane wraps below the editor. Pressing it again hides the pane. The button stays usable in the Source tab and when the element is disabled."
- "The pane is an <iframe> whose sandbox attribute is exactly the empty string. It has no allow-scripts or allow-same-origin token, no src and no allow attribute, and it has referrerpolicy no-referrer. Its content is set only through srcdoc. The srcdoc head starts with meta charset, then a Content-Security-Policy meta with PREVIEW_CSP (default-src 'none'; img-src * data:; style-src 'unsafe-inline'), then a no-referrer meta."
- "The preview renders immediately when it is switched on. After that, it re-renders PREVIEW_DEBOUNCE_MS (300 ms) after the last WYSIWYG edit, Source tab input or external value set, so it updates live in both modes. While it is off, nothing is scheduled or rendered."
- "A YouTube placeholder <figure data-embed=\"youtube\" data-id=\"ID\"></figure>, with an id that matches the provider idPattern, shows as a thumbnail card in the preview: an img with src https://img.youtube.com/vi/ID/hqdefault.jpg and referrerpolicy no-referrer, plus the label '<embedCard>: YouTube ID'. The preview never contains an iframe. The preview copy also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute."
- "The preview uses post-body typography from an inline stylesheet in the srcdoc. Its colours and font are concrete values that getComputedStyle reads from the host's --c-* variables at render time and that pass safeCssValue, or neutral defaults otherwise. The srcdoc never contains var(--c-. In blogeditor.css, every selector stays under golem15-journal-blogeditor and every colour is a var(--c-*)."
- "Toggling or rendering the preview never dispatches summer-change. It never calls serializeDocument for an untouched document, never dispatches an editor transaction and never writes the stored-value fields. An untouched imported post still returns the exact given string after any number of toggles and renders. smoke.test.ts, corpus.test.ts and the fixtures are not edited and stay green."
- "assets/dist is rebuilt and committed, scripts/check-dist.sh prints 'check-dist: assets/dist matches a fresh build', and go vet ./... passes in sm-journal-plugin. The README Blog Editor and Admin sections describe the preview. Every commit is in sm-journal-plugin without co-author tags, and nothing is pushed or deployed."
artifacts:
- path: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
provides: "PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
contains: "export function buildPreviewDocument"
- path: ../sm-journal-plugin/assets/src/blogeditor/element.ts
provides: "showPreview(on), #renderPreview reading the value getter, #schedulePreview debounce, split panes and the sandboxed iframe"
contains: "showPreview"
- path: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
provides: "the preview toggle button outside the tablist, ToolbarHost.togglePreview, Toolbar.setPreview"
contains: "togglePreview"
- path: ../sm-journal-plugin/assets/src/blogeditor/labels.ts
provides: "preview and previewFrame labels in en and pl"
contains: "Podgląd"
- path: ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
provides: "split-pane grid with the narrow-width wrap, preview pane and iframe styles, all under the tag with --c-* only"
contains: "auto-fit"
- path: ../sm-journal-plugin/tests/blogeditor/preview.test.ts
provides: "vitest coverage: toggle and aria-pressed, debounced live update, sandbox attribute, embed card, active-content drop, colours, no summer-change, untouched value"
contains: "PREVIEW_DEBOUNCE_MS"
- path: ../sm-journal-plugin/assets/dist/blogeditor.js
provides: "rebuilt committed bundle including the preview"
contains: "img.youtube.com"
key_links:
- from: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
to: ../sm-journal-plugin/assets/src/blogeditor/element.ts
via: "button[data-command=preview] click -> host.togglePreview() -> showPreview(!#previewOn)"
pattern: "togglePreview"
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
to: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
via: "#renderPreview reads this.value and sets frame.srcdoc = buildPreviewDocument(...)"
pattern: "buildPreviewDocument"
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
to: "onUpdate, #sourceInput, value setter"
via: "#schedulePreview() after each value-changing path"
pattern: "schedulePreview"
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
to: ../sm-journal-plugin/assets/src/blogeditor/embed.ts
via: "previewBody accepts a placeholder only through isEmbedProvider plus EMBED_PROVIDERS[provider].idPattern"
pattern: "idPattern"
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
to: ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
via: "previewBody parses its copy in inert(), a document without a browsing context"
pattern: "inert()"
---
<objective>
Add a toggleable live preview pane to the Journal Blog Editor element (`golem15-journal-blogeditor`) in the sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, like the WinterCMS Blog plugin's preview. A localized "Preview"/"Podgląd" toolbar button splits the editor 50/50. The right pane shows the current HTML in a sandboxed `<iframe srcdoc>` with post-body typography, YouTube placeholders show as thumbnail cards, and the pane updates live with a debounce in both WYSIWYG and Source modes.
Purpose: editors see how the post body will read without leaving the form. The editor's exact-value contract stays intact: an untouched post still saves byte for byte, and previewing never counts as an edit.
Output: a new `preview.ts` module, changes to the element, toolbar, labels and CSS, a new vitest file, the rebuilt and committed `assets/dist`, and README updates. This is three commits in sm-journal-plugin. There is no summercms framework change, and the element contract published in summercms v0.1.18 (the value property, the attributes, summer-change, summer-upload and summer-upload-result) is unchanged.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
Element sources (absolute paths; the repo is a sibling, not a submodule):
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/element.ts
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/toolbar.ts
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/labels.ts
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/serializer.ts
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/embed.ts
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/blogeditor.css
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/tests/blogeditor/helpers.ts
Facts the planner verified (no need to re-derive):
- sm-journal-plugin is on branch `main` at `9d5bfc4` (chore(deps): require summercms v0.1.18). The only untracked file is `go.work.sum`, which is never committed. `node_modules/` is installed, so check-dist.sh does not run npm ci.
- The `value` getter (element.ts) returns `#value` in `clean` mode, which is the exact given string with no serializer call. It returns `#sourceText` in `source` mode. In `wysiwyg` mode it returns `serializeDocument(editor, #blocks)`. `serializeDocument` (serializer.ts) only reads `editor.state.doc` and builds nodes in the inert document. It writes no element state. The preview reads through the getter and nothing else.
- `inert()` (extensions.ts) is `document.implementation.createHTMLDocument('')`. Markup parsed there or in a `template` created from it loads nothing and runs nothing. RawHtml's renderHTML already uses this pattern.
- The Embed node accepts a placeholder only when it has exactly two attributes (`data-embed`, `data-id`), no child nodes, `isEmbedProvider(provider)` and the provider's `idPattern` (`^[A-Za-z0-9_-]{11}$`). Its card text is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`.
- The admin SPA's CSP (summercms boardwalk) is `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'`. A srcdoc document inherits it, so img.youtube.com images and an inline `<style>` are allowed, and a pasted `<base>` is already blocked. A srcdoc document also inherits the parent's base URL, so relative image URLs such as `/storage/...` resolve against the admin origin.
- happy-dom 20.11.6 (the vitest environment) returns inline custom properties from `getComputedStyle(el).getPropertyValue('--c-x')` (probed). It reflects `iframe.srcdoc` and `sandbox` to attributes. Tests read `getAttribute('srcdoc')` and `getAttribute('sandbox')` and never `contentDocument`.
- The smoke test `toolbar > offers every D-04 feature` queries `button[data-command=...]` per command, and no test counts buttons or children, so an extra button and wrapper divs break nothing.
Executor runtime notes:
- The user's login shell is fish. Run anything with bash syntax through `bash -c '...'`. Every verify command below is already wrapped.
- Code commits go to sm-journal-plugin with `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin ...`. Stage explicit paths only. Commit messages carry no co-author tags or trailers (user rule, overrides any default attribution). Do not push and do not deploy.
- If npm or pnpm fails with a read-only store or cache error (`/tmp/.pnpm-store` belongs to another user), rerun with `TMPDIR` and the cache pointed into the session scratchpad, for example `bash -c 'S=/tmp/claude-1002/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/9d521e8a-c2b5-4e55-bdcd-429b1ab44bfc/scratchpad; mkdir -p $S/tmp $S/npm-cache; cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && TMPDIR=$S/tmp npm_config_cache=$S/npm-cache npm run build'`. For pnpm, use `--store-dir $S/pnpm-store`.
- Use `node_modules/.bin/vitest` and `node_modules/.bin/vite`, never `npx` (no network installs). No package is added. package.json and package-lock.json stay byte-identical.
- The orchestrator commits the planning docs (this PLAN, the SUMMARY and STATE). The executor commits only code in sm-journal-plugin.
</context>
<source_coverage>
| Source item (quick task description) | Covered by |
|---|---|
| "Podgląd"/"Preview" toolbar button through the existing ui-locale handling (labelsFor) | Task 1 (labels, toolbar) |
| Splits the editor 50/50, with the right pane rendering the current HTML | Task 1 (panes, grid CSS, srcdoc) |
| Works in WYSIWYG and Source modes and updates live with a debounce | Task 2 (#schedulePreview, PREVIEW_DEBOUNCE_MS); Task 1 makes the button usable in Source |
| Sandboxed `<iframe srcdoc>` with no allow-scripts and no allow-same-origin | Task 1 (PREVIEW_SANDBOX '', srcdoc only) |
| Post-body typography | Task 2 (srcdoc stylesheet) |
| YouTube placeholders as thumbnail cards (img.youtube.com plus a label), never a live iframe | Task 2 (previewBody, youtubeThumbnail) |
| Per element, off by default, aria-pressed | Task 1 (#previewOn per instance, aria-pressed via setPreview) |
| Narrow widths: the pane collapses below the editor | Task 1 (auto-fit grid; checked by hand in a browser, noted in the SUMMARY) |
| Toggling or previewing never emits summer-change and never re-serializes an untouched document; the value is read without a mutating serializer; the untouched string is returned exactly; existing untouched tests stay green | Task 1 (value-getter-only read, serializer spy tests, existing files unedited); Task 2 (the same after debounced renders) |
| Only --c-* variables, scoped under the tag; srcdoc carries its own inline stylesheet with values resolved by getComputedStyle or neutral defaults | Tasks 1 and 2 (CSS gates, resolvePreviewColors, safeCssValue) |
| Vitest: toggle and aria-pressed, debounced live update with fake timers, sandbox attribute, embed card, no summer-change, untouched value after toggling | Task 1 (toggle, sandbox, no summer-change, untouched) and Task 2 (debounce, embed card, colours, active content) |
| Rebuild and commit assets/dist with check-dist green; go vet passes | Every task's verify; Task 3 final gate |
| Update the plugin README's Admin section | Task 3 |
| Do not push or deploy | Runtime notes; Task 3 done criteria |
No item is deferred or unplanned.
</source_coverage>
<tasks>
<task type="tracer">
<name>Task 1: End-to-end preview toggle (toolbar button, element, sandboxed srcdoc frame)</name>
<files>../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
<read_first>
- element.ts in full: the value getter and setter, connectedCallback, disconnectedCallback, showSource, #load, #sourceInput and #emitChange
- toolbar.ts in full: ToolbarHost, Toolbar, the GROUPS buttons, the tabs and refresh()
- labels.ts: the Labels interface, the en and pl tables, labelsFor
- serializer.ts: the serializeDocument signature (read only, to confirm it is pure)
- blogeditor.css: the tag-scoped selectors, the [hidden] rule and the aria-pressed rule
- tests/blogeditor/helpers.ts (mount, button, click) and the first 105 lines of tests/blogeditor/smoke.test.ts, for the test style
</read_first>
<precondition>sm-journal-plugin HEAD is 9d5bfc4, `git status --porcelain` shows only `?? go.work.sum`, and node_modules/.bin/vitest exists.</precondition>
<behavior>
New file tests/blogeditor/preview.test.ts. At the top, vi.mock the serializer module ('../../assets/src/blogeditor/serializer') with importOriginal: re-export everything and wrap serializeDocument in vi.fn around the real function. afterEach calls vi.useRealTimers(), vi.mocked(serializeDocument).mockClear() and document.body.replaceChildren(). Reuse mount, button and click from ./helpers, and the fixture from './fixtures/imported-post.html?raw'.
- Toggle: mount('<p>Hello</p>'). button(el, 'preview') has the text 'Preview', aria-label 'Preview' and aria-pressed 'false'. el.querySelector('.blogeditor__preview') is hidden. After a click, aria-pressed is 'true', the pane is not hidden, '.blogeditor__panes' has class 'blogeditor__panes--split', and the iframe's srcdoc already contains '<p>Hello</p>' with no timer advanced. Another click gives aria-pressed 'false', a hidden pane and no split class.
- Locale: with ui-locale 'pl' the button text and aria-label are 'Podgląd' and the iframe title is 'Podgląd wpisu'. With en the title is 'Post preview'.
- Per element: mount two elements and toggle the first. The second's button keeps aria-pressed 'false' and its pane stays hidden.
- Usable everywhere: after el.showSource(true), and separately with the disabled attribute set, the preview button is not disabled and a click turns the preview on.
- Sandbox: iframe.getAttribute('sandbox') is exactly ''. The attribute string matches neither /allow-scripts/ nor /allow-same-origin/. The iframe has no src or allow attribute, and referrerpolicy is 'no-referrer'. The srcdoc contains the PREVIEW_CSP meta (http-equiv Content-Security-Policy with content PREVIEW_CSP) and '<meta name="referrer" content="no-referrer">'.
- Untouched value: mount(fixture) and keep const before = editor.state.doc. Toggle on, off and on again. Then changes is [], el.value toBe fixture, editor.state.doc toBe before (no transaction ran), serializeDocument was not called, and the srcdoc contains 'Jan Kowalski'. Setting el.value = fixture afterwards emits nothing.
- Touched value: mount('<p>a</p>'), insert 'X' at position 1 (one user edit), then toggle on. changes has length 1, the srcdoc contains '<p>Xa</p>', serializeDocument was called (the touched path goes through the getter), and toggling off and on adds no summer-change.
</behavior>
<action>
Work only in /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin (see the runtime notes). This task wires one path end to end: button, element, preview module, iframe. It also builds the final security shape of the frame. Task 2 adds the content transforms and live updates.
1. labels.ts: add `preview` and `previewFrame` to the Labels interface and to both tables. In en they are 'Preview' and 'Post preview'. In pl they are 'Podgląd' and 'Podgląd wpisu'. labelsFor and the element's ui-locale reading stay the only locale mechanism.
2. Create assets/src/blogeditor/preview.ts as a pure module with no element state. It exports:
- `PREVIEW_SANDBOX = ''`: the iframe's sandbox attribute value, an empty token list, which is the most restrictive sandbox (no scripts, opaque origin, no forms, popups or top navigation).
- `PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"`.
- `PREVIEW_DEBOUNCE_MS = 300` (used from Task 2).
- `interface PreviewOptions { labels: Labels; lang?: string | null }`.
- `buildPreviewDocument(html: string, options: PreviewOptions): string`. It returns a full document: a doctype, then an html element, then a head with meta charset utf-8. The http-equiv Content-Security-Policy meta whose content is PREVIEW_CSP and the meta name=referrer content=no-referrer follow, and nothing else comes before them in head. The body wraps the HTML in `article class="post-body"`. In this task the HTML is inserted as given. Task 2 adds previewBody, the stylesheet and lang.
- Doc comments state why: the sandbox blocks scripts from pasted HTML and gives the frame an opaque origin, and CSS variables do not cross into the frame.
3. toolbar.ts:
- ToolbarHost gains `togglePreview: () => void`. Toolbar gains `setPreview: (on: boolean) => void`, which sets the preview button's aria-pressed to 'true' or 'false'.
- After the tabs, append a `div.blogeditor__view` holding one button. Give it type button, data-command 'preview', textContent labels.preview, title and aria-label labels.preview, and an initial aria-pressed of 'false'. Its mousedown calls preventDefault (it keeps the editor selection, like the other buttons) and its click calls host.togglePreview().
- It is a toggle, not a tab. Keep it out of the role=tablist div and out of the `buttons` array, so refresh() never disables it. The preview is read-only, so it works in the Source tab, with the disabled attribute and before an editor exists.
4. element.ts:
- Add private state: `#previewOn = false` (per instance, off by default, never stored anywhere), `#panes`, `#previewPane`, `#previewFrame` and `#previewTimer`.
- In connectedCallback, append the mount and the textarea inside `div.blogeditor__main`, which sits inside `div.blogeditor__panes`. Follow it with `div.blogeditor__preview`, created hidden and holding the iframe. Keep the existing append order otherwise: toolbar, forms, panes, status, file input.
- Create the iframe with setAttribute('sandbox', PREVIEW_SANDBOX), referrerpolicy 'no-referrer' and title labels.previewFrame. Never add a sandbox token, src, allow or allowfullscreen.
- Pass `togglePreview: () => this.showPreview(!this.#previewOn)` to buildToolbar. At the end of connectedCallback, call showPreview(true) when #previewOn is already true (a re-attached element).
- Add a public `showPreview(on: boolean): void`. It sets #previewOn and returns if the panes are not built yet. Otherwise it sets the pane's hidden to !on, toggles the class `blogeditor__panes--split` on #panes and calls toolbar.setPreview(on). When on, it renders right away through #renderPreview(). When off, it clears the pending timer.
- Add `#renderPreview()`. It clears the timer and returns unless #previewOn and the frame exist. It reads the HTML only through the value getter (`this.value`), then sets `frame.srcdoc = buildPreviewDocument(html, { labels: this.#labels })`. The preview path never calls editor.getHTML(), #load, setContent, captureBlocks or #emitChange. It never dispatches a transaction and never assigns #value, #mode, #sourceText, #parsed or #blocks. So an untouched document never reaches serializeDocument, and nothing in the stored-value path changes.
- In disconnectedCallback, clear #previewTimer and keep #previewOn.
5. blogeditor.css: every new selector starts with golem15-journal-blogeditor, and every colour is a var(--c-*).
- `.blogeditor__view` is a flex row with a 2px gap, like `.blogeditor__tabs`.
- `.blogeditor__panes--split` is a grid with `grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr))`. That gives two equal 1fr columns (50/50) when the element is at least about 720px wide. Below that it gives one column, with the preview wrapping below the editor, because auto-fit collapses the empty tracks. No media query is needed, and the element's width drives it rather than the viewport's.
- `.blogeditor__main` gets min-width 0.
- `.blogeditor__preview` is a flex container with min-width 0, min-height 360px, background var(--c-surface) and a 1px var(--c-border) divider that reads in both arrangements, for example an outline.
- Its iframe gets display block, flex 1 1 auto, width 100%, min-height 360px, border 0 and background var(--c-surface).
- The existing [hidden] rule hides the pane when it is off. Existing rules stay as they are.
6. Write tests/blogeditor/preview.test.ts per the behavior block. Do not edit smoke.test.ts, corpus.test.ts, helpers.ts or anything under fixtures.
7. Run `npm run build`, then `bash scripts/check-dist.sh`, `npm test` and `go vet ./...`, all inside bash -c. Stage only this task's files and commit in sm-journal-plugin with the subject `feat(editor): toggleable preview pane in a sandboxed frame` and a two-line body (the toggle and split, the empty sandbox with srcdoc only and the value getter as the only read). Add no co-author trailer and do not stage go.work.sum.
</action>
<verify>
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "togglePreview" assets/src/blogeditor/toolbar.ts && grep -qF "showPreview" assets/src/blogeditor/element.ts && grep -qF "PREVIEW_SANDBOX" assets/src/blogeditor/element.ts && grep -qF "auto-fit" assets/src/blogeditor/blogeditor.css && grep -qF "Podgląd" assets/src/blogeditor/labels.ts && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
</verify>
<done>Clicking the localized Preview button splits the element and shows the current value in an iframe whose sandbox attribute is exactly empty and whose srcdoc carries the CSP and no-referrer metas. aria-pressed follows the toggle per element. Toggling an untouched imported post emits nothing, runs no transaction, never calls serializeDocument and keeps the exact string. The whole vitest suite passes, including the unedited smoke and corpus files. check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Live debounced updates, YouTube thumbnail cards, post-body typography and resolved colours</name>
<files>../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
<read_first>
- preview.ts and the element.ts preview members as committed in Task 1
- embed.ts (EMBED_PROVIDERS, isEmbedProvider, idPattern) and, in extensions.ts, the inert() function and the Embed node's parseHTML getAttrs and addNodeView card text (use grep -n to find the lines, then read only those ranges)
- the .ProseMirror content rules in blogeditor.css (headings, lists, blockquote, code, table, figure), which the preview typography mirrors
</read_first>
<behavior>
Add these to tests/blogeditor/preview.test.ts. Call vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) after mount and before the toggle, and advance only by explicit amounts.
- WYSIWYG live update: mount('<p>a</p>') and toggle on, so the srcdoc has '<p>a</p>'. Insert 'X' at 1. The srcdoc is unchanged after advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1) and contains '<p>Xa</p>' after advancing 1 more ms.
- Debounce reset: make an edit, advance 200, make a second edit, advance 200. The srcdoc still shows neither edit. After advancing 100 more it shows both.
- Source mode: call showSource(true), set the textarea to '<h2>From source</h2>' and dispatch input. After PREVIEW_DEBOUNCE_MS the srcdoc contains '<h2>From source</h2>'. changes is exactly ['<h2>From source</h2>'] (the input only; the render adds nothing).
- External value: with the preview on, set el.value = '<p>New</p>'. After the debounce the srcdoc contains '<p>New</p>' and changes is [].
- Off means idle: toggle on, then off, then edit and advance 1000 ms. The srcdoc is unchanged from the last render.
- Detach: make an edit with the preview on, call el.remove(), then advance 1000 ms. No error is thrown and the srcdoc is unchanged.
- Untouched after renders: mount(fixture), toggle on, then advanceTimersByTime(1000). changes is [], el.value toBe fixture and serializeDocument was not called.
- Embed card: mount('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>') and toggle on. The srcdoc contains 'https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'referrerpolicy="no-referrer"' and 'Embedded video: YouTube dQw4w9WgXcQ'. With ui-locale pl it contains 'Osadzone wideo: YouTube dQw4w9WgXcQ'. The srcdoc matches no /<iframe/i, and el.value equals the placeholder string exactly.
- Invalid placeholder: '<figure data-embed="youtube" data-id="short"></figure>' gives a srcdoc with no 'img.youtube.com'.
- Active content in the copy only: value '<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe><img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'. The srcdoc matches none of /<script/i, /<iframe/i, /onerror/i or /http-equiv="refresh"/i, and still contains '<p>a</p>' and 'src="/x.png"'. el.value equals the given string exactly and changes is [].
- Colours: set el.style.setProperty('--c-text', 'rgb(1, 2, 3)') before toggling, and the srcdoc contains 'rgb(1, 2, 3)'. No srcdoc ever contains 'var(--c-'. With no variables set, the srcdoc contains PREVIEW_DEFAULTS.colors.text.
- safeCssValue unit cases: an empty value, a value with '}', a value with '<', a value with ';' and a 201-character value all return the fallback. ' rgb(1, 2, 3) ' returns 'rgb(1, 2, 3)', and '"DM Sans", sans-serif' is returned unchanged.
- Lang and typography: with locale 'pl' the srcdoc contains '<html lang="pl">'. With locale 'pl"><x' it has no lang attribute. The srcdoc has one style element with rules for '.post-body', 'blockquote', 'figcaption' and '.embed-card', and the body wraps the content in '<article class="post-body">'.
</behavior>
<action>
TDD: add the behavior cases first and run `node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`. Note in the SUMMARY which cases fail against Task 1, then implement until the whole suite is green.
1. preview.ts:
- Export `interface PreviewColors` with the fields surface, text, muted, border, borderStrong, subtle and primary. Export `PREVIEW_DEFAULTS = { colors, fontFamily }`. Use neutral light-theme constants: a white surface, near-black text, a mid-grey muted colour, light-grey and darker-grey borders, an off-white subtle colour and a blue primary. The font stack is system-ui with sans-serif fallbacks.
- Export `safeCssValue(value, fallback)`. It returns the trimmed value, or the fallback when the value is empty, longer than 200 characters, or contains a less-than, greater-than, curly brace, semicolon, backslash or a CSS comment opener. A host value can then never close the style element or inject a rule.
- Export `resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string }`. It reads getComputedStyle(host).getPropertyValue for --c-surface, --c-text, --c-muted, --c-border, --c-border-strong, --c-subtle and --c-primary, plus the computed fontFamily, each through safeCssValue with its PREVIEW_DEFAULTS fallback.
- Export `youtubeThumbnail(id)`, which returns the https://img.youtube.com/vi/ID/hqdefault.jpg URL. Call it only for ids that passed the provider idPattern, so no unchecked text reaches a URL.
- Export `previewBody(html: string, labels: Labels): string`. It works on a copy:
- Parse the copy into a template element created from inert().
- Remove the script, iframe, frame, frameset, object, embed, meta, base and link elements, and every attribute whose lowercased name starts with 'on'. This is defense in depth on top of the sandbox, and the stored value never changes.
- Replace each figure[data-embed] that is exactly the editor's placeholder with a card. "Exactly" means two attributes, no child nodes, isEmbedProvider(provider) and EMBED_PROVIDERS[provider].idPattern accepting the id, the same test as the Embed node's parse rule. The card is a figure with class embed-card holding an img and a figcaption. The img has src youtubeThumbnail(id), an empty alt, referrerpolicy no-referrer, loading lazy, width 480 and height 360. The figcaption's textContent is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`, the editor card's text, set through textContent so it is escaped.
- Leave any other figure[data-embed] as it is, with no thumbnail request.
- Return the copy's HTML through a div in the inert document.
- Extend PreviewOptions with `colors?: PreviewColors` and `fontFamily?: string`. In buildPreviewDocument:
- Put the html element's lang attribute only when options.lang matches /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.
- Add one style element after the two metas, holding the post-body typography built from the given colours and font, with PREVIEW_DEFAULTS for anything missing.
- Wrap previewBody(html, labels) in the article.
- The typography mirrors the editor's .ProseMirror content rules, so the preview reads like a post body:
- body: margin 0, padding 16px 20px, background surface, colour text, a 16px font with 1.7 line-height in the resolved family, overflow-wrap anywhere.
- .post-body: max-width 72ch with auto side margins.
- Headings h1 to h4 at weight 600 with sizes 1.875em, 1.5em, 1.25em and 1.1em and margin 1.4em 0 0.5em.
- p, ul, ol, blockquote, table, figure and pre: margin 0 0 1em.
- ul uses disc and ol uses decimal, each with 1.5em left padding.
- Links: primary colour, underlined.
- blockquote: a 3px borderStrong left border, 1em left padding and the muted colour.
- code and pre: on the subtle colour with a monospace stack. pre gets padding and an 8px radius.
- hr: a borderStrong top border.
- img: max-width 100%, height auto.
- Tables: collapse borders at full width. td and th get a 1px borderStrong border, 4px 8px padding and vertical-align top. th gets the subtle background, weight 600 and left alignment.
- figcaption: 13px, muted, margin-top 4px.
- .embed-card: max-width 480px, a 1px border-colour border, an 8px radius, overflow hidden and the subtle background. Its img gets display block, width 100%, aspect-ratio 16/9 and object-fit cover. Its figcaption gets 8px 12px padding and margin 0.
- The stylesheet uses only the concrete resolved values.
2. element.ts:
- Add `#schedulePreview()`. It returns unless #previewOn and the frame exist. Otherwise it clears the pending timer and sets #previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS).
- Call it in four places:
- in the editor's onUpdate after #emitChange, which covers WYSIWYG typing, toolbar commands, undo and redo
- at the end of #sourceInput (the Source tab)
- at the end of the value setter after #load (an external value; the echo early return stays first, so an echo schedules nothing)
- in attributeChangedCallback (locale changes the preview lang)
- #renderPreview now passes `lang: this.getAttribute('locale')` and the result of resolvePreviewColors(this) to buildPreviewDocument. It skips assigning srcdoc when the built document equals the current srcdoc attribute, which avoids a needless reload.
- Nothing else changes in the value, mode or event logic. The preview still reads only through the value getter.
3. Run `npm run build`, `bash scripts/check-dist.sh`, `npm test` and `go vet ./...` through bash -c. Commit only this task's files with the subject `feat(editor): live preview with embed cards and post typography` and a short body (the 300 ms debounce in both modes, thumbnail cards with no player, the preview-copy element and attribute drop, and colours resolved from the host). Add no co-author trailer.
</action>
<verify>
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "PREVIEW_DEBOUNCE_MS" assets/src/blogeditor/element.ts && grep -qF "schedulePreview" assets/src/blogeditor/element.ts && grep -qF "resolvePreviewColors" assets/src/blogeditor/element.ts && grep -qF "idPattern" assets/src/blogeditor/preview.ts && grep -qF "inert()" assets/src/blogeditor/preview.ts && grep -qF "img.youtube.com" assets/dist/blogeditor.js && test "$(grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js)" = 0 && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
</verify>
<done>The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while it is off and never after detach. YouTube placeholders show as img.youtube.com thumbnail cards with the localized label and no iframe. Active elements and on* attributes are absent from the preview copy while the value keeps them byte for byte. The srcdoc carries post-body typography with resolved or default colours and no var(--c-. All vitest files pass, check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
</task>
<task type="auto">
<name>Task 3: README Blog Editor and Admin sections, final gate</name>
<files>../sm-journal-plugin/README.md</files>
<read_first>
- README.md, the "### Blog Editor" and "### Admin: Blog Editor field types" sections (grep -n for the headings, then read that range only)
- preview.ts as committed, so every identifier and value the README names matches the code
</read_first>
<action>
1. In "### Blog Editor", extend the toolbar sentence of the first paragraph so it also lists a Preview toggle.
2. Add a bullet that starts with "**Preview.**" after the "Embeds" bullet. It states:
- The Preview button ('Podgląd' for ui-locale pl) is off by default and per element. It shows its state through aria-pressed.
- It splits the editor 50/50, and the pane wraps below the editor when the element is narrower than about 720px.
- The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS`) after the last change. Each update reloads the frame.
- The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images.
- The preview copy leaves out script, iframe, frame, frameset, object, embed, meta, base and link elements and event handler attributes.
- YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player. This, with the post's own images, is the only network traffic the pane makes.
- The styling is post-body typography, with `--c-*` values read from the element at render time or neutral defaults.
- Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
3. In "### Admin: Blog Editor field types", add one sentence after the type table: both types show the element's Preview pane, and previewing never changes what a save stores.
4. Name no consuming application. Every identifier you name (`PREVIEW_DEBOUNCE_MS`, `PREVIEW_CSP`, `summer-change`) must exist in the code.
5. Commit README.md alone with the subject `docs(readme): blog editor preview pane` and no co-author trailer.
6. Run the final gate in the verify command, and do not push. In the SUMMARY:
- Record the three commit SHAs.
- Record the bundle sizes before and after (the 16.2-02 baseline was 466,051 bytes).
- Note that the 50/50 split, the narrow-width wrap, real sandbox enforcement and thumbnail loading are a browser check for the user, which happy-dom cannot do.
</action>
<verify>
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "**Preview.**" README.md && grep -qF "Podgląd" README.md && grep -qF "sandbox" README.md && grep -qF "img.youtube.com" README.md && grep -qF "PREVIEW_DEBOUNCE_MS" README.md && grep -qF "PREVIEW_CSP" README.md && test "$(grep -Eic "grzyby|fonoteka|plytarium|płytarium" README.md)" = 0 && shas=$(git log --format=%H 9d5bfc4..HEAD) && test "$(grep -c . <<< "$shas")" -ge 3 && bodies=$(git log --format=%B 9d5bfc4..HEAD) && test -z "$(grep -i co-authored-by <<< "$bodies")" && changed=$(git diff --name-only 9d5bfc4 HEAD) && test -z "$(grep -vxF -e "" -e README.md -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$changed")" && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json go.mod go.sum && st=$(git status --porcelain) && test -z "$(grep -vxF -e "" -e "?? go.work.sum" <<< "$st")" && subj=$(git log -1 --format=%s) && grep -q "^docs(readme)" <<< "$subj"'</automated>
</verify>
<done>The README's Blog Editor section lists the Preview toggle and has a Preview bullet covering the toggle, layout, debounce, sandbox, CSP, embed cards, typography and the value contract. The Admin field types section notes that previewing never changes what is saved. The final gate is green: vitest, check-dist and go vet. The branch has at least three local commits since 9d5bfc4 that touch only the listed files, carry no co-author trailer and leave package.json, the lockfile, go.mod, go.sum and the existing tests unchanged. The working tree is clean apart from go.work.sum. Nothing is pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| stored or admin-pasted post HTML → preview iframe | Untrusted markup (raw HTML blocks, Source tab text, imported posts) is rendered as a live document for the first time in the admin |
| preview iframe → admin origin | The admin page holds the session, the admin DOM and the SPA state; the preview document must not reach them |
| preview iframe → third parties (img.youtube.com, image hosts) | Rendering triggers network requests from the administrator's browser |
| host page CSS (--c-* values) → srcdoc stylesheet | Values computed from the admin's CSS are interpolated into the preview document |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-iwu-01 | Elevation of Privilege (script execution, admin XSS) | element.ts preview iframe, preview.ts buildPreviewDocument | high | mitigate | The iframe's sandbox attribute is exactly '' (PREVIEW_SANDBOX), so no scripts run. Content enters only through srcdoc, with no src and no allow. The srcdoc inherits the admin CSP (script-src 'self') and adds PREVIEW_CSP (default-src 'none'). previewBody also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute from the copy. The Task 1 test pins sandbox === '' with no allow-scripts. The Task 2 test pins the active-content drop. |
| T-iwu-02 | Information Disclosure (admin origin, session, DOM) | preview iframe origin | high | mitigate | There is no allow-same-origin token, so the srcdoc document has an opaque origin. It cannot read admin cookies, storage, the parent DOM or the SPA, and the parent never reaches into contentDocument. The Task 1 sandbox test pins this. |
| T-iwu-03 | Information Disclosure (third-party requests) | YouTube thumbnail cards, post images | low | mitigate | A thumbnail is requested only while the preview is switched on (off by default), and only for ids that pass the provider idPattern. The thumbnail URL is youtubeThumbnail(id), so no free text reaches a URL. The img has referrerpolicy no-referrer, the srcdoc has a no-referrer meta, and the admin's same-origin Referrer-Policy is inherited, so the admin URL never leaks. PREVIEW_CSP limits network access to images: fonts, stylesheets, frames and media are blocked. Residual risk (accepted): the administrator's IP and user agent reach img.youtube.com, the same exposure every site visitor gets from the layer's player. |
| T-iwu-04 | Tampering (stored value integrity) | element.ts #renderPreview and #schedulePreview | medium | mitigate | The preview reads only the value getter: clean returns the exact string, source returns the Source text, and wysiwyg calls the pure serializeDocument. It never calls getHTML, setContent, #load or #emitChange, never dispatches transactions and never writes value fields. Tests assert no summer-change, el.value === fixture, editor.state.doc identity and serializeDocument not called for an untouched post. smoke.test.ts and corpus.test.ts stay unedited and green. |
| T-iwu-05 | Spoofing (navigation, phishing from previewed links) | preview iframe | low | mitigate | The empty sandbox blocks top navigation, popups and form submission, so a click on a previewed link can only navigate the sandboxed frame itself. The meta refresh element is dropped from the copy, and the inherited base-uri 'none' blocks a pasted base element. |
| T-iwu-06 | Tampering (CSS injection into srcdoc) | preview.ts resolvePreviewColors and safeCssValue | low | mitigate | Every computed --c-* value and the font family pass safeCssValue. A value that contains characters able to close the style element or start a rule (angle or curly brackets, semicolon, backslash, comment opener), or that runs over 200 characters, falls back to PREVIEW_DEFAULTS. Unit cases cover each. |
| T-iwu-07 | Denial of Service (render per keystroke on long posts) | element.ts #schedulePreview | low | mitigate | A 300 ms debounce applies, nothing runs while the preview is off, the timer is cleared on toggle off and on disconnect, and an identical srcdoc is not re-assigned. Tests cover the debounce reset, the idle-when-off case and detach. |
| T-iwu-SC | Tampering | npm/pip/cargo installs | low | accept | No package is added. The Task 1 to Task 3 gates diff package.json and package-lock.json against 9d5bfc4. The build uses the committed lockfile through check-dist.sh. |
</threat_model>
<verification>
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test'`: smoke.test.ts, corpus.test.ts and preview.test.ts all pass, and the first two are unedited.
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && bash scripts/check-dist.sh'` prints `check-dist: assets/dist matches a fresh build`.
- `go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin vet ./...` is clean.
- `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin log --oneline 9d5bfc4..HEAD` shows the three commits (two feat(editor), one docs(readme)), and nothing is pushed.
- Browser check (human judgment, recorded in the SUMMARY and not blocking): in the admin post form, Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window. A `<script>` in a raw HTML block does nothing in the pane. A YouTube embed shows its thumbnail card.
</verification>
<success_criteria>
- The localized Preview toggle, with aria-pressed and per-element state that is off by default, splits the editor 50/50 and wraps the pane below the editor on narrow widths.
- The pane is a srcdoc iframe with an empty sandbox attribute, a CSP meta and a no-referrer meta. It updates live with a 300 ms debounce in WYSIWYG and Source modes.
- YouTube placeholders show as img.youtube.com thumbnail cards with a label, with no iframe. The preview uses post-body typography with resolved --c-* values or neutral defaults, and blogeditor.css stays tag-scoped with --c-* colours only.
- The value contract holds: toggling or previewing never emits summer-change, never serializes an untouched document and never changes the value. The existing untouched-value tests pass unedited.
- assets/dist is rebuilt and committed, check-dist is green, go vet is clean, the README Blog Editor and Admin sections are updated, there are no co-author tags, and nothing is pushed or deployed.
</success_criteria>
<output>
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261010-iwu-blog-editor-live-preview-pane-toggleable/261010-iwu-SUMMARY.md` when done. It records `commits_repo: ../sm-journal-plugin`, the three commit SHAs, the bundle size delta, the TDD RED evidence for Task 2 and the browser check as a human_judgment coverage item.
</output>

View File

@@ -0,0 +1,147 @@
---
phase: quick-261010-iwu
plan: 01
subsystem: journal-blogeditor
status: complete
tags: [blog-editor, preview, iframe, sandbox, csp, tiptap, journal]
commits_repo: ../sm-journal-plugin
requires:
- "16.2-02 Blog Editor element (value contract, toolbar, labels, embed placeholder)"
provides:
- "Toggleable live preview pane in golem15-journal-blogeditor (sandboxed iframe srcdoc)"
- "preview.ts: PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
affects:
- "sm-journal-plugin assets/dist (embedded admin bundle)"
tech-stack:
added: []
patterns:
- "Read-only preview through the value getter only (no serializer for an untouched document)"
- "Lazy iframe creation on first switch-on"
- "Inert-document copy for preview transforms (active-content drop, embed thumbnail cards)"
key-files:
created:
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
- ../sm-journal-plugin/tests/blogeditor/preview.test.ts
modified:
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
- ../sm-journal-plugin/assets/dist/blogeditor.js
- ../sm-journal-plugin/assets/dist/blogeditor.css
- ../sm-journal-plugin/README.md
decisions:
- "The preview iframe is created on the first switch-on, not at connect, because the unedited smoke test asserts the element holds no iframe after mount; it also means an element that never previews has no extra browsing context"
- "Task 2 shipped as one feat commit (no separate RED test commit) so npm test stays green at every commit; the RED evidence is recorded here"
metrics:
duration: "~20 min"
completed: 2026-10-10
tasks: 3
files: 9
estimate:
tokens: 70000
tasks: 3
actuals:
tokens: 10058 # chars/4 over the realized diff of sources, tests and README (40,233 chars; the regenerated assets/dist bundle excluded)
tasks: 3
commits: 3
plan_head_before: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
plan_head_after: 2c78211cb90e27cf8515b104613c3eac1a0aca5f
---
# Quick 261010-iwu Plan 01: Blog Editor Live Preview Pane Summary
A per-element Preview/Podgląd toggle on the Journal Blog Editor splits the editor 50/50 with an auto-fit grid. The second pane is an `<iframe srcdoc>` with an empty `sandbox` attribute, a CSP meta and a no-referrer meta. It re-renders 300 ms after the last change in WYSIWYG or Source mode, shows YouTube placeholders as img.youtube.com thumbnail cards and uses post-body typography with colours resolved from the host's `--c-*` variables. It reads only the value getter, so an untouched post still saves byte for byte.
## Commits (sm-journal-plugin, branch main, not pushed)
| Task | Commit | Message |
|------|--------|---------|
| 1 | `df67939` | feat(editor): toggleable preview pane in a sandboxed frame |
| 2 | `c1f6a83` | feat(editor): live preview with embed cards and post typography |
| 3 | `2c78211` | docs(readme): blog editor preview pane |
`git rev-list --count 9d5bfc4..HEAD` = 3. No commit has a co-author trailer. `go.work.sum` stays untracked.
## What was built
- **labels.ts:** `preview` / `previewFrame` labels: 'Preview' / 'Post preview' (en) and 'Podgląd' / 'Podgląd wpisu' (pl).
- **toolbar.ts:** a `div.blogeditor__view` holding `button[data-command=preview]` with `aria-pressed`, placed outside the tablist and the `buttons` array so `refresh()` never disables it. Adds `ToolbarHost.togglePreview` and `Toolbar.setPreview`.
- **element.ts:**
- Per-instance `#previewOn` (off by default) and a public `showPreview(on)`.
- The editor and the Source textarea now sit inside `div.blogeditor__main`, which is inside `div.blogeditor__panes`, next to a hidden `div.blogeditor__preview`.
- The iframe is created lazily with `sandbox=""` and `referrerpolicy=no-referrer`.
- `#renderPreview()` reads only `this.value` and skips an identical srcdoc.
- `#schedulePreview()` debounces renders. It is called from onUpdate, `#sourceInput`, the value setter (after the echo early return) and attributeChangedCallback.
- The timer is cleared on toggle-off and on disconnect.
- **preview.ts:**
- Constants and `safeCssValue`.
- `resolvePreviewColors`, which uses getComputedStyle with fallbacks to `PREVIEW_DEFAULTS`.
- `youtubeThumbnail`.
- `previewBody`: an inert-document copy that drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every `on*` attribute. It turns exact placeholders, checked with `isEmbedProvider` and `idPattern`, into `figure.embed-card` with an img and a figcaption.
- `buildPreviewDocument`: charset, CSP and referrer metas, one `<style>`, `lang` only when it matches the BCP-47-ish pattern, and `article.post-body`.
- **blogeditor.css:** `.blogeditor__view`, `.blogeditor__panes--split` (`repeat(auto-fit, minmax(min(100%, 360px), 1fr))`), `.blogeditor__main`, `.blogeditor__preview` and its iframe. All are tag-scoped and use only `var(--c-*)`.
- **tests/blogeditor/preview.test.ts:** 23 cases covering the toggle and aria-pressed, the locale, per-element state, lazy frame creation, usability in Source and disabled, the sandbox and CSP, the untouched and touched value contract, debounced live updates (WYSIWYG, debounce reset, Source, external value, idle when off, detach, untouched after renders), embed cards (en and pl), the invalid placeholder, the active-content drop, colours, lang and typography, and safeCssValue unit cases.
- **README.md:** the Blog Editor toolbar sentence, a "**Preview.**" bullet and an Admin field-types sentence.
## TDD Gate Compliance (Task 2)
RED: the 14 Task 2 cases were added first and run against the Task 1 code (`node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`). 10 of them failed:
- live updates: re-renders a WYSIWYG edit after the debounce; restarts the debounce on every change; re-renders Source tab input; re-renders an external value
- content: YouTube thumbnail card; active-content drop; colours; lang and typography
- safeCssValue: both cases (export missing)
4 passed already: schedules nothing while off, drops a pending render on detach, untouched after renders, and no thumbnail for an invalid placeholder. They hold trivially when there is no scheduling or no card. They are guards, not drivers.
GREEN: after the preview.ts and element.ts changes, all 55 tests in the three files pass.
## Bundle size
| File | 16.2-02 baseline / before | After |
|------|---------------------------|-------|
| assets/dist/blogeditor.js | 466,051 B | 471,714 B (+5,663 B; 149.65 kB gzip) |
| assets/dist/blogeditor.css | 7,480 B | 8,036 B (+556 B) |
## Verification
- `npm test`: 55/55 pass (smoke 31, corpus, preview 23). smoke.test.ts, corpus.test.ts, helpers.ts and the fixtures are unchanged against 9d5bfc4.
- `bash scripts/check-dist.sh` prints `check-dist: assets/dist matches a fresh build`.
- `go vet ./...` is clean.
- package.json, package-lock.json, go.mod and go.sum are byte-identical to 9d5bfc4.
- All three task verify commands passed, including the Task 1 tracer re-run before expansion.
## Human judgment (browser check, not blocking)
happy-dom cannot check these, so they need a look in a real browser in the admin post form:
- Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window (under about 720px element width).
- A `<script>` in a raw HTML block does nothing in the pane (real sandbox enforcement).
- A YouTube embed shows its thumbnail card, and the image loads under the admin CSP plus PREVIEW_CSP.
- Relative image URLs (`/storage/...`) resolve in the srcdoc against the admin origin.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Iframe created lazily instead of at connect**
- **Found during:** Task 1
- **Issue:** The plan had connectedCallback create the iframe up front. The unedited smoke test `embeds > round-trips the exact placeholder and shows a card, never an iframe` asserts `el.querySelector('iframe')` is null after mount, so it failed.
- **Fix:** `#previewFrameOf()` creates the iframe in the pane on the first `showPreview(true)` and reuses it afterwards. The pane div still exists, hidden, from connect. `#schedulePreview` stays idle until a frame exists. The locale test toggles on before reading the frame title, and a new case asserts there is no iframe before the first switch-on and exactly one after repeated toggles.
- **Files modified:** element.ts, preview.test.ts
- **Commit:** df67939
## Out-of-scope observation
The active-content test value holds `<iframe src="https://example.test/">`. When that value is mounted, happy-dom logs `ENOTFOUND example.test` fetch errors (stderr only, and the tests pass). A probe showed the source is `mount()` alone, meaning TipTap's existing `elementFromString` parses through `window.DOMParser`. Neither `previewBody` (inert document) nor the sandboxed srcdoc frame triggers it. Browsers do not load subresources in DOMParser documents, so this is a happy-dom artifact in the existing editor parse path. It predates this task and was not changed.
## Known Stubs
None.
## Threat Flags
None. The only new surface is the sandboxed srcdoc iframe and thumbnail requests to img.youtube.com, both covered by the plan's threat model (T-iwu-01 to T-iwu-07).
## Self-Check: PASSED
- FOUND: assets/src/blogeditor/preview.ts and tests/blogeditor/preview.test.ts
- FOUND commits: df67939, c1f6a83, 2c78211 (in sm-journal-plugin `git log`)