Skip to main content

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 switch 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​

WHEN

an artifact carries a share type this build has no registered viewer for

THEN

the shell MUST render the generic-file viewer (size, checksum, download) and MUST NOT return an error page

Scenario: Registered type​

WHEN

an artifact's share type is registered

THEN

the shell MUST render that type's registered body partial and MUST expose its declared anchor affordances

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 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​

WHEN

a markdown artifact with headings is opened

THEN

the viewer MUST show the rendered body and a TOC whose entries link to the corresponding heading blocks

Scenario: Stable block ids​

WHEN

the same markdown body is rendered on two requests or two surfaces

THEN

each block MUST receive the same 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 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​

WHEN

a reader activates the react + affordance on a rendered block or bullet

THEN

the viewer MUST produce an md_block/md_bullet anchor carrying that block's block_id (and bullet path) for SPEC-0006 to persist

Scenario: Select to comment in the margin​

WHEN

a reader selects text and starts a comment

THEN

the viewer MUST capture a 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.

Scenario: Highlighted source with outline​

WHEN

a code artifact is opened

THEN

the viewer MUST show syntax-highlighted source with line numbers and a symbol outline whose entries jump to the corresponding lines

Scenario: Language fallback​

WHEN

the source language is unrecognized

THEN

the viewer MUST still render line-numbered, selectable plain source rather than failing

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 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​

WHEN

a reader activates the comment affordance on line 42

THEN

the viewer MUST produce a code_line anchor {"line":42} for SPEC-0006

Scenario: React on a range​

WHEN

a reader selects lines 40–47 and reacts

THEN

the viewer MUST produce a 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 (0..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​

WHEN

a reader drops a pin on the image and writes a comment

THEN

the viewer MUST produce an image_region anchor with normalized {"x","y"} (optionally w,h) for SPEC-0006 to persist

Scenario: Pin overlay unavailable​

WHEN

the pin overlay script fails to load

THEN

the viewer MUST still render the image and offer whole-artifact reactions/comments

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 artifact.

Scenario: Non-previewable blob​

WHEN

a FILE/GZ artifact (e.g. a .sql.gz dump) is opened

THEN

the viewer MUST show size, gzip note, checksum, and a download control, and MUST NOT inline the body

Scenario: Checksum-verifiable download​

WHEN

a reader downloads the body

THEN

the bytes served MUST match the displayed checksum

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.

Scenario: Browse a mixed-media bundle​

WHEN

a bundle containing a markdown file and an image is opened

THEN

the viewer MUST show a tab per file and render the selected file with its registered viewer and anchor affordances

Scenario: Annotate within a member file​

WHEN

a reader reacts or comments inside a bundle member file

THEN

the anchor MUST resolve to that specific member file, not the bundle as a whole

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 + ◆ 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​

WHEN

any of the five viewers renders

THEN

the surrounding header and panel structure MUST be identical, with only the body and the type-specific panel fields varying

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.

Scenario: Scripts fail to load​

WHEN

Alpine/HTMX or the pin widget does not load

THEN

the body MUST still render and remain readable, and whole-artifact reactions/comments MUST remain available

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.

Scenario: Read without capability​

WHEN

a client requests a viewer or a body download without the artifact's link capability or member access

THEN

the server MUST deny the read (404/403 per the access policy) and render no body

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 Retry-After.

Scenario: Burst on body download​

WHEN

a client exceeds the configured rate downloading bodies

THEN

the server MUST respond 429 with Retry-After and not serve the body

Requirement: Security Headers​

Viewer responses MUST set a strict 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​

WHEN

a markdown body contains <script> or an event-handler attribute

THEN

the rendered output MUST be sanitized so it cannot execute in Cairn's origin

Scenario: Sniff-proof raw body​

WHEN

a raw body is downloaded

THEN

it MUST be served with 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.

Scenario: Oversize request​

WHEN

a viewer-side request exceeds the configured limit

THEN

the server MUST reject it with 413 without buffering the full body

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).

Scenario: Cross-site post from a viewer​

WHEN

a state-changing request from a viewer arrives on a session-auth route without a valid CSRF token

THEN

the server MUST reject it

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.

Scenario: Open-redirect attempt​

WHEN

a viewer request supplies an external redirect target

THEN

the server MUST ignore it and redirect only to a safe internal path

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 main 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​

WHEN

a viewer distinguishes something by color (e.g. a syntax token or type badge)

THEN

an equivalent text or shape cue MUST also be present

Requirement: Icon-Only Controls​

Every icon-only control a viewer exposes (copy, download, react +, image pin, bundle tab close, TOC toggle) MUST have an aria-label describing its action.

Scenario: Icon button​

WHEN

a viewer control has no visible text label

THEN

it MUST expose an 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 aria-live (polite for normal updates) so assistive technology announces the change without stealing focus.

Scenario: Comment posted in a viewer​

WHEN

a comment thread is swapped into the margin or panel

THEN

the update MUST be announced via an 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).

Scenario: Keyboard-only tabbing through a bundle​

WHEN

a keyboard user moves through bundle tabs

THEN

arrow keys MUST move between tabs, Enter/Space MUST activate one, and focus MUST be visible on the dark canvas

Scenario: Keyboard-only reaction​

WHEN

a keyboard user opens a viewer's reaction picker

THEN

focus MUST move into it, cycle within it, and return to the trigger on close

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 & PathPurposeAuth
GET /{id} (web)Render the app shell + resolved body viewerPublic — gated by link capability (ADR-0007); returns 404/403 without it
GET /v1/artifacts/{id}Fetch metadata + rendered/preview payload for a viewerPublic — gated by link capability (ADR-0007)
GET /v1/artifacts/{id}/bodyDownload 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.