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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down
6 changes: 5 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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).
100 changes: 100 additions & 0 deletions internal/ui/charm.go
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))
}
43 changes: 43 additions & 0 deletions internal/ui/charm_test.go
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)
}
}
}
124 changes: 124 additions & 0 deletions internal/ui/phosphor.go
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))
}
Loading
Loading