SPEC-0003: Artifact Viewers
Overview
Cairn renders every artifact inside one app shell (SPEC-0001) but gives each
share type a type-specific body viewer. This capability formalizes the
per-share-type viewers that render inside that shell: markdown, code,
image, file/generic, and bundle. Each viewer is a server-rendered
html/template fragment selected through the ADR-0002 viewer registry, enhanced
with HTMX (server swaps) and Alpine (local interactivity) per ADR-0011.
This spec realizes ADR-0002 (the extensible share-type model and its viewer registry, including graceful degradation to the generic-file viewer) and ADR-0011 (the server-rendered shell, its body partials, and its accessibility posture). It depends on SPEC-0002 for the artifact core, the share-type registry, content-addressed bodies, and body-download semantics.
Two live viewers — the webhook inspector and the trajectory waterfall + activity stream — are their own capabilities (SPEC-0005 and SPEC-0004) and are out of scope here. Annotation mechanics (how a reaction or comment is stored, tallied, validated, and posted) live in SPEC-0006; this spec specifies only each viewer's annotation anchor affordances — what a reader can react to or comment on in that viewer, and how the render locates that anchor so SPEC-0006 can persist it.
Requirements
Requirement: Registry-Driven Viewer Resolution
The shell MUST resolve an artifact's body viewer through the ADR-0002 registry keyed
by the artifact's share type; it MUST NOT an artifact carries a share type this build has no registered viewer for the shell MUST render the generic-file viewer (size, checksum, download)
and MUST NOT return an error page an artifact's share type is registered the shell MUST render that type's registered body partial and MUST expose
its declared anchor affordancesswitch on type in the shell chrome.
Resolution MUST be total: an artifact whose type is unregistered, unknown to this
build, or whose body cannot be interpreted MUST resolve to the generic-file
viewer and remain viewable, downloadable, and annotatable at the whole-artifact
level. No share type SHALL be able to render an artifact unviewable.Scenario: Unknown share type
Scenario: Registered type
Requirement: Markdown Viewer
The markdown viewer MUST render the artifact body as sanitized HTML with a
table of contents derived from its headings. Each rendered block MUST carry the
deterministic a markdown artifact with headings is opened the viewer MUST show the rendered body and a TOC whose entries link to the
corresponding heading blocks the same markdown body is rendered on two requests or two surfaces each block MUST receive the same block_id assigned at ingest (ADR-0006) so anchors resolve to the
same block on every render. The TOC entries MUST be in-page links to their heading
blocks. Rendered markdown MUST NOT execute active content (see Security
Requirements).Scenario: Rendered document with TOC
Scenario: Stable block ids
block_id
Requirement: Markdown Annotation Anchors
The markdown viewer MUST expose an anchor affordance to react under a block and
react to the left of a bullet, and MUST expose a select-text-to-comment
affordance whose comment thread lands in the right margin. Reaction anchors MUST
serialize as a reader activates the react the viewer MUST produce an a reader selects text and starts a comment the viewer MUST capture a md_block ({"block_id":…}) and md_bullet
({"block_id":…,"path":[…]}); comment anchors MUST serialize as text_selection
({"start","end","quote"}) over the canonical body text. The viewer MUST NOT offer
anchor affordances outside markdown's declared capability set (ADR-0006); posting is
governed by SPEC-0006.Scenario: React under a block
+ affordance on a rendered block or bulletmd_block/md_bullet anchor carrying that
block's block_id (and bullet path) for SPEC-0006 to persistScenario: Select to comment in the margin
text_selection anchor (offsets + quoted
substring) and render the resulting thread in the right margin
Requirement: Code Viewer
The code viewer MUST render the body as syntax-highlighted source with line
numbers and a symbol outline (a navigable list of top-level symbols) beside or
above the source. Highlighting MUST be performed server-side and MUST NOT execute the
source. Line numbers MUST correspond one-to-one to the immutable body's lines so they
are permanent coordinates. a code artifact is opened the viewer MUST show syntax-highlighted source with line numbers and a
symbol outline whose entries jump to the corresponding lines the source language is unrecognized the viewer MUST still render line-numbered, selectable plain source rather
than failingScenario: Highlighted source with outline
Scenario: Language fallback
Requirement: Code Annotation Anchors
The code viewer MUST let a reader react to a single line or a line range and
comment on a single line or a text selection. Reaction anchors MUST serialize as
a reader activates the comment affordance on line 42 the viewer MUST produce a a reader selects lines 40–47 and reacts the viewer MUST produce a code_line ({"line":N}) or code_range ({"start":A,"end":B}); comment anchors
MUST serialize as code_line or text_selection. A code_line anchor SHOULD carry
a short hash of the line's text so a client can detect it is annotating against a
stale render (ADR-0006). These anchors MUST match code's declared capability set.Scenario: Comment on a line
code_line anchor {"line":42} for SPEC-0006Scenario: React on a range
code_range anchor {"start":40,"end":47}
Requirement: Image Viewer
The image viewer MUST display the image and support dropping a pin at a point on
the image to anchor a region comment, plus a react-below affordance for the whole
image. Pin placement MUST use normalized fractional coordinates ( a reader drops a pin on the image and writes a comment the viewer MUST produce an the pin overlay script fails to load the viewer MUST still render the image and offer whole-artifact
reactions/comments0..1) so a pin
survives display scaling and thumbnailing (ADR-0006). The pin overlay is a bespoke
vanilla-JS/SVG widget (ADR-0011); committing a pinned comment is a server post
handled by SPEC-0006. If the pin script fails to load, the viewer MUST still show the
static image and expose whole-artifact reactions/comments.Scenario: Drop a pin to comment
image_region anchor with normalized {"x","y"}
(optionally w,h) for SPEC-0006 to persistScenario: Pin overlay unavailable
Requirement: File / Generic Viewer
For non-previewable blobs the generic-file viewer MUST show the file size, a
gzip note when the body is gzip-compressed, the content checksum
(SHA-256/base62 per ADR-0005), and a download control that fetches the raw bytes.
It MUST NOT attempt to render the body inline. This viewer MUST also serve as the
universal degradation floor for unknown types. Its only annotation anchor is the
whole a the viewer MUST show size, gzip note, checksum, and a download control,
and MUST NOT inline the body a reader downloads the body the bytes served MUST match the displayed checksumartifact.Scenario: Non-previewable blob
FILE/GZ artifact (e.g. a .sql.gz dump) is openedScenario: Checksum-verifiable download
Requirement: Bundle Viewer
The bundle viewer MUST present a bundle's member files in a tabbed layout, one tab
per file, and MUST render each selected file by delegating back through the
registry to that file's own viewer (markdown, code, image, or generic file). Each
member file's viewer MUST expose that file's own anchor affordances; annotations MUST
be anchored so they resolve to the correct member file. The tab strip MUST indicate
each file's type. a bundle containing a markdown file and an image is opened the viewer MUST show a tab per file and render the selected file with its
registered viewer and anchor affordances a reader reacts or comments inside a bundle member file the anchor MUST resolve to that specific member file, not the bundle as a
wholeScenario: Browse a mixed-media bundle
Scenario: Annotate within a member file
Requirement: Viewer Renders Inside the Shared Shell
Every viewer MUST render only the body slot of the app shell; it MUST NOT
re-implement the header (logo · type badge · title · one URL control with copy +
any of the five viewers renders the surrounding header and panel structure MUST be identical, with only the
body and the type-specific panel fields varying◆ mcp · Share) or the collapsible metadata + comments panel, which are owned by the
shell (SPEC-0001, ADR-0011). A viewer MUST supply only its type-specific
metadata-panel fields (e.g. a file's checksum, an image's pin count) for the shell
to place in the panel.Scenario: Consistent chrome across types
Requirement: Progressive Enhancement
Core reading — open a share, read its body, read its comments — MUST work with server-
rendered HTML alone and MUST degrade gracefully if Alpine/HTMX or a bespoke widget
fails to load. JS-only affordances (image pins, selection-to-comment) MUST fail to an
accessible fallback (a static image, the readable body, whole-artifact annotation)
rather than a blank or broken region. Alpine/HTMX or the pin widget does not load the body MUST still render and remain readable, and whole-artifact
reactions/comments MUST remain availableScenario: Scripts fail to load
Security Requirements
This capability is web-facing. The following are MANDATORY.
Requirement: Authentication & Authorization
Read access to a rendered viewer MUST enforce the ADR-0007 link-capability access
policy: only a client presenting the artifact's link capability (or an authenticated
member with access) MAY view it. The body-download route MUST enforce the same
policy. Any annotation affordance a viewer exposes MUST defer authorization to
SPEC-0006 (posting requires authentication); agents MUST NOT exceed the human's
permissions. a client requests a viewer or a body download without the artifact's link
capability or member access the server MUST deny the read (404/403 per the access policy) and render no
bodyScenario: Read without capability
Requirement: Rate Limiting
All viewer-render and body-download endpoints MUST be rate-limited per-identity/per-IP;
exceeding the limit MUST return 429 with a client exceeds the configured rate downloading bodies the server MUST respond 429 with Retry-After.Scenario: Burst on body download
Retry-After and not serve the body
Requirement: Security Headers
Viewer responses MUST set a strict a markdown body contains the rendered output MUST be sanitized so it cannot execute in Cairn's origin a raw body is downloaded it MUST be served with Content-Security-Policy,
X-Content-Type-Options: nosniff, Referrer-Policy, and (over HTTPS) HSTS.
User-supplied artifact bodies (markdown HTML, code, images, bundle members) MUST be
served/rendered so they cannot execute in Cairn's app origin: markdown MUST be
sanitized, code MUST be escaped/highlighted as inert text, and raw bodies SHOULD be
downloaded from an isolated origin or with Content-Disposition: attachment and a
non-executable content type.Scenario: Untrusted markdown body
<script> or an event-handler attributeScenario: Sniff-proof raw body
nosniff and disposition/typing that prevents the
browser from executing it as active content in the app origin
Requirement: Request Body Size Limits
The body-download and any viewer-side upload/post endpoints MUST enforce a maximum
response/request size and MUST stream rather than fully buffer large bodies; oversize
requests MUST be rejected with 413 before buffering the full body. a viewer-side request exceeds the configured limit the server MUST reject it with 413 without buffering the full bodyScenario: Oversize request
Requirement: CSRF Protection
Any session-authenticated state-changing action initiated from a viewer (e.g. posting
a pinned comment through the shell) MUST be CSRF-protected via token or SameSite
strategy. Token-authenticated API/MCP requests are exempt (no ambient credentials). a state-changing request from a viewer arrives on a session-auth route
without a valid CSRF token the server MUST reject itScenario: Cross-site post from a viewer
Requirement: Redirect & SSRF Validation
Any viewer-driven redirect (e.g. after a download or share action) MUST target only an
allow-listed internal path; no user-supplied absolute URL SHALL be honored as a
redirect target. If a viewer ever fetches a user-supplied URL server-side, it MUST be
guarded against SSRF. a viewer request supplies an external redirect target the server MUST ignore it and redirect only to a safe internal pathScenario: Open-redirect attempt
Accessibility Requirements
This capability renders user-facing UI. WCAG 2.1 AA is the minimum target.
Requirement: WCAG 2.1 AA & Semantics
All viewers MUST meet WCAG 2.1 AA. The body MUST sit within the shell's a viewer distinguishes something by color (e.g. a syntax token or type
badge) an equivalent text or shape cue MUST also be presentmain
landmark; headings/TOC MUST use correct heading semantics and a navigable structure.
Information MUST NOT be conveyed by color alone — code token colors, type badges, and
reaction clusters MUST carry text/shape cues too. Images MUST carry accessible
descriptions.Scenario: Color-only cue
Requirement: Icon-Only Controls
Every icon-only control a viewer exposes (copy, download, react a viewer control has no visible text label it MUST expose an +, image pin, bundle
tab close, TOC toggle) MUST have an aria-label describing its action.Scenario: Icon button
aria-label describing its action
Requirement: Dynamic Content Regions
Viewer regions updated via HTMX swaps (a newly posted comment thread, a loaded bundle
tab) MUST use a comment thread is swapped into the margin or panel the update MUST be announced via an aria-live (polite for normal updates) so assistive technology
announces the change without stealing focus.Scenario: Comment posted in a viewer
aria-live region
Requirement: Keyboard Navigation & Focus Management
All viewer interactive elements MUST be keyboard-operable with a logical tab order:
Enter/Space activate, Escape dismisses popovers, and arrow keys move within composite
widgets (bundle tab list, symbol outline). The reaction picker and any pin/comment
popover MUST trap focus while open and restore focus to the trigger on close. Image
pins MUST be reachable and placeable/openable by keyboard (or provide an equivalent
keyboard path to region annotation). a keyboard user moves through bundle tabs arrow keys MUST move between tabs, Enter/Space MUST activate one, and focus
MUST be visible on the dark canvas a keyboard user opens a viewer's reaction picker focus MUST move into it, cycle within it, and return to the trigger on closeScenario: Keyboard-only tabbing through a bundle
Scenario: Keyboard-only reaction
HTTP Endpoints
Viewers are rendered by, and read bodies from, the following core routes (ADR-0012).
Auth-by-default: reads are Auth: Public only because they are gated by the ADR-0007
link capability, which is itself the access credential; all other access is
Auth: Required.
| Method & Path | Purpose | Auth |
|---|---|---|
GET /{id} (web) | Render the app shell + resolved body viewer | Public — gated by link capability (ADR-0007); returns 404/403 without it |
GET /v1/artifacts/{id} | Fetch metadata + rendered/preview payload for a viewer | Public — gated by link capability (ADR-0007) |
GET /v1/artifacts/{id}/body | Download raw, checksum-verifiable bytes (file/bundle download) | Public — gated by link capability (ADR-0007) |
No new mutating endpoints are introduced by this spec; annotation posts a viewer
initiates are the SPEC-0006 endpoints and are Auth: Required.