Skip to content

feat(ui): snow winter scene, rain panel size, and snow fixes#13

Merged
bschellenberger2600 merged 9 commits into
mainfrom
feat/ui-snow-rain-mode
Jun 17, 2026
Merged

feat(ui): snow winter scene, rain panel size, and snow fixes#13
bschellenberger2600 merged 9 commits into
mainfrom
feat/ui-snow-rain-mode

Conversation

@bschellenberger2600

@bschellenberger2600 bschellenberger2600 commented Apr 19, 2026

Copy link
Copy Markdown
Member

Summary

Adds snow rain_animation_mode with a winter strip (flakes, drifting ground snow, cabin, smoke, evergreens, staged snowman), rain_panel_size presets (compact / comfortable / tall) with runtime clamping, and follow-up fixes for stability and polish.

Fixes (second commit)

  • Stack overflow: rainVisible no longer calls layout measurement that re-entered the header; measurement uses rainStripIncludedInLayoutMeasure() while the live view still uses rainVisible().
  • Snow: snow_accumulation_rate (1–8× depth per landing), settings row in snow mode, loader default, tests.
  • Scene: trees placed at init; snowman feet on the row above the ground bank for separation; clearer bold/contrast styling.

Tests

go test ./... (pass)

Docs

README + example TOML updated on the base commit for snow and panel size.


Note

Medium Risk
Changes TUI layout measurement and visible rain-strip logic; mistakes could hide the animation or mis-size panels, but scope is UI-only with added tests.

Overview
Adds snow as a rain_animation_mode with a new winter strip: falling flakes, per-column ground depth, cabin/smoke/trees/frost, and a snowman that advances from nearby landings. Scene logic lives in snow_scene.go and hooks into RainBackground update/render paths.

Introduces rain_panel_size (compact / comfortable / tall) and snow_accumulation_rate (1–8× depth per landing via SnowAccumPerLanding). Defaults, loader normalization, example TOML, and README are updated; the settings TUI adds Rain panel size, snow in the mode list, and a Snow accumulation row when snow is selected (garden extension rows are generalized the same way).

The animation canvas is no longer fixed at five rows: height comes from the preset and clampedRainBackgroundHeight, which shrinks the canvas until main and ignored panels fit. Fixes a stack overflow by measuring layout with rainStripIncludedInLayoutMeasure() and gated header helpers instead of rainVisible() during capacity math; rainVisible() still decides whether to paint the strip once sizes are known.

Reviewed by Cursor Bugbot for commit b21ba56. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features
    • Added “snow” rain animation with snowfall, ground accumulation, and a progressive scene (cabin, trees, snowman).
    • Added rain panel size presets (compact, comfortable default, tall) with automatic terminal-height clamping.
    • Added snow accumulation rate configuration to control depth added per landing flake.
  • Documentation
    • Updated README with snow-mode behavior, rain_panel_size presets, and example TOML.
  • Bug Fixes
    • Rain background sizing now adapts more reliably to window size changes.
  • Tests
    • Added coverage for snow accumulation, panel sizing/normalization, and snow scene rendering.

… canvas

Add snow animation with falling flakes, unbounded ground accumulation,
log cabin with chimney smoke and lit windows, random trees with frost,
and a staged snowman. Introduce rain_panel_size (compact/comfortable/tall)
with terminal clamping via panel height measurement. Default comfortable
(8 rows). Settings TUI and README updated.
- Break rainVisible vs measured header cycle (rainStripIncludedInLayoutMeasure + header gate)\n- Add snow_accumulation_rate (1–8× per landing) and settings TUI row\n- Trees spawn at init; snowman elevated above ground bank; bolder styling and split parens on base
@coderabbitai

coderabbitai Bot commented Apr 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da2fce2a-0991-450e-a617-77e125ca3bb0

📥 Commits

Reviewing files that changed from the base of the PR and between ad6153e and b21ba56.

📒 Files selected for processing (6)
  • README.md
  • internal/ui/config_view.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
  • internal/ui/snow_scene.go
  • internal/ui/view_layout.go
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
  • internal/ui/snow_scene.go
  • internal/ui/config_view.go
  • internal/ui/view_layout.go

📝 Walkthrough

Walkthrough

Adds a "snow" rain animation mode with an interactive scene (trees, cabin, snowman, smoke triggered by flake landings), configurable rain panel sizing (compact/comfortable/tall) with dynamic height clamping to fit TUI panels, snow accumulation controls, and refactors layout measurement to decouple rain-strip visibility from layout-fitting decisions.

Changes

Snow Animation and Panel Sizing

Layer / File(s) Summary
Configuration types and defaults
internal/config/types.go, internal/config/defaults.go, internal/config/loader.go, internal/config/config_test.go, internal/config/snow_accum_test.go
Added UIConfig.RainPanelSize (string, preset: compact/comfortable/tall), UIConfig.SnowAccumulationRate (float64), UIRainAnimationSnow constant, panel preset constants, and helper functions (RainPanelRows, NormalizeRainPanelSize, SnowAccumPerLanding). Set defaults and extended Viper loader to normalize presets during config load. Added tests validating preset mapping and snow accumulation behavior.
Snow scene implementation
internal/ui/snow_scene.go
Implements complete snow animation: evergreen tree placement with frost aging, cabin with chimney, multi-phase snowman build state machine, and chimney smoke particles with aging/drift. Includes scene initialization, per-frame simulation (frost updates, snowman phase progression when ground deep enough, smoke spawning/aging), collision detection for tree/snowman placement, and full scene rendering with night sky, styled trees, cabin variants, depth-based ground glyphs, and overlaid smoke.
Rain background integration
internal/ui/rain_bg.go, internal/ui/rain_bg_test.go
Extended RainBackground with snow-mode state (ground depths, trees, cabin, snowman, smoke). Added SetSnowAccumPerLanding setter and updated drop spawning to use snowflake glyphs in snow mode. Drops respect ground area, increment SnowGround on landing, trigger snowman interaction, and drive scene progression each frame. Rendering calls paintSnowScene for snow and uses dedicated night/snowflake palette. Tests verify width/height rendering, ground accumulation, tree placement constraints, and snowman phase progression.
UI config view: mode-specific settings
internal/ui/config_view.go
Added snow-mode rows and "Rain panel size" enum row; generalized mode-extension infrastructure to support garden-only or snow-only extension rows based on active mode. Updated row indexing, value translation, and applyConfigChange cases to set RainPanelSize (all modes), SnowAccumulationRate (snow), or GardenGrowthPace (garden). Updated rain background rebuild conditions to trigger when mode, width, or clamped height differ.
Dynamic rain panel clamping
internal/ui/repo_selector.go
Implements dynamic height clamping: rainPanelPresetRowsFromCfg resolves preset row counts, rainPanelFitsHeight tests whether panels fit, and clampedRainBackgroundHeight selects largest fitting height. Rewrites rainVisible() for layout-fit validation. Updates model initialization (static/streaming) and resize handling to apply clamped heights. Extends applyGardenTuning to set snow accumulation when in snow mode.
Layout measurement refactoring
internal/ui/view_layout.go
Introduces parameterized helpers mainViewHeaderBlockWithRainGate and ignoredViewHeaderBlockWithRainGate to separate rain-strip inclusion from generic visibility. Legacy mainViewHeaderBlock() and ignoredViewHeaderBlock() remain as wrappers. Updates panel height/capacity calculations to use rainStripIncludedInLayoutMeasure() instead of rainVisible() to avoid recursion during measurement while preserving visibility logic.
Documentation
README.md
Documents snow animation mode (trees, cabin, snowman, snowflakes, smoke), explains rain_panel_size presets and automatic height clamping, and provides TOML example for [ui] configuration.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

  • git-fire/git-rain#9: Both extend rain-mode infrastructure in internal/config/types.go and internal/ui/rain_bg.go, but this PR adds snow mode while that PR adds matrix mode.
  • git-fire/git-rain#10: Both update mode-specific UI plumbing in internal/ui/config_view.go and rain rendering logic in internal/ui/rain_bg.go for different animation modes.
  • git-fire/git-rain#5: Related to rain panel sizing and layout/clamping logic adjustments in repo_selector.go and view_layout.go.

Poem

🐰 A flake drifts down on a midnight screen so deep,
It piles and whispers secrets where snowmen sleep.
Trees frost and cabins glow, pixels soft and bright,
The rabbit hops through panels—compact, tall, comfortable—goodnight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: snow winter scene addition, rain panel size feature, and associated fixes/improvements to the rain animation system.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ui-snow-rain-mode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Fixes golangci-lint staticcheck SA1019 in CI (Go 1.20+).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
internal/config/config_test.go (1)

30-37: Consider making this test table-driven.

NormalizeRainPanelSize trims whitespace, lowercases, and falls back to comfortable for unknown/empty input — none of which this test exercises. As per coding guidelines ("Prefer table-driven tests for multi-case functions"), extending to a table-driven shape would also improve error messages.

♻️ Suggested refactor
 func TestNormalizeRainPanelSize(t *testing.T) {
-	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
-		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
-	}
-	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
-		t.Errorf("got %q", got)
-	}
+	tests := []struct {
+		in   string
+		want string
+	}{
+		{" TALL ", config.UIRainPanelTall},
+		{"compact", config.UIRainPanelCompact},
+		{"Comfortable", config.UIRainPanelComfortable},
+		{"", config.UIRainPanelComfortable},
+		{"   ", config.UIRainPanelComfortable},
+		{"bogus", config.UIRainPanelComfortable},
+	}
+	for _, tc := range tests {
+		if got := config.NormalizeRainPanelSize(tc.in); got != tc.want {
+			t.Errorf("NormalizeRainPanelSize(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/config_test.go` around lines 30 - 37, Convert
TestNormalizeRainPanelSize into a table-driven test that iterates over cases for
inputs and expected outputs; include cases exercising trimming (" TALL "),
different casing ("compact"), lowercase, empty string, unknown value to assert
fallback to config.UIRainPanelComfortable, and any other variants; update test
to loop over a slice of structs with fields name/input/expected and use
t.Run(name, func(t *testing.T){ if got := config.NormalizeRainPanelSize(input);
got != expected { t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input,
got, expected) } }); keep references to NormalizeRainPanelSize, UIRainPanelTall,
UIRainPanelCompact, and UIRainPanelComfortable to locate the code.
internal/ui/repo_selector.go (2)

290-294: Stream init skips snow tuning.

Unlike the static ctor (line 213) which calls base.applyGardenTuning(base.rainBg), the stream path only wires garden tuning via SetGardenTuning and never applies SetSnowAccumPerLanding. If RainAnimationMode == "snow", snowAccumPerLanding remains 0 until the first tea.WindowSizeMsg (line 348) runs applyGardenTuning. In practice the first WindowSizeMsg arrives early so the runtime guard at rain_bg.go:788 (if add < 1 { add = 1 }) masks this, but the two initializers drifting apart will bite the next time snow-only tuning is added.

♻️ Proposed alignment
 	rainH := base.clampedRainBackgroundHeight()
 	rainBg := NewRainBackground(bgW, rainH, animMode)
-	rainBg.SetGardenTuning(gardenTuningFromConfig(cfg, rainTickMS, bgW))
 	base.rainBg = rainBg
+	base.applyGardenTuning(base.rainBg)
 	return base
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/repo_selector.go` around lines 290 - 294, The stream
initialization creates rainBg with NewRainBackground and calls SetGardenTuning
but never applies snow-specific tuning, leaving snowAccumPerLanding at 0 until
tea.WindowSizeMsg triggers applyGardenTuning; fix this by invoking the same
application path used in the static ctor—after base.rainBg = rainBg call, call
base.applyGardenTuning(base.rainBg) (or explicitly call
rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when
cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see
symbols: NewRainBackground, SetGardenTuning, applyGardenTuning,
SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).

747-773: Clamp loop allocates a fresh RainBackground per probe.

clampedRainBackgroundHeight linearly steps from preset down to 3, and each rainPanelFitsHeight call allocates a new RainBackground (plus Flowers/CloudRow/garden state) and runs two binary-searched lipgloss.Height measurements of the rendered panel. This runs on every tea.WindowSizeMsg, and Update's WindowSizeMsg handler itself reconstructs m.rainBg right after. Not a correctness issue, but worth reusing a single probe RainBackground (e.g. mutate its Height/re-Reset) or short-circuiting once the first fitting height is found via binary search rather than linear scan when responsiveness matters on large terminals.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/config_view.go`:
- Around line 199-217: The switch in snowAccumLabel incorrectly maps unspecified
values (5 and 7) to "1×"; instead compute n :=
config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) and return a dynamic
label using that value (e.g. format "%d×") so any integer 1–8 is shown
correctly; update snowAccumLabel to handle nil cfg as before, then use
fmt.Sprintf with the computed n to produce the label.

In `@internal/ui/rain_bg_test.go`:
- Around line 298-351: Tests call the deprecated global rand.Seed which triggers
staticcheck SA1019; make RainBackground (and its snow scene) accept an injected
*rand.Rand (add RainBackground.SetRand(r *rand.Rand) and use that RNG in
spawnDrop/spawnDropGarden/snowNoteFlakeLand/snowAdvanceScene instead of
package-level rand) so tests can do rb.SetRand(rand.New(rand.NewSource(42))) for
deterministic behavior; if that refactor is out of scope for this PR, suppress
the linter in the test by replacing the rand.Seed calls with a
`//nolint:staticcheck` comment on the same line to unblock CI.

In `@internal/ui/snow_scene.go`:
- Around line 61-68: The current Snowman placement logic sets rb.SnowmanX to
rb.Width-5 (or falls back left of the cabin) which lets the snowman hat/scarf
overlap the cabin for mid-range widths; change the computation of rb.SnowmanX to
ensure a 3-column buffer to the right of the cabin by taking rb.SnowmanX =
max(rb.SnowCabinLeft + snowCabinW + 3, rb.Width - 5) and then clamp that value
into the valid horizontal bounds (at least 2 and at most rb.Width-3) before
using it; update the branch that sets rb.SnowmanX (and any fallback logic) to
use these max/clamp calculations and reference rb.SnowmanX, rb.SnowCabinLeft,
snowCabinW and rb.Width when making the change.
- Around line 289-297: snowPaintLine misuses the range loop index (byte offset)
causing multi-byte runes to be placed at wrong columns; fix by iterating runes
and maintaining a separate column counter (e.g., col) instead of using the range
index i, so compute x := left + col and call rb.snowPaintCell(cells, x, y,
string(r), st) while incrementing col for each rune; update the loop in
snowPaintLine to use for _, r := range line with a column counter (or iterate
over []rune(line)) so multi-byte characters like '█' and '≋' map to consecutive
terminal cells.

---

Nitpick comments:
In `@internal/config/config_test.go`:
- Around line 30-37: Convert TestNormalizeRainPanelSize into a table-driven test
that iterates over cases for inputs and expected outputs; include cases
exercising trimming (" TALL "), different casing ("compact"), lowercase, empty
string, unknown value to assert fallback to config.UIRainPanelComfortable, and
any other variants; update test to loop over a slice of structs with fields
name/input/expected and use t.Run(name, func(t *testing.T){ if got :=
config.NormalizeRainPanelSize(input); got != expected {
t.Fatalf("NormalizeRainPanelSize(%q)= %q, want %q", input, got, expected) } });
keep references to NormalizeRainPanelSize, UIRainPanelTall, UIRainPanelCompact,
and UIRainPanelComfortable to locate the code.

In `@internal/ui/repo_selector.go`:
- Around line 290-294: The stream initialization creates rainBg with
NewRainBackground and calls SetGardenTuning but never applies snow-specific
tuning, leaving snowAccumPerLanding at 0 until tea.WindowSizeMsg triggers
applyGardenTuning; fix this by invoking the same application path used in the
static ctor—after base.rainBg = rainBg call, call
base.applyGardenTuning(base.rainBg) (or explicitly call
rainBg.SetSnowAccumPerLanding(...) using the gardenTuningFromConfig result when
cfg.RainAnimationMode == "snow") so snow tuning is applied immediately (see
symbols: NewRainBackground, SetGardenTuning, applyGardenTuning,
SetSnowAccumPerLanding, rain_bg.go:788, tea.WindowSizeMsg).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 856334c3-7e2f-40ba-b8a0-a7500388a225

📥 Commits

Reviewing files that changed from the base of the PR and between 692d726 and f99cdf5.

📒 Files selected for processing (13)
  • README.md
  • internal/config/config_test.go
  • internal/config/defaults.go
  • internal/config/loader.go
  • internal/config/snow_accum_test.go
  • internal/config/types.go
  • internal/ui/config_view.go
  • internal/ui/rain_bg.go
  • internal/ui/rain_bg_test.go
  • internal/ui/repo_selector.go
  • internal/ui/snow_scene.go
  • internal/ui/view_layout.go
  • internal/ui/view_layout_test.go

Comment thread internal/ui/config_view.go
Comment thread internal/ui/rain_bg_test.go
Comment thread internal/ui/snow_scene.go
Comment thread internal/ui/snow_scene.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Multi-byte runes cause wrong cell positions in snowPaintLine
    • snowPaintLine now advances a rune column counter instead of using the range byte index for x, so multi-byte glyphs paint in consecutive cells.
Preview (ad6153eefb)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
 halve growth speed, or `0.5` to roughly double it. The other knobs trade
 visual density (more or fewer seeds, longer or shorter blooms) for clarity.
 
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
 ### Config file, locks, and crashes
 
 **Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running.

diff --git a/internal/config/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
 	"github.com/git-rain/git-rain/internal/config"
 )
 
+func TestRainPanelRowsPresets(t *testing.T) {
+	tests := []struct {
+		in   string
+		want int
+	}{
+		{config.UIRainPanelCompact, 5},
+		{config.UIRainPanelComfortable, 8},
+		{config.UIRainPanelTall, 11},
+		{"", 8},
+		{"unknown-preset", 8},
+	}
+	for _, tc := range tests {
+		if got := config.RainPanelRows(tc.in); got != tc.want {
+			t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+	}
+	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+		t.Errorf("got %q", got)
+	}
+}
+
 func TestDefaultConfig_Values(t *testing.T) {
 	cfg := config.DefaultConfig()
 
+	if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+		t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+	}
 	if cfg.Global.BranchMode != "mainline" {
 		t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
 	}

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
 		UI: UIConfig{
 			ShowRainAnimation:       true,
 			RainAnimationMode:       UIRainAnimationBasic,
+			RainPanelSize:           UIRainPanelComfortable,
 			ShowStartupQuote:        true,
 			StartupQuoteBehavior:    UIQuoteBehaviorRefresh,
 			StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
 			RainTickMS:              DefaultUIRainTickMS,
 			ColorProfile:            UIColorProfileStorm,
+			// SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+			SnowAccumulationRate: 0,
 		},
 	}
 }
@@ -114,10 +117,14 @@
 show_rain_animation = true
 
 # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
 rain_animation_mode = "basic"
 
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
 # Show flavor quotes in the TUI banner
 show_startup_quote = true
 
@@ -133,6 +140,10 @@
 # Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
 color_profile = "storm"
 
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
 # --- Garden mode tuning (advanced) -----------------------------------------
 # These keys only affect rain_animation_mode = "garden". Leave them unset
 # (or at 0) to use the built-in defaults; tweak to make growth slower or

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
 		return nil, fmt.Errorf("invalid config: %w", err)
 	}
 
+	cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
 	return &cfg, nil
 }
 
@@ -92,6 +94,7 @@
 
 	v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
 	v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+	v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
 	v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
 	v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
 	v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
 	v.SetDefault("ui.garden_offspring_min", defaults.UI.GardenOffspringMin)
 	v.SetDefault("ui.garden_offspring_max", defaults.UI.GardenOffspringMax)
 	v.SetDefault("ui.garden_offspring_spread", defaults.UI.GardenOffspringSpread)
+	v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
 }
 
 // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on

diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+	tests := []struct {
+		rate float64
+		want int
+	}{
+		{0, 1},
+		{-1, 1},
+		{1, 1},
+		{1.4, 1},
+		{1.5, 2},
+		{3, 3},
+		{8, 8},
+		{8.4, 8},
+		{99, 8},
+	}
+	for _, tt := range tests {
+		if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+			t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+		}
+	}
+}

diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
 // Package config defines the git-rain configuration schema and related constants.
 package config
 
+import (
+	"math"
+	"strings"
+)
+
 // Config represents the complete git-rain configuration
 type Config struct {
 	Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
 	ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
 
 	// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-	// "matrix" (falling code glyphs in the same column pattern), or "garden"
-	// (seeds, rain, growth, then sun).
+	// "matrix" (falling code glyphs in the same column pattern), "garden"
+	// (seeds, rain, growth, then sun), or "snow" (winter scene).
 	RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
 
+	// RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+	// "comfortable", or "tall". The runtime clamps to the terminal so the panel
+	// still fits (see RainPanelRows).
+	RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
 	// Show flavor quotes: TUI banner plus CLI motivation lines.
 	ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
 
@@ -111,6 +121,11 @@
 	// GardenOffspringSpread is the half-width X jitter applied around the
 	// parent column when scattering offspring seeds. 0 = default.
 	GardenOffspringSpread int `mapstructure:"garden_offspring_spread" toml:"garden_offspring_spread,omitempty"`
+
+	// SnowAccumulationRate scales how much ground snow depth each landed flake
+	// adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+	// Values are rounded to a whole number of depth units per landing (1..8).
+	SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
 }
 
 const (
@@ -127,8 +142,38 @@
 	UIRainAnimationAdvanced = "advanced"
 	UIRainAnimationMatrix   = "matrix"
 	UIRainAnimationGarden   = "garden"
+	UIRainAnimationSnow     = "snow"
+
+	UIRainPanelCompact     = "compact"
+	UIRainPanelComfortable = "comfortable"
+	UIRainPanelTall        = "tall"
 )
 
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return 5
+	case UIRainPanelTall:
+		return 11
+	default:
+		return 8
+	}
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return UIRainPanelCompact
+	case UIRainPanelTall:
+		return UIRainPanelTall
+	default:
+		return UIRainPanelComfortable
+	}
+}
+
 // UIColorProfiles returns valid built-in UI color profile names.
 func UIColorProfiles() []string {
 	return []string{
@@ -139,3 +184,19 @@
 		UIColorProfileSynthwave,
 	}
 }
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+	if rate <= 0 {
+		return 1
+	}
+	n := int(math.Round(rate))
+	if n < 1 {
+		return 1
+	}
+	if n > 8 {
+		return 8
+	}
+	return n
+}

diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -42,7 +42,13 @@
 		config.UIRainAnimationAdvanced,
 		config.UIRainAnimationMatrix,
 		config.UIRainAnimationGarden,
+		config.UIRainAnimationSnow,
 	}},
+	{label: "Rain panel size", kind: configRowEnum, options: []string{
+		config.UIRainPanelCompact,
+		config.UIRainPanelComfortable,
+		config.UIRainPanelTall,
+	}},
 	{label: "Show flavor quotes", kind: configRowBool},
 	{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
 		config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
 	{label: "Custom hex palette", kind: configRowComingSoon},
 }
 
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
 var gardenSettingsConfigRows = []configRow{
 	{label: "Garden growth pace", kind: configRowEnum, options: []string{"calm", "normal", "fast"}},
 	{label: "Garden seed rate", kind: configRowEnum, options: []string{"rare", "normal", "often"}},
 	{label: "Garden offspring", kind: configRowEnum, options: []string{"few", "default", "many"}},
 }
 
-func gardenSettingsRowCount(cfg *config.Config) int {
-	if cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationGarden) {
-		return len(gardenSettingsConfigRows)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+	{label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "6×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+	if cfg == nil {
+		return nil
 	}
-	return 0
+	switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+	case config.UIRainAnimationGarden:
+		return gardenSettingsConfigRows
+	case config.UIRainAnimationSnow:
+		return snowSettingsConfigRows
+	default:
+		return nil
+	}
 }
 
+func modeExtensionRowCount(cfg *config.Config) int {
+	r := modeExtensionRows(cfg)
+	return len(r)
+}
+
 func visibleConfigRowCount(cfg *config.Config) int {
-	return len(configRows) + gardenSettingsRowCount(cfg)
+	return len(configRows) + modeExtensionRowCount(cfg)
 }
 
 // logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
 func logicalRowIndex(visibleI int, cfg *config.Config) int {
-	g := gardenSettingsRowCount(cfg)
+	g := modeExtensionRowCount(cfg)
 	if g == 0 {
 		return visibleI
 	}
-	if visibleI < 5 {
+	if visibleI < firstModeExtensionVisibleRow {
 		return visibleI
 	}
-	if visibleI < 5+g {
-		return len(configRows) + (visibleI - 5)
+	if visibleI < firstModeExtensionVisibleRow+g {
+		return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
 	}
 	return visibleI - g
 }
@@ -98,9 +124,10 @@
 	if li < len(configRows) {
 		return configRows[li]
 	}
+	ext := modeExtensionRows(cfg)
 	gi := li - len(configRows)
-	if gi >= 0 && gi < len(gardenSettingsConfigRows) {
-		return gardenSettingsConfigRows[gi]
+	if ext != nil && gi >= 0 && gi < len(ext) {
+		return ext[gi]
 	}
 	return configRows[len(configRows)-1]
 }
@@ -169,6 +196,26 @@
 	return "default"
 }
 
+func snowAccumLabel(cfg *config.Config) string {
+	if cfg == nil {
+		return "1×"
+	}
+	switch config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate) {
+	case 2:
+		return "2×"
+	case 3:
+		return "3×"
+	case 4:
+		return "4×"
+	case 6:
+		return "6×"
+	case 8:
+		return "8×"
+	default:
+		return "1×"
+	}
+}
+
 func configRowValue(visibleI int, cfg *config.Config) string {
 	if cfg == nil {
 		return ""
@@ -194,28 +241,33 @@
 		}
 		return cfg.UI.RainAnimationMode
 	case 5:
+		return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+	case 6:
 		if cfg.UI.ShowStartupQuote {
 			return "true"
 		}
 		return "false"
-	case 6:
+	case 7:
 		return cfg.UI.StartupQuoteBehavior
-	case 7:
+	case 8:
 		return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
-	case 8:
+	case 9:
 		if cfg.UI.RainTickMS <= 0 {
 			return strconv.Itoa(config.DefaultUIRainTickMS)
 		}
 		return strconv.Itoa(cfg.UI.RainTickMS)
-	case 9:
+	case 10:
 		return cfg.UI.ColorProfile
-	case 10:
+	case 11:
 		return "coming soon"
-	case 11:
+	case 12:
+		if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+			return snowAccumLabel(cfg)
+		}
 		return gardenGrowthPaceLabel(cfg)
-	case 12:
+	case 13:
 		return gardenSeedRateLabel(cfg)
-	case 13:
+	case 14:
 		return gardenOffspringLabel(cfg)
 	}
 	return ""
@@ -234,7 +286,7 @@
 			cfg.Global.DisableScan = !cfg.Global.DisableScan
 		case 3:
 			cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
-		case 5:
+		case 6:
 			cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
 		}
 	case configRowEnum:
@@ -258,27 +310,46 @@
 			}
 		case 4:
 			cfg.UI.RainAnimationMode = opts[idx]
-		case 6:
+		case 5:
+			cfg.UI.RainPanelSize = opts[idx]
+		case 7:
 			cfg.UI.StartupQuoteBehavior = opts[idx]
-		case 7:
+		case 8:
 			sec, err := strconv.Atoi(opts[idx])
 			if err == nil && sec > 0 {
 				cfg.UI.StartupQuoteIntervalSec = sec
 			}
-		case 8:
+		case 9:
 			applyRainTickChange(cfg, opts, dir)
-		case 9:
+		case 10:
 			cfg.UI.ColorProfile = opts[idx]
-		case 11:
-			switch opts[idx] {
-			case "calm":
-				cfg.UI.GardenGrowthPace = 1.32
-			case "fast":
-				cfg.UI.GardenGrowthPace = 0.78
-			default:
-				cfg.UI.GardenGrowthPace = 0
+		case 12:
+			if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+				switch opts[idx] {
+				case "2×":
+					cfg.UI.SnowAccumulationRate = 2
+				case "3×":
+					cfg.UI.SnowAccumulationRate = 3
+				case "4×":
+					cfg.UI.SnowAccumulationRate = 4
+				case "6×":
+					cfg.UI.SnowAccumulationRate = 6
+				case "8×":
+					cfg.UI.SnowAccumulationRate = 8
+				default:
+					cfg.UI.SnowAccumulationRate = 0
+				}
+			} else {
+				switch opts[idx] {
+				case "calm":
+					cfg.UI.GardenGrowthPace = 1.32
+				case "fast":
+					cfg.UI.GardenGrowthPace = 0.78
+				default:
+					cfg.UI.GardenGrowthPace = 0
+				}
 			}
-		case 12:
+		case 13:
 			switch opts[idx] {
 			case "rare":
 				cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +358,7 @@
 			default:
 				cfg.UI.GardenSeedRate = 0
 			}
-		case 13:
+		case 14:
 			switch opts[idx] {
 			case "few":
 				cfg.UI.GardenOffspringMin = 1
@@ -497,9 +568,10 @@
 	m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
 	m.rainAnimationMode = m.cfg.UI.RainAnimationMode
 	if m.rainBg != nil {
-		bgW, h := m.rainBg.Width, m.rainBg.Height
-		if m.rainBg.Mode != m.rainAnimationMode {
-			m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+		bgW := resolveRainBackgroundWidth(m.windowWidth)
+		wantH := m.clampedRainBackgroundHeight()
+		if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+			m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
 		} else {
 			m.rainBg.Mode = m.rainAnimationMode
 		}

diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go
--- a/internal/ui/rain_bg.go
+++ b/internal/ui/rain_bg.go
@@ -364,6 +364,18 @@
 	GardenPlots []gardenPlot
 	GardenSunny bool         // garden mode: rain finished, sky cleared
 	Garden      GardenTuning // pacing knobs (always resolved to non-zero values)
+
+	// Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+	SnowGround            []int
+	SnowCabinLeft         int
+	SnowTrees             []snowTree
+	SnowSmoke             []snowSmoke
+	SnowmanPhase          int
+	SnowmanX              int
+	SnowmanBuild          int
+	SnowmanAux            int // frame counter for accessory delays
+	SnowCabinFrost        int
+	snowAccumPerLanding   int // ground depth per landed flake (1..8)
 }
 
 // NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
 	rb.Garden = ResolveGardenTuning(t)
 }
 
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+	if n < 1 {
+		n = 1
+	}
+	if n > 8 {
+		n = 8
+	}
+	rb.snowAccumPerLanding = n
+}
+
 func (rb *RainBackground) buildCloudRow() []string {
 	row := make([]string, rb.Width)
 	for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
 	if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
 		rb.GardenPlots = make([]gardenPlot, rb.Width)
 	}
+	if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+		rb.initSnowScene()
+	}
 	startY := 0
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
-		startY = 1 // leave top row for clouds / sky
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+		startY = 1 // leave top row for sky / clouds / night
 	}
 	targetCount := rb.Width * 2
 	if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
 			}
 		}
 	}
+	if rb.Mode == config.UIRainAnimationSnow {
+		char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+		speed = 2 + rand.Intn(2)
+	}
 	drop := RainDrop{
 		X:        rand.Intn(rb.Width),
 		Y:        startY,
@@ -663,9 +694,9 @@
 
 	minY := 0
 	maxDropY := rb.Height - 1
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
 		minY = 1
-		maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+		maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
 	}
 
 	if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
 			}
 			p.Age = p.MaxAge
 		}
+
+		if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+			add := rb.snowAccumPerLanding
+			if add < 1 {
+				add = 1
+			}
+			rb.SnowGround[p.X] += add
+			rb.snowNoteFlakeLand(p.X)
+			p.Age = p.MaxAge
+		}
 	}
 
 	// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
 	}
 
 	// Periodically refresh cloud row in advanced / garden (storm) mode
-	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
 		rb.CloudRow = rb.buildCloudRow()
 	}
 
@@ -783,6 +824,10 @@
 		rb.gardenAdvancePlots()
 		rb.gardenMaybeFinishStorm()
 	}
+
+	if rb.Mode == config.UIRainAnimationSnow {
+		rb.snowAdvanceScene()
+	}
 }
 
 // Render returns the rain background as a string
@@ -816,6 +861,8 @@
 	switch rb.Mode {
 	case config.UIRainAnimationGarden:
 		rb.paintGardenOverlays(cells)
+	case config.UIRainAnimationSnow:
+		rb.paintSnowScene(cells)
 	case config.UIRainAnimationAdvanced:
 		// Top row: clouds
 		if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
 		return result.String()
 	}
 
+	if mode == config.UIRainAnimationSnow {
+		night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+		snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+		for x := 0; x < width; x++ {
+			phase := float64(frame)*0.03 + float64(x)*0.11
+			ch := "·"
+			if int(phase*3)%4 == 0 {
+				ch = " "
+			} else if int(phase*5)%7 == 0 {
+				ch = snow.Render("*")
+				result.WriteString(ch)
+				continue
+			}
+			result.WriteString(night.Render(ch))
+		}
+		return result.String()
+	}
+
 	if mode == config.UIRainAnimationMatrix {
 		for x := 0; x < width; x++ {
 			phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
 	if mode == config.UIRainAnimationMatrix {
 		return matrixRainColors
 	}
+	if mode == config.UIRainAnimationSnow {
+		return snowRainColors
+	}
 	if mode == config.UIRainAnimationGarden {
 		return gardenRainColors
 	}
@@ -1367,3 +1435,15 @@
 	lipgloss.Color("#9FA8DA"),
 	lipgloss.Color("#C5CAE9"),
 }
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+	lipgloss.Color("#263238"),
+	lipgloss.Color("#37474F"),
+	lipgloss.Color("#455A64"),
+	lipgloss.Color("#78909C"),
+	lipgloss.Color("#90A4AE"),
+	lipgloss.Color("#B0BEC5"),
+	lipgloss.Color("#CFD8DC"),
+	lipgloss.Color("#ECEFF1"),
+}

diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,63 @@
 		t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
 	}
 }
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+	const width = 40
+	s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+	if got := lipgloss.Width(s); got != width {
+		t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+	}
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+	const w, h = 32, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	for i := 0; i < 50; i++ {
+		rb.Update()
+	}
+	out := rb.Render()
+	lines := strings.Split(out, "\n")
+	if len(lines) != h {
+		t.Fatalf("expected %d lines, got %d", h, len(lines))
+	}
+	for i, line := range lines {
+		if got := lipgloss.Width(line); got != w {
+			t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+		}
+	}
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+	const w, h = 24, 6
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SetSnowAccumPerLanding(4)
+	sum0 := 0
+	for _, v := range rb.SnowGround {
+		sum0 += v
+	}
+	for i := 0; i < 120; i++ {
+		rb.Update()
+	}
+	sum1 := 0
+	for _, v := range rb.SnowGround {
+		sum1 += v
+	}
+	if sum1 <= sum0 {
+		t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+	}
+}
+
+func TestSnowmanProgressesWithLandings(t *testing.T) {
+	const w, h = 40, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SnowmanPhase = snowmanPhaseBaseDot
+	rb.SnowmanBuild = 0
+	for i := 0; i < 30; i++ {
+		rb.snowNoteFlakeLand(rb.SnowmanX)
+	}
+	rb.snowAdvanceScene()
+	if rb.SnowmanPhase == snowmanPhaseBaseDot {
+		t.Fatal("expected snowman phase to advance after landings near anchor")
+	}
+}

diff --git a/internal/ui/repo_selector.go b/internal/ui/repo_selector.go
--- a/internal/ui/repo_selector.go
+++ b/internal/ui/repo_selector.go
@@ -186,13 +186,10 @@
 	s.Style = lipgloss.NewStyle().Foreground(activeProfile().boxBorder)
 
 	animMode := config.UIRainAnimationBasic
-	rainBg := NewRainBackground(resolveRainBackgroundWidth(80), 5, animMode)
... diff truncated: showing 800 of 1559 lines

You can send follow-ups to the cloud agent here.

Comment thread internal/ui/snow_scene.go
cursoragent and others added 2 commits April 19, 2026 19:02
snowPaintLine used the range index as x offset; in Go that index is the
UTF-8 byte position, so multi-byte glyphs (█, ≋, ─) were spaced incorrectly.
Track a rune/column counter for correct cell placement.

Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Replace fixed width slots with rejection sampling: tree count scales with width and height, minimum trunk spacing scales with width, canopy uses a 3-column footprint check vs cabin/snowman. Add tests for spacing and valid sites.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
internal/ui/snow_scene.go (1)

61-68: ⚠️ Potential issue | 🟡 Minor

Snowman placement still overlaps the cabin at mid-range widths.

For Width > 16, SnowmanX is unconditionally set to Width - 5. At Width = 18, SnowCabinLeft = 18/2 - 3 = 6, so the cabin spans cells 6..12 (snowCabinW = 7) and SnowmanX = 13 puts the snowman immediately adjacent to the cabin's right wall. Once the hat brim and scarf render at SnowmanX-2..SnowmanX+2 (lines 461/464), they overlap the cabin. snowFootprintFree is only consulted for trees, so nothing else prevents this collision.

🐛 Proposed fix — enforce a buffer to the right of the cabin and clamp into canvas bounds
-	if rb.Width > 16 {
-		rb.SnowmanX = rb.Width - 5
-	} else {
-		rb.SnowmanX = rb.SnowCabinLeft - 4
-		if rb.SnowmanX < 2 {
-			rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3)
-		}
-	}
+	if rb.Width > 16 {
+		rb.SnowmanX = max(rb.SnowCabinLeft+snowCabinW+3, rb.Width-5)
+		if rb.SnowmanX > rb.Width-3 {
+			rb.SnowmanX = rb.Width - 3
+		}
+	} else {
+		rb.SnowmanX = rb.SnowCabinLeft - 4
+		if rb.SnowmanX < 2 {
+			rb.SnowmanX = min(rb.Width-3, rb.SnowCabinLeft+snowCabinW+3)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/snow_scene.go` around lines 61 - 68, The SnowmanX assignment for
the Width>16 branch can place the snowman so its hat/scarf
(SnowmanX-2..SnowmanX+2) overlap the cabin; modify the Width>16 branch (where
SnowmanX is set to rb.Width-5) to enforce a right-side buffer from the cabin by
computing cabinRight := rb.SnowCabinLeft + snowCabinW - 1 and then set
rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer, rb.Width-2) (pick buffer = 3)
so the snowman is moved at least `buffer` cells to the right of the cabin and
still clamped inside the canvas; keep the existing fallback logic for small
widths intact and do not change snowFootprintFree usage.
🧹 Nitpick comments (1)
internal/ui/snow_scene.go (1)

300-319: Hoist per-frame style construction to package-level vars (lines 344–363).

paintSnowScene allocates ~19 fresh lipgloss.Style values every frame via lipgloss.NewStyle().Foreground(...). These are pure constants — they don't depend on frame state or dimensions. Declaring them once at package scope (e.g. var snowNightStyle = lipgloss.NewStyle().Foreground(...)) removes these allocations from the render hot path and matches how other parts of the codebase cache style values.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/snow_scene.go` around lines 300 - 319, The paintSnowScene
function currently allocates ~19 lipgloss.Style instances each frame (variables
night, star, wood, woodHi, win, ever, everFrost, snowBall, snowHi, snowParen,
coal, nose, smokeSt, hatSt, frost); hoist these constant style constructions to
package-level vars (e.g. define corresponding var nightStyle =
lipgloss.NewStyle().Foreground(...) etc.) so they are created once at init time,
then replace the local declarations in paintSnowScene with references to those
package-level variables to remove per-frame allocations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/snow_scene.go`:
- Around line 460-471: The scarf is being drawn above the face because scarfY is
set to feetY - 3 (above faceY = feetY - 2); change the scarf row to sit below
the face by setting scarfY to faceY + 1 (or feetY - 1) where scarfY is computed,
and keep the existing rb.snowPaintLine call that uses scarfY so the scarf
renders at the neck between head and body instead of above the face (update the
variable assignment near the snowman layout code that defines scarfY).
- Around line 398-412: The loop in snow_scene.go repeatedly calls
lipgloss.NewStyle() per cell; hoist the three depth styles (e.g.,
sLight="#E3F2FD", sMid="#BBDEFB", sDeep="#90CAF9") and the faint variant once
outside the for loop and pick the appropriate precomputed style inside it
instead of allocating new ones each iteration; update the branch that sets st
and the faint usage (st.Faint(true)) to use the precomputed faint-style
variable, keeping rb.SnowGround, snowGroundGlyph and rb.snowPaintCell calls
unchanged.
- Around line 74-101: In snowSpawnInitialTrees the slots slice can contain
duplicate or too-close x positions and snowFootprintFree only checks
cabin/snowman, so dedupe and enforce spacing before appending: compute a unique,
sorted list of candidate x positions from slots, then for each candidate check
snowFootprintFree(x,1) AND ensure no existing rb.SnowTrees (including ones just
appended in this call) have abs(tree.x - x) < MIN_GAP (choose MIN_GAP=2 or 3),
and skip candidates that are outside safe bounds or violate the gap; finally
append only when both checks pass (update references to snowSpawnInitialTrees,
slots, snowFootprintFree, rb.SnowTrees, and the snowTree struct).

---

Duplicate comments:
In `@internal/ui/snow_scene.go`:
- Around line 61-68: The SnowmanX assignment for the Width>16 branch can place
the snowman so its hat/scarf (SnowmanX-2..SnowmanX+2) overlap the cabin; modify
the Width>16 branch (where SnowmanX is set to rb.Width-5) to enforce a
right-side buffer from the cabin by computing cabinRight := rb.SnowCabinLeft +
snowCabinW - 1 and then set rb.SnowmanX = clamp(rb.Width-5, cabinRight+buffer,
rb.Width-2) (pick buffer = 3) so the snowman is moved at least `buffer` cells to
the right of the cabin and still clamped inside the canvas; keep the existing
fallback logic for small widths intact and do not change snowFootprintFree
usage.

---

Nitpick comments:
In `@internal/ui/snow_scene.go`:
- Around line 300-319: The paintSnowScene function currently allocates ~19
lipgloss.Style instances each frame (variables night, star, wood, woodHi, win,
ever, everFrost, snowBall, snowHi, snowParen, coal, nose, smokeSt, hatSt,
frost); hoist these constant style constructions to package-level vars (e.g.
define corresponding var nightStyle = lipgloss.NewStyle().Foreground(...) etc.)
so they are created once at init time, then replace the local declarations in
paintSnowScene with references to those package-level variables to remove
per-frame allocations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ae41f14-58d5-4b83-94e3-926e6022a691

📥 Commits

Reviewing files that changed from the base of the PR and between f99cdf5 and ad6153e.

📒 Files selected for processing (2)
  • internal/ui/rain_bg_test.go
  • internal/ui/snow_scene.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/ui/rain_bg_test.go

Comment thread internal/ui/snow_scene.go
Comment thread internal/ui/snow_scene.go
Comment thread internal/ui/snow_scene.go
Comment thread internal/ui/snow_scene.go Outdated
Comment thread internal/ui/config_view.go
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Snow accumulation rate not applied at stream startup
    • NewRepoSelectorModelStream now assigns the rain background and calls applyGardenTuning so snow accumulation rate is applied on startup, not only after WindowSizeMsg.
  • ✅ Fixed: Trees placed at snowman's future position during init
    • snowFootprintFree now treats the snowman reservation as active during phase none when Width >= 18, matching when the snowman will spawn so initial trees avoid that band.
Preview (639adee692)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -357,6 +357,24 @@
 halve growth speed, or `0.5` to roughly double it. The other knobs trade
 visual density (more or fewer seeds, longer or shorter blooms) for clarity.
 
+### Snow mode and rain panel size
+
+`rain_animation_mode = "snow"` uses the same animation strip for a winter scene:
+falling snowflakes, snow that keeps piling on the ground, a small log cabin
+with chimney smoke and lit windows, occasional evergreen trees that pick up
+frost, and a snowman that grows in stages (two spheres, then face, pipe, and
+top hat).
+
+`rain_panel_size` controls how many terminal rows the animation canvas uses:
+`compact` (5), `comfortable` (8, default), or `tall` (11). The TUI clamps the
+height automatically so the bordered panel still fits short terminals.
+
+```toml
+[ui]
+rain_animation_mode = "snow"
+rain_panel_size = "comfortable"
+```
+
 ### Config file, locks, and crashes
 
 **Registry (`repos.toml`)** — Writes use a cross-process lock file (`repos.toml.lock`), atomic replace, and stale-lock detection (owner PID). If a process dies mid-run you may still see a leftover lock: the CLI prompts to remove it when safe, or you can use **`--force-unlock-registry`** in scripts. This is the same class of “stale lock / don’t corrupt the database” problem as other multi-repo tools; treat lock removal like any other forced unlock — only when you are sure no other `git-rain` is running.

diff --git a/internal/config/config_test.go b/internal/config/config_test.go
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -9,9 +9,39 @@
 	"github.com/git-rain/git-rain/internal/config"
 )
 
+func TestRainPanelRowsPresets(t *testing.T) {
+	tests := []struct {
+		in   string
+		want int
+	}{
+		{config.UIRainPanelCompact, 5},
+		{config.UIRainPanelComfortable, 8},
+		{config.UIRainPanelTall, 11},
+		{"", 8},
+		{"unknown-preset", 8},
+	}
+	for _, tc := range tests {
+		if got := config.RainPanelRows(tc.in); got != tc.want {
+			t.Errorf("RainPanelRows(%q) = %d, want %d", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestNormalizeRainPanelSize(t *testing.T) {
+	if got := config.NormalizeRainPanelSize(" TALL "); got != config.UIRainPanelTall {
+		t.Errorf("NormalizeRainPanelSize = %q, want %q", got, config.UIRainPanelTall)
+	}
+	if got := config.NormalizeRainPanelSize("compact"); got != config.UIRainPanelCompact {
+		t.Errorf("got %q", got)
+	}
+}
+
 func TestDefaultConfig_Values(t *testing.T) {
 	cfg := config.DefaultConfig()
 
+	if cfg.UI.RainPanelSize != config.UIRainPanelComfortable {
+		t.Errorf("default RainPanelSize = %q, want %q", cfg.UI.RainPanelSize, config.UIRainPanelComfortable)
+	}
 	if cfg.Global.BranchMode != "mainline" {
 		t.Errorf("default BranchMode = %q, want %q", cfg.Global.BranchMode, "mainline")
 	}

diff --git a/internal/config/defaults.go b/internal/config/defaults.go
--- a/internal/config/defaults.go
+++ b/internal/config/defaults.go
@@ -37,11 +37,14 @@
 		UI: UIConfig{
 			ShowRainAnimation:       true,
 			RainAnimationMode:       UIRainAnimationBasic,
+			RainPanelSize:           UIRainPanelComfortable,
 			ShowStartupQuote:        true,
 			StartupQuoteBehavior:    UIQuoteBehaviorRefresh,
 			StartupQuoteIntervalSec: DefaultUIStartupQuoteIntervalSec,
 			RainTickMS:              DefaultUIRainTickMS,
 			ColorProfile:            UIColorProfileStorm,
+			// SnowAccumulationRate 0 => runtime uses 1× (see SnowAccumPerLanding).
+			SnowAccumulationRate: 0,
 		},
 	}
 }
@@ -114,10 +117,14 @@
 show_rain_animation = true
 
 # Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-# "matrix" (falling code characters), or "garden" (seeds bloom into a meadow,
-# then the rain stops and the sun comes out)
+# "matrix" (falling code characters), "garden" (seeds bloom into a meadow,
+# then the rain stops and the sun comes out), or "snow" (winter scene)
 rain_animation_mode = "basic"
 
+# Animation canvas height: "compact" (5 rows), "comfortable" (8), or "tall" (11).
+# Clamped automatically if the terminal is short.
+rain_panel_size = "comfortable"
+
 # Show flavor quotes in the TUI banner
 show_startup_quote = true
 
@@ -133,6 +140,10 @@
 # Color profile: "storm", "drizzle", "monsoon", "rainbow", "synthwave"
 color_profile = "storm"
 
+# --- Snow mode (rain_animation_mode = "snow") -------------------------------
+# Ground depth added per landed flake (1–8). 1 = default; 3 ≈ three times faster piling.
+# snow_accumulation_rate = 1
+
 # --- Garden mode tuning (advanced) -----------------------------------------
 # These keys only affect rain_animation_mode = "garden". Leave them unset
 # (or at 0) to use the built-in defaults; tweak to make growth slower or

diff --git a/internal/config/loader.go b/internal/config/loader.go
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -69,6 +69,8 @@
 		return nil, fmt.Errorf("invalid config: %w", err)
 	}
 
+	cfg.UI.RainPanelSize = NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+
 	return &cfg, nil
 }
 
@@ -92,6 +94,7 @@
 
 	v.SetDefault("ui.show_rain_animation", defaults.UI.ShowRainAnimation)
 	v.SetDefault("ui.rain_animation_mode", defaults.UI.RainAnimationMode)
+	v.SetDefault("ui.rain_panel_size", defaults.UI.RainPanelSize)
 	v.SetDefault("ui.show_startup_quote", defaults.UI.ShowStartupQuote)
 	v.SetDefault("ui.startup_quote_behavior", defaults.UI.StartupQuoteBehavior)
 	v.SetDefault("ui.startup_quote_interval_sec", defaults.UI.StartupQuoteIntervalSec)
@@ -108,6 +111,7 @@
 	v.SetDefault("ui.garden_offspring_min", defaults.UI.GardenOffspringMin)
 	v.SetDefault("ui.garden_offspring_max", defaults.UI.GardenOffspringMax)
 	v.SetDefault("ui.garden_offspring_spread", defaults.UI.GardenOffspringSpread)
+	v.SetDefault("ui.snow_accumulation_rate", defaults.UI.SnowAccumulationRate)
 }
 
 // Bounded lock acquisition for config.toml: SaveConfig runs from the TUI on

diff --git a/internal/config/snow_accum_test.go b/internal/config/snow_accum_test.go
new file mode 100644
--- /dev/null
+++ b/internal/config/snow_accum_test.go
@@ -1,0 +1,25 @@
+package config
+
+import "testing"
+
+func TestSnowAccumPerLanding(t *testing.T) {
+	tests := []struct {
+		rate float64
+		want int
+	}{
+		{0, 1},
+		{-1, 1},
+		{1, 1},
+		{1.4, 1},
+		{1.5, 2},
+		{3, 3},
+		{8, 8},
+		{8.4, 8},
+		{99, 8},
+	}
+	for _, tt := range tests {
+		if got := SnowAccumPerLanding(tt.rate); got != tt.want {
+			t.Errorf("SnowAccumPerLanding(%v) = %d, want %d", tt.rate, got, tt.want)
+		}
+	}
+}

diff --git a/internal/config/types.go b/internal/config/types.go
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -1,6 +1,11 @@
 // Package config defines the git-rain configuration schema and related constants.
 package config
 
+import (
+	"math"
+	"strings"
+)
+
 // Config represents the complete git-rain configuration
 type Config struct {
 	Global GlobalConfig `mapstructure:"global" toml:"global"`
@@ -57,10 +62,15 @@
 	ShowRainAnimation bool `mapstructure:"show_rain_animation" toml:"show_rain_animation"`
 
 	// Animation mode: "basic" (rain drops), "advanced" (clouds + rain + flowers),
-	// "matrix" (falling code glyphs in the same column pattern), or "garden"
-	// (seeds, rain, growth, then sun).
+	// "matrix" (falling code glyphs in the same column pattern), "garden"
+	// (seeds, rain, growth, then sun), or "snow" (winter scene).
 	RainAnimationMode string `mapstructure:"rain_animation_mode" toml:"rain_animation_mode"`
 
+	// RainPanelSize is how tall the animation canvas is in the TUI: "compact",
+	// "comfortable", or "tall". The runtime clamps to the terminal so the panel
+	// still fits (see RainPanelRows).
+	RainPanelSize string `mapstructure:"rain_panel_size" toml:"rain_panel_size"`
+
 	// Show flavor quotes: TUI banner plus CLI motivation lines.
 	ShowStartupQuote bool `mapstructure:"show_startup_quote" toml:"show_startup_quote"`
 
@@ -111,6 +121,11 @@
 	// GardenOffspringSpread is the half-width X jitter applied around the
 	// parent column when scattering offspring seeds. 0 = default.
 	GardenOffspringSpread int `mapstructure:"garden_offspring_spread" toml:"garden_offspring_spread,omitempty"`
+
+	// SnowAccumulationRate scales how much ground snow depth each landed flake
+	// adds when rain_animation_mode = "snow". 1 = default; 2 ≈ twice as fast.
+	// Values are rounded to a whole number of depth units per landing (1..8).
+	SnowAccumulationRate float64 `mapstructure:"snow_accumulation_rate" toml:"snow_accumulation_rate,omitempty"`
 }
 
 const (
@@ -127,8 +142,38 @@
 	UIRainAnimationAdvanced = "advanced"
 	UIRainAnimationMatrix   = "matrix"
 	UIRainAnimationGarden   = "garden"
+	UIRainAnimationSnow     = "snow"
+
+	UIRainPanelCompact     = "compact"
+	UIRainPanelComfortable = "comfortable"
+	UIRainPanelTall        = "tall"
 )
 
+// RainPanelRows returns the target animation height in terminal rows for a
+// panel size preset. Unknown or empty values use comfortable.
+func RainPanelRows(preset string) int {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return 5
+	case UIRainPanelTall:
+		return 11
+	default:
+		return 8
+	}
+}
+
+// NormalizeRainPanelSize returns a canonical preset name.
+func NormalizeRainPanelSize(preset string) string {
+	switch strings.ToLower(strings.TrimSpace(preset)) {
+	case UIRainPanelCompact:
+		return UIRainPanelCompact
+	case UIRainPanelTall:
+		return UIRainPanelTall
+	default:
+		return UIRainPanelComfortable
+	}
+}
+
 // UIColorProfiles returns valid built-in UI color profile names.
 func UIColorProfiles() []string {
 	return []string{
@@ -139,3 +184,19 @@
 		UIColorProfileSynthwave,
 	}
 }
+
+// SnowAccumPerLanding returns ground depth units added per landed snowflake.
+// rate is cfg.UI.SnowAccumulationRate; 0 or negative means 1. Result is in [1, 8].
+func SnowAccumPerLanding(rate float64) int {
+	if rate <= 0 {
+		return 1
+	}
+	n := int(math.Round(rate))
+	if n < 1 {
+		return 1
+	}
+	if n > 8 {
+		return 8
+	}
+	return n
+}

diff --git a/internal/ui/config_view.go b/internal/ui/config_view.go
--- a/internal/ui/config_view.go
+++ b/internal/ui/config_view.go
@@ -42,7 +42,13 @@
 		config.UIRainAnimationAdvanced,
 		config.UIRainAnimationMatrix,
 		config.UIRainAnimationGarden,
+		config.UIRainAnimationSnow,
 	}},
+	{label: "Rain panel size", kind: configRowEnum, options: []string{
+		config.UIRainPanelCompact,
+		config.UIRainPanelComfortable,
+		config.UIRainPanelTall,
+	}},
 	{label: "Show flavor quotes", kind: configRowBool},
 	{label: "Flavor quote behavior", kind: configRowEnum, options: []string{
 		config.UIQuoteBehaviorRefresh,
@@ -58,37 +64,57 @@
 	{label: "Custom hex palette", kind: configRowComingSoon},
 }
 
-// Garden settings rows appear in the menu only when rain mode is garden,
-// directly under "Rain animation mode" (see logicalRowIndex).
+// firstModeExtensionVisibleRow is where mode-specific rows are inserted
+// (after "Rain animation mode" and "Rain panel size").
+const firstModeExtensionVisibleRow = 6
+
+// Garden settings rows appear only when rain mode is garden.
 var gardenSettingsConfigRows = []configRow{
 	{label: "Garden growth pace", kind: configRowEnum, options: []string{"calm", "normal", "fast"}},
 	{label: "Garden seed rate", kind: configRowEnum, options: []string{"rare", "normal", "often"}},
 	{label: "Garden offspring", kind: configRowEnum, options: []string{"few", "default", "many"}},
 }
 
-func gardenSettingsRowCount(cfg *config.Config) int {
-	if cfg != nil && strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationGarden) {
-		return len(gardenSettingsConfigRows)
+// Snow settings rows appear only when rain mode is snow.
+var snowSettingsConfigRows = []configRow{
+	{label: "Snow accumulation", kind: configRowEnum, options: []string{"1×", "2×", "3×", "4×", "5×", "6×", "7×", "8×"}},
+}
+
+func modeExtensionRows(cfg *config.Config) []configRow {
+	if cfg == nil {
+		return nil
 	}
-	return 0
+	switch strings.ToLower(strings.TrimSpace(cfg.UI.RainAnimationMode)) {
+	case config.UIRainAnimationGarden:
+		return gardenSettingsConfigRows
+	case config.UIRainAnimationSnow:
+		return snowSettingsConfigRows
+	default:
+		return nil
+	}
 }
 
+func modeExtensionRowCount(cfg *config.Config) int {
+	r := modeExtensionRows(cfg)
+	return len(r)
+}
+
 func visibleConfigRowCount(cfg *config.Config) int {
-	return len(configRows) + gardenSettingsRowCount(cfg)
+	return len(configRows) + modeExtensionRowCount(cfg)
 }
 
 // logicalRowIndex maps a visible settings row to legacy ids 0..len(configRows)-1
-// or len(configRows)+k for garden-only rows.
+// or len(configRows)+k for mode-extension rows (garden or snow).
 func logicalRowIndex(visibleI int, cfg *config.Config) int {
-	g := gardenSettingsRowCount(cfg)
+	g := modeExtensionRowCount(cfg)
 	if g == 0 {
 		return visibleI
 	}
-	if visibleI < 5 {
+	if visibleI < firstModeExtensionVisibleRow {
 		return visibleI
 	}
-	if visibleI < 5+g {
-		return len(configRows) + (visibleI - 5)
+	if visibleI < firstModeExtensionVisibleRow+g {
+		return len(configRows) + (visibleI - firstModeExtensionVisibleRow)
 	}
 	return visibleI - g
 }
@@ -98,9 +124,10 @@
 	if li < len(configRows) {
 		return configRows[li]
 	}
+	ext := modeExtensionRows(cfg)
 	gi := li - len(configRows)
-	if gi >= 0 && gi < len(gardenSettingsConfigRows) {
-		return gardenSettingsConfigRows[gi]
+	if ext != nil && gi >= 0 && gi < len(ext) {
+		return ext[gi]
 	}
 	return configRows[len(configRows)-1]
 }
@@ -169,6 +196,14 @@
 	return "default"
 }
 
+func snowAccumLabel(cfg *config.Config) string {
+	if cfg == nil {
+		return "1×"
+	}
+	n := config.SnowAccumPerLanding(cfg.UI.SnowAccumulationRate)
+	return fmt.Sprintf("%d×", n)
+}
+
 func configRowValue(visibleI int, cfg *config.Config) string {
 	if cfg == nil {
 		return ""
@@ -194,28 +229,33 @@
 		}
 		return cfg.UI.RainAnimationMode
 	case 5:
+		return config.NormalizeRainPanelSize(cfg.UI.RainPanelSize)
+	case 6:
 		if cfg.UI.ShowStartupQuote {
 			return "true"
 		}
 		return "false"
-	case 6:
+	case 7:
 		return cfg.UI.StartupQuoteBehavior
-	case 7:
+	case 8:
 		return strconv.Itoa(cfg.UI.StartupQuoteIntervalSec)
-	case 8:
+	case 9:
 		if cfg.UI.RainTickMS <= 0 {
 			return strconv.Itoa(config.DefaultUIRainTickMS)
 		}
 		return strconv.Itoa(cfg.UI.RainTickMS)
-	case 9:
+	case 10:
 		return cfg.UI.ColorProfile
-	case 10:
+	case 11:
 		return "coming soon"
-	case 11:
+	case 12:
+		if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+			return snowAccumLabel(cfg)
+		}
 		return gardenGrowthPaceLabel(cfg)
-	case 12:
+	case 13:
 		return gardenSeedRateLabel(cfg)
-	case 13:
+	case 14:
 		return gardenOffspringLabel(cfg)
 	}
 	return ""
@@ -234,7 +274,7 @@
 			cfg.Global.DisableScan = !cfg.Global.DisableScan
 		case 3:
 			cfg.UI.ShowRainAnimation = !cfg.UI.ShowRainAnimation
-		case 5:
+		case 6:
 			cfg.UI.ShowStartupQuote = !cfg.UI.ShowStartupQuote
 		}
 	case configRowEnum:
@@ -258,27 +298,50 @@
 			}
 		case 4:
 			cfg.UI.RainAnimationMode = opts[idx]
-		case 6:
+		case 5:
+			cfg.UI.RainPanelSize = opts[idx]
+		case 7:
 			cfg.UI.StartupQuoteBehavior = opts[idx]
-		case 7:
+		case 8:
 			sec, err := strconv.Atoi(opts[idx])
 			if err == nil && sec > 0 {
 				cfg.UI.StartupQuoteIntervalSec = sec
 			}
-		case 8:
+		case 9:
 			applyRainTickChange(cfg, opts, dir)
-		case 9:
+		case 10:
 			cfg.UI.ColorProfile = opts[idx]
-		case 11:
-			switch opts[idx] {
-			case "calm":
-				cfg.UI.GardenGrowthPace = 1.32
-			case "fast":
-				cfg.UI.GardenGrowthPace = 0.78
-			default:
-				cfg.UI.GardenGrowthPace = 0
+		case 12:
+			if strings.EqualFold(strings.TrimSpace(cfg.UI.RainAnimationMode), config.UIRainAnimationSnow) {
+				switch opts[idx] {
+				case "2×":
+					cfg.UI.SnowAccumulationRate = 2
+				case "3×":
+					cfg.UI.SnowAccumulationRate = 3
+				case "4×":
+					cfg.UI.SnowAccumulationRate = 4
+				case "5×":
+					cfg.UI.SnowAccumulationRate = 5
+				case "6×":
+					cfg.UI.SnowAccumulationRate = 6
+				case "7×":
+					cfg.UI.SnowAccumulationRate = 7
+				case "8×":
+					cfg.UI.SnowAccumulationRate = 8
+				default:
+					cfg.UI.SnowAccumulationRate = 0
+				}
+			} else {
+				switch opts[idx] {
+				case "calm":
+					cfg.UI.GardenGrowthPace = 1.32
+				case "fast":
+					cfg.UI.GardenGrowthPace = 0.78
+				default:
+					cfg.UI.GardenGrowthPace = 0
+				}
 			}
-		case 12:
+		case 13:
 			switch opts[idx] {
 			case "rare":
 				cfg.UI.GardenSeedRate = 0.06
@@ -287,7 +350,7 @@
 			default:
 				cfg.UI.GardenSeedRate = 0
 			}
-		case 13:
+		case 14:
 			switch opts[idx] {
 			case "few":
 				cfg.UI.GardenOffspringMin = 1
@@ -497,9 +560,10 @@
 	m.rainTick = time.Duration(m.cfg.UI.RainTickMS) * time.Millisecond
 	m.rainAnimationMode = m.cfg.UI.RainAnimationMode
 	if m.rainBg != nil {
-		bgW, h := m.rainBg.Width, m.rainBg.Height
-		if m.rainBg.Mode != m.rainAnimationMode {
-			m.rainBg = NewRainBackground(bgW, h, m.rainAnimationMode)
+		bgW := resolveRainBackgroundWidth(m.windowWidth)
+		wantH := m.clampedRainBackgroundHeight()
+		if m.rainBg.Mode != m.rainAnimationMode || m.rainBg.Width != bgW || m.rainBg.Height != wantH {
+			m.rainBg = NewRainBackground(bgW, wantH, m.rainAnimationMode)
 		} else {
 			m.rainBg.Mode = m.rainAnimationMode
 		}

diff --git a/internal/ui/rain_bg.go b/internal/ui/rain_bg.go
--- a/internal/ui/rain_bg.go
+++ b/internal/ui/rain_bg.go
@@ -364,6 +364,18 @@
 	GardenPlots []gardenPlot
 	GardenSunny bool         // garden mode: rain finished, sky cleared
 	Garden      GardenTuning // pacing knobs (always resolved to non-zero values)
+
+	// Snow mode (winter scene); used only when Mode == UIRainAnimationSnow.
+	SnowGround            []int
+	SnowCabinLeft         int
+	SnowTrees             []snowTree
+	SnowSmoke             []snowSmoke
+	SnowmanPhase          int
+	SnowmanX              int
+	SnowmanBuild          int
+	SnowmanAux            int // frame counter for accessory delays
+	SnowCabinFrost        int
+	snowAccumPerLanding   int // ground depth per landed flake (1..8)
 }
 
 // NewRainBackground creates a new rain background
@@ -393,6 +405,18 @@
 	rb.Garden = ResolveGardenTuning(t)
 }
 
+// SetSnowAccumPerLanding sets how many ground depth units each snowflake adds
+// when it lands (snow mode only). Values are clamped to [1, 8].
+func (rb *RainBackground) SetSnowAccumPerLanding(n int) {
+	if n < 1 {
+		n = 1
+	}
+	if n > 8 {
+		n = 8
+	}
+	rb.snowAccumPerLanding = n
+}
+
 func (rb *RainBackground) buildCloudRow() []string {
 	row := make([]string, rb.Width)
 	for x := 0; x < rb.Width; x++ {
@@ -412,9 +436,12 @@
 	if rb.Mode == config.UIRainAnimationGarden && rb.Width > 0 {
 		rb.GardenPlots = make([]gardenPlot, rb.Width)
 	}
+	if rb.Mode == config.UIRainAnimationSnow && rb.Width > 0 {
+		rb.initSnowScene()
+	}
 	startY := 0
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
-		startY = 1 // leave top row for clouds / sky
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
+		startY = 1 // leave top row for sky / clouds / night
 	}
 	targetCount := rb.Width * 2
 	if rb.Mode == config.UIRainAnimationGarden {
@@ -447,6 +474,10 @@
 			}
 		}
 	}
+	if rb.Mode == config.UIRainAnimationSnow {
+		char = snowflakeChars[rand.Intn(len(snowflakeChars))]
+		speed = 2 + rand.Intn(2)
+	}
 	drop := RainDrop{
 		X:        rand.Intn(rb.Width),
 		Y:        startY,
@@ -663,9 +694,9 @@
 
 	minY := 0
 	maxDropY := rb.Height - 1
-	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden {
+	if rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow {
 		minY = 1
-		maxDropY = rb.Height - 2 // leave bottom row for plants / flowers
+		maxDropY = rb.Height - 2 // leave bottom row for ground / plants / snow pile
 	}
 
 	if rb.Mode == config.UIRainAnimationGarden && rb.GardenSunny {
@@ -751,6 +782,16 @@
 			}
 			p.Age = p.MaxAge
 		}
+
+		if rb.Mode == config.UIRainAnimationSnow && p.Y >= maxDropY && p.Y < rb.Height && rb.SnowGround != nil && p.X >= 0 && p.X < len(rb.SnowGround) {
+			add := rb.snowAccumPerLanding
+			if add < 1 {
+				add = 1
+			}
+			rb.SnowGround[p.X] += add
+			rb.snowNoteFlakeLand(p.X)
+			p.Age = p.MaxAge
+		}
 	}
 
 	// Remove dead drops (off screen or expired)
@@ -775,7 +816,7 @@
 	}
 
 	// Periodically refresh cloud row in advanced / garden (storm) mode
-	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden) && rb.Frame%30 == 0 && rb.Width > 0 {
+	if (rb.Mode == config.UIRainAnimationAdvanced || rb.Mode == config.UIRainAnimationGarden || rb.Mode == config.UIRainAnimationSnow) && rb.Frame%30 == 0 && rb.Width > 0 {
 		rb.CloudRow = rb.buildCloudRow()
 	}
 
@@ -783,6 +824,10 @@
 		rb.gardenAdvancePlots()
 		rb.gardenMaybeFinishStorm()
 	}
+
+	if rb.Mode == config.UIRainAnimationSnow {
+		rb.snowAdvanceScene()
+	}
 }
 
 // Render returns the rain background as a string
@@ -816,6 +861,8 @@
 	switch rb.Mode {
 	case config.UIRainAnimationGarden:
 		rb.paintGardenOverlays(cells)
+	case config.UIRainAnimationSnow:
+		rb.paintSnowScene(cells)
 	case config.UIRainAnimationAdvanced:
 		// Top row: clouds
 		if len(rb.CloudRow) >= rb.Width {
@@ -1264,6 +1311,24 @@
 		return result.String()
 	}
 
+	if mode == config.UIRainAnimationSnow {
+		night := lipgloss.NewStyle().Foreground(lipgloss.Color("#37474F"))
+		snow := lipgloss.NewStyle().Foreground(lipgloss.Color("#ECEFF1"))
+		for x := 0; x < width; x++ {
+			phase := float64(frame)*0.03 + float64(x)*0.11
+			ch := "·"
+			if int(phase*3)%4 == 0 {
+				ch = " "
+			} else if int(phase*5)%7 == 0 {
+				ch = snow.Render("*")
+				result.WriteString(ch)
+				continue
+			}
+			result.WriteString(night.Render(ch))
+		}
+		return result.String()
+	}
+
 	if mode == config.UIRainAnimationMatrix {
 		for x := 0; x < width; x++ {
 			phase := float64(frame)*0.075 + float64(x)*0.24
@@ -1321,6 +1386,9 @@
 	if mode == config.UIRainAnimationMatrix {
 		return matrixRainColors
 	}
+	if mode == config.UIRainAnimationSnow {
+		return snowRainColors
+	}
 	if mode == config.UIRainAnimationGarden {
 		return gardenRainColors
 	}
@@ -1367,3 +1435,15 @@
 	lipgloss.Color("#9FA8DA"),
 	lipgloss.Color("#C5CAE9"),
 }
+
+// snowRainColors is a cool snowflake palette (high → light).
+var snowRainColors = []lipgloss.Color{
+	lipgloss.Color("#263238"),
+	lipgloss.Color("#37474F"),
+	lipgloss.Color("#455A64"),
+	lipgloss.Color("#78909C"),
+	lipgloss.Color("#90A4AE"),
+	lipgloss.Color("#B0BEC5"),
+	lipgloss.Color("#CFD8DC"),
+	lipgloss.Color("#ECEFF1"),
+}

diff --git a/internal/ui/rain_bg_test.go b/internal/ui/rain_bg_test.go
--- a/internal/ui/rain_bg_test.go
+++ b/internal/ui/rain_bg_test.go
@@ -285,3 +285,100 @@
 		t.Fatalf("expected drops cleared, got %d", len(rb.Drops))
 	}
 }
+
+func TestRenderRainWaveSnowWidth(t *testing.T) {
+	const width = 40
+	s := RenderRainWave(width, 3, config.UIRainAnimationSnow, false)
+	if got := lipgloss.Width(s); got != width {
+		t.Fatalf("lipgloss.Width(RenderRainWave snow) = %d, want %d", got, width)
+	}
+}
+
+func TestRainBackgroundSnowRenderLineWidths(t *testing.T) {
+	const w, h = 32, 8
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	for i := 0; i < 50; i++ {
+		rb.Update()
+	}
+	out := rb.Render()
+	lines := strings.Split(out, "\n")
+	if len(lines) != h {
+		t.Fatalf("expected %d lines, got %d", h, len(lines))
+	}
+	for i, line := range lines {
+		if got := lipgloss.Width(line); got != w {
+			t.Fatalf("line %d: lipgloss.Width = %d, want %d\n%q", i, got, w, line)
+		}
+	}
+}
+
+func TestSnowGroundDepthIncreases(t *testing.T) {
+	const w, h = 24, 6
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	rb.SetSnowAccumPerLanding(4)
+	sum0 := 0
+	for _, v := range rb.SnowGround {
+		sum0 += v
+	}
+	for i := 0; i < 120; i++ {
+		rb.Update()
+	}
+	sum1 := 0
+	for _, v := range rb.SnowGround {
+		sum1 += v
+	}
+	if sum1 <= sum0 {
+		t.Fatalf("expected snow ground accumulation, sum0=%d sum1=%d", sum0, sum1)
+	}
+}
+
+func TestSnowTreesPlacedWithSpacingAndScale(t *testing.T) {
+	const w, h = 48, 10
+	rb := NewRainBackground(w, h, config.UIRainAnimationSnow)
+	if len(rb.SnowTrees) < 2 {
+		t.Fatalf("expected at least 2 snow trees for %dx%d, got %d", w, h, len(rb.SnowTrees))
+	}
+	minGap := w / 12
+	if minGap < 4 {
+		minGap = 4
+	}
+	if minGap > 10 {
+		minGap = 10
+	}
+	for i := range rb.SnowTrees {
+		tr := rb.SnowTrees[i]
+		if tr.x < 1 || tr.x >= w-1 {
+			t.Fatalf("tree trunk x out of bounds: %d", tr.x)
+		}
+		if !rb.snowTreeSiteFree(tr.x) {
+			t.Fatalf("tree at x=%d overlaps cabin/snowman or clips canopy", tr.x)
+		}
+		for j := i + 1; j < len(rb.SnowTrees); j++ {
+			if d := absInt(tr.x - rb.SnowTrees[j].x); d < minGap {
+				t.Fatalf("trees closer than minGap %d: x=%d and x=%d", minGap, tr.x, rb.SnowTrees[j].x)
+			}
+		}
+	}
+}
+
+func TestSnowTreesAtLeastOneWhenMarginExists(t *testing.T) {
... diff truncated: showing 800 of 1635 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit f5360af. Configure here.

Comment thread internal/ui/repo_selector.go Outdated
Comment thread internal/ui/snow_scene.go
cursoragent and others added 3 commits April 19, 2026 19:25
Co-authored-by: Ben Schellenberger <bschellenberger2600@users.noreply.github.com>
Keep snowman clear of the cabin at mid-range widths, hoist ground-snow
lipgloss styles out of the per-cell loop, and move the scarf to the neck
row below the face.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot completed successfully on the latest push, but no Bugbot review comment is present for HEAD (b21ba56). Per policy I am not approving until that signal is available. No reviewers were assigned (only the PR author is in the org).

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

@bschellenberger2600
bschellenberger2600 merged commit 7b743f7 into main Jun 17, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants