@ -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 & 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 & 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; obUploadResul t = 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(){