diff --git a/docs/superpowers/plans/2026-08-18-wrn-html-editing.md b/docs/superpowers/plans/2026-08-18-wrn-html-editing.md
new file mode 100644
index 00000000..b29f2250
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-18-wrn-html-editing.md
@@ -0,0 +1,1178 @@
+# HTML Editing Support for `.wrn` Files — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make writing markup inside a `.wrn` `view { }` block behave like writing HTML — tag and attribute completion, tag closing and renaming, hover docs, Emmet, and tag-level folding.
+
+**Architecture:** The language server extracts `view { }` blocks into a virtual HTML document where everything outside them is replaced by whitespace of identical length. Source and virtual positions are therefore the same, so no mapping table exists. `vscode-html-languageservice` answers completion, hover, folding, and tag closing against that virtual document, and its results merge with the WRNexus component index the server already builds. Only auto-close-on-type lives in the VS Code extension, because LSP has no request for it.
+
+**Tech Stack:** TypeScript 6.0.3, Bun (test runner), `vscode-html-languageservice`, VS Code extension API, LSP 3.16.
+
+**Spec:** `docs/superpowers/specs/2026-08-18-wrn-html-editing-design.md`
+
+## Global Constraints
+
+- Test runner is **Bun**. Language server tests live in `packages/language-server/test/` and use `import { expect, test } from "bun:test";`.
+- The virtual HTML document must satisfy: `virtualHtmlDocument(doc).text.length === doc.text.length`, with newlines at identical offsets. This is what makes position mapping unnecessary.
+- Region detection uses a **tolerant scanner**, never the `@wrnexus/syntax` parser — completion fires on unparseable documents.
+- WRNexus-specific syntax (`@click`, `client:visible`, `{expr}`) is **not** blanked out of the virtual document.
+- Completion inside a view block returns **one merged list**: WRNexus entries get `sortText` prefix `0`, HTML entries `1`. On exact label collision, the WRNexus entry wins and the HTML entry is dropped.
+- **HTML formatting is out of scope.** `formatWrn` owns markup formatting. Do not register an HTML formatter.
+- `vscode-html-languageservice` must be a dependency of **both** `packages/language-server` and `editors/vscode`.
+- New setting name, exactly: `wrnexus.html.autoClosingTags`, default `true`.
+- Custom LSP request name, exactly: `wrn/tagComplete`.
+- `TextDocument` is `{ uri: string; text: string; version?: number }` from `packages/language-server/src/index.ts`.
+- Commit after every task. Never use `--no-verify`.
+- After any change to `packages/language-server/src/`, run `bun run --cwd editors/vscode build` and commit the regenerated `editors/vscode/src/language-server.cjs`, or `check:editor-language-server` fails.
+
+## File Structure
+
+**New:**
+
+| File | Responsibility |
+| ---------------------------------------------------- | -------------------------------------------------------------------------- |
+| `packages/language-server/src/html-regions.ts` | Tolerant view-block scanner + offset-preserving virtual HTML document |
+| `packages/language-server/src/html-service.ts` | Wraps `vscode-html-languageservice`: completion, hover, folding, tag close |
+| `packages/language-server/test/html-regions.test.ts` | Scanner and invariant tests |
+| `packages/language-server/test/html-service.test.ts` | Completion merge, hover, folding, tag-close tests |
+
+**Modified:**
+
+| File | Change |
+| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
+| `packages/language-server/package.json` | Add `vscode-html-languageservice` dependency |
+| `packages/language-server/src/server.ts` | Merge HTML into completion/hover, add folding + linked editing + `wrn/tagComplete`, extend trigger characters |
+| `editors/vscode/package.json` | Add dependency, Emmet mapping, `wrnexus.html.autoClosingTags` setting |
+| `editors/vscode/src/extension.js` | Auto-close-on-type listener |
+| `editors/vscode/src/completion.js` | Stand down inside view blocks |
+| `editors/vscode/test/validate.mjs` | Manifest assertions |
+
+---
+
+### Task 1: View-block scanner and virtual HTML document
+
+The load-bearing piece. Everything else reads positions through this, so its invariant is tested before any feature uses it.
+
+**Files:**
+
+- Create: `packages/language-server/src/html-regions.ts`
+- Create: `packages/language-server/test/html-regions.test.ts`
+
+**Interfaces:**
+
+- Consumes: `TextDocument` from `packages/language-server/src/index.ts`.
+- Produces:
+ - `interface HtmlRegion { start: number; end: number }`
+ - `viewRegions(text: string): HtmlRegion[]`
+ - `virtualHtmlDocument(document: TextDocument): { uri: string; languageId: "html"; text: string }`
+ - `isInsideHtml(document: TextDocument, offset: number): boolean`
+ - `clearHtmlRegionCache(uri?: string): void`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `packages/language-server/test/html-regions.test.ts`:
+
+```ts
+import { expect, test } from "bun:test";
+import { isInsideHtml, viewRegions, virtualHtmlDocument } from "../src/html-regions.ts";
+
+function doc(text: string): { uri: string; text: string; version?: number } {
+ return { uri: `file:///${Math.random()}.wrn`, text };
+}
+
+const PAGE = `page Home {
+ view {
+
hello
+ }
+}
+`;
+
+test("the virtual document preserves length and newline offsets", () => {
+ // This is what makes position mapping unnecessary. If it breaks, every
+ // feature reports positions off by some amount instead of failing loudly.
+ const source = doc(PAGE);
+ const virtual = virtualHtmlDocument(source);
+
+ expect(virtual.text.length).toBe(source.text.length);
+ expect(virtual.languageId).toBe("html");
+ for (let i = 0; i < source.text.length; i += 1) {
+ if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n");
+ }
+});
+
+test("markup survives into the virtual document and everything else is blanked", () => {
+ const virtual = virtualHtmlDocument(doc(PAGE));
+ expect(virtual.text).toContain('
hello
');
+ expect(virtual.text).not.toContain("page Home");
+ expect(virtual.text).not.toContain("view");
+});
+
+test("an apostrophe in text content does not swallow later regions", () => {
+ // A scanner treating ' as a string delimiter anywhere considers the rest of
+ // the file one open string and loses every later region.
+ const source = `page A {
+ view {
+
it's fine
+ }
+}
+component B {
+ view {
+ second
+ }
+}
+`;
+ expect(viewRegions(source)).toHaveLength(2);
+ expect(virtualHtmlDocument(doc(source)).text).toContain("second");
+});
+
+test("interpolation braces nest without ending the region early", () => {
+ const source = `page A {
+ view {
+
after
+ }
+}
+`;
+ const regions = viewRegions(source);
+ expect(regions).toHaveLength(1);
+ expect(virtualHtmlDocument(doc(source)).text).toContain("after");
+});
+
+test("unparseable mid-edit markup still yields a region", () => {
+ // Completion fires exactly when the document does not parse.
+ const source = `page A {
+ view {
+
{
+ const source = `page A {
+ functions {
+ function go() {}
+ }
+}
+`;
+ expect(viewRegions(source)).toEqual([]);
+ expect(virtualHtmlDocument(doc(source)).text.trim()).toBe("");
+});
+
+test("regions are cached per document version", () => {
+ // One keystroke fans out into completion, hover, and tag-close requests.
+ const first = doc(PAGE);
+ first.version = 1;
+ expect(isInsideHtml(first, PAGE.indexOf("
");
+
+ const void_ = `page A {
+ view {
+
+ }
+}
+`;
+ expect(htmlTagComplete(doc(void_), positionOf(void_, " "))).toBeNull();
+});
+
+test("completes a self-closing component tag", () => {
+ const text = `page A {
+ view {
+ ");
+});
+
+test("returns no tag completion outside a view block", () => {
+ const text = `page A {
+ functions {
+ function go() { }
+ }
+}
+`;
+ expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull();
+});
+
+test("folding ranges stay inside view regions", () => {
+ const text = `page A {
+ view {
+
+
one
+
+ }
+}
+`;
+ const ranges = htmlFoldingRanges(doc(text));
+ expect(ranges.length).toBeGreaterThan(0);
+
+ const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
+ for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
+});
+```
+
+- [ ] **Step 3: Run the tests to verify they fail**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: FAIL — cannot resolve `../src/html-service.ts`
+
+- [ ] **Step 4: Write the implementation**
+
+Create `packages/language-server/src/html-service.ts`:
+
+```ts
+import { getLanguageService, TextDocument as HtmlTextDocument } from "vscode-html-languageservice";
+import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
+import { offsetAt, type Position, type TextDocument } from "./index.ts";
+
+export interface HtmlCompletionItem {
+ label: string;
+ kind: number;
+ detail?: string;
+ documentation?: string;
+ sortText?: string;
+ insertText?: string;
+}
+
+const service = getLanguageService();
+
+/** The virtual document as the HTML service's own document type. */
+function htmlDocument(document: TextDocument) {
+ const virtual = virtualHtmlDocument(document);
+ return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
+}
+
+function markdown(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (value && typeof value === "object" && "value" in value) {
+ return String((value as { value: unknown }).value);
+ }
+ return "";
+}
+
+/**
+ * HTML completions for a position inside a view block.
+ *
+ * Every item carries the `1` sortText prefix so the server can rank WRNexus
+ * entries above these without filtering either list.
+ */
+export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
+ if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
+
+ const virtual = htmlDocument(document);
+ const parsed = service.parseHTMLDocument(virtual);
+ const list = service.doComplete(virtual, position, parsed);
+
+ return list.items.map((item) => ({
+ label: item.label,
+ kind: typeof item.kind === "number" ? item.kind : 1,
+ detail: item.detail,
+ documentation: markdown(item.documentation),
+ sortText: `1${item.sortText ?? item.label}`,
+ insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
+ }));
+}
+
+export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
+ if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
+
+ const virtual = htmlDocument(document);
+ const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
+ if (!result) return null;
+
+ const contents = markdown(result.contents);
+ return contents ? { contents } : null;
+}
+
+export function htmlFoldingRanges(
+ document: TextDocument,
+): Array<{ startLine: number; endLine: number }> {
+ return service
+ .getFoldingRanges(htmlDocument(document))
+ .map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
+}
+
+/**
+ * The snippet that closes the tag being typed, or null.
+ *
+ * Void elements and already-closed tags return null, which is why this decision
+ * belongs here rather than in the editor client.
+ */
+export function htmlTagComplete(document: TextDocument, position: Position): string | null {
+ if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
+
+ const virtual = htmlDocument(document);
+ return service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual)) ?? null;
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: PASS (8 tests)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/language-server/src/html-service.ts packages/language-server/test/html-service.test.ts packages/language-server/package.json bun.lock
+git commit -m "feat(language-server): answer HTML completion, hover, folding, and tag close"
+```
+
+---
+
+### Task 3: Merge HTML into server completion and hover
+
+**Files:**
+
+- Modify: `packages/language-server/src/server.ts`
+- Modify: `packages/language-server/test/language-server.test.ts`
+
+**Interfaces:**
+
+- Consumes: `htmlCompletions`, `htmlHover` from Task 2; `isInsideHtml` from Task 1.
+- Produces: `mergeCompletions(wrnexus: Array<{ label: string }>, html: Array<{ label: string }>)` exported from `packages/language-server/src/html-service.ts` for direct testing.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `packages/language-server/test/html-service.test.ts`:
+
+```ts
+import { mergeCompletions } from "../src/html-service.ts";
+
+test("merging ranks WRNexus entries above HTML and drops exact collisions", () => {
+ const merged = mergeCompletions(
+ [
+ { label: "Card", kind: 7 },
+ { label: "table", kind: 7 },
+ ],
+ [
+ { label: "div", kind: 10, sortText: "1div" },
+ { label: "table", kind: 10, sortText: "1table" },
+ ],
+ );
+
+ const labels = merged.map((item) => item.label);
+ expect(labels.filter((label) => label === "table")).toHaveLength(1);
+ expect(merged.find((item) => item.label === "Card")?.sortText?.startsWith("0")).toBe(true);
+ expect(merged.find((item) => item.label === "div")?.sortText?.startsWith("1")).toBe(true);
+
+ const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? ""));
+ expect(sorted[0]!.label).toBe("Card");
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: FAIL — `mergeCompletions` is not exported
+
+- [ ] **Step 3: Implement the merge**
+
+Append to `packages/language-server/src/html-service.ts`:
+
+```ts
+/**
+ * One completion list from both sources.
+ *
+ * WRNexus entries take the `0` sortText prefix so they rank above HTML without
+ * either list being filtered. An exact label collision resolves to the
+ * WRNexus entry: a component named `Table` is what the author meant.
+ */
+export function mergeCompletions(
+ wrnexus: T[],
+ html: T[],
+): T[] {
+ const taken = new Set(wrnexus.map((item) => item.label));
+ return [
+ ...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })),
+ ...html.filter((item) => !taken.has(item.label)),
+ ];
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Wire completion and hover into the server**
+
+In `packages/language-server/src/server.ts`, add to the imports:
+
+```ts
+import { htmlCompletions, htmlHover, mergeCompletions } from "./html-service.ts";
+```
+
+Replace the `textDocument/completion` case (currently at line 198) with:
+
+```ts
+ case "textDocument/completion": {
+ const document = documents.get(params.textDocument.uri);
+ const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
+ const html = document ? htmlCompletions(document, params.position) : [];
+ result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
+ break;
+ }
+```
+
+Replace the body of the `textDocument/hover` case with:
+
+```ts
+ case "textDocument/hover": {
+ const document = documents.get(params.textDocument.uri);
+ const html = document ? htmlHover(document, params.position) : null;
+ result(message.id, html ?? (document ? hover(document, params.position) : null));
+ break;
+ }
+```
+
+- [ ] **Step 6: Extend the advertised trigger characters**
+
+In the `initialize` capabilities, replace the completion provider line with:
+
+```ts
+ completionProvider: {
+ triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
+ },
+```
+
+- [ ] **Step 7: Verify nothing regressed**
+
+Run: `bun test packages/language-server`
+Expected: PASS, no failures
+
+- [ ] **Step 8: Rebuild the editor bundle and commit**
+
+```bash
+bun run --cwd editors/vscode build
+git add packages/language-server/src/server.ts packages/language-server/src/html-service.ts packages/language-server/test/html-service.test.ts editors/vscode/src
+git commit -m "feat(language-server): merge HTML completions and hover into one response"
+```
+
+---
+
+### Task 4: Folding and linked editing
+
+**Files:**
+
+- Modify: `packages/language-server/src/server.ts`
+- Modify: `packages/language-server/src/html-service.ts`
+- Modify: `packages/language-server/test/html-service.test.ts`
+
+**Interfaces:**
+
+- Consumes: `htmlFoldingRanges` from Task 2.
+- Produces: `htmlLinkedEditingRanges(document: TextDocument, position: Position): Array<{ start: Position; end: Position }> | null`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `packages/language-server/test/html-service.test.ts`:
+
+```ts
+import { htmlLinkedEditingRanges } from "../src/html-service.ts";
+
+test("linked editing returns both the opening and closing tag names", () => {
+ const text = `page A {
+ view {
+
x
+ }
+}
+`;
+ const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, " {
+ const text = `page A {
+ functions {
+ function go() { }
+ }
+}
+`;
+ expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull();
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: FAIL — `htmlLinkedEditingRanges` is not exported
+
+- [ ] **Step 3: Implement linked editing**
+
+Append to `packages/language-server/src/html-service.ts`:
+
+```ts
+/** Ranges of the opening and closing tag names, so renaming one renames both. */
+export function htmlLinkedEditingRanges(
+ document: TextDocument,
+ position: Position,
+): Array<{ start: Position; end: Position }> | null {
+ if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
+
+ const virtual = htmlDocument(document);
+ const ranges = service.findLinkedEditingRanges(
+ virtual,
+ position,
+ service.parseHTMLDocument(virtual),
+ );
+ return ranges && ranges.length ? ranges : null;
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `bun test packages/language-server/test/html-service.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Wire both into the server**
+
+In `packages/language-server/src/server.ts`, extend the import from `./html-service.ts` to include `htmlFoldingRanges` and `htmlLinkedEditingRanges`.
+
+Add to the `initialize` capabilities:
+
+```ts
+ foldingRangeProvider: true,
+ linkedEditingRangeProvider: true,
+```
+
+Add these cases to the request switch, beside the existing `textDocument/hover` case:
+
+```ts
+ case "textDocument/foldingRange": {
+ const document = documents.get(params.textDocument.uri);
+ result(message.id, document ? htmlFoldingRanges(document) : []);
+ break;
+ }
+ case "textDocument/linkedEditingRange": {
+ const document = documents.get(params.textDocument.uri);
+ const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
+ result(message.id, ranges ? { ranges } : null);
+ break;
+ }
+```
+
+- [ ] **Step 6: Verify and commit**
+
+Run: `bun test packages/language-server`
+Expected: PASS
+
+```bash
+bun run --cwd editors/vscode build
+git add packages/language-server/src editors/vscode/src
+git commit -m "feat(language-server): add tag folding and linked editing"
+```
+
+---
+
+### Task 5: Auto-close on type in the extension
+
+The only client-side piece: LSP has no request for closing a tag as it is typed.
+
+**Files:**
+
+- Modify: `packages/language-server/src/server.ts`
+- Modify: `editors/vscode/src/extension.js`
+- Modify: `editors/vscode/package.json`
+
+**Interfaces:**
+
+- Consumes: `htmlTagComplete` from Task 2.
+- Produces: LSP request `wrn/tagComplete`, params `{ textDocument: { uri }, position }`, returning a snippet string or `null`.
+
+- [ ] **Step 1: Add the server handler**
+
+In `packages/language-server/src/server.ts`, extend the `./html-service.ts` import to include `htmlTagComplete`, then add this case to the request switch:
+
+```ts
+ case "wrn/tagComplete": {
+ const document = documents.get(params.textDocument.uri);
+ result(message.id, document ? htmlTagComplete(document, params.position) : null);
+ break;
+ }
+```
+
+- [ ] **Step 2: Add the setting to the manifest**
+
+In `editors/vscode/package.json`, add to `contributes.configuration.properties`:
+
+```json
+"wrnexus.html.autoClosingTags": {
+ "type": "boolean",
+ "default": true,
+ "description": "Automatically close HTML tags inside .wrn view blocks."
+}
+```
+
+Add `vscode-html-languageservice` to `dependencies`, and add to `contributes.configurationDefaults`:
+
+```json
+"emmet.includeLanguages": {
+ "wrn": "html"
+}
+```
+
+- [ ] **Step 3: Add the client listener**
+
+In `editors/vscode/src/extension.js`, after the language client starts, register:
+
+```js
+/**
+ * Auto-close tags as they are typed.
+ *
+ * LSP has no request for this, so the client watches document changes and asks
+ * the server whether the tag should close. The server owns the decision because
+ * void elements and already-closed tags must not be closed.
+ */
+function registerAutoCloseTags(context, client) {
+ const listener = vscode.workspace.onDidChangeTextDocument(async (event) => {
+ if (event.document.languageId !== "wrn") return;
+ if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return;
+
+ const change = event.contentChanges[0];
+ if (!change || (change.text !== ">" && change.text !== "/")) return;
+
+ const editor = vscode.window.activeTextEditor;
+ if (!editor || editor.document !== event.document) return;
+
+ const position = change.range.start.translate(0, change.text.length);
+ const snippet = await client.sendRequest("wrn/tagComplete", {
+ textDocument: { uri: event.document.uri.toString() },
+ position: { line: position.line, character: position.character },
+ });
+ if (typeof snippet !== "string" || !snippet) return;
+
+ await editor.insertSnippet(new vscode.SnippetString(snippet), position);
+ });
+
+ context.subscriptions.push(listener);
+}
+```
+
+Call `registerAutoCloseTags(context, client);` after the client is created, and extend the existing export line to .
+
+- [ ] **Step 4: Verify the bundle still starts under Node**
+
+Run: `bun run --cwd editors/vscode build && bun run check:editor-language-server`
+Expected: "Verified ... starts under Node."
+
+This is the check that catches a missing or unresolvable `vscode-html-languageservice`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add packages/language-server/src/server.ts editors/vscode/src editors/vscode/package.json
+git commit -m "feat(vscode): close HTML tags as they are typed in .wrn files"
+```
+
+---
+
+### Task 6: Stand down the duplicate client completion provider
+
+The extension and the server both answer completion on `<` today, so VS Code concatenates two lists. This makes the server the single owner inside view blocks.
+
+**Files:**
+
+- Modify: `editors/vscode/src/completion.js`
+- Create: `editors/vscode/test/completion-scope.test.js`
+
+**Interfaces:**
+
+- Consumes: nothing from earlier tasks — the check is a local text scan so the extension does not need the server for it.
+- Produces: `isInsideViewBlock(text: string, offset: number): boolean` exported from `editors/vscode/src/completion.js`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `editors/vscode/test/completion-scope.test.js`:
+
+```js
+const test = require("node:test");
+const assert = require("node:assert");
+const { isInsideViewBlock } = require("../src/completion.js");
+
+const PAGE = `page Home {
+ view {
+
hello
+ }
+ functions {
+ function go() {}
+ }
+}
+`;
+
+test("a markup offset is inside a view block", () => {
+ assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("
{
+ assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `node --test editors/vscode/test/completion-scope.test.js`
+Expected: FAIL — `isInsideViewBlock` is not a function
+
+- [ ] **Step 3: Implement the guard**
+
+In `editors/vscode/src/completion.js`, add:
+
+```js
+/**
+ * Whether an offset sits inside a `view { }` block.
+ *
+ * The language server owns completion there and returns a merged list, so this
+ * provider stands down to avoid VS Code concatenating two independent lists.
+ * Quotes are only tracked inside a tag: `
it's
` would otherwise open a
+ * string that never closes.
+ */
+function isInsideViewBlock(text, offset) {
+ const pattern = /\bview\s*\{/g;
+ let match;
+ while ((match = pattern.exec(text))) {
+ const start = match.index + match[0].length;
+ let depth = 1;
+ let inTag = false;
+ let quote = null;
+ let index = start;
+ for (; index < text.length && depth > 0; index += 1) {
+ const char = text[index];
+ if (quote) {
+ if (char === quote) quote = null;
+ continue;
+ }
+ if (inTag && (char === '"' || char === "'")) quote = char;
+ else if (char === "<") inTag = true;
+ else if (char === ">") inTag = false;
+ else if (char === "{") depth += 1;
+ else if (char === "}") depth -= 1;
+ }
+ if (offset >= start && offset <= index) return true;
+ pattern.lastIndex = index;
+ }
+ return false;
+}
+```
+
+Add an early return at the top of `provideCompletionItems`:
+
+```js
+if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
+```
+
+Add `isInsideViewBlock` to the file's `module.exports`.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `node --test editors/vscode/test/completion-scope.test.js`
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add editors/vscode/src/completion.js editors/vscode/test/completion-scope.test.js
+git commit -m "fix(vscode): stop duplicating completions inside view blocks"
+```
+
+---
+
+### Task 7: Manifest guards and the full gate
+
+**Files:**
+
+- Modify: `editors/vscode/test/validate.mjs`
+
+**Interfaces:**
+
+- Consumes: the manifest changes from Task 5.
+- Produces: nothing consumed downstream.
+
+- [ ] **Step 1: Add the manifest assertions**
+
+`editors/vscode/test/validate.mjs` is the file that already checks the manifest
+(marketplace icon, first-line detection, and so on). It uses `ok(name)` / `bad(name, detail)`
+helpers and counts `failures`, rather than a test framework. Match that style.
+
+The file already loads the manifest for the marketplace checks. Append, beside the existing
+manifest assertions:
+
+```js
+const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
+emmetLanguages?.wrn === "html"
+ ? ok("Emmet is mapped for wrn documents")
+ : bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
+
+const autoClose = manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
+autoClose?.type === "boolean" && autoClose?.default === true
+ ? ok("auto-closing tags setting is contributed")
+ : bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
+
+manifest.dependencies?.["vscode-html-languageservice"]
+ ? ok("HTML language service ships as a runtime dependency")
+ : bad("HTML language service ships as a runtime dependency");
+```
+
+If the manifest is loaded under a different variable name in that file, use the existing name.
+
+- [ ] **Step 2: Run the validator to verify it passes**
+
+Run: `bun run --cwd editors/vscode validate`
+Expected: the three new lines print `ok`, and the run reports no failures
+
+If any print `FAIL`, the corresponding manifest edit in Task 5 was missed; fix the manifest
+rather than the assertion.
+
+- [ ] **Step 3: Run the full production gate**
+
+Run: `bun run check:production`
+Expected: PASS
+
+If `check:public-api` fails, the new exports are intentional: review the diff is additive only, then run `bun run generate:public-api`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add editors/vscode/test/validate.mjs docs/public-api-0.8.json
+git commit -m "test(vscode): guard the Emmet mapping and auto-close setting"
+```
+
+---
+
+### Task 8: Verify in a real editor
+
+Every other task is unit-tested. This one confirms the features actually appear in VS Code, because a green suite has not proved that in this codebase before.
+
+**Files:**
+
+- Create: `examples/basic-app/app/pages/html-editing-check.wrn`
+
+**Interfaces:**
+
+- Consumes: everything above.
+- Produces: nothing consumed downstream.
+
+- [ ] **Step 1: Create a scratch page to type into**
+
+Create `examples/basic-app/app/pages/html-editing-check.wrn`:
+
+```
+page HtmlEditingCheck {
+ seo {
+ title = "HTML editing check"
+ description = "Scratch page for verifying editor support inside view blocks."
+ canonical = "/html-editing-check"
+ }
+
+ view {
+
+
Editor check
+
+ }
+}
+```
+
+- [ ] **Step 2: Launch the extension host**
+
+Run: `bun run --cwd editors/vscode build`
+
+Then open the repository in VS Code and press F5 to start the Extension Development Host, or install the built extension.
+
+- [ ] **Step 3: Verify each feature by hand**
+
+Open `examples/basic-app/app/pages/html-editing-check.wrn` in the host window and confirm, inside the `view { }` block:
+
+1. Typing `<` suggests HTML tags, with any WRNexus components listed above them.
+2. Typing `` inserts `` automatically.
+3. Typing ` ` does **not** insert a closing tag.
+4. Renaming `` to `` renames the closing tag with it.
+5. Hovering `
` shows documentation.
+6. Typing `ul>li*3` and pressing Tab expands via Emmet.
+7. The `` element can be folded from the gutter.
+
+Then confirm, inside the `seo { }` block, that typing `<` does **not** offer HTML tags.
+
+- [ ] **Step 4: Remove the scratch page and commit**
+
+```bash
+rm examples/basic-app/app/pages/html-editing-check.wrn
+git add -A examples/basic-app
+git commit -m "chore: remove HTML editing verification page"
+```
+
+If any check in Step 3 failed, stop and fix it before this commit rather than recording a pass that did not happen.
+
+---
+
+## Deferred
+
+- HTML formatting — `formatWrn` owns markup formatting; improving it is separate work.
+- Moving the remaining `completion.js` component intelligence into the server. This plan only requires it to stand down inside view blocks.