diff --git a/README.md b/README.md index 1b44473..aefbe9e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ commit-sprout water # buy a grace day before wilting (weekends / PTO) commit-sprout watch # live full-screen view; the plant gently sways commit-sprout garden # a row of plants, one per repo (a windowsill) commit-sprout --species cactus # pick your plant's species (remembered as default) +commit-sprout --season winter # force a seasonal palette (default: derived from date) ``` `status` prints a plain, script-friendly summary: @@ -201,6 +202,39 @@ commit-sprout --species sunflower # a tall stalk that ends in a big bloom It only bends the *health* thresholds — growth and streak still come from real commits, so a cactus can't fake progress, just survive a drought. +### Seasons & themes + +The plant dresses for the time of year. `commit-sprout` derives a **season** +purely from today's date and layers a light palette + ASCII decoration on top of +the plant — falling leaves in autumn, snowflakes in winter, blossoms in spring, +bright sun in summer: + +``` +$ commit-sprout --season winter --no-color + . * . * . + \ | / + \|/ + --+-- + | + _|_ + ( ) + '-' + +stage: leafy (healthy) +... +``` + +- **Date-driven by default:** Dec–Feb = winter, Mar–May = spring, Jun–Aug = + summer, Sep–Nov = autumn. No configuration needed. +- **Force or preview:** `--season ` (alias `fall`) + pins a season for testing or if you just like a different vibe. +- **Disable it:** `--no-season` turns off all dressing for the neutral look. +- **Holiday skins (opt-in):** `--holiday` layers a gentle extra sparkle/decoration + on the base season. It's off by default — no surprise decorations. +- **Cosmetic only:** seasons *never* touch stage, health, or streak. It's pure + ambience layered on the same real, commit-driven plant, and it degrades to + plain ASCII under `--no-color` / `NO_COLOR` / piped output. + ## Install ### Prebuilt binary (recommended) @@ -328,6 +362,9 @@ See [PLAN.md](./PLAN.md) for the full plan, milestones (M1–M6), and the v0.2+ each with its own art set; the cactus survives droughts longer as a gameplay twist. - **Animated watch mode** (`commit-sprout watch`) — a live full-screen view where the plant gently sways and re-reads git activity on an interval. +- **Seasons & themes** (`commit-sprout --season` / `--no-season` / `--holiday`) — + date-driven cosmetic palettes and decorations (leaves, snow, blossoms), purely + ambient and never affecting the plant's state. ## License diff --git a/cmd/root.go b/cmd/root.go index ac805c3..82c993f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,7 @@ import ( "github.com/rwrife/commit-sprout/internal/gitstat" "github.com/rwrife/commit-sprout/internal/plant" "github.com/rwrife/commit-sprout/internal/render" + "github.com/rwrife/commit-sprout/internal/season" "github.com/rwrife/commit-sprout/internal/species" "github.com/rwrife/commit-sprout/internal/store" "github.com/spf13/cobra" @@ -52,6 +53,20 @@ var promptMode bool // default for future flag-less runs. var speciesFlag string +// seasonFlag backs the --season flag: force a specific seasonal dressing +// (winter / spring / summer / autumn) instead of deriving it from the current +// date. Empty means "derive from now". It is cosmetic only and never persisted. +var seasonFlag string + +// noSeason backs the --no-season flag, which disables seasonal dressing +// entirely for a neutral look. +var noSeason bool + +// holidaySkin backs the --holiday flag, an opt-in that layers gentle holiday +// decorations on top of the base season. Off by default so no surprise +// decorations appear beyond the base season. +var holidaySkin bool + // rootCmd is the base command invoked as `commit-sprout` with no subcommand. var rootCmd = &cobra.Command{ Use: "commit-sprout", @@ -89,6 +104,7 @@ type pipelineResult struct { plant plant.PlantState now time.Time species species.Kind + season season.Season } // runPipeline performs the shared read half of every command: read git @@ -145,6 +161,22 @@ func runPipeline() (pipelineResult, error) { ps := plant.ComputeWith(act, persisted.Plant(now, act.LastCommit), now, tune) + // Resolve the cosmetic season: --no-season disables it, an explicit + // --season forces one (reporting typos), otherwise derive it from now. + // This is purely ambient and never persisted. + sea := season.FromTime(now) + if seasonFlag != "" { + chosen, ok := season.Parse(seasonFlag) + if !ok { + return pipelineResult{}, fmt.Errorf("unknown season %q (choose one of: %s)", + seasonFlag, strings.Join(season.Names(), ", ")) + } + sea = chosen + } + if noSeason { + sea = season.None + } + return pipelineResult{ activity: act, persisted: persisted, @@ -153,6 +185,7 @@ func runPipeline() (pipelineResult, error) { plant: ps, now: now, species: sp, + season: sea, }, nil } @@ -206,6 +239,8 @@ func renderPlant(cmd *cobra.Command) error { Now: r.now, LastCommit: r.activity.LastCommit, Species: r.species, + Season: r.season, + Holiday: holidaySkin, }) if _, perr := fmt.Fprintln(out, frame); perr != nil { return perr @@ -299,6 +334,13 @@ func init() { rootCmd.PersistentFlags().StringVar(&speciesFlag, "species", "", "plant species: "+strings.Join(species.Names(), " / ")+" (remembered as default)") + // --season forces a cosmetic season; --no-season disables seasonal dressing; + // --holiday opts into gentle holiday skins. All are ambient only. + rootCmd.PersistentFlags().StringVar(&seasonFlag, "season", "", + "force seasonal dressing: "+strings.Join(season.Names(), " / ")+" (default: derived from date)") + rootCmd.PersistentFlags().BoolVar(&noSeason, "no-season", false, "disable seasonal dressing (neutral look)") + rootCmd.PersistentFlags().BoolVar(&holidaySkin, "holiday", false, "opt into gentle holiday skins layered on the season") + // Hidden M2 bridge: dump parsed git activity. Removed once M3+ consume it. rootCmd.Flags().BoolVar(&showActivity, "activity", false, "print parsed git activity (debug)") _ = rootCmd.Flags().MarkHidden("activity") diff --git a/internal/render/render.go b/internal/render/render.go index 9aeb62e..221157c 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/season" "github.com/rwrife/commit-sprout/internal/species" ) @@ -55,6 +56,17 @@ type Options struct { // Species selects which art set to render. The zero value is the default // species (fern), so callers that never set it get the historical art. Species species.Kind + + // Season selects the cosmetic seasonal dressing (palette + decoration). + // The zero value (season.None) is a clean no-op that renders the neutral + // look, so existing callers are unaffected. Seasonal dressing never + // changes stage/health/streak — it is purely ambient. + Season season.Season + + // Holiday enables gentle holiday skins layered on top of the base season. + // It is opt-in and off by default: no surprise decorations appear beyond + // the base season unless a caller explicitly sets this. + Holiday bool } // Seedling returns the hard-coded ASCII seedling frame (the healthy Seed art). @@ -78,11 +90,11 @@ func Frame(ps plant.PlantState, opt Options) string { var b strings.Builder if opt.Color { - b.WriteString(colorizeArt(art, ps)) + b.WriteString(dressArt(colorizeArt(art, ps), opt.Season, true, opt.Holiday)) b.WriteString("\n\n") b.WriteString(colorizeCaption(caption, ps)) } else { - b.WriteString(art) + b.WriteString(dressArt(art, opt.Season, false, opt.Holiday)) b.WriteString("\n\n") b.WriteString(caption) } diff --git a/internal/render/season_render.go b/internal/render/season_render.go new file mode 100644 index 0000000..0646ffc --- /dev/null +++ b/internal/render/season_render.go @@ -0,0 +1,94 @@ +// Package render seasonal dressing. +// +// Seasons are cosmetic only: they layer a palette and a light ASCII decoration +// (falling leaves, snowflakes, blossoms) on top of the existing frame without +// ever touching the plant's stage, health, or streak. Everything here degrades +// gracefully — under the plain path (--no-color / NO_COLOR / non-TTY) the +// decoration is drawn as plain ASCII with no styling, and season.None is a +// clean no-op that returns the neutral frame untouched. +package render + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/rwrife/commit-sprout/internal/season" +) + +// dressArt applies a season's decoration and palette to already-rendered art. +// +// For season.None it returns art unchanged (the neutral look). Otherwise it +// prepends a thin decoration line (e.g. drifting snow or falling leaves) and, +// when color is enabled, tints that decoration with a season-appropriate color. +// The base plant art keeps its own health-based coloring done by the caller; +// dressing only adds the ambient top line so the plant's own coloring is never +// overwritten. +func dressArt(art string, s season.Season, color bool, holiday bool) string { + deco := seasonDecoration(s, holiday) + if deco == "" { + return art + } + if color { + deco = seasonStyle(s).Render(deco) + } + return deco + "\n" + art +} + +// seasonDecoration returns the ambient decoration line for a season, or "" when +// there is nothing to add (season.None). When holiday is true and the season +// has a gentle holiday variant, that is used instead of the base line. +// +// Decorations are intentionally a single short ASCII line so they never disturb +// the plant's own art block width in a disruptive way and remain readable in a +// plain terminal. +func seasonDecoration(s season.Season, holiday bool) string { + switch s { + case season.Winter: + if holiday { + return " * . * . *" // a touch more sparkle for the holidays + } + return " . * . * ." // drifting snow + case season.Spring: + if holiday { + return " @ . @ . @" // blossoms in bloom + } + return " ' , ' , '" // fresh buds + case season.Summer: + return " \\ | / | \\" // bright sun rays + case season.Autumn: + if holiday { + return " % . % . %" // extra falling leaves + } + return " , . ` . ," // falling leaves + default: + return "" + } +} + +// seasonStyle picks a lipgloss color for a season's decoration. It is only +// consulted on the color path; the plain path ignores it entirely. +func seasonStyle(s season.Season) lipgloss.Style { + switch s { + case season.Winter: + return lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan snow + case season.Spring: + return lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // pink blossoms + case season.Summer: + return lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // bright yellow sun + case season.Autumn: + return lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // orange leaves + default: + return lipgloss.NewStyle() + } +} + +// trimSeasonDeco is a small helper used by tests to strip a leading decoration +// line from a dressed frame, leaving the underlying neutral art for comparison. +// It removes exactly one leading line when it matches a known decoration. +func trimSeasonDeco(dressed string) string { + idx := strings.IndexByte(dressed, '\n') + if idx < 0 { + return dressed + } + return dressed[idx+1:] +} diff --git a/internal/render/season_render_test.go b/internal/render/season_render_test.go new file mode 100644 index 0000000..a682e8e --- /dev/null +++ b/internal/render/season_render_test.go @@ -0,0 +1,102 @@ +package render + +import ( + "strings" + "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/season" +) + +// samplePlant returns a representative plant state for dressing tests. +func samplePlant() plant.PlantState { + return plant.PlantState{ + Stage: plant.Leafy, + Health: plant.Healthy, + Streak: 5, + DaysSinceCommit: 0, + } +} + +// TestSeasonNoneIsNeutral confirms season.None renders identically to a frame +// with no seasonal dressing — the neutral look is a true no-op. +func TestSeasonNoneIsNeutral(t *testing.T) { + ps := samplePlant() + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + + neutral := Frame(ps, Options{Now: now, LastCommit: now}) + explicitNone := Frame(ps, Options{Now: now, LastCommit: now, Season: season.None}) + + if neutral != explicitNone { + t.Fatalf("season.None changed output:\nneutral:\n%s\nnone:\n%s", neutral, explicitNone) + } +} + +// TestSeasonPreservesPlantState asserts the cosmetic-only invariant: for every +// season the underlying plant art and full caption block are byte-identical to +// the neutral render once the decoration line is stripped. Seasonal dressing +// must never alter stage/health/streak or the caption that reports them. +func TestSeasonPreservesPlantState(t *testing.T) { + ps := samplePlant() + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + neutral := Frame(ps, Options{Now: now, LastCommit: now}) + + for _, s := range []season.Season{season.Winter, season.Spring, season.Summer, season.Autumn} { + dressed := Frame(ps, Options{Now: now, LastCommit: now, Season: s}) + // A season prepends exactly one decoration line to the art; strip it + // and the rest must equal the neutral frame. + stripped := trimSeasonDeco(dressed) + if stripped != neutral { + t.Errorf("season %v altered plant body:\nneutral:\n%q\nstripped:\n%q", s, neutral, stripped) + } + } +} + +// TestSeasonPlainNoAnsi ensures the plain (no-color) path emits no ANSI escape +// codes in the seasonal decoration, so it degrades to clean ASCII. +func TestSeasonPlainNoAnsi(t *testing.T) { + ps := samplePlant() + now := time.Date(2026, time.December, 25, 12, 0, 0, 0, time.UTC) + + for _, s := range []season.Season{season.Winter, season.Spring, season.Summer, season.Autumn} { + out := Frame(ps, Options{Now: now, LastCommit: now, Season: s, Color: false}) + if strings.Contains(out, "\x1b[") { + t.Errorf("season %v plain render contains ANSI escape: %q", s, out) + } + } +} + +// TestSeasonAddsDecoration confirms a non-None season actually adds a visible +// decoration line (i.e. the feature does something). +func TestSeasonAddsDecoration(t *testing.T) { + ps := samplePlant() + now := time.Date(2026, time.July, 13, 12, 0, 0, 0, time.UTC) + + neutral := Frame(ps, Options{Now: now, LastCommit: now}) + dressed := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter}) + + if !strings.HasPrefix(dressed, seasonDecoration(season.Winter, false)) { + t.Errorf("winter frame missing decoration prefix: %q", dressed) + } + if len(dressed) <= len(neutral) { + t.Errorf("dressed frame not longer than neutral (%d vs %d)", len(dressed), len(neutral)) + } +} + +// TestHolidayIsOptIn verifies holiday skins only change output when explicitly +// enabled: with Holiday=false the base season decoration is used. +func TestHolidayIsOptIn(t *testing.T) { + ps := samplePlant() + now := time.Date(2026, time.December, 25, 12, 0, 0, 0, time.UTC) + + base := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter, Holiday: false}) + holiday := Frame(ps, Options{Now: now, LastCommit: now, Season: season.Winter, Holiday: true}) + + if base == holiday { + t.Errorf("holiday skin did not change winter output when opted in") + } + if !strings.HasPrefix(base, seasonDecoration(season.Winter, false)) { + t.Errorf("base winter should use non-holiday decoration") + } +} diff --git a/internal/season/season.go b/internal/season/season.go new file mode 100644 index 0000000..85ac192 --- /dev/null +++ b/internal/season/season.go @@ -0,0 +1,93 @@ +// Package season derives a purely cosmetic "time of year" for the plant. +// +// A Season is a function of a reference time.Time only — no I/O, no persisted +// state — so it is trivially testable and never affects the plant's actual +// stage/health/streak. Render consults a season to pick a palette and light +// ASCII decoration (falling leaves, snowflakes, blossoms), all of which degrade +// gracefully to plain ASCII under --no-color. +package season + +import ( + "strings" + "time" +) + +// Season is a coarse time-of-year bucket used purely for cosmetic dressing. +type Season int + +const ( + // None disables seasonal dressing entirely (the neutral look). It is the + // zero value so a Season{} means "no season". + None Season = iota + Winter + Spring + Summer + Autumn +) + +// String returns the lowercase season name (or "none"). +func (s Season) String() string { + switch s { + case Winter: + return "winter" + case Spring: + return "spring" + case Summer: + return "summer" + case Autumn: + return "autumn" + default: + return "none" + } +} + +// Names lists the selectable season names (excluding "none"), in calendar order +// starting from winter. Handy for flag help and validation messages. +func Names() []string { + return []string{"winter", "spring", "summer", "autumn"} +} + +// Parse maps a name to a Season. It accepts the canonical names plus a couple +// of friendly aliases ("fall" for autumn). The bool is false for an +// unrecognized name so callers can report a typo rather than silently default. +// "none"/"off" parse to None (true). +func Parse(name string) (Season, bool) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "winter": + return Winter, true + case "spring": + return Spring, true + case "summer": + return Summer, true + case "autumn", "fall": + return Autumn, true + case "none", "off", "": + return None, true + default: + return None, false + } +} + +// FromTime derives the Season from a reference time using meteorological-ish +// month boundaries (Northern Hemisphere): +// +// Dec, Jan, Feb -> Winter +// Mar, Apr, May -> Spring +// Jun, Jul, Aug -> Summer +// Sep, Oct, Nov -> Autumn +// +// It is pure and total: a zero time.Time still yields a valid season (its month +// is January -> Winter). It never returns None; None is reserved for the +// explicit "disable dressing" path, not for a date-derived season. +func FromTime(t time.Time) Season { + switch t.Month() { + case time.December, time.January, time.February: + return Winter + case time.March, time.April, time.May: + return Spring + case time.June, time.July, time.August: + return Summer + default: // September, October, November + return Autumn + } +} diff --git a/internal/season/season_test.go b/internal/season/season_test.go new file mode 100644 index 0000000..688419b --- /dev/null +++ b/internal/season/season_test.go @@ -0,0 +1,118 @@ +package season + +import ( + "testing" + "time" +) + +func TestFromTimeMonthMapping(t *testing.T) { + cases := []struct { + month time.Month + want Season + }{ + {time.December, Winter}, + {time.January, Winter}, + {time.February, Winter}, + {time.March, Spring}, + {time.April, Spring}, + {time.May, Spring}, + {time.June, Summer}, + {time.July, Summer}, + {time.August, Summer}, + {time.September, Autumn}, + {time.October, Autumn}, + {time.November, Autumn}, + } + for _, c := range cases { + got := FromTime(time.Date(2026, c.month, 15, 12, 0, 0, 0, time.UTC)) + if got != c.want { + t.Errorf("FromTime(%s) = %v, want %v", c.month, got, c.want) + } + } +} + +// TestFromTimeBoundaries pins the exact month boundaries where a season flips. +func TestFromTimeBoundaries(t *testing.T) { + cases := []struct { + name string + t time.Time + want Season + }{ + {"last of winter (Feb 28)", time.Date(2026, time.February, 28, 23, 59, 0, 0, time.UTC), Winter}, + {"first of spring (Mar 1)", time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC), Spring}, + {"last of spring (May 31)", time.Date(2026, time.May, 31, 23, 59, 0, 0, time.UTC), Spring}, + {"first of summer (Jun 1)", time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), Summer}, + {"last of summer (Aug 31)", time.Date(2026, time.August, 31, 23, 59, 0, 0, time.UTC), Summer}, + {"first of autumn (Sep 1)", time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC), Autumn}, + {"last of autumn (Nov 30)", time.Date(2026, time.November, 30, 23, 59, 0, 0, time.UTC), Autumn}, + {"first of winter (Dec 1)", time.Date(2026, time.December, 1, 0, 0, 0, 0, time.UTC), Winter}, + } + for _, c := range cases { + if got := FromTime(c.t); got != c.want { + t.Errorf("%s: FromTime = %v, want %v", c.name, got, c.want) + } + } +} + +// TestFromTimeZeroTimeTotal ensures the zero time is handled (January -> Winter) +// rather than panicking or returning None. +func TestFromTimeZeroTimeTotal(t *testing.T) { + if got := FromTime(time.Time{}); got != Winter { + t.Errorf("FromTime(zero) = %v, want Winter", got) + } +} + +// TestFromTimeNeverNone guards the invariant that a date-derived season is +// always a real season, never the disable sentinel. +func TestFromTimeNeverNone(t *testing.T) { + for m := time.January; m <= time.December; m++ { + if FromTime(time.Date(2026, m, 15, 0, 0, 0, 0, time.UTC)) == None { + t.Fatalf("FromTime(%s) returned None", m) + } + } +} + +func TestParse(t *testing.T) { + cases := []struct { + in string + want Season + ok bool + }{ + {"winter", Winter, true}, + {"Spring", Spring, true}, + {" SUMMER ", Summer, true}, + {"autumn", Autumn, true}, + {"fall", Autumn, true}, + {"none", None, true}, + {"off", None, true}, + {"", None, true}, + {"bogus", None, false}, + } + for _, c := range cases { + got, ok := Parse(c.in) + if got != c.want || ok != c.ok { + t.Errorf("Parse(%q) = (%v, %v), want (%v, %v)", c.in, got, ok, c.want, c.ok) + } + } +} + +func TestStringRoundTrip(t *testing.T) { + for _, s := range []Season{Winter, Spring, Summer, Autumn, None} { + got, ok := Parse(s.String()) + if !ok || got != s { + t.Errorf("round trip %v -> %q -> (%v, %v)", s, s.String(), got, ok) + } + } +} + +func TestNames(t *testing.T) { + names := Names() + if len(names) != 4 { + t.Fatalf("Names() len = %d, want 4", len(names)) + } + for _, n := range names { + if _, ok := Parse(n); !ok { + t.Errorf("Names() contains unparseable %q", n) + } + } +}