diff --git a/apps/docs/app/labs/Hover.test.tsx b/apps/docs/app/labs/Hover.test.tsx
deleted file mode 100644
index 85f173f0..00000000
--- a/apps/docs/app/labs/Hover.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { renderToStaticMarkup } from 'react-dom/server'
-import { describe, expect, it } from 'vitest'
-import * as Hover from './Hover'
-
-describe('Content', () => {
- it('renders a documented Silk hover as highlighted code followed by Markdown prose', () => {
- const markup = renderToStaticMarkup(
-
Allocates owned storage')
- })
-
- it('renders safe links and does not make executable Markdown links interactive', () => {
- const markup = renderToStaticMarkup(
- ,
- )
-
- expect(markup).toContain('href="https://example.com"')
- expect(markup).not.toContain('href="javascript:')
- })
-})
diff --git a/apps/docs/app/labs/Hover.tsx b/apps/docs/app/labs/Hover.tsx
deleted file mode 100644
index cfbc165c..00000000
--- a/apps/docs/app/labs/Hover.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import * as SilkCodeMirror from '@silk-effect/language/CodeMirror'
-import type { BlockContent, PhrasingContent, RootContent } from 'mdast'
-import { fromMarkdown } from 'mdast-util-from-markdown'
-import { createElement, Fragment, type ReactNode } from 'react'
-
-const safeLink = (destination: string): string | undefined => {
- const normalized = destination.trim()
- if (normalized.startsWith('//')) return undefined
- const scheme = /^([a-z][a-z\d+.-]*):/i.exec(normalized)?.[1]?.toLowerCase()
- return scheme === undefined || scheme === 'http' || scheme === 'https' || scheme === 'mailto'
- ? destination
- : undefined
-}
-
-const inlines = (children: ReadonlyArray): ReadonlyArray =>
- children.map((child, index) => inline(child, index))
-
-const inline = (node: PhrasingContent, key: number): ReactNode => {
- switch (node.type) {
- case 'text':
- return {node.value}
- case 'inlineCode':
- return {node.value}
- case 'emphasis':
- return {inlines(node.children)}
- case 'strong':
- return {inlines(node.children)}
- case 'link': {
- const href = safeLink(node.url)
- return href === undefined ? (
- {inlines(node.children)}
- ) : (
-
- {inlines(node.children)}
-
- )
- }
- case 'linkReference':
- return {inlines(node.children)}
- case 'break':
- return
- case 'image': {
- const href = safeLink(node.url)
- return href === undefined ? (
- {node.alt}
- ) : (
-
- {node.alt}
-
- )
- }
- case 'imageReference':
- return {node.alt}
- case 'html':
- return {node.value}
- default:
- return null
- }
-}
-
-const silk = (value: string): ReadonlyArray => {
- const result: Array = []
- let offset = 0
- for (const range of SilkCodeMirror.highlightRanges(value)) {
- if (range.from < offset || range.to <= range.from) continue
- if (range.from > offset) result.push(value.slice(offset, range.from))
- result.push(
-
- {value.slice(range.from, range.to)}
- ,
- )
- offset = range.to
- }
- if (offset < value.length) result.push(value.slice(offset))
- return result
-}
-
-const block = (node: RootContent | BlockContent, key: number): ReactNode => {
- switch (node.type) {
- case 'paragraph':
- return {inlines(node.children)}
- case 'heading':
- return createElement(`h${node.depth}`, { key }, inlines(node.children))
- case 'code':
- return (
-
- {node.lang?.toLowerCase() === 'silk' ? silk(node.value) : node.value}
-
- )
- case 'blockquote':
- return (
-
- {node.children.map((child, index) => block(child, index))}
-
- )
- case 'list': {
- const List = node.ordered ? 'ol' : 'ul'
- return (
-
- {node.children.map((item, itemIndex) => (
-
- {item.children.map((child, childIndex) => block(child, childIndex))}
-
- ))}
-
- )
- }
- case 'thematicBreak':
- return
- case 'html':
- return {node.value}
- case 'definition':
- return null
- default:
- return null
- }
-}
-
-/** Safely renders the CommonMark payload supplied by one language-server hover. */
-export function Content(props: { readonly markdown: string }) {
- const root = fromMarkdown(props.markdown)
- return (
-
- {root.children.map((child, index) => block(child, index))}
-
- )
-}
diff --git a/apps/docs/app/labs/editor.tsx b/apps/docs/app/labs/editor.tsx
index 4904485e..afe10121 100644
--- a/apps/docs/app/labs/editor.tsx
+++ b/apps/docs/app/labs/editor.tsx
@@ -1,138 +1,20 @@
'use client'
/**
- * The workbench source editor: CodeMirror with lexer-driven Silk highlighting.
+ * The workbench source editor: a thin React wrapper over the shared snippet editor core.
*
- * The compiler's spans are byte-addressed while CodeMirror's are UTF-16, so selections are
- * translated before they reach the shared span cursor. Token colors come from the stable
- * `cm-silk-*` classes styled in workbench.module.css — the workbench is always dark, so it does
- * not use the extension's light default highlight style.
+ * The core owns CodeMirror, highlighting, diagnostics, and hover; this wrapper owns everything
+ * workbench-shaped — the shared span cursor, the format command handle, and the snapshot the whole
+ * workbench shares. Colors ride the `--silk-snippet-*` custom properties mapped to workbench
+ * variables in workbench.module.css.
*/
-import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
-import { setDiagnostics } from '@codemirror/lint'
-import { Annotation, EditorState, StateEffect, StateField } from '@codemirror/state'
-import type { DecorationSet } from '@codemirror/view'
-import { Decoration, EditorView, hoverTooltip, keymap } from '@codemirror/view'
import * as Analysis from '@silk-effect/compiler/Analysis'
-import * as SilkCodeMirror from '@silk-effect/language/CodeMirror'
-import * as LspDocument from '@silk-effect/lsp/Document'
-import * as Effect from 'effect/Effect'
-import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
-import { createRoot } from 'react-dom/client'
+import * as SnippetEditor from '@silk-effect/snippet/Editor'
+import { type MutableRefObject, useEffect, useRef } from 'react'
import type { Span } from '@silk-effect/inspector'
-import * as Hover from './Hover'
const encoder = new TextEncoder()
-const decoder = new TextDecoder()
-
-/** One language-server view of the active module, rebuilt with the shared snapshot. */
-interface LspSession {
- readonly document: LspDocument.Document
- readonly snapshot: Analysis.Snapshot
- readonly source: string
-}
-
-/** Translates one protocol position into a clamped CodeMirror character offset. */
-const lspOffset = (
- state: EditorState,
- position: { readonly line: number; readonly character: number },
-): number => {
- const line = state.doc.line(Math.min(position.line + 1, state.doc.lines))
- return Math.min(line.from + position.character, line.to)
-}
-
-/** Marks transactions that reconcile external state, so the update listener does not echo them. */
-const External = Annotation.define()
-
-/**
- * The shared span cursor drawn into the editor, so a row click in any pane lights up the same
- * bytes here — the editor is a phase like any other, in both directions.
- */
-const setSpanCursor = StateEffect.define<{ readonly from: number; readonly to: number } | null>()
-
-const spanCursorMark = Decoration.mark({ class: 'cm-silk-span-cursor' })
-
-const spanCursorField = StateField.define({
- create: () => Decoration.none,
- update: (value, transaction) => {
- let next = value.map(transaction.changes)
- for (const effect of transaction.effects) {
- if (effect.is(setSpanCursor)) {
- next =
- effect.value === null
- ? Decoration.none
- : Decoration.set([spanCursorMark.range(effect.value.from, effect.value.to)])
- }
- }
- return next
- },
- provide: (self) => EditorView.decorations.from(self),
-})
-
-const theme = EditorView.theme({
- '&': { height: '100%', fontSize: '11.5px', backgroundColor: 'transparent' },
- '.cm-scroller': { fontFamily: 'var(--wb-font)', lineHeight: '19px' },
- '.cm-content': { padding: '5px 0 5px 8px', caretColor: 'var(--wb-ink)' },
- '&.cm-focused': { outline: 'none' },
- '.cm-cursor': { borderLeftColor: 'var(--wb-ink)' },
- '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
- backgroundColor: 'rgba(198, 166, 120, 0.22)',
- },
- '.cm-lintRange-error': {
- backgroundImage: 'none',
- textDecoration: 'underline wavy var(--wb-error) 1px',
- textUnderlineOffset: '3px',
- },
- '.cm-tooltip': {
- backgroundColor: 'var(--wb-bg-pane)',
- border: '1px solid var(--wb-hairline-strong)',
- color: 'var(--wb-ink-2)',
- fontFamily: 'var(--wb-font)',
- fontSize: '11px',
- },
- '.cm-tooltip.cm-tooltip-hover': { padding: '3px 7px' },
- '.cm-tooltip-lint': { padding: '0' },
- '.cm-diagnostic': { borderLeft: 'none', padding: '3px 7px' },
- '.cm-diagnostic-error': { borderLeft: '2px solid var(--wb-error)' },
- '.cm-silk-type-tooltip': {
- boxSizing: 'border-box',
- maxWidth: 'min(560px, calc(100vw - 24px))',
- lineHeight: '1.45',
- },
- '.cm-silk-type-tooltip > *': { margin: '0' },
- '.cm-silk-type-tooltip > * + *': { marginTop: '8px' },
- '.cm-silk-type-tooltip pre': {
- overflowX: 'auto',
- padding: '2px 0 5px',
- borderBottom: '1px solid var(--wb-hairline-strong)',
- color: 'var(--wb-ink)',
- whiteSpace: 'pre',
- },
- '.cm-silk-type-tooltip :not(pre) > code': {
- padding: '1px 3px',
- borderRadius: '2px',
- backgroundColor: 'var(--wb-active-strong)',
- color: 'var(--wb-ink)',
- },
- '.cm-silk-type-tooltip h1, .cm-silk-type-tooltip h2, .cm-silk-type-tooltip h3, .cm-silk-type-tooltip h4, .cm-silk-type-tooltip h5, .cm-silk-type-tooltip h6': {
- color: 'var(--wb-ink)',
- fontSize: 'inherit',
- fontWeight: '600',
- },
- '.cm-silk-type-tooltip ul, .cm-silk-type-tooltip ol': {
- marginBottom: '0',
- paddingLeft: '20px',
- },
- '.cm-silk-type-tooltip li + li': { marginTop: '3px' },
- '.cm-silk-type-tooltip blockquote': {
- paddingLeft: '8px',
- borderLeft: '2px solid var(--wb-hairline-strong)',
- color: 'var(--wb-ink-3)',
- },
- '.cm-silk-type-tooltip a': { color: 'var(--wb-violet)', textDecoration: 'underline' },
- '.cm-silk-type-tooltip hr': { border: '0', borderTop: '1px solid var(--wb-hairline)' },
-})
export function SilkEditor(props: {
readonly value: string
@@ -147,202 +29,58 @@ export function SilkEditor(props: {
readonly className?: string
}) {
const containerRef = useRef(null)
- const viewRef = useRef(null)
+ const handleRef = useRef(null)
const initialRef = useRef(props.value)
const callbacksRef = useRef({ onChange: props.onChange, onSelect: props.onSelect })
callbacksRef.current = { onChange: props.onChange, onSelect: props.onSelect }
- // The language-server session mirrors the snapshot the whole workbench shares.
- const session = useMemo(
- () => {
- const source = Analysis.sources(props.snapshot).get(props.module)
- const bytes =
- source === undefined ? encoder.encode(props.value) : Uint8Array.from(source.bytes)
- return {
- document: LspDocument.make({
- uri: props.module,
- version: 0,
- workspace: `labs:${props.module}`,
- module: props.module,
- sourceRoot: '/',
- // During a typing burst, the editor value is ahead of analysis. Keep LSP document bytes
- // paired with their snapshot; the fallback covers a newly added module until it settles.
- bytes,
- }),
- snapshot: props.snapshot,
- source: decoder.decode(bytes),
- }
- },
- [props.module, props.snapshot],
- )
- const sessionRef = useRef(session)
- sessionRef.current = session
-
- // Canonical formatting through the language server's Document actor. Reads only refs, so the
- // one instance created on mount stays valid for the editor's whole life.
- const formatRef = useRef<() => boolean>(() => false)
- formatRef.current = () => {
- const view = viewRef.current
- if (view === null) return false
- const { document: lspDocument, snapshot } = sessionRef.current
- if (view.state.doc.toString() !== sessionRef.current.source) return false
- const edit = Effect.runSync(LspDocument.format(lspDocument, snapshot))[0]
- if (edit === undefined) return false
- view.dispatch({
- changes: {
- from: lspOffset(view.state, edit.range.start),
- to: lspOffset(view.state, edit.range.end),
- insert: edit.newText,
- },
- })
- return true
- }
- if (props.formatRef !== undefined) props.formatRef.current = () => formatRef.current()
-
useEffect(() => {
const container = containerRef.current
if (container === null) return
- const listener = EditorView.updateListener.of((update) => {
- const external = update.transactions.some(
- (transaction) => transaction.annotation(External) === true,
- )
- if (external) return
- if (update.docChanged) callbacksRef.current.onChange(update.state.doc.toString())
- if (update.selectionSet) {
- const range = update.state.selection.main
- if (range.empty) return
- // Selecting text moves the same span cursor a row click moves, translated to bytes.
- const doc = update.state.doc.toString()
- callbacksRef.current.onSelect({
- start: SilkCodeMirror.charOffsetToByteOffset(doc, range.from),
- end: SilkCodeMirror.charOffsetToByteOffset(doc, range.to),
- })
- }
- })
- // Hover asks the language server's Document actor for its structured compiler presentation.
- const typeHover = hoverTooltip((view, position) => {
- const { document: lspDocument, snapshot } = sessionRef.current
- if (view.state.doc.toString() !== sessionRef.current.source) return null
- const line = view.state.doc.lineAt(position)
- const hover = LspDocument.hover(lspDocument, snapshot, {
- line: line.number - 1,
- character: position - line.from,
- })
- if (
- hover?.range === undefined ||
- typeof hover.contents !== 'object' ||
- !('value' in hover.contents)
- )
- return null
- const hoverText = hover.contents.value
- return {
- pos: lspOffset(view.state, hover.range.start),
- end: lspOffset(view.state, hover.range.end),
- above: true,
- create: () => {
- const dom = document.createElement('div')
- const root = createRoot(dom)
- root.render( )
- return { dom, destroy: () => root.unmount() }
- },
- }
- })
- const view = new EditorView({
+ const handle = SnippetEditor.mount({
parent: container,
- state: EditorState.create({
- doc: initialRef.current,
- extensions: [
- history(),
- keymap.of([
- { key: 'Shift-Alt-f', run: () => formatRef.current() },
- // Format on save: there is nothing to save, but the muscle memory is universal.
- // Always handled, so the browser's save dialog never opens over the workbench.
- {
- key: 'Mod-s',
- run: () => {
- formatRef.current()
- return true
- },
- },
- ...defaultKeymap,
- ...historyKeymap,
- ]),
- SilkCodeMirror.extension(),
- spanCursorField,
- typeHover,
- theme,
- EditorView.contentAttributes.of({ 'aria-label': 'Silk source code' }),
- listener,
- ],
- }),
+ doc: initialRef.current,
+ editable: true,
+ features: { diagnostics: true, hover: true },
+ onChange: (value) => callbacksRef.current.onChange(value),
+ onSelect: (range) => callbacksRef.current.onSelect(range),
})
- viewRef.current = view
+ handleRef.current = handle
return () => {
- viewRef.current = null
- view.destroy()
+ handleRef.current = null
+ handle.destroy()
}
}, [])
+ if (props.formatRef !== undefined)
+ props.formatRef.current = () => handleRef.current?.format() ?? false
+
// Module switches and preset/URL loads replace the document wholesale.
useEffect(() => {
- const view = viewRef.current
- if (view === null) return
- const current = view.state.doc.toString()
- if (current !== props.value) {
- view.dispatch({
- changes: { from: 0, to: current.length, insert: props.value },
- annotations: External.of(true),
- })
- }
+ handleRef.current?.setValue(props.value)
}, [props.value])
- // Diagnostics ride the shared snapshot: every edit re-analyzes, every analysis re-lints.
- // Runs after the sync effect above, so the view's doc always matches the linted value.
+ // The language-server session mirrors the snapshot the whole workbench shares. During a typing
+ // burst the editor value is ahead of analysis; the core goes quiet until the snapshot catches up.
useEffect(() => {
- const view = viewRef.current
- if (view === null || view.state.doc.toString() !== session.source) return
- const diagnostics = LspDocument.diagnostics(session.document, session.snapshot, () => undefined)
- view.dispatch(
- setDiagnostics(
- view.state,
- diagnostics.map((diagnostic) => ({
- from: lspOffset(view.state, diagnostic.range.start),
- to: lspOffset(view.state, diagnostic.range.end),
- severity: 'error' as const,
- message:
- typeof diagnostic.code === 'string'
- ? `${diagnostic.code}: ${diagnostic.message}`
- : diagnostic.message,
- })),
- ),
- )
- }, [session])
-
- // Reflect the shared span cursor, scrolling to it only when it came from another pane. A cursor
- // in a different module draws nothing here — its offsets belong to another file's bytes.
- const cursor = props.cursor !== undefined && props.cursor.module === props.module
- ? props.cursor
- : undefined
+ const handle = handleRef.current
+ if (handle === null) return
+ const source = Analysis.sources(props.snapshot).get(props.module)
+ const bytes =
+ source === undefined ? encoder.encode(props.value) : Uint8Array.from(source.bytes)
+ handle.setSession(SnippetEditor.session(props.module, bytes, props.snapshot))
+ // props.value is deliberately not a dependency: sessions pair bytes with their snapshot.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.module, props.snapshot])
+
+ // Reflect the shared span cursor. A cursor in a different module draws nothing here — its
+ // offsets belong to another file's bytes.
+ const cursor =
+ props.cursor !== undefined && props.cursor.module === props.module ? props.cursor : undefined
useEffect(() => {
- const view = viewRef.current
- if (view === null) return
- if (cursor === undefined) {
- view.dispatch({ effects: setSpanCursor.of(null) })
- return
- }
- const doc = view.state.doc.toString()
- const from = SilkCodeMirror.byteOffsetToCharOffset(doc, cursor.start)
- const to = SilkCodeMirror.byteOffsetToCharOffset(doc, cursor.end)
- if (to <= from) {
- view.dispatch({ effects: setSpanCursor.of(null) })
- return
- }
- const selection = view.state.selection.main
- const effects: Array> = [setSpanCursor.of({ from, to })]
- if (selection.from !== from || selection.to !== to) {
- effects.push(EditorView.scrollIntoView(from))
- }
- view.dispatch({ effects })
+ handleRef.current?.setSpanHighlight(
+ cursor === undefined ? null : { start: cursor.start, end: cursor.end },
+ )
}, [cursor])
return
diff --git a/apps/docs/app/labs/workbench.module.css b/apps/docs/app/labs/workbench.module.css
index 5a9fc4dc..ba516164 100644
--- a/apps/docs/app/labs/workbench.module.css
+++ b/apps/docs/app/labs/workbench.module.css
@@ -662,6 +662,20 @@
overflow: hidden;
color: var(--wb-ink);
tab-size: 2;
+ /* The shared snippet editor core reads --silk-snippet-* properties; the workbench is always
+ dark, so its own palette is mapped onto them here. */
+ --silk-snippet-font: var(--wb-font);
+ --silk-snippet-font-size: 11.5px;
+ --silk-snippet-line-height: 19px;
+ --silk-snippet-padding: 5px 0 5px 8px;
+ --silk-snippet-ink: var(--wb-ink);
+ --silk-snippet-ink-muted: var(--wb-ink-2);
+ --silk-snippet-selection: rgba(198, 166, 120, 0.22);
+ --silk-snippet-error: var(--wb-error);
+ --silk-snippet-tooltip-bg: var(--wb-bg-pane);
+ --silk-snippet-border: var(--wb-hairline-strong);
+ --silk-snippet-code-bg: var(--wb-active-strong);
+ --silk-snippet-accent: var(--wb-violet);
}
.editor :global(.cm-editor) {
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 2ac123af..f638455f 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -25,6 +25,7 @@
"@silk-effect/language": "workspace:*",
"@silk-effect/lsp": "workspace:*",
"@silk-effect/platform-webcontainer": "workspace:*",
+ "@silk-effect/snippet": "workspace:*",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"dockview": "^7.0.4",
diff --git a/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml
new file mode 100644
index 00000000..701445b8
--- /dev/null
+++ b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-26
diff --git a/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md
new file mode 100644
index 00000000..1ecfd2ec
--- /dev/null
+++ b/openspec/changes/archive/2026-08-26-extract-embeddable-silk-snippet-element/design.md
@@ -0,0 +1,124 @@
+# Design
+
+## Context
+
+See proposal.md — Why. Current state that shapes the approach:
+
+- `apps/docs/app/labs/editor.tsx` (`SilkEditor`) already does everything the element needs on the
+ main thread, synchronously: CodeMirror with `SilkCodeMirror.extension()` for highlighting, and
+ direct calls to `LspDocument.diagnostics`, `LspDocument.hover`, and `LspDocument.format` against
+ an `Analysis.Snapshot`. No worker, no protocol.
+- React's real footprint is two things: hook-based lifecycle glue in `SilkEditor`, and
+ `Hover.tsx`, which renders hover CommonMark via `createRoot` into the CodeMirror tooltip.
+- `LspDocument.inlayHints` exists (packages/lsp/src/Document.ts) with no consumer.
+- Stdlib sources ship inside `@silk-effect/compiler` (`CompilerStdlib.sources`), so a browser
+ bundle of the compiler resolves stdlib imports with no fetching. Labs proves in-browser
+ compilation works today.
+- Doctest compiles each fence as one standalone module via `Analysis.ofSourceRealized(identity,
+ bytes, target)` with default target `wasm32-unknown-unknown`.
+- The static site (`packages/documentation-site`) renders fences as escaped `` strings
+ (Prose.ts) from documentation JSON, where a fence's `language` field carries the full comma-form
+ token (`silk,ignore`).
+- `packages/language` depends only on `compiler` and `documentation`; CodeMirror is a peer
+ dependency there.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- One framework-free package owning the element; labs and the static site are both consumers.
+- Behavior parity with doctest: what the element compiles is exactly what doctest verified.
+- Keep the extraction mechanical — no new semantic engine, no worker, no protocol.
+
+**Non-Goals:**
+
+- Running snippets (evaluation/output) — display and editing semantics only.
+- Multi-file or cross-snippet projects; one element is one standalone module.
+- A worker architecture; revisit only if editable snippets measurably jank.
+- Replacing the docs app's Shiki path for non-Silk fences.
+- Publishing/versioning strategy for the element bundle beyond the site's own output.
+
+## Decisions
+
+**New package `@silk-effect/snippet`, not a `language` subpath.** The element needs
+`@silk-effect/lsp`; `packages/language` deliberately has no `lsp` dependency and other consumers
+(TextMate, vscode) should not inherit one. Dependencies: `compiler`, `lsp`, `language`,
+CodeMirror packages, `mdast-util-from-markdown`. Two deliverables: an ESM library export (custom
+element class + registration function) and a self-registering IIFE/ESM bundle for script-tag use
+in generated sites.
+
+**Keep CodeMirror for read-only snippets.** Alternative — a hand-rolled span renderer for
+read-only mode — was rejected: tooltips, squiggle decorations, byte↔UTF-16 translation, and the
+editable upgrade path all already work in CodeMirror, and read-only is one
+`EditorState.readOnly.of(true)` facet. Bundle cost is accepted until measured to matter.
+
+**Custom element with shadow DOM.** `connectedCallback` reads `textContent` as the source (HTML
+entity decoding applies; generators escape normally), replaces it with the shadow-rendered editor,
+and keeps the light-DOM text as the no-JS fallback until upgrade. Observed boolean attributes:
+`diagnostics`, `hover`, `inlay-hints`, `editable`. Theming via `--silk-snippet-*` custom
+properties with light/dark defaults keyed off `prefers-color-scheme`; the labs workbench keeps its
+own dark values by setting the properties.
+
+**Compilation is per-element, lazy, main-thread.** Each element with at least one semantic
+attribute compiles its own content with `Analysis.ofSourceRealized` (doctest's identity scheme and
+default target) when an `IntersectionObserver` first reports it visible. Highlight-only elements
+never compile. Editable elements recompile on a debounced document change, replacing the snapshot
+the semantic providers read — the same shape as labs' snapshot-per-edit flow. No snapshot sharing
+across elements: snippets are independent modules and correctness beats a cache.
+
+**Hover rendering moves from React to DOM construction.** `Hover.tsx`'s mdast walk is rewritten
+node-for-node using `document.createElement`, preserving `safeLink` (http/https/mailto only) and
+the highlighted rendering of nested `silk` code via `SilkCodeMirror.highlightRanges`. This DOM
+renderer lives in the snippet package; labs deletes `Hover.tsx` and uses the element.
+
+**Inlay hints via CodeMirror widget decorations.** `LspDocument.inlayHints` results become inline
+widget decorations (not document text), recomputed with the snapshot. This is the only genuinely
+new feature code in the change.
+
+**Labs re-points, workbench API preserved.** `SilkEditor` keeps its current props signature but
+delegates to the element (or directly to the extracted mounting API), keeping `workbench.tsx`
+untouched. The span-cursor field and URL/format wiring stay in labs — they are workbench concerns,
+exposed by the element as a small imperative surface (set span highlight, format, get/set value)
+on the element instance.
+
+**Site emission via fence attributes.** Prose.ts branches on the parsed fence language token using
+doctest's `Example.parseLanguage` convention: `silk` → element with `diagnostics hover`;
+`silk,ignore` → element with no semantic attributes; anything else → existing ``. The
+site build copies the element bundle into the output and references it with a relative `
-
+ ${
+ snippetScript ? `\n ` : ''
+ }