feat(bit-by-bit): streak-multiplier rewards, Data tab with wipe, footer, onboarding upload [vibed-nf6g]

- Reward is now streak-multiplied: units per day = streakOnDay(d) × triangular(checks);
  recalibrate pixelsPerCheck (÷ triangular(30)) and the upload finish-day estimate.
- Rename the "Backup" settings tab to "Data" and add a double-confirm "Wipe all data" action.
- Add a page footer disclosing authorship (Claude Code — Sonnet 5, Opus 4.8 & Fable 5).
- About page now explains exactly how pixels are earned (per-check escalation + streak multiplier).
- Allow uploading an image during first-run onboarding, not just presets.
- Update DESIGN.md, ARCHITECTURE.md, README.md to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Elijah Voigt 1 week ago
parent 757d6ad6f5
commit 1259e28865

@ -0,0 +1,37 @@
---
# vibed-nf6g
title: Footer, Data tab with wipe, streak-multiplier rewards, About pixel rules
status: completed
type: feature
priority: normal
created_at: 2026-07-14T23:41:06Z
updated_at: 2026-07-15T00:10:14Z
---
Four changes to bit-by-bit index.html + DESIGN.md:
1. Add a page footer disclosing it was written with Claude Code (Sonnet 5, Opus 4.8, Fable 5).
2. Rename the 'Backup' settings tab to 'Data' and add a 'wipe all data' option there.
3. Streak becomes a reward multiplier: units per day = streak-on-that-day x triangular(checks). Recalibrate pixelsPerCheck and the finish estimate; update DESIGN.md.
4. About page: describe specifically how earning pixels works (per-check escalation + streak multiplier), encouraging finishing the whole day.
## Summary of Changes
All four changes landed in `index.html` (+ `docs/DESIGN.md`, `docs/ARCHITECTURE.md`, `README.md`):
1. **Footer** — added `<footer class="app-footer">` (plain text, no external link) disclosing 'Written with Claude Code · Sonnet 5, Opus 4.8 & Fable 5'.
2. **Data tab + wipe** — renamed the 'Backup' settings tab to 'Data' (data-tab/panel id `data`), added a 'Danger zone' with a double-confirm 'Wipe all data' button that clears the localStorage keys, resets to `defaultState()`, and returns to first-run onboarding.
3. **Streak multiplier** — reveal units per day are now `streakOnDay(d) × triangular(checks)` (new `streakOnDay` helper). Recalibrated `pixelsPerCheckFor` to divide by `triangular(30) × triangular(habitCount)`, and fixed the upload finish-day estimate to solve the triangular streak accumulation.
4. **About page** — added two paragraphs describing exactly how pixels are earned (per-check escalation + streak multiplier) and encouraging finishing the whole day's list; updated the version line to name all three models.
## Validation
- `grep` for `http://|https://|fetch(` → no external references.
- Playwright headless run: fresh load shows onboarding + footer; seeded 3-consecutive-day log on a 36px image renders '6 / 36 pixels' (confirms 1+2+3 streak-multiplied units, not the old flat 3); settings tabs are Habits/Artwork/Data/About (no Backup); wipe button clears localStorage and returns to onboarding; **zero console errors, zero non-local network requests**.
## Follow-up: upload during onboarding
The onboarding 'Pick your first picture' step only offered presets — no way to upload an image on first setup (upload previously lived only in Settings → Artwork). Added the upload pipeline to onboarding step 2:
- New file input + grid-size select + name field + live preview/estimate in `#obStep2`, reusing `downsampleImageFile`/`renderThumbnail`.
- Generalized the onboarding selection from `obSelectedPreset` to `obSelectedDef` (preset or upload) plus `obUploadResult`; preset and upload selections mutually supersede each other; 'Start painting' commits either.
- Validated via Playwright: uploading a PNG in onboarding shows the preview + '~N days' estimate, enables Start, and starts a 24×24 `source:'upload'` artwork named from the field — zero console errors, zero network requests. Preset/footer/data/wipe/streak regression suite still green. Updated DESIGN.md §2.

@ -18,10 +18,16 @@ next one.
Each artwork is a pixel grid. On start, a seeded PRNG (mulberry32 + Each artwork is a pixel grid. On start, a seeded PRNG (mulberry32 +
FisherYates) fixes a random reveal order that stays stable across sessions. FisherYates) fixes a random reveal order that stays stable across sessions.
Checking off a habit reveals `pixelsPerCheck` pixels — sized so a typical image Checking off habits reveals pixels, and **the more you do in a single day, the
finishes in about 30 days of consistency. Pixels never un-reveal across days; bigger the reward** — the first habit each day is worth 1 unit, the second 2,
missing a day just pauses progress. Includes 8 built-in pixel artworks, or the third 3, and so on (so a 4-habit day earns 1 + 2 + 3 + 4 = 10 units).
upload any image to pixelate it into a 1648 cell grid. **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 1648 cell grid.
Dark/light theme follows your OS by default, with a manual override. Streaks 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 count any day you checked at least one habit; days where you did everything get

@ -5,10 +5,10 @@ Everything lives in one file, `index.html`, organized into sections:
| Component | Responsibility | | Component | Responsibility |
|---|---| |---|---|
| **State** | Single JSON document under localStorage key `bitbybit.v1`; load/save with corruption guards; versioned for future migration. | | **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, `pixelsPerCheck` and revealed-pixel derivation. | | **Mechanics** | Habit check/uncheck logging, daily rollover, streak math, seeded (mulberry32) reveal-order permutation, `pixelsPerCheck` calibration, and log-derived reveal: each day earns `triangular(checks)` base units (the Nth check is worth N), multiplied by that day's streak length. |
| **Artwork** | Preset pixel-art definitions (palette + row strings); upload pipeline (offscreen canvas downsample → pixel grid); gallery of finished pieces. | | **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. | | **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 / backup), theme cycling (auto→light→dark via `data-theme` + CSS custom properties), export/import, confetti celebration. | | **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. |
Data flows one way: user action → mutate state → persist → re-render. The Data flows one way: user action → mutate state → persist → re-render. The
reveal permutation is derived from a stored seed, never persisted, so exports reveal permutation is derived from a stored seed, never persisted, so exports

@ -48,9 +48,13 @@ stay revealed.
- Max content width ~28rem, centered; scales up gracefully on desktop. - Max content width ~28rem, centered; scales up gracefully on desktop.
- Settings (⚙️) opens a panel/modal with sections: **Habits**, **Artwork**, - Settings (⚙️) opens a panel/modal with sections: **Habits**, **Artwork**,
**Backup** (export/import), **About**. Use `<dialog>` or an equivalent overlay. **Data** (export/import plus a destructive "wipe all data" action), **About**.
Use `<dialog>` or an equivalent overlay.
- A small page **footer** discloses authorship ("Written with Claude Code ·
Sonnet 5, Opus 4.8 & Fable 5"), plain text with no external link.
- First visit (no saved state): a friendly 2-step onboarding — (1) add habits, - First visit (no saved state): a friendly 2-step onboarding — (1) add habits,
(2) pick an artwork — then straight into the main screen. (2) pick an artwork (a preset **or** an uploaded image, same upload pipeline as
settings) — then straight into the main screen.
## 3. Core Mechanics ## 3. Core Mechanics
@ -70,13 +74,30 @@ stay revealed.
- On image start, generate a random permutation of opaque pixel indices using a - 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 — **seeded PRNG (mulberry32)** with a stored seed. This is the reveal order —
stable across sessions and re-renders. stable across sessions and re-renders.
- **Escalating daily reward:** the _Nth_ habit checked on a given day is worth
`N` base units (1 + 2 + 3 + … ), so a day of `k` checks earns `triangular(k) =
k·(k+1)/2` base units. 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 units 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 is worth `3`
units 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.
- `pixelsPerCheck` is computed **once, at image start**: - `pixelsPerCheck` is computed **once, at image start**:
`max(1, round(opaqueCount / (habitCount * 30)))` — so a typical image finishes `max(1, round(opaqueCount / (triangular(30) · triangular(habitCount))))`
in about a month of consistency. Locked for the life of the image even if calibrated so a "perfect month" (all habits every day for 30 days, whose
habits change (prevents gaming/confusion). streak-weighted units total `triangular(30) · triangular(habitCount)`) just
- `checks` counter on the image: +1 per habit check, 1 on same-day uncheck fills the image. Locked for the life of the image even if habits change
(clamped ≥ 0 and never below checks from previous days — derive today's floor (prevents gaming/confusion). Clamped to ≥1 so the very first check always
from the log). Revealed pixel count = `min(opaqueCount, checks * pixelsPerCheck)`. reveals at least one pixel; small images simply fill in sooner than a month.
- **Revealed pixels are derived from the log, not stored.** Revealed unit total =
`Σ streakLen(d) · triangular(len(log[d]))` over days `d ≥ startedAt`; revealed
pixel count = `min(opaqueCount, floor(units · pixelsPerCheck))`. 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.
- Unrevealed cells render as a subtle dim checkerboard ("static"). Revealed - Unrevealed cells render as a subtle dim checkerboard ("static"). Revealed
cells show their color and **pop in with a tiny scale/settle animation** when cells show their color and **pop in with a tiny scale/settle animation** when
newly revealed (animate only the delta, not on every render). newly revealed (animate only the delta, not on every render).
@ -127,7 +148,6 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
"pixels": [null, "#e84d4d", ...], // row-major, length w*h, null = transparent "pixels": [null, "#e84d4d", ...], // row-major, length w*h, null = transparent
"seed": 123456789, "seed": 123456789,
"pixelsPerCheck": 2, "pixelsPerCheck": 2,
"checks": 37,
"startedAt": "2026-07-01" "startedAt": "2026-07-01"
}, },
"log": { // immutable history of check-offs "log": { // immutable history of check-offs
@ -181,9 +201,12 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
- System font stack; headers get a chunky "pixel-ish" treatment via - System font stack; headers get a chunky "pixel-ish" treatment via
letter-spacing/weight (no embedded fonts — keep the file lean). letter-spacing/weight (no embedded fonts — keep the file lean).
## 7. Export / Import ## 7. Export / Import / Wipe
- **Export:** button in Backup settings → downloads - **Wipe all data:** a clearly-marked destructive button in the **Data** settings
section (styled as danger). Double-confirms, then removes the localStorage keys,
resets in-memory state to a clean slate, and returns to first-run onboarding.
- **Export:** button in Data settings → downloads
`bit-by-bit-backup-YYYY-MM-DD.json` (pretty-printed full state) via Blob + `bit-by-bit-backup-YYYY-MM-DD.json` (pretty-printed full state) via Blob +
object URL. Also a "Copy to clipboard" fallback. object URL. Also a "Copy to clipboard" fallback.
- **Import:** file picker (accept `.json,application/json`) → parse → validate - **Import:** file picker (accept `.json,application/json`) → parse → validate
@ -206,8 +229,9 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
## 9. Edge Cases (must handle) ## 9. Edge Cases (must handle)
1. First run / empty state → onboarding. 1. First run / empty state → onboarding.
2. Same-day uncheck cannot reduce `checks` below the floor established by 2. Same-day uncheck only shrinks today's (streak-multiplied) contribution;
previous days' log entries. 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 3. Habit deleted after being checked today → today's log keeps the id; counts
still work. still work.
4. Day rollover with tab open → checklist resets without reload. 4. Day rollover with tab open → checklist resets without reload.
@ -215,4 +239,7 @@ Single localStorage key: `bitbybit.v1`. Entire state is one JSON document:
6. localStorage full or unavailable (private mode) → app still renders; banner 6. localStorage full or unavailable (private mode) → app still renders; banner
warns that progress won't persist. warns that progress won't persist.
7. Image with very few opaque pixels (e.g., 20) finishes early — fine; celebrate. 7. Image with very few opaque pixels (e.g., 20) finishes early — fine; celebrate.
8. `pixelsPerCheck` overshoot on final check just clamps to 100%. 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).

@ -146,6 +146,10 @@
text-align:center; box-shadow:var(--shadow); font-weight:700; font-size:0.95rem; } text-align:center; box-shadow:var(--shadow); font-weight:700; font-size:0.95rem; }
.stat-chip small{ display:block; font-weight:500; color:var(--text-dim); font-size:0.7rem; margin-top:2px; } .stat-chip small{ display:block; font-weight:500; color:var(--text-dim); font-size:0.7rem; margin-top:2px; }
/* ---- footer ---- */
.app-footer{ text-align:center; color:var(--text-dim); font-size:0.72rem; line-height:1.5;
padding:var(--space-2) 0 var(--space-1); }
/* ---- buttons ---- */ /* ---- buttons ---- */
.btn{ min-height:44px; padding:0 var(--space-4); border-radius:var(--radius-md); border:1px solid var(--border); .btn{ min-height:44px; padding:0 var(--space-4); border-radius:var(--radius-md); border:1px solid var(--border);
background:var(--bg-sunken); cursor:pointer; font-weight:600; white-space:nowrap; } background:var(--bg-sunken); cursor:pointer; font-weight:600; white-space:nowrap; }
@ -290,6 +294,10 @@
<div class="stat-chip">🖼️ <span id="statImages">0</span><small>finished</small></div> <div class="stat-chip">🖼️ <span id="statImages">0</span><small>finished</small></div>
</section> </section>
</main> </main>
<footer class="app-footer">
Written with Claude Code · Sonnet 5, Opus 4.8 &amp; Fable 5
</footer>
</div> </div>
<!-- ===================== ONBOARDING ===================== --> <!-- ===================== ONBOARDING ===================== -->
@ -320,8 +328,26 @@
<div id="obStep2" hidden> <div id="obStep2" hidden>
<h2 class="onboarding-title">Pick your first picture 🎨</h2> <h2 class="onboarding-title">Pick your first picture 🎨</h2>
<p class="onboarding-sub">Every habit you check off reveals a few more pixels. Choose one to start uncovering.</p> <p class="onboarding-sub">Choose a preset — or upload your own. Every habit you check off reveals a few more pixels.</p>
<div class="art-grid" id="obArtGrid"></div> <div class="art-grid" id="obArtGrid"></div>
<div class="section-title">Or upload your own</div>
<p class="hint">Any image works — it'll be pixelated to fit. Nothing leaves your device.</p>
<div class="field-row">
<input type="file" id="obUploadFile" accept="image/*" aria-label="Choose image to upload">
<select id="obUploadGridSize" aria-label="Grid size">
<option value="16">16×16</option>
<option value="24" selected>24×24</option>
<option value="32">32×32</option>
<option value="48">48×48</option>
</select>
</div>
<div class="field-row">
<input type="text" id="obUploadName" maxlength="40" placeholder="Name this artwork" aria-label="Artwork name">
</div>
<div class="upload-preview" id="obUploadPreviewRow" hidden>
<canvas id="obUploadPreviewCanvas"></canvas>
<div id="obUploadEstimate" class="hint"></div>
</div>
<div class="wizard-actions"> <div class="wizard-actions">
<button id="obBackTo1" class="btn" type="button">← Back</button> <button id="obBackTo1" class="btn" type="button">← Back</button>
<button id="obStart" class="btn btn-primary" type="button" disabled>Start painting</button> <button id="obStart" class="btn btn-primary" type="button" disabled>Start painting</button>
@ -339,7 +365,7 @@
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button class="tab-btn" data-tab="habits" role="tab" aria-selected="true" type="button">Habits</button> <button class="tab-btn" data-tab="habits" role="tab" aria-selected="true" type="button">Habits</button>
<button class="tab-btn" data-tab="artwork" role="tab" aria-selected="false" type="button">Artwork</button> <button class="tab-btn" data-tab="artwork" role="tab" aria-selected="false" type="button">Artwork</button>
<button class="tab-btn" data-tab="backup" role="tab" aria-selected="false" type="button">Backup</button> <button class="tab-btn" data-tab="data" role="tab" aria-selected="false" type="button">Data</button>
<button class="tab-btn" data-tab="about" role="tab" aria-selected="false" type="button">About</button> <button class="tab-btn" data-tab="about" role="tab" aria-selected="false" type="button">About</button>
</div> </div>
<div class="dialog-body"> <div class="dialog-body">
@ -386,7 +412,7 @@
<div class="gallery-grid" id="galleryGrid"></div> <div class="gallery-grid" id="galleryGrid"></div>
</div> </div>
<div class="tab-panel" id="panel-backup" hidden role="tabpanel"> <div class="tab-panel" id="panel-data" hidden role="tabpanel">
<div class="section-title">Export</div> <div class="section-title">Export</div>
<p class="hint">Download a full backup, or copy it to your clipboard.</p> <p class="hint">Download a full backup, or copy it to your clipboard.</p>
<button id="exportBtn" class="btn btn-primary btn-block" type="button">⬇ Download backup</button> <button id="exportBtn" class="btn btn-primary btn-block" type="button">⬇ Download backup</button>
@ -394,15 +420,27 @@
<div class="section-title">Import</div> <div class="section-title">Import</div>
<p class="hint">Choose a Paint by Habits backup file to restore. This replaces everything currently saved.</p> <p class="hint">Choose a Paint by Habits backup file to restore. This replaces everything currently saved.</p>
<input type="file" id="importFile" accept=".json,application/json" aria-label="Choose backup file to import"> <input type="file" id="importFile" accept=".json,application/json" aria-label="Choose backup file to import">
<div class="section-title">Danger zone</div>
<p class="hint">Permanently erase every habit, all your check-off history, your current
artwork and your whole gallery. This can't be undone — export a backup first if you
might want it back.</p>
<button id="wipeDataBtn" class="btn btn-danger btn-block" type="button">🗑 Wipe all data</button>
</div> </div>
<div class="tab-panel about-text" id="panel-about" hidden role="tabpanel"> <div class="tab-panel about-text" id="panel-about" hidden role="tabpanel">
<p>Paint by Habits is a cozy pixel-art habit tracker. Check off your daily habits and watch a hidden <p>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 — picture reveal itself, one tiny square at a time. Missing a day never erases progress —
pixels stay revealed forever.</p> pixels stay revealed forever.</p>
<p><strong>How you earn pixels:</strong> every habit you check off reveals more of the picture — so
the more you do, the more appears. Each check within a day is worth more than the last: your 1st
check paints a little, your 2nd paints more, your 3rd more still. Checking off your <em>whole</em>
list in a day is the sweet spot.</p>
<p><strong>Streaks multiply everything.</strong> Every consecutive day you show up bumps the multiplier:
on a 3-day streak your 1st check of the day is worth 3× and your 2nd is worth 6×; on a 5-day streak
they're worth 5× and 10×. Come back tomorrow and finish the day's list, and the picture floods in.</p>
<p>Everything lives only in this browser's local storage, on this device. Nothing is ever sent <p>Everything lives only in this browser's local storage, on this device. Nothing is ever sent
anywhere.</p> anywhere.</p>
<p>Version 1 · Written with Claude Sonnet 5.</p> <p>Version 1 · Written with Claude Code — Sonnet 5, Opus 4.8 &amp; Fable 5.</p>
</div> </div>
</div> </div>
</dialog> </dialog>
@ -453,7 +491,6 @@
pixels: img.pixels.slice(), pixels: img.pixels.slice(),
seed: (img.seed == null ? 1 : (img.seed >>> 0)), seed: (img.seed == null ? 1 : (img.seed >>> 0)),
pixelsPerCheck: Math.max(1, img.pixelsPerCheck | 0 || 1), pixelsPerCheck: Math.max(1, img.pixelsPerCheck | 0 || 1),
checks: Math.max(0, img.checks | 0),
startedAt: /^\d{4}-\d{2}-\d{2}$/.test(img.startedAt) ? img.startedAt : todayStr() startedAt: /^\d{4}-\d{2}-\d{2}$/.test(img.startedAt) ? img.startedAt : todayStr()
}; };
} }
@ -595,16 +632,58 @@
return order; return order;
} }
// Escalating daily reward with a streak multiplier:
// • Within a day, the Nth habit checked is worth N "units" (1 + 2 + 3 + … =
// a triangular number), so k checks in a day earn triangular(k) base units.
// • The whole day's units are then multiplied by that day's streak length
// (consecutive active days ending on it): on a 3-day streak the 1st check
// is worth 3 units, the 2nd 6, and so on.
// 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; }
// 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){
var streak = 0, cursor = day;
while (log[cursor] && log[cursor].length > 0) {
streak++;
cursor = addDaysStr(cursor, -1);
}
return streak;
}
function revealedUnits(image){
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);
}
}
return units;
}
function getRevealInfo(image){ function getRevealInfo(image){
var order = getRevealOrder(image); var order = getRevealOrder(image);
var opaque = order.length; var opaque = order.length;
var revealedCount = Math.min(opaque, image.checks * image.pixelsPerCheck); var revealedCount = Math.min(opaque, Math.floor(revealedUnits(image) * image.pixelsPerCheck));
var set = new Set(order.slice(0, revealedCount)); var set = new Set(order.slice(0, revealedCount));
return { order: order, opaque: opaque, revealedCount: revealedCount, set: set }; return { order: order, opaque: opaque, revealedCount: revealedCount, set: set };
} }
// Pixels revealed per unit, fixed at image start. Calibrated so a "perfect
// month" — checking all habits every day for 30 days — just fills the image.
// With the streak multiplier a perfect month earns
// Σ_{day=1..30} day × triangular(habitCount) = triangular(30) × triangular(habitCount)
// units, so pixelsPerCheck ≈ opaqueCount / (triangular(30) × triangular(habitCount)).
// Clamped to ≥1 so the very first check always reveals at least one pixel;
// small images just fill in comfortably sooner than a month.
function pixelsPerCheckFor(opaqueCount, habitCount){ function pixelsPerCheckFor(opaqueCount, habitCount){
return Math.max(1, Math.round(opaqueCount / (Math.max(1, habitCount) * 30))); var perfectMonthUnits = triangular(30) * triangular(Math.max(1, habitCount));
return Math.max(1, Math.round(opaqueCount / perfectMonthUnits));
} }
function randomSeed(){ function randomSeed(){
@ -636,7 +715,6 @@
pixels: def.pixels.slice(), pixels: def.pixels.slice(),
seed: randomSeed(), seed: randomSeed(),
pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount), pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount),
checks: 0,
startedAt: todayStr() startedAt: todayStr()
}; };
} }
@ -684,14 +762,6 @@
return new Date(p[0], p[1] - 1, p[2]); return new Date(p[0], p[1] - 1, p[2]);
} }
function computeFloor(image, log, today){
var floor = 0;
for (var d in log) {
if (d < today && d >= image.startedAt) floor += (log[d] || []).length;
}
return floor;
}
function computeStreak(log, today){ function computeStreak(log, today){
var streak = 0; var streak = 0;
var cursor = today; var cursor = today;
@ -1239,11 +1309,12 @@
var already = idx !== -1; var already = idx !== -1;
if (wantChecked && !already) { if (wantChecked && !already) {
list.push(habitId);
var delta = 0; var delta = 0;
// Snapshot the revealed count BEFORE logging the check — reveal is now
// derived from the log, so the delta is (after log) (before log).
var before = state.image ? getRevealInfo(state.image).revealedCount : 0;
list.push(habitId);
if (state.image) { if (state.image) {
var before = getRevealInfo(state.image).revealedCount;
state.image.checks += 1;
var afterInfo = getRevealInfo(state.image); var afterInfo = getRevealInfo(state.image);
delta = afterInfo.revealedCount - before; delta = afterInfo.revealedCount - before;
if (delta > 0) triggerPixelPop(afterInfo.order.slice(before, afterInfo.revealedCount)); if (delta > 0) triggerPixelPop(afterInfo.order.slice(before, afterInfo.revealedCount));
@ -1266,10 +1337,8 @@
} else if (!wantChecked && already) { } else if (!wantChecked && already) {
list.splice(idx, 1); list.splice(idx, 1);
if (list.length === 0) delete state.log[today]; // don't keep empty day arrays around if (list.length === 0) delete state.log[today]; // don't keep empty day arrays around
if (state.image) { // Reveal is derived from the log, so removing today's entry only shrinks
var floor = computeFloor(state.image, state.log, today); // today's triangular contribution — past days stay revealed automatically.
state.image.checks = Math.max(floor, state.image.checks - 1);
}
saveNow(); saveNow();
renderHabits(); renderArt(); renderStats(); renderTodayHeader(); renderHabits(); renderArt(); renderStats(); renderTodayHeader();
} }
@ -1316,7 +1385,8 @@
} }
/* ---- onboarding ---- */ /* ---- onboarding ---- */
var obSelectedPreset = null; var obSelectedDef = null; // chosen artwork def (preset or upload)
var obUploadResult = null; // last successful upload downsample { w, h, pixels }
function renderOnboardingStep(){ function renderOnboardingStep(){
populateEmojiSelect(q('obHabitEmoji')); populateEmojiSelect(q('obHabitEmoji'));
@ -1357,14 +1427,61 @@
grid.innerHTML = ''; grid.innerHTML = '';
PRESETS.forEach(function(def){ PRESETS.forEach(function(def){
grid.appendChild(buildArtTile(def, function(d, tile){ grid.appendChild(buildArtTile(def, function(d, tile){
obSelectedPreset = d; obSelectedDef = d;
clearObUpload(); // picking a preset supersedes any pending upload
Array.prototype.forEach.call(grid.children, function(c){ c.classList.remove('selected'); }); Array.prototype.forEach.call(grid.children, function(c){ c.classList.remove('selected'); });
tile.classList.add('selected'); tile.classList.add('selected');
q('obStart').disabled = false; q('obStart').disabled = false;
}, obSelectedPreset === def)); }, false));
}); });
} }
// Reset the onboarding upload controls (file, preview) without touching a
// preset selection.
function clearObUpload(){
obUploadResult = null;
q('obUploadFile').value = '';
q('obUploadPreviewRow').hidden = true;
}
function obUpdateUploadPreview(){
var file = q('obUploadFile').files[0];
var gridSize = parseInt(q('obUploadGridSize').value, 10);
if (!file) return;
downsampleImageFile(file, gridSize).then(function(result){
var opaque = result.pixels.filter(function(p){ return p != null; }).length;
q('obUploadPreviewRow').hidden = false;
renderThumbnail(q('obUploadPreviewCanvas'), result, 72);
if (opaque === 0) {
// A fully-transparent grid could never be completed — block it.
obUploadResult = null;
obSelectedDef = null;
q('obStart').disabled = true;
q('obUploadEstimate').textContent =
'This one came out fully transparent — nothing to reveal! Try a different image or a bigger grid.';
return;
}
obUploadResult = result;
// Selecting an upload supersedes any highlighted preset tile.
Array.prototype.forEach.call(q('obArtGrid').children, function(c){ c.classList.remove('selected'); });
obSelectedDef = { name: '', source: 'upload', w: result.w, h: result.h, pixels: result.pixels };
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount);
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('obUploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') +
' to finish (' + opaque + ' opaque pixels).';
q('obStart').disabled = false;
}).catch(function(){
obUploadResult = null;
if (obSelectedDef && obSelectedDef.source === 'upload') { obSelectedDef = null; q('obStart').disabled = true; }
q('obUploadPreviewRow').hidden = true;
window.alert("Hmm, that didn't look like an image Paint by Habits could read. Try a different file?");
});
}
q('obUploadFile').addEventListener('change', obUpdateUploadPreview);
q('obUploadGridSize').addEventListener('change', obUpdateUploadPreview);
q('obHabitName').addEventListener('keydown', function(ev){ q('obHabitName').addEventListener('keydown', function(ev){
if (ev.key === 'Enter') { ev.preventDefault(); q('obAddHabit').click(); } if (ev.key === 'Enter') { ev.preventDefault(); q('obAddHabit').click(); }
}); });
@ -1387,10 +1504,17 @@
q('dotStep2').classList.remove('active'); q('dotStep2').classList.remove('active');
}); });
q('obStart').addEventListener('click', function(){ q('obStart').addEventListener('click', function(){
if (!obSelectedPreset) return; var def = obSelectedDef;
startNewImage(obSelectedPreset); if (!def) return;
if (def.source === 'upload') {
if (!obUploadResult) return;
// Read the (possibly just-typed) name at commit time.
var name = (q('obUploadName').value || '').trim().slice(0, 40) || 'My Upload';
def = { name: name, source: 'upload', w: obUploadResult.w, h: obUploadResult.h, pixels: obUploadResult.pixels };
}
startNewImage(def);
saveNow(); saveNow();
obSelectedPreset = null; obSelectedDef = null; obUploadResult = null;
render(); render();
}); });
@ -1529,7 +1653,12 @@
lastUploadResult = result; lastUploadResult = result;
var habitCount = Math.max(1, state.habits.length); var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, habitCount); var ppc = pixelsPerCheckFor(opaque, habitCount);
var estDays = Math.max(1, Math.ceil(opaque / (ppc * habitCount))); // With the streak multiplier, D perfect days in a row reveal
// ppc × triangular(habitCount) × triangular(D) pixels
// (each day's units scaled by its streak length 1..D). Solve
// triangular(D) ≥ opaque / (ppc × triangular(habitCount)) for D.
var targetTri = opaque / (ppc * triangular(habitCount));
var estDays = Math.max(1, Math.ceil((Math.sqrt(1 + 8 * targetTri) - 1) / 2));
q('uploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') + ' to finish, ' + q('uploadEstimate').textContent = '~' + estDays + ' day' + (estDays === 1 ? '' : 's') + ' to finish, ' +
'checking in daily (' + opaque + ' opaque pixels).'; 'checking in daily (' + opaque + ' opaque pixels).';
q('useUploadBtn').disabled = false; q('useUploadBtn').disabled = false;
@ -1637,6 +1766,22 @@
reader.readAsText(file); reader.readAsText(file);
}); });
/* ---- data: wipe everything ---- */
q('wipeDataBtn').addEventListener('click', function(){
if (!window.confirm('Wipe ALL Paint by Habits data — every habit, all check-off history, ' +
'your current artwork and your whole gallery? This cannot be undone.')) return;
if (!window.confirm('Last chance: really erase everything and start over?')) return;
// Remove the persisted keys and reset the in-memory state to a clean slate,
// then treat the session as a genuine first run (shows onboarding again).
try { localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(CORRUPT_KEY); } catch (e) { /* ignore */ }
state = defaultState();
isFirstRun = true;
resetArtRuntime();
settingsDialog.close();
applyTheme();
render();
});
/* ---- day rollover & resize ---- */ /* ---- day rollover & resize ---- */
var currentDay = todayStr(); var currentDay = todayStr();
function checkRollover(){ function checkRollover(){

Loading…
Cancel
Save