hue viewer — Feature Requirements (scoped by interaction tier)
Status: living inventory · Date: 2026-08-29 · Scope: every viewer requirement that is not specific to one backend. What a GPU window owns is in gui.md; what a terminal owns is in tui.md; everything else is here, scoped by the interaction tier it needs rather than by the canvas that happens to paint it.
Why this file exists
Requirements used to be filed by backend, and that produced defects rather than merely reading oddly. hue's in-document search was specified once in gui.md and nowhere in tui.md, and the two implementations silently diverged on four axes — case sensitivity, haystack, granularity and jump placement. A backend-shaped spec cannot express "these must agree", because it has no place to say it.
IMPORTANT
A divergence between the GUI and the TUI is a defect, not a variation. Where a tier is achievable in a backend, the behaviour must be identical; where it is not, the row states the degradation explicitly. See UIA13.
Tiers
From sparkles.input.tier.InteractionTier:
| Tier | Meaning |
|---|---|
passive | Expressible in static output — no event loop needed |
interactive | Needs a live event loop (key, pointer, wheel traffic) |
precise | Needs sub-cell positions or continuous time |
precise → interactive | Precise where the canvas allows it, degrading to cell/step granularity where it does not — the degradation is part of the requirement |
invariant | Not an interaction requirement at all: an architectural or performance rule that holds at every tier |
WARNING
No row in this file may assume a frame clock. WIN1 gives the window a 60 fps loop; TSF1 gives the terminal one frame per repaint; TKB4 states flatly that "a terminal has no frames". Any requirement expressed in elapsed time must therefore name a duration and let each host reach it its own way — never a frame count.
Known divergences
Recorded rather than silently merged. Each is the same class of defect as the search bug, found by an audit of all 151 requirement rows.
| # | Capability | GUI says | TUI says | Resolution |
|---|---|---|---|---|
| D1 | Fence copy-button anchor | COD3: a cutout of the top border | TSL5: the fence header-band right edge | Border cutout — it matches COD4, MDP22 and the diagram in gui.md. The header-band implementation is the drift |
| D2 | Copy confirmation duration | COD3: ~1.2 s checkmark | TSL5: "until the state clears"; TSL8: holdMs: 1600 | 1.2 s, in both hosts — named as a duration, never a frame count. TSL5's "until the state clears" was the terminal admitting it has no clock to count |
| D3 | Scrollbar track click | SCB4: centre the viewport | TSB2: click-to-page/centre | Centre. Both traced to the same ScrollView.stepV, so one text described behaviour the shared machine does not have |
| D4 | Copy keybinding | SEL4: Ctrl-C / Cmd-C only | TKB2: y copies everywhere | y plus Cmd-C; Ctrl-C left for the host (KBD3). SEL4 updated |
| D5 | Theme cycling arrows | THG1: ←/→ cycle the theme, unconditional | TKB3: arrows are pane-scoped; <leader>ut from anywhere | <leader>ut only: the arrows left the viewer with KBD1. A TUI row silently amended a GUI row — the structure this file dissolves |
| D6 | ```ansi fences | MDP12: decoded by an off-screen VT | tui.md prose says "passed straight to the real terminal"; its own MDP-T3 records that as rejected — "it would corrupt the box" | VT decode. tui.md contradicted itself |
| D7 | Match tint (FND2) | paints per-match rects from matchRects | painted nothing — search was jump-only | Resolved here. The terminal paints the same rects, pre-mixed for a cell. tui.md's parity map had claimed this tint for a pane that painted none |
| D8 | Goto line (:N) | Mode.gotoLine + parseGotoLine | Command.startGoto is an empty arm — "the TUI has no go-to bar yet" | The terminal owes the bar. Deferred to the input-line unification, not built here: adding a missing feature is a wider change than making one behaviour agree |
| D9 | Selection granularity | SEL1: a half-open byte range into the source, char-precise, with gutters and decorations excluded (SEL2) | the terminal pane's Selection!long runs over visual line indices — whole rows, gutter included | The byte range. A line-wise selection cannot satisfy SEL1 (it is not a byte range), SEL2 (it covers the gutter) or SEL8 (whole-document copy reproduces the file). The pane already has the identity channel it needs — sourceOffsetAt for the inspector, selectionRects for the match tint — so this is a conversion, not a new mechanism. Visible wherever a PreviewTui is embedded: the picker's preview selects differently from the document beside it |
D1, D2, D3, D4 and D5 are decided above and their rows below carry the decision; the implementations have not yet been reconciled, and each row's Status says so. D6 is a documentation fix. D7 and D8 were found by this round's search work; D7 is resolved here and D8 stays open. D9 was found by the picker's grep preview and is open — it is the largest of the remaining ones, since it changes how a whole pane selects.
Render model (RND)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| RND1 | Each frame must clear to the page background and paint only the viewport-visible rows (index-culled by topLine). | invariant | full (a0b0f93a) | — | the op cull + base clip in runGui |
| RND2 | Every view must be one laid-out widget tree painted by a single painter; content scrolls by visual row. | passive | full (b4b837f4) | TSF3 | ViewerModel.rebuild; the one paint pass |
| RND4 | Content must have a 1-cell background-filled left padding and a scrollbar gutter on the right; the left padding is page background. | passive | full (5ca88625) | — | padX, rightPad, gutterPx in runGui |
| RND5 | Totality is the law: an unknown language, missing grammar, oversized file, or parse failure must degrade to plain uncolored text in the window — never a crash, never half-colored output. | passive | full (74d8f6a3) | — | ENG4 fallback (general spec); drawText |
| RND7 | The render fold must go through the two sparkles:syntax seams this backend motivated: byStyledLine (spans clipped at \n, stable per-row y) and toRgb(Color, palette, default) (the unset/default_/palette/rgb sum type → RgbColor). | passive | full (b55be7aa) | — | byStyledLine; toRgb (sparkles:syntax) |
Window reflow (WIN)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| WIN3 | Resizing must reflow content: any change in the available column count re-runs layout. | interactive | full (2febf905) | — | ResizeDebounce.step → ViewerModel.relayout |
Views & toggle (VIW)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| VIW1 | A raw highlighted-source view must render the file's styled runs (wrapped). | passive | full (b55be7aa) | — | viewCodeDocument |
| VIW2 | v must cycle the view modes in the order of progressive enhancement — plain text → syntax-highlighted → rendered md-preview — so each press demonstrates the next fidelity tier the document's content earns: what the bytes say, what the grammar adds, what the renderer builds from the structure. A markdown file opens at the top tier (the preview) and the cycle wraps back down to plain; a non-markdown file has no preview tier and toggles plain ↔ syntax. The status chrome names the mode (preview / raw / plain). | interactive | full | — | ViewerModel.cycleView/plainSyntax; Command.toggleView |
| VIW3 | The preview model must be built once at load from the markdown structural model + per-fence highlight/ANSI decode. | passive | full (9b0a4b50) | — | buildPreviewModel |
| VIW4 | The plain view is the raw view with an empty event stream — same wrapping, folding, gutter, and selection; only the styling is absent. | passive | full | — | viewCodeDocument(source, null, …) |
| VIW5 | File inclusion (VitePress's @include directive: an HTML comment whose text is @include: and a path): when a markdown document is previewed, an include comment on its own line must be replaced by the referenced file's content before the structural parse, recursively — with VitePress's forms: a path relative to the including file or @/-prefixed from the source root, a {start,end} line range (either bound optional), and a #region name selecting the lines between #region name and #endregion name markers. As in VitePress, an include inside a fenced code block expands too; a snippet import (VIW6) there stays code. The expanded text is the document in every view, as DVN6's merged text is, so the line gutter, search and goto-line agree across views; --raw (which opens the file as code) shows the file's own bytes. | interactive | full | — | expandIncludes, expandIncludesFromDisk, DocumentPipeline.fromSource |
| VIW6 | Code snippet import (VitePress <<< path{highlight}{lang} [label] on its own line): the preview must render the file as a fenced code block — language from the {lang} suffix or the file extension, [label] as its title (a tab inside a ::: code-group, TBU2), #region and line-range selection as for VIW5, line highlights honoured. Used across docs/ today (the event-horizon tutorials, the design-system catalog). | interactive | partial: line highlights are parsed and dropped, not drawn | — | expandIncludes (md.include.snippetBecomesALabelledFence) |
| VIW7 | Inclusion is confined and total: @/ resolves against a configured source root (default: the nearest docs/ holding .vitepress, else the document's directory) and a path may leave that root only within the repository (@/../apps/… is used today), never outside it; a missing file, an unknown region, a cycle or a depth over 8 renders a visible, located error block in place of the directive — never a crash, a silent omission, or a read outside the confinement. Included text is untrusted like the document itself. A symlink inside the repository that resolves outside it is refused like a path that does. | interactive | full | — | expandIncludesFromDisk (md.include.confinedToTheRepository, md.include.fromDiskFollowsNoSymlinkOutOfTheRepository) |
Wrapping & visual-line model (WRP)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| WRP1 | Prose must soft-wrap (word-wrap) to the available width, with hang-indent on continuation lines. | passive | full (d871c47c) | — | wrapLines (TextWrap); Widget.hangIndent |
| WRP2 | Code and ```ansi fence lines must hard-wrap (character-level, styled runs split at the column) to the panel width; no horizontal overflow/clipping. | passive | superseded | — | fence rows render unwrapped and clip within the panel (the canvas clip pair) |
| WRP3 | The raw (non-markdown) view must wrap long physical lines too (greedy word wrap; indentation survives as a no-break prefix; an overwide token overflows and clips). | passive | full (f0148ef8) | — | viewCodeDocument TextWrap.greedy |
| WRP4 | All views must reflow on window/font resize (rebuild the wrapped-line list on width change). | interactive | full (8a03bff1) | TSF2 | ViewerModel.relayout on widthCols change |
| WRP5 | Markdown preview horizontal scroll suppression: Markdown documents in preview mode must never engage a document-level horizontal scrollbar (contentCols = widthCols); all prose must wrap to the pane width. Embedded structures that exceed panel width (tables TBL7, code blocks COD6) scroll internally via their own component viewports/scrollbars without expanding the document's horizontal extent. The raw view retains document horizontal scrolling. | passive | full | — | ViewerModel.relayout (widthCols clamp for markdown preview); tui.d horizontal scrollbar tests |
Line numbers (NUM)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| NUM1 | A file line-number gutter must number by physical (source) line, shown only on the first visual row of a wrapped line (continuations blank). | passive | full (85dcc4cec) | — | a GUT3 channel composed after layout (lineNumberCells), no longer painted in runGui — so the TUI and the markdown preview number too |
| NUM2 | The file gutter is on by default; --line-numbers=false disables it and l toggles it at runtime (reflow follows). | passive | full (eb4df89c0) | — | Generalized to GUT5: --gutter all|none|<names> selects any channel by name, --line-numbers=false is a shorthand over it, l and <leader>vg toggle at runtime in both backends. The stream backends leave the numbers out unless named — their output is the text a reader pipes and copies |
| NUM3 | The gutter width must be stable (from the source line count) so toggling never oscillates the wrap width. | passive | full (85dcc4cec) | — | GUT4 reservation: digitCount(srcTotal), never the data |
| NUM4 | Each code block must have an in-panel code-relative line-number gutter (1..N), first-wrapped-row only, dimmed. | passive | full (5b862346) | — | MdViewOptions.codeLineNumbers: the gutter in viewMarkdownInto's codeFence case |
| NUM5 | Code line numbers are on by default; --code-line-numbers=false disables and c toggles at runtime. | passive | full (5b862346) | — | codeLineNumbers; KEY_C |
Navigation & scroll (NAV)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| NAV1 | Mouse wheel must scroll (3 lines/notch) — except with Ctrl held, which zooms instead (FNT3); ↑/↓ and j/k scroll one line; PageUp/PageDown a page; Home/End to ends. | interactive | full (e813f99a) | — | normal-mode key block in runGui |
| NAV2 | gl must enter goto-line mode; entering a number jumps to that source line's visual row. (g became a prefix when the keymap was unified — gg/Shift-G are the vim motions, lantern LMP3.) | interactive | full (1e218180) | — | Mode.gotoLine; visualOfSrc |
| NAV3 | The per-frame clamp must keep the scroll offset addressing a real row — and nothing more. It must not re-impose maxTop: that is the pane-filling rule, which belongs to the scroll operations (NAV6), and enforcing it every frame is what moved the first line when the window grew (issue #299). | interactive | full (511d82d5) | — | ViewerModel.clampView |
| NAV4 | The scroll offset must be defined in source terms, not as a visual row index: a visual row is a function of the laid-out width, so a resize, a Ctrl-± font step or a pane toggle changes what a given index means. | interactive | full (67623407) | — | ScrollAnchor; ViewerModel.top |
| NAV5 | Every re-layout must keep the first visible line in place — capture the source byte showing at the top of the pane before the rebuild, re-find it after. Two modes, --scroll-anchor: segment (default) keeps the exact wrapped segment; line snaps to the wrapped line's first segment. Blank/synthetic rows above the anchored content are preserved with it, and a new document is exempt (its outgoing rows describe the outgoing buffer). | interactive | full (67623407) | TSF2 | captureAnchor/rowForAnchor/restoreAnchor |
| NAV6 | A resize must only change how much of the file follows the first line. A pane that grew must not scroll the content up to keep the tail flush with the bottom; the offset may legally sit past the last full screen, downward travel is then refused rather than snapped, and scrolling back inside the clamp restores ordinary behaviour. Every scroll container the offset round-trips through must be published the same extent (scrollExtent). | interactive | full (52610621) | TSF2 | scrollVertical/scrollTo/scrollExtent |
| NAV7 | The rule must hold for every scroller keyed to content: the document view in both backends, the explorer and inspector trees (TreeViewState.resize — row lists, so only the growth case applies), and the DSV browser (which scrolls through the document pipeline). Fence and table inner viewports are anchored by construction — their offsets are keyed by source span and their bodies do not re-wrap. | interactive | full (d658bada) | — | tree_view.resize; fenceScrollAt |
The width-reflow regressions exercise parsed Markdown preview paragraphs, which actually wrap at the bounded content width, and select segment or line explicitly. Raw source-code views use TextWrap.none and horizontal scrolling; their anchor regressions preserve unchanged source rows rather than assume that a narrower pane wraps code.
Scrollbar (SCB)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| SCB1 | A scrollbar must appear only when content exceeds the viewport; its thumb size/position reflect the visible fraction and scroll progress. | passive | full | TSB1 | OpKind.scrollbar; scrollbarRail; STM2 |
| SCB2 | On hover/drag the handle must expand to a font-proportional width (1.5 cells) from a thin proportional idle rail, eased over time. | precise → interactive | full | TSB3 | semantic ScrollbarAnim.percent; RaylibCanvas.scrollbar |
| SCB3 | The reserved right gutter must equal the expanded handle width so the handle fills the gutter without overlapping text. | passive | full | TSB1 | scrollbarGutter(); railExpandedPx |
| SCB4 | Dragging the thumb must track the cursor; clicking the track must center the viewport on the click. | interactive | full | TSB2 | ScrollView.stepV/stepH; STM9 |
| SCB5 | The hover track + thumb must use a distinct (link-tinted) color, so they read against the grayscale page/code bands. | passive | full | — | DrawOp.barTrackColor; visual.fg |
Live theme cycling (THG)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| THG1 | <leader>ut / <leader>uT must cycle the theme (wrapping), re-resolve colors, rebuild the derived palette, and repaint live. | interactive | full (2febf905) | TKB3 | applyTheme; KEY_LEFT/KEY_RIGHT |
| THG2 | Decoration colors (heading accents, quote-bar cycle, callout accents, scrollbar) must be theme-derived, not fixed. | passive | full (17b33d09) | — | MdViewTheme.derive (quoteBars, the accents) |
| THG3 | The cycle order must group themes by brightness by default (--group-themes; disable with =false): the alphabetical list is stably partitioned dark-first (a theme is light when its document background's relative luminance exceeds the midpoint), so each brightness forms one contiguous run and rapidly cycling dark themes never flashes a light background — the boundary is crossed only deliberately. | passive | full | — | sortedThemes(name, grouped); isLightTheme |
Search & goto (FND)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| FND1 | / (raw view) must start an incremental search; a bottom input line shows the query + match count. Both hosts run one input bar (input_line.InputState): the same acceptance rules decide what a keystroke is worth, so neither can reach the shared matcher with a query the other could not produce. | interactive | full (1e218180) | — | input_line.InputState; Mode.search; input-line paint |
| FND2 | All matches must be found in the source and overlaid as a translucent tint, the current match brighter. A cell backend has no alpha, so it pre-mixes the same colours against the page background at the window's alphas — the degradation is stated, not left to drift. | interactive | full | — | findMatches; match overlay; tui.search.matchesArePainted |
| FND3 | Enter jumps to the first match at/after the view; n/Shift-n cycle matches (centered). | interactive | full (1e218180) | — | jumpToMatch; Command.matchNext/matchPrev |
| FND4 | Because lines wrap, matches (source coords) must be remapped to visual rows via each line's srcLine+wrapColOffset. | passive | full (f0148ef8) | — | ViewerModel.visualOfMatch; per-match rects from selectionRects |
| FND5 | Cancelling a search (Esc) and opening a fresh one (/) must drop the matches, not merely the typed query — the count, the tint and what n/N cycle all reset together. One entry point does this: ViewerModel.clearSearch. | interactive | full | — | clearSearch; asserted in workspace.searchAgreesAcrossBackends |
Markdown preview constructs (MDP)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| MDP1 | Headings must render a per-level Nerd icon leader + per-level accent color + a subtle full-width band. | passive | full (21da63cf) | MDP-T1 | Layouter.heading; headingAccents |
| MDP2 | Paragraphs must soft-wrap; inline styling (bold/italic/strikethrough) must map to attributes. | passive | full (17b33d09) | — | inlinesToSpans (strong/emphasis/strikethrough); wrapLines |
| MDP3 | Inline code spans must render with a distinct background. | passive | full (17b33d09) | — | inlinesToSpans codeSpan; MdViewTheme.inlineCodeBg |
| MDP4 | Bullet lists must use ●○◆◇ glyphs cycled by nesting depth; ordered lists number. A nested child block indents by exactly its parent item's leader width (2 cells under ● , 3 under 1. ), so depth accumulates as the sum of ancestor leaders and nested content starts where the item's own text does. | passive | full | — | the list case's leader + padded child wrappers |
| MDP5 | Task-list checkboxes must render Nerd glyphs ( unchecked, checked in green). | passive | full (76405ab1) | — | Layouter.list checkbox |
| MDP6 | Block quotes must render as cards: a heavy ┃ accent bar (2-wide border; color cycling by depth) over a bar-tinted background whose tint deepens with each nesting level (0.06 + 0.04·depth toward the level's bar color) — nesting reads at a glance. | passive | full | — | the blockQuote panel; quoteBars; the cell backends' ┃ |
| MDP7 | GitHub callouts (> [!NOTE/TIP/IMPORTANT/WARNING/CAUTION]) must render the same card treatment in the callout's accent: heavy ┃ bar, accent-tinted background (nesting deepens it), icon + bold accent title sitting tight on the first body block; the marker is stripped from the body. Detection is source-based (the marker parses as a shortcut-link). | passive | full (bb296176) | — | detectCallout/calloutPanel |
| MDP8 | Links must prepend a per-destination icon (github/gitlab/mail/web/file), then the underlined label. | passive | full | — | inlinesToSpans link with MdViewGlyphs.linkIcon (md.render_widgets.linkIconAndImageGlyphLeadTheirText) |
| MDP9 | Images must render a monochrome Nerd glyph + alt text + destination (not the tofu-prone 🖼 emoji). | passive | full | — | inlinesToSpans image: MdViewGlyphs.imageIcon, the alt text, then pushImageDest (md.render_widgets.linkIconAndImageGlyphLeadTheirText) |
| MDP10 | Tables must render the full grid as glyph runs: rounded outer corners, │ column separators, a heavy ┝━┿━┥ rule under the bold header row, and — by default — light ├─┼─┤ inner rules between body rows (MdViewOptions.tableRowRules); one padding cell inside each separator, per-column alignment from the delimiter row, cell text edge-trimmed before track measurement. The whole-table copy button sits in a top-border cutout (TBL6). | passive | full | — | the table adapter over ui.components.table buildTableWidgets (the shared layout core; MDP10's glyph grid is the component's) |
| MDP11 | Thematic breaks must render a full-width rule; HTML blocks render their raw lines (muted italic). | passive | full (d871c47c) | — | Layouter.rule/htmlBlock |
| MDP12 | ```ansi fences must be decoded to styled lines by an off-screen libghostty-vt terminal (no PTY/window) and rendered with the live theme's default colors substituted for default-colored cells. | passive | full (ffd36f7c) | — | gui_ansi.decodeAnsi; AnsiLine/AnsiSpan |
| MDP13 | Definition lists and footnotes are not rendered (the bundled grammar collapses them); they degrade to plain paragraphs / nothing. | passive | partial | MDP-T4 | md/model.d (documented grammar limits) |
| MDP14 | LaTeX math, wiki-links ([[…]]), and inline highlight (==text==) are not rendered — deferred from the render-markdown.nvim shortlist. | passive | not started | — | render-markdown gap-analysis (deferred tier) |
| MDP15 | Custom checkbox states beyond [ ]/[x] (e.g. [-]) are intentionally not recognized (a [-] is a genuine markdown shortcut-link — ambiguous). | passive | non-goal | — | dropped in 3d3ad89f |
| MDP16 | Footnote superscripts ([^1]) and bare-URL autolinks (GFM) are not rendered — not modeled by the bundled grammar / inline pass. | passive | not started | — | md/model.d (not modeled) |
| MDP17 | YAML/TOML front-matter is not rendered as a distinct block. | passive | not started | — | render-markdown parity gap |
| MDP18 | Only the 5 GitHub callout types are recognized; the wider Obsidian set (abstract/todo/question/failure/danger/bug/example/…) is not. | passive | not started | — | matchCalloutType (5 types) |
| MDP19 | Table border presets (round/double/none) and explicit alignment-indicator glyphs are not exposed (box borders + alignment ship via core-cli). | passive | not started | — | the markdown view's table panel (fixed preset) |
| MDP20 | Further render-markdown cosmetic parity — heading border glyphs / min-width / margins, code-block diff backgrounds & insets, inline-code icon affixes, per-level org indent, sign-column gutter — is intentionally not matched (hue's preview is lighter-weight). | passive | non-goal | — | design (lighter-weight preview) |
| MDP21 | Preview-diff decorations: the preview widgets must accept per-block / per-cell change decorations (added/removed/changed tint slots) so a markdown diff renders in the decorated preview (DVN6 --diff-preview) — changed table cells tinted in the box-drawn table, word-level tinting inside paragraphs, struck/collapsed deleted blocks. | passive | not started | — | DVN6; viewMarkdown decoration channel |
| MDP22 | VitePress code groups (::: code-group … :::) must be one block in the document model, with the fenced blocks as its children, showing one at a time behind a tab strip (WGT23) that rides the showing fence’s TOP BORDER, BOXED into the border: the strip carries its own top border, the tabs’ row IS the box’s top border line, and the strip’s ╭ is the box’s top-left corner (╭──┬──╮ over │ tab │ tab ├──); line-drawn — only the active tab fills inside its walls. A tab's title is the fence's [label] with its devicon in front, falling back to the language (which a specific devicon replaces). Selection is source-anchored (codeBody.start), clickable in both hosts. A malformed container degrades to the blocks it was made of rather than swallowing them. | interactive | full | — | md/model.d foldCodeGroups; viewCodeGroup → fenceTopBorder; activeCodeTabs |
| MDP23 | Reference-style links ([text][ref], [ref][], [ref]) must resolve against the document's link reference definitions and render as links — including labels carrying inline formatting, and links inside table cells. A definition may appear below its use; an undefined label degrades to the literal source text, brackets included (which is what keeps a [!NOTE] marker from becoming an empty-href link). | passive | full | — | md/model.d collectLinkDefs/linkRef/linkLabelEquals |
| MDP24 | Link and image titles ([a](url "t"), and a definition's own title) must render as a muted trailing (title) annotation — not underlined, not link-colored, and carrying no source identity, so it never joins a selection or widens a link's hover range. | passive | full | — | inlinesToSpans pushLinkTitle; render_html.d writeTitleAttr |
| MDP25 | Hovering any link form must change the pointer to the link hand, in both hosts. A link is not addressable in the widget tree (a prose row is one rich node), so the hit resolves through the identity channel: sourceOffsetAt → mdLinkRanges. It ranks below every bar/divider hover (DCK9: chrome outranks content). | interactive | full | — | viewer_model.d linkAt/linkShape; workspace.d paneHoverShape; gui.d hoverShape |
| MDP26 | Inline code span normalization and wrapping: Inline code spans must conform to CommonMark §6.3: embedded line endings (\r\n, \n, \r) normalize to single space; leading and trailing space padding is stripped if the span length noBreak: false), while single-word spans remain atomic (noBreak: true). | passive | full | — | sparkles.source_view.markdown inlinesToSpans; md.render_widgets.codeSpanWrappingAndNormalization |
Code blocks (COD)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| COD1 | A code block must render as a rounded box on all four sides (top ╭─ lang ─╮, side │…│, bottom ╰──╯), connected by procedural box-drawing. | passive | full (88c5df57) | — | codeFence; box runs |
| COD2 | The TOP BORDER must embed the language: ╭─ python ──╮. A specific devicon replaces the language name outright (the glyph is the recognizable form); the generic glyph keeps the name beside it. A grouped fence embeds the code group's tab strip on the border line instead (MDP22) — tabs carrying their own icons. | passive | full | — | langTitle/langIcon; fenceTopBorder (the groupTabs arm); tabTitle |
| COD3 | A copy-to-clipboard button must sit in a cutout of the top border just before the corner (── ⧉ ─╮); clicking copies the block body and flips to a green checkmark for ~1.2s. | precise → interactive | full (f1d468ac) | TSL5 | fenceTopBorder's cutout glyph (the fenceHitBase hit); ViewerModel.markCopied |
| COD4 | The copy button must anchor to the border cutout column (not screenW) so it stays aligned across widths. | passive | full (88c5df57) | — | the header band's copy glyph (widget view) |
| COD5 | Highlighted code must keep its per-token syntax colors inside the panel; the panel background is distinct from the page. | passive | full (f1a53bd3) | — | codeFence highlighted branch |
| COD6 | Body overflow must be handled by a user-selectable --code-overflow mode. scroll (default): the body is a per-fence viewport (clipX/clipY + childOffset, number gutter pinned in x, scrolling in y), the BOTTOM BORDER is the horizontal scrollbar (╰━━───╯ — wheel sideways / Shift+wheel, press, drag, and track-click through the shared ScrollView machine), a fence taller than --code-max-lines (default auto — the whole box, borders included, fits the document pane; 0 disables) shows a fixed-height viewport whose RIGHT BORDER is the vertical scrollbar (│ track, ┃ thumb — the bottom bar's mirror; a vertical wheel scrolls the fence to its edge before the document), and clipped content never engages the document-wide bar. Both bars run the pane scrollbars' ScrollView machine and emit the same semantic scrollbar operation as pane bars; the GUI resolves a sub-cell rail around the border line and cell hosts degrade it to glyphs. The pointer shape follows through the same shape() chain (SCV6). wrap: greedy in-panel wrapping, continuations hanging past the number gutter. wrap-at:N: the body pre-wraps to an N-cell box (frame included; continuations blank their gutter number) and rides the scroll structure — the box caps at min(N, panel), the bottom-border bar appears only when N exceeds the panel, and --code-max-lines counts visual lines. An empty fenced body renders as an empty panel, never the raw fence markers. | precise → interactive | full | — | OverflowPolicy (ScrollOverflow/WrapOverflow/WrapAtOverflow — shared with --table-overflow)/FenceScroll; OpKind.scrollbar; ViewerModel.setFenceScroll/barSv + clip-aware contentCols; wheelCellsX (sparkles:input) |
| COD7 | A fence inside a block quote / callout must treat its body as code, not as quoted lines: the > continuation prefixes (blanked to spaces in the working copy for the inline re-parse) are dropped everywhere the body is consumed — rendering, syntax-highlight anchoring, the copy button, group height-counting, and the scroll clamps — with the identity channel mapped exactly across the dropped ranges. An empty fenced body renders as an empty panel, never the raw fence markers. | passive | full | — | MdBlock.codeOmit; fenceBody/FenceBody.srcAt; MdFence.text |
Mouse selection & clipboard (SEL)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| SEL1 | A left-drag must select content; the selection is a half-open byte range into the original source file (const(char)[]). | interactive | full (88c5df57) | TSL1 | sourceOffsetAt (the identity channel); TextSpan.srcStart |
| SEL2 | Selection must include content only — gutters, line numbers, box borders, bullets, and other decorations are excluded (they have no srcStart). Tables are not "excluded" but handled by the separate TBL grid regime. | passive | full (88c5df57) | TSL1 | srcStart == size_t.max skip; TBL |
| SEL3 | srcStart must survive wrapping and word-splitting (each split piece carries its byte offset). | passive | full (88c5df57) | TSL1 | wrapSpans identity propagation |
| SEL4 | The selection must be highlighted with a translucent tint; y / Cmd-C must copy source[a..b] to the clipboard and trigger a top-right notification toast. | interactive | full (88c5df57) | TSL2, TSL8 | selection highlight pass; SetClipboardText; Timeline |
| SEL5 | A click consumed by the copy button or over the scrollbar must not start a selection. | interactive | full (88c5df57) | — | copyClicked in the GUI press handler: copy buttons, code tabs and bar presses set it |
| SEL6 | A ```ansi fence body must be cell-granular file-offset selectable, like a terminal emulator: each decoded cell maps to its source byte (the off-screen VT is 1:1 with source lines and a span's text is the contiguous source between two escapes), so a drag selects character-precise and the │/gutter/padding stay excluded (SEL2). Copying a range yields the raw source between the endpoints (escapes included). | interactive | full | TSL4 | anchorAnsiLines: each decoded (GUI, ViewerModel.fenceRenderer) or stripped (terminal, hueFenceRenderer) span keeps the source bytes it came from, split wherever an escape interrupts it (viewer_model.anAnsiFenceIsSelectableBySourceByte); TSL4 |
| SEL7 | A copy mode must govern how a text-regime selection is emitted: --ansi-copy=raw|strip (default raw), toggleable at runtime with a status-bar indicator. strip runs stripSgr over source[a..b] (drops SGR escapes); it also applies to the ANSI code-fence copy button (COD3). | interactive | full (debfd92d) | TSL4 | CliParams.ansiCopy (CLI10); stripSgr; copy pass |
| SEL8 | Selecting the whole document (drag from start to end) must reproduce the input source file on the clipboard: every selectable region maps to source offsets, so source[selMin..selMax] over the full range is the file (ANSI raw + table markdown included). | interactive | full (c018f39a) | — | contiguous source-offset discipline across SEL/TBL; text-regime table crossing |
| SEL9 | Diff panes are selection domains: in a split diff a drag is confined to the side it starts in — the interactive mirror of the HTML rule (HTM8 / DVL4); copying honors --diff-copy=text|patch (DVL8) — plain text of the selected side, or a valid unified sub-patch. | interactive | not started | DIF-T3 | proposed diff selection domain; DVL8 |
| SEL10 | Selection and scrolling must compose: while the primary button remains held, wheel/key/jump scrolling extends the selection at the stationary pointer, and holding a drag in either edge band autoscrolls that axis continuously (both axes for a diagonal drag) while extending the selection. It stops at the content end and on release, on GUI and TUI alike. | precise → interactive | full | TSL6 | container-owned SCV8; synthetic pane-local drag from DockContainer.tickScroll |
Table grid selection (TBL)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| TBL1 | A drag inside a table must select via smart drag: within one cell → a sub-cell character range; across cells → a rectangle of whole cells (rows×cols spanned); Shift snaps the rectangle to full rows, Alt to full columns. | precise → interactive | full (debfd92d) | — | table_select.d; gui.d table-regime drag |
| TBL2 | The selection must be highlighted (per selected cell's on-screen rects; a sub-cell selection clipped to its character range). Ctrl-C must copy it: a rectangle per --table-copy=auto|tsv|markdown|source (CLI11; source re-emits a DSV document's own dialect, DSC2), a sub-cell → the cell substring. | interactive | full (debfd92d) | — | selectionRects over cell source spans; table_select serializers |
| TBL3 | Screen↔cell mapping must be wrap- and alignment-aware — mapping a click (output line + x) to (gridRow, gridCol, charInCell) and back to a cell's screen rects — via a new core-cli table layout API. | passive | full (634dff4b) | — | keyedRects cell rects + sourceOffsetAt (gui.d hitAt); MdCell |
| TBL4 | A drag that starts on a table cell uses the 2D grid regime (confined to that table); a text-regime drag that merely crosses a table selects it character-precise (each cell maps to its source byte via cellSrc[row][col] + charInCell; borders fall back to the block span), so whole-document selection reproduces the table markdown (SEL8). The regime is fixed at mouse-down. | interactive | full (bc3ef6cf) | — | gui.d mouse-down regime pick; MdCell.span source slices; hitAt |
| TBL5 | The smart-drag→region rule and the TSV/markdown/sub-cell serialization must be presentation-free and testable (no raylib), consumed by the GUI painter/copy. | invariant | full (4d1e2da2) | — | table_select.d (pure); unit tests |
| TBL6 | A whole-table copy button must sit in a right-side cutout of the table's top border (the same idiom as the code button COD3 — slightly left of the corner); clicking copies the whole grid serialized per --table-copy (TSV/markdown/source — a DSV document's full grid under source reproduces the file byte-for-byte, DSC4) and flashes a checkmark. Rendered + click-copied in the GUI and the TUI (tui.md TSL). | interactive | full | TSL5 | the tableCopyHitBase cutout hit (source-anchored at the table's span.start); serializeTable over the full grid; ViewerModel.markTableCopied |
| TBL7 | A table wider than its panel must be handled by a user-selectable --table-overflow mode sharing the fence's OverflowPolicy (COD6). scroll (default): natural column widths behind a per-table framed viewport — pinned frame corners and side caps, everything junction-bearing (cells, inner rules, the top/bottom junction runs) scrolling as one offset, the bottom border doubling as the horizontal scrollbar (junctions dropped while it shows), the TBL6 cutout pinned; the right border never overflows and clipped content never engages the document-wide bar (IXB2). Both bars emit the semantic scrollbar op the pane bars use. wrap: cells wrap so the table fits the panel via the shared table core, never a bar. wrap-at:N: wrap to a total width of N (frame included), then scroll when N exceeds the panel. The available box is maxWidth - indentCols, so nested tables respect their ancestors' indentation. | precise → interactive | full | TSL7 | MdViewOptions.tableOverflow/TableScroll; ui.components.table TableViewportSpec/buildTableWidgets; ViewerModel.setTableScroll/tableExtent/tableAtRow; the shared barPress/barDrag + wheel routing in both hosts |
| TBL8 | A table whose interior exceeds --table-max-lines (scroll/wrap-at modes) must show a fixed-height vertical viewport whose right border is the track (┃ thumb, the bottom bar's mirror); a vertical wheel scrolls the table to its edge before the document. | interactive | full | TSL7 | MdViewOptions.tableMaxLines; TableViewportSpec.maxLines; resolvedTableMaxLines; scrollTableV wheel-to-edge in both hosts |
Font behaviour (FNT)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| FNT5 | Bold/italic/strike/underline attributes must map onto the shared TextStyle; real bold/italic faces are used when present. | passive | full (d1dd79d5) | — | rlTextStyle; FontSet.resolveFace (sparkles:raylib-text) |
| FNT8 | Preview decoration glyphs (heading/callout/link icons, checkboxes, box-drawing) are Nerd-Font codepoints; a non-Nerd --font degrades them to tofu. | passive | partial | TCP3 | defaultGuiFont doc; icon glyph sites |
Diff layout (DIF)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| DIF-T2 | Below the split-width threshold the TUI degrades to the stacked single-rail unified variant (a context-aware line-number column: old numbers on -, new on +/context) rather than two cramped rails (DVL3; the diffs.nvim precedent). | passive | not started | — | research: diffs.nvim |
Verification (DBG)
| ID | Requirement | Tier | Status | Supersedes | Traces to |
|---|---|---|---|---|---|
| DBG3 | The markdown view must be guarded by a golden fixture suite: every construct exercised individually plus a composed document, rendered through the production pipeline (extractMarkdown → viewMarkdown → layout → CellGrid) at width 80 and compared as plain glyph grids — the layout oracle every rendering change lands through as a reviewable diff. Regenerate intentionally with SPARKLES_UPDATE_GOLDENS=1 dub test :source-view -- -i md.goldens. | invariant | full | — | sparkles.source_view.goldens; libs/source-view/test/data/md/goldens/ (*.md + *.txt / *.interactive.txt) |