-
Notifications
You must be signed in to change notification settings - Fork 0
feat: full-fidelity theme skins (Phosphor / Synthwave / Charm) #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
681c0ca
refactor: route renderers through a per-theme Skin (invisible)
StrangeNoob 9a6b0e9
feat: full-fidelity Phosphor skin (single-color CRT)
StrangeNoob 2bf638a
feat: full-fidelity Synthwave skin (neon arcade)
StrangeNoob c5ba5b9
feat: full-fidelity Charm skin (cozy pastel)
StrangeNoob bdd509f
docs: skin architecture spec + README/ARCHITECTURE for full-fidelity …
StrangeNoob 1ac96b2
fix: address CodeRabbit review on PR #13
StrangeNoob File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
67 changes: 67 additions & 0 deletions
67
docs/superpowers/specs/2026-07-04-theme-skins-full-fidelity-design.md
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| # Full-fidelity theme skins — design | ||
|
|
||
| Follow-up to the color-only theme selector. The Turn 2 mockups (synthwave / | ||
| charm / phosphor) differ in **structure**, not just color — dither banners, pill | ||
| HUDs, reverse-video CRT headers, gradient logos, rounded borders — so the | ||
| renderers themselves had to become theme-driven. | ||
|
|
||
| ## The `Skin` seam (`skin.go`) | ||
|
|
||
| Renderers assemble *semantic* content and hand it to the active skin, which owns | ||
| the layout: | ||
|
|
||
| ```go | ||
| type Skin interface { | ||
| Logo(lines []string) string | ||
| RoomHeader(h roomHeader) string // act banner + title/boss + story/taunt + timer | ||
| Body(act int, s string) string // objective / free body text | ||
| CursorCell(act int, s string) string | ||
| FrameBuffer(act int, lines []string) string | ||
| HUD(act int, segs []segment) string | ||
| } | ||
| ``` | ||
|
|
||
| - `roomHeader` and `[]segment` (with `segRole`) carry the content; the skin | ||
| decides plain text vs. chip vs. reverse-video vs. border. | ||
| - `activeSkin()` looks up `skinRegistry[activeThemeName]`, falling back to | ||
| `classicSkin`. A theme registers a skin only when it restyles layout; color-only | ||
| themes just ride the palette. | ||
| - Scope: skins restyle the **title logo** and the **room** (header, buffer, HUD, | ||
| cursor, objective). The map, welcome, stats, and results stay structurally | ||
| neutral and re-skin by palette only — the Turn 2 mockups themed those three | ||
| screens (title / room / boss), not the map. | ||
| - `roundedBox` is a shared helper for the bordered-buffer skins (synthwave, charm). | ||
|
|
||
| ## Skins | ||
|
|
||
| - **classicSkin** — the original look, reproduced byte-for-byte (proven with a | ||
| golden render diff over title / room / room+hint / boss+visual). Every theme | ||
| fell back to it before its own skin landed, so each phase was independently safe. | ||
| - **phosphorSkin** — one hue/act, five brightness steps, reverse-video chips, | ||
| block-figlet logo, `> ` objective, line-number gutter, `!! BOSS !!` + T-MINUS. | ||
| Palette dim shifts to green so the color-only screens stay in-hue. | ||
| - **synthwaveSkin** — cyan→magenta gradient block logo, `▓▒░` dither banners, | ||
| rounded box + purple line numbers, act-color block cursor, neon chip HUD, | ||
| `BOSS »` chip + TIME bar. | ||
| - **charmSkin** — `✧ nvim─quest ✧` wordmark, lowercase `act i · …` banners, `♥` | ||
| objective, rounded act-colored borders, spaced pastel pills, pink block cursor, | ||
| encouraging copy. | ||
|
|
||
| ## Phasing | ||
|
|
||
| Built and committed in four checkpoints: (1) invisible Skin refactor + | ||
| classicSkin, then one skin per phase (phosphor → synthwave → charm), rendering | ||
| each screen against the mockup at every step. | ||
|
|
||
| ## Testing | ||
|
|
||
| `classicSkin` golden-diff (throwaway, during Phase 1); per-skin chrome tests | ||
| (`phosphor_test.go`, `synthwave_test.go`, `charm_test.go`) assert the | ||
| distinguishing glyphs (gutter `│`, `▓▒░`, rounded `╭`, lowercase banner) appear | ||
| under that theme and not under Classic; `TestTitleReskinsAcrossThemes` keeps the | ||
| four titles visually distinct. | ||
|
|
||
| ## Out of scope | ||
|
|
||
| - Themed map / welcome / stats / results structure (color-only there). | ||
| - Copy rewrites beyond the HUD/header (lesson text is shared across themes). |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package ui | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/charmbracelet/lipgloss" | ||
| ) | ||
|
|
||
| func init() { skinRegistry["Charm"] = charmSkin{} } | ||
|
|
||
| // charmSkin is the cozy pastel theme: lowercase, encouraging copy, a ✧ wordmark, | ||
| // rounded act-colored borders, spaced pastel pills, and a pink block cursor. The | ||
| // friendliest option — for people scared of vim. | ||
| type charmSkin struct{} | ||
|
|
||
| const ( | ||
| charmPink = lipgloss.Color("218") | ||
| charmLav = lipgloss.Color("147") | ||
| charmPeach = lipgloss.Color("216") | ||
| charmInk = lipgloss.Color("233") | ||
| charmPillBg = lipgloss.Color("236") | ||
| ) | ||
|
|
||
| func charmPill(bg, fg lipgloss.Color, s string) string { | ||
| return lipgloss.NewStyle().Background(bg).Foreground(fg).Render(" " + strings.TrimSpace(s) + " ") | ||
| } | ||
|
|
||
| func (charmSkin) Logo(_ []string) string { | ||
| return "\n " + lipgloss.NewStyle().Foreground(charmPink).Bold(true).Render("✧ nvim─quest ✧") + "\n" | ||
| } | ||
|
|
||
| func (charmSkin) RoomHeader(h roomHeader) string { | ||
| pal := paletteFor(h.act) | ||
| title := lipgloss.NewStyle().Foreground(pal.Primary).Bold(true). | ||
| Render("act " + strings.ToLower(roman(h.act)) + " · " + strings.ToLower(actName(h.act))) | ||
| var b strings.Builder | ||
| if h.isBoss { | ||
| right := charmPill(charmPeach, charmInk, "boss: "+strings.ToLower(h.title)) | ||
| b.WriteString(spread(title, right, roomInnerW) + "\n") | ||
| b.WriteString(dimStyle.Render(" "+h.taunt+" (rude!)") + "\n") | ||
| b.WriteString(dimStyle.Render(" time ") + charmTimeBar(h.timeLeft, h.timeTotal) + | ||
| lipgloss.NewStyle().Foreground(pal.Primary).Render(fmt.Sprintf(" %d:%02d", h.timeLeft/60, h.timeLeft%60)) + | ||
| dimStyle.Render(fmt.Sprintf(" — step %d of %d, you've got this", h.step, h.steps)) + "\n\n") | ||
| } else { | ||
| right := dimStyle.Render(fmt.Sprintf("room %d of %d", h.roomIndex, h.roomTotal)) | ||
| b.WriteString(spread(title, right, roomInnerW) + "\n") | ||
| b.WriteString(dimStyle.Render(" "+h.story) + "\n\n") | ||
| } | ||
| return b.String() | ||
| } | ||
|
|
||
| func (charmSkin) Body(act int, s string) string { | ||
| return lipgloss.NewStyle().Foreground(charmPink).Render("♥ ") + | ||
| lipgloss.NewStyle().Foreground(paletteFor(act).Primary).Render(s) | ||
| } | ||
|
|
||
| func (charmSkin) CursorCell(_ int, s string) string { | ||
| return lipgloss.NewStyle().Background(charmPink).Foreground(charmInk).Render(s) | ||
| } | ||
|
|
||
| func (charmSkin) FrameBuffer(act int, lines []string) string { | ||
| border := lipgloss.NewStyle().Foreground(paletteFor(act).Primary) | ||
| num := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) | ||
| return roundedBox(lines, roomInnerW, border, num, true) | ||
| } | ||
|
|
||
| func (charmSkin) HUD(act int, segs []segment) string { | ||
| pal := paletteFor(act) | ||
| var parts []string | ||
| for _, s := range segs { | ||
| switch s.role { | ||
| case segMode: | ||
| mode := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(s.text, "-- "), " --")) | ||
| parts = append(parts, charmPill(pal.Primary, charmInk, mode)) | ||
| case segHearts: | ||
| parts = append(parts, charmPill(charmPillBg, charmPink, s.text)) | ||
| case segCombo: | ||
| parts = append(parts, charmPill(charmPillBg, charmLav, s.text)) | ||
| case segKeys: | ||
| parts = append(parts, charmPill(charmPillBg, charmPeach, s.text)) | ||
| default: | ||
| parts = append(parts, charmPill(charmPillBg, lipgloss.Color("245"), s.text)) | ||
| } | ||
| } | ||
| return strings.Join(parts, " ") | ||
| } | ||
|
|
||
| func charmTimeBar(left, total int) string { | ||
| const w = 20 | ||
| filled := 0 | ||
| if total > 0 { | ||
| filled = left * w / total | ||
| } | ||
| if filled > w { | ||
| filled = w | ||
| } | ||
| return lipgloss.NewStyle().Foreground(charmPeach).Render(strings.Repeat("█", filled)) + | ||
| dimStyle.Render(strings.Repeat("░", w-filled)) | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package ui | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestCharmSkinChrome(t *testing.T) { | ||
| defer applyTheme("Classic") | ||
|
|
||
| m := newTestModel(t) | ||
| m, _ = m.openLesson(0) | ||
| applyTheme("Charm") | ||
|
|
||
| if _, ok := activeSkin().(charmSkin); !ok { | ||
| t.Fatalf("active skin = %T, want charmSkin", activeSkin()) | ||
| } | ||
| room := m.viewRoom() | ||
| for _, want := range []string{"act i ·", "╭", "♥"} { // lowercase banner, rounded box, ♥ objective | ||
| if !strings.Contains(room, want) { | ||
| t.Errorf("charm room missing %q", want) | ||
| } | ||
| } | ||
| if strings.Contains(room, "ACT I ·") || strings.Contains(room, "ACT 1 ·") { | ||
| t.Error("charm banner should be lowercase") | ||
| } | ||
| } | ||
|
|
||
| func TestCharmBossChrome(t *testing.T) { | ||
| defer applyTheme("Classic") | ||
|
|
||
| m := newTestModel(t) | ||
| m.lessonIdx = bossLessonIdx(m, 3) | ||
| m, _ = m.startBoss() | ||
| applyTheme("Charm") | ||
|
|
||
| boss := m.viewRoom() | ||
| for _, want := range []string{"boss:", "(rude!)", "you've got this"} { | ||
| if !strings.Contains(boss, want) { | ||
| t.Errorf("charm boss missing %q", want) | ||
| } | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| package ui | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/charmbracelet/lipgloss" | ||
| ) | ||
|
|
||
| func init() { skinRegistry["Phosphor"] = phosphorSkin{} } | ||
|
|
||
| // phosphorSkin is a single-color CRT: one hue per act, five brightness steps, | ||
| // hierarchy carried by luminance and reverse-video chips rather than a second | ||
| // color. Steps run bright→dim: [0] actionable + cursor, [1] chrome chips, | ||
| // [2] body, [3] meta, [4] footers/rules. | ||
| type phosphorSkin struct{} | ||
|
|
||
| var phRamp = map[int][5]lipgloss.Color{ | ||
| 1: {"46", "40", "34", "28", "22"}, // greens | ||
| 2: {"220", "214", "172", "130", "94"}, // ambers | ||
| 3: {"51", "44", "37", "30", "23"}, // cyans | ||
| 4: {"201", "165", "127", "90", "53"}, // magentas (act IV; extends the brief) | ||
| } | ||
|
|
||
| func phC(act, step int) lipgloss.Color { | ||
| r, ok := phRamp[act] | ||
| if !ok { | ||
| r = phRamp[1] | ||
| } | ||
| return r[step] | ||
| } | ||
|
|
||
| func phFg(act, step int) lipgloss.Style { | ||
| return lipgloss.NewStyle().Foreground(phC(act, step)) | ||
| } | ||
|
|
||
| // phChip is a bright reverse-video chip (bright bg, near-black fg). | ||
| func phChip(act, step int, s string) string { | ||
| return lipgloss.NewStyle().Background(phC(act, step)). | ||
| Foreground(lipgloss.Color("232")).Bold(true).Render(s) | ||
| } | ||
|
|
||
| // phDarkChip is a recessed reverse chip: near-black bg with in-hue fg. | ||
| func phDarkChip(act, step int, s string) string { | ||
| return lipgloss.NewStyle().Background(lipgloss.Color("233")). | ||
| Foreground(phC(act, step)).Render(s) | ||
| } | ||
|
|
||
| func (phosphorSkin) Logo(_ []string) string { | ||
| const g = 1 // the title runs on the act-I green | ||
| var b strings.Builder | ||
| b.WriteString(phChip(g, 1, " NVIM-QUEST ") + phFg(g, 3).Render(" SYS.READY") + "\n\n") | ||
| for _, ln := range []string{ | ||
| ` █▄ █ █ █ █ █▀▄▀█ █▀█ █ █ █▀▀ █▀ ▀█▀`, | ||
| ` █ ▀█ ▀▄▀ █ █ ▀ █ ▀▀█ █▄█ ██▄ ▄█ █ `, | ||
| } { | ||
| b.WriteString(phFg(g, 0).Bold(true).Render(ln) + "\n") | ||
| } | ||
| return b.String() | ||
| } | ||
|
|
||
| func (phosphorSkin) RoomHeader(h roomHeader) string { | ||
| act := h.act | ||
| var b strings.Builder | ||
| if h.isBoss { | ||
| b.WriteString(phChip(act, 0, " !! BOSS !! ") + | ||
| phDarkChip(act, 1, " "+strings.ToUpper(h.title)+" ") + | ||
| " " + phDarkChip(act, 3, fmt.Sprintf(" STEP %d/%d ", h.step, h.steps)) + "\n") | ||
| b.WriteString(phFg(act, 3).Render(" "+h.taunt) + "\n") | ||
| b.WriteString(phFg(act, 2).Render(" T-MINUS ") + phTimerBar(act, h.timeLeft, h.timeTotal) + | ||
| phFg(act, 0).Bold(true).Render(fmt.Sprintf(" %d:%02d", h.timeLeft/60, h.timeLeft%60)) + "\n\n") | ||
| } else { | ||
| b.WriteString(phChip(act, 1, " ACT "+roman(act)+" / "+actName(act)+" ") + | ||
| " " + phDarkChip(act, 2, fmt.Sprintf(" ROOM %d/%d ", h.roomIndex, h.roomTotal)) + "\n") | ||
| b.WriteString(phFg(act, 3).Render(" "+h.story) + "\n\n") | ||
| } | ||
| return b.String() | ||
| } | ||
|
|
||
| func (phosphorSkin) Body(act int, s string) string { | ||
| return phFg(act, 0).Bold(true).Render("> " + s) | ||
| } | ||
|
|
||
| func (phosphorSkin) CursorCell(act int, s string) string { | ||
| return lipgloss.NewStyle().Background(phC(act, 0)).Foreground(lipgloss.Color("232")).Render(s) | ||
| } | ||
|
|
||
| func (phosphorSkin) FrameBuffer(act int, lines []string) string { | ||
| out := make([]string, len(lines)) | ||
| for i, ln := range lines { | ||
| out[i] = " " + phFg(act, 4).Render(fmt.Sprintf("%3d ", i+1)) + | ||
| phFg(act, 3).Render("│ ") + ln | ||
| } | ||
| return strings.Join(out, "\n") | ||
| } | ||
|
|
||
| func (phosphorSkin) HUD(act int, segs []segment) string { | ||
| var b strings.Builder | ||
| for _, s := range segs { | ||
| text := " " + strings.TrimSpace(s.text) + " " | ||
| switch s.role { | ||
| case segMode: | ||
| b.WriteString(phChip(act, 1, text)) | ||
| case segKeys: | ||
| b.WriteString(phChip(act, 1, " "+strings.ToUpper(strings.TrimSpace(s.text))+" ")) | ||
| default: | ||
| b.WriteString(phDarkChip(act, 2, text)) | ||
| } | ||
| } | ||
| return b.String() | ||
| } | ||
|
|
||
| func phTimerBar(act, left, total int) string { | ||
| const w = 24 | ||
| filled := 0 | ||
| if total > 0 { | ||
| filled = left * w / total | ||
| } | ||
| if filled > w { | ||
| filled = w | ||
| } | ||
| return phFg(act, 0).Render(strings.Repeat("█", filled)) + | ||
| phFg(act, 4).Render(strings.Repeat("░", w-filled)) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.