Skip to content

feat(ui): theme-driven redesign of all three panels - #48

Merged
stanlyzoolo merged 9 commits into
mainfrom
feat/redesign-theming
Jul 29, 2026
Merged

feat(ui): theme-driven redesign of all three panels#48
stanlyzoolo merged 9 commits into
mainfrom
feat/redesign-theming

Conversation

@stanlyzoolo

@stanlyzoolo stanlyzoolo commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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.goTheme, ten semantic color roles plus the gauge's dim track. styles.go — every package-level var XxxStyle becomes a field of Styles, built by the single NewStyles(Theme). The model holds one and reads it through m.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 and ui.PlaceOverlay take the theme too, so nothing is left behind on the old palette. TestStylesBuildFromTheme fails if any field is built from something other than its Theme.

The roles retire ColorCategory/ColorKey/ColorMuted — what they marked is carried by weight, Emphasis and the new Surface background.

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. Theme is keepkit's vocabulary of meanings; these are somebody else's marks, and the whole value of a cyan dot beside go is 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 an updates section that cuts across the tags, the title carries the tracked and outdated counts.

[2] brief — the six label: value lines become a metrics strip (installed / latest / maintenance / stars as captioned columns on Surface, 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 same Text role the card's changelog notes render in, and both code blocks and inline code 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. Key hints lost their brackets. The right corner names the running build.

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]. That is what freed t/u/r from their per-focus meanings and let the status bar go global.

u on the card is gone; enter does it. r is unbound in [1] rather than gaining a third meaning.

Defects found reviewing the diff (dde55b3)

  1. changelogHeading compared installed and latest as raw strings while printing both through DisplayVersion, so a tool reporting 1.10.2 against a v1.10.2 tag — up to date — got a v1.10.2 → v1.10.2 arrow. Gated on hasUpdate now.
  2. metricsStrip sized its columns from the widest caption alone, so ✕ not installed rendered as ✕ not insta at the 80x24 baseline. Sized by the widest value too; the two version-less installed states become one word each.
  3. The meta line's language bar wrapped internally on a multi-language repo, which breaks the line's arithmetic — lipgloss.Width measures 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 new 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.

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. 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 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 under RUNEWIDTH_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. selfCompactCell drops 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 Text role — the very one the card's changelog notes use — 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.

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:

  1. metricsStrip 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. 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.
  2. A language share rounded to whole percents printed 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

  • Theme picker overlay, built-in themes, persisting the choiceTHEMING-PREWORK.md defers them to a separate feature after this refactor. The infrastructure is in place.
  • U update all / T themes from 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, and U is the existing self-update.
  • A rule under readme section headingsglamour.StyleConfig cannot express it; the only way to fake it is injecting a --- the author never wrote.
  • Font sizes (the mockup's 19px name, smaller metric values) — a terminal has one font size, set by the emulator for the whole grid. The three sizes the design draws are three steps of weight and brightness here.
  • Lightening the near-black language colors — linguist picks them against a white page, so lua, powershell and json are 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.gif and demo/update.gif still show the old UI.

Checks

go build / go vet / go test -race ./... / golangci-lint run — all green, plus the cross-compile step (GOOS=windows build+vet, GOOS=darwin build). Coverage: internal/model 94.0%, internal/ui 98.2%. Docs (CLAUDE.md, ARCHITECTURE.md, README.md) updated in the same branch and re-verified against the code.

🤖 Generated with Claude Code

stanlyzoolo and others added 9 commits July 28, 2026 23:26
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>
@stanlyzoolo
stanlyzoolo merged commit adf8aa2 into main Jul 29, 2026
3 checks passed
@stanlyzoolo
stanlyzoolo deleted the feat/redesign-theming branch July 29, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant