diff --git a/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md b/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md
new file mode 100644
index 0000000..606b439
--- /dev/null
+++ b/.beans/vibed-4rql--per-habit-streaks-each-habit-scores-its-own-streak.md
@@ -0,0 +1,19 @@
+---
+# vibed-4rql
+title: 'Per-habit streaks: each habit scores its own streak value'
+status: completed
+type: feature
+priority: normal
+created_at: 2026-07-16T16:31:23Z
+updated_at: 2026-07-16T16:37:00Z
+---
+
+Replace the single day-level streak + triangular(checks) reveal formula with per-habit streaks: each habit tracks its own consecutive-day streak, and checking it reveals pixels equal to that habit's current streak length (1st day = 1 pixel, 2nd consecutive day = 2, etc.), with no cross-habit multiplier. Remove the global streak header/stat chip entirely (per user decision) and show each habit's own streak inline on its row instead. Update DESIGN.md, README.md, ARCHITECTURE.md, and index.html (mechanics + About text + UI).
+
+## Summary of Changes
+
+- index.html: replaced the day-level triangular(checks) Γ streak-multiplier reveal with per-habit streaks. Added `habitStreakEndingOn(log, habitId, day)` and `currentHabitStreak(log, habitId, today)` (display, forgives an unchecked-yet today); `revealedUnits()` now sums each checked habit's own streak length per day. Removed `triangular()`, `streakOnDay()`, and the global `computeStreak()`.
+- Removed the global "π₯ N-day streak" header label and stat chip entirely (per user decision); each habit row now shows its own inline streak badge (e.g. π₯3) when > 0.
+- Updated the in-app About text and CSS (`.habit-streak`, removed `.streak-label`).
+- Updated docs/DESIGN.md (Β§2 mockup, Β§3.2, Β§3.4, Β§9), docs/ARCHITECTURE.md, and README.md to describe per-habit streak scoring instead of day-level/triangular + multiplier.
+- Validated with a Playwright smoke test (seeded localStorage, checkbox clicks) confirming reveal math, streak badges, and toast deltas; no console errors or non-file:// requests. Also captured a headless screenshot to confirm the UI layout.
diff --git a/bit-by-bit/README.md b/bit-by-bit/README.md
index f76a2ed..95d2a31 100644
--- a/bit-by-bit/README.md
+++ b/bit-by-bit/README.md
@@ -18,20 +18,18 @@ next one.
Each artwork is a pixel grid. On start, a seeded PRNG (mulberry32 +
FisherβYates) fixes a random reveal order that stays stable across sessions.
-Checking off habits reveals pixels, and **the more you do in a single day, the
-bigger the reward** β the first habit each day is worth 1 unit, the second 2,
-the third 3, and so on (so a 4-habit day earns 1 + 2 + 3 + 4 = 10 units).
-**Streaks multiply everything:** each day's units are scaled by the length of
-your active streak, so on a 3-day streak the first check is worth 3 units and
-the second 6, and on a 5-day streak they're worth 5 and 10. The per-image
-reveal rate is sized so a "perfect month" of doing everything daily just fills
-the picture. Pixels never un-reveal across days; missing a day just pauses
-progress. Includes 8 built-in pixel artworks, or upload any image to pixelate
-it into a 16β48 cell grid.
-
-Dark/light theme follows your OS by default, with a manual override. Streaks
-count any day you checked at least one habit; days where you did everything get
-a β¨ perfect-day sparkle.
+**Every habit keeps its own streak** β checking a habit reveals pixels equal to
+its own current streak length: 1 pixel the first day, 2 the next consecutive
+day, 3 the day after, and so on. There's no bonus for doing several habits in
+one day; each habit scores purely on its own history. Bigger images just take
+longer to fill in. Pixels never un-reveal across days; missing a day resets
+that habit's streak, but never erases pixels already earned. Includes 8
+built-in pixel artworks, or upload any image to pixelate it into a 16β48 cell
+grid.
+
+Dark/light theme follows your OS by default, with a manual override. Each
+habit shows its own streak inline on its row; days where you did everything
+get a β¨ perfect-day sparkle.
## Run it
diff --git a/bit-by-bit/docs/ARCHITECTURE.md b/bit-by-bit/docs/ARCHITECTURE.md
index 44dbfde..82d01c6 100644
--- a/bit-by-bit/docs/ARCHITECTURE.md
+++ b/bit-by-bit/docs/ARCHITECTURE.md
@@ -5,7 +5,7 @@ Everything lives in one file, `index.html`, organized into sections:
| Component | Responsibility |
|---|---|
| **State** | Single JSON document under localStorage key `bitbybit.v1`; load/save with corruption guards; versioned for future migration. |
-| **Mechanics** | Habit check/uncheck logging, daily rollover, streak math, seeded (mulberry32) reveal-order permutation, and log-derived reveal: each day reveals `triangular(checks)` pixels (the Nth check reveals N), multiplied by that day's streak length. |
+| **Mechanics** | Habit check/uncheck logging, daily rollover, per-habit streak math, seeded (mulberry32) reveal-order permutation, and log-derived reveal: each habit reveals pixels equal to its own current streak length (1 the first day, 2 the next consecutive day, and so on), independent of any other habit. |
| **Artwork** | Preset pixel-art definitions (palette + row strings); upload pipeline (offscreen canvas downsample β pixel grid); gallery of finished pieces. |
| **Rendering** | Canvas renderer for the artwork (revealed pixels, checkerboard for hidden, pop-in animation for deltas); DOM renderer for checklist, stats, settings. |
| **UI wiring** | Onboarding, settings dialog (habits / artwork / data / about), theme cycling (autoβlightβdark via `data-theme` + CSS custom properties), export/import/wipe, confetti celebration, and a page footer disclosing authorship. |
diff --git a/bit-by-bit/docs/DESIGN.md b/bit-by-bit/docs/DESIGN.md
index 4c2a76c..a31302c 100644
--- a/bit-by-bit/docs/DESIGN.md
+++ b/bit-by-bit/docs/DESIGN.md
@@ -34,15 +34,14 @@ stay revealed.
β β
ββββββββββββββββββββββββββββββββ€
β Today Β· Sunday, July 13 β
-β π₯ 5-day streak β
β ββββββββββββββββββββββββ β
-β β π§ Drink water [β] β β today's habit checklist β
-β β π Go for a run [ ] β β big, thumb-friendly rows
-β β π Read 10 pages [ ] β β
+β β π§ Drink water π₯5[β]β β today's habit checklist β each row shows
+β β π Go for a run [ ] β β its own streak; big, thumb-friendly rows
+β β π Read 10 pages π₯2[]β β
β ββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ€
-β streak Β· total checks Β· β small stat chips
-β images finished β
+β total checks Β· images β small stat chips
+β finished β
ββββββββββββββββββββββββββββββββ
```
@@ -74,24 +73,23 @@ stay revealed.
- On image start, generate a random permutation of opaque pixel indices using a
**seeded PRNG (mulberry32)** with a stored seed. This is the reveal order β
stable across sessions and re-renders.
-- **Escalating daily reward, 1 unit = 1 pixel:** the _Nth_ habit checked on a
- given day reveals `N` pixels (1 + 2 + 3 + β¦ ), so a day of `k` checks reveals
- `triangular(k) = kΒ·(k+1)/2` pixels before the streak multiplier. Doing more
- in one day is rewarded super-linearly; the toast and pop for a later check
- reveal noticeably more pixels than an earlier one.
-- **Streak multiplier:** a day's pixels are multiplied by that day's **streak
- length** β the number of consecutive days with β₯1 check ending on (and
- including) that day. On a 3-day streak the 1st check of the day reveals `3`
- pixels and the 2nd `6`; on a 5-day streak, `5` and `10`. A day's streak length
- counts *backwards* from itself, so checking or unchecking a later day never
- changes an earlier day's multiplier.
+- **Per-habit streaks, 1 unit = 1 pixel:** each habit has its own streak β
+ the number of consecutive days (ending on, and including, a given day) that
+ habit was checked. Checking a habit reveals pixels equal to **that habit's
+ own streak length** on the day checked: 1 pixel the first day, 2 pixels the
+ next consecutive day, 3 the day after, and so on. There is no cross-habit
+ multiplier or bonus for doing several habits in one day β every habit scores
+ purely on its own history. A habit's streak counts *backwards* from the day
+ it's evaluated on, so checking or unchecking a different day (or a different
+ habit) never changes it.
- **Revealed pixels are derived from the log, not stored.** Revealed pixel total =
- `min(opaqueCount, Ξ£ streakLen(d) Β· triangular(len(log[d])))` over days
- `d β₯ startedAt`. No separate per-image calibration constant β bigger images
- simply take longer to fill in, smaller ones finish sooner. Because reveal
- is a pure function of the log, same-day uncheck only shrinks today's
- contribution β past days can never be un-revealed, and reloads/undo stay
- perfectly consistent with no separate counter to keep in sync.
+ `min(opaqueCount, Ξ£ Ξ£ streakLen(habit, d))` over days `d β₯ startedAt` and
+ habits `habit` checked on day `d`. No separate per-image calibration
+ constant β bigger images simply take longer to fill in, smaller ones finish
+ sooner. Because reveal is a pure function of the log, same-day uncheck only
+ shrinks that habit's contribution for today β past days can never be
+ un-revealed, and reloads/undo stay perfectly consistent with no separate
+ counter to keep in sync.
- Unrevealed cells render as a subtle dim checkerboard ("static"). Revealed
cells show their color and **pop in with a tiny scale/settle animation** when
newly revealed (animate only the delta, not on every render).
@@ -115,14 +113,13 @@ stay revealed.
- "Today" = local date `YYYY-MM-DD` (from local time, not UTC). Recompute on an
interval (~30s) and on `visibilitychange` so a tab left open overnight rolls
over correctly (checklist resets, floor for uncheck updates).
-- **Streak:** consecutive days (ending today or yesterday) where **all habits
- then existing** were checked⦠simplify: a day counts if **every habit checked
- that day β₯ 1** is too fuzzy β use: day counts if the user checked **all
- currently-listed habits** is wrong for history. **Rule:** a day counts toward
- the streak if **at least one habit was checked** that day; show a separate
- "perfect day" sparkle β¨ on days where all habits were done. Streak of
- any-activity is kinder and simpler; perfect days are the bonus flair.
-- Stat chips: π₯ current streak, β
total checks all-time, πΌοΈ images finished.
+- **Streak:** per-habit, not global β each habit's streak is the count of
+ consecutive days (ending today or yesterday, so it stays "alive" until a day
+ is fully missed) that habit itself was checked. Shown inline on that habit's
+ row (e.g. π₯3), not as a single app-wide number. Separately, show a "perfect
+ day" sparkle β¨ on days where *every* currently-listed habit was checked β
+ purely a flair indicator, unrelated to streak math.
+- Stat chips: β
total checks all-time, πΌοΈ images finished.
## 4. Data Model
@@ -222,17 +219,19 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
## 9. Edge Cases (must handle)
1. First run / empty state β onboarding.
-2. Same-day uncheck only shrinks today's (streak-multiplied) contribution;
+2. Same-day uncheck only shrinks that habit's contribution for today;
previous days' revealed pixels are never lost (reveal is derived from the log,
- and each day's streak multiplier counts backwards from itself).
-3. Habit deleted after being checked today β today's log keeps the id; counts
- still work.
+ and each habit's streak counts backwards from the day it's evaluated on).
+3. Habit deleted after being checked today β today's log keeps the id; its
+ streak/counts keep working off the log id even though it no longer appears
+ in the habit list or UI.
4. Day rollover with tab open β checklist resets without reload.
5. Import of garbage / wrong-version / truncated JSON β friendly rejection.
6. localStorage full or unavailable (private mode) β app still renders; banner
warns that progress won't persist.
7. Image with very few opaque pixels (e.g., 20) finishes early β fine; celebrate.
8. A big escalating reward overshooting the last pixels just clamps to 100%.
-9. Legacy saves carrying an old `checks` field load fine β it is ignored and
- reveal is recomputed from the log (older images may reveal more than before,
- since the reward is now triangular per day and multiplied by streak length).
+9. Legacy saves carrying an old `checks` field, or images started under the
+ previous day-level/triangular reward, load fine β it is ignored and reveal
+ is always recomputed from the log under the current per-habit-streak rule
+ (revealed totals may shift from what an old version would have shown).
diff --git a/bit-by-bit/index.html b/bit-by-bit/index.html
index 8e5bc7c..92e9615 100644
--- a/bit-by-bit/index.html
+++ b/bit-by-bit/index.html
@@ -126,7 +126,6 @@
box-shadow:var(--shadow); display:flex; flex-direction:column; gap:var(--space-3); }
.today-header{ display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:var(--space-2); }
.today-label{ font-weight:700; }
- .streak-label{ color:var(--accent-strong); font-weight:700; font-size:0.9rem; }
.habit-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:var(--space-2); }
.habit-row{ display:flex; align-items:center; gap:var(--space-3); border-radius:var(--radius-md);
background:var(--bg-sunken); min-height:44px; transition:background .2s ease; }
@@ -136,7 +135,8 @@
.habit-label{ display:flex; align-items:center; gap:var(--space-3); flex:1; min-height:44px; padding:var(--space-2) var(--space-3);
cursor:pointer; }
.habit-emoji{ font-size:1.35rem; }
- .habit-name{ font-size:1rem; }
+ .habit-name{ font-size:1rem; flex:1; }
+ .habit-streak{ color:var(--accent-strong); font-weight:700; font-size:0.85rem; flex-shrink:0; }
.habit-checkbox{ width:26px; height:26px; margin:0 var(--space-3) 0 0; accent-color:var(--accent-strong); cursor:pointer; flex-shrink:0; }
.empty-state{ color:var(--text-dim); font-style:italic; text-align:center; margin:var(--space-2) 0; }
@@ -282,14 +282,12 @@
No habits yet β plant your first one in Settings π±
Paint by Habits is a cozy pixel-art habit tracker. Check off your daily habits and watch a hidden picture reveal itself, one tiny square at a time. Missing a day never erases progress β pixels stay revealed forever.
-How you earn pixels: each habit you check off today reveals pixels β your 1st - check reveals 1 pixel, your 2nd reveals 2, your 3rd reveals 3, and so on. Checking off your - whole list in a day is the sweet spot.
-Streaks multiply everything. Every consecutive day you show up bumps the multiplier: - on a 3-day streak your 1st check of the day reveals 3 pixels and your 2nd reveals 6; on a 5-day streak - they reveal 5 and 10. Come back tomorrow and finish the day's list, and the picture floods in.
+Each habit has its own streak. A habit's pixel value is its current streak + length β check it off once, that's 1 pixel; keep checking it every consecutive day and it's + worth 2, then 3, then 4, and so on. Missing a day resets that habit's streak back to 1 the next + time you check it, but never erases pixels you've already earned.
+Every habit scores on its own β there's no bonus for doing them all in one day, just each + one quietly climbing the longer you keep it up.
Everything lives only in this browser's local storage, on this device. Nothing is ever sent anywhere.
@@ -634,23 +632,22 @@ return order; } - // Escalating daily reward with a streak multiplier, 1 unit = 1 pixel: - // β’ Within a day, the Nth habit checked reveals N pixels (1st = 1, 2nd = 2, - // ...), so k checks in a day reveal triangular(k) = kΒ·(k+1)/2 pixels. - // β’ Each of those pixels is then multiplied by that day's streak length - // (consecutive active days ending on it): on a 3-day streak the 1st - // check reveals 3 pixels, the 2nd 6; on a 5-day streak, 5 and 10. + // Per-habit streaks, 1 unit = 1 pixel: each habit tracks its own streak of + // consecutive days it was checked. Checking a habit reveals pixels equal to + // its streak length as of that check: 1 pixel the first day, 2 the next + // consecutive day, 3 the day after, and so on. There is no cross-habit + // multiplier β every habit scores purely on its own history. // Revealed pixels are re-derived from the log every time rather than stored, // so undo/redo and reloads stay perfectly consistent and past days can never - // be un-revealed by unchecking today (a day's streak length counts backwards - // from itself, so it is unaffected by checking or unchecking later days). - function triangular(k){ return k * (k + 1) / 2; } + // be un-revealed by unchecking today (a habit's streak on a given day counts + // backwards from that day, so it is unaffected by checking or unchecking + // that habit on later days). - // Streak length for a single day: consecutive days with β₯1 check, ending at - // (and including) `day`. 0 if `day` itself has no checks. - function streakOnDay(log, day){ + // A single habit's streak ending on (and including) `day`: consecutive days + // with that habit checked. 0 if the habit wasn't checked on `day` itself. + function habitStreakEndingOn(log, habitId, day){ var streak = 0, cursor = day; - while (log[cursor] && log[cursor].length > 0) { + while (log[cursor] && log[cursor].indexOf(habitId) !== -1) { streak++; cursor = addDaysStr(cursor, -1); } @@ -661,8 +658,10 @@ var units = 0; for (var d in state.log) { if (d >= image.startedAt) { - var checks = (state.log[d] || []).length; - units += streakOnDay(state.log, d) * triangular(checks); + var checked = state.log[d] || []; + for (var i = 0; i < checked.length; i++) { + units += habitStreakEndingOn(state.log, checked[i], d); + } } } return units; @@ -749,15 +748,13 @@ return new Date(p[0], p[1] - 1, p[2]); } - function computeStreak(log, today){ - var streak = 0; - var cursor = today; - if (!log[cursor] || log[cursor].length === 0) cursor = addDaysStr(cursor, -1); - while (log[cursor] && log[cursor].length > 0) { - streak++; - cursor = addDaysStr(cursor, -1); - } - return streak; + // A habit's current streak for display: same as habitStreakEndingOn, but if + // the habit hasn't been checked yet today the streak is still shown as + // "alive" (counted through yesterday) rather than dropping to 0 the instant + // the day rolls over β it only truly breaks once a day passes with no check. + function currentHabitStreak(log, habitId, today){ + var day = habitStreakEndingOn(log, habitId, today) > 0 ? today : addDaysStr(today, -1); + return habitStreakEndingOn(log, habitId, day); } function isPerfectDay(log, today, habits){ @@ -1238,8 +1235,6 @@ var label = 'Today Β· ' + d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); if (isPerfectDay(state.log, today, state.habits)) label += ' β¨'; q('todayLabel').textContent = label; - var streak = computeStreak(state.log, today); - q('streakLabel').textContent = streak > 0 ? ('π₯ ' + streak + '-day streak') : 'π₯ Start your streak today'; } function renderHabits(){ @@ -1265,6 +1260,13 @@ nameSpan.textContent = h.name; label.appendChild(emojiSpan); label.appendChild(nameSpan); + var streak = currentHabitStreak(state.log, h.id, today); + if (streak > 0) { + var streakSpan = document.createElement('span'); + streakSpan.className = 'habit-streak'; + streakSpan.textContent = 'π₯' + streak; + label.appendChild(streakSpan); + } var cb = document.createElement('input'); cb.type = 'checkbox'; @@ -1283,7 +1285,6 @@ } function renderStats(){ - q('statStreak').textContent = computeStreak(state.log, todayStr()); q('statChecks').textContent = totalChecksAllTime(state.log); q('statImages').textContent = state.gallery.filter(function(g){ return g.complete; }).length; } @@ -1325,7 +1326,7 @@ list.splice(idx, 1); if (list.length === 0) delete state.log[today]; // don't keep empty day arrays around // Reveal is derived from the log, so removing today's entry only shrinks - // today's triangular contribution β past days stay revealed automatically. + // that habit's contribution for today β past days stay revealed automatically. saveNow(); renderHabits(); renderArt(); renderStats(); renderTodayHeader(); }