feat(ui): theme-driven redesign of all three panels - #48
Merged
Conversation
Reworks the whole screen to the design handoff in docs/plans/design_handoff_keepkit_redesign, on top of the theming pre-work that handoff asks for first. Theming. internal/ui/theme.go introduces Theme — ten semantic color roles plus the gauge's dim track — and styles.go turns every package `var XxxStyle` into a field of Styles, built by the single NewStyles(Theme). The model holds one and every renderer reads it through m.sty(), so switching every color in keepkit is switching one value. The roles retire ColorCategory/ColorKey/ColorMuted: what they marked is now carried by weight, Emphasis and the new Surface background. glamour's readme theme and ui.PlaceOverlay take the theme too, so no surface is left behind on the old palette. [1] tools. Rows gain a right-aligned version column (signal + ↑ when behind, ok + • on keepkit's own row); the selected row is a Surface-filled block spanning the panel; group headers lead with their label; the grouped view opens with an updates section that cuts across the tags; the panel title carries the tracked and outdated counts. [2] brief. The six labelled "label: value" lines become a metrics strip — installed / latest / maintenance / stars as captioned columns on Surface, re-flowing to fewer columns on a narrow panel — plus a one-line meta row where an empty note or tag offers the key that fills it. The changelog gains a heading with the version transition and the release link. [3] readme. The preprocessor drops the leading H1, and the slogan under it when it repeats the repo description the card already prints. glamour headings lose their reprinted "## " markers; code blocks get the Surface plate. Status bar. One global key list in every focus instead of three per-focus ones; what is panel-local moved into that panel's footer. The API gauge is hidden until the quota is worth acting on and drops its key hint. Key hints lose their brackets — the accent already says "this is a key". Keymap, per the handoff: # edits tags, R renames, t/u/R are global, and enter is each panel's primary action (run in [1], update in [2]). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three documents described the pre-redesign UI: package-level styles, the card's label column, per-focus status bars, the u-updates-a-tool keymap and a three-column hotkeys overlay. Each claim re-verified against the code — including the mermaid package graph, which the ui -> loader edge already covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The API gauge is on screen whenever the quota is known again, and `L api` is back in the global key list: hiding the gauge at rest hid the only visible sign that keepkit has an API surface, and with it the [L] overlay. - `[1]` rows are indented one column past their section header and every section but the first opens with a blank row, so a group reads as a group. - `[2]` gets air between its blocks — two blank rows around the plate, one around every other block. - Languages read at one brightness and are separated by middots: a language and its share are one fact. - The changelog body is stepped in under its heading, and a fenced sample lands on the metrics strip's plate. markdownToLines gained the mdCode kind it needs for that — the third tag now has a reader. - `e note` / `# tags` leave the `[2]` footer: the meta line already offers them beside the values they edit. - The `[?]` overlay gets horizontal air (the row budget is fixed by the 80x24 background; the width budget had ~15 columns of slack). - Every hint's label steps down to Dim: the key is the only part of a hint the eye needs to find. Two of the ten are terminal-bound and unchanged: a TUI has one font size, so the card's tool name is bold Emphasis (its "larger" is not expressible) and the release date under LATEST is Dim, which is the same demotion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three are the same shape — a rule that was right for the code it was written next to and wrong for the data that actually reaches it. changelogHeading compared the installed and latest versions as raw strings while printing both through DisplayVersion, so a tool whose --version says "1.10.2" against a "v1.10.2" tag — up to date — got a "v1.10.2 → v1.10.2" arrow to nowhere on the one card with nothing to report. It is gated on hasUpdate now, the predicate every other site uses. The gap to the release link became a rule while the function was open: the handoff asks for one, and nothing else separates the notes from the meta line above now that the card has no section headers. metricsStrip sized its columns from the widest caption alone, so at the 80x24 baseline — the default terminal, and the exact state a tracker is opened in — "✕ not installed" rendered as "✕ not insta". The column now fits its widest value too, and the two version-less installed states become one word each (✓ present / ✕ missing): the caption above them already says INSTALLED, and they were the only values in the strip too wide for a baseline column. The meta line's language bar wrapped internally on a repo with several languages, which breaks the line's whole arithmetic: lipgloss.Width measures a multi-line cell by its widest line, not by the footprint it occupies, so the next cell could be spliced onto the middle of the bar — and the continuation lines arrived with no "lang" label to say what they were. It is cut to one line like every other over-wide value here, with a … saying a language was left out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list's group headers and every footer ran flush into the panel frame, which reads as having overflowed it — the reference keeps a blank column there. panelGutter is that column, at both ends: a header is " cli ─── ", a footer's cells start and end one cell in, and a tool row's marker moves into the header's own column so the two line up down the panel with the names one step in from them. The row's gutters are painted, so the selected row's fill still breaks out of them and spans the panel edge to edge — a highlight stopping at the gutter reads as a highlighted word rather than as a filled row. The border-colored rule above the [1] footer is gone with the panelFooter parameter that chose it: it made the footer read as a fourth section of the list instead of as the frame's own caption, and no other panel wanted one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. The metric values held the same Emphasis role as the tool's name above them, which left the name nothing to be the peak of; they step down to Text, one below the name and one above their own captions. The two exceptions stay, because they 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 gap under the header block goes back to one plain blank row. The strip's own padding row is filled with the plate colour and already reads as air, so the second plain row on top of it read as a hole rather than as breathing room. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both panels started their content against the title spliced into their top border — the vertical twin of the gutter the group headers already keep on their sides. In [1] the row is a real screen line, so it goes into the line maps as a non-selectable one and every tool shifts by one; syncToolsViewport now walks up over *every* non-selectable row above the selection rather than just one, so a section header and the blank above it are revealed together instead of the blank being left off screen. In [2] buildCard records its clickable lines while writing, so the row costs nothing there. The changelog heading had two blank rows above it where every other block in the card has one. Test-side, the list-row assertions go through a toolRows helper that drops the padding row, so they keep indexing tools from 0 and survive the next change to the panel's padding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The selection is the ⏺ marker and the name's weight again. The Surface fill turned every cursor move into a block sliding down the panel, which is far more motion than "this one is selected" is worth in a list that is scanned; Surface keeps the two blocks that are genuinely blocks — the metrics strip and the changelog's code lines. The [?] overlay showed the version raw, which is fine for a release and useless otherwise: `go run .` has nothing to report and says "dev", while any built working copy carries Go's pseudo-version — 44 characters of timestamp whose leading number is a release that does not exist yet. displayVersion recovers the two facts a reader wants from it, using Go's own rule for how that number was derived: v0.1.1-0.20260728221642-0b86a47f4f05 reads as v0.1.0+0b86a47, a pre-release base keeps its own tag, a repository with no tag says dev, and +dirty rides along. The + and the commit are what keep it from reading as the release itself, and the raw value stays in m.appVersion because the self-update gate reads it and has to keep seeing a working copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six refinements from reading the redesign on a real terminal, plus two defects the reading turned up. The card and the readme panel keep a blank column at both ends. Content that touches the border it lives in reads as having overflowed it, and on the right that column is also what keeps text off the scrollbar thumb. Two new single definitions own the budgets — cardWidth() and the amended helpWrapWidth() — and indentLines applies the indent last, to whole finished lines: plain spaces outside the styling can never split an escape sequence, they land in front of the metrics plate's background segments rather than inside them, and an indent cannot shift a line index, so buildCard's clickable-link map stays correct for free. Each cell in the metrics strip is centered in its column. A caption and the value under it are one measurement; flush-left hung them off a rule that was nowhere near either. The language stack is the card's one picture. "languages" heads its own list, separated from the first language by the same middot that separates two of them; only the dot carries color, in GitHub's own per-language hue, and a full-width band under the list holds the same shares. The band normalizes over the languages actually listed — a band summed over the repo's total would leave a gap standing for languages the card never named — hands out one cell per language before distributing the remainder by largest fractional part, and uses a width-stable glyph, since a full block measures two cells under RUNEWIDTH_EASTASIAN=1. A blank row closes the block: the band runs the full width, so without one the line under it read as a caption hanging off the bar. The colors live in ui.LanguageColor and are deliberately not Theme roles — Theme is keepkit's vocabulary of meanings, these are linguist's brand marks, and the value of a cyan dot beside "go" is that it is the cyan the reader has seen on every repo page. The status bar's right corner names the running build. That corner used to hold a third copy of the pending-updates count — the [1] title carries it beside the tracked count and every outdated row carries its own arrow — while the version had no surface outside an overlay. When a newer release is known and still uninstalled the version takes the signal role and the same arrow an outdated tool row carries, so the app announces its own update in the vocabulary it announces everyone else's. selfCompactCell drops the name it was repeating and keeps only the action; the two are governed by the same gate, so it can never appear without its subject beside it. Panel [3]'s body text moves to the Text role — the very one the card's changelog notes render in — and inline code reads exactly like a code span there, Emphasis on the Surface plate. Danger 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. Footer separators are painted dim like the card's, since an unpainted middot renders at the terminal's default brightness, louder than the labels it separates. Two defects the visual pass found, both older than this branch. The metrics grid solved its column count by dividing inner by the column width, ignoring the blank cell at each end and the rules between pairs: a 40-cell panel was told it had three columns, handed each of them ten, and printed MAINTENANC — the caption metricMinCol exists to protect. And a language share rounded to whole percents printed "makefile 0%" beside a band segment that plainly was not zero. Every new assertion was killed by a mutation before being kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/plans/design_handoff_keepkit_redesign, plus the theming pre-work that handoff asks for first, and a second pass over the result read on a real terminal.Theming
internal/ui/theme.go—Theme, ten semantic color roles plus the gauge's dim track.styles.go— every package-levelvar XxxStylebecomes a field ofStyles, built by the singleNewStyles(Theme). The model holds one and reads it throughm.sty().Result: no hex literal below
internal/ui,grep 'ui\.[A-Z][A-Za-z]*Style\b'empty, and switching every color in the app is switching one value. glamour's readme theme andui.PlaceOverlaytake the theme too, so nothing is left behind on the old palette.TestStylesBuildFromThemefails if any field is built from something other than itsTheme.The roles retire
ColorCategory/ColorKey/ColorMuted— what they marked is carried by weight,Emphasisand the newSurfacebackground.One palette is deliberately not a set of roles:
ui.LanguageColor(lang.go) holds linguist's per-language brand colors, and a theme switch must not repaint them.Themeis keepkit's vocabulary of meanings; these are somebody else's marks, and the whole value of a cyan dot besidegois that it is the cyan the reader has already seen on every repo page.Panels
[1] tools — right-aligned version column (signal +
↑when behind, ok +•on keepkit's own row), section headers lead with their label, the grouped view opens with anupdatessection that cuts across the tags, the title carries the tracked and outdated counts.[2] brief — the six
label: valuelines become a metrics strip (installed / latest / maintenance / stars as captioned columns onSurface, each cell centered in its column, re-flowing to fewer columns on a narrow panel), then the language stack as the card's one picture — a named list with a colored dot per language and a full-width proportional band under it — then a one-line meta row where an empty note or tag offers the key that fills it. The changelog gains a heading with the version transition and the release link.[3] readme — the preprocessor drops the leading H1, and the slogan under it when it repeats the repo description the card already prints. glamour headings lose their reprinted
##markers; body text takes the sameTextrole the card's changelog notes render in, and both code blocks and inline code get theSurfaceplate.Status bar — one global key list in every focus instead of three per-focus ones; what is panel-local moved into that panel's footer. Key hints lost their brackets. The right corner names the running build.
Keymap
Per the handoff:
#edits tags,Rrenames,t/u/Rare global, andenteris each panel's primary action — run in[1], update in[2]. That is what freedt/u/rfrom their per-focus meanings and let the status bar go global.uon the card is gone;enterdoes it.ris unbound in[1]rather than gaining a third meaning.Defects found reviewing the diff (dde55b3)
changelogHeadingcompared installed and latest as raw strings while printing both throughDisplayVersion, so a tool reporting1.10.2against av1.10.2tag — up to date — got av1.10.2 → v1.10.2arrow. Gated onhasUpdatenow.metricsStripsized its columns from the widest caption alone, so✕ not installedrendered as✕ not instaat the 80x24 baseline. Sized by the widest value too; the two version-less installed states become one word each.lipgloss.Widthmeasures a multi-line cell by its widest line, not by the footprint it occupies. Cut to one line with a…. (Superseded by the second pass: the languages are their own block now and are emitted outside the cell-wrapping loop, so there is no cell to break.)Second pass (adbf9e1)
Six refinements from reading the redesign on a real terminal, and two defects that reading turned up.
A gutter in
[2]and[3]. One blank column at both ends of everything the two panels draw. Content that touches the border it lives in reads as having overflowed it, and on the right that column is also what keeps text off the scrollbar thumb. Two single definitions own the budgets — the newcardWidth()and the amendedhelpWrapWidth()— andindentLinesapplies the indent last, to whole finished lines: plain spaces outside the styling can never split an escape sequence, they land in front of the metrics plate's background segments rather than inside them, and an indent cannot shift a line index, sobuildCard's clickable-link map stays correct for free.Centered metrics. Each cell is centered in its column, odd slack to the right. A caption and the value under it are one measurement; flush-left hung them off a rule that was nowhere near either.
The language stack becomes a picture.
languagesheads its own list, separated from the first language by the same middot that separates two of them; only the dot carries color, in GitHub's own hue, and a full-width band under the list holds the same shares, closed by a blank row so the band reads as an edge rather than an underline. The band normalizes over the languages actually listed — summed over the repo's total it would leave a gap standing for languages the card never named — hands out one cell per language before distributing the remainder by largest fractional part, and uses▬(U+25AC) rather than a full block, which measures two cells underRUNEWIDTH_EASTASIAN=1.The running build in the bar's corner. That corner held a third copy of the pending-updates count — the
[1]title carries it beside the tracked count and every outdated row carries its own↑— while the version had no surface outside an overlay. When a newer release is known and still uninstalled the version takes the signal role and the same↑an outdated tool row carries, so the app announces its own update in the vocabulary it announces everyone else's.selfCompactCelldrops the name it was repeating and keeps only the action (U update/U restart); the two are governed by the same gate, so the action can never appear without its subject beside it.Readme body and inline code speak the card's language. Body text moves to the
Textrole — the very one the card's changelog notes use — and inline code reads exactly like a code span there:Emphasison theSurfaceplate.Dangerwas 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.Painted footer separators. Dim, like the card's — an unpainted middot renders at the terminal's default brightness, louder than the labels it is separating.
Two defects the visual pass found, both older than this branch and invisible to the suite:
metricsStripsolved its column count by dividinginnerby the column width, ignoring the blank cell at each end and the rules between pairs. A 40-cell panel was told it had three columns, handed each of them ten, and printedMAINTENANC— the captionmetricMinColexists to protect. The count is now solved against the row the strip actually draws, and the stand-down threshold (metricStripMinWidth) includes the row's two blank edge cells, which it was short by.makefile 0%beside a band segment that plainly was not zero. Shares now read as GitHub writes them — one decimal, a whole number left whole,<0.1%below a tenth.Every assertion added or changed in this pass was killed by a mutation before being kept — the gutter in each panel, the centering, the band, the blank row under it, the painted separator, the version cell's
↑, and the readme's code role.Not implemented, on purpose
THEMING-PREWORK.mddefers them to a separate feature after this refactor. The infrastructure is in place.U update all/T themesfrom the mockup's key sheet — the handoff says its key lists are approximate and to reconcile with the real keymap. There is no bulk update, andUis the existing self-update.glamour.StyleConfigcannot express it; the only way to fake it is injecting a---the author never wrote.lua,powershellandjsonare very dark dots on a dark terminal. Lightening them would make the dot no longer the color it is claiming to be.Known follow-up
demo/hero.gifanddemo/update.gifstill show the old UI.Checks
go build/go vet/go test -race ./.../golangci-lint run— all green, plus the cross-compile step (GOOS=windowsbuild+vet,GOOS=darwinbuild). Coverage:internal/model94.0%,internal/ui98.2%. Docs (CLAUDE.md,ARCHITECTURE.md,README.md) updated in the same branch and re-verified against the code.🤖 Generated with Claude Code