diff --git a/README.md b/README.md index 97680b2..817e211 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ commit-sprout brag # one-line, standup-ready recap of recent growth commit-sprout prompt # compact glyph for your shell prompt / tmux status commit-sprout --prompt # same thing, as a flag (handy inside a prompt string) commit-sprout water # buy a grace day before wilting (weekends / PTO) +commit-sprout card # export the plant as an SVG badge for your README 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) @@ -111,6 +112,35 @@ ever faking *progress*: - **Health only** — grace never changes your growth stage or streak count; it just keeps a watered plant perky through a planned break. +### README badge (SVG card) + +The green-squares contribution graph is culture — `card` gives commit-sprout an +on-brand equivalent. It exports the current plant as a **self-contained SVG** +(no external fonts or assets) you can embed as a living-ish badge: + +```bash +commit-sprout card # writes plant.svg in the current directory +commit-sprout card --out docs/plant.svg +commit-sprout card --out - # stream the SVG to stdout (pipe it anywhere) +commit-sprout card --no-color # mono card (single ink color) +``` + +The card mirrors the terminal view — the same stage, health, streak, and art — +as monospace text plus a caption, so the badge is recognizably the same plant. +Color honors the theme (green / amber / red foliage, with a magenta bloom +accent); `--no-color` (or `NO_COLOR`) yields a mono card. Writes are atomic, so +a crash mid-export never corrupts an existing `plant.svg`. Exporting a card is +read-only: it never updates the remembered plant state. + +Embed it in your README: + +```markdown +![my commit-sprout](./plant.svg) +``` + +Regenerate it however you like — a `post-commit` git hook, a nightly cron job, +or a CI step that commits the refreshed `plant.svg`. + ### Watch mode (windowsill) ```bash diff --git a/cmd/card.go b/cmd/card.go new file mode 100644 index 0000000..ab1318d --- /dev/null +++ b/cmd/card.go @@ -0,0 +1,137 @@ +// Card subcommand: export the current plant as a self-contained SVG so it can +// be embedded as a README badge (and, with --out -, streamed to stdout). It +// rides the same read pipeline as every other command, so the SVG shows exactly +// the plant the terminal render would — same stage, health, streak, and pests — +// just as a shareable, screenshot-free artifact. +// +// The green-squares contribution badge is culture; this gives commit-sprout an +// on-brand equivalent: a living-ish plant you can drop in a README. +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/rwrife/commit-sprout/internal/gitstat" + "github.com/rwrife/commit-sprout/internal/render" + "github.com/spf13/cobra" +) + +// cardOut backs the --out flag: the destination SVG path. The special value "-" +// writes to stdout; any other value is a file path (default: plant.svg in cwd). +var cardOut string + +// cardCmd implements `commit-sprout card`. +var cardCmd = &cobra.Command{ + Use: "card", + Short: "Export the current plant as an embeddable SVG (README badge)", + Long: `Render the current plant to a self-contained SVG file so you can embed a +living-ish plant badge in your README. The card mirrors the terminal view — the +same stage, health, streak, and art — as monospace text plus a caption, with no +external fonts or assets. + +By default it writes plant.svg in the current directory (atomically). Use +"--out path.svg" to choose a destination, or "--out -" to stream the SVG to +stdout. Color honors the theme (green / amber / red foliage, magenta bloom); +--no-color yields a mono card.`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runCard(cmd) + }, +} + +// runCard reads the pipeline, renders the plant to SVG, and either streams it to +// stdout (--out -) or writes it atomically to the chosen file. It does not +// persist plant state: exporting a card is a read-only snapshot. +func runCard(cmd *cobra.Command) error { + r, err := runPipeline() + if err != nil { + if errors.Is(err, gitstat.ErrNotARepo) { + return notARepoMessage(cmd) + } + return err + } + + svg := render.Card(r.plant, render.CardOptions{ + Color: cardUseColor(), + Species: r.species, + Now: r.now, + LastCommit: r.activity.LastCommit, + }) + + // --out - streams to stdout so the card can be piped or captured inline. + if cardOut == "-" { + _, perr := fmt.Fprint(cmd.OutOrStdout(), svg) + return perr + } + + if err := atomicWriteFile(cardOut, []byte(svg)); err != nil { + return fmt.Errorf("card: writing %s: %w", cardOut, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "🪴 Wrote %s\n", cardOut) + return nil +} + +// cardUseColor decides whether the SVG takes the themed color path. Unlike the +// terminal render, a card is an artifact (a file or an embed), not a TTY, so +// TTY detection would wrongly strip color. Color is therefore on by default and +// only disabled by --no-color or the NO_COLOR environment variable. +func cardUseColor() bool { + if noColor { + return false + } + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + return true +} + +// atomicWriteFile writes data to path via a temp file in the same directory +// followed by a rename, so a reader never sees a partially written SVG and a +// crash mid-write cannot corrupt an existing card. It mirrors the durability +// approach used by internal/store.Save. +func atomicWriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + + tmp, err := os.CreateTemp(dir, ".commit-sprout-card-*.svg.tmp") + if err != nil { + return fmt.Errorf("creating temp file in %s: %w", dir, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("writing temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("syncing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp file: %w", err) + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return fmt.Errorf("setting permissions: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replacing %s: %w", path, err) + } + return nil +} + +func init() { + cardCmd.Flags().StringVar(&cardOut, "out", "plant.svg", + `SVG destination path, or "-" for stdout (default: plant.svg)`) + rootCmd.AddCommand(cardCmd) +} diff --git a/cmd/root.go b/cmd/root.go index da84e4f..1b4edde 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -332,8 +332,9 @@ func init() { rootCmd.SetVersionTemplate("commit-sprout {{.Version}}\n") // --no-color forces the plain-ASCII render path (also honored via NO_COLOR - // and automatically for non-TTY output). - rootCmd.Flags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)") + // and automatically for non-TTY output). Persistent so subcommands like + // `card` can honor it for a mono export. + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)") // --no-save renders without persisting state (read-only run). rootCmd.PersistentFlags().BoolVar(&noSave, "no-save", false, "do not persist plant state for this run") diff --git a/internal/render/card.go b/internal/render/card.go new file mode 100644 index 0000000..1ff55d2 --- /dev/null +++ b/internal/render/card.go @@ -0,0 +1,200 @@ +// SVG card rendering: turn the current plant into a self-contained, embeddable +// SVG so people can drop a commit-sprout badge into their README. The card +// mirrors the terminal look — the same per-stage/health ASCII art as monospace +// rows plus a stage/streak caption — so the SVG and the shell view are +// recognizably the same plant, just screenshot-free and shareable. +// +// The SVG is dependency-free (no external fonts/assets, no ): it uses a +// generic monospace font family and inlines every glyph as text, so it renders +// identically wherever it is embedded (GitHub, a static site, a raw viewer). +package render + +import ( + "fmt" + "strings" + "time" + + "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/species" +) + +// CardOptions controls how a plant is rendered to an SVG card. +type CardOptions struct { + // Color enables the themed color path: foliage takes the same + // health-driven color used in the terminal render (green / yellow / red, + // with a magenta bloom accent). When false, the whole card is mono + // (a single ink color on a light background), mirroring --no-color. + Color bool + + // Species selects which art set to draw. The zero value is the default + // species, matching the terminal render. + Species species.Kind + + // Now is the reference time used to phrase the caption's last-commit line + // ("today", "3 days ago"). The zero value falls back to time.Now. + Now time.Time + + // LastCommit is the timestamp of the most recent commit, used for the + // caption. The zero value renders as the friendly "no commits yet" nudge. + LastCommit time.Time +} + +// SVG glyph metrics. These are chosen so a generic monospace font lays out +// cleanly: cellW/cellH are the advance width and line height in user units, and +// the viewBox is sized from the art/caption dimensions plus padding. +const ( + cardCellW = 9.6 // monospace advance width per column + cardCellH = 18.0 // line height per row + cardPadX = 16.0 // left/right padding + cardPadY = 16.0 // top/bottom padding + cardFontPx = 15.0 // art font size + cardCapPx = 12.0 // caption font size + cardCapCellH = 15.0 // caption line height +) + +// Card palette (SVG-friendly hex, distinct from the terminal's ANSI indices but +// chosen to read the same: healthy green, thirsty amber, wilting red, bloom +// magenta, dim caption ink). +const ( + cardBG = "#fbfdf7" + cardBorder = "#d7e2c8" + cardInk = "#2f3a2a" + cardCaptionInk = "#6b7566" + cardHealthyHex = "#2e7d32" + cardThirstyHex = "#b8860b" + cardWiltingHex = "#c0392b" + cardBloomHex = "#a83fa8" +) + +// cardArtColor picks the foliage color for the color path, mirroring +// colorizeArt's health/bloom logic. +func cardArtColor(ps plant.PlantState) string { + switch ps.Health { + case plant.Thirsty: + return cardThirstyHex + case plant.Wilting: + return cardWiltingHex + default: + if ps.Stage == plant.Blooming { + return cardBloomHex + } + return cardHealthyHex + } +} + +// Card renders the plant state to a complete, standalone SVG document string. +// +// The art rows mirror the terminal frame's plain art (with pests overlaid), and +// the caption reuses the same stage/streak/last-commit block. Color honors the +// theme when opt.Color is true; otherwise the card is mono. The output is a full +// -prefixed document suitable for writing to a .svg file or +// embedding via . +func Card(ps plant.PlantState, opt CardOptions) string { + // Reuse the exact art the terminal would draw (plain path + pests) so the + // card and shell views are the same plant. + art := withPests(species.ArtFor(opt.Species, ps.Stage, ps.Health), ps) + artRows := strings.Split(art, "\n") + + // Reuse the shared caption block for parity with the terminal render. We + // pass a plain Options so the caption phrasing (last-commit "today"/"N days + // ago", wilt/pest hints, mood) matches exactly. + cap := caption(ps, Options{Now: opt.Now, LastCommit: opt.LastCommit}) + capRows := strings.Split(cap, "\n") + + // Dimensions: width is the widest of art / caption; height is stacked rows + // plus padding and a small gap between the art and caption blocks. + artCols := maxRuneWidth(artRows) + capCols := maxRuneWidth(capRows) + cols := artCols + if capCols > cols { + cols = capCols + } + + artH := float64(len(artRows)) * cardCellH + gap := 10.0 + capH := float64(len(capRows)) * cardCapCellH + width := cardPadX*2 + float64(cols)*cardCellW + height := cardPadY*2 + artH + gap + capH + + artFill := cardInk + if opt.Color { + artFill = cardArtColor(ps) + } + + var b strings.Builder + fmt.Fprintf(&b, ``+"\n") + fmt.Fprintf(&b, + ``+"\n", + width, height, width, height, + svgEscape(ps.Stage.String()), svgEscape(ps.Health.String())) + + // Background + subtle border so the badge reads as a card on any page. + fmt.Fprintf(&b, + ` `+"\n", + width-1, height-1, cardBG, cardBorder) + + // Art block: one with a per row so columns line up under a + // monospace font. x is reset per row and dy advances the line. + artBaseY := cardPadY + cardFontPx + fmt.Fprintf(&b, + ` `+"\n", + cardFontPx, artFill) + for i, row := range artRows { + y := artBaseY + float64(i)*cardCellH + fmt.Fprintf(&b, ` %s`+"\n", + cardPadX, y, svgEscape(padRow(row))) + } + fmt.Fprintf(&b, " \n") + + // Caption block: dimmed ink, smaller font, below the art. + capBaseY := cardPadY + artH + gap + cardCapPx + fmt.Fprintf(&b, + ` `+"\n", + cardCapPx, cardCaptionInk) + for i, row := range capRows { + y := capBaseY + float64(i)*cardCapCellH + fmt.Fprintf(&b, ` %s`+"\n", + cardPadX, y, svgEscape(row)) + } + fmt.Fprintf(&b, " \n") + + fmt.Fprintf(&b, "\n") + return b.String() +} + +// padRow renders a blank art row as a single space so its is non-empty +// (some renderers collapse empty tspans, which would drop the visual line). +func padRow(row string) string { + if row == "" { + return " " + } + return row +} + +// maxRuneWidth returns the widest row in runes, used to size the viewBox. +func maxRuneWidth(rows []string) int { + max := 0 + for _, r := range rows { + if n := len([]rune(r)); n > max { + max = n + } + } + return max +} + +// svgEscape escapes the five XML special characters so arbitrary art/caption +// text (including the '&', '<', '>' that can appear in glyphs) stays valid XML. +func svgEscape(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return replacer.Replace(s) +} diff --git a/internal/render/card_test.go b/internal/render/card_test.go new file mode 100644 index 0000000..f90527e --- /dev/null +++ b/internal/render/card_test.go @@ -0,0 +1,114 @@ +package render + +import ( + "encoding/xml" + "strings" + "testing" + "time" + + "github.com/rwrife/commit-sprout/internal/plant" + "github.com/rwrife/commit-sprout/internal/species" +) + +// knownCardState returns a fixed, healthy blooming plant so the card output is +// deterministic for assertions. +func knownCardState() plant.PlantState { + return plant.PlantState{ + Stage: plant.Blooming, + Health: plant.Healthy, + Streak: 7, + DaysSinceCommit: 0, + } +} + +func TestCard_ValidSVGRootAndGlyphRows(t *testing.T) { + ps := knownCardState() + now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + svg := Card(ps, CardOptions{ + Color: false, + Species: species.Default, + Now: now, + LastCommit: now, + }) + + // It must be well-formed XML with an root element. + dec := xml.NewDecoder(strings.NewReader(svg)) + var rootLocal string + for { + tok, err := dec.Token() + if err != nil { + break + } + if se, ok := tok.(xml.StartElement); ok { + rootLocal = se.Name.Local + break + } + } + if rootLocal != "svg" { + t.Fatalf("expected root element, got %q; svg was:\n%s", rootLocal, svg) + } + + // Fully parse to ensure the whole document is valid XML (no unescaped + // glyphs, balanced tags). + if err := xml.Unmarshal([]byte(svg), new(interface{})); err != nil { + t.Fatalf("card SVG is not valid XML: %v\n%s", err, svg) + } + + // The art rows must mirror the plain terminal art for this known state: + // one per art line, in order. + art := species.ArtFor(species.Default, ps.Stage, ps.Health) + for _, row := range strings.Split(art, "\n") { + want := svgEscape(padRow(row)) + if !strings.Contains(svg, ">"+want+"") { + t.Errorf("expected art glyph row %q as a tspan in SVG output", row) + } + } + + // The caption block carries the stage/streak so the badge is self-describing. + if !strings.Contains(svg, "stage: blooming (healthy)") { + t.Errorf("expected stage caption in card; svg:\n%s", svg) + } + if !strings.Contains(svg, "streak:") { + t.Errorf("expected streak caption in card") + } +} + +func TestCard_ColorHonorsHealthTheme(t *testing.T) { + now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + + cases := []struct { + name string + ps plant.PlantState + wantHx string + }{ + {"healthy", plant.PlantState{Stage: plant.Leafy, Health: plant.Healthy, DaysSinceCommit: 0}, cardHealthyHex}, + {"thirsty", plant.PlantState{Stage: plant.Leafy, Health: plant.Thirsty, DaysSinceCommit: 2}, cardThirstyHex}, + {"wilting", plant.PlantState{Stage: plant.Leafy, Health: plant.Wilting, DaysSinceCommit: 6}, cardWiltingHex}, + {"bloom", plant.PlantState{Stage: plant.Blooming, Health: plant.Healthy, DaysSinceCommit: 0}, cardBloomHex}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svg := Card(tc.ps, CardOptions{Color: true, Now: now, LastCommit: now}) + if !strings.Contains(svg, `fill="`+tc.wantHx+`"`) { + t.Errorf("expected foliage fill %s for %s state", tc.wantHx, tc.name) + } + }) + } +} + +func TestCard_NoColorIsMono(t *testing.T) { + now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + svg := Card(plant.PlantState{Stage: plant.Blooming, Health: plant.Healthy, DaysSinceCommit: 0}, + CardOptions{Color: false, Now: now, LastCommit: now}) + + // The mono path must not emit any of the themed foliage colors. + for _, hex := range []string{cardHealthyHex, cardThirstyHex, cardWiltingHex, cardBloomHex} { + if strings.Contains(svg, `font-size="15" fill="`+hex+`"`) { + t.Errorf("mono card should not use themed foliage color %s", hex) + } + } + // Art should use the neutral ink instead. + if !strings.Contains(svg, `font-size="15" fill="`+cardInk+`"`) { + t.Errorf("mono card should use neutral ink %s for art", cardInk) + } +}