From 681c0cabb2b9c03446c56caf4f56eef5becfb103 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 02:45:16 +0530 Subject: [PATCH 1/6] refactor: route renderers through a per-theme Skin (invisible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Skin interface that owns the structural chrome — Logo, RoomHeader, CursorCell, FrameBuffer, HUD — and route viewTitle/viewRoom through the active skin. Renderers now assemble semantic content (roomHeader, buffer lines, HUD segments) and let the skin draw it. classicSkin reproduces the current look byte-for-byte (verified via a golden render diff across title, room, room+hint, and boss+visual screens), and every theme still falls back to it — so this commit changes nothing on screen. It's the seam the phosphor/synthwave/charm skins plug into next. --- internal/ui/room.go | 60 +++++++++++------------ internal/ui/skin.go | 114 +++++++++++++++++++++++++++++++++++++++++++ internal/ui/title.go | 10 ++-- 3 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 internal/ui/skin.go diff --git a/internal/ui/room.go b/internal/ui/room.go index 486c1bb..31f1356 100644 --- a/internal/ui/room.go +++ b/internal/ui/room.go @@ -238,27 +238,23 @@ func (m Model) awardBoss() (tea.Model, tea.Cmd) { func (m Model) viewRoom() string { l := m.lessons[m.lessonIdx] - pal := paletteFor(l.Act) ch := m.cur() var b strings.Builder - b.WriteString(lipgloss.NewStyle().Foreground(pal.Primary).Bold(true). - Render(fmt.Sprintf("ACT %d · %s", l.Act, actName(l.Act))) + "\n") + h := roomHeader{act: l.Act, isBoss: m.inBoss} if m.inBoss { - boss := l.Boss - b.WriteString(dangerStyle.Render("⚔ BOSS: "+boss.Name) + "\n") - b.WriteString(dimStyle.Render(boss.Taunt) + "\n") - b.WriteString(fmt.Sprintf("%s %d:%02d · step %d/%d\n\n", - timerBar(m.timeLeft, boss.TimeLimitSec, 30), - m.timeLeft/60, m.timeLeft%60, m.bossStep+1, len(boss.Steps))) + h.title, h.taunt = l.Boss.Name, l.Boss.Taunt + h.timeLeft, h.timeTotal = m.timeLeft, l.Boss.TimeLimitSec + h.step, h.steps = m.bossStep+1, len(l.Boss.Steps) } else { - b.WriteString(fmt.Sprintf("%s — room %d/%d\n", l.Title, m.chIdx+1, len(l.Challenges))) - b.WriteString(dimStyle.Render(l.Story) + "\n\n") + h.title, h.story = l.Title, l.Story + h.roomIndex, h.roomTotal = m.chIdx+1, len(l.Challenges) } + b.WriteString(activeSkin().RoomHeader(h)) if m.isFreshPlayer() && !m.inBoss { b.WriteString(m.renderFirstTimeHelp() + "\n") } b.WriteString(ch.Intro + "\n\n") - b.WriteString(m.renderBuffer() + "\n\n") + b.WriteString(activeSkin().FrameBuffer(m.bufferLines()) + "\n\n") b.WriteString(m.renderHUD(ch) + "\n") if m.heartMsg != "" && !m.flash { b.WriteString(dangerStyle.Render(m.heartMsg) + "\n") @@ -296,31 +292,33 @@ func (m Model) renderFirstTimeHelp() string { return b.String() } -func (m Model) renderBuffer() string { +// bufferLines renders the code buffer's rows (cursor and selection cells via the +// active skin) without any indent or framing — the skin's FrameBuffer wraps them. +func (m Model) bufferLines() []string { var lines []string for i, line := range m.sim.Buffer { switch { case m.flash: - lines = append(lines, " "+successStyle.Render(line)) + lines = append(lines, successStyle.Render(line)) case m.sim.Mode == engine.ModeVisual: - lines = append(lines, " "+renderSelected(line, i, m.sim)) + lines = append(lines, renderSelected(line, i, m.sim)) case m.sim.Mode == engine.ModeVisualBlock: - lines = append(lines, " "+renderBlockSelected(line, i, m.sim)) + lines = append(lines, renderBlockSelected(line, i, m.sim)) case i == m.sim.Cursor.Row: col := m.sim.Cursor.Col r := []rune(line) var s string if col >= len(r) { - s = string(r) + cursorStyle.Render(" ") + s = string(r) + activeSkin().CursorCell(" ") } else { - s = string(r[:col]) + cursorStyle.Render(string(r[col])) + string(r[col+1:]) + s = string(r[:col]) + activeSkin().CursorCell(string(r[col])) + string(r[col+1:]) } - lines = append(lines, " "+s) + lines = append(lines, s) default: - lines = append(lines, " "+line) + lines = append(lines, line) } } - return strings.Join(lines, "\n") + return lines } // renderSelected reverse-videos the part of row's line that falls inside the @@ -339,7 +337,7 @@ func renderSelected(line string, row int, sim *engine.Simulator) string { if row == end.Row { to = min(end.Col+1, len(r)) } - return string(r[:from]) + cursorStyle.Render(string(r[from:to])) + string(r[to:]) + return string(r[:from]) + activeSkin().CursorCell(string(r[from:to])) + string(r[to:]) } // renderBlockSelected reverse-videos the rectangular columns [c0,c1] on the @@ -352,7 +350,7 @@ func renderBlockSelected(line string, row int, sim *engine.Simulator) string { r := []rune(line) from := min(c0, len(r)) to := min(c1+1, len(r)) - return string(r[:from]) + cursorStyle.Render(string(r[from:to])) + string(r[to:]) + return string(r[:from]) + activeSkin().CursorCell(string(r[from:to])) + string(r[to:]) } func (m Model) renderHUD(ch content.Challenge) string { @@ -369,20 +367,20 @@ func (m Model) renderHUD(ch content.Challenge) string { case engine.ModeVisualBlock: mode = "-- VISUAL BLOCK --" } - parts := []string{mode} + segs := []segment{{mode, segMode}} if m.sim.Recording() { - parts = append(parts, dangerStyle.Render("● REC")) + segs = append(segs, segment{"● REC", segRec}) } if p := m.sim.Pending(); p != "" { - parts = append(parts, p) + segs = append(segs, segment{p, segPending}) } - parts = append(parts, - strings.Repeat("♥ ", m.hearts)+strings.Repeat("· ", 3-m.hearts), - fmt.Sprintf("⚡x%d", m.combo)) + segs = append(segs, + segment{strings.Repeat("♥ ", m.hearts) + strings.Repeat("· ", 3-m.hearts), segHearts}, + segment{fmt.Sprintf("⚡x%d", m.combo), segCombo}) if !m.inBoss { - parts = append(parts, fmt.Sprintf("keys %d · par %d", m.keystrokes, ch.Par)) + segs = append(segs, segment{fmt.Sprintf("keys %d · par %d", m.keystrokes, ch.Par), segKeys}) } - return strings.Join(parts, " ") + return activeSkin().HUD(segs) } func timerBar(left, total, width int) string { diff --git a/internal/ui/skin.go b/internal/ui/skin.go new file mode 100644 index 0000000..076cc5c --- /dev/null +++ b/internal/ui/skin.go @@ -0,0 +1,114 @@ +package ui + +import ( + "fmt" + "strings" +) + +// Skin owns a theme's structural chrome. Renderers assemble semantic content — +// an act, a set of buffer lines, a list of HUD segments — and hand it to the +// active skin, which decides how that content is drawn: plain text, background +// chips, reverse-video, dither banners, bordered boxes, and so on. +// +// Color-only themes need no bespoke structure, so they all fall back to +// classicSkin (the game's original look) and vary only through the palette that +// applyTheme installs. A theme registers a Skin only when it restyles layout. +type Skin interface { + Logo(lines []string) string // title wordmark + RoomHeader(h roomHeader) string // act banner + title/boss + story/taunt + timer + CursorCell(s string) string // one cursor / selection cell + FrameBuffer(lines []string) string // wrap the buffer body (indent / border / gutter) + HUD(segs []segment) string // the room status bar +} + +// roomHeader is the semantic content of a room's top block; the skin lays it out. +type roomHeader struct { + act int + title string // lesson title, or boss name when isBoss + isBoss bool + story string // lesson flavor text (non-boss) + taunt string // boss taunt (boss) + roomIndex int // 1-based room number within the lesson (non-boss) + roomTotal int + timeLeft int // boss timer, seconds + timeTotal int + step, steps int // boss step x/n +} + +type segRole int + +const ( + segMode segRole = iota + segRec + segPending + segHearts + segCombo + segKeys +) + +// segment is one labelled cell of the HUD; the skin styles it by role. +type segment struct { + text string + role segRole +} + +// skinRegistry maps a theme name to its structural skin. A theme absent here +// renders through classicSkin, re-skinned only by its palette. +var skinRegistry = map[string]Skin{} + +func activeSkin() Skin { + if s, ok := skinRegistry[activeThemeName]; ok { + return s + } + return classicSkin{} +} + +// classicSkin is the game's original chrome: colored-but-plain text, a reverse +// cursor, an unbordered two-space-indented buffer, and a space-joined HUD. +type classicSkin struct{} + +func (classicSkin) Logo(lines []string) string { + var b strings.Builder + for i, ln := range lines { + b.WriteString(paletteFor(i*4/len(lines)+1).PrimaryStyle().Render(ln) + "\n") + } + return b.String() +} + +func (classicSkin) RoomHeader(h roomHeader) string { + var b strings.Builder + b.WriteString(paletteFor(h.act).PrimaryStyle().Bold(true). + Render(fmt.Sprintf("ACT %d · %s", h.act, actName(h.act))) + "\n") + if h.isBoss { + b.WriteString(dangerStyle.Render("⚔ BOSS: "+h.title) + "\n") + b.WriteString(dimStyle.Render(h.taunt) + "\n") + b.WriteString(fmt.Sprintf("%s %d:%02d · step %d/%d\n\n", + timerBar(h.timeLeft, h.timeTotal, 30), + h.timeLeft/60, h.timeLeft%60, h.step, h.steps)) + } else { + b.WriteString(fmt.Sprintf("%s — room %d/%d\n", h.title, h.roomIndex, h.roomTotal)) + b.WriteString(dimStyle.Render(h.story) + "\n\n") + } + return b.String() +} + +func (classicSkin) CursorCell(s string) string { return cursorStyle.Render(s) } + +func (classicSkin) FrameBuffer(lines []string) string { + for i, ln := range lines { + lines[i] = " " + ln + } + return strings.Join(lines, "\n") +} + +func (classicSkin) HUD(segs []segment) string { + parts := make([]string, len(segs)) + for i, s := range segs { + if s.role == segRec { + parts[i] = dangerStyle.Render(s.text) + } else { + parts[i] = s.text + } + } + return strings.Join(parts, " ") +} diff --git a/internal/ui/title.go b/internal/ui/title.go index 4aebf61..7f9ed27 100644 --- a/internal/ui/title.go +++ b/internal/ui/title.go @@ -81,12 +81,10 @@ func (m Model) viewTitle() string { ` |_| `, } var b strings.Builder - // Paint the logo top-to-bottom across the theme's four act colors so the - // title itself visibly re-skins the moment a new theme is applied (a single - // act color barely changes between themes — act I is green in all of them). - for i, ln := range logoLines { - b.WriteString(paletteFor(i*4/len(logoLines)+1).PrimaryStyle().Render(ln) + "\n") - } + // The skin paints the wordmark. classicSkin gradients it across the four act + // colors so the title visibly re-skins between themes; other skins render + // their own logo treatment. + b.WriteString(activeSkin().Logo(logoLines)) b.WriteString(dimStyle.Render("learn the blade cursor · four acts · one journey") + "\n\n") for i, item := range menuItems { line := " " + item From 9a6b0e97d79651282f5e05826860c41a24c0078d Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 02:53:10 +0530 Subject: [PATCH 2/6] feat: full-fidelity Phosphor skin (single-color CRT) phosphorSkin renders the Turn 2c mockup: one hue per act with five brightness steps, hierarchy carried by luminance + reverse-video chips rather than a second color. Reverse-chip act banner, block-figlet logo + SYS.READY bar, bright "> " objective line, line-number gutter buffer, bg-block cursor, chip HUD, and for bosses a "!! BOSS !!" chip + T-MINUS bar. Phosphor's palette dim shifts to green so the color-only screens (map, stats) stay in-hue. Registered via skinRegistry; Classic and the other themes are untouched. --- internal/ui/phosphor.go | 124 +++++++++++++++++++++++++++++++++++ internal/ui/phosphor_test.go | 47 +++++++++++++ internal/ui/room.go | 23 +++---- internal/ui/skin.go | 19 +++--- internal/ui/styles.go | 7 +- 5 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 internal/ui/phosphor.go create mode 100644 internal/ui/phosphor_test.go diff --git a/internal/ui/phosphor.go b/internal/ui/phosphor.go new file mode 100644 index 0000000..c58fa81 --- /dev/null +++ b/internal/ui/phosphor.go @@ -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)) +} diff --git a/internal/ui/phosphor_test.go b/internal/ui/phosphor_test.go new file mode 100644 index 0000000..902d73c --- /dev/null +++ b/internal/ui/phosphor_test.go @@ -0,0 +1,47 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestPhosphorSkinChrome(t *testing.T) { + defer applyTheme("Classic") + + m := newTestModel(t) + m, _ = m.openLesson(0) + applyTheme("Phosphor") // openLesson ran under Classic; switch after building + + if _, ok := activeSkin().(phosphorSkin); !ok { + t.Fatalf("active skin = %T, want phosphorSkin", activeSkin()) + } + room := m.viewRoom() + if !strings.Contains(room, "│") { + t.Error("phosphor room should render a line-number gutter (│)") + } + if !strings.Contains(room, "ACT I /") { + t.Error("phosphor room header should use the reverse-chip act banner (ACT I / …)") + } + + // The classic look must not leak the gutter — proves the skin is the switch. + applyTheme("Classic") + if strings.Contains(m.viewRoom(), "│") { + t.Error("classic room must not have a line-number gutter") + } +} + +func TestPhosphorBossChrome(t *testing.T) { + defer applyTheme("Classic") + + m := newTestModel(t) + m.lessonIdx = bossLessonIdx(m, 3) + m, _ = m.startBoss() + applyTheme("Phosphor") + + boss := m.viewRoom() + for _, want := range []string{"!! BOSS !!", "T-MINUS"} { + if !strings.Contains(boss, want) { + t.Errorf("phosphor boss missing %q", want) + } + } +} diff --git a/internal/ui/room.go b/internal/ui/room.go index 31f1356..0a3d3ee 100644 --- a/internal/ui/room.go +++ b/internal/ui/room.go @@ -253,8 +253,8 @@ func (m Model) viewRoom() string { if m.isFreshPlayer() && !m.inBoss { b.WriteString(m.renderFirstTimeHelp() + "\n") } - b.WriteString(ch.Intro + "\n\n") - b.WriteString(activeSkin().FrameBuffer(m.bufferLines()) + "\n\n") + b.WriteString(activeSkin().Body(l.Act, ch.Intro) + "\n\n") + b.WriteString(activeSkin().FrameBuffer(l.Act, m.bufferLines()) + "\n\n") b.WriteString(m.renderHUD(ch) + "\n") if m.heartMsg != "" && !m.flash { b.WriteString(dangerStyle.Render(m.heartMsg) + "\n") @@ -295,23 +295,24 @@ func (m Model) renderFirstTimeHelp() string { // bufferLines renders the code buffer's rows (cursor and selection cells via the // active skin) without any indent or framing — the skin's FrameBuffer wraps them. func (m Model) bufferLines() []string { + act := m.lessons[m.lessonIdx].Act var lines []string for i, line := range m.sim.Buffer { switch { case m.flash: lines = append(lines, successStyle.Render(line)) case m.sim.Mode == engine.ModeVisual: - lines = append(lines, renderSelected(line, i, m.sim)) + lines = append(lines, renderSelected(act, line, i, m.sim)) case m.sim.Mode == engine.ModeVisualBlock: - lines = append(lines, renderBlockSelected(line, i, m.sim)) + lines = append(lines, renderBlockSelected(act, line, i, m.sim)) case i == m.sim.Cursor.Row: col := m.sim.Cursor.Col r := []rune(line) var s string if col >= len(r) { - s = string(r) + activeSkin().CursorCell(" ") + s = string(r) + activeSkin().CursorCell(act, " ") } else { - s = string(r[:col]) + activeSkin().CursorCell(string(r[col])) + string(r[col+1:]) + s = string(r[:col]) + activeSkin().CursorCell(act, string(r[col])) + string(r[col+1:]) } lines = append(lines, s) default: @@ -323,7 +324,7 @@ func (m Model) bufferLines() []string { // renderSelected reverse-videos the part of row's line that falls inside the // visual selection (inclusive, possibly spanning rows). -func renderSelected(line string, row int, sim *engine.Simulator) string { +func renderSelected(act int, line string, row int, sim *engine.Simulator) string { start, end := sim.Selection() if row < start.Row || row > end.Row { return line @@ -337,12 +338,12 @@ func renderSelected(line string, row int, sim *engine.Simulator) string { if row == end.Row { to = min(end.Col+1, len(r)) } - return string(r[:from]) + activeSkin().CursorCell(string(r[from:to])) + string(r[to:]) + return string(r[:from]) + activeSkin().CursorCell(act, string(r[from:to])) + string(r[to:]) } // renderBlockSelected reverse-videos the rectangular columns [c0,c1] on the // rows the visual block spans. -func renderBlockSelected(line string, row int, sim *engine.Simulator) string { +func renderBlockSelected(act int, line string, row int, sim *engine.Simulator) string { r0, r1, c0, c1 := sim.BlockSelection() if row < r0 || row > r1 { return line @@ -350,7 +351,7 @@ func renderBlockSelected(line string, row int, sim *engine.Simulator) string { r := []rune(line) from := min(c0, len(r)) to := min(c1+1, len(r)) - return string(r[:from]) + activeSkin().CursorCell(string(r[from:to])) + string(r[to:]) + return string(r[:from]) + activeSkin().CursorCell(act, string(r[from:to])) + string(r[to:]) } func (m Model) renderHUD(ch content.Challenge) string { @@ -380,7 +381,7 @@ func (m Model) renderHUD(ch content.Challenge) string { if !m.inBoss { segs = append(segs, segment{fmt.Sprintf("keys %d · par %d", m.keystrokes, ch.Par), segKeys}) } - return activeSkin().HUD(segs) + return activeSkin().HUD(m.lessons[m.lessonIdx].Act, segs) } func timerBar(left, total, width int) string { diff --git a/internal/ui/skin.go b/internal/ui/skin.go index 076cc5c..f45c64f 100644 --- a/internal/ui/skin.go +++ b/internal/ui/skin.go @@ -14,11 +14,12 @@ import ( // classicSkin (the game's original look) and vary only through the palette that // applyTheme installs. A theme registers a Skin only when it restyles layout. type Skin interface { - Logo(lines []string) string // title wordmark - RoomHeader(h roomHeader) string // act banner + title/boss + story/taunt + timer - CursorCell(s string) string // one cursor / selection cell - FrameBuffer(lines []string) string // wrap the buffer body (indent / border / gutter) - HUD(segs []segment) string // the room status bar + Logo(lines []string) string // title wordmark + 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 // one cursor / selection cell + FrameBuffer(act int, lines []string) string // wrap the buffer body (indent / border / gutter) + HUD(act int, segs []segment) string // the room status bar } // roomHeader is the semantic content of a room's top block; the skin lays it out. @@ -92,16 +93,18 @@ func (classicSkin) RoomHeader(h roomHeader) string { return b.String() } -func (classicSkin) CursorCell(s string) string { return cursorStyle.Render(s) } +func (classicSkin) Body(_ int, s string) string { return s } -func (classicSkin) FrameBuffer(lines []string) string { +func (classicSkin) CursorCell(_ int, s string) string { return cursorStyle.Render(s) } + +func (classicSkin) FrameBuffer(_ int, lines []string) string { for i, ln := range lines { lines[i] = " " + ln } return strings.Join(lines, "\n") } -func (classicSkin) HUD(segs []segment) string { +func (classicSkin) HUD(_ int, segs []segment) string { parts := make([]string, len(segs)) for i, s := range segs { if s.role == segRec { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 9c6b660..3111c20 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -43,9 +43,12 @@ var themeRegistry = []Theme{ Dim: "245", Success: "121", Danger: "203", }, { + // Phosphor leans monochrome: a dim green (not gray) so meta/footers stay + // in-hue. phosphorSkin adds the reverse-video CRT chrome + brightness + // ramps; these colors drive the color-only screens (map, stats). Name: "Phosphor", - Acts: map[int]Palette{1: {"46", "40"}, 2: {"220", "172"}, 3: {"51", "44"}, 4: {"141", "97"}}, - Dim: "240", Success: "46", Danger: "203", + Acts: map[int]Palette{1: {"46", "40"}, 2: {"220", "172"}, 3: {"51", "44"}, 4: {"201", "127"}}, + Dim: "28", Success: "46", Danger: "203", }, } From 2bf638a29a04fbaf5b8ee4260baa61cefae3bbc4 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 02:56:14 +0530 Subject: [PATCH 3/6] feat: full-fidelity Synthwave skin (neon arcade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit synthwaveSkin renders the Turn 2a mockup: a cyan→magenta gradient block logo, ▓▒░ dither act banners, a rounded buffer box with purple line numbers, an act-color block cursor, ▸▸ objective chevrons, neon background-chip HUD, and a BOSS » chip + TIME bar. Adds a shared roundedBox helper (reused by charm next). --- internal/ui/skin.go | 26 ++++++++ internal/ui/synthwave.go | 115 ++++++++++++++++++++++++++++++++++ internal/ui/synthwave_test.go | 24 +++++++ 3 files changed, 165 insertions(+) create mode 100644 internal/ui/synthwave.go create mode 100644 internal/ui/synthwave_test.go diff --git a/internal/ui/skin.go b/internal/ui/skin.go index f45c64f..521a8a9 100644 --- a/internal/ui/skin.go +++ b/internal/ui/skin.go @@ -3,6 +3,8 @@ package ui import ( "fmt" "strings" + + "github.com/charmbracelet/lipgloss" ) // Skin owns a theme's structural chrome. Renderers assemble semantic content — @@ -53,6 +55,30 @@ type segment struct { role segRole } +// roundedBox frames buffer lines in a rounded border, optionally with a +// line-number gutter. Shared by the skins that draw a bordered buffer +// (synthwave, charm). innerW is the character width between the borders. +func roundedBox(lines []string, innerW int, border, num lipgloss.Style, showNums bool) string { + bs := lipgloss.RoundedBorder() + rule := func(l, mid, r string) string { + return border.Render(l + strings.Repeat(mid, innerW) + r) + } + out := []string{rule(bs.TopLeft, bs.Top, bs.TopRight)} + for i, ln := range lines { + content := ln + if showNums { + content = num.Render(fmt.Sprintf("%2d ", i+1)) + ln + } + pad := innerW - 1 - lipgloss.Width(content) + if pad < 0 { + pad = 0 + } + out = append(out, border.Render(bs.Left)+" "+content+strings.Repeat(" ", pad)+border.Render(bs.Right)) + } + out = append(out, rule(bs.BottomLeft, bs.Bottom, bs.BottomRight)) + return strings.Join(out, "\n") +} + // skinRegistry maps a theme name to its structural skin. A theme absent here // renders through classicSkin, re-skinned only by its palette. var skinRegistry = map[string]Skin{} diff --git a/internal/ui/synthwave.go b/internal/ui/synthwave.go new file mode 100644 index 0000000..7b3faab --- /dev/null +++ b/internal/ui/synthwave.go @@ -0,0 +1,115 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func init() { skinRegistry["Synthwave"] = synthwaveSkin{} } + +// synthwaveSkin is the loudest theme: a cyan→magenta gradient logo, neon +// background chips, ▓▒░ dither banners, a rounded buffer box, and a cursor that +// takes the act color as a block. Arcade energy. +type synthwaveSkin struct{} + +const ( + swPurple = lipgloss.Color("141") // line numbers, meta + swDim = lipgloss.Color("60") // recessed chrome + swBoxBg = lipgloss.Color("237") // chip backgrounds + swInk = lipgloss.Color("233") // text on a neon chip + swHot = lipgloss.Color("205") // boss / danger magenta +) + +// swChip is a background-tinted HUD/banner chip. +func swChip(bg, fg lipgloss.Color, s string) string { + return lipgloss.NewStyle().Background(bg).Foreground(fg).Render(s) +} + +func (synthwaveSkin) Logo(_ []string) string { + grad := []lipgloss.Color{"51", "81", "141", "213"} // cyan → magenta + lines := []string{ + ` █▄ █ █ █ █ █▀▄▀█ █▀█ █ █ █▀▀ █▀ ▀█▀`, + ` █ ▀█ ▀▄▀ █ █ ▀ █ ▀▀█ █▄█ ██▄ ▄█ █ `, + } + var b strings.Builder + for _, ln := range lines { + r := []rune(ln) + for i, c := range grad { + lo, hi := i*len(r)/len(grad), (i+1)*len(r)/len(grad) + b.WriteString(lipgloss.NewStyle().Foreground(c).Bold(true).Render(string(r[lo:hi]))) + } + b.WriteString("\n") + } + return b.String() +} + +func (synthwaveSkin) RoomHeader(h roomHeader) string { + act := h.act + pal := paletteFor(act) + banner := swChip(pal.Primary, swInk, " ACT "+roman(act)+" ") + + lipgloss.NewStyle().Foreground(pal.Primary).Render("▓▒░ ") + + lipgloss.NewStyle().Foreground(pal.Primary).Bold(true).Render(actName(act)) + var b strings.Builder + if h.isBoss { + right := swChip(swHot, swInk, " BOSS » "+strings.ToUpper(h.title)+" ") + b.WriteString(spread(banner, right, 72) + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" "+h.taunt) + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" TIME ") + + swTimeBar(act, h.timeLeft, h.timeTotal) + + lipgloss.NewStyle().Foreground(pal.Accent).Bold(true).Render(fmt.Sprintf(" %d:%02d", h.timeLeft/60, h.timeLeft%60)) + + lipgloss.NewStyle().Foreground(swDim).Render(fmt.Sprintf(" step %d/%d", h.step, h.steps)) + "\n\n") + } else { + right := lipgloss.NewStyle().Foreground(swDim).Render(fmt.Sprintf("room %d/%d", h.roomIndex, h.roomTotal)) + b.WriteString(spread(banner, right, 72) + "\n") + b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" "+h.story) + "\n\n") + } + return b.String() +} + +func (synthwaveSkin) Body(act int, s string) string { + pal := paletteFor(act) + return lipgloss.NewStyle().Foreground(pal.Accent).Bold(true).Render("▸▸ ") + + lipgloss.NewStyle().Foreground(pal.Primary).Render(s) +} + +func (synthwaveSkin) CursorCell(act int, s string) string { + return lipgloss.NewStyle().Background(paletteFor(act).Primary).Foreground(swInk).Render(s) +} + +func (synthwaveSkin) FrameBuffer(act int, lines []string) string { + border := lipgloss.NewStyle().Foreground(swDim) + num := lipgloss.NewStyle().Foreground(swPurple) + return roundedBox(lines, 72, border, num, true) +} + +func (synthwaveSkin) HUD(act int, segs []segment) string { + pal := paletteFor(act) + var b strings.Builder + for _, s := range segs { + text := " " + strings.TrimSpace(s.text) + " " + switch s.role { + case segMode: + b.WriteString(swChip(pal.Primary, swInk, text)) + default: + b.WriteString(swChip(swBoxBg, pal.Accent, text)) + } + b.WriteString(" ") + } + return strings.TrimRight(b.String(), " ") +} + +func swTimeBar(act, left, total int) string { + const w = 23 + filled := 0 + if total > 0 { + filled = left * w / total + } + if filled > w { + filled = w + } + pal := paletteFor(act) + return lipgloss.NewStyle().Foreground(pal.Accent).Render(strings.Repeat("█", filled)) + + lipgloss.NewStyle().Foreground(swDim).Render(strings.Repeat("░", w-filled)) +} diff --git a/internal/ui/synthwave_test.go b/internal/ui/synthwave_test.go new file mode 100644 index 0000000..f8d25c4 --- /dev/null +++ b/internal/ui/synthwave_test.go @@ -0,0 +1,24 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestSynthwaveSkinChrome(t *testing.T) { + defer applyTheme("Classic") + + m := newTestModel(t) + m, _ = m.openLesson(0) + applyTheme("Synthwave") + + if _, ok := activeSkin().(synthwaveSkin); !ok { + t.Fatalf("active skin = %T, want synthwaveSkin", activeSkin()) + } + room := m.viewRoom() + for _, want := range []string{"▓▒░", "╭", "▸▸"} { // dither banner, rounded box, objective chevrons + if !strings.Contains(room, want) { + t.Errorf("synthwave room missing %q", want) + } + } +} From c5ba5b9b3ba0762248e88b9cb42e3d34b3c6ac45 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 02:58:23 +0530 Subject: [PATCH 4/6] feat: full-fidelity Charm skin (cozy pastel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit charmSkin renders the Turn 2b mockup: a ✧ nvim─quest ✧ wordmark, lowercase "act i · the cursor dojo" banners, a ♥ objective line, rounded act-colored buffer borders, spaced pastel pills for the HUD, a pink block cursor, and encouraging copy ("(rude!)", "you've got this"). Reuses the shared roundedBox. --- internal/ui/charm.go | 100 ++++++++++++++++++++++++++++++++++++++ internal/ui/charm_test.go | 27 ++++++++++ 2 files changed, 127 insertions(+) create mode 100644 internal/ui/charm.go create mode 100644 internal/ui/charm_test.go diff --git a/internal/ui/charm.go b/internal/ui/charm.go new file mode 100644 index 0000000..b1cde83 --- /dev/null +++ b/internal/ui/charm.go @@ -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, 72) + "\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, 72) + "\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, 72, 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)) +} diff --git a/internal/ui/charm_test.go b/internal/ui/charm_test.go new file mode 100644 index 0000000..54ac641 --- /dev/null +++ b/internal/ui/charm_test.go @@ -0,0 +1,27 @@ +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") + } +} From bdd509f2e8fb373beef3c968b8a4f165b61968e2 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 02:59:56 +0530 Subject: [PATCH 5/6] docs: skin architecture spec + README/ARCHITECTURE for full-fidelity themes --- README.md | 6 +- docs/ARCHITECTURE.md | 6 +- ...-07-04-theme-skins-full-fidelity-design.md | 67 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-04-theme-skins-full-fidelity-design.md diff --git a/README.md b/README.md index 6ee833b..8e05259 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,10 @@ Your progress is saved to `~/.nvim-quest/progress.json`. You move through a handful of screens: a **welcome** intro (first launch only), the **world map**, a **room** (a single challenge), a **boss** fight, a **results** -screen, and a **theme** picker (from the title menu — switch the game's colors any -time; your progress is untouched). +screen, and a **theme** picker (from the title menu — restyle the game any time; +your progress is untouched). Four themes ship: **Classic**, **Synthwave** (neon +arcade), **Charm** (cozy pastel), and **Phosphor** (single-color CRT). Each fully +re-skins the room chrome — banners, HUD, cursor, buffer frame — not just colors. The world map is an **accordion**: cleared and locked acts collapse to a one-line summary and only the current act expands, so it stays readable no matter how many diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 351d47a..2c01e98 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,7 +30,11 @@ internal/ ui/ Bubble Tea front end (the only package that imports all others) app.go root Model, screen enum, Update/View dispatch keys.go normalizeKey (Bubble Tea key names → engine vocabulary) - styles.go selectable color themes + per-act palettes + styles.go theme registry (palettes + role colors) + applyTheme + skin.go Skin interface: per-theme structural chrome + classicSkin + phosphor.go Phosphor skin — single-color CRT (reverse-video, gutter) + synthwave.go Synthwave skin — neon arcade (gradient logo, dither, box) + charm.go Charm skin — cozy pastel (lowercase, pills, rounded box) themes.go theme picker screen (persists to progress.json) welcome.go first-launch welcome screen title.go title menu + stats screen diff --git a/docs/superpowers/specs/2026-07-04-theme-skins-full-fidelity-design.md b/docs/superpowers/specs/2026-07-04-theme-skins-full-fidelity-design.md new file mode 100644 index 0000000..1d24126 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-theme-skins-full-fidelity-design.md @@ -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). From 1ac96b20f64fd57a4f910bc19f2b0f4d8ec53bc9 Mon Sep 17 00:00:00 2001 From: StrangeNoob Date: Sat, 4 Jul 2026 03:13:01 +0530 Subject: [PATCH 6/6] fix: address CodeRabbit review on PR #13 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roundedBox now grows to fit its widest line (capped at 76) so a long buffer line can never push the right border out of column (was clamped-pad only). - Extract the shared roomInnerW=72 width const (was a magic literal in three spots across synthwave/charm). - README: clarify only gameplay progress is untouched — the theme choice is saved. - Add charm boss-room chrome coverage. Skipped (with reason): WriteString→Fprintf style nits (matches the codebase's existing pattern), per-render activeSkin() caching (a trivial map lookup), and synthwave's single non-mode HUD role (intentional — one chip style for stats). --- README.md | 2 +- internal/ui/charm.go | 6 +++--- internal/ui/charm_test.go | 16 ++++++++++++++++ internal/ui/skin.go | 31 ++++++++++++++++++++++++------- internal/ui/synthwave.go | 6 +++--- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8e05259..3c2fc26 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Your progress is saved to `~/.nvim-quest/progress.json`. You move through a handful of screens: a **welcome** intro (first launch only), the **world map**, a **room** (a single challenge), a **boss** fight, a **results** screen, and a **theme** picker (from the title menu — restyle the game any time; -your progress is untouched). Four themes ship: **Classic**, **Synthwave** (neon +your gameplay progress is untouched, and the theme choice is saved). Four themes ship: **Classic**, **Synthwave** (neon arcade), **Charm** (cozy pastel), and **Phosphor** (single-color CRT). Each fully re-skins the room chrome — banners, HUD, cursor, buffer frame — not just colors. diff --git a/internal/ui/charm.go b/internal/ui/charm.go index b1cde83..20da998 100644 --- a/internal/ui/charm.go +++ b/internal/ui/charm.go @@ -37,14 +37,14 @@ func (charmSkin) RoomHeader(h roomHeader) string { var b strings.Builder if h.isBoss { right := charmPill(charmPeach, charmInk, "boss: "+strings.ToLower(h.title)) - b.WriteString(spread(title, right, 72) + "\n") + 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, 72) + "\n") + b.WriteString(spread(title, right, roomInnerW) + "\n") b.WriteString(dimStyle.Render(" "+h.story) + "\n\n") } return b.String() @@ -62,7 +62,7 @@ func (charmSkin) CursorCell(_ int, s string) string { 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, 72, border, num, true) + return roundedBox(lines, roomInnerW, border, num, true) } func (charmSkin) HUD(act int, segs []segment) string { diff --git a/internal/ui/charm_test.go b/internal/ui/charm_test.go index 54ac641..d425faa 100644 --- a/internal/ui/charm_test.go +++ b/internal/ui/charm_test.go @@ -25,3 +25,19 @@ func TestCharmSkinChrome(t *testing.T) { 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) + } + } +} diff --git a/internal/ui/skin.go b/internal/ui/skin.go index 521a8a9..5f363ce 100644 --- a/internal/ui/skin.go +++ b/internal/ui/skin.go @@ -55,20 +55,37 @@ type segment struct { role segRole } +// roomInnerW is the character width the bordered-buffer skins lay out to. It +// sits inside the 80-col play area with room for the border and screen padding. +const roomInnerW = 72 + // roundedBox frames buffer lines in a rounded border, optionally with a // line-number gutter. Shared by the skins that draw a bordered buffer -// (synthwave, charm). innerW is the character width between the borders. -func roundedBox(lines []string, innerW int, border, num lipgloss.Style, showNums bool) string { +// (synthwave, charm). minInner is the minimum width between the borders; the box +// grows to fit its widest line (capped) so a long line can never push the right +// border out of column. +func roundedBox(lines []string, minInner int, border, num lipgloss.Style, showNums bool) string { + const maxInner = 76 + rendered := make([]string, len(lines)) + innerW := minInner + for i, ln := range lines { + rendered[i] = ln + if showNums { + rendered[i] = num.Render(fmt.Sprintf("%2d ", i+1)) + ln + } + if w := lipgloss.Width(rendered[i]) + 1; w > innerW { // +1 for the leading space + innerW = w + } + } + if innerW > maxInner { + innerW = maxInner + } bs := lipgloss.RoundedBorder() rule := func(l, mid, r string) string { return border.Render(l + strings.Repeat(mid, innerW) + r) } out := []string{rule(bs.TopLeft, bs.Top, bs.TopRight)} - for i, ln := range lines { - content := ln - if showNums { - content = num.Render(fmt.Sprintf("%2d ", i+1)) + ln - } + for _, content := range rendered { pad := innerW - 1 - lipgloss.Width(content) if pad < 0 { pad = 0 diff --git a/internal/ui/synthwave.go b/internal/ui/synthwave.go index 7b3faab..eacf918 100644 --- a/internal/ui/synthwave.go +++ b/internal/ui/synthwave.go @@ -54,7 +54,7 @@ func (synthwaveSkin) RoomHeader(h roomHeader) string { var b strings.Builder if h.isBoss { right := swChip(swHot, swInk, " BOSS » "+strings.ToUpper(h.title)+" ") - b.WriteString(spread(banner, right, 72) + "\n") + b.WriteString(spread(banner, right, roomInnerW) + "\n") b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" "+h.taunt) + "\n") b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" TIME ") + swTimeBar(act, h.timeLeft, h.timeTotal) + @@ -62,7 +62,7 @@ func (synthwaveSkin) RoomHeader(h roomHeader) string { lipgloss.NewStyle().Foreground(swDim).Render(fmt.Sprintf(" step %d/%d", h.step, h.steps)) + "\n\n") } else { right := lipgloss.NewStyle().Foreground(swDim).Render(fmt.Sprintf("room %d/%d", h.roomIndex, h.roomTotal)) - b.WriteString(spread(banner, right, 72) + "\n") + b.WriteString(spread(banner, right, roomInnerW) + "\n") b.WriteString(lipgloss.NewStyle().Foreground(swDim).Render(" "+h.story) + "\n\n") } return b.String() @@ -81,7 +81,7 @@ func (synthwaveSkin) CursorCell(act int, s string) string { func (synthwaveSkin) FrameBuffer(act int, lines []string) string { border := lipgloss.NewStyle().Foreground(swDim) num := lipgloss.NewStyle().Foreground(swPurple) - return roundedBox(lines, 72, border, num, true) + return roundedBox(lines, roomInnerW, border, num, true) } func (synthwaveSkin) HUD(act int, segs []segment) string {