From b556d77920838485a49a4f0db4a95661f6e741e9 Mon Sep 17 00:00:00 2001 From: stanlyzoolo <51911715+stanlyzoolo@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:07:02 +0300 Subject: [PATCH] docs: make theme.go state the two palettes it does not own Three documents claimed the contract change from #65 was recorded in theme.go. It was not: the file still said switching every color in keepkit is switching one Theme value, which stopped being true for panel [3]'s heading ladder and code-fence accents. The file that defines the rule is the one a contributor reads before adding a color. Also drops two stale claims the same pass turned up: CLAUDE.md called LanguageColor the one palette a theme switch must not repaint while its own internal/ui row already said there were two, and the readme pipeline doc said no general sanitizer is pulled in when glamour v1.0.0 holds a bluemonday.StrictPolicy and has put it in go.mod all along. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- docs/design/readme-pipeline.md | 4 ++-- internal/ui/theme.go | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2358eb2..42b5fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Central panel actions (`focusBrief`)** operate on the data the card already shows: **`enter` installs the release the card is offering** (the panel's primary action, and the mirror of `enter`-runs-a-tool in `[1]` — same key, because in both panels it is the thing the user came to that panel to do; see **Update** below), `o` opens the repo in the browser, `c` opens the changelog/releases page, `r` force-refreshes the tool's data, `s` cycles the status (`loader.NextStatus`: `active → trying → inactive`, unknown values fall back to active), `e` edits the note, `#` edits the tool's single tag. **`#` rather than `t`**: `t` is now the global track verb, and a tag editor reachable only from the card is where the tag is shown. `o`/`c` go through `openURLCmd` (resolved per-`GOOS` by `browserCommand`); a tool with no `GitHub` sets `m.statusMsg` instead of launching. `s`/`e`/`#` mutate `m.meta` via `loader.UpsertMeta`, persist with `loader.SaveMeta`, then refresh the card with `m.briefViewport.SetContent(m.renderCard())`. The tags editor commits through `parseTag` (mode.go): the input is **one** tag — everything past the first comma is dropped, so typing `cli, foo` and loading a legacy `[cli, foo]` list both land on `cli` and the editor can never disagree with `LoadMeta`'s `Tags[:1]` migration about a tag's shape. Spaces inside a tag are kept (`dev tools`); empty input clears it to `nil`, which `omitempty` drops from `meta.yaml`. Everything downstream reads the one tag through `tagOf(mt)` rather than joining the slice. - **Clickable card lines**: `renderCard()` is a thin wrapper over **`buildCard() (string, map[int]string)`**, which returns the card text plus the index of its clickable lines (0-based content line → URL) — the **title line** (`https://` + `t.GitHub`, exactly what `[o]` opens: the **link is the full ref while the displayed value is the bare `owner/repo`** printed beside the tool's name, the host being implied; a ref `NormalizeRepo` rejects — an unsupported or spoofed host like `github.com.evil.com/x/y` — renders in full instead, since shortening exactly there would hide the host that makes the link not what it looks like, and the ref is appended only when it fits the line at all) and the **changelog heading** (`msg.htmlUrl` **verbatim** — the release's own page, unlike `[c]`'s `/releases`), registered **only when the heading's `release notes ↗` affordance actually fit**: a heading rendered without it looks like plain text and must not open a browser. Indices are recorded *while writing* (`strings.Count(sb.String(), "\n")` immediately before the line), because line heights vary — the metrics strip is several rows and the tagline and changelog bodies wrap. The wrapper keeps the ~30 `SetContent(renderCard())` call sites unchanged; `handleMouse` is the only consumer of the map and recomputes it per click, so it can never be stale. A **content line is a screen row** here: the viewport *truncates* a line wider than the panel rather than soft-wrapping it, which is what lets a click row map straight onto a content-line index — `TestBriefContentLineIsScreenRow` pins that, because a bubbles that wrapped instead would shift every link below an overlong line. No visual styling marks the links (no underline/bold) — deliberate. - **Card changelog body**: the block is headed by `changelogHeading` — the word `changelog` in `EmphasisBold`, the version transition it covers (`v0.3.2 → v1.0.2`, stated rather than left for the reader to infer from two numbers in the strip above) and, right-aligned when it fits, `release notes ↗` in the `Link` role, with a border-colored **rule filling the gap** between them — it ties the two ends of the row into one heading and separates the notes from the meta line above, which nothing else does now that the card has no section headers. The transition is gated on **`hasUpdate`**, not on the two strings differing: both are printed through `DisplayVersion`, so a tool whose `--version` says `1.10.2` against a `v1.10.2` tag is up to date, and the raw compare printed it a `v1.10.2 → v1.10.2` arrow to nowhere on the one card with nothing to report. The notes themselves render through **`markdownToLines(body, max(m.cardWidth(), 10))`** (textutil.go — `cardWidth()` is the card's single width definition, the same one `buildCard`'s `indentLines` steps the finished text in by; the `briefW-2` it used to size on was one cell wider, and since the code plate is padded to the block's full width it painted over the right `panelGutter`), which replaced a `stripMarkdown` + `wrapText` pair that destroyed the markdown instead of respecting it — it ate list markers (`strings.Trim(line, "*_")` took a leading bullet), left `[text](url)` raw, and its `<…>` HTML strip swallowed `` autolinks whole. The converter is **one line pass** over the body carrying two state flags (inside a fenced block, inside an HTML comment) and returns **pre-wrapped** `mdLine{text, kind}` values: wrapping lives *inside* it because hanging indents need the block structure, and because styling must land on whole finished lines — `wrapLine` counts runes and knows nothing about ANSI. `renderChangelogBlock` is then a trivial consumer: every line is stepped in by `changelogIndent` (plain spaces outside the styling, so no escape sequence is split) so the notes read as belonging to the heading above them; `mdHeading` → `Styles.EmphasisBold`, `mdCode` → `Text` on the `Surface` plate **padded to the block's full width** (a background stopping at the last glyph is a ragged highlight, and a command in a release note is something to run rather than more prose; the plate alone does the raising — an `Emphasis` foreground here made every code line the loudest thing on the card), everything else → `Styles.Text`, and a blank line written as a bare `"\n"` (styling an empty string only emits an empty escape pair). It no longer prints the release URL itself — that link moved to the heading, which is also the line `buildCard` registers as clickable. There are **three kinds**, and each exists because the card renders it differently — heading, code, body; a fourth (list, quote) still has no reader, so what makes those blocks special lives inside the converter, in their indent and markers. `mdCode` covers the blank rows inside a fence too, or the plate would be punched through by them. Rules worth remembering: CRLF is normalized **first** (GitHub bodies routinely arrive CRLF from the web form; a `\r`-suffixed closing fence would fail to match and swallow the rest of the body as code); a heading and a list marker both **require the space** after the marker, so `#123 fixed …` stays an issue ref and `**Breaking**` stays a paragraph — the old `TrimLeft(line, "#")`/`Trim(line, "*_")` bug class cannot come back; `---`/`***`/`___` **and** a `===` run are rule lines that collapse to a blank and are **never** list items (`---` is the stock separator above "Full Changelog", and a bare `===` left in place put a literal row of equals signs in the card — full setext support is deliberately out: the text and the layout stay right, only the emphasis is lost); the code fence deliberately accepts **any** indent, unlike CommonMark's 3-space limit, because 4+ spaces there means an indented code block, which this converter does not implement — the strict form only mis-read a fence nested under a list item, leaking the language tag out as a body line reading `go`; a fence **closes on its own marker only** (`mdFenceOpenRe` captures the run, `rcFenceCloses` matches it — the same rule and the same helper as the README pass), so a `~~~` block wrapping ``` ``` ``` samples stays one block instead of ending at the inner fence and swallowing the tail after the real closer; a heading gets a blank row on **both** sides even when the source wrote none — GoReleaser bodies pack headings tight against their lists — with both inserts riding `emitBlank`'s collapse, so an authored blank never doubles and a heading that converted to nothing still yields exactly one blank; a heading and a paragraph share **`mdEmitInline`**, so a line whose markup collapses to nothing (a badge-only line, a bare `
`, a heading whose only content was an image) can only ever become a blank through `emitBlank` — an empty line still tagged `mdHeading` would sit outside the collapse and hand the next reader of `kind` a line that is not a heading; bullets normalize to `•` (U+2022 — East-Asian **Ambiguous**, the same accepted class as `⏺`/`↑`/`─`, and it never enters the wrap math, which is rune-based) with nesting clamped to 2 levels of 2 spaces and continuations hanging under the first text column; **inline code is masked before any rule runs** (`rcMaskSpans`, the README preprocessor's own mechanism — the spans come back with their delimiter runs cut and their body verbatim, so nothing can rewrite what the author wrote as code: `--output ` used to lose its argument to the HTML strip and emphasis fired across two adjacent spans), on what is left the order is load-bearing (images → links → **autolinks before the HTML-tag strip** → emphasis), the strip itself is **`rcHTMLTagRe`'s allowlist and not a generic `<…>` eater** (release prose carries `Vec` and ``, which are not markup), and the underscore emphasis pattern requires a non-word rune outside both delimiters or `update_cmd` would silently lose its underscore. Two masking gaps stay open and are documented side by side on `mdInline`: `mdCutComments` runs upstream in the line loop and still cuts an HTML comment written inside a span, and the per-line invocation cannot mask a span split across two source lines. The mask sentinel is NUL, so `markdownToLines` drops NUL from the body on entry — the README path is sanitized by `cleanTerminalOutput` first, this one takes the API body raw and a forged placeholder would otherwise be substituted. An empty conversion result falls through to the existing `no release notes available.` branch, which therefore now also covers a **non-empty** body the converter consumed whole (all comments, all separators). The conversion is memoized in **`m.changelogRender`** (`changelogRenderCache` in render.go, one entry keyed by `(body, width)` — `markdownToLines`'s only two inputs), the same shape as `readmeRenderCache` and for the same reason: the whole card is rebuilt on **every spinner frame** (the `spinner.TickMsg` handler, ~12/s for as long as a `[r]` refresh or an update runs), so a large release body would go through the converter's regexes twelve times a second to animate one glyph — measured at 3.3 ms and 1 MB of garbage per pass on a 49 KB body against 15 ns on a hit. It hangs off `Model` as a **pointer**, because the card renderers are value receivers and could not fill a plain field, and its method **tolerates a nil receiver** (a cache-less but correct mode) since most tests build `Model{}` literals and never call `New()`. -- **Card meta block**: everything the user has told keepkit about the tool, plus what the repo says it is written in. It is **two blocks shaped by what they are**. The language stack is a *distribution*, so it gets the card's one picture: `languages · ● go 99% · ● shell 1%` (the full word heading its own list, separated from the first language by the same middot that separates two languages — it is the head of that list, not a caption over it), then **`renderLangBand`** under it, a row of exactly `inner` cells holding those same shares in **GitHub's own per-language colors**. Only the `●` carries color and the name+share read at one brightness — they are one fact, and five colored names would be a rainbow. The band's shares are normalized over the languages *actually listed* (`languagePercents` keeps the top five), because a band summed over the repo's total would leave a gap standing for languages the card never named; cells are handed out one per language first and the remainder by largest fractional part, so nothing listed is missing from the band and the row is exactly `inner` cells rather than `inner`±rounding. Its glyph is `▬` (U+25AC), **width-stable** like the gauge's `▮` — `█` is East-Asian Ambiguous and would double the band's footprint (`TestLanguageBandGlyphWidth`); the `●` beside a name deliberately is *not* in that class, since it rides in wrapped text where an over-wide measurement can only wrap a row early. The colors live in **`ui.LanguageColor`** (`internal/ui/lang.go`) and are the one thing in the app a theme switch must **not** repaint: `Theme` is keepkit's vocabulary of meanings, these are linguist's brand marks, and the whole value of a cyan dot beside `go` is that it is the cyan the reader has seen on every repo page. An unknown language falls back to `Dim` — unrecognized rather than wrong. Accepted caveat: linguist picks against a white page, so `lua`/`powershell`/`json` are near-black dots on a dark terminal. The band closes with **one blank row**: it runs the full width of the panel, so without one the line under it reads as a caption hanging off the bar rather than as the next thing (written bare — styling an empty string only emits an empty escape pair, the rule the changelog's blank rows follow). Below it, `status`, `tags` and `note` share **one wrapped line** — three short values that each took a whole row spent three rows on a sentence's worth of text, and the band above already gives the block its structure. Wrapping there is **by whole cells**: a cell carries ANSI, so it must never be cut mid-escape, and half a `note …` reads as noise anyway (the language block, which is not one line, is emitted above the loop rather than fed to it). Labels are `Dim`, values `Text`, and an **empty value does not get a line of its own**: it gets the key that fills it (`tags — # add`, `note — e write`), which is the only thing an empty field is good for. In edit mode the input replaces the value **in place**, so the card never jumps while it is being typed into. A value too wide for the panel is cut **before** it is styled and marked with `…` — cutting afterwards would land inside an escape sequence, which the viewport re-emits to the terminal verbatim. The card is not where a long note is read in full; the editor shows all of it. `TestMetaLineShape` pins the order and the band's exact width; `TestMetaLineFieldsWrapByWholeCells` pins that a narrow panel breaks the field line between two cells and never inside one. +- **Card meta block**: everything the user has told keepkit about the tool, plus what the repo says it is written in. It is **two blocks shaped by what they are**. The language stack is a *distribution*, so it gets the card's one picture: `languages · ● go 99% · ● shell 1%` (the full word heading its own list, separated from the first language by the same middot that separates two languages — it is the head of that list, not a caption over it), then **`renderLangBand`** under it, a row of exactly `inner` cells holding those same shares in **GitHub's own per-language colors**. Only the `●` carries color and the name+share read at one brightness — they are one fact, and five colored names would be a rainbow. The band's shares are normalized over the languages *actually listed* (`languagePercents` keeps the top five), because a band summed over the repo's total would leave a gap standing for languages the card never named; cells are handed out one per language first and the remainder by largest fractional part, so nothing listed is missing from the band and the row is exactly `inner` cells rather than `inner`±rounding. Its glyph is `▬` (U+25AC), **width-stable** like the gauge's `▮` — `█` is East-Asian Ambiguous and would double the band's footprint (`TestLanguageBandGlyphWidth`); the `●` beside a name deliberately is *not* in that class, since it rides in wrapped text where an over-wide measurement can only wrap a row early. The colors live in **`ui.LanguageColor`** (`internal/ui/lang.go`), one of the **two** palettes a theme switch must **not** repaint (`HeadingColors`/`ChromaColors` is the other — see the `internal/ui` row above): `Theme` is keepkit's vocabulary of meanings, these are linguist's brand marks, and the whole value of a cyan dot beside `go` is that it is the cyan the reader has seen on every repo page. An unknown language falls back to `Dim` — unrecognized rather than wrong. Accepted caveat: linguist picks against a white page, so `lua`/`powershell`/`json` are near-black dots on a dark terminal. The band closes with **one blank row**: it runs the full width of the panel, so without one the line under it reads as a caption hanging off the bar rather than as the next thing (written bare — styling an empty string only emits an empty escape pair, the rule the changelog's blank rows follow). Below it, `status`, `tags` and `note` share **one wrapped line** — three short values that each took a whole row spent three rows on a sentence's worth of text, and the band above already gives the block its structure. Wrapping there is **by whole cells**: a cell carries ANSI, so it must never be cut mid-escape, and half a `note …` reads as noise anyway (the language block, which is not one line, is emitted above the loop rather than fed to it). Labels are `Dim`, values `Text`, and an **empty value does not get a line of its own**: it gets the key that fills it (`tags — # add`, `note — e write`), which is the only thing an empty field is good for. In edit mode the input replaces the value **in place**, so the card never jumps while it is being typed into. A value too wide for the panel is cut **before** it is styled and marked with `…` — cutting afterwards would land inside an escape sequence, which the viewport re-emits to the terminal verbatim. The card is not where a long note is read in full; the editor shows all of it. `TestMetaLineShape` pins the order and the band's exact width; `TestMetaLineFieldsWrapByWholeCells` pins that a narrow panel breaks the field line between two cells and never inside one. - **Card metrics strip**: `metricsStrip` is what the card's `[info]` section became — installed / latest / maintenance / stars laid out as **captioned columns on the `Theme.Surface` background** instead of six `label: value` lines whose labels ran down the left edge and pushed every value into a column of its own. Captions are uppercase (`INSTALLED`, `LATEST`, `MAINTENANCE`, `STARS`) because a terminal has no smaller type size to demote a label with, and the values are what the eye should land on. **`installed:` still has four states** and the two version-less ones stay distinct: a resolved version in `Text`, `✓ present` in `Ok` (a tool that is installed but won't name its version — a ratatui app that ignores `--version` — is a working install and reads affirmative), `✕ missing` in `Danger` (the one thing on the card that is actually wrong), and `detecting…` in `Dim` while the local probe is in flight. Both version-less values are **one word**, because the caption above them already says INSTALLED and the sentences they used to be were the only values in the strip too wide for a baseline-width column. `latest:` renders in `SignalBold` with a trailing ` ↑` when `hasUpdate`, otherwise `Text`, and the release date is a **second line under it** rather than a suffix on it. **The values sit at `Text`, one step below the tool's name and one above their own captions**: a terminal has a single font size — the grid belongs to the terminal, not to the app — so the three sizes the design draws in the card's head are three steps of weight and brightness here, and there is room for exactly one peak. Spending the brightest role on four measurements left the name nothing to be the peak of. The two exceptions carry meaning rather than rank: a pending release is one of the screen's three "act on this" points, and a broken install is its one alarm. The header block is separated from the strip by **one** plain blank row — the strip's own padding row is filled with the plate colour and already reads as air, so a second plain row on top of it reads as a hole. Both versions go through **`version.DisplayVersion`**, which puts a `v` in front of a bare version number: a tool's `--version` prints `1.10.2` where its release is tagged `v1.10.2`, and the two used to sit one letter apart for the same binary. It edits nothing else — `canonSemver` decides only *whether* the string is a version number (`nightly`, `cli-2.0` pass through untouched), and its own output is deliberately not what is displayed, since it drops zero-padding, a 4th segment and build metadata. The `\uf412` glyph the two version lines used to carry is gone with the labels that needed disambiguating: a caption says what the number is. A metric with nothing to report is **left out entirely**, so a tool with no GitHub ref shows a one-cell strip rather than three empty captions, and an empty strip is no strip at all. The grid **re-flows rather than truncates**, and it is sized by the widest *value* as well as the widest caption (`need`): sizing on captions alone cut `✕ not installed` to `✕ not insta` at the 80×24 baseline — the default terminal, and the exact state a tracker is opened in. The count is solved against the row the strip actually draws — a blank cell at each end plus a rule between every pair — by counting up while `2 + cols*need + (cols-1)*3` still fits, **not** by dividing `inner` by `need`: that division ignores the overhead, so a 40-cell panel was told it had three columns and then handed each of them 10 cells, cutting `MAINTENANCE` to `MAINTENANC` — the caption the floor exists to protect. At the 80-column baseline the card panel is 27 cells and even two columns need 29, so the grid stands on one; a value longer than any caption costs a column rather than its own legibility. Below `metricStripMinWidth` (`metricMinCol` plus the row's two blank edge cells — measuring against `metricMinCol` alone was two cells short, and a 12-cell panel drew a single 10-cell column) the strip stands down completely, which only a hand-built model reaches since the panel has a 30-cell minimum. **Every row is exactly `inner` cells** — a short row would break the fill into a ragged edge — and every segment carries the background itself for the reason the selected list row does. **Each cell is centered in its column** (odd slack to the right, so a caption and its value can differ by at most one cell in where they start): a caption and the value under it are one measurement, and flush-left hangs them off a rule that is nowhere near either. `TestMetricsStripLayout` pins the width, the caption-over-value reading (via `metricValue`, which identifies a column by the `│` rules around it rather than by the caption's start offset — centered caption and value deliberately do not begin at the same cell), the centering and the re-flow; `TestMetricsStripOmitsUnknowns` pins the omission. - **Panel titles**: all three panels inset a title into their top border (`┌─ ▸ [1] tools 27 3↑ ─…─┐`) via the shared `insetPanelTitle` — an ANSI-safe splice over the already-rendered frame (`ui`'s `truncateVisible` is unexported; the helper repaints the border runs from their `stripANSI` text and drops the title in **already styled**). That is what lets one title carry several colors, which the `[1]` counts are the point of: the tracked count in `Dim`, the update count in `Signal` — the tracker's whole reason to exist, so it is the one thing in a panel title that gets the signal color, and it is absent when nothing is behind. A `panelTitle` carries **both** a plain and a styled form: the plain one is what the border arithmetic measures, because escape sequences are not cells, and a title that does not fit is dropped whole (a chopped title reads worse than none). **Focus is marked twice** — by the accent color and by a `▸` prefix — so it survives a monochrome terminal and a reader who cannot separate the two panel colors; `TestPanelTitleFollowsFocus` pins both signals. The titles are lowercase (`[1] tools`, `[2] brief`, `[3] readme` / `[3] help` / `[3] man`, overridden by `[3] update` while a live log shows and by `[3] update finished` / `[3] update failed` once it ended) and double as the documentation for the digit focus hotkeys, so the status bar carries no digit hints. `[3]` additionally names **the two sources it is not showing** (`· h help · m man`) in the border color: those keys switch what the panel *is*, which is a property of the panel rather than an action on its content — and they are dropped while the update log owns it, since none of the three modes is what is on screen. All title characters are single-width and non-East-Asian-Ambiguous except `▸` (U+25B8, Ambiguous like the list markers, and measured with the plain form either way), keeping the border width math stable. - **Panel footers**: each panel reserves the last `panelFooterRows` (2) of its content height for a **blank** spacer plus a footer line. `[1]` carried a border-colored rule there for a while and it only made the footer read as a fourth section of the list rather than as the frame's own caption. Cells are joined by a **dim** ` · ` — the same painted middot the card's language list uses, since an unpainted one renders at the terminal's default brightness, louder than the hint labels it is separating (`TestPanelFooterSeparatorIsDim`). `[1]` carries `/ filter · enter run · space group` — `enter` is a `[1]` action, not a global one (see **Status bar**), and the three are ordered most-important-first because cells drop from the right and on a narrow list "run" outranks "group"; a right cell that is absent now reserves **nothing**, gap included, which is what had been dropping `[1]`'s last cell one step earlier than the width required. `[2]` carries its own actions led by the contextual `enter update to ` when a release is pending (and deliberately **not** `e note` / `# tags`: both are already offered in the meta line beside the values they edit, and a footer repeating them spends the row on saying it twice), `[3]` the source name, the `page/pages` position, the entry-cursor hints while there is an index to walk, the zoom toggle `z zoom` **last** (least actionable of the four — where you are in the text and how to walk it both outrank a width preference — and dropped entirely while an update log owns the panel, exactly as the title drops the source hints), and `ctrl+d/u page` pinned right. Measured shed geometry for that last cell: it survives from ~114 columns with no entry index to walk and from ~150 once there is one, since the index adds a `j/k navigate` cell. **The split is the index, not the source**: `--help` measures at ~150 because it has one, and a `man` page without one sheds at ~105 like the readme — so a per-mode number is the wrong shape to remember, and a test that fixes a width per mode is asserting about its fixture's cache rather than about the mode. Between ~82 (where `z` starts doing something) and whichever of those two widths applies, the key works unadvertised here, which is what the `[?]` overlay, the one surface that never sheds, is for. Cells are dropped from the right until they fit, exactly like the status bar and for the same reason: a footer that wrapped would push the panel one row past its height, and lipgloss answers that by scrolling the top border off the alt screen. **`calcListHeight()` is the single definition** of the viewport height inside a panel (`calcVpHeight() - footerRows()`), used both by `applyLayout` (which sizes the viewports) and by the renderers that stack viewport + footer back to the full height — two copies of that arithmetic would drift and cost a row. On a terminal too short to spare them (`calcVpHeight() < 6`) `footerRows()` is 0 and the panels are content only: two of six rows is a third of the panel, and a footer is a reminder while the content is the point. diff --git a/docs/design/readme-pipeline.md b/docs/design/readme-pipeline.md index b6241db..74b7ff3 100644 --- a/docs/design/readme-pipeline.md +++ b/docs/design/readme-pipeline.md @@ -8,10 +8,10 @@ is described in [`updating.md`](updating.md). - **Panel `[3]` modes (`helpMode`)**: three sources — `helpModeReadme = 2` (the **default** set in `New()`), `helpModeHelp = 0`, `helpModeMan = 1`. `helpMode` is a sticky global field, not per tool: `[R]`, `[H]` and `[M]` switch it, all three from `focusBrief || focusHelp` — the gate `z` (the panel-width toggle, `toggleZoom` in model.go) shares, being the fourth key that changes what `[3]` is; it changes no source, so it never touches `helpMode`, but it does change `helpWrapWidth()`, which puts the README through this whole pipeline again (the width is part of `readmeRenderCache`'s key) and resets the entry spotlight — the same cost a width resize already has. The three are **capitals as a set** so that none of them collides with a lowercase verb — `r` is `[2]`'s refresh and `m` the global rename — and so that the trio is uniform: readme used to be lowercase `r` and `focusHelp`-only, which meant the `[3]` title advertised `r readme` while pressing it in `[2]` silently spent three requests on a refresh instead. All three go through the shared **`switchHelpMode(mode)`** (model.go), which sets the mode, dismisses a *completed* update log (a tool's via `updateLogFor`, keepkit's own via `dismissSelfLog()` — the latter ahead of the `selectedMeta` guard), calls `setHelpContent()` + `GotoTop()` and returns the fetch command for the mode's missing source (README via `needsReadme`, `--help`/`man` via the `helpCache` miss + `helpLoadingFor`); focus stays with the caller, because all three also fire from `[2]` and move focus to `[3]` with them. A **live** update log keeps `[3]` in every path (the log branch sits ahead of the readme branch *and* the `No tool selected` guard in `renderHelpContent`, `setHelpContent` gates on `showsUpdateLog()`, and the readme case in `autoFetchCmdsForSelected` sits after that same case). **`m.helpCache` is a `map[string][2]string` whose values are indexed by `helpMode`, so mode 2 panics on every index site** — README content lives in `m.readmeData` instead and each index site (`rawHelpText`, `renderHelpContent`, `autoFetchCmdsForSelected`) carries a readme early-return/branch *before* the array read; `helpOutputMsg` indexes `msg.mode`, which only ever carries help/man. Rendering: `renderReadme` (readme.go) is a three-stage pipeline — `cleanTerminalOutput` (the same sanitizer every probe capture gets) → **`cleanReadmeMarkdown`** (readme_clean.go, the house-style preprocessor) → glamour with **`WithStyles(keepkitStyle(t, dark))`** (readme_style.go), `WithWordWrap(helpWrapWidth())`, `WithColorProfile(lipgloss.ColorProfile())` (glamour hardcodes TrueColor and would ignore `NO_COLOR`/dumb terms) and `WithInlineTableLinks(true)` (a table link in its cell, not a numbered footnote under the table). Dark/light is still resolved **once at construction** into `m.darkBG` via lipgloss's cached `HasDarkBackground()`, because `glamour.WithAutoStyle()` probes the terminal with a termenv OSC query that reads stdin and races Bubble Tea's input reader; what changed is that the answer now picks a *StyleConfig* rather than a style *name*, so `readmeStyleName` is gone and `renderReadme` branches on `testReadmeStyle != ""` (→ `WithStandardStyle`) directly — the seam's only job is to make the constructor fail. A glamour failure falls back to the **preprocessed** text (not the merely sanitized text), or a failed render would be the one path that still shows badge and href noise; never an empty panel. An input that cleans down to whitespace returns `""` early, which is what routes a badge-only README to the placeholder below. - **The preprocessor (`cleanReadmeMarkdown`)** exists because a README is written for a browser: badges, logos, `` wrappers, hrefs nobody can click in a TTY, emoji a terminal font renders as tofu. It is pure and rests on one inviolable rule — **code is never rewritten**, since a fenced block and an inline span are exactly how a README *shows* the markup these rules delete. So `rcSegments` splits the input into protected fenced blocks (opener = 3+ backticks or tildes at any indent — CommonMark's 3-space limit only ever mis-read a fence nested under a list item; closer = the **same character**, a run **at least as long**, nothing but whitespace after, which keeps a ```` ``` ````-wrapping fence one block; an **unterminated fence protects to EOF**, because `version.getReadme` truncates at `readmeMaxBytes` and the cut can land mid-fence) and the cleanable runs between them, and inline spans are **masked** inside a cleanable segment (`rcMaskSpans`, a NUL-bracketed placeholder — NUL is the sentinel precisely because `cleanTerminalOutput` ran first and drops every control character). Masking rather than segmenting is deliberate: the block-level rules (a multi-line HTML comment, a `` body) need the segment to stay one string, and a span that *contains* `` would gate the unwrapping and eat the very brackets the gate protects. Standalone `[label]: url` definition lines are dropped as the pure metadata they now are, under **two** guards, because deleting a line of someone's README is the most destructive thing this pass does: the destination must be a single token (or ``) with at most a quoted title, and the line **may not interrupt a paragraph** (CommonMark forbids that anyway) — without them `[1]: first item explained` and a `[note]: this matters` sitting mid-paragraph both silently vanished. *HTML*: comments whole; ``/`