Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/design/readme-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ is described in [`updating.md`](updating.md).
- **Removal rules**, all package-level regexes (the `helpTokenRe` idiom — this runs synchronously inside `Update()` on up to 512 KiB). *Images* go whole in every form (`![alt](url)`, reference `![alt][ref]`/`![alt][]`, and the shortcut `![alt]`), which needs no badge-vs-logo heuristic for the simple reason that **no image can ever render in a TTY**. A *linked* badge collapses for free: image removal turns `[![alt](img)](target)` into `[](target)` and the link unwrap then empties it, which is why images run first. *Links* are unwrapped to their text — panel `[3]` links are not clickable, so the href half is pure noise — and **autolinks and bare URLs are left alone**, since there the URL *is* the content (this is also why the style-level route was rejected: glamour renders both through the same `Link` primitive, so blanking it deletes them). The inline destination pattern is shared with the card's converter as **`mdDest`**, which allows **one level of nested parentheses**: a flat `[^)]*` stopped at the inner `)` of a shields.io badge (`…/badge/a-(b)-blue.svg`) or a Wikipedia link (`…/wiki/Foo_(bar)`) and left the rest of the URL on screen as text — exactly the noise the pass exists to remove. Deeper nesting needs recursion, which RE2 does not have. **Both reference forms are gated on a declared label** — `[text][ref]` and the shortcut `[label]` alike, collected **document-wide** because definitions collect at the bottom of a README while their uses sit at the top and a fence between them puts the two in different segments. Ungated, the rule ate a task list's `[x]`, a prose `[experimental]`, and — the reference form's own case — `arr[i][j]` in prose, which came out as `arri`. The label set is built **after** the HTML rules run, or a definition commented out with `<!-- -->` 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 `<angled>`) 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; `<picture>`/`<video>`/`<audio>`/`<svg>`/`<script>`/`<style>` whole **including bodies** (one regex per name — RE2 has no backreference); every other tag stripped keeping its inner text (`<kbd>Ctrl</kbd>` → `Ctrl`, a `<details>`/`<summary>` block keeps its content). The tag name is matched against a **fixed allowlist** (`rcHTMLNames`), not a generic identifier shape — a shape-based pattern cannot tell `<kbd>` from `Vec<String>`, and it is the allowlist that lets `<https://…>`, `<user@host>`, `Vec<String>` and `a < b` through; an unknown name is left as written, the honest degradation, and no general sanitizer (bluemonday) is pulled in. Two details there are load-bearing: the trailing **`\b`** after the name is what makes the ~80-branch alternation order-independent (Go's regexp is leftmost-*first*, so without it `a` would claim `<abbr>` and leave `br>` behind), and the attribute part (**`rcTagAttrs`**) matches quoted values whole, since a flat `[^<>]*` ends the tag at the first `>` — including one inside a value, which left `<img alt="a > b" src="x.png">` rendering as ` b" src="x.png">`. *Emoji*: the pictographic SMP blocks U+1F000–U+1FAFF plus the joiners that would be stranded without them (VS16 U+FE0F, ZWJ U+200D, the combining enclosing keycap U+20E3, which is what makes `1️⃣` leave a bare `1`); BMP symbols a terminal font does carry (`✓ ★ →`, and `✨`, `⭐`, `❗`) are kept, and `✅→✓` / `❌→✗` / `☑→✓` are translated rather than dropped, because a feature table's meaning lives in that column. *Shortcodes* (`:name:`) go only when `name` is in **`definition.Github()`** — goldmark-emoji's dictionary, the same one glamour gets, promoted to a direct dependency for this and adding nothing to `go.sum` — so `:30:` inside `12:30:45` and an unknown `:foo:` survive.
- **Post-removal tidy-up** is per line and **gated on the line having actually changed**: interior double spaces collapse, the tail is trimmed, and a line whose content removal emptied is dropped (`rcLineContent` strips leading block markers first, so `## 🚀` — which cleans to `##`, a heading with no content glamour would paint as a styled blank row — is recognized as empty). The gate is what keeps an untouched `- - -` thematic break from being mistaken for an emptied bullet. A **hard line break survives explicitly**, not by the gate: `rcTidyLine` carries two trailing spaces over when the original had them, because that is markup and a removal inside the line has no business retiring it. The **indent comes from the original line**, or a leading `🚀 ` would leave an indent the author never wrote — four of them and markdown reads the line as an indented code block. A dropped line takes an **orphaned setext underline** with it (the `=` form only; a `-` run is equally a thematic break and renders as a rule either way), or removal of the title left a visible row of equals signs behind. Runs of blank lines then fold to one (in markdown one break and five are the same break), which is what makes a ten-badge header vanish instead of leaving ten empty rows.
- **`rcLineContent`'s loop slices, it does not `ReplaceAllString`** — and that is a correctness fix, not a micro-optimization. The pattern is `^`-anchored, so a replace could only ever rewrite the head, yet it copied the whole remaining line every pass: one line of `🚀 ` followed by 262144 `- ` markers (a 512 KiB README, exactly `readmeMaxBytes`) took **8.4 seconds** inside a synchronous `Update()`, i.e. the entire TUI — render loop and keyboard — frozen on untrusted remote input. `TestCleanReadmeMarkdownPathologicalInputIsFast` is the guard; the same input now finishes in single-digit milliseconds. Nothing else in the pass is superlinear: `rcSpanEnd`'s failing forward scan can happen at most once per *distinct* backtick-run length (any later run of the same length pairs with it and consumes the interval), which bounds the total at ~106 ms on a purpose-built worst case, and the regex passes measure 13–51 ms on 512 KiB. A realistic 512 KiB README preprocesses in ~30 ms against glamour's own ~1.2 s, and the whole result is memoized by `readmeRenderCache`.
- **The theme (`keepkitStyle(t ui.Theme, dark)`)** **clones** `styles.DarkStyleConfig`/`LightStyleConfig` and overrides accents rather than building from scratch: `StyleConfig` has dozens of fields (chroma tokens, table separators, list indents) and inheriting them means a glamour upgrade that adds one cannot leave the panel with a hole in it. It takes the `Theme` rather than reading colors from a package, which is what puts the readme panel under the same one-value theme switch as everything else (the panel re-renders on the existing `switchHelpMode` path, and the theme is part of `readmeRenderCache`'s key). **The globals hold pointers and `styles.DefaultStyles` aliases the same structs**, so every override assigns a **fresh pointer** (`ptrTo`) — writing through a cloned one would restyle glamour for the whole process, which `TestKeepkitStyleLeavesGlobalsUntouched` guards with a JSON snapshot (a plain struct copy would alias the very pointers the bug corrupts and pass on it). Overrides: **H1** `Accent` bold with **no background plate** (`BackgroundColor = nil`) — the standard style's bright plate was the loudest thing on the screen and the main reason a rendered README read as pasted in from another app; its padding spaces go with it. **H2** `Emphasis` bold, the panel's section heading, matching the card's own. **Every heading loses its reprinted `## ` prefix**: that is source, not text, and weight already says the line is a heading. **Document margin 0** (a fresh `*uint`): glamour's own margin eats 2–4 columns of an already narrow panel, and the panel frame is the breathing room. The rest is **dark-only** — `Heading` (the base every `Hn` cascades onto, so it is the H3–H5 rule) and `H6` in `Text` bold, `Strong` in `Emphasis` (the bold lead of a feature line is the readme's own emphasis peak), **`Document`** — the base every block cascades onto, so it is the panel's body-text rule — in **`Text`**, the very role the card's changelog notes render in: the two sitting side by side at slightly different grays was the last thing that read as "this panel came from another app". **Inline `Code` in `Emphasis` on the `Surface` plate**, exactly like a code span in that changelog: it was `Danger` with no plate, and that was wrong twice — red is the card's one alarm color and a README spends it a dozen times a screen, and the dark red was the *least* legible thing in the panel rather than the most. The plate is what makes it noticeable, the brightest text role is what makes it readable, and the prefix/suffix spaces the standard style already puts around inline code become the plate's padding for free. **`CodeBlock` on the same `Surface` background** (an install command the user is about to run) — and that one has **two live render paths**, which is what the override missed at first: glamour sends a fence through `rules.Chroma` whenever `ColorProfile != termenv.Ascii`, i.e. in every real session, and only falls back to the `StyleBlock` fields for `NO_COLOR`/dumb terminals — which is also what this package's TTY-less tests exercise. So the `StyleBlock` override stays *and* `CodeBlock.Chroma` is repainted, **bounded to two entries**: `Background` carries the plate and `Text` gets the same background because chroma's formatter emits one per token (without it the plate is punched through wherever a token falls back to `Text`); every other token entry is inherited unchanged, so the syntax accents stay the standard config's and no per-token judgement call is made. The clone is a fresh pointer for `ptrTo`'s reason. One caveat: glamour registers the built chroma style under the **process-global, one-shot name `"charm"`** and skips the registration when the name is taken, so the first render in a process wins the slot — which is why the assertion is struct-level (`TestKeepkitStyleRepaintsChroma`) and why a mid-session theme switch could not repaint a fence anyway, consistent with `m.darkBG` being resolved once at construction. The repaint is **dark-only**, like `LinkText` in `Link`, `HorizontalRule` in `Border` and `BlockQuote` in `Dim` — those tints are chosen against a dark panel and are unreadable on white, where the standard light colors stay (and where `CodeBlock.Chroma` therefore still aliases the global). **One part of the redesign is deliberately missing**: a section heading is meant to carry a border-colored rule out to the panel edge, and `StyleConfig` cannot express that — `Prefix`/`Suffix` are inline and `HorizontalRule` is a fixed string, so the only way to fake it is to inject a `---` the author never wrote into their README. Weight and `Emphasis` carry the heading on their own instead. The theme is covered at the **struct level**, not on rendered output: tests have no TTY, so `lipgloss.ColorProfile()` is Ascii and glamour strips every color it would emit.
- **The theme (`keepkitStyle(t ui.Theme, dark)`)** **clones** `styles.DarkStyleConfig`/`LightStyleConfig` and overrides accents rather than building from scratch: `StyleConfig` has dozens of fields (chroma tokens, table separators, list indents) and inheriting them means a glamour upgrade that adds one cannot leave the panel with a hole in it. It takes the `Theme` rather than reading colors from a package, which is what puts the readme panel under the same one-value theme switch as everything else (the panel re-renders on the existing `switchHelpMode` path, and the theme is part of `readmeRenderCache`'s key). **The globals hold pointers and `styles.DefaultStyles` aliases the same structs**, so every override assigns a **fresh pointer** (`ptrTo`) — writing through a cloned one would restyle glamour for the whole process, which `TestKeepkitStyleLeavesGlobalsUntouched` guards with a JSON snapshot (a plain struct copy would alias the very pointers the bug corrupts and pass on it). Overrides: **H1** `Accent` bold with **no background plate** (`BackgroundColor = nil`) — the standard style's bright plate was the loudest thing on the screen and the main reason a rendered README read as pasted in from another app; its padding spaces go with it. **H2** `Emphasis` bold, the panel's section heading, matching the card's own. **Every heading loses its reprinted `## ` prefix**: that is source, not text, and weight already says the line is a heading. **Document margin 0** (a fresh `*uint`): glamour's own margin eats 2–4 columns of an already narrow panel, and the panel frame is the breathing room. The rest is **dark-only** — `Heading` (the base every `Hn` cascades onto, so it is the H3–H5 rule) and `H6` in `Text` bold, `Strong` in `Emphasis` (the bold lead of a feature line is the readme's own emphasis peak), **`Document`** — the base every block cascades onto, so it is the panel's body-text rule — in **`Text`**, the very role the card's changelog notes render in: the two sitting side by side at slightly different grays was the last thing that read as "this panel came from another app". **Inline `Code` in `Text` on the `Surface` plate**, exactly like a code span in that changelog: it was `Danger` with no plate, and that was wrong twice — red is the card's one alarm color and a README spends it a dozen times a screen, and the dark red was the *least* legible thing in the panel rather than the most. It then spent a while in `Emphasis`, and that overshot the other way: a README spends dozens of spans per screen while `Emphasis` is meant to peak a few times per frame, so every code span was the loudest thing in the panel. The plate alone does the raising — the text stays at body brightness — and the prefix/suffix spaces the standard style already puts around inline code become the plate's padding for free. **`CodeBlock` on the same `Surface` background** (an install command the user is about to run) — and that one has **two live render paths**, which is what the override missed at first: glamour sends a fence through `rules.Chroma` whenever `ColorProfile != termenv.Ascii`, i.e. in every real session, and only falls back to the `StyleBlock` fields for `NO_COLOR`/dumb terminals — which is also what this package's TTY-less tests exercise. So the `StyleBlock` override stays *and* `CodeBlock.Chroma` is repainted, **bounded to two entries**: `Background` carries the plate and `Text` gets the same background because chroma's formatter emits one per token (without it the plate is punched through wherever a token falls back to `Text`); every other token entry is inherited unchanged, so the syntax accents stay the standard config's and no per-token judgement call is made. The clone is a fresh pointer for `ptrTo`'s reason. One caveat: glamour registers the built chroma style under the **process-global, one-shot name `"charm"`** and skips the registration when the name is taken, so the first render in a process wins the slot — which is why the assertion is struct-level (`TestKeepkitStyleRepaintsChroma`) and why a mid-session theme switch could not repaint a fence anyway, consistent with `m.darkBG` being resolved once at construction. The repaint is **dark-only**, like `LinkText` in `Link`, `HorizontalRule` in `Border` and `BlockQuote` in `Dim` — those tints are chosen against a dark panel and are unreadable on white, where the standard light colors stay (and where `CodeBlock.Chroma` therefore still aliases the global). **One part of the redesign is deliberately missing**: a section heading is meant to carry a border-colored rule out to the panel edge, and `StyleConfig` cannot express that — `Prefix`/`Suffix` are inline and `HorizontalRule` is a fixed string, so the only way to fake it is to inject a `---` the author never wrote into their README. Weight and `Emphasis` carry the heading on their own instead. The theme is covered at the **struct level**, not on rendered output: tests have no TTY, so `lipgloss.ColorProfile()` is Ascii and glamour strips every color it would emit.
16 changes: 9 additions & 7 deletions internal/model/readme_style.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@ func keepkitStyle(t ui.Theme, dark bool) ansi.StyleConfig {
// alarm color and a README spends it a dozen times a screen, and the dark red
// was the *least* legible thing in the panel rather than the most.
//
// It now reads exactly like a code span in the card's changelog — Emphasis on
// It now reads exactly like a code span in the card's changelog — Text on
// the Surface plate — which is the same rule stated once in two panels: a
// literal the reader could type is raised off the prose, not colored against
// it. The plate is what makes it noticeable; the brightest text role is what
// makes it readable. The prefix/suffix spaces the standard style already puts
// around inline code become the plate's padding for free.
cfg.Code.Color = ptrTo(string(t.Emphasis))
// it. The plate alone does the raising; the text stays at body brightness,
// because a spell in Emphasis made every code span the loudest thing in the
// panel (a README spends dozens per screen, and Emphasis is meant to peak a
// few times per frame). The prefix/suffix spaces the standard style already
// puts around inline code become the plate's padding for free.
cfg.Code.Color = ptrTo(string(t.Text))
cfg.Code.BackgroundColor = ptrTo(string(t.Surface))

// A code block is an install command the user is about to run: the one
Expand All @@ -113,7 +115,7 @@ func keepkitStyle(t ui.Theme, dark bool) ansi.StyleConfig {
// which is also what this package's TTY-less tests exercise. So the
// StyleBlock override below stays, and the Chroma repaint above it is what
// the user actually sees.
cfg.CodeBlock.Color = ptrTo(string(t.Emphasis))
cfg.CodeBlock.Color = ptrTo(string(t.Text))
cfg.CodeBlock.BackgroundColor = ptrTo(string(t.Surface))

// The repaint is deliberately bounded to two entries: Background carries the
Expand All @@ -136,7 +138,7 @@ func keepkitStyle(t ui.Theme, dark bool) ansi.StyleConfig {
if cfg.CodeBlock.Chroma != nil {
chromaCfg := *cfg.CodeBlock.Chroma
chromaCfg.Background.BackgroundColor = ptrTo(string(t.Surface))
chromaCfg.Text.Color = ptrTo(string(t.Emphasis))
chromaCfg.Text.Color = ptrTo(string(t.Text))
chromaCfg.Text.BackgroundColor = ptrTo(string(t.Surface))
cfg.CodeBlock.Chroma = &chromaCfg
}
Expand Down
Loading
Loading