The mechanics of publishing to the Artifact platform — md2artifact, the annotation layer (comments and suggested edits), rendering, republishing, and the viewer sandbox quirks. Use when publishing or updating any report, plan, spec, findings page, transcript view or dashboard as an Artifact, when running md2artifact, when building or debugging a select-to-comment layer, when a republish is refused, or when a control works locally but not in the viewer.
Installs into .claude/skills of the current project.
Are you the author of Artifact Writing?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/yulonglin-artifact-writing)
---
name: artifact-writing
description: The mechanics of publishing to the Artifact platform — md2artifact, the annotation layer (comments and suggested edits), rendering, republishing, and the viewer sandbox quirks. Use when publishing or updating any report, plan, spec, findings page, transcript view or dashboard as an Artifact, when running md2artifact, when building or debugging a select-to-comment layer, when a republish is refused, or when a control works locally but not in the viewer.
---
# Artifact Writing
This skill is the **mechanics** of the Artifact platform and `md2artifact` — what the viewer does, what it refuses, and how the annotation layer must be built. It carries no presentation or evidence standards: how a page should read lives in **`~/.claude/checklists/presentation.md`**, and what a results page must show — evidence one click away, provenance, transcript legibility, what the annotation layer is for — lives in **`~/.claude/checklists/results-analysis.md`**. Read the relevant one alongside this file.
Yulong's primary reading surface is the Artifact, not terminal scrollback. For any substantive unit of work — an explanation of how something works, a design or audit, experiment results, a debugging postmortem — publish or update an Artifact and keep the chat reply to BLUF plus what changed plus the link. Inline chat text is for quick answers, status and decisions.
**Deliverables live in git first.** A page that exists only as a successful `Artifact` call is lost when publishing breaks, so write the source and the built HTML under `artifacts/<slug>/`, **commit both**, and let the publish be the last step — the layout and the commit-the-HTML rule are in `artifacts/README.md`.
## `md2artifact` builds the page; raw `.md` drops everything
**Markdown artifacts cannot carry JavaScript**, so publishing a spec, report or plan as raw `.md` silently drops the table of contents, the annotation layer and the export guard. Anything meant to be reviewed goes through `md2artifact <file.md>` and is published as the resulting HTML. Raw `.md` is for pages nobody needs to comment on.
`md2artifact` takes the document's H1 as the `<title>`, so the H1 is the artifact's name in the gallery and the qualifying detail belongs in the `description` parameter and in the line below the H1 rather than in the H1 itself. What the title should *say* is the heading standard in `~/.claude/checklists/presentation.md`.
**Raw HTML in the source stays escaped, except sanctioned passthroughs.** `md2artifact` parses with html off, so arbitrary tags render as visible literal text — never write raw HTML expecting it to work. The sanctioned exceptions are the `PASSTHROUGHS` registry in `custom_bins/md2artifact`: mermaid (```mermaid fence or `<pre class="mermaid">`), `<details>`/`<summary>` collapsibles (tags on their own lines, plain-text summary), and a ```svg fence. When Anthropic ships a new viewer-native construct that pages should carry, preserve it by adding one registry entry plus a guard test there — not by enabling raw HTML.
**Charts go in a ```svg fence, so a chart page goes through the pipeline instead of around it.** One complete `<svg …>…</svg>` element in a fence whose info string is exactly `svg` (any extra word stays escaped, as with mermaid) is emitted verbatim — attribute order, `viewBox` casing and attribute prefixes such as `xlink:` all survive, because the check parses the body to judge it and never re-serialises it. The body must be well-formed XML: declare `xmlns` (and `xmlns:xlink` before an `xlink:` prefix), close every element, and spell entities numerically (` `, not ` `). An *element* name may not carry a prefix — write `<svg>`, never `<s:svg>` — and a CDATA section, a comment and any other markup declaration are refused outright, because an XML parser and the browser's HTML parser read those three constructs differently and the browser is the one that matters.
**Elements are an allowlist, so an unlisted name refuses by default.** `SVG_ALLOWED_ELEMENTS` in `custom_bins/md2artifact` holds the whole chart vocabulary: `svg`, `g`, `defs`, `symbol`, `use`, `a`, `style`, `title`, `desc`; the shapes `path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`; text `text`, `tspan`, `textPath`; paint and clipping `marker`, `clipPath`, `mask`, `pattern`, `linearGradient`, `radialGradient`, `stop`; and `filter` with `feGaussianBlur`, `feOffset`, `feBlend`, `feColorMatrix`, `feComposite`, `feFlood`, `feMerge`, `feMergeNode`, `feDropShadow`, `feMorphology`. Everything else refuses — `foreignObject`, `script`, `image`, `animate`, `switch`, `metadata` and every HTML tag included. If an element vanished into an escaped block, the note names it: add that name to the set rather than working around it.
**Styling is a second allowlist, so a new way to spell a fetch is refused unread.** A property in a `<style>` element, a `style=` attribute or a presentation attribute must be one of the ~70 names in `STYLE_PROPERTIES` in `custom_bins/md2artifact` — paint (`fill`, `stroke`, `stroke-width`, the opacities and dash properties), text (`font-*`, `text-anchor`, the baselines, spacing), layout (`display`, `visibility`, geometry) and same-document references (`clip-path`, `mask`, `filter`, `marker-*`) — and its value must be one of five shapes: a hex colour, a number with an optional unit, a bare word, `url(#id)`, or a numeric colour function (`rgb`, `rgba`, `hsl`, `hsla`). A quoted string is permitted only in a font stack. A `<style>` element is a flat list of `selector { … }` rules and nothing else. Everything outside those shapes refuses — every function nobody listed, every backslash escape, every comment, every at-rule, every `:` and `/` — and the note names the property, so a diagram that trips it is fixable. Attribute values that are not styling get their own positive shape by name: path data for `d`, a call from a closed list for `transform`, the SVG or XLink URI for a namespace declaration, plain text for `aria-*`, `data-*`, `role`, `lang` and `title`, and a `#` fragment for `href`. An attribute name nobody listed still has to satisfy the styling-value grammar, so an unlisted name cannot smuggle a URL. Two rules are about the name, since no value shape can express them: no `on*` handler, and no `ping` (SVG2 gives `<a>` one, and following the link POSTs to every URL in it). This replaced a text matcher that searched a flattened value for dangerous constructs and was bypassed three times — an escaped spelling, a comment, and a shorthand keyword that flattening joined to the fetching function after it. All three now fail on the grammar rather than on a rule naming them. Note also that a `<style>` inside the fence is document-scoped rather than chart-scoped — its rules reach the whole page — so give chart classes specific names. The accepted source is then re-scanned under HTML tokenizer rules, not only the XML tree, so a divergence between the two parsers fails closed. A refused fence renders as an escaped code block with a note saying why, so an untrusted document still cannot put script in your page. The page CSS gives every inlined chart `max-width:100%`; what the chart should *look* like is the `dataviz` skill and `lib/plotting/tokens.json`; the guard test is `tests/test_md2artifact_svg.py`.
## One topic, one living page
Update the existing artifact for the topic in place — same URL, passing `url` when the artifact came from an earlier session — rather than minting a new artifact per session. New URL only on a hard topic pivot.
**When the in-place update is refused**, most often an org mismatch (`org_mismatch`, "caller org does not match owner org", or "this Artifact is in another of the user's organizations", which appears when the session's auth org changed after first publication, e.g. after a `/model` switch between differently-billed models), retry once. If it fails again, do **not** pass `contract: 'latest'` to force it — that silently changes the published page's runtime semantics to buy a cosmetic update.
**Publish a new artifact in the current org — that is the default, not a question to raise** (Yulong, 2026-08-28). Don't stop to ask about `/login` versus forking; build the new artifact and hand over its URL, noting the old link is stale. A new file path is required, since republishing the same file path re-targets the old unreachable URL and fails again — `cp` the built HTML to a new path and publish that with no `url`. Put a supersedes note at the top of the new page: what it supersedes, a link to the superseded URL, and why the URL moved. Update every in-repo link. Keep the old page rather than deleting it — its comment threads are the user's work — but treat it as archived. The one exception is the user explicitly saying they will `/login` to recover the old URL.
**Never republish over a page the user may have annotated without saying so first.** `localStorage` survives a redeploy at the same URL, but the anchors the highlights attach to may not, so treat highlight loss as expected on republish.
## The annotation layer: comments are the user's work
Losing a comment is the worst failure the page can have. The shape: select text, a box appears, type, Enter saves; comments persist in `localStorage`, the collected comments are listed at the end of the page, and one button copies them all to the clipboard **as Markdown** Yulong can paste straight back into a session. `md2artifact` implements all of this; the rules below matter when building, debugging or reviewing a select-to-comment layer.
**Layer v2 adds a Suggest-edit mode, and Comment stays the default.** The box gains a Suggest edit button; entering that mode pre-fills the textarea with the selected text for the user to rewrite, and an actively-cleared replacement is a suggested deletion. A saved edit renders as strikethrough original plus inserted replacement, visually distinct from comment highlights, and clicking either part reopens it. The export gains a "Suggested edits" section of numbered Replace/With blocks that the badge and unload guard count alongside comments — and those quotes are **rendered text, applied to the Markdown source by an LLM session's judgment, never mechanically**: a sed loop over the export corrupts the source (the round-trip itself is in `spec-artifact`). Three rules specific to the mode:
- **The prefill is not user work.** Draft autosave and "a non-empty box refuses to close on an outside press" key on a dirty flag set by the first user modification, not on non-emptiness — otherwise every opened edit box is an unclosable phantom draft.
- **Layer-inserted text is invisible to anchoring.** Replacement text the layer inserts into the flow is marked and excluded from all quote-scanning, so re-anchoring matches original document text only; without this the first saved edit corrupts every anchor after it.
- **A selection overlapping an existing edit is declined**, pointing at the existing edit rather than opening a second box — overlapping edits cannot be exported appliably.
Pages built before v2 are frozen at v1 per the rule below and gain the mode only on rebuild and republish.
Known edges, reviewed by council 2026-09-02 and left as they are because none loses a stored entry: a comment made *before* an edit whose struck span later contains it re-anchors as "missed" (still stored, still exported); a replacement identical to its quote saves a no-op edit; and because the comment key is derived from the artifact directory, a page rebuilt from a scratch copy under `build/` derives a different key — build in place, or pass `--key`.
- **A comment stays readable and editable after it is written** — clicking its highlight reopens it with the existing text, to reread, revise or delete. A note that survives only as a `title` tooltip is effectively gone the moment it is written.
- **Unexported comments block destructive transitions**: track whether anything changed since the last copy or export, and guard `beforeunload` while it has.
- **A half-typed note is already the user's work**: autosave it as a draft on every keystroke and reopen the box on it, so a forced refresh costs nothing.
- **Derive the storage key from the filename, not the title**, so a retitled republish still finds the comments — and qualify a generic stem with its artifact directory, because `artifacts/<slug>/spec.md` is the standard layout and a stem-only key would hand every spec in the gallery `review-spec`, one origin and one shared comment list (`tests/test_md2artifact_key.py`); and namespace every auxiliary key with a **prefix** — with `key + "-draft"`, a page genuinely titled "Spec-draft" shares a key with the draft of a page titled "Spec", and one silently deletes the other. Never let a failed write pass in silence: `setItem` throws on a full quota, so a swallowed failure leaves the panel counting a comment that is already gone.
- **Recovering comments you are not certain are this page's is worse than showing none.** Three mechanisms were built here and all three came out for losing or leaking data: a rolling backup key resurrected comments the user had deliberately cleared, permanently flagged unexported; an IndexedDB mirror let a save that raced its own asynchronous recovery destroy the comments it was recovering; and a scan of neighbouring `localStorage` keys, gated on one quote of the candidate list matching this page, adopted a *different* document's notes — a confidential comment from one review page was reproduced onto an unrelated one, then written into its storage and its exports. Any cross-document guess needs the user to confirm it, not a heuristic. Only the state's own key, and formats every deployed generation can read, are safe to load unprompted.
- **Say "exported" only when something was actually exported.** `navigator.clipboard.writeText` rejects under permissions and `execCommand("copy")` returns `false`; a handler that ignores both reports success and stands the unload guard down while nothing left the browser. Opening a textarea is not an export either — clear the state on the textarea's `copy` event.
**The comment bar is exactly Copy all and Delete all, plus edit and delete on each comment** — the comments have no download button, only clipboard routes, and Copy falls back to a selectable textarea when the clipboard is refused. Two controls the reader can trust beat four where two are inert.
**Below it, a "This page" row offers Download HTML and Copy HTML**, so a reader can take the page itself to someone the Artifact link does not reach. Both hand over the page as it was parsed — snapshotted before any highlight, suggested edit or tick is restored — with the layer still in it, so the copy stays annotatable. Copy HTML needs nothing and is always shown. In the viewer, Download HTML goes only through the `downloads` capability, and when that resolves `null` the button shows disabled and points at Copy HTML; in a downloaded copy opened as a local file there is no viewer, and an ordinary `<a download>` saves it. So **publish every annotated page with `capabilities: {"downloads": true}`**. The declaration is a full set, so restate anything else the page already declares; omitting `capabilities` on a later redeploy carries the stored set forward, so pages published before this change show a disabled Download until one republish declares it. **Download reaches the owner only**: checked by Yulong on 2026-09-24, a public-link viewer gets no working Download, so Copy HTML is the route that carries a page to someone outside.
**Select → type → Enter is the whole workflow.** The keyboard path has to complete without touching a button: Enter saves, Shift+Enter is a newline, Escape discards. When the buttons are read at all, the primary one comes first in DOM order (which is also tab order), and the destructive one is pushed away from it. A box whose leading control is Cancel puts the least likely action under the cursor.
**Nothing implicit may write a comment.** Auto-saving a half-typed note when the user "clicks away" sounds protective and is not: the gestures that land outside the box are ordinary ones — right-clicking your own selection to copy it, grabbing the scrollbar to reread the passage, pressing a destructive button and then cancelling its confirm. Each of those committed a comment the reviewer had not finished writing. A press outside closes an **empty** box and leaves a non-empty one open; the words stay on screen, and the keystroke draft covers the refresh. For the same reason, a control that would overwrite unsaved text must decline and point at it rather than replace it.
**Touch selection needs `selectionchange`, not `mouseup`.** iOS Safari fires no `mouseup` for a touch selection drag, so a `mouseup`-only annotation layer leaves every iPhone and iPad reader unable to comment at all — and it looks fine on the desktop where it was built. Any select-to-comment page must open its note box from a debounced `selectionchange`, must not steal focus on touch (focusing a field while iOS shows its selection handles drops the selection), and must re-attach highlights on load from the stored quotes, because a reload rebuilds the page and Safari discards background tabs freely. `md2artifact` does all three; `tests/test_md2artifact_ios.py` guards them.
**A selection event may open the note box; it must never close it.** Opening the box focuses its textarea, and focusing collapses the document selection — so a handler that closes on "the selection went away" closes the box it just opened, one debounce later. That is the flicker where a box appears and vanishes as you select. Make opening and closing asymmetric: selection events only open, and the only things that close are Save, Cancel/Escape, and a press outside an *empty* box — a box with words in it stays open on a press outside, as above. The corollary is that the box must survive anything the browser does to the selection behind it: a typed note is never discarded, and never committed, by an event the user did not cause. This is behaviour no string match can check, so it is guarded in a real browser by `tests/test_md2artifact_browser.py`; the same tests fail against the pre-fix layer, which is what makes them worth having.
**Escape the non-ASCII in an injected layer.** The layer lands in host pages whose charset it does not control, and a page served without `charset=utf-8` is decoded as latin-1, so a raw 💬 or curly quote in a JavaScript string reaches the reader as mojibake (the badge rendered as `ðŸ'¬`). Spell them as `\uD83D\uDCAC` and `\u201C` instead — an escape survives either decoding. `tests/test_annotate_html.py` holds the layer's strings to ASCII, exempting comments, where nothing renders them and readable prose is worth more.
**Single-key shortcuts must not fire while the reader is typing.** The comment box is a `<textarea>` inside the annotation layer, so a host page that guards only `INPUT` lets every letter of a note double as a command — on the context-ledger page, typing `d` in a comment marked the selected row *drop*. Two defences, both cheap:
- The layer stops its own key events at its root in the **bubble** phase, so handlers bound on `document` or `window` never see them. Bubble at the layer root is the one position that works: handlers *inside* the layer (the textarea's Enter and Escape) sit deeper and have already run, while a capture listener on `window` would fire too early and kill the layer's own keys.
- The host page still guards independently — `INPUT`, `TEXTAREA`, `SELECT`, `isContentEditable`, and anything inside `[data-annotation-layer]` — because a page may bind its own capture-phase listeners, which no bubble-phase stop can reach.
Test it by typing the shortcut letters into a real comment box and asserting the page state did not change. A page whose shortcuts are only tested outside the comment box has not been tested.
## Checklist state persists and copies out
`- [ ] item` renders a real checkbox whose state is stored under a key of its own, `an-state:<key>:<id>`, prefix-namespaced away from the comment array and read back on load. One key per control is what makes two tabs safe: two readers ticking different items write different keys, so there is nothing to reconcile; two readers ticking the same item resolve last-writer-wins, and the losing tab repaints to the stored value. A tick the browser refuses to store is reported per item, with the badge, the panel line and the close-tab guard a refused comment save raises. State written by the earlier layer, under one shared `an-states:<key>` document, is read at the point a control with no per-item value of its own is read, and that document is never written and never deleted — so there is no migration pass to finish, nothing that records the copy as done, and a page rolled back to the earlier layer still finds every tick. A numbered source list keeps its numbers: only an unordered checklist trades its bullet for the control. Copy all gains a leading `## Checklist` section listing each item as `- [x] label`, so the verdict travels with the notes explaining it. The `[ ]` here is ticked and copied out, which is the live-control case `communication.md` allows; a decorative `[ ]` sitting in prose is still out.
**A refused write is as loud for a tick as for a note.** Every read and write is wrapped, so blocked or full site data never breaks the page — but it must never be silent either: a review queue is all checklist and no prose, so a page whose storage refuses the states raises the same badge, the same panel line and the same close-the-tab guard a refused comment write raises. Persist a new kind of page state and wire it into that one signal, or the first reader with site data turned off loses their verdict without being told.
**A two-state checkbox is the default, not the only option.** `md2artifact --states approve,approve-pending-edits,deny` replaces it with a button cycling that set, named for screen readers and operated by Enter or Space; `[ ]` starts at the first state and `[x]` at the last, and the export writes the state in the brackets (`- [deny] label`) — which is why a state carrying `[`, `]` or a control character is refused at build time, since it would produce a line that no longer parses back. Declare nothing and the page stays exactly the plain-checkbox page it was. The wiring keys on `data-an-state-id`, so a hand-written page emitting the same wrapper gets all of it.
**The id is a slug of the item's own label, so editing a label resets that item's tick.** Saved state is keyed by label, not by position, which is what lets a reviewer reorder or insert items without losing their ticks — the trade is that rewording an item orphans its stored value and the item comes back at its source default. Reword deliberately, or accept the reset. Two items with the same label are disambiguated by a numeric suffix allocated against every id already emitted, so no two controls can share a storage slot.
## The viewer sandbox refuses modals and plain downloads
**`confirm`, `alert` and `prompt` do not exist in the Artifact viewer.** The viewer renders the page inside a sandboxed iframe with no `allow-modals` keyword, so `window.confirm` returns false without ever asking — Chrome only logs `Ignored call to 'confirm()'. The document is sandboxed`. A destructive control that returns early on that refusal is a dead button in the one place the page is actually read. This bit three controls at once: **Delete all** and the per-comment **delete** in the annotation layer, and a **Reset marks** button in the host page itself — so it is a rule about every control on every artifact, not a quirk of the layer. Guard destructive controls in the page instead: the button arms on the first click, says what the second click will destroy, and disarms on a timeout, on Escape, or on any re-render. Never stub `window.confirm` to test such a path — the stub supplies the dialog the viewer never will, so it hides exactly this bug. Test the control inside `<iframe sandbox="allow-scripts allow-same-origin">`, which is what the viewer does.
A page-initiated file save — a blob URL on an `<a download>` — is inert in the same sandbox. (An earlier note said a page carrying one made every publish warn; a publish with one on 2026-09-24 raised no warning.) The one working route is the `downloads` capability, declared at publish (see the "This page" row above); the viewer shows a confirmation, and the reader may decline.
**Every control on the page is exercised before publishing**, in a real browser, including the destructive ones: reset, delete-all, per-item delete, copy, and each filter and sort. A button that renders is not a button that works — the shortcut collision above shipped in a page whose keyboard path had been tested only where no comment box was open.
## Rendering mechanics
**Transcripts.** The full text sits in a collapsed `<details>` beside the excerpt (the *why* is in `~/.claude/checklists/results-analysis.md`); inside it, visually locate the excerpt the prose is about with a `<mark>`, a coloured left border or a boxed span, so expanding the section shows immediately where the fragment sits in context. Keep the role palette theme-aware. Long transcripts scroll inside their own container (`overflow-x: auto`, bounded `max-height`), never the page body.
**Enumerated instances.** Where a page lists many samples, episodes or runs, give each collapsed row a compact per-step strip in its header — one cell per step (attempt, turn, retry, epoch), coloured by that step's outcome plus a label — so recovery, degradation and "it failed the same way ten times" are visible before anything is opened.
**Code blocks — style every `<pre>`, not just the one you were looking at.** A rule keyed to the class you happened to use (`pre.body`) leaves every other `<pre>` on the page with browser-default `white-space: pre`, so an embedded file's long lines run straight out of the container with no scrollbar and the block looks broken the first time anyone expands it. Give `pre` itself `white-space: pre-wrap`, `word-break: break-word`, `overflow-x: auto` and a bounded `max-height`, then specialise. The same trap catches tables, `<img>` and anything else wide enough to escape.
**Truncated embeds.** Pages that inline a file usually cap its length. A file trimmed silently reads as complete and misleads whoever quotes it — state the cap and how much is missing, right where the content ends.
**Table of contents.** Fixed on the left for any page that scrolls past a couple of screens, with the current position indicated. It must not steal width from the content on narrow viewports — collapse it to a top bar, a toggle, or hide it under a breakpoint.
**Sortable, filterable tables.** Clicking a column label sorts, again reverses, a third time returns to the natural order; the caret opens the value menu. Sorting flattens any grouping — a sort the reader asked for must not be silently confined inside buckets — and it conflicts with hand-ordering, so a page offering both must say so rather than appear to ignore a drag. Provide one **Clear sort & filters** control: filters that cannot be cleared in one action strand the reader in an empty table. What the menu must contain — distinct values with live counts, All/None, numeric columns banded rather than listing every integer — is the standard in `~/.claude/checklists/results-analysis.md`.
**Mermaid layout.** Favour vertical space: `flowchart TD` over `LR`, and the equivalent orientation choice for other diagram types. TD alone is not enough — several siblings at one rank still render wide, so stack them with invisible `~~~` links and wrap long labels with `<br/>`. Keep explicit spaces in the text around joins and keep each node's label on ONE source line: labels assembled across source lines, or tight against `<br/>`, render as words concatenated without spaces. Scrolling vertically is easy; panning horizontally is not.
## Reach for a neighbour instead when
- the question is **what the page should say or show** — headings, plots, attention, collapse-by-default, terminology: `~/.claude/checklists/presentation.md`
- the page reports a **run**: evidence one click from every aggregate, provenance, transcript legibility, the per-finding shape, what the annotation layer is *for* — `~/.claude/checklists/results-analysis.md`
- the numbers need **statistical machinery** — intervals, nulls, chance correction, slicing: the `results-artifact` skill
- it is a **spec or plan**: the `spec-artifact` skill
- the draft is finished and the risk is being **misread**: the `reduce-ambiguity` skill, run once per page
- the page is published and needs **recording or reconciling** in `ARTIFACTS.md`: the `artifacts-sync` skill
- the sentences themselves are the problem: `~/.claude/checklists/writing.md`
- a chart is going on the page: the built-in `dataviz` skill (`house-plots` is for papers)
## Why this file is thin
Five checklists hold the standards; skills route to them. This one keeps only what is true of the Artifact platform and `md2artifact` specifically — the quirks no checklist should have to carry. See `~/.claude/checklists/README.md`.