Overhaul the theme and styling system across all puzzle packages: - Introduce WCAG-compliant color palettes with contrast utilities - Unify style accessors through the theme package - Clean up unused imports and dead code across all packages - Update README and AGENTS.md documentation
- commit
- d4c9ea720465c6bb43c9d70874fb552587b96959
- parent
- 6b311561cb5430a4a618e8ab875f89d80bb776c8
- author
- Dami <dami.etoile@gmail.com>
@@ -45,7 +45,7 @@ puzzletea/
4545 ├── theme/ # Color theming (WCAG-compliant palettes, contrast utils)4646 ├── stats/ # XP/level math, streaks, card rendering4747 ├── game/ # Plugin interfaces, cursor, keys, style, border helpers48-├── store/ # SQLite persistence (~/.puzzletea/puzzletea.db)48+├── store/ # SQLite persistence (~/.puzzletea/history.db)4949 ├── ui/ # Shared UI: menu list, main menu, table, panel, styles5050 ├── daily/ # Daily puzzle seeding, RNG, mode selection5151 ├── resolve/ # CLI argument resolution (category/mode name matching)@@ -2,10 +2,21 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Eight puzzle types, multiple difficulty modes, save/load persistence, and a plugin architecture for adding new games.5+Eight puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and a plugin architecture for adding new games.66 77 88 9+## Features10+11+- **8 puzzle games** -- Nonogram, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu12+- **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.13+- **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP.14+- **Stats dashboard** -- Profile level, daily streak, victory counts, and XP progress bars per category.15+- **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.16+- **Mouse support** -- Click and drag in Nonogram, Shikaku, Word Search, and Lights Out.17+- **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.18+- **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.19+920 ## Games1021 1122 | Game | Description | Modes |@@ -17,65 +28,7 @@ Eight puzzle types, multiple difficulty modes, save/load persistence, and a plug
1728 | **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids |1829 | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |1930 | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |20-| **Takuzu** | Fill grid with two symbols (●/○) | 7 modes from 6x6 to 14x14 |21-22-## Previews23-24-### Nonogram25-Fill cells to match row and column hints.26-27-28-29-[Game details and controls](nonogram/README.md)30-31-### Shikaku32-Divide the grid into rectangles, where each rectangle contains exactly the number of cells shown in its clue.33-34-35-36-[Game details and controls](shikaku/README.md)37-38-### Sudoku39-Classic 9x9 number placement puzzle.40-41-42-43-[Game details and controls](sudoku/README.md)44-45-### Word Search46-Find hidden words in a letter grid.47-48-49-50-[Game details and controls](wordsearch/README.md)51-52-### Hashiwokakero53-Connect islands with bridges.54-55-56-57-[Game details and controls](hashiwokakero/README.md)58-59-### Hitori60-Shade cells to eliminate duplicate numbers.61-62-63-64-[Game details and controls](hitori/README.md)65-66-### Lights Out67-Toggle lights to turn all off.68-69-70-71-[Game details and controls](lightsout/README.md)72-73-### Takuzu74-Fill the grid with two symbols following three simple rules.75-76-77-78-[Game details and controls](takuzu/README.md)31+| **Takuzu** | Fill grid with two symbols | 7 modes from 6x6 to 14x14 |7932 8033 ## Install8134 @@ -125,7 +78,7 @@ Launch the interactive menu:
12578 puzzletea12679 ```12780 128-Or jump straight into a game:81+Start a new game directly:12982 13083 ```bash13184 puzzletea new nonogram medium@@ -134,20 +87,37 @@ puzzletea new lights-out
13487 puzzletea new hashi easy13588 ```13689 137-Manage saved games:90+Resume and manage saved games:13891 13992 ```bash14093 puzzletea list # show saved games94+puzzletea list --all # include abandoned games14195 puzzletea continue amber-falcon # resume by name14296 ```14397 144-Flag aliases also work:98+Use a seed for deterministic puzzle generation:99+100+```bash101+puzzletea new --set-seed myseed102+```103+104+Override the color theme:105+106+```bash107+puzzletea --theme "Catppuccin Mocha"108+```109+110+Flag aliases on the root command also work:145111 146112 ```bash147113 puzzletea --new nonogram:medium148114 puzzletea --continue amber-falcon149115 ```150116 117+### CLI Aliases118+119+Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.120+151121 ## Controls152122 153123 ### Global@@ -157,18 +127,81 @@ puzzletea --continue amber-falcon
157127 | `Enter` | Select |158128 | `Escape` | Go back |159129 | `Ctrl+N` | Return to main menu |160-| `Ctrl+C` | Quit |161-| `Ctrl+E` | Toggle debug overlay |130+| `Ctrl+R` | Reset puzzle |162131 | `Ctrl+H` | Toggle full help |132+| `Ctrl+E` | Toggle debug overlay |133+| `Ctrl+C` | Quit |163134 164135 ### Navigation165136 166-Arrow keys, WASD, and Vim bindings (hjkl) are supported for grid movement across all games.137+Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement across all games.138+139+### Mouse140+141+Nonogram, Shikaku, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details.167142 168143 ## Game Persistence169144 170145 Games are automatically saved to `~/.puzzletea/history.db` (SQLite). Navigating away saves progress; quitting with `Ctrl+C` marks the game as abandoned. Completed games are preserved and can be revisited.171146 147+## Previews148+149+### Nonogram150+Fill cells to match row and column hints.151+152+153+154+[Game details and controls](nonogram/README.md)155+156+### Shikaku157+Divide the grid into rectangles, where each rectangle contains exactly the number of cells shown in its clue.158+159+160+161+[Game details and controls](shikaku/README.md)162+163+### Sudoku164+Classic 9x9 number placement puzzle.165+166+167+168+[Game details and controls](sudoku/README.md)169+170+### Word Search171+Find hidden words in a letter grid.172+173+174+175+[Game details and controls](wordsearch/README.md)176+177+### Hashiwokakero178+Connect islands with bridges.179+180+181+182+[Game details and controls](hashiwokakero/README.md)183+184+### Hitori185+Shade cells to eliminate duplicate numbers.186+187+188+189+[Game details and controls](hitori/README.md)190+191+### Lights Out192+Toggle lights to turn all off.193+194+195+196+[Game details and controls](lightsout/README.md)197+198+### Takuzu199+Fill the grid with two symbols following three simple rules.200+201+202+203+[Game details and controls](takuzu/README.md)204+172205 ## Building and Testing173206 174207 [just](https://github.com/casey/just) is used as the command runner:@@ -177,6 +210,7 @@ Games are automatically saved to `~/.puzzletea/history.db` (SQLite). Navigating
177210 just # build178211 just run # build and run179212 just test # run tests (go test ./...)213+just test-short # run tests, skipping slow generator tests180214 just lint # run golangci-lint181215 just fmt # format with gofumpt182216 just tidy # go mod tidy@@ -218,7 +252,7 @@ Create a directory (e.g., `mypuzzle/`) with these files:
218252 219253 These files in the project root must be edited:220254 221-- **`model.go`**: Import the package and add a `game.Category` entry to `GameCategories` (maintain alphabetical order). The import triggers `init()`, which registers save/load — no blank import needed.255+- **`model.go`**: Import the package and add a `game.Category` entry to `GameCategories` (maintain alphabetical order). The import triggers `init()`, which registers save/load -- no blank import needed.222256 - **`resolve.go`**: Add the canonical name and any CLI aliases to `categoryAliases`.223257 224258 ### 3. Add a VHS preview@@ -232,11 +266,11 @@ These files in the project root must be edited:
232266 just fmt && just lint && just test233267 ```234268 235-See any existing game package (e.g., `nonogram/`) for the full pattern, and `CLAUDE.md` for detailed conventions.269+See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGENTS.md` for detailed conventions.236270 237271 ## License238272 239-See [LICENSE](LICENSE) for details.273+[MIT](LICENSE)240274 241275 ## Built With242276 @@ -20,7 +20,9 @@ func (m model) View() tea.View {
2020 case playMenuView:2121 content = ui.CenterView(m.width, m.height, m.playMenu.ViewAsPanel("Play"))2222 case optionsMenuView:23- content = ui.CenterView(m.width, m.height, m.optionsMenu.ViewAsPanel("Options"))23+ items := m.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")24+ panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")25+ content = ui.CenterView(m.width, m.height, panel)2426 case seedInputView:2527 panel := ui.Panel(2628 "Enter Seed",@@ -25,7 +25,7 @@ func DefaultBorderColors() GridBorderColors {
2525 BackgroundBG: p.BG,2626 CrosshairBG: p.Surface,2727 SolvedBorderFG: p.SuccessBorder,28- SolvedBG: p.Success,28+ SolvedBG: p.SuccessBG,2929 }3030 }3131 @@ -53,7 +53,6 @@ type BaseMode struct {
5353 description string5454 }5555 56-// NewBaseMode creates a BaseMode with the given title and description.5756 func NewBaseMode(title, description string) BaseMode {5857 return BaseMode{title: title, description: description}5958 }@@ -87,7 +86,6 @@ type HelpToggleMsg struct{ Show bool }
8786 // Registry maps game type names to their import functions.8887 var Registry = map[string]func([]byte) (Gamer, error){}8988 90-// Register adds an import function for a game type to the registry.9189 func Register(name string, fn func([]byte) (Gamer, error)) {9290 Registry[name] = fn9391 }@@ -12,15 +12,13 @@ import (
1212 // Shared cursor / conflict color accessors. Puzzle packages that compose1313 // styles on top of the cursor colors read these at render time.1414 15-func CursorFG() color.Color { return theme.Current().AccentText }16-func CursorBG() color.Color { return theme.Current().Accent }17-func CursorWarmFG() color.Color { return theme.Current().WarmText }18-func CursorWarmBG() color.Color { return theme.Current().Warm }19-func ConflictFG() color.Color { return theme.Current().Error }20-func ConflictBG() color.Color { return theme.Current().ErrorBG }15+func CursorFG() color.Color { return theme.Current().AccentText }16+func CursorBG() color.Color { return theme.Current().AccentBG }17+func ConflictFG() color.Color { return theme.Current().Error }18+func ConflictBG() color.Color { return theme.Current().ErrorBG }19+func SolvedFG() color.Color { return theme.Current().SolvedFG }2120 2221 // CursorStyle highlights the cursor position with an accent background.23-// Used by lightsout, sudoku, wordsearch.2422 func CursorStyle() lipgloss.Style {2523 return lipgloss.NewStyle().2624 Bold(true).@@ -28,24 +26,21 @@ func CursorStyle() lipgloss.Style {
2826 Background(CursorBG())2927 }3028 31-// CursorWarmStyle highlights the cursor with a warmer background.32-// Used by hitori, takuzu, nonogram.33-func CursorWarmStyle() lipgloss.Style {34- return lipgloss.NewStyle().35- Bold(true).36- Foreground(CursorWarmFG()).37- Background(CursorWarmBG())38-}39-4029 // CursorSolvedStyle highlights the cursor position on a solved grid.41-// Shared across all puzzle types.4230 func CursorSolvedStyle() lipgloss.Style {4331 return lipgloss.NewStyle().4432 Bold(true).4533 Foreground(CursorFG()).46- Background(theme.Current().Success)34+ Background(theme.Current().SuccessBG)4735 }4836 37+// Bracket markers for cursor identification. These are prepended/appended38+// to cell content so the cursor is identifiable independent of BG contrast.39+const (40+ CursorLeft = "\u25b8" // ▸41+ CursorRight = "\u25c2" // ◂42+)43+4944 // StatusBarStyle returns the shared style for the status/help bar below each puzzle grid.5045 func StatusBarStyle() lipgloss.Style {5146 return lipgloss.NewStyle().@@ -17,7 +17,6 @@ func init() {
1717 })1818 }1919 20-// HashiMode defines a hashiwokakero difficulty/configuration.2120 type HashiMode struct {2221 game.BaseMode2322 Width int@@ -27,9 +26,9 @@ type HashiMode struct {
2726 }2827 2928 var (30- _ game.Mode = HashiMode{} // compile-time interface check31- _ game.Spawner = HashiMode{} // compile-time interface check32- _ game.SeededSpawner = HashiMode{} // compile-time interface check29+ _ game.Mode = HashiMode{}30+ _ game.Spawner = HashiMode{}31+ _ game.SeededSpawner = HashiMode{}3332 )3433 3534 func NewMode(title, description string, width, height, minIslands, maxIslands int) HashiMode {@@ -73,7 +72,6 @@ var Modes = []list.Item{
7372 NewMode("Hard 13x13", "13x13 grid with 59-68 islands.", 13, 13, 59, 68),7473 }7574 76-// DailyModes is the subset of Modes eligible for daily puzzle rotation.7775 var DailyModes = []list.Item{7876 Modes[3], // Easy 9x97977 Modes[1], // Medium 7x7@@ -121,6 +121,9 @@ func (m Model) cycleBridge(dx, dy int) Model {
121121 }122122 123123 m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)124+ if m.puzzle.IsSolved() {125+ m.selectedIsland = nil126+ }124127 return m125128 }126129 @@ -26,6 +26,7 @@ bridges and all islands are connected.
2626 |-----|--------|2727 | Arrow keys / WASD / hjkl | Jump to nearest island |2828 | `Enter` / `Space` | Select island for bridging |29+| `Ctrl+R` | Reset puzzle |2930 | `Ctrl+H` | Toggle full help |3031 | `Ctrl+E` | Toggle debug overlay |3132 | `Ctrl+N` | Return to main menu |@@ -37,27 +37,22 @@ func tryGenerateSeeded(width, height, islandCount int, rng *rand.Rand) *Puzzle {
3737 Islands: islands,3838 }3939 40- // Build a spanning tree to ensure connectivity4140 if !buildSpanningTreeSeeded(p, rng) {4241 return nil4342 }4443 45- // Add extra bridges for complexity4644 addExtraBridgesSeeded(p, rng)4745 48- // Set required counts from the solution4946 for i := range p.Islands {5047 p.Islands[i].Required = p.BridgeCount(p.Islands[i].ID)5148 }5249 53- // Verify all islands have Required >= 15450 for _, isl := range p.Islands {5551 if isl.Required < 1 {5652 return nil5753 }5854 }5955 60- // Clear bridges — player starts with none6156 p.Bridges = nil6257 p.invalidateCache()6358 @@ -128,7 +123,6 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
128123 return true129124 }130125 131- // Find all valid pairs (share row or column, no island in between)132126 type pair struct{ id1, id2 int }133127 var pairs []pair134128 @@ -141,14 +135,12 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
141135 continue // not aligned142136 }143137 144- // Check no island in between145138 if isDirectlyConnectable(p, a, b) {146139 pairs = append(pairs, pair{a.ID, b.ID})147140 }148141 }149142 }150143 151- // Shuffle pairs for randomness152144 rng.Shuffle(len(pairs), func(i, j int) {153145 pairs[i], pairs[j] = pairs[j], pairs[i]154146 })@@ -301,7 +293,6 @@ func isDirectlyConnectable(p *Puzzle, a, b Island) bool {
301293 return false302294 }303295 304-// abs returns the absolute value of an integer.305296 func abs(x int) int {306297 if x < 0 {307298 return -x@@ -120,11 +120,9 @@ func (p *Puzzle) FindAdjacentIsland(fromID, dx, dy int) *Island {
120120 }121121 x, y := from.X+dx, from.Y+dy122122 for x >= 0 && x < p.Width && y >= 0 && y < p.Height {123- // Check for island at this position124123 if isl := p.FindIslandAt(x, y); isl != nil {125124 return isl126125 }127- // Check if a perpendicular bridge crosses this cell128126 ci := p.CellContent(x, y)129127 if dx == 0 && ci.Kind == cellBridgeH {130128 return nil // horizontal bridge blocks vertical ray@@ -146,7 +144,6 @@ func (p *Puzzle) WouldCross(id1, id2 int) bool {
146144 return true147145 }148146 149- // Determine direction of new bridge150147 horizontal := isl1.Y == isl2.Y151148 vertical := isl1.X == isl2.X152149 if !horizontal && !vertical {@@ -2,94 +2,90 @@ package hashiwokakero
22 33 import (44 "fmt"5+ "image/color"56 "strings"67 78 "charm.land/lipgloss/v2"8- "charm.land/lipgloss/v2/compat"99 "github.com/FelineStateMachine/puzzletea/game"1010 "github.com/FelineStateMachine/puzzletea/theme"1111 )1212 1313 const cellWidth = 31414 15-// Color palette — warm earth-tone theme (ANSI 256, adaptive for light/dark terminals)16-var (17- colorIslandFg = compat.AdaptiveColor{Dark: lipgloss.Color("236"), Light: lipgloss.Color("187")}18- colorIslandBg = compat.AdaptiveColor{Dark: lipgloss.Color("187"), Light: lipgloss.Color("58")}19- colorSatisfiedFg = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}20- colorSatisfiedBg = compat.AdaptiveColor{Dark: lipgloss.Color("194"), Light: lipgloss.Color("236")}21- colorOverFg = compat.AdaptiveColor{Dark: lipgloss.Color("160"), Light: lipgloss.Color("167")}22- colorOverBg = compat.AdaptiveColor{Dark: lipgloss.Color("224"), Light: lipgloss.Color("52")}23- colorAdjacentBg = compat.AdaptiveColor{Dark: lipgloss.Color("223"), Light: lipgloss.Color("58")}24- colorBridge = compat.AdaptiveColor{Dark: lipgloss.Color("180"), Light: lipgloss.Color("95")}25- colorBridgeSolved = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}26- colorEmptyDot = compat.AdaptiveColor{Dark: lipgloss.Color("252"), Light: lipgloss.Color("239")}27- colorEmptySolved = compat.AdaptiveColor{Dark: lipgloss.Color("151"), Light: lipgloss.Color("107")}28- colorInfoSatisfied = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}29- colorInfoText = compat.AdaptiveColor{Dark: lipgloss.Color("180"), Light: lipgloss.Color("95")}30-)31-32-var (33- baseStyle = lipgloss.NewStyle()34-35- islandDefaultStyle = baseStyle.36- Foreground(colorIslandFg).37- Background(colorIslandBg).38- Bold(true)39-40- islandSatisfiedStyle = baseStyle.41- Foreground(colorSatisfiedFg).42- Background(colorSatisfiedBg).43- Bold(true)15+func islandColors() []color.Color { return theme.Current().CardColors() }4416 45- islandOverStyle = baseStyle.46- Foreground(colorOverFg).47- Background(colorOverBg).48- Bold(true)49-50- // islandCursorStyle and islandCursorSolvedStyle resolved at render51- // time via game.CursorStyle() and game.CursorSolvedStyle().17+func islandDefaultStyle(islandID int) lipgloss.Style {18+ colors := islandColors()19+ bg := colors[islandID%len(colors)]20+ return lipgloss.NewStyle().21+ Foreground(theme.TextOnBG(bg)).22+ Background(bg).23+ Bold(true)24+}5225 53- // islandSelectedStyle resolved at render time via islandSelectedStyle().26+func islandSatisfiedStyle() lipgloss.Style {27+ p := theme.Current()28+ return lipgloss.NewStyle().29+ Foreground(p.SuccessBorder).30+ Background(p.SuccessBG).31+ Bold(true)32+}5433 55- islandAdjacentStyle = baseStyle.56- Foreground(colorIslandFg).57- Background(colorAdjacentBg).58- Bold(true)34+func islandOverStyle() lipgloss.Style {35+ p := theme.Current()36+ return lipgloss.NewStyle().37+ Foreground(p.Error).38+ Background(p.ErrorBG).39+ Bold(true)40+}5941 60- bridgeHStyle = baseStyle.61- Foreground(colorBridge)42+// islandHighlightBGStyle returns the style for adjacent/neighbor islands.6243 63- bridgeVStyle = baseStyle.64- Foreground(colorBridge)44+func islandAdjacentStyle(islandID int) lipgloss.Style {45+ colors := islandColors()46+ bg := colors[islandID%len(colors)]47+ return lipgloss.NewStyle().48+ Foreground(theme.TextOnBG(bg)).49+ Background(bg).50+ Bold(true)51+}6552 66- bridgeHSolvedStyle = baseStyle.67- Foreground(colorBridgeSolved)53+func bridgeStyle() lipgloss.Style {54+ return lipgloss.NewStyle().55+ Foreground(theme.Current().Linked)56+}6857 69- bridgeVSolvedStyle = baseStyle.70- Foreground(colorBridgeSolved)58+func bridgeSolvedStyle() lipgloss.Style {59+ return lipgloss.NewStyle().60+ Foreground(theme.Current().SuccessBorder)61+}7162 72- emptyStyle = baseStyle.73- Foreground(colorEmptyDot)63+func emptyDotStyle() lipgloss.Style {64+ return lipgloss.NewStyle().65+ Foreground(theme.Current().TextDim)66+}7467 75- gridBorderStyle = lipgloss.NewStyle().76- Border(lipgloss.RoundedBorder()).77- Padding(1).78- BorderForeground(colorEmptyDot)68+func gridBorderStyle() lipgloss.Style {69+ return lipgloss.NewStyle().70+ Border(lipgloss.RoundedBorder()).71+ Padding(1).72+ BorderForeground(theme.Current().TextDim)73+}7974 80- gridBorderSolvedStyle = lipgloss.NewStyle().81- Border(lipgloss.RoundedBorder()).82- Padding(1).83- BorderForeground(colorBridgeSolved)84-)75+func gridBorderSolvedStyle() lipgloss.Style {76+ return lipgloss.NewStyle().77+ Border(lipgloss.RoundedBorder()).78+ Padding(1).79+ BorderForeground(theme.Current().SuccessBorder)80+}8581 8682 // islandSelectedStyle returns the style for a selected island, using the8783 // current theme's Highlight color for the background.8884 func islandSelectedStyle() lipgloss.Style {89- bg := theme.Current().Highlight90- return baseStyle.91- Foreground(theme.TextOnBG(bg)).92- Background(bg).85+ p := theme.Current()86+ return lipgloss.NewStyle().87+ Foreground(p.Highlight).88+ Background(p.SelectionBG).9389 Bold(true)9490 }9591 @@ -114,11 +110,12 @@ func isHighlightedNeighbor(m Model, islandID int) bool {
114110 115111 // emptyCellView renders an empty grid cell as a subtle dot.116112 func emptyCellView(solved bool) string {117- s := emptyStyle113+ p := theme.Current()114+ s := emptyDotStyle()118115 if solved {119- s = s.Foreground(colorEmptySolved)116+ s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)120117 }121- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("·")118+ return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("\u00b7")122119 }123120 124121 // gridView renders the puzzle using a display grid with gap cells between@@ -140,9 +137,9 @@ func gridView(m Model, solved bool) string {
140137 grid := lipgloss.JoinVertical(lipgloss.Left, rows...)141138 142139 if solved {143- return gridBorderSolvedStyle.Render(grid)140+ return gridBorderSolvedStyle().Render(grid)144141 }145- return gridBorderStyle.Render(grid)142+ return gridBorderStyle().Render(grid)146143 }147144 148145 // displayCellView renders a single cell in the display grid.@@ -179,7 +176,7 @@ func displayCellView(m Model, dx, dy int, solved bool) string {
179176 }180177 181178 // Odd x and odd y — intersection of gaps, always empty182- return baseStyle.Width(cellWidth).Render(" ")179+ return lipgloss.NewStyle().Width(cellWidth).Render(" ")183180 }184181 185182 // hGapView renders the horizontal gap between logical cell (lx,ly) and (lx+1,ly).@@ -201,7 +198,7 @@ func hGapView(m Model, lx, ly int, solved bool) string {
201198 if ciR.Kind == cellBridgeH {202199 return bridgeHView(ciR.BridgeCount, solved)203200 }204- return baseStyle.Width(cellWidth).Render(" ")201+ return lipgloss.NewStyle().Width(cellWidth).Render(" ")205202 }206203 207204 // vGapView renders the vertical gap between logical cell (lx,ly) and (lx,ly+1).@@ -223,58 +220,68 @@ func vGapView(m Model, lx, ly int, solved bool) string {
223220 if ciB.Kind == cellBridgeV {224221 return bridgeVView(ciB.BridgeCount, solved)225222 }226- return baseStyle.Width(cellWidth).Render(" ")223+ return lipgloss.NewStyle().Width(cellWidth).Render(" ")227224 }228225 229226 func islandView(m Model, islandID int, solved bool) string {230227 isl := m.puzzle.FindIslandByID(islandID)231228 if isl == nil {232- return emptyStyle.Width(cellWidth).Render(" ")229+ return emptyDotStyle().Width(cellWidth).Render(" ")233230 }234231 235- style := islandDefaultStyle232+ style := islandDefaultStyle(islandID)236233 current := m.puzzle.BridgeCount(islandID)234+ isCursor := m.cursorIsland == islandID237235 238- if solved && m.cursorIsland == islandID {236+ if solved && isCursor {239237 style = game.CursorSolvedStyle()240238 } else if solved {241- style = islandSatisfiedStyle239+ colors := islandColors()240+ bg := colors[islandID%len(colors)]241+ style = lipgloss.NewStyle().242+ Foreground(theme.TextOnBG(bg)).243+ Background(bg).244+ Bold(true)242245 } else if m.selectedIsland != nil && *m.selectedIsland == islandID {243246 style = islandSelectedStyle()244- } else if m.cursorIsland == islandID {247+ } else if isCursor {245248 style = game.CursorStyle()246249 } else if current == isl.Required {247- style = islandSatisfiedStyle250+ style = islandSatisfiedStyle()248251 } else if current > isl.Required {249- style = islandOverStyle252+ style = islandOverStyle()250253 } else if isHighlightedNeighbor(m, islandID) {251- style = islandAdjacentStyle254+ style = islandAdjacentStyle(islandID)252255 }253256 254257 label := fmt.Sprintf("%d", isl.Required)258+ if isCursor && !solved {259+ label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight260+ return style.Render(label)261+ }255262 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(label)256263 }257264 258265 func bridgeHView(count int, solved bool) string {259- r := "───"266+ r := "\u2500\u2500\u2500"260267 if count == 2 {261- r = "═══"268+ r = "\u2550\u2550\u2550"262269 }263- s := bridgeHStyle270+ s := bridgeStyle()264271 if solved {265- s = bridgeHSolvedStyle272+ s = bridgeSolvedStyle()266273 }267274 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)268275 }269276 270277 func bridgeVView(count int, solved bool) string {271- r := "│"278+ r := "\u2502"272279 if count == 2 {273- r = "║"280+ r = "\u2551"274281 }275- s := bridgeVStyle282+ s := bridgeStyle()276283 if solved {277- s = bridgeVSolvedStyle284+ s = bridgeSolvedStyle()278285 }279286 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)280287 }@@ -293,6 +300,8 @@ func statusBarView(selected, showFullHelp bool) string {
293300 }294301 295302 func infoView(p *Puzzle) string {303+ pal := theme.Current()304+296305 satisfied := 0297306 total := len(p.Islands)298307 for _, isl := range p.Islands {@@ -301,8 +310,8 @@ func infoView(p *Puzzle) string {
301310 }302311 }303312 304- satisfiedStyle := lipgloss.NewStyle().Foreground(colorInfoSatisfied)305- infoStyle := lipgloss.NewStyle().Foreground(colorInfoText)313+ satisfiedStyle := lipgloss.NewStyle().Foreground(pal.SuccessBorder)314+ infoStyle := lipgloss.NewStyle().Foreground(pal.Info)306315 307316 var sb strings.Builder308317 sb.WriteString(infoStyle.Render("Islands: "))@@ -17,7 +17,6 @@ func init() {
1717 })1818 }1919 20-// HitoriMode represents a specific difficulty configuration for Hitori.2120 type HitoriMode struct {2221 game.BaseMode2322 Size int@@ -30,7 +29,6 @@ var (
3029 _ game.SeededSpawner = HitoriMode{}3130 )3231 33-// NewMode creates a new Hitori game mode.3432 func NewMode(title, desc string, size int, blackRatio float64) HitoriMode {3533 return HitoriMode{3634 BaseMode: game.NewBaseMode(title, desc),@@ -39,7 +37,6 @@ func NewMode(title, desc string, size int, blackRatio float64) HitoriMode {
3937 }4038 }4139 42-// Spawn creates a new game instance for this mode.4340 func (h HitoriMode) Spawn() (game.Gamer, error) {4441 puzzle, err := Generate(h.Size, h.BlackRatio)4542 if err != nil {@@ -56,10 +53,6 @@ func (h HitoriMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5653 return New(h, puzzle)5754 }5855 59-// Modes defines the available difficulty levels.60-// Difficulty is controlled by grid size and the ratio of black cells.61-// Smaller grids with fewer black cells are easier to deduce; larger grids62-// with more black cells require deeper chains of logic.6356 var Modes = []list.Item{6457 NewMode("Mini", "5\u00d75 grid, gentle introduction.", 5, 0.32),6558 NewMode("Easy", "6\u00d76 grid, straightforward logic.", 6, 0.32),@@ -69,7 +62,6 @@ var Modes = []list.Item{
6962 NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28),7063 }7164 72-// DailyModes is the subset of Modes eligible for daily puzzle rotation.7365 var DailyModes = []list.Item{7466 Modes[1], // Easy 6x67567 Modes[2], // Medium 8x8@@ -6,7 +6,6 @@ import (
66 "charm.land/bubbles/v2/key"77 )88 9-// KeyMap defines the keybindings for Hitori.109 type KeyMap struct {1110 game.CursorKeyMap1211 ShadeCell key.Binding@@ -14,7 +13,6 @@ type KeyMap struct {
1413 ClearCell key.Binding1514 }1615 17-// DefaultKeyMap provides the standard keybindings.1816 var DefaultKeyMap = KeyMap{1917 CursorKeyMap: game.DefaultCursorKeyMap,2018 ShadeCell: key.NewBinding(@@ -43,7 +41,6 @@ func (m *Model) updateKeyBindings() {
4341 m.keys.ClearCell.SetEnabled(!solved)4442 }4543 46-// GetFullHelp implements game.Gamer.4744 func (m Model) GetFullHelp() [][]key.Binding {4845 return [][]key.Binding{4946 {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},@@ -65,7 +65,6 @@ func hasNoAdjacentShaded(marks [][]cellMark, size int) bool {
6565 // allWhiteConnected returns true if all non-shaded cells form a single6666 // orthogonally connected region. Uses BFS.6767 func allWhiteConnected(marks [][]cellMark, size int) bool {68- // Find first non-shaded cell.6968 startX, startY := -1, -17069 whiteCount := 07170 for y := range size {@@ -144,7 +143,6 @@ func computeConflicts(numbers grid, marks [][]cellMark, size int) [][]bool {
144143 // only flagged when at least one cell in the group is circled, so that145144 // the initial board is not immediately flooded with conflict markers.146145 func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool, size int) {147- // Check rows.148146 for y := range size {149147 seen := map[rune][]int{}150148 for x := range size {@@ -173,7 +171,6 @@ func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool
173171 }174172 }175173 176- // Check columns.177174 for x := range size {178175 seen := map[rune][]int{}179176 for y := range size {@@ -3,85 +3,57 @@ package hitori
33 import (44 "fmt"55 6- "github.com/FelineStateMachine/puzzletea/game"7-86 "charm.land/lipgloss/v2"9- "charm.land/lipgloss/v2/compat"10-)11-12-var (13- baseStyle = lipgloss.NewStyle()14- backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}15-16- numberStyle = baseStyle.17- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("250")}).18- Background(backgroundColor)19-20- shadedStyle = baseStyle.21- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("238")}).22- Background(compat.AdaptiveColor{Light: lipgloss.Color("240"), Dark: lipgloss.Color("238")})23-24- circledStyle = baseStyle.25- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("25"), Dark: lipgloss.Color("75")}).26- Background(backgroundColor)27-28- // cursorSolvedStyle resolved at render time via game.CursorSolvedStyle().29-30- crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}31- solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}32-33- borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"349 )3510 3611 const cellWidth = 33712 3813 func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved, conflict bool) string {14+ p := theme.Current()3915 var s lipgloss.Style4016 var display string4117 4218 switch mark {4319 case shaded:44- s = shadedStyle20+ s = lipgloss.NewStyle().21+ Foreground(p.Surface).22+ Background(p.Surface)4523 display = " "4624 case circled:47- s = circledStyle25+ s = lipgloss.NewStyle().26+ Foreground(p.Info).27+ Background(p.BG)4828 display = fmt.Sprintf(" %c ", num)4929 default:50- s = numberStyle30+ s = lipgloss.NewStyle().31+ Foreground(p.FG).32+ Background(p.BG)5133 display = fmt.Sprintf(" %c ", num)5234 }5335 54- // Priority: cursor+solved > cursor > conflict > crosshair > solved > base.36+ // Priority: cursor+solved > cursor > solved > conflict > crosshair > base.5537 if isCursor && solved {5638 s = game.CursorSolvedStyle()39+ display = game.CursorLeft + fmt.Sprintf("%c", num) + game.CursorRight5740 } else if isCursor {58- s = s.Background(game.CursorWarmBG()).Bold(true)59- if mark != circled {60- s = s.Foreground(game.CursorWarmFG())61- }41+ s = game.CursorStyle()42+ display = game.CursorLeft + fmt.Sprintf("%c", num) + game.CursorRight6243 } else if solved {63- s = s.Background(solvedBG)44+ s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)6445 } else if conflict {65- s = s.Background(game.ConflictBG())66- if mark != circled {67- s = s.Foreground(game.ConflictFG())68- }46+ s = s.Foreground(game.ConflictFG()).Background(game.ConflictBG())6947 } else if inCursorRow || inCursorCol {70- s = s.Background(crosshairBG)48+ s = s.Background(p.Surface)7149 }7250 7351 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)7452 }7553 76-var gridBorderColors = game.GridBorderColors{77- BorderFG: borderFG,78- BackgroundBG: backgroundColor,79- CrosshairBG: crosshairBG,80- SolvedBorderFG: compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")},81- SolvedBG: solvedBG,82-}83-8454 func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {55+ colors := game.DefaultBorderColors()56+8557 w := 08658 if len(numbers) > 0 {8759 w = len(numbers[0])@@ -92,7 +64,7 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conf
9264 var rowCells []string9365 inCursorRow := y == c.Y9466 95- rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))67+ rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))9668 9769 for x, num := range row {9870 isCursor := x == c.X && y == c.Y@@ -101,12 +73,12 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conf
10173 rowCells = append(rowCells, cell)10274 }10375 104- rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))76+ rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))10577 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))10678 }10779 108- topRow := game.HBorderRow(w, c.X, cellWidth, "╭", "╮", gridBorderColors, solved)109- botRow := game.HBorderRow(w, c.X, cellWidth, "╰", "╯", gridBorderColors, solved)80+ topRow := game.HBorderRow(w, c.X, cellWidth, "\u256d", "\u256e", colors, solved)81+ botRow := game.HBorderRow(w, c.X, cellWidth, "\u2570", "\u256f", colors, solved)11082 11183 return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)11284 }@@ -11,27 +11,23 @@ import (
1111 //go:embed help.md1212 var HelpContent string1313 14-// Register the import function for save games.1514 func init() {1615 game.Register("Lights Out", func(data []byte) (game.Gamer, error) {1716 return ImportModel(data)1817 })1918 }2019 21-// Mode represents a specific difficulty configuration for Lights Out.2220 type Mode struct {2321 game.BaseMode2422 Width, Height int2523 }2624 27-// Compile-time assertions2825 var (2926 _ game.Mode = Mode{}3027 _ game.Spawner = Mode{}3128 _ game.SeededSpawner = Mode{}3229 )3330 34-// NewMode creates a new game mode.3531 func NewMode(title, desc string, w, h int) Mode {3632 return Mode{3733 BaseMode: game.NewBaseMode(title, desc),@@ -40,7 +36,6 @@ func NewMode(title, desc string, w, h int) Mode {
4036 }4137 }4238 43-// Spawn creates a new game instance for this mode.4439 func (m Mode) Spawn() (game.Gamer, error) {4540 return New(m.Width, m.Height)4641 }@@ -49,7 +44,6 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
4944 return NewSeeded(m.Width, m.Height, rng)5045 }5146 52-// Modes defines the available difficulty levels.5347 var Modes = []list.Item{5448 NewMode("Easy", "3x3 grid", 3, 3),5549 NewMode("Medium", "5x5 grid", 5, 5),@@ -57,7 +51,6 @@ var Modes = []list.Item{
5751 NewMode("Extreme", "9x9 grid", 9, 9),5852 }5953 60-// DailyModes is the subset of Modes eligible for daily puzzle rotation.6154 var DailyModes = []list.Item{6255 Modes[1], // Medium 5x56356 Modes[2], // Hard 7x7@@ -16,6 +16,7 @@ goal is to turn every light off.
1616 |-----|--------|1717 | Arrow keys / WASD / hjkl | Move cursor |1818 | `Enter` / `Space` | Toggle light |19+| `Ctrl+R` | Reset puzzle |1920 | `Ctrl+H` | Toggle full help |2021 | `Ctrl+E` | Toggle debug overlay |2122 | `Ctrl+N` | Return to main menu |@@ -46,18 +46,17 @@ func Toggle(grid [][]bool, x, y int) {
4646 }4747 w := len(grid[0])4848 49- // Helper to safely toggle5049 safeToggle := func(c, r int) {5150 if c >= 0 && c < w && r >= 0 && r < h {5251 grid[r][c] = !grid[r][c]5352 }5453 }5554 56- safeToggle(x, y) // Center57- safeToggle(x, y-1) // Up58- safeToggle(x, y+1) // Down59- safeToggle(x-1, y) // Left60- safeToggle(x+1, y) // Right55+ safeToggle(x, y)56+ safeToggle(x, y-1)57+ safeToggle(x, y+1)58+ safeToggle(x-1, y)59+ safeToggle(x+1, y)6160 }6261 6362 func IsSolved(grid [][]bool) bool {@@ -4,8 +4,8 @@ import (
44 "strings"55 66 "charm.land/lipgloss/v2"7- "charm.land/lipgloss/v2/compat"87 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"99 )1010 1111 // screenToGrid converts terminal screen coordinates to grid cell coordinates.@@ -51,52 +51,69 @@ func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {
5151 return col, row, true5252 }5353 54-var (55- baseStyle = lipgloss.NewStyle()56-57- colorOn = compat.AdaptiveColor{Light: lipgloss.Color("222"), Dark: lipgloss.Color("180")}58- colorOff = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")}54+const (55+ cellWidth = 456+ cellHeight = 257+)5958 60- styleOn = baseStyle.61- Background(colorOn)59+func lightOnStyle() lipgloss.Style {60+ p := theme.Current()61+ return lipgloss.NewStyle().62+ Background(theme.Blend(p.BG, p.Accent, 0.85))63+}6264 63- styleOff = baseStyle.64- Background(colorOff)65+func lightOffStyle() lipgloss.Style {66+ return lipgloss.NewStyle().67+ Background(theme.Current().BG)68+}6569 66- // cursorStyle and cursorSolvedStyle resolved at render time67- // via game.CursorStyle() and game.CursorSolvedStyle().70+func solvedCellStyle() lipgloss.Style {71+ return lipgloss.NewStyle().72+ Background(theme.Current().SuccessBG)73+}6874 69- solvedStyle = baseStyle.70- Background(compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")})75+func gridBorderStyle() lipgloss.Style {76+ p := theme.Current()77+ return lipgloss.NewStyle().78+ Border(lipgloss.RoundedBorder()).79+ BorderForeground(p.Border).80+ BorderBackground(p.BG)81+}7182 72- gridBorderStyle = lipgloss.NewStyle().73- Border(lipgloss.RoundedBorder()).74- BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).75- BorderBackground(colorOff)83+func gridBorderSolvedStyle() lipgloss.Style {84+ p := theme.Current()85+ return lipgloss.NewStyle().86+ Border(lipgloss.RoundedBorder()).87+ BorderForeground(p.SuccessBorder).88+ BorderBackground(p.SuccessBG)89+}7690 77- gridBorderSolvedStyle = lipgloss.NewStyle().78- Border(lipgloss.RoundedBorder()).79- BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}).80- BorderBackground(compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")})81-)91+func cursorOnStyle() lipgloss.Style {92+ p := theme.Current()93+ return lipgloss.NewStyle().94+ Background(theme.Blend(p.Accent, p.AccentText, 0.25))95+}8296 83-const (84- cellWidth = 485- cellHeight = 286-)97+func cursorOffStyle() lipgloss.Style {98+ p := theme.Current()99+ return lipgloss.NewStyle().100+ Background(theme.Blend(p.BG, p.Accent, 0.45))101+}87102 88103 func cellView(isOn, isCursor, solved bool) string {89- s := styleOff104+ s := lightOffStyle()90105 if isOn {91- s = styleOn106+ s = lightOnStyle()92107 }93108 94109 if isCursor && solved {95110 s = game.CursorSolvedStyle()96111 } else if solved {97- s = solvedStyle112+ s = solvedCellStyle()113+ } else if isCursor && isOn {114+ s = cursorOnStyle()98115 } else if isCursor {99- s = game.CursorStyle()116+ s = cursorOffStyle()100117 }101118 102119 content := strings.Repeat(" ", cellWidth)@@ -116,9 +133,9 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string {
116133 grid := lipgloss.JoinVertical(lipgloss.Left, rows...)117134 118135 if solved {119- return gridBorderSolvedStyle.Render(grid)136+ return gridBorderSolvedStyle().Render(grid)120137 }121- return gridBorderStyle.Render(grid)138+ return gridBorderStyle().Render(grid)122139 }123140 124141 func statusBarView(showFullHelp bool) string {@@ -24,9 +24,9 @@ type NonogramMode struct {
2424 }2525 2626 var (27- _ game.Mode = NonogramMode{} // compile-time interface check28- _ game.Spawner = NonogramMode{} // compile-time interface check29- _ game.SeededSpawner = NonogramMode{} // compile-time interface check27+ _ game.Mode = NonogramMode{}28+ _ game.Spawner = NonogramMode{}29+ _ game.SeededSpawner = NonogramMode{}3030 )3131 3232 func NewMode(title, description string, width, height int, density float64) NonogramMode {@@ -65,7 +65,6 @@ var Modes = []list.Item{
6565 NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56),6666 }6767 68-// DailyModes is the subset of Modes eligible for daily puzzle rotation.6968 var DailyModes = []list.Item{7069 Modes[3], // Standard 10x107170 Modes[4], // Classic 10x10@@ -182,7 +182,7 @@ func (m Model) View() string {
182182 g := gridView(m.grid, m.cursor, m.solved)183183 r := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)184184 c := colHintView(m.colHints, maxHeight, m.currentHints.cols)185- spacer := baseStyle.Width(maxWidth).Height(maxHeight).Render("")185+ spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")186186 status := statusBarView(m.showFullHelp)187187 188188 s1 := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, c)@@ -23,31 +23,30 @@ correctly satisfied.
2323 | `z` | Fill cell |2424 | `x` | Mark cell |2525 | `Backspace` | Clear cell |26+| `Ctrl+R` | Reset puzzle |2627 | `Ctrl+H` | Toggle full help |2728 | `Ctrl+E` | Toggle debug overlay |2829 | `Ctrl+N` | Return to main menu |2930 3031 ## Modes3132 32-| Mode | Grid | Density | Description |33-|------|------|---------|-------------|34-| Easy 5x5 | 5x5 | ~35% | Simple hints |35-| Medium 5x5 | 5x5 | ~50% | Balanced challenge |36-| Hard 5x5 | 5x5 | ~65% | Dense hints |37-| Easy 10x10 | 10x10 | ~35% | Simple hints |38-| Medium 10x10 | 10x10 | ~50% | Balanced challenge |39-| Hard 10x10 | 10x10 | ~65% | Dense hints |40-| Easy 15x15 | 15x15 | ~35% | Simple hints |41-| Medium 15x15 | 15x15 | ~50% | Balanced challenge |42-| Hard 15x15 | 15x15 | ~65% | Dense hints |43-| Easy 20x20 | 20x20 | ~35% | Simple hints |44-| Medium 20x20 | 20x20 | ~50% | Balanced challenge |45-| Hard 20x20 | 20x20 | ~65% | Dense hints |33+| Mode | Grid | Fill | Description |34+|------|------|------|-------------|35+| Mini | 5x5 | ~65% | Quick puzzle, straightforward hints |36+| Pocket | 5x5 | ~50% | Compact but balanced |37+| Teaser | 5x5 | ~35% | Small but tricky |38+| Standard | 10x10 | ~67% | Classic size, dense hints |39+| Classic | 10x10 | ~52% | The typical nonogram experience |40+| Tricky | 10x10 | ~37% | Sparse hints require reasoning |41+| Large | 15x15 | ~69% | Bigger grid, constraining hints |42+| Grand | 15x15 | ~54% | A substantial challenge |43+| Epic | 20x20 | ~71% | A epic undertaking |44+| Massive | 20x20 | ~56% | Truly massive puzzle |4645 4746 ## Quick Start4847 4948 ```bash50-puzzletea new nonogram easy-5x551-puzzletea new nonogram medium-10x1052-puzzletea new nonogram hard-20x2049+puzzletea new nonogram mini50+puzzletea new nonogram classic51+puzzletea new nonogram massive5352 ```@@ -118,7 +118,7 @@ func (m *Model) gridOrigin() (x, y int) {
118118 title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)119119 rowHints := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)120120 colHints := colHintView(m.colHints, maxHeight, m.currentHints.cols)121- spacer := baseStyle.Width(maxWidth).Height(maxHeight).Render("")121+ spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")122122 123123 s1 := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, colHints)124124 gridBlock := lipgloss.JoinVertical(lipgloss.Center, s1, "placeholder")@@ -894,8 +894,6 @@ func TestGenerateRandomTomography_5x5_VerifyUnique(t *testing.T) {
894894 }895895 }896896 897-// Ensure the game import is used (it's needed for the init() registration side effect898-// and the NewMode constructor uses game.NewBaseMode internally).899897 var _ game.Gamer = Model{}900898 901899 // --- Spawn Performance Benchmark (P2) ---@@ -2,43 +2,12 @@ package nonogram
22 33 import (44 "fmt"5+ "image/color"56 "strings"67 78 "charm.land/lipgloss/v2"8- "charm.land/lipgloss/v2/compat"99 "github.com/FelineStateMachine/puzzletea/game"10-)11-12-var (13- baseStyle = lipgloss.NewStyle()14- backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}15-16- filledStyle = baseStyle.17- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("222")}).18- Background(compat.AdaptiveColor{Light: lipgloss.Color("223"), Dark: lipgloss.Color("58")})19-20- markedStyle = baseStyle.21- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("131"), Dark: lipgloss.Color("173")}).22- Background(compat.AdaptiveColor{Light: lipgloss.Color("224"), Dark: lipgloss.Color("236")})23-24- emptyStyle = baseStyle.25- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).26- Background(backgroundColor)27-28- // cursorStyle and cursorSolvedStyle are resolved at render time29- // via game.CursorWarmStyle() and game.CursorSolvedStyle().30-31- crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}32- crosshairFilledBG = compat.AdaptiveColor{Light: lipgloss.Color("223"), Dark: lipgloss.Color("100")}33- solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}34-35- hintStyle = baseStyle.36- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("137"), Dark: lipgloss.Color("137")})37-38- hintSatisfiedStyle = baseStyle.39- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")})40-41- separatorFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}10+ "github.com/FelineStateMachine/puzzletea/theme"4211 )4312 4413 const (@@ -46,21 +15,50 @@ const (
4615 spacerEvery = 54716 )4817 49-var (50- hintCellStyle = baseStyle.Width(cellWidth)18+var renderRuneMap = map[rune]string{19+ filledTile: " \u25a0 ",20+ markedTile: " \u2715 ",21+ emptyTile: " \u00b7 ",22+}5123 52- renderStyleMap = map[rune]lipgloss.Style{53- filledTile: filledStyle,54- markedTile: markedStyle,55- emptyTile: emptyStyle,56- }24+func filledStyle() lipgloss.Style {25+ p := theme.Current()26+ return lipgloss.NewStyle().27+ Foreground(p.AccentText).28+ Background(p.AccentBG)29+}30+31+func markedStyle() lipgloss.Style {32+ p := theme.Current()33+ return lipgloss.NewStyle().34+ Foreground(p.AccentSoft).35+ Background(p.BG)36+}37+38+func emptyStyle() lipgloss.Style {39+ p := theme.Current()40+ return lipgloss.NewStyle().41+ Foreground(p.TextDim).42+ Background(p.BG)43+}5744 58- renderRuneMap = map[rune]string{59- filledTile: " ■ ",60- markedTile: " ✕ ",61- emptyTile: " · ",45+func hintStyle() lipgloss.Style {46+ return lipgloss.NewStyle().47+ Foreground(theme.Current().Info)48+}49+50+func hintSatisfiedStyle() lipgloss.Style {51+ return lipgloss.NewStyle().52+ Foreground(theme.Current().SuccessBorder)53+}54+55+func renderStyleMap() map[rune]lipgloss.Style {56+ return map[rune]lipgloss.Style{57+ filledTile: filledStyle(),58+ markedTile: markedStyle(),59+ emptyTile: emptyStyle(),6260 }63-)61+}6462 6563 // needsSpacer reports whether a separator should be inserted after index i6664 // in a dimension of size n (i.e. after every spacerEvery cells, except the last).@@ -71,10 +69,11 @@ func needsSpacer(i, n int) bool {
7169 // hSeparator builds a horizontal separator row using box-drawing characters.7270 // w is the number of grid columns. cursorX is the cursor column (-1 to disable crosshair).7371 // bg is the default background, crossBG is the crosshair-highlighted background.74-func hSeparator(w, cursorX int, bg, crossBG compat.AdaptiveColor) string {75- defStyle := baseStyle.Foreground(separatorFG).Background(bg)76- highStyle := baseStyle.Foreground(separatorFG).Background(crossBG)77- segment := strings.Repeat("─", cellWidth)72+func hSeparator(w, cursorX int, bg, crossBG color.Color) string {73+ p := theme.Current()74+ defStyle := lipgloss.NewStyle().Foreground(p.Border).Background(bg)75+ highStyle := lipgloss.NewStyle().Foreground(p.Border).Background(crossBG)76+ segment := strings.Repeat("\u2500", cellWidth)7877 7978 var parts []string8079 for x := range w {@@ -84,7 +83,7 @@ func hSeparator(w, cursorX int, bg, crossBG compat.AdaptiveColor) string {
8483 }8584 parts = append(parts, style.Render(segment))8685 if needsSpacer(x, w) {87- parts = append(parts, defStyle.Render("┼"))86+ parts = append(parts, defStyle.Render("\u253c"))8887 }8988 }9089 return lipgloss.JoinHorizontal(lipgloss.Top, parts...)@@ -98,6 +97,8 @@ func colHintView(c TomographyDefinition, height int, current ...TomographyDefini
9897 curr = current[0]9998 }10099 100+ hintCellStyle := lipgloss.NewStyle().Width(cellWidth)101+101102 n := len(c)102103 var renderedCols []string103104 for i, hints := range c {@@ -110,9 +111,9 @@ func colHintView(c TomographyDefinition, height int, current ...TomographyDefini
110111 satisfied := hasCurrent && i < len(curr) && intSliceEqual(hints, curr[i])111112 112113 for _, hint := range hints {113- style := hintStyle114+ style := hintStyle()114115 if satisfied {115- style = hintSatisfiedStyle116+ style = hintSatisfiedStyle()116117 }117118 hintCell := style.Width(cellWidth).118119 Align(lipgloss.Center).@@ -123,12 +124,12 @@ func colHintView(c TomographyDefinition, height int, current ...TomographyDefini
123124 renderedCols = append(renderedCols, renderedCol)124125 125126 if needsSpacer(i, n) {126- sepStyle := baseStyle.Foreground(separatorFG)127+ sepStyle := lipgloss.NewStyle().Foreground(theme.Current().Border)127128 var lines []string128129 for range height - 1 {129130 lines = append(lines, " ")130131 }131- lines = append(lines, sepStyle.Render("│"))132+ lines = append(lines, sepStyle.Render("\u2502"))132133 renderedCols = append(renderedCols, lipgloss.JoinVertical(lipgloss.Left, lines...))133134 }134135 }@@ -148,9 +149,9 @@ func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinit
148149 for i, hints := range r {149150 satisfied := hasCurrent && i < len(curr) && intSliceEqual(hints, curr[i])150151 151- style := hintStyle152+ style := hintStyle()152153 if satisfied {153- style = hintSatisfiedStyle154+ style = hintSatisfiedStyle()154155 }155156 156157 var rowHints []string@@ -164,10 +165,10 @@ func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinit
164165 renderedRows = append(renderedRows, renderedRow)165166 166167 if needsSpacer(i, n) {167- sep := baseStyle.Foreground(separatorFG).168+ sep := lipgloss.NewStyle().Foreground(theme.Current().Border).168169 Width(width).169170 Align(lipgloss.Right).170- Render("─")171+ Render("\u2500")171172 renderedRows = append(renderedRows, sep)172173 }173174 }@@ -176,18 +177,21 @@ func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinit
176177 }177178 178179 func gridView(g grid, c game.Cursor, solved bool) string {180+ p := theme.Current()181+179182 h := len(g)180183 w := 0181184 if h > 0 {182185 w = len(g[0])183186 }184187 185- sepStyle := baseStyle.Foreground(separatorFG)188+ sepStyle := lipgloss.NewStyle().Foreground(p.Border)186189 187190 // Determine background for horizontal separators (matches grid background).188- gridBG := backgroundColor191+ gridBG := p.BG192+ crosshairBG := p.Surface189193 if solved {190- gridBG = solvedBG194+ gridBG = p.SuccessBG191195 }192196 193197 var rows []string@@ -206,7 +210,7 @@ func gridView(g grid, c game.Cursor, solved bool) string {
206210 if !solved && inCursorRow {207211 bg = crosshairBG208212 }209- rowBuilder = append(rowBuilder, sepStyle.Background(bg).Render("│"))213+ rowBuilder = append(rowBuilder, sepStyle.Background(bg).Render("\u2502"))210214 }211215 }212216 row := lipgloss.JoinHorizontal(lipgloss.Top, rowBuilder...)@@ -225,30 +229,35 @@ func gridView(g grid, c game.Cursor, solved bool) string {
225229 }226230 227231 func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string {228- s, ok := renderStyleMap[val]232+ p := theme.Current()233+ styles := renderStyleMap()234+ s, ok := styles[val]229235 if !ok {230- s = renderStyleMap[emptyTile]236+ s = styles[emptyTile]237+ }238+239+ r, ok := renderRuneMap[val]240+ if !ok {241+ r = renderRuneMap[emptyTile]231242 }232243 233244 if isCursor && solved {234245 s = game.CursorSolvedStyle()246+ r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight235247 } else if isCursor {236- s = game.CursorWarmStyle()248+ s = game.CursorStyle()249+ r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight237250 } else if solved {238- s = s.Background(solvedBG)251+ s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)239252 } else if inCursorRow || inCursorCol {240253 // Apply crosshair background tint — filled cells get a more active color241254 if val == filledTile {242- s = s.Background(crosshairFilledBG)255+ s = s.Background(theme.MidTone(p.Surface, p.AccentBG))243256 } else {244- s = s.Background(crosshairBG)257+ s = s.Background(p.Surface)245258 }246259 }247260 248- r, ok := renderRuneMap[val]249- if !ok {250- r = renderRuneMap[emptyTile]251- }252261 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)253262 }254263 @@ -44,7 +44,6 @@ var CategoryAliases = map[string]string{
4444 func Category(name string, categories []list.Item) (game.Category, error) {4545 norm := Normalize(name)4646 47- // Check aliases first.4847 if canonical, ok := CategoryAliases[norm]; ok {4948 norm = canonical5049 }@@ -17,7 +17,6 @@ func init() {
1717 })1818 }1919 20-// ShikakuMode defines a Shikaku difficulty/configuration.2120 type ShikakuMode struct {2221 game.BaseMode2322 Width int@@ -31,7 +30,6 @@ var (
3130 _ game.SeededSpawner = ShikakuMode{}3231 )3332 34-// NewMode creates a new Shikaku game mode.3533 func NewMode(title, description string, width, height, maxRectSize int) ShikakuMode {3634 return ShikakuMode{3735 BaseMode: game.NewBaseMode(title, description),@@ -41,7 +39,6 @@ func NewMode(title, description string, width, height, maxRectSize int) ShikakuM
4139 }4240 }4341 44-// Spawn creates a new game instance for this mode.4542 func (s ShikakuMode) Spawn() (game.Gamer, error) {4643 puzzle, err := GeneratePuzzle(s.Width, s.Height, s.MaxRectSize)4744 if err != nil {@@ -50,7 +47,6 @@ func (s ShikakuMode) Spawn() (game.Gamer, error) {
5047 return New(s, puzzle), nil5148 }5249 53-// SpawnSeeded creates a new game instance using a deterministic RNG.5450 func (s ShikakuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {5551 puzzle, err := GeneratePuzzleSeeded(s.Width, s.Height, s.MaxRectSize, rng)5652 if err != nil {@@ -59,7 +55,6 @@ func (s ShikakuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5955 return New(s, puzzle), nil6056 }6157 62-// Modes defines the available difficulty levels.6358 var Modes = []list.Item{6459 NewMode("Mini 5x5", "5x5 grid, gentle introduction.", 5, 5, 5),6560 NewMode("Easy 7x7", "7x7 grid, straightforward puzzles.", 7, 7, 8),@@ -68,7 +63,6 @@ var Modes = []list.Item{
6863 NewMode("Expert 12x12", "12x12 grid, maximum challenge.", 12, 12, 20),6964 }7065 71-// DailyModes is the subset of Modes eligible for daily puzzle rotation.7266 var DailyModes = []list.Item{7367 Modes[1], // Easy 7x77468 Modes[2], // Medium 8x8@@ -25,6 +25,7 @@ clue and the clue's value equals the rectangle's area.
2525 | Arrow keys / WASD / hjkl | Move cursor |2626 | `Enter` / `Space` | Select clue for expansion |2727 | `Backspace` | Delete rectangle at cursor |28+| `Ctrl+R` | Reset puzzle |2829 | `Ctrl+H` | Toggle full help |2930 | `Ctrl+E` | Toggle debug overlay |3031 | `Ctrl+N` | Return to main menu |@@ -5,7 +5,6 @@ import (
55 "github.com/FelineStateMachine/puzzletea/game"66 )77 8-// KeyMap defines the keybindings for Shikaku.98 type KeyMap struct {109 game.CursorKeyMap1110 ShrinkUp key.Binding@@ -17,7 +16,6 @@ type KeyMap struct {
1716 Delete key.Binding1817 }1918 20-// DefaultKeyMap provides the standard keybindings.2119 var DefaultKeyMap = KeyMap{2220 CursorKeyMap: game.DefaultCursorKeyMap,2321 ShrinkUp: key.NewBinding(@@ -50,7 +48,6 @@ var DefaultKeyMap = KeyMap{
5048 ),5149 }5250 53-// GetFullHelp implements game.Gamer.5451 func (m Model) GetFullHelp() [][]key.Binding {5552 return [][]key.Binding{5653 {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},@@ -6,7 +6,6 @@ import (
66 "strings"77 88 "charm.land/lipgloss/v2"9- "charm.land/lipgloss/v2/compat"109 "github.com/FelineStateMachine/puzzletea/game"1110 "github.com/FelineStateMachine/puzzletea/theme"1211 )@@ -18,33 +17,17 @@ const cellWidth = 3
1817 // adapt automatically when the user switches themes.1918 func rectColors() []color.Color { return theme.Current().CardColors() }2019 21-var (22- baseStyle = lipgloss.NewStyle()23-24- emptyDotColor = compat.AdaptiveColor{Dark: lipgloss.Color("242"), Light: lipgloss.Color("249")}25- clueColor = compat.AdaptiveColor{Dark: lipgloss.Color("230"), Light: lipgloss.Color("236")}26- clueBgColor = compat.AdaptiveColor{Dark: lipgloss.Color("238"), Light: lipgloss.Color("254")}27-28- // Cursor colors resolved at render time via game.CursorStyle().29-30- previewGoodBg = compat.AdaptiveColor{Dark: lipgloss.Color("28"), Light: lipgloss.Color("157")}31- previewBadBg = compat.AdaptiveColor{Dark: lipgloss.Color("124"), Light: lipgloss.Color("217")}32-33- solvedBg = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("151")}34- solvedBorder = compat.AdaptiveColor{Dark: lipgloss.Color("149"), Light: lipgloss.Color("22")}35- borderColor = compat.AdaptiveColor{Dark: lipgloss.Color("240"), Light: lipgloss.Color("250")}36-37- infoSatisfied = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}38- infoText = compat.AdaptiveColor{Dark: lipgloss.Color("180"), Light: lipgloss.Color("95")}39-40- gridBorderStyle = lipgloss.NewStyle().41- Border(lipgloss.RoundedBorder()).42- BorderForeground(borderColor)20+func gridBorderStyle() lipgloss.Style {21+ return lipgloss.NewStyle().22+ Border(lipgloss.RoundedBorder()).23+ BorderForeground(theme.Current().Border)24+}4325 44- gridBorderSolvedStyle = lipgloss.NewStyle().45- Border(lipgloss.RoundedBorder()).46- BorderForeground(solvedBorder)47-)26+func gridBorderSolvedStyle() lipgloss.Style {27+ return lipgloss.NewStyle().28+ Border(lipgloss.RoundedBorder()).29+ BorderForeground(theme.Current().SuccessBorder)30+}4831 4932 func gridView(m Model, solved bool) string {5033 // Build preview rectangle for visual feedback.@@ -78,18 +61,19 @@ func gridView(m Model, solved bool) string {
7861 grid := lipgloss.JoinVertical(lipgloss.Left, rows...)7962 8063 if solved {81- return gridBorderSolvedStyle.Render(grid)64+ return gridBorderSolvedStyle().Render(grid)8265 }83- return gridBorderStyle.Render(grid)66+ return gridBorderStyle().Render(grid)8467 }8568 8669 func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *Clue) string {70+ p := theme.Current()8771 isCursor := m.cursor.X == x && m.cursor.Y == y8872 clue := m.puzzle.FindClueAt(x, y)8973 owner := m.puzzle.CellOwner(x, y)9074 9175 // Determine display text.92- display := " · "76+ display := " \u00b7 "9377 if clue != nil {9478 display = fmt.Sprintf("%2d ", clue.Value)9579 if clue.Value < 10 {@@ -98,10 +82,10 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
9882 }9983 10084 // Base style.101- s := baseStyle.Foreground(emptyDotColor)85+ s := lipgloss.NewStyle().Foreground(p.TextDim)10286 10387 if clue != nil {104- s = baseStyle.Foreground(clueColor).Background(clueBgColor).Bold(true)88+ s = lipgloss.NewStyle().Foreground(p.Given).Background(p.Surface).Bold(true)10589 }10690 10791 // Rectangle background color (from current theme palette).@@ -121,16 +105,15 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
121105 preview.Area() == previewClue.Value &&122106 !m.puzzle.Overlaps(*preview, previewClue.ID)123107 if good {124- s = s.Background(previewGoodBg).Foreground(theme.TextOnBG(previewGoodBg))108+ s = s.Background(p.SuccessBG).Foreground(theme.TextOnBG(p.SuccessBG))125109 } else {126- s = s.Background(previewBadBg).Foreground(theme.TextOnBG(previewBadBg))110+ s = s.Background(p.ErrorBG).Foreground(theme.TextOnBG(p.ErrorBG))127111 }128112 }129113 130114 // Selected clue highlight.131115 if m.selectedClue != nil && clue != nil && clue.ID == *m.selectedClue && !inPreview {132- bg := theme.Current().Highlight133- s = s.Background(bg).Foreground(theme.TextOnBG(bg)).Bold(true)116+ s = s.Background(p.SelectionBG).Foreground(theme.TextOnBG(p.SelectionBG)).Bold(true)134117 }135118 136119 // Solved styling.@@ -139,7 +122,7 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
139122 bg := colors[owner%len(colors)]140123 s = s.Background(bg).Foreground(theme.TextOnBG(bg))141124 } else {142- s = s.Background(solvedBg)125+ s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)143126 }144127 if isCursor {145128 s = game.CursorSolvedStyle()@@ -151,19 +134,21 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
151134 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)152135 }153136 154-func infoView(p *Puzzle) string {155- placed := len(p.Rectangles)156- total := len(p.Clues)137+func infoView(puzzle *Puzzle) string {138+ p := theme.Current()139+140+ placed := len(puzzle.Rectangles)141+ total := len(puzzle.Clues)157142 correct := 0158- for _, r := range p.Rectangles {159- clue := p.FindClueByID(r.ClueID)143+ for _, r := range puzzle.Rectangles {144+ clue := puzzle.FindClueByID(r.ClueID)160145 if clue != nil && r.Area() == clue.Value {161146 correct++162147 }163148 }164149 165- satisfiedStyle := lipgloss.NewStyle().Foreground(infoSatisfied)166- infoStyle := lipgloss.NewStyle().Foreground(infoText)150+ satisfiedStyle := lipgloss.NewStyle().Foreground(p.SuccessBorder)151+ infoStyle := lipgloss.NewStyle().Foreground(p.Info)167152 168153 var sb strings.Builder169154 sb.WriteString(infoStyle.Render("Rectangles: "))@@ -280,7 +280,6 @@ func (s *Store) ListAllGames() ([]GameRecord, error) {
280280 return games, rows.Err()281281 }282282 283-// Close closes the database connection.284283 func (s *Store) Close() error {285284 return s.db.Close()286285 }@@ -510,7 +510,6 @@ func TestGetGameByName(t *testing.T) {
510510 func TestFullLifecycle(t *testing.T) {511511 s := openTestStore(t)512512 513- // Create a game.514513 rec := newTestRecord("lifecycle")515514 if err := s.CreateGame(rec); err != nil {516515 t.Fatal(err)@@ -519,17 +518,14 @@ func TestFullLifecycle(t *testing.T) {
519518 t.Errorf("initial status = %q, want %q", rec.Status, StatusNew)520519 }521520 522- // Start playing.523521 if err := s.UpdateStatus(rec.ID, StatusInProgress); err != nil {524522 t.Fatal(err)525523 }526524 527- // Save progress.528525 if err := s.UpdateSaveState(rec.ID, `{"progress":"halfway"}`); err != nil {529526 t.Fatal(err)530527 }531528 532- // Verify mid-game state.533529 mid, _ := s.GetGameByName("lifecycle")534530 if mid.Status != StatusInProgress {535531 t.Errorf("mid-game status = %q, want %q", mid.Status, StatusInProgress)@@ -538,12 +534,10 @@ func TestFullLifecycle(t *testing.T) {
538534 t.Errorf("mid-game SaveState = %q", mid.SaveState)539535 }540536 541- // Complete the game.542537 if err := s.UpdateStatus(rec.ID, StatusCompleted); err != nil {543538 t.Fatal(err)544539 }545540 546- // Verify completed state.547541 done, _ := s.GetGameByName("lifecycle")548542 if done.Status != StatusCompleted {549543 t.Errorf("final status = %q, want %q", done.Status, StatusCompleted)@@ -552,7 +546,6 @@ func TestFullLifecycle(t *testing.T) {
552546 t.Error("expected CompletedAt to be set")553547 }554548 555- // Appears in list.556549 games, _ := s.ListGames()557550 found := false558551 for _, g := range games {@@ -23,9 +23,9 @@ type SudokuMode struct {
2323 }2424 2525 var (26- _ game.Mode = SudokuMode{} // compile-time interface check27- _ game.Spawner = SudokuMode{} // compile-time interface check28- _ game.SeededSpawner = SudokuMode{} // compile-time interface check26+ _ game.Mode = SudokuMode{}27+ _ game.Spawner = SudokuMode{}28+ _ game.SeededSpawner = SudokuMode{}2929 )3030 3131 func NewMode(title, description string, providedCount int) SudokuMode {@@ -52,7 +52,6 @@ var Modes = []list.Item{
5252 NewMode("Diabolical", "17–21 clues. Swordfish / XY-Chains.", 17),5353 }5454 55-// DailyModes is the subset of Modes eligible for daily puzzle rotation.5655 var DailyModes = []list.Item{5756 Modes[1], // Easy5857 Modes[2], // Medium@@ -47,12 +47,10 @@ func New(mode SudokuMode, provided []cell) (game.Gamer, error) {
4747 return m, nil4848 }4949 50-// Init implements game.Gamer.5150 func (m Model) Init() tea.Cmd {5251 return nil5352 }5453 55-// Update implements game.Gamer.5654 func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {5755 switch msg := msg.(type) {5856 case game.HelpToggleMsg:@@ -77,12 +75,10 @@ func (m Model) SetTitle(t string) game.Gamer {
7775 return m7876 }7977 80-// IsSolved implements game.Gamer.8178 func (m Model) IsSolved() bool {8279 return m.isSolved()8380 }8481 85-// Reset implements game.Gamer.8682 func (m Model) Reset() game.Gamer {8783 m.grid = newGrid(m.provided)8884 m.conflicts = computeConflicts(m.grid)@@ -97,7 +93,6 @@ func (m *Model) updateCell(v int) {
9793 m.conflicts = computeConflicts(m.grid)9894 }9995 100-// View implements game.Gamer.10196 func (m Model) View() string {10297 solved := isSolvedWith(m.grid, m.conflicts)10398 title := game.TitleBarView("Sudoku", m.modeTitle, solved)@@ -107,7 +102,6 @@ func (m Model) View() string {
107102 return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)108103 }109104 110-// GetDebugInfo implements game.Gamer.111105 func (m Model) GetDebugInfo() string {112106 cursorCell := m.grid[m.cursor.Y][m.cursor.X]113107 isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]@@ -5,90 +5,101 @@ import (
55 "strings"66 77 "charm.land/lipgloss/v2"8- "charm.land/lipgloss/v2/compat"98 "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/theme"1010 )1111 12-var (13- backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}12+var sudokuCellWidth = 21413 15- emptyCellStyle = lipgloss.NewStyle().16- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).17- Background(backgroundColor)18-19- providedCellStyle = lipgloss.NewStyle().20- Bold(true).21- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("179")}).22- Background(backgroundColor)23-24- userCellStyle = lipgloss.NewStyle().25- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("187")}).26- Background(backgroundColor)27-28- // cursorCellStyle resolved at render time via game.CursorStyle().29-30- conflictCellStyle = lipgloss.NewStyle().31- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("160"), Dark: lipgloss.Color("167")}).32- Background(compat.AdaptiveColor{Light: lipgloss.Color("224"), Dark: lipgloss.Color("52")})14+func emptyCellStyle() lipgloss.Style {15+ p := theme.Current()16+ return lipgloss.NewStyle().17+ Foreground(p.TextDim).18+ Background(p.BG)19+}3320 34- sameNumberStyle = lipgloss.NewStyle().35- Bold(true).36- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("187")})21+func providedCellStyle() lipgloss.Style {22+ p := theme.Current()23+ return lipgloss.NewStyle().24+ Bold(true).25+ Foreground(p.Given).26+ Background(p.BG)27+}3728 38- crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}29+func userCellStyle() lipgloss.Style {30+ p := theme.Current()31+ return lipgloss.NewStyle().32+ Foreground(p.AccentSoft).33+ Background(p.BG)34+}3935 40- boxBorderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}36+func conflictCellStyle() lipgloss.Style {37+ p := theme.Current()38+ return lipgloss.NewStyle().39+ Foreground(p.Error).40+ Background(p.ErrorBG)41+}4142 42- gridBorderStyle = lipgloss.NewStyle().43- Border(lipgloss.RoundedBorder()).44- BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).45- BorderBackground(backgroundColor)43+func sameNumberStyle() lipgloss.Style {44+ p := theme.Current()45+ return lipgloss.NewStyle().46+ Bold(true).47+ Foreground(p.Accent)48+}4649 47- gridBorderSolvedStyle = lipgloss.NewStyle().48- Border(lipgloss.RoundedBorder()).49- BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}).50- BorderBackground(backgroundColor)50+func gridBorderStyle() lipgloss.Style {51+ p := theme.Current()52+ return lipgloss.NewStyle().53+ Border(lipgloss.RoundedBorder()).54+ BorderForeground(p.Border).55+ BorderBackground(p.BG)56+}5157 52- sudokuCellWidth = 253-)58+func gridBorderSolvedStyle() lipgloss.Style {59+ p := theme.Current()60+ return lipgloss.NewStyle().61+ Border(lipgloss.RoundedBorder()).62+ BorderForeground(p.SuccessBorder).63+ BorderBackground(p.BG)64+}5465 5566 func renderGrid(m Model, solved bool, conflicts [gridSize][gridSize]bool) string {67+ p := theme.Current()68+5669 var rows []string5770 5871 for y := range gridSize {5972 var cells []string6073 for x := range gridSize {6174 c := m.grid[y][x]62- style := getCellStyle(m, c, x, y, conflicts[y][x])75+ style := getCellStyle(m, c, x, y, conflicts[y][x], solved)6376 content := cellContent(c)6477 rendered := style.Width(sudokuCellWidth).Align(lipgloss.Center).Render(content)6578 cells = append(cells, rendered)6679 67- // Insert vertical box separator after columns 3 and 66880 if x == 2 || x == 5 {69- bg := backgroundColor81+ bg := p.BG7082 if m.cursor.Y == y {71- bg = crosshairBG83+ bg = p.Surface7284 }73- sep := lipgloss.NewStyle().Foreground(boxBorderFG).Background(bg).Render("│")85+ sep := lipgloss.NewStyle().Foreground(p.Border).Background(bg).Render("\u2502")7486 cells = append(cells, sep)7587 }7688 }7789 row := lipgloss.JoinHorizontal(lipgloss.Top, cells...)7890 rows = append(rows, row)7991 80- // Insert horizontal box separator after rows 3 and 68192 if y == 2 || y == 5 {82- sepLine := strings.Repeat("─", sudokuCellWidth)93+ sepLine := strings.Repeat("\u2500", sudokuCellWidth)8394 var renderedParts []string8495 for x := range gridSize {85- bg := backgroundColor96+ bg := p.BG8697 if m.cursor.X == x {87- bg = crosshairBG98+ bg = p.Surface8899 }89- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(boxBorderFG).Background(bg).Render(sepLine))100+ renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(p.Border).Background(bg).Render(sepLine))90101 if x == 2 || x == 5 {91- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(boxBorderFG).Background(backgroundColor).Render("┼"))102+ renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(p.Border).Background(p.BG).Render("\u253c"))92103 }93104 }94105 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, renderedParts...))@@ -98,25 +109,35 @@ func renderGrid(m Model, solved bool, conflicts [gridSize][gridSize]bool) string
98109 grid := lipgloss.JoinVertical(lipgloss.Left, rows...)99110 100111 if solved {101- return gridBorderSolvedStyle.Render(grid)112+ return gridBorderSolvedStyle().Render(grid)102113 }103- return gridBorderStyle.Render(grid)114+ return gridBorderStyle().Render(grid)104115 }105116 106-func getCellStyle(m Model, c cell, x, y int, conflict bool) lipgloss.Style {117+func getCellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {118+ p := theme.Current()119+ isCursor := m.cursor.X == x && m.cursor.Y == y107120 cursorVal := m.grid[m.cursor.Y][m.cursor.X].v108121 109- // Priority: cursor > conflict > same number > provided > user > empty110- if m.cursor.X == x && m.cursor.Y == y {122+ // Priority: cursor+solved > cursor > conflict > same number > provided > user > empty123+ if isCursor && solved {124+ return game.CursorSolvedStyle()125+ }126+127+ if isCursor {111128 return game.CursorStyle()112129 }113130 131+ if solved {132+ return lipgloss.NewStyle().Foreground(p.SolvedFG).Background(p.SuccessBG)133+ }134+114135 if conflict {115- return conflictCellStyle136+ return conflictCellStyle()116137 }117138 118139 if cursorVal != 0 && c.v == cursorVal {119- return sameNumberStyle140+ return sameNumberStyle()120141 }121142 122143 isProvided := m.providedGrid[y][x]@@ -126,31 +147,31 @@ func getCellStyle(m Model, c cell, x, y int, conflict bool) lipgloss.Style {
126147 inCrosshair := inCursorRow || inCursorCol || inCursorBox127148 128149 if isProvided {129- s := providedCellStyle150+ s := providedCellStyle()130151 if inCrosshair {131- s = s.Background(crosshairBG)152+ s = s.Background(p.Surface)132153 }133154 return s134155 }135156 136157 if c.v != 0 {137- s := userCellStyle158+ s := userCellStyle()138159 if inCrosshair {139- s = s.Background(crosshairBG)160+ s = s.Background(p.Surface)140161 }141162 return s142163 }143164 144- s := emptyCellStyle165+ s := emptyCellStyle()145166 if inCrosshair {146- s = s.Background(crosshairBG)167+ s = s.Background(p.Surface)147168 }148169 return s149170 }150171 151172 func cellContent(c cell) string {152173 if c.v == 0 {153- return "·"174+ return "\u00b7"154175 }155176 return fmt.Sprintf("%d", c.v)156177 }@@ -160,9 +181,8 @@ func cellContent(c cell) string {
160181 func computeConflicts(g grid) [gridSize][gridSize]bool {161182 var conflicts [gridSize][gridSize]bool162183 163- // Check rows164184 for y := range gridSize {165- var seen [10][]int // value → list of x positions185+ var seen [10][]int // value -> list of x positions166186 for x := range gridSize {167187 v := g[y][x].v168188 if v != 0 {@@ -178,7 +198,6 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
178198 }179199 }180200 181- // Check columns182201 for x := range gridSize {183202 var seen [10][]int184203 for y := range gridSize {@@ -196,7 +215,6 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
196215 }197216 }198217 199- // Check 3x3 boxes200218 for boxY := range 3 {201219 for boxX := range 3 {202220 type pos struct{ y, x int }@@ -17,7 +17,6 @@ func init() {
1717 })1818 }1919 20-// TakuzuMode represents a specific difficulty configuration for Takuzu.2120 type TakuzuMode struct {2221 game.BaseMode2322 Size int@@ -30,7 +29,6 @@ var (
3029 _ game.SeededSpawner = TakuzuMode{}3130 )3231 33-// NewMode creates a new Takuzu game mode.3432 func NewMode(title, desc string, size int, prefilled float64) TakuzuMode {3533 return TakuzuMode{3634 BaseMode: game.NewBaseMode(title, desc),@@ -39,7 +37,6 @@ func NewMode(title, desc string, size int, prefilled float64) TakuzuMode {
3937 }4038 }4139 42-// Spawn creates a new game instance for this mode.4340 func (t TakuzuMode) Spawn() (game.Gamer, error) {4441 complete := generateComplete(t.Size)4542 puzzle, provided := generatePuzzle(complete, t.Size, t.Prefilled)@@ -52,11 +49,6 @@ func (t TakuzuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5249 return New(t, puzzle, provided)5350 }5451 55-// Modes defines the available difficulty levels.56-// Difficulty is controlled by two axes: grid size and clue density.57-// Smaller grids with more clues need only basic pattern recognition (doubles,58-// sandwich patterns). Larger grids with fewer clues require counting, uniqueness59-// elimination, and deeper chains of deduction.6052 var Modes = []list.Item{6153 NewMode("Beginner", "6×6 grid, ~50% clues. Doubles and sandwich patterns.", 6, 0.50),6254 NewMode("Easy", "6×6 grid, ~40% clues. Counting required.", 6, 0.40),@@ -67,7 +59,6 @@ var Modes = []list.Item{
6759 NewMode("Extreme", "14×14 grid, ~28% clues. Maximum challenge.", 14, 0.28),6860 }6961 70-// DailyModes is the subset of Modes eligible for daily puzzle rotation.7162 var DailyModes = []list.Item{7263 Modes[2], // Medium 8x87364 Modes[3], // Tricky 10x10@@ -24,6 +24,7 @@ every cell is filled and all three rules are satisfied.
2424 | `z` / `0` | Place ● |2525 | `x` / `1` | Place ○ |2626 | `Backspace` | Clear cell |27+| `Ctrl+R` | Reset puzzle |2728 | `Ctrl+H` | Toggle full help |2829 | `Ctrl+E` | Toggle debug overlay |2930 | `Ctrl+N` | Return to main menu |@@ -16,7 +16,6 @@ func generateCompleteSeeded(size int, rng *rand.Rand) grid {
1616 1717 const maxRetries = 101818 for attempt := 0; attempt < maxRetries; attempt++ {19- // Reset grid2019 for y := range size {2120 for x := range size {2221 g[y][x] = emptyCell@@ -5,7 +5,6 @@ import (
55 "github.com/FelineStateMachine/puzzletea/game"66 )77 8-// KeyMap defines the keybindings for Takuzu.98 type KeyMap struct {109 game.CursorKeyMap1110 PlaceZero key.Binding@@ -13,7 +12,6 @@ type KeyMap struct {
1312 Clear key.Binding1413 }1514 16-// DefaultKeyMap provides the standard keybindings.1715 var DefaultKeyMap = KeyMap{1816 CursorKeyMap: game.DefaultCursorKeyMap,1917 PlaceZero: key.NewBinding(@@ -43,7 +41,6 @@ func (m *Model) updateKeyBindings() {
4341 m.keys.Clear.SetEnabled(!onProvided && !solved)4442 }4543 46-// GetFullHelp implements game.Gamer.4744 func (m Model) GetFullHelp() [][]key.Binding {4845 return [][]key.Binding{4946 {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},@@ -2,77 +2,82 @@ package takuzu
22 33 import (44 "charm.land/lipgloss/v2"5- "charm.land/lipgloss/v2/compat"65 "github.com/FelineStateMachine/puzzletea/game"6+ "github.com/FelineStateMachine/puzzletea/theme"77 )88 9-var (10- baseStyle = lipgloss.NewStyle()11- backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}12- zeroStyle = baseStyle.13- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("222")}).14- Background(backgroundColor)15-16- oneStyle = baseStyle.17- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("68"), Dark: lipgloss.Color("111")}).18- Background(backgroundColor)19-20- emptyStyle = baseStyle.21- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).22- Background(backgroundColor)23-24- // cursorSolvedStyle resolved at render time via game.CursorSolvedStyle().9+const cellWidth = 32510 26- crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}27- solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}11+var renderRuneMap = map[rune]string{12+ zeroCell: " \u25cf ",13+ oneCell: " \u25cb ",14+ emptyCell: " \u00b7 ",15+}2816 29- borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}30-)17+func zeroStyle() lipgloss.Style {18+ p := theme.Current()19+ return lipgloss.NewStyle().20+ Foreground(p.Accent).21+ Background(p.BG)22+}3123 32-const cellWidth = 324+func oneStyle() lipgloss.Style {25+ p := theme.Current()26+ return lipgloss.NewStyle().27+ Foreground(p.Secondary).28+ Background(p.BG)29+}3330 34-var (35- renderStyleMap = map[rune]lipgloss.Style{36- zeroCell: zeroStyle,37- oneCell: oneStyle,38- emptyCell: emptyStyle,39- }31+func emptyStyle() lipgloss.Style {32+ p := theme.Current()33+ return lipgloss.NewStyle().34+ Foreground(p.TextDim).35+ Background(p.BG)36+}4037 41- renderRuneMap = map[rune]string{42- zeroCell: " ● ",43- oneCell: " ○ ",44- emptyCell: " · ",38+func renderStyleMap() map[rune]lipgloss.Style {39+ return map[rune]lipgloss.Style{40+ zeroCell: zeroStyle(),41+ oneCell: oneStyle(),42+ emptyCell: emptyStyle(),4543 }46-)44+}4745 4846 func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {49- s, ok := renderStyleMap[val]47+ p := theme.Current()48+ styles := renderStyleMap()49+ s, ok := styles[val]5050 if !ok {51- s = emptyStyle51+ s = emptyStyle()5252 }5353 5454 if isProvided && val != emptyCell {5555 s = s.Bold(true)5656 }5757 58+ r, ok := renderRuneMap[val]59+ if !ok {60+ r = renderRuneMap[emptyCell]61+ }62+5863 if isCursor && solved {5964 s = game.CursorSolvedStyle()65+ r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight6066 } else if isCursor {61- s = s.Background(game.CursorWarmBG()).Bold(true)67+ s = game.CursorStyle()68+ r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight6269 } else if solved {63- s = s.Background(solvedBG)70+ s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)6471 } else if inCursorRow || inCursorCol {65- s = s.Background(crosshairBG)72+ s = s.Background(p.Surface)6673 }6774 68- r, ok := renderRuneMap[val]69- if !ok {70- r = renderRuneMap[emptyCell]71- }7275 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)7376 }7477 7578 func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {79+ colors := game.DefaultBorderColors()80+7681 w := 07782 if len(g) > 0 {7883 w = len(g[0])@@ -84,7 +89,7 @@ func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {
8489 inCursorRow := y == c.Y8590 8691 // Left border segment for this row.87- rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))92+ rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))8893 8994 for x, val := range row {9095 isCursor := x == c.X && y == c.Y@@ -94,29 +99,21 @@ func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {
9499 }95100 96101 // Right border segment for this row.97- rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))102+ rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))98103 99104 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))100105 }101106 102107 // Build top and bottom border rows with per-column crosshair highlighting.103- topRow := game.HBorderRow(w, c.X, cellWidth, "╭", "╮", gridBorderColors, solved)104- botRow := game.HBorderRow(w, c.X, cellWidth, "╰", "╯", gridBorderColors, solved)108+ topRow := game.HBorderRow(w, c.X, cellWidth, "\u256d", "\u256e", colors, solved)109+ botRow := game.HBorderRow(w, c.X, cellWidth, "\u2570", "\u256f", colors, solved)105110 106111 return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)107112 }108113 109-var gridBorderColors = game.GridBorderColors{110- BorderFG: borderFG,111- BackgroundBG: backgroundColor,112- CrosshairBG: crosshairBG,113- SolvedBorderFG: compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")},114- SolvedBG: solvedBG,115-}116-117114 func statusBarView(showFullHelp bool) string {118115 if showFullHelp {119- return game.StatusBarStyle().Render("arrows/wasd: move z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")116+ return game.StatusBarStyle().Render("arrows/wasd: move z: \u25cf x: \u25cb bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")120117 }121- return game.StatusBarStyle().Render("z: ● x: ○ bkspc: clear")118+ return game.StatusBarStyle().Render("z: \u25cf x: \u25cb bkspc: clear")122119 }@@ -148,10 +148,20 @@ func TextOnBG(bg color.Color) color.Color {
148148 return p.BG149149 }150150 151-// midTone returns a color perceptually halfway between a and b by151+// Blend returns a color that is t fraction of the way from a toward b.152+// t=0 returns a, t=1 returns b. Used to derive muted background colors153+// from foreground accent colors.154+func Blend(a, b color.Color, t float64) color.Color {155+ ar, ag, ab, _ := a.RGBA()156+ br, bg, bb, _ := b.RGBA()157+ return blendRGB(uint8(ar>>8), uint8(ag>>8), uint8(ab>>8),158+ uint8(br>>8), uint8(bg>>8), uint8(bb>>8), t)159+}160+161+// MidTone returns a color perceptually halfway between a and b by152162 // blending their sRGB channels. This produces a muted tone suitable for153163 // dim/secondary text that is guaranteed to sit between the two anchors.154-func midTone(a, b color.Color) color.Color {164+func MidTone(a, b color.Color) color.Color {155165 ar, ag, ab, _ := a.RGBA()156166 br, bg, bb, _ := b.RGBA()157167 return color.NRGBA{@@ -15,51 +15,71 @@ const DefaultThemeName = "PuzzleTea Default"
1515 // after [enforcePaletteContrast] runs. The contrast enforcement step1616 // auto-selects cursor text color (light or dark) based on accent luminance.1717 func defaultPalette() Palette {18- p := Palette{19- // Surfaces20- BG: lipgloss.Color("#262626"), // 235 — main background21- FG: lipgloss.Color("#d0d0d0"), // 252 — primary text (9.8:1 on BG)22- Surface: lipgloss.Color("#3a3a3a"), // 237 — subtle elevation23- SurfaceBright: lipgloss.Color("#e4e4e4"), // 254 — bright surface / cursor text18+ bg := lipgloss.Color("#262626") // 235 — main background19+ fg := lipgloss.Color("#d0d0d0") // 252 — primary text20+ surface := lipgloss.Color("#3a3a3a") // 237 — subtle elevation21+22+ // FG-role tokens23+ accent := lipgloss.Color("#cc8844") // 3 yellow — primary accent24+ accentSoft := lipgloss.Color("#cc9966") // 11 brightYellow25+ accentText := lipgloss.Color("#e4e4e4") // 7 white — text on AccentBG26+ success := lipgloss.Color("#55aa55") // 2 green — success FG27+ successBorder := lipgloss.Color("#99cc33") // 10 brightGreen28+ solvedFG := lipgloss.Color("#eeeeee") // 15 brightWhite29+ errorFG := lipgloss.Color("#cc3333") // 1 red — error FG30+ info := lipgloss.Color("#bb8844") // 4 blue — info31+ given := lipgloss.Color("#993399") // 5 purple — given32+ linked := lipgloss.Color("#cc9933") // 6 cyan — linked33+ highlight := lipgloss.Color("#99cccc") // 14 brightCyan — highlight FG34+ secondary := lipgloss.Color("#cccc99") // 12 brightBlue — secondary35+ tertiary := lipgloss.Color("#99cc99") // 13 brightPurple — tertiary2436 25- // Accent — earth-tone orange26- Accent: lipgloss.Color("#cc8844"), // ~180 — primary accent (5.2:1 on BG)27- AccentSoft: lipgloss.Color("#cc9966"), // 180 — secondary highlight28- AccentText: lipgloss.Color("#e4e4e4"), // 254 — text on accent background37+ p := Palette{38+ BG: bg,39+ FG: fg,40+ Surface: surface,2941 30- // Text31- TextDim: lipgloss.Color("#949494"), // ~246 — muted text (5.0:1 on BG)42+ Accent: accent,43+ AccentSoft: accentSoft,44+ AccentText: accentText,45+ TextDim: lipgloss.Color("#949494"), // ~246 — muted text46+ Border: lipgloss.Color("#585858"), // ~240 — borders3247 33- // Chrome34- Border: lipgloss.Color("#585858"), // ~240 — borders (2.1:1 on BG, 1.6:1 on Surface)48+ Success: success,49+ SuccessBorder: successBorder,50+ SolvedFG: solvedFG,51+ Error: errorFG,52+ Info: info,53+ Given: given,54+ Linked: linked,55+ Highlight: highlight,56+ Secondary: secondary,57+ Tertiary: tertiary,3558 36- // Game semantics37- Success: lipgloss.Color("#145530"), // deeper green38- SuccessBorder: lipgloss.Color("#99cc33"), // 149 — solved border text39- Error: lipgloss.Color("#cc3333"), // 167 — conflict foreground40- ErrorBG: lipgloss.Color("#330000"), // 52 — conflict background41- Info: lipgloss.Color("#bb8844"), // ~180 — hint/info text (4.8:1 on BG)42- Highlight: lipgloss.Color("#cc9933"), // 179 — selection highlight43- Warm: lipgloss.Color("#b86b00"), // ~166 — warm cursor BG44- WarmText: lipgloss.Color("#e4e4e4"), // 254 — text on warm background59+ // Derived BGs (blended toward BG/Surface)60+ AccentBG: Blend(bg, accent, blendAccentBG),61+ SuccessBG: Blend(bg, success, blendSuccessBG),62+ ErrorBG: Blend(bg, errorFG, blendErrorBG),63+ SelectionBG: Blend(surface, highlight, blendSelectionBG),64+ HighlightBG: Blend(surface, linked, blendHighlightBG),4565 4666 ANSI: [16]color.Color{47- lipgloss.Color("#262626"), // 0 black48- lipgloss.Color("#cc3333"), // 1 red49- lipgloss.Color("#145530"), // 2 green — success50- lipgloss.Color("#cc8844"), // 3 yellow — primary accent51- lipgloss.Color("#bb8844"), // 4 blue — info/hint52- lipgloss.Color("#993399"), // 5 purple53- lipgloss.Color("#cc9933"), // 6 cyan — highlight54- lipgloss.Color("#d0d0d0"), // 7 white55- lipgloss.Color("#949494"), // 8 brightBlack — textDim56- lipgloss.Color("#330000"), // 9 brightRed — error bg57- lipgloss.Color("#99cc33"), // 10 brightGreen — success border58- lipgloss.Color("#cc9966"), // 11 brightYellow — accent soft59- lipgloss.Color("#cccc99"), // 12 brightBlue60- lipgloss.Color("#99cc99"), // 13 brightPurple61- lipgloss.Color("#ffcc99"), // 14 brightCyan62- lipgloss.Color("#eeeeee"), // 15 brightWhite67+ lipgloss.Color("#262626"), // 0 black — border68+ lipgloss.Color("#cc3333"), // 1 red — error69+ lipgloss.Color("#55aa55"), // 2 green — success70+ lipgloss.Color("#cc8844"), // 3 yellow — accent71+ lipgloss.Color("#bb8844"), // 4 blue — info72+ lipgloss.Color("#993399"), // 5 purple — given73+ lipgloss.Color("#cc9933"), // 6 cyan — linked74+ lipgloss.Color("#d0d0d0"), // 7 white — accentText75+ lipgloss.Color("#949494"), // 8 brightBlack — surface76+ lipgloss.Color("#cc6666"), // 9 brightRed — (unassigned)77+ lipgloss.Color("#99cc33"), // 10 brightGreen — successBorder78+ lipgloss.Color("#cc9966"), // 11 brightYellow — accentSoft79+ lipgloss.Color("#cccc99"), // 12 brightBlue — secondary80+ lipgloss.Color("#99cc99"), // 13 brightPurple — tertiary81+ lipgloss.Color("#99cccc"), // 14 brightCyan — highlight82+ lipgloss.Color("#eeeeee"), // 15 brightWhite — solvedFG6383 },6484 }6585 return enforcePaletteContrast(p)@@ -8,33 +8,46 @@ import (
88 99 // Palette holds the semantic color tokens derived from a terminal color scheme.1010 // Every style in the application reads from the active palette via [Current].11+//12+// All ANSI-sourced tokens are foreground/text-role colors. Background-role13+// colors (AccentBG, SuccessBG, ErrorBG, SelectionBG, HighlightBG) are derived14+// by blending a FG-role color toward BG or Surface, so they always match the15+// theme's luminance range.1116 type Palette struct {1217 // Surfaces13- BG color.Color // main background14- FG color.Color // primary text / foreground15- Surface color.Color // slightly elevated surface (crosshair, panels)16- SurfaceBright color.Color // bright surface (light-mode backgrounds)18+ BG color.Color // main background (theme Background)19+ FG color.Color // primary text (theme Foreground)20+ Surface color.Color // subtle elevation (crosshair BG tint)1721 18- // Brand / accent19- Accent color.Color // primary accent (menu highlights, cursor BG)20- AccentSoft color.Color // muted accent (secondary highlights)21- AccentText color.Color // text on accent background (cursor text, title bars)22+ // Brand / accent (FG-role from ANSI)23+ Accent color.Color // primary accent (ANSI 3 — yellow)24+ AccentSoft color.Color // muted accent (ANSI 11 — brightYellow)25+ AccentText color.Color // text on AccentBG (ANSI 7 — white)2226 2327 // Text24- TextDim color.Color // secondary / muted text28+ TextDim color.Color // secondary / muted text (derived)2529 2630 // Chrome27- Border color.Color // borders, separators, dividers28-29- // Game semantics30- Success color.Color // solved state31- SuccessBorder color.Color // solved border32- Error color.Color // conflict / error foreground33- ErrorBG color.Color // conflict / error background34- Info color.Color // hints, informational text35- Highlight color.Color // selection, active highlight36- Warm color.Color // warm accent (cursor variant)37- WarmText color.Color // text on warm background31+ Border color.Color // borders, separators (ANSI 0 — black)32+33+ // Game FG-role tokens (all from ANSI slots, enforced against BG)34+ Success color.Color // success/solved FG text (ANSI 2 — green)35+ SuccessBorder color.Color // solved border FG (ANSI 10 — brightGreen)36+ SolvedFG color.Color // high-contrast text on state BGs (ANSI 15 — brightWhite)37+ Error color.Color // conflict/error FG text (ANSI 1 — red)38+ Info color.Color // hints, informational text (ANSI 4 — blue)39+ Given color.Color // immutable/provided cell FG (ANSI 5 — purple)40+ Linked color.Color // connected/related element FG (ANSI 6 — cyan)41+ Highlight color.Color // selection/highlight FG (ANSI 14 — brightCyan)42+ Secondary color.Color // second value type (ANSI 12 — brightBlue)43+ Tertiary color.Color // third differentiator (ANSI 13 — brightPurple)44+45+ // Derived background-role tokens (blended, not from ANSI slots)46+ AccentBG color.Color // cursor / active element BG47+ SuccessBG color.Color // solved cell BG48+ ErrorBG color.Color // conflict cell BG49+ SelectionBG color.Color // drag / selection BG50+ HighlightBG color.Color // adjacent / neighbor highlight BG3851 3952 // Full 16-color ANSI palette for puzzle-specific use (e.g. shikaku rects).4053 // Order: black, red, green, yellow, blue, purple, cyan, white,@@ -61,8 +74,22 @@ func (p Palette) CardColors() []color.Color {
6174 }6275 }6376 77+// Blend ratios for derived background colors. Each ratio controls how much78+// of the source FG color is mixed into the base (BG or Surface) to produce79+// the derived BG. Higher values produce more vivid tinted backgrounds.80+const (81+ blendAccentBG = 0.45 // cursor / active element82+ blendSuccessBG = 0.30 // solved cells83+ blendErrorBG = 0.25 // conflict cells84+ blendSelectionBG = 0.35 // drag selection85+ blendHighlightBG = 0.30 // adjacent / neighbor highlight86+)87+6488 // derivePalette maps a Theme's 16 terminal colors onto semantic tokens and6589 // enforces minimum WCAG contrast ratios for every critical FG/BG pairing.90+//91+// All ANSI slots are mapped to FG-role tokens. Background-role tokens are92+// derived by blending a FG color toward BG, respecting the theme's luminance.6693 func derivePalette(t Theme) Palette {6794 bg := lipgloss.Color(t.Background)6895 fg := lipgloss.Color(t.Foreground)@@ -86,38 +113,63 @@ func derivePalette(t Theme) Palette {
86113 lipgloss.Color(t.BrightWhite),87114 }88115 89- // Pick distinct slots for Surface / TextDim / Border so they are not90- // all identical. Surface stays at brightBlack (8); TextDim uses white91- // (7) or brightBlack (8) depending on which has better contrast against92- // BG; Border uses black (0).116+ // --- Non-chromatic luminance ramp ---93117 surface := ansi[8] // brightBlack — subtle elevation94- textDim := midTone(bg, fg) // halfway between BG and FG95- border := ansi[0] // black — distinct from surface118+ textDim := MidTone(bg, fg) // halfway between BG and FG119+ border := ansi[0] // black — grid chrome96120 97121 // If border is too close to BG, swap to brightBlack.98122 if contrastRatio(border, bg) < 1.5 {99123 border = surface100124 }101125 126+ // --- FG-role chromatic tokens (all from ANSI slots) ---127+ accent := ansi[3] // yellow — primary accent128+ success := ansi[2] // green — success/solved FG129+ errorFG := ansi[1] // red — conflict/error FG130+ linked := ansi[6] // cyan — connected elements131+ highlight := ansi[14] // bCyan — selection/highlight FG132+ secondary := ansi[12] // bBlue — second value type133+ highlightSrc := linked // source color for HighlightBG blend134+ _ = highlightSrc135+136+ // --- Derived BG-role tokens (blended toward BG) ---137+ accentBG := Blend(bg, accent, blendAccentBG)138+ successBG := Blend(bg, success, blendSuccessBG)139+ errorBG := Blend(bg, errorFG, blendErrorBG)140+ selectionBG := Blend(surface, highlight, blendSelectionBG)141+ highlightBG := Blend(surface, linked, blendHighlightBG)142+102143 p := Palette{103- BG: bg,104- FG: fg,105- Surface: surface,106- SurfaceBright: ansi[7], // white107- Accent: ansi[3], // yellow108- AccentSoft: ansi[11], // brightYellow109- AccentText: ansi[7], // white — text on accent background110- TextDim: textDim,111- Border: border,112- Success: ansi[2], // green144+ BG: bg,145+ FG: fg,146+ Surface: surface,147+148+ Accent: accent,149+ AccentSoft: ansi[11], // brightYellow150+ AccentText: ansi[7], // white — text on AccentBG151+152+ TextDim: textDim,153+ Border: border,154+155+ Success: success,113156 SuccessBorder: ansi[10], // brightGreen114- Error: ansi[1], // red115- ErrorBG: ansi[9], // brightRed116- Info: ansi[4], // blue117- Highlight: ansi[6], // cyan118- Warm: ansi[11], // brightYellow119- WarmText: ansi[7], // white — text on warm background120- ANSI: ansi,157+ SolvedFG: ansi[15], // brightWhite — text on state BGs158+ Error: errorFG,159+ Info: ansi[4], // blue160+ Given: ansi[5], // purple161+ Linked: linked,162+ Highlight: highlight,163+ Secondary: secondary,164+ Tertiary: ansi[13], // brightPurple165+166+ AccentBG: accentBG,167+ SuccessBG: successBG,168+ ErrorBG: errorBG,169+ SelectionBG: selectionBG,170+ HighlightBG: highlightBG,171+172+ ANSI: ansi,121173 }122174 123175 return enforcePaletteContrast(p)@@ -127,36 +179,39 @@ func derivePalette(t Theme) Palette {
127179 // WCAG AA minimums. It nudges foreground colors toward the palette's brightest128180 // or darkest ANSI slot when the original contrast is insufficient.129181 //130-// Ordering matters: background-role colors (Accent, Success, ErrorBG) are131-// finalized first, then foreground-role colors that sit on top of them.182+// FG-role tokens are enforced against BG (the main canvas). Tokens used on183+// derived BGs (AccentBG, SuccessBG, ErrorBG) are enforced against those184+// derived backgrounds after the FG tokens are finalized.132185 func enforcePaletteContrast(p Palette) Palette {133- // --- Phase 1: finalize colors used as backgrounds ---186+ // --- Phase 1: FG-role tokens against the main BG (normal text → 4.5:1) ---134187 135- // Accent on BG (normal text for menu items → 4.5:1).136188 p.Accent = ensurePairContrast(p.Accent, p.BG, p, minContrastNormal)137-138- // --- Phase 2: finalize foreground colors against their backgrounds ---139-140- // AccentText on Accent (cursor text, title bars — large/bold text → 3:1).141- p.AccentText = ensurePairContrast(p.AccentText, p.Accent, p, minContrastLarge)142-143- // WarmText on Warm (warm cursor text — large/bold text → 3:1).144- p.WarmText = ensurePairContrast(p.WarmText, p.Warm, p, minContrastLarge)145-146- // Solved: SuccessBorder on Success (large/bold text → 3:1).147- p.SuccessBorder = ensurePairContrast(p.SuccessBorder, p.Success, p, minContrastLarge)148-149- // Conflict: Error on ErrorBG (large/bold text → 3:1).150- p.Error = ensurePairContrast(p.Error, p.ErrorBG, p, minContrastLarge)151-152- // TextDim on BG (normal text → 4.5:1).153- p.TextDim = ensurePairContrast(p.TextDim, p.BG, p, minContrastNormal)154-155- // FG on BG (normal text → 4.5:1).189+ p.AccentSoft = ensurePairContrast(p.AccentSoft, p.BG, p, minContrastNormal)156190 p.FG = ensurePairContrast(p.FG, p.BG, p, minContrastNormal)157-158- // Info on BG (normal text → 4.5:1).191+ p.TextDim = ensurePairContrast(p.TextDim, p.BG, p, minContrastNormal)159192 p.Info = ensurePairContrast(p.Info, p.BG, p, minContrastNormal)193+ p.Error = ensurePairContrast(p.Error, p.BG, p, minContrastNormal)194+ p.Success = ensurePairContrast(p.Success, p.BG, p, minContrastNormal)195+ p.Given = ensurePairContrast(p.Given, p.BG, p, minContrastNormal)196+ p.Linked = ensurePairContrast(p.Linked, p.BG, p, minContrastNormal)197+ p.Secondary = ensurePairContrast(p.Secondary, p.BG, p, minContrastNormal)198+ p.Tertiary = ensurePairContrast(p.Tertiary, p.BG, p, minContrastNormal)199+ p.Highlight = ensurePairContrast(p.Highlight, p.BG, p, minContrastNormal)200+201+ // --- Phase 2: re-derive BGs after FG colors are finalized ---202+ // (FG nudging may have shifted the source colors used for blending.)203+204+ p.AccentBG = Blend(p.BG, p.Accent, blendAccentBG)205+ p.SuccessBG = Blend(p.BG, p.Success, blendSuccessBG)206+ p.ErrorBG = Blend(p.BG, p.Error, blendErrorBG)207+ p.SelectionBG = Blend(p.Surface, p.Highlight, blendSelectionBG)208+ p.HighlightBG = Blend(p.Surface, p.Linked, blendHighlightBG)209+210+ // --- Phase 3: FG tokens on derived BGs (bold text → 3:1) ---211+212+ p.AccentText = ensurePairContrast(p.AccentText, p.AccentBG, p, minContrastLarge)213+ p.SolvedFG = ensurePairContrast(p.SolvedFG, p.SuccessBG, p, minContrastNormal)214+ p.SuccessBorder = ensurePairContrast(p.SuccessBorder, p.SuccessBG, p, minContrastLarge)160215 161216 return p162217 }@@ -43,11 +43,15 @@ func PreviewPanel(themeName string, height int) string {
4343 lines = append(lines, "")4444 4545 // Semantic color swatches: two per row to keep labels readable.46- lines = append(lines, dim.Render("Semantic"))46+ lines = append(lines, dim.Render("FG Tokens"))4747 lines = append(lines, swatchPair(p, "Accent", p.Accent, "Soft", p.AccentSoft))48- lines = append(lines, swatchPair(p, "Info", p.Info, "Warm", p.Warm))4948 lines = append(lines, swatchPair(p, "OK", p.Success, "Error", p.Error))50- lines = append(lines, swatchPair(p, "FG", p.FG, "Dim", p.TextDim))49+ lines = append(lines, swatchPair(p, "Info", p.Info, "Given", p.Given))50+ lines = append(lines, swatchPair(p, "Linked", p.Linked, "2nd", p.Secondary))51+ lines = append(lines, "")52+ lines = append(lines, dim.Render("Derived BGs"))53+ lines = append(lines, swatchPair(p, "Cursor", p.AccentBG, "Solved", p.SuccessBG))54+ lines = append(lines, swatchPair(p, "Error", p.ErrorBG, "Select", p.SelectionBG))5155 lines = append(lines, "")5256 5357 // ANSI 16-color grid: 8 per row, compact 3-char swatches.@@ -59,11 +63,11 @@ func PreviewPanel(themeName string, height int) string {
5963 // Game state previews.6064 lines = append(lines, dim.Render("Game States"))6165 lines = append(lines, lipgloss.NewStyle().62- Background(p.Accent).Foreground(p.AccentText).Bold(true).66+ Background(p.AccentBG).Foreground(p.AccentText).Bold(true).6367 Width(previewW).Padding(0, 1).6468 Render("Cursor"))6569 lines = append(lines, lipgloss.NewStyle().66- Background(p.Success).Foreground(p.SuccessBorder).Bold(true).70+ Background(p.SuccessBG).Foreground(p.SolvedFG).Bold(true).6771 Width(previewW).Padding(0, 1).6872 Render("Solved"))6973 lines = append(lines, lipgloss.NewStyle().@@ -172,14 +172,23 @@ func assertPaletteContrast(t *testing.T, name string, p Palette) {
172172 }173173 174174 pairs := []pair{175+ // FG tokens on main BG (normal text → 4.5:1)175176 {"FG on BG", p.FG, p.BG, minContrastNormal},176177 {"TextDim on BG", p.TextDim, p.BG, minContrastNormal},177- {"Info on BG", p.Info, p.BG, minContrastNormal},178178 {"Accent on BG", p.Accent, p.BG, minContrastNormal},179- {"AccentText on Accent (cursor)", p.AccentText, p.Accent, minContrastLarge},180- {"WarmText on Warm (warm cursor)", p.WarmText, p.Warm, minContrastLarge},181- {"SuccessBorder on Success (solved)", p.SuccessBorder, p.Success, minContrastLarge},182- {"Error on ErrorBG (conflict)", p.Error, p.ErrorBG, minContrastLarge},179+ {"AccentSoft on BG", p.AccentSoft, p.BG, minContrastNormal},180+ {"Info on BG", p.Info, p.BG, minContrastNormal},181+ {"Error on BG", p.Error, p.BG, minContrastNormal},182+ {"Success on BG", p.Success, p.BG, minContrastNormal},183+ {"Given on BG", p.Given, p.BG, minContrastNormal},184+ {"Linked on BG", p.Linked, p.BG, minContrastNormal},185+ {"Secondary on BG", p.Secondary, p.BG, minContrastNormal},186+ {"Tertiary on BG", p.Tertiary, p.BG, minContrastNormal},187+ {"Highlight on BG", p.Highlight, p.BG, minContrastNormal},188+ // FG tokens on derived BGs (bold text → 3:1)189+ {"AccentText on AccentBG (cursor)", p.AccentText, p.AccentBG, minContrastLarge},190+ {"SolvedFG on SuccessBG (solved text)", p.SolvedFG, p.SuccessBG, minContrastNormal},191+ {"SuccessBorder on SuccessBG (solved)", p.SuccessBorder, p.SuccessBG, minContrastLarge},183192 }184193 185194 for _, pp := range pairs {@@ -194,7 +203,7 @@ func assertPaletteContrast(t *testing.T, name string, p Palette) {
194203 func TestMidTone(t *testing.T) {195204 black := color.NRGBA{R: 0, G: 0, B: 0, A: 255}196205 white := color.NRGBA{R: 255, G: 255, B: 255, A: 255}197- mid := midTone(black, white)206+ mid := MidTone(black, white)198207 r, g, b, _ := mid.RGBA()199208 // Should be roughly 127/128 for each channel.200209 got := fmt.Sprintf("%d,%d,%d", r>>8, g>>8, b>>8)@@ -39,7 +39,6 @@ func (m *MainMenu) CursorDown() {
3939 }4040 }4141 42-// Selected returns the currently highlighted menu item.4342 func (m MainMenu) Selected() MenuItem {4443 return m.items[m.cursor]4544 }@@ -48,7 +47,7 @@ func (m MainMenu) Selected() MenuItem {
4847 func (m MainMenu) View() string {4948 styledLogo := LogoStyle().Render(logo)5049 51- items := m.renderItems()50+ items := m.RenderItems()5251 5352 footer := FooterHint().Render("↑/↓ navigate • enter select • ctrl+c quit")5453 @@ -67,11 +66,25 @@ func (m MainMenu) View() string {
6766 // suitable for submenus that should look visually distinct from the6867 // root main menu.6968 func (m MainMenu) ViewAsPanel(title string) string {70- return Panel(title, m.renderItems(), "↑/↓ navigate • enter select • esc back")69+ return Panel(title, m.RenderItems(), "↑/↓ navigate • enter select • esc back")7170 }7271 73-// renderItems returns the styled item list shared by View and ViewAsPanel.74-func (m MainMenu) renderItems() string {72+// RenderItems returns the styled item list shared by View and ViewAsPanel.73+func (m MainMenu) RenderItems() string {74+ // Pre-compute the widest item line so the block has a stable width75+ // regardless of which item is selected.76+ maxW := 077+ for _, item := range m.items {78+ // Selected cursor prefix ("⦾ ") is the widest variant at 3 chars.79+ w := lipgloss.Width("⦾ " + item.ItemTitle)80+ if dw := lipgloss.Width(" " + item.Desc); dw > w {81+ w = dw82+ }83+ if w > maxW {84+ maxW = w85+ }86+ }87+7588 var itemLines []string7689 for i, item := range m.items {7790 var line, cursor, title string@@ -86,5 +99,7 @@ func (m MainMenu) renderItems() string {
8699 line = cursor + title + desc + "\n"87100 itemLines = append(itemLines, line)88101 }89- return strings.Join(itemLines, "\n")102+103+ content := strings.Join(itemLines, "\n")104+ return lipgloss.NewStyle().Width(maxW).Render(content)90105 }@@ -20,7 +20,7 @@ func LogoStyle() lipgloss.Style {
2020 p := theme.Current()2121 return lipgloss.NewStyle().2222 Bold(true).23- Foreground(p.FG).23+ Foreground(theme.TextOnBG(p.Accent)).2424 Background(p.Accent).2525 Padding(0, 1).2626 Margin(1, 0)@@ -17,7 +17,6 @@ func init() {
1717 })1818 }1919 20-// WordSearchMode implements game.Mode for word search puzzles2120 type WordSearchMode struct {2221 game.BaseMode2322 Width int@@ -29,12 +28,11 @@ type WordSearchMode struct {
2928 }3029 3130 var (32- _ game.Mode = WordSearchMode{} // compile-time interface check33- _ game.Spawner = WordSearchMode{} // compile-time interface check34- _ game.SeededSpawner = WordSearchMode{} // compile-time interface check31+ _ game.Mode = WordSearchMode{}32+ _ game.Spawner = WordSearchMode{}33+ _ game.SeededSpawner = WordSearchMode{}3534 )3635 37-// NewMode creates a new WordSearchMode with the given parameters3836 func NewMode(title, description string, width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) WordSearchMode {3937 return WordSearchMode{4038 BaseMode: game.NewBaseMode(title, description),@@ -63,7 +61,6 @@ var Modes = []list.Item{
6361 NewMode("Hard 20x20", "Find 15 words in a 20x20 grid.", 20, 20, 15, 5, 10, []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft}),6462 }6563 66-// DailyModes is the subset of Modes eligible for daily puzzle rotation.6764 var DailyModes = []list.Item{6865 Modes[0], // Easy 10x106966 }@@ -65,7 +65,6 @@ func buildFoundCells(width, height int, words []Word) [][]bool {
6565 return fc6666 }6767 68-// New creates a new word search game6968 func New(mode WordSearchMode, g grid, words []Word) (game.Gamer, error) {7069 return &Model{7170 width: mode.Width,@@ -22,6 +22,7 @@ The puzzle is solved when all words are found.
2222 | Arrow keys / WASD / hjkl | Move cursor |2323 | `Enter` / `Space` | Select start or confirm end |2424 | `Escape` | Cancel current selection |25+| `Ctrl+R` | Reset puzzle |2526 | `Ctrl+H` | Toggle full help |2627 | `Ctrl+E` | Toggle debug overlay |2728 | `Ctrl+N` | Return to main menu |@@ -5,7 +5,6 @@ import (
55 "strings"66 )77 8-// GenerateWordSearch creates a new word search grid with the specified parameters98 func GenerateWordSearch(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) (grid, []Word) {109 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))1110 return GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen, allowedDirs, rng)@@ -16,7 +15,6 @@ func GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen int, allo
1615 words := selectWordsSeeded(wordCount, minLen, maxLen, rng)1716 placedWords := make([]Word, 0, len(words))1817 19- // Try to place each word2018 for _, wordText := range words {2119 word := tryPlaceWordSeeded(g, wordText, allowedDirs, 100, rng)2220 if word != nil {@@ -24,20 +22,17 @@ func GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen int, allo
2422 }2523 }2624 27- // Fill remaining empty cells with random letters2825 fillEmptyCellsSeeded(g, rng)2926 3027 return g, placedWords3128 }3229 33-// selectWords randomly selects words from the word list3430 func selectWords(count, minLen, maxLen int) []string {3531 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))3632 return selectWordsSeeded(count, minLen, maxLen, rng)3733 }3834 3935 func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {40- // Collect all valid words4136 var candidates []string4237 for length := minLen; length <= maxLen; length++ {4338 if words, ok := wordsByLength[length]; ok {@@ -45,7 +40,6 @@ func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {
4540 }4641 }4742 48- // Shuffle and take first 'count' words4943 rng.Shuffle(len(candidates), func(i, j int) {5044 candidates[i], candidates[j] = candidates[j], candidates[i]5145 })@@ -57,7 +51,6 @@ func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {
5751 return candidates[:count]5852 }5953 60-// tryPlaceWord attempts to place a word in the grid6154 func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int) *Word {6255 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))6356 return tryPlaceWordSeeded(g, text, allowedDirs, maxAttempts, rng)@@ -69,15 +62,12 @@ func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempt
6962 width := len(g[0])7063 7164 for range maxAttempts {72- // Random starting position7365 x := rng.IntN(width)7466 y := rng.IntN(height)7567 76- // Random direction7768 dir := allowedDirs[rng.IntN(len(allowedDirs))]7869 dx, dy := dir.Vector()7970 80- // Check if word fits in this direction8171 endX := x + dx*(len(text)-1)8272 endY := y + dy*(len(text)-1)8373 @@ -85,7 +75,6 @@ func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempt
8575 continue8676 }8777 88- // Check if placement is valid (no conflicts, allow letter overlap)8978 valid := true9079 for i := range len(text) {9180 cx := x + dx*i@@ -102,7 +91,6 @@ func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempt
10291 continue10392 }10493 105- // Place the word10694 for i := range len(text) {10795 cx := x + dx*i10896 cy := y + dy*i@@ -121,7 +109,6 @@ func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempt
121109 return nil122110 }123111 124-// fillEmptyCells fills all empty cells with random letters125112 func fillEmptyCells(g grid) {126113 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))127114 fillEmptyCellsSeeded(g, rng)@@ -80,16 +80,16 @@ func (m *Model) gridOrigin() (x, y int) {
8080 // The mainView (grid + spacer + word list) is centered within the8181 // overall view. Measure its width to compute left padding.8282 gridContent := renderGrid(*m)83- gBorder := gridBorderStyle83+ gBorder := gridBorderStyle()8484 if m.solved {85- gBorder = gridBorderSolvedStyle85+ gBorder = gridBorderSolvedStyle()8686 }8787 gridRendered := gBorder.Render(gridContent)8888 8989 wordListContent := renderWordList(*m)90- wBorder := wordListBorderStyle90+ wBorder := wordListBorderStyle()9191 if m.solved {92- wBorder = wordListBorderSolvedStyle92+ wBorder = wordListBorderSolvedStyle()9393 }9494 gridHeight := lipgloss.Height(gridRendered)9595 wBorder = wBorder.Height(gridHeight - 2)@@ -5,65 +5,76 @@ import (
55 "strings"66 77 "charm.land/lipgloss/v2"8- "charm.land/lipgloss/v2/compat"98 "github.com/FelineStateMachine/puzzletea/game"109 "github.com/FelineStateMachine/puzzletea/theme"1110 )1211 13-var (14- normalStyle = lipgloss.NewStyle().15- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("252")}).16- Background(compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")})17-18- // cursorStyle resolved at render time via game.CursorStyle().19-20- // selectionStyle resolved at render time via selectionStyle().21-22- foundStyle = lipgloss.NewStyle().23- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("151")}).24- Background(compat.AdaptiveColor{Light: lipgloss.Color("194"), Dark: lipgloss.Color("237")})12+func normalStyle() lipgloss.Style {13+ p := theme.Current()14+ return lipgloss.NewStyle().15+ Foreground(p.FG).16+ Background(p.BG)17+}2518 26- wordListHeaderStyle = lipgloss.NewStyle().27- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("173")}).28- Bold(true).29- Underline(true)19+func foundStyle() lipgloss.Style {20+ p := theme.Current()21+ return lipgloss.NewStyle().22+ Foreground(p.SolvedFG).23+ Background(p.SuccessBG)24+}3025 31- foundWordStyle = lipgloss.NewStyle().32- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("28"), Dark: lipgloss.Color("115")}).33- Strikethrough(true)26+func wordListHeaderStyle() lipgloss.Style {27+ return lipgloss.NewStyle().28+ Foreground(theme.Current().Accent).29+ Bold(true).30+ Underline(true)31+}3432 35- unfoundWordStyle = lipgloss.NewStyle().36- Foreground(compat.AdaptiveColor{Light: lipgloss.Color("95"), Dark: lipgloss.Color("180")})33+func foundWordStyle() lipgloss.Style {34+ return lipgloss.NewStyle().35+ Foreground(theme.Current().SuccessBorder).36+ Strikethrough(true)37+}3738 38- borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}39- gridBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")}40- solvedBdrFG = compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}39+func unfoundWordStyle() lipgloss.Style {40+ return lipgloss.NewStyle().41+ Foreground(theme.Current().Info)42+}4143 42- gridBorderStyle = lipgloss.NewStyle().43- Border(lipgloss.RoundedBorder()).44- BorderForeground(borderFG).45- BorderBackground(gridBG)44+func gridBorderStyle() lipgloss.Style {45+ p := theme.Current()46+ return lipgloss.NewStyle().47+ Border(lipgloss.RoundedBorder()).48+ BorderForeground(p.Border).49+ BorderBackground(p.BG)50+}4651 47- gridBorderSolvedStyle = lipgloss.NewStyle().48- Border(lipgloss.RoundedBorder()).49- BorderForeground(solvedBdrFG).50- BorderBackground(gridBG)52+func gridBorderSolvedStyle() lipgloss.Style {53+ p := theme.Current()54+ return lipgloss.NewStyle().55+ Border(lipgloss.RoundedBorder()).56+ BorderForeground(p.SuccessBorder).57+ BorderBackground(p.BG)58+}5159 52- wordListBorderStyle = lipgloss.NewStyle().53- Border(lipgloss.RoundedBorder()).54- BorderForeground(borderFG).55- Padding(0, 1)60+func wordListBorderStyle() lipgloss.Style {61+ return lipgloss.NewStyle().62+ Border(lipgloss.RoundedBorder()).63+ BorderForeground(theme.Current().Border).64+ Padding(0, 1)65+}5666 57- wordListBorderSolvedStyle = lipgloss.NewStyle().58- Border(lipgloss.RoundedBorder()).59- BorderForeground(solvedBdrFG).60- Padding(0, 1)61-)67+func wordListBorderSolvedStyle() lipgloss.Style {68+ return lipgloss.NewStyle().69+ Border(lipgloss.RoundedBorder()).70+ BorderForeground(theme.Current().SuccessBorder).71+ Padding(0, 1)72+}6273 6374 // selectionStyle returns the style for cells in the active drag selection,64-// using the current theme's Highlight color for the background.75+// using the current theme's SelectionBG color for the background.6576 func selectionStyle() lipgloss.Style {66- bg := theme.Current().Highlight77+ bg := theme.Current().SelectionBG6778 return lipgloss.NewStyle().6879 Foreground(theme.TextOnBG(bg)).6980 Background(bg)@@ -74,11 +85,11 @@ func renderView(m Model) string {
7485 gridContent := renderGrid(m)7586 wordListContent := renderWordList(m)7687 77- gBorder := gridBorderStyle78- wBorder := wordListBorderStyle88+ gBorder := gridBorderStyle()89+ wBorder := wordListBorderStyle()7990 if m.solved {80- gBorder = gridBorderSolvedStyle81- wBorder = wordListBorderSolvedStyle91+ gBorder = gridBorderSolvedStyle()92+ wBorder = wordListBorderSolvedStyle()8293 }8394 8495 gridView := gBorder.Render(gridContent)@@ -110,7 +121,11 @@ func renderGrid(m Model) string {
110121 for x := 0; x < m.width; x++ {111122 letter := m.grid.Get(x, y)112123 style := getCellStyle(m, x, y)113- cells = append(cells, style.Render(fmt.Sprintf(" %c ", letter)))124+ display := fmt.Sprintf(" %c ", letter)125+ if m.cursor.X == x && m.cursor.Y == y {126+ display = game.CursorLeft + fmt.Sprintf("%c", letter) + game.CursorRight127+ }128+ cells = append(cells, style.Render(display))114129 }115130 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))116131 }@@ -121,22 +136,19 @@ func renderGrid(m Model) string {
121136 func getCellStyle(m Model, x, y int) lipgloss.Style {122137 // Priority order: cursor > selection > found word > normal123138 124- // Check if cursor position125139 if m.cursor.X == x && m.cursor.Y == y {126140 return game.CursorStyle()127141 }128142 129- // Check if in current selection130143 if m.selection == startSelected && isInSelection(m, x, y) {131144 return selectionStyle()132145 }133146 134- // Check if part of found word135147 if m.foundCells[y][x] {136- return foundStyle148+ return foundStyle()137149 }138150 139- return normalStyle151+ return normalStyle()140152 }141153 142154 func isInSelection(m Model, x, y int) bool {@@ -152,14 +164,14 @@ func isInSelection(m Model, x, y int) bool {
152164 func renderWordList(m Model) string {153165 var sb strings.Builder154166 155- sb.WriteString(wordListHeaderStyle.Render("Words to Find:"))167+ sb.WriteString(wordListHeaderStyle().Render("Words to Find:"))156168 sb.WriteString("\n\n")157169 158170 for _, word := range m.words {159171 if word.Found {160- sb.WriteString(foundWordStyle.Render(fmt.Sprintf("✓ %s", word.Text)))172+ sb.WriteString(foundWordStyle().Render(fmt.Sprintf("\u2713 %s", word.Text)))161173 } else {162- sb.WriteString(unfoundWordStyle.Render(fmt.Sprintf("○ %s", word.Text)))174+ sb.WriteString(unfoundWordStyle().Render(fmt.Sprintf("\u25cb %s", word.Text)))163175 }164176 sb.WriteString("\n")165177 }@@ -1,6 +1,6 @@
11 package wordsearch22 3-// Word list organized by length for difficulty filtering3+// Word list organized by length for difficulty filtering.44 55 var wordsByLength = map[int][]string{66 3: {"ABS", "ABU", "ACC", "ACE", "ACM", "ACT", "ADA", "ADD", "ADS", "AID", "AIM", "AIR", "AKA", "ALI", "ALL", "ALT", "AMD", "AMP", "AMY", "ANA", "AND", "ANN", "ANT", "ANY", "AOL", "API", "ARC", "ARG", "ARM", "ART", "ASH", "ASK", "ASN", "ASP", "ATE", "ATI", "ATM", "AUD", "AUG", "AUS", "AVE", "AVG", "AVI", "BAD", "BAR", "BAT", "BAY", "BBC", "BBS", "BBW", "BED", "BEN", "BET", "BID", "BIG", "BIN", "BIT", "BIZ", "BMW", "BOB", "BOC", "BON", "BOW", "BOX", "BRA", "BUS", "BUT", "BUY", "BYE", "CAB", "CAN", "CAP", "CAR", "CAS", "CAT", "CBS", "CCD", "CDT", "CEO", "CET", "CFR", "CGI", "CHI", "CHO", "CIA", "CIO", "CIR", "CMS", "CNN", "CON", "COP", "COS", "COX", "CPU", "CRM", "CRY", "CST", "CUM", "CUP", "CUT", "CVS", "DAM", "DAN", "DAS", "DAT", "DEC", "DEE", "DEL", "DEM", "DEN", "DID", "DIE", "DIM", "DIP", "DIS", "DIV", "DIY", "DNA", "DNS", "DOC", "DOE", "DOG", "DOM", "DON", "DOS", "DOT", "DOW", "DRY", "DSC", "DTS", "DUE", "DUO", "EAR", "EAT", "EAU", "EDT", "EGG", "END", "ENT", "EPA", "EST", "ETC", "EUR", "EVA", "EVE", "EYE", "FAN", "FAQ", "FAR", "FAT", "FAX", "FBI", "FCC", "FDA", "FED", "FEE", "FEW", "FIG", "FIT", "FIX", "FLU", "FLY", "FOG", "FOO", "FOX", "FUN", "FWD", "GAP", "GAS", "GCC", "GDP", "GEL", "GEM", "GEN", "GIG", "GMC", "GNU", "GOD", "GOT", "GPS", "GSM", "GST", "GTK", "GUI", "GUN", "GUY", "GYM", "HAD", "HAM", "HAS", "HAT", "HAY", "HER", "HEY", "HIM", "HIP", "HIS", "HIT", "HOT", "HRS", "HUB", "IAN", "IBM", "ICE", "ICQ", "ICT", "IDE", "III", "ILL", "IMG", "INC", "IND", "INF", "INK", "INN", "INS", "ION", "IPS", "IRA", "IRC", "IRS", "ISA", "ISO", "ISP", "IST", "ITS", "JAM", "JAR", "JAY", "JET", "JOB", "JOE", "JON", "JPG", "JUL", "JVC", "KAI", "KAY", "KDE", "KEN", "KID", "KIM", "KIT", "LAB", "LAN", "LAP", "LAT", "LAW", "LAY", "LCD", "LED", "LEG", "LEN", "LEO", "LES", "LEU", "LIB", "LID", "LIT", "LIZ", "LLP", "LOG", "LOL", "LOS", "LOT", "LOU", "LOW", "LTD", "MAC", "MAD", "MAE", "MAG", "MAI", "MAN", "MAP", "MAS", "MAT", "MAX", "MBA", "MED", "MEL", "MEM", "MEN", "MHZ", "MIA", "MIC", "MID", "MIL", "MIN", "MIX", "MLB", "MLS", "MOD", "MOM", "MON", "MPG", "MPH", "MRS", "MSG", "MSN", "MTV", "MUD", "MUG", "NAM", "NAV", "NBA", "NET", "NEW", "NFL", "NHL", "NHS", "NIL", "NON", "NOR", "NOT", "NOV", "NOW", "NSW", "NYC", "OAK", "OBJ", "OCT", "ODD", "OEM", "OIL", "OLD", "ONS", "OPT", "ORG", "PAC", "PAL", "PAM", "PAS", "PAY", "PCI", "PCS", "PCT", "PDA", "PEE", "PEN", "PER", "PGP", "PHD", "PHI", "PHP", "PIC", "PIG", "PIT", "PLC", "PMC", "POD", "POR", "POS", "POT", "PPM", "PRO", "PSP", "PST", "PTS", "PUT", "PVC", "QLD", "QUE", "QUI", "RAM", "RAN", "RAP", "RAT", "RAW", "RAY", "RCA", "REC", "RED", "REF", "REG", "REP", "RFC", "RIM", "RIO", "RIP", "RNA", "ROB", "ROD", "ROW", "ROY", "RPM", "RSS", "RUG", "RUN", "SAM", "SAN", "SAO", "SAP", "SAT", "SEA", "SEE", "SEN", "SEO", "SEQ", "SET", "SEX", "SHE", "SIM", "SIN", "SIP", "SIR", "SKU", "SKY", "SMS", "SOA", "SOC", "SOL", "SPA", "SPY", "SRC", "SRI", "SSL", "STE", "STR", "SUB", "SUE", "SUM", "SUN", "TAB", "TAG", "TAN", "TAR", "TAX", "TBA", "TCP", "TED", "TEE", "TEN", "TEX", "TFT", "TGP", "THE", "THU", "TIE", "TIL", "TIM", "TIP", "TIT", "TOE", "TOM", "TON", "TOO", "TOP", "TOY", "TRI", "TUB", "TUE", "TWO", "UNA", "UND", "UNE", "UPC", "UPS", "URI", "URL", "URW", "USA", "USB", "USC", "USD", "USE", "USR", "VAL", "VAN", "VAT", "VCR", "VHS", "VIA", "VIC", "VID", "VIP", "VON", "WAL", "WAN", "WAV", "WAX", "WAY", "WEB", "WED", "WET", "WHO", "WHY", "WMA", "WOW", "WTO", "WWW", "XML", "XXX", "YEN", "YET", "YOU", "ZIP", "ZOO", "ZUM", "ZUS"},