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 +
FisherYates) fixes a random reveal order that stays stable across sessions.
Checking off a habit reveals `pixelsPerCheck` pixels — sized so a typical image
finishes in about 30 days of consistency. 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.
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 1648 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

@ -5,10 +5,10 @@ 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, `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. |
| **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
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.
- 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,
(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
@ -70,13 +74,30 @@ 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:** 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**:
`max(1, round(opaqueCount / (habitCount * 30)))` — so a typical image finishes
in about a month of consistency. Locked for the life of the image even if
habits change (prevents gaming/confusion).
- `checks` counter on the image: +1 per habit check, 1 on same-day uncheck
(clamped ≥ 0 and never below checks from previous days — derive today's floor
from the log). Revealed pixel count = `min(opaqueCount, checks * pixelsPerCheck)`.
`max(1, round(opaqueCount / (triangular(30) · triangular(habitCount))))`
calibrated so a "perfect month" (all habits every day for 30 days, whose
streak-weighted units total `triangular(30) · triangular(habitCount)`) just
fills the image. Locked for the life of the image even if habits change
(prevents gaming/confusion). Clamped to ≥1 so the very first check always
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
cells show their color and **pop in with a tiny scale/settle animation** when
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
"seed": 123456789,
"pixelsPerCheck": 2,
"checks": 37,
"startedAt": "2026-07-01"
},
"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
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 +
object URL. Also a "Copy to clipboard" fallback.
- **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)
1. First run / empty state → onboarding.
2. Same-day uncheck cannot reduce `checks` below the floor established by
previous days' log entries.
2. Same-day uncheck only shrinks today's (streak-multiplied) contribution;
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.
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
warns that progress won't persist.
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; }
.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 ---- */
.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; }
@ -290,6 +294,10 @@
<div class="stat-chip">🖼️ <span id="statImages">0</span><small>finished</small></div>
</section>
</main>
<footer class="app-footer">
Written with Claude Code · Sonnet 5, Opus 4.8 &amp; Fable 5
</footer>
</div>
<!-- ===================== ONBOARDING ===================== -->
@ -320,8 +328,26 @@
<div id="obStep2" hidden>
<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="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">
<button id="obBackTo1" class="btn" type="button">← Back</button>
<button id="obStart" class="btn btn-primary" type="button" disabled>Start painting</button>
@ -339,7 +365,7 @@
<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="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>
</div>
<div class="dialog-body">
@ -386,7 +412,7 @@
<div class="gallery-grid" id="galleryGrid"></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>
<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>
@ -394,15 +420,27 @@
<div class="section-title">Import</div>
<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">
<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 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
picture reveal itself, one tiny square at a time. Missing a day never erases progress —
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
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>
</dialog>
@ -453,7 +491,6 @@
pixels: img.pixels.slice(),
seed: (img.seed == null ? 1 : (img.seed >>> 0)),
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()
};
}
@ -595,16 +632,58 @@
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){
var order = getRevealOrder(image);
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));
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){
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(){
@ -636,7 +715,6 @@
pixels: def.pixels.slice(),
seed: randomSeed(),
pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount),
checks: 0,
startedAt: todayStr()
};
}
@ -684,14 +762,6 @@
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){
var streak = 0;
var cursor = today;
@ -1239,11 +1309,12 @@
var already = idx !== -1;
if (wantChecked && !already) {
list.push(habitId);
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) {
var before = getRevealInfo(state.image).revealedCount;
state.image.checks += 1;
var afterInfo = getRevealInfo(state.image);
delta = afterInfo.revealedCount - before;
if (delta > 0) triggerPixelPop(afterInfo.order.slice(before, afterInfo.revealedCount));
@ -1266,10 +1337,8 @@
} else if (!wantChecked && already) {
list.splice(idx, 1);
if (list.length === 0) delete state.log[today]; // don't keep empty day arrays around
if (state.image) {
var floor = computeFloor(state.image, state.log, today);
state.image.checks = Math.max(floor, state.image.checks - 1);
}
// Reveal is derived from the log, so removing today's entry only shrinks
// today's triangular contribution — past days stay revealed automatically.
saveNow();
renderHabits(); renderArt(); renderStats(); renderTodayHeader();
}
@ -1316,7 +1385,8 @@
}
/* ---- 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(){
populateEmojiSelect(q('obHabitEmoji'));
@ -1357,13 +1427,60 @@
grid.innerHTML = '';
PRESETS.forEach(function(def){
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'); });
tile.classList.add('selected');
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){
if (ev.key === 'Enter') { ev.preventDefault(); q('obAddHabit').click(); }
@ -1387,10 +1504,17 @@
q('dotStep2').classList.remove('active');
});
q('obStart').addEventListener('click', function(){
if (!obSelectedPreset) return;
startNewImage(obSelectedPreset);
var def = obSelectedDef;
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();
obSelectedPreset = null;
obSelectedDef = null; obUploadResult = null;
render();
});
@ -1529,7 +1653,12 @@
lastUploadResult = result;
var habitCount = Math.max(1, state.habits.length);
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, ' +
'checking in daily (' + opaque + ' opaque pixels).';
q('useUploadBtn').disabled = false;
@ -1637,6 +1766,22 @@
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 ---- */
var currentDay = todayStr();
function checkRollover(){

Loading…
Cancel
Save