You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1818 lines
82 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Paint by Habits — pixel habit tracker</title>
<!-- Redrawn at runtime by updateFavicon() as a data-URL PNG of the current artwork. -->
<link id="favicon" rel="icon" href="data:image/png;base64,iVBORw0KGgo=">
<link id="faviconApple" rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgo=">
<style>
/* ============================= THEME TOKENS ============================= */
/* Defaults on plain :root so the page has sane colors before JS sets
data-theme (no FOUC). Light is the base; the media query below flips the
pre-JS default to dark for dark-scheme users. The [data-theme] blocks win
once JS runs (higher specificity). */
:root{
--radius-sm:6px; --radius-md:12px; --radius-lg:18px;
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:24px; --space-6:32px;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--bg:#faf6ef; --bg-elevated:#ffffff; --bg-sunken:#f1ebe0;
--text:#2f2a24; --text-dim:#746a5d; --border:#e6dcc9;
--accent:#ff6f91; --accent-strong:#e04b70; --accent-soft:#ffe3ec;
--accent2:#6a4fd0; --success:#2f9e5b; --danger:#c23b3b; --warning:#8a5a10; --warning-bg:#fbe7c4;
--canvas-bg:#e8e1d2; --canvas-static-a:#dcd3bf; --canvas-static-b:#e8e1d2;
--shadow:0 4px 14px rgba(80,55,20,0.12); --focus-ring:#6a4fd0;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#1b1926; --bg-elevated:#252231; --bg-sunken:#16141e;
--text:#f1ece3; --text-dim:#b3a9c9; --border:#3a3548;
--accent:#ff9bb3; --accent-strong:#ff7096; --accent-soft:#3a2333;
--accent2:#9a8cff; --success:#5cd68a; --danger:#ff7b7b; --warning:#ffd27a; --warning-bg:#4a3a1a;
--canvas-bg:#2c283a; --canvas-static-a:#232032; --canvas-static-b:#2c283a;
--shadow:0 4px 18px rgba(0,0,0,0.45); --focus-ring:#9a8cff;
}
}
:root[data-theme="light"]{
--bg:#faf6ef; --bg-elevated:#ffffff; --bg-sunken:#f1ebe0;
--text:#2f2a24; --text-dim:#746a5d; --border:#e6dcc9;
--accent:#ff6f91; --accent-strong:#e04b70; --accent-soft:#ffe3ec;
--accent2:#6a4fd0; --success:#2f9e5b; --danger:#c23b3b; --warning:#8a5a10; --warning-bg:#fbe7c4;
--canvas-bg:#e8e1d2; --canvas-static-a:#dcd3bf; --canvas-static-b:#e8e1d2;
--shadow:0 4px 14px rgba(80,55,20,0.12); --focus-ring:#6a4fd0;
}
:root[data-theme="dark"]{
--bg:#1b1926; --bg-elevated:#252231; --bg-sunken:#16141e;
--text:#f1ece3; --text-dim:#b3a9c9; --border:#3a3548;
--accent:#ff9bb3; --accent-strong:#ff7096; --accent-soft:#3a2333;
--accent2:#9a8cff; --success:#5cd68a; --danger:#ff7b7b; --warning:#ffd27a; --warning-bg:#4a3a1a;
--canvas-bg:#2c283a; --canvas-static-a:#232032; --canvas-static-b:#2c283a;
--shadow:0 4px 18px rgba(0,0,0,0.45); --focus-ring:#9a8cff;
}
*{box-sizing:border-box;}
/* Author display rules (e.g. flex overlays) would otherwise beat the UA's
[hidden]{display:none}; !important keeps the hidden attribute authoritative,
including over inline styles. */
[hidden]{ display:none !important; }
html,body{margin:0; padding:0; width:100%; overflow-x:hidden;}
body{
background:var(--bg); color:var(--text); font-family:var(--font-body);
min-height:100vh; line-height:1.4;
}
button, input, select, textarea{ font:inherit; color:inherit; }
*:focus-visible{ outline:3px solid var(--focus-ring); outline-offset:2px; }
img, canvas{ max-width:100%; }
.app{ max-width:30rem; margin:0 auto; padding:var(--space-4); padding-bottom:72px;
display:flex; flex-direction:column; gap:var(--space-5); }
@media (min-width:640px){ .app{ max-width:34rem; padding:var(--space-6); } }
main{ display:flex; flex-direction:column; gap:var(--space-5); }
/* Desktop: artwork on the left, today + stats on the right, so the
checklist is visible without scrolling. */
@media (min-width:880px){
.app{ max-width:58rem; }
main{ display:grid; grid-template-columns:minmax(0,7fr) minmax(0,5fr);
gap:var(--space-5); align-items:start; }
.artwork-card{ grid-row:1 / span 2; position:sticky; top:var(--space-5); }
}
/* ---- header ---- */
.app-header{ display:flex; align-items:center; justify-content:space-between; gap:var(--space-3); }
.app-title{ font-size:clamp(1.15rem, 5vw, 1.5rem); font-weight:800; letter-spacing:0.04em;
margin:0; white-space:nowrap; }
.app-title .bloom{ color:var(--accent-strong); }
.header-actions{ display:flex; gap:var(--space-2); }
.icon-btn{
min-width:44px; min-height:44px; border-radius:var(--radius-md);
border:1px solid var(--border); background:var(--bg-elevated);
font-size:1.15rem; cursor:pointer; display:inline-flex; align-items:center; justify-content:center;
}
.icon-btn:hover{ background:var(--bg-sunken); }
/* ---- banner ---- */
.banner{ background:var(--warning-bg); color:var(--warning); border:1px solid var(--warning);
padding:var(--space-3) var(--space-4); border-radius:var(--radius-md); font-size:0.85rem; }
/* ---- artwork card ---- */
.artwork-card{ position:relative; background:var(--bg-elevated); border-radius:var(--radius-lg);
padding:var(--space-4); box-shadow:var(--shadow); display:flex; flex-direction:column;
align-items:center; gap:var(--space-3); }
.art-canvas{ background:var(--canvas-bg); border-radius:var(--radius-md); display:block;
image-rendering:pixelated; image-rendering:crisp-edges; }
.progress-row{ width:100%; display:flex; flex-direction:column; align-items:center; gap:var(--space-1); }
.progress-bar{ width:100%; height:10px; border-radius:6px; background:var(--border); overflow:hidden; }
.progress-fill{ height:100%; width:0%; background:linear-gradient(90deg, var(--accent2), var(--accent));
transition:width .5s ease; }
.progress-text{ font-size:0.85rem; color:var(--text-dim); text-align:center; }
.artwork-empty{ text-align:center; padding:var(--space-5) var(--space-3); color:var(--text-dim); }
.artwork-empty p{ margin:0 0 var(--space-3); font-style:italic; }
.toast-layer{ position:absolute; top:4px; left:0; right:0; height:0; overflow:visible;
display:flex; justify-content:center; pointer-events:none; }
.toast{ position:absolute; background:var(--accent-strong); color:#fff; padding:5px 12px;
border-radius:999px; font-size:0.8rem; font-weight:700; white-space:nowrap;
animation: toastFloat 1.5s ease forwards; }
@keyframes toastFloat{
0%{ opacity:0; transform:translateY(6px) scale(0.9); }
15%{ opacity:1; transform:translateY(0) scale(1); }
75%{ opacity:1; }
100%{ opacity:0; transform:translateY(-30px) scale(1.02); }
}
/* ---- today card ---- */
.today-card{ background:var(--bg-elevated); border-radius:var(--radius-lg); padding:var(--space-4);
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; }
.habit-row.checked{ background:var(--accent-soft); }
.habit-row.squish{ animation:squish .32s ease; }
@keyframes squish{ 0%{transform:scale(1);} 40%{transform:scale(.96);} 70%{transform:scale(1.03);} 100%{transform:scale(1);} }
.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-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; }
/* ---- stats ---- */
.stats-row{ display:flex; gap:var(--space-2); }
.stat-chip{ flex:1; background:var(--bg-elevated); border-radius:var(--radius-md); padding:var(--space-3);
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; }
.btn:hover{ filter:brightness(1.05); }
.btn:disabled{ opacity:0.45; cursor:not-allowed; filter:none; }
.btn-primary{ background:var(--accent); color:#2a1420; border-color:var(--accent-strong); }
.btn-primary:hover{ background:var(--accent-strong); color:#fff; }
.btn-danger{ background:transparent; color:var(--danger); border-color:var(--danger); }
.btn-block{ width:100%; }
.btn-sm{ min-height:36px; padding:0 var(--space-3); font-size:0.85rem; }
/* ---- onboarding ---- */
.onboarding-overlay{ position:fixed; inset:0; background:var(--bg); z-index:60; overflow-y:auto;
display:flex; padding:var(--space-5) var(--space-4); }
.onboarding-card{ margin:auto; max-width:28rem; width:100%; background:var(--bg-elevated);
border-radius:var(--radius-lg); padding:var(--space-5); box-shadow:var(--shadow); }
.onboarding-steps{ display:flex; gap:var(--space-2); justify-content:center; margin-bottom:var(--space-4); }
.step-dot{ width:9px; height:9px; border-radius:50%; background:var(--border); }
.step-dot.active{ background:var(--accent-strong); }
.onboarding-title{ font-size:1.3rem; font-weight:800; margin:0 0 var(--space-2); text-align:center; }
.onboarding-sub{ color:var(--text-dim); text-align:center; margin:0 0 var(--space-4); }
.field-row{ display:flex; gap:var(--space-2); margin-bottom:var(--space-3); }
.field-row input[type=text]{ flex:1 1 0; min-width:0; min-height:44px; padding:0 var(--space-3);
border-radius:var(--radius-md);
border:1px solid var(--border); background:var(--bg-sunken); color:var(--text); }
.field-row select{ flex-shrink:0; min-height:44px; padding:0 var(--space-2); border-radius:var(--radius-md);
border:1px solid var(--border); background:var(--bg-sunken); color:var(--text); }
.pending-habits{ list-style:none; margin:0 0 var(--space-4); padding:0; display:flex; flex-direction:column; gap:var(--space-2); }
.pending-habits li{ display:flex; align-items:center; gap:var(--space-2); background:var(--bg-sunken);
border-radius:var(--radius-md); padding:var(--space-2) var(--space-3); }
.pending-habits li span.nm{ flex:1; }
.wizard-actions{ display:flex; gap:var(--space-3); align-items:center; justify-content:space-between;
margin-top:var(--space-4); }
.wizard-actions .btn{ flex-shrink:0; }
.art-grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(84px,1fr)); gap:var(--space-3); }
.art-tile{ background:var(--bg-sunken); border:2px solid transparent; border-radius:var(--radius-md);
padding:var(--space-2); display:flex; flex-direction:column; align-items:center; gap:6px;
cursor:pointer; text-align:center; }
.art-tile canvas{ background:var(--canvas-bg); border-radius:var(--radius-sm); }
.art-tile.selected{ border-color:var(--accent-strong); }
.art-tile .lbl{ font-size:0.75rem; line-height:1.25; color:var(--text-dim); }
.art-tile:hover{ filter:brightness(1.05); }
/* ---- dialog / settings ---- */
dialog::backdrop{ background:rgba(20,15,10,0.45); }
.settings-dialog{ border:none; border-radius:var(--radius-lg); padding:0; width:92vw; max-width:34rem;
max-height:86vh; background:var(--bg-elevated); color:var(--text); box-shadow:var(--shadow); }
.settings-dialog[open]{ display:flex; flex-direction:column; }
.dialog-header{ display:flex; align-items:center; justify-content:space-between; padding:var(--space-4);
border-bottom:1px solid var(--border); }
.dialog-header h2{ margin:0; font-size:1.15rem; }
.tabs{ display:flex; gap:var(--space-1); padding:var(--space-2) var(--space-4) 0; border-bottom:1px solid var(--border);
overflow-x:auto; scrollbar-width:none; }
.tab-btn{ background:none; border:none; padding:var(--space-2) var(--space-2); border-bottom:3px solid transparent;
cursor:pointer; font-weight:600; font-size:0.95rem; color:var(--text-dim); min-height:44px;
white-space:nowrap; flex-shrink:0; }
@media (min-width:640px){ .tab-btn{ padding:var(--space-2) var(--space-3); font-size:1rem; } }
.tab-btn[aria-selected="true"]{ color:var(--text); border-bottom-color:var(--accent-strong); }
.dialog-body{ padding:var(--space-4); overflow-y:auto; flex:1; }
.tab-panel[hidden]{ display:none; }
.habit-edit-row{ display:flex; align-items:center; gap:var(--space-2); background:var(--bg-sunken);
border-radius:var(--radius-md); padding:var(--space-2) var(--space-3); margin-bottom:var(--space-2); }
.habit-edit-row .nm{ flex:1; }
.section-title{ font-weight:700; margin:var(--space-4) 0 var(--space-2); }
.section-title:first-child{ margin-top:0; }
.hint{ color:var(--text-dim); font-size:0.82rem; }
/* Native file pickers look unfinished; dress the button to match .btn. */
input[type=file]{ font-size:0.85rem; color:var(--text-dim); max-width:100%; }
input[type=file]::file-selector-button{
min-height:36px; margin-right:var(--space-3); padding:0 var(--space-3);
border-radius:var(--radius-md); border:1px solid var(--border);
background:var(--bg-sunken); color:var(--text); font:inherit; font-size:0.85rem;
font-weight:600; cursor:pointer;
}
input[type=file]::file-selector-button:hover{ filter:brightness(1.05); }
.upload-preview{ display:flex; align-items:center; gap:var(--space-3); margin:var(--space-3) 0; }
.gallery-grid{ display:grid; grid-template-columns:repeat(auto-fill, minmax(64px,1fr)); gap:var(--space-2); }
.gallery-tile{ text-align:center; font-size:0.68rem; color:var(--text-dim); }
.gallery-tile canvas{ background:var(--canvas-bg); border-radius:var(--radius-sm); }
.about-text p{ margin:0 0 var(--space-3); color:var(--text-dim); }
/* ---- celebration ---- */
.celebration-overlay{ position:fixed; inset:0; background:rgba(10,8,15,0.55); z-index:70;
display:flex; align-items:center; justify-content:center; padding:var(--space-4); }
.celebration-card{ background:var(--bg-elevated); border-radius:var(--radius-lg); padding:var(--space-5);
max-width:26rem; width:100%; text-align:center; box-shadow:var(--shadow); }
.celebration-card h2{ margin:var(--space-3) 0 var(--space-1); }
.celebration-card p{ color:var(--text-dim); margin:0 0 var(--space-4); }
.wiggle{ animation:wiggle 0.6s ease-in-out 2; }
@keyframes wiggle{ 0%,100%{transform:rotate(0deg);} 25%{transform:rotate(-4deg);} 75%{transform:rotate(4deg);} }
.confetti-canvas{ position:fixed; inset:0; z-index:65; pointer-events:none; }
@media (prefers-reduced-motion: reduce){
*, *::before, *::after{
animation-duration:0.001ms !important; animation-iteration-count:1 !important;
transition-duration:0.001ms !important;
}
}
</style>
</head>
<body>
<div class="app">
<header class="app-header">
<h1 class="app-title"><span class="bloom"></span> Paint by Habits</h1>
<div class="header-actions">
<button id="themeToggle" class="icon-btn" type="button" aria-label="Cycle theme (auto, light, dark)" title="Theme">🌓</button>
<button id="settingsBtn" class="icon-btn" type="button" aria-label="Open settings" title="Settings">⚙️</button>
</div>
</header>
<div id="storageBanner" class="banner" role="status" hidden></div>
<main id="mainView" hidden>
<section class="artwork-card">
<div id="artworkEmpty" class="artwork-empty" hidden>
<p>No picture chosen yet — pick one and start painting it in, bit by bit 🎨</p>
<button id="pickArtworkBtn" class="btn btn-primary" type="button">Choose artwork</button>
</div>
<div id="artworkActive" hidden style="width:100%; display:flex; flex-direction:column; align-items:center; gap:var(--space-3);">
<canvas id="artCanvas" class="art-canvas"></canvas>
<div class="progress-row">
<div class="progress-bar"><div id="progressFill" class="progress-fill"></div></div>
<div id="progressText" class="progress-text">0 / 0 pixels</div>
</div>
</div>
<div id="toastLayer" class="toast-layer" aria-live="polite"></div>
</section>
<section class="today-card">
<div class="today-header">
<span id="todayLabel" class="today-label">Today</span>
<span id="streakLabel" class="streak-label">🔥 0-day streak</span>
</div>
<ul id="habitList" class="habit-list"></ul>
<p id="emptyHabits" class="empty-state" hidden>No habits yet — plant your first one in Settings 🌱</p>
</section>
<section class="stats-row">
<div class="stat-chip">🔥 <span id="statStreak">0</span><small>streak</small></div>
<div class="stat-chip"><span id="statChecks">0</span><small>checks</small></div>
<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 ===================== -->
<div id="onboarding" class="onboarding-overlay" hidden>
<div class="onboarding-card">
<!-- Mirror of #storageBanner: the onboarding overlay covers the page, so
the storage warning must also be visible here on first run. -->
<div id="storageBannerOb" class="banner" role="status" hidden style="margin-bottom:var(--space-4);"></div>
<div class="onboarding-steps">
<span class="step-dot" id="dotStep1"></span>
<span class="step-dot" id="dotStep2"></span>
</div>
<div id="obStep1">
<h2 class="onboarding-title">Welcome 🌱</h2>
<p class="onboarding-sub">Let's plant a few daily habits. You can add up to 12 — start with just one or two if you like.</p>
<div class="field-row">
<input type="text" id="obHabitName" maxlength="40" placeholder="e.g. Drink water" aria-label="Habit name">
<select id="obHabitEmoji" aria-label="Habit emoji"></select>
</div>
<button id="obAddHabit" class="btn btn-block" type="button">+ Add habit</button>
<ul id="obPendingList" class="pending-habits" style="margin-top:var(--space-3);"></ul>
<div class="wizard-actions">
<span class="hint" id="obStep1Hint">Add at least one habit to continue</span>
<button id="obToStep2" class="btn btn-primary" type="button" disabled>Next →</button>
</div>
</div>
<div id="obStep2" hidden>
<h2 class="onboarding-title">Pick your first picture 🎨</h2>
<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>
</div>
</div>
</div>
</div>
<!-- ===================== SETTINGS DIALOG ===================== -->
<dialog id="settingsDialog" class="settings-dialog">
<div class="dialog-header">
<h2>Settings</h2>
<button id="closeSettings" class="icon-btn" type="button" aria-label="Close settings"></button>
</div>
<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="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">
<div class="tab-panel" id="panel-habits" role="tabpanel">
<div class="section-title">Your habits</div>
<div id="habitEditList"></div>
<p id="habitCapHint" class="hint" hidden>You've reached the 12-habit limit. Remove one to add another.</p>
<div class="section-title">Add a habit</div>
<div class="field-row">
<input type="text" id="newHabitName" maxlength="40" placeholder="e.g. Read 10 pages" aria-label="New habit name">
<select id="newHabitEmoji" aria-label="New habit emoji"></select>
</div>
<button id="addHabitBtn" class="btn btn-primary btn-block" type="button">+ Add habit</button>
</div>
<div class="tab-panel" id="panel-artwork" hidden role="tabpanel">
<div class="section-title">Current artwork</div>
<p id="currentArtworkInfo" class="hint">No artwork in progress.</p>
<div class="section-title">Choose a preset</div>
<div class="art-grid" id="settingsArtGrid"></div>
<div class="section-title">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="uploadFile" accept="image/*" aria-label="Choose image to upload">
<select id="uploadGridSize" 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="uploadName" maxlength="40" placeholder="Name this artwork" aria-label="Artwork name">
</div>
<div class="upload-preview" id="uploadPreviewRow" hidden>
<canvas id="uploadPreviewCanvas"></canvas>
<div>
<div id="uploadEstimate" class="hint"></div>
</div>
</div>
<button id="useUploadBtn" class="btn btn-primary btn-block" type="button" disabled>Use this image</button>
<div class="section-title">Gallery</div>
<p id="galleryEmpty" class="hint">Finished (and retired) pictures will appear here.</p>
<div class="gallery-grid" id="galleryGrid"></div>
</div>
<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>
<button id="copyBtn" class="btn btn-block" type="button" style="margin-top:var(--space-2);">📋 Copy to clipboard</button>
<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 Code — Sonnet 5, Opus 4.8 &amp; Fable 5.</p>
</div>
</div>
</dialog>
<!-- ===================== CELEBRATION ===================== -->
<div id="celebration" class="celebration-overlay" hidden>
<div class="celebration-card">
<canvas id="celebrationCanvas"></canvas>
<h2 id="celebrationTitle">You did it!</h2>
<p id="celebrationSub"></p>
<button id="celebrationNext" class="btn btn-primary btn-block" type="button">Choose next artwork</button>
<button id="celebrationClose" class="btn btn-block" type="button" style="margin-top:var(--space-2);">Not now</button>
</div>
</div>
<canvas id="confettiCanvas" class="confetti-canvas" aria-hidden="true" hidden></canvas>
<script>
(function(){
'use strict';
/* ==========================================================================
STATE — persistence, schema, load/save
========================================================================== */
var STORAGE_KEY = 'bitbybit.v1';
var CORRUPT_KEY = 'bitbybit.v1.corrupt';
var storageAvailable = true;
var isFirstRun = false;
var state = null;
var saveTimer = null;
function defaultState(){
return { version:1, theme:'auto', habits:[], image:null, log:{}, gallery:[] };
}
function validateShape(s){
return !!s && typeof s === 'object' && Array.isArray(s.habits) &&
s.log && typeof s.log === 'object' && !Array.isArray(s.log);
}
function normalizeImage(img){
if (!img || typeof img !== 'object') return null;
if (!Array.isArray(img.pixels) || !img.w || !img.h) return null;
if (img.pixels.length !== img.w * img.h) return null;
return {
name: String(img.name || 'Untitled'),
source: img.source === 'upload' ? 'upload' : 'preset',
w: img.w | 0, h: img.h | 0,
pixels: img.pixels.slice(),
seed: (img.seed == null ? 1 : (img.seed >>> 0)),
pixelsPerCheck: Math.max(1, img.pixelsPerCheck | 0 || 1),
startedAt: /^\d{4}-\d{2}-\d{2}$/.test(img.startedAt) ? img.startedAt : todayStr()
};
}
function normalizeGalleryEntry(g){
if (!g || !Array.isArray(g.pixels) || !g.w || !g.h) return null;
return {
name: String(g.name || 'Untitled'), w: g.w | 0, h: g.h | 0,
pixels: g.pixels.slice(),
finishedAt: /^\d{4}-\d{2}-\d{2}$/.test(g.finishedAt) ? g.finishedAt : todayStr(),
complete: !!g.complete
};
}
function normalizeState(s){
var habits = Array.isArray(s.habits) ? s.habits
.filter(function(h){ return h && h.id && h.name; })
.slice(0, 12)
.map(function(h){ return { id:String(h.id), name:String(h.name).slice(0,40), emoji: h.emoji || '⭐' }; })
: [];
var gallery = Array.isArray(s.gallery) ? s.gallery.map(normalizeGalleryEntry).filter(Boolean).slice(-30) : [];
var log = {};
if (s.log && typeof s.log === 'object') {
for (var k in s.log) {
if (/^\d{4}-\d{2}-\d{2}$/.test(k) && Array.isArray(s.log[k])) {
log[k] = s.log[k].map(String);
}
}
}
return {
version: 1,
theme: (s.theme === 'light' || s.theme === 'dark') ? s.theme : 'auto',
habits: habits,
image: normalizeImage(s.image),
log: log,
gallery: gallery
};
}
function loadState(){
var raw = null;
try { raw = localStorage.getItem(STORAGE_KEY); }
catch (e) { storageAvailable = false; isFirstRun = true; return defaultState(); }
if (!raw) { isFirstRun = true; return defaultState(); }
try {
var parsed = JSON.parse(raw);
if (!validateShape(parsed)) throw new Error('bad shape');
isFirstRun = false;
return normalizeState(parsed);
} catch (e) {
try { localStorage.setItem(CORRUPT_KEY, raw); } catch (e2) { /* ignore */ }
isFirstRun = true;
return defaultState();
}
}
function saveNow(){
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
storageAvailable = true;
setStorageWarning(false);
} catch (e) {
storageAvailable = false;
setStorageWarning(true);
}
}
function scheduleSave(){
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, 400);
}
function setStorageWarning(show){
// Two banner elements: one on the main screen, one inside the onboarding
// overlay (which covers the page on first run).
var msg = "Heads up — this browser won't let Paint by Habits save progress right now " +
"(private browsing, or storage disabled?). Everything still works this session, it just " +
"won't be remembered after you leave.";
['storageBanner', 'storageBannerOb'].forEach(function(id){
var el = document.getElementById(id);
if (!el) return;
if (show) { el.textContent = msg; el.hidden = false; }
else { el.hidden = true; }
});
}
/* ==========================================================================
MECHANICS — dates, PRNG, streaks, reveal math, check-off logic
========================================================================== */
function pad2(n){ return n < 10 ? '0' + n : '' + n; }
function todayStr(d){
d = d || new Date();
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
function addDaysStr(dateStr, delta){
var parts = dateStr.split('-').map(Number);
var dt = new Date(parts[0], parts[1] - 1, parts[2]);
dt.setDate(dt.getDate() + delta);
return todayStr(dt);
}
// Seeded PRNG — mulberry32 (deterministic; the reveal order is re-derived
// from `seed` at load time rather than persisted).
function mulberry32(a){
return function(){
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng){
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(rng() * (i + 1));
var tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
return a;
}
function opaqueIndices(image){
var idx = [];
for (var i = 0; i < image.pixels.length; i++) if (image.pixels[i] != null) idx.push(i);
return idx;
}
var revealCache = null; // { key, order }
function getRevealOrder(image){
var key = image.seed + '|' + image.w + 'x' + image.h + '|' + image.startedAt + '|' + image.pixels.length;
if (revealCache && revealCache.key === key) return revealCache.order;
var idx = opaqueIndices(image);
var rng = mulberry32(image.seed >>> 0);
var order = shuffle(idx, rng);
revealCache = { key: key, order: 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){
var order = getRevealOrder(image);
var opaque = order.length;
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){
var perfectMonthUnits = triangular(30) * triangular(Math.max(1, habitCount));
return Math.max(1, Math.round(opaqueCount / perfectMonthUnits));
}
function randomSeed(){
// Never return 0: normalizeImage treats a missing seed as 1, and a 0 seed
// would reshuffle after a save/load round-trip.
return (Math.floor(Math.random() * 0xFFFFFFFF) >>> 0) || 1;
}
// Snapshot of a just-finished image. The canvas keeps rendering this (fully
// revealed) until the celebration is dismissed, so the reward moment never
// flashes the empty state and the final pixels get to play their pop-in.
// State itself is archived + persisted immediately, so closing the tab
// mid-celebration loses nothing.
var pendingCelebration = null; // { name, w, h, pixels, days } | null
function resetArtRuntime(){
revealCache = null;
artLayout = null;
pendingCelebration = null;
popping.clear();
if (popRAF) { cancelAnimationFrame(popRAF); popRAF = null; }
}
function buildImageFromDef(def){
var opaque = def.pixels.filter(function(p){ return p != null; }).length;
var habitCount = Math.max(1, state.habits.length);
return {
name: def.name, source: def.source, w: def.w, h: def.h,
pixels: def.pixels.slice(),
seed: randomSeed(),
pixelsPerCheck: pixelsPerCheckFor(opaque, habitCount),
startedAt: todayStr()
};
}
function archiveCurrentImage(complete){
if (!state.image) return;
state.gallery.push({
name: state.image.name, w: state.image.w, h: state.image.h,
pixels: state.image.pixels, finishedAt: todayStr(), complete: !!complete
});
if (state.gallery.length > 30) state.gallery = state.gallery.slice(-30);
}
function startNewImage(def){
if (state.image) archiveCurrentImage(false);
state.image = buildImageFromDef(def);
resetArtRuntime();
}
function completeImageIfDone(){
if (!state.image) return null;
var info = getRevealInfo(state.image);
if (info.opaque > 0 && info.revealedCount >= info.opaque) {
var finished = state.image;
var startedAt = finished.startedAt;
var finishedAt = todayStr();
// Persist the completion right away (archive + null the active image) so
// closing the tab mid-celebration loses nothing…
archiveCurrentImage(true);
state.image = null;
saveNow();
// …but do NOT reset the render runtime: the canvas keeps showing the
// finished art (via pendingCelebration) and the in-flight pixel pops
// finish playing. Runtime resets when the celebration is dismissed.
var days = Math.round((parseDateLocal(finishedAt) - parseDateLocal(startedAt)) / 86400000) + 1;
pendingCelebration = { name: finished.name, w: finished.w, h: finished.h,
pixels: finished.pixels, days: Math.max(1, days) };
return pendingCelebration;
}
return null;
}
function parseDateLocal(s){
var p = s.split('-').map(Number);
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;
}
function isPerfectDay(log, today, habits){
if (!habits.length) return false;
var list = log[today] || [];
return habits.every(function(h){ return list.indexOf(h.id) !== -1; });
}
function totalChecksAllTime(log){
var total = 0;
for (var d in log) total += (log[d] || []).length;
return total;
}
/* ==========================================================================
ARTWORK — presets, pixel decoding, upload downsampling
========================================================================== */
var RAW_PRESETS = [
{"name":"Cheeky Mushroom","w":18,"h":18,"palette":{"A":"#7a1f1f","B":"#e0504f","C":"#b23838","H":"#f4908c","S":"#fff3df","O":"#c9a35f","T":"#f5e2b8","D":"#dcbb87","L":"#fff6df"},"rows":["..................","..................","......AAAAAA......","....AAHHHHBBAA....","...AHHHHBBBSSBA...","..AHSSSBBBBSSBCA..",".AHHBSSBBBBBCCCCA.",".ABBBBBBSSCCCSSCA.",".ABBBBBBSSCCCSCCA.",".AAAABCCCCCCCAAAA.",".....OLLLLLTO.....","......OLLLTO......","......OLLTTO......","......OLTTTO......","......OTTTDO......","......OTTDDO......",".....OTTDDDDO.....",".....OOOOOOOO....."]},
{"name":"Curious Cat","w":18,"h":18,"palette":{"A":"#4a4640","B":"#f2a65a","C":"#d4863f","H":"#ffce8a","P":"#ffc2c2","p":"#f7a8a8","E":"#3a2e2e","W":"#ffffff","N":"#e2708a","K":"#ffb3ba","w":"#e8e2d8"},"rows":["..................","..................","...A.........A....","..AHp.......PBA...","..App.AAAAAAppA...",".AAPHAHHHHHBBBCA..","...AHHHHHHBBBBA...","..AHHHHHHBBBBBBA..","..AHHWEHBBBWEBBA..","..AHHEEBBBBEEBCA..","wwAHHEEBBBBEECCAww","..AHBBBBNNBBCCCA..","wwABKBBBNBCCCKCAww","..ABBBBABCACCCCA..","...ABBBBAACCCCA...","....AABCCCCCAA....","......AAAAAA......",".................."]},
{"name":"Warm Heart","w":18,"h":16,"palette":{"A":"#8a1f3d","B":"#e8486b","C":"#c2325a","H":"#ff8ba3"},"rows":["..................","..................",".AAAAAAA..AAAAAAA.","AHHHHHHHAAHBBBBBBA","AHHHHHHHHHBBBBBBBA","AHHHHHHHHBBBBBBBBA","AHHHHHHHBBBBBBBBBA","AHHHHHBBBBBBBBBBCA","AHHHHBBBBBBBBBCCCA",".AHHBBBBBBBBBCCCA.",".ABBBBBBBBBBCCCCA.","..ABBBBBBBCCCCCA..","...AABBBBCCCCAA...",".....ABBCCCCA.....","......AACCAA......","........AA........"]},
{"name":"Happy Rainbow","w":22,"h":16,"palette":{"d":"#f4908c","e":"#e0504f","f":"#b23838","g":"#e8823f","h":"#f7b27a","i":"#c06726","j":"#f7dd7e","k":"#e8c53f","l":"#c2a022","m":"#5cb96a","n":"#8fd99a","p":"#4a90d9","q":"#3d8f4a","s":"#82b8ec","t":"#8a63c9","u":"#2f6aab","v":"#f5f2ee","x":"#b394e0","y":"#63409e"},"rows":["......................","......................","......................","......................","......................",".......deeeeeef.......",".....dddeggggefff.....","....ddhhggggggiiff....","...ddhhjkkkkkkliiff...","..ddhhjjmmmmmmlliiff..","..dhhjjnmppppmqlliif..",".ddhjjnsppttppuqlliff.",".dhhjnnsttttttuqqliif.",".vvvvnsxt....tyuqvvvv.","vvvvvnsx......yuqvvvvv","dvvvvnsx......yuqvvvvf"]},
{"name":"Zoom Rocket","w":16,"h":24,"palette":{"A":"#8a2a2a","B":"#e0504f","C":"#b23838","H":"#f4908c","a":"#8a95a3","b":"#e6ecf2","c":"#c3cdd8","h":"#ffffff","F":"#7a1f1f","G":"#b23838","f":"#e0504f","g":"#f4908c","W":"#3a5a7a","w":"#7bb8e0","x":"#cdeeff","p":"#ffe27a","o":"#ffb347","q":"#e8823f","j":"#fff3c2","k":"#ffdd8a"},"rows":[".......A........",".......A........","......ABA.......",".....ABBBA......",".....ABCCA......","....ABCCCCA.....","...ahhhhhbba....","...ahhhhhbba....","...ahhhxxbba....","...ahhxxwwba....","...ahhxwwwba....","...ahhwwwwca....","...ahbbwwbca....","..FhhbbbbcccF...","..FgbbbbcccfF...",".FggbbbbcccffF..",".FfFbbbccccGGF..",".FF.apjjjocaFF..","F....apjoaa...F.","......ojo.......",".......k........",".......q........","................","................"]},
{"name":"Sunny Sunflower","w":22,"h":22,"palette":{"A":"#b8860b","B":"#f4c430","C":"#d9a520","H":"#ffe27a","D":"#5c3a1e","E":"#7a4e26","F":"#8f5a2e","S":"#6b3e18","g":"#3d6b2e","i":"#2c4f22","h":"#5a9142","j":"#7fbf5c"},"rows":["......................",".......AAAAAAA........","......AHHHHHHBA.......","....AAHHHHHHBBBAA.....","...AHHHHHHHBBBBBBA....","...AHHHHHHBBBBBBBA....","...AHHHHHBBBBBBBBA....","..AHHHHHBSSSSBBBBBA...","..AHHHHBFSFSSFBBBCA...",".AHHHBBBFFSSFEBBCCCA..","..AHBBBBFFSFEECCCCA...","..ABBBBBSFFSESCCCCA...","...ABBBBBFSESCCCCA....","...ABBBBBBBCCCCCCA....","...ABBBBBBCCCCCCCA....","....AABBBCCCCCCAA.....","......ABCChhCCA.......",".......AAAhhjjjg......","..........gjjhhig.....","..........gigggg......","..........gg..........","..........gg.........."]},
{"name":"Friendly Ghost","w":16,"h":18,"palette":{"A":"#8a8fae","B":"#eef1fb","C":"#c7cce6","H":"#ffffff","E":"#4a4e6b","K":"#ffc2d6"},"rows":["................","................","......AAAA......","....AAHHHHAA....","...AHHHHHHBBA...","..AHHHHHHBBBBA..","..AHHHHHBBBBBA..","..AHHHHBBBBBBA..",".AHHEEEBBEEEBBA.",".AHHEEEBBEEEBCA.",".AKKBBBBBBBBKKA.","BAHBBBBEEBBCCCAB","AABBBBBEEBCCCCAA",".ABBBBBBBCCCCCA.",".ABBBBBBACCCCCCA","..ABBBBA.ACCCCA.","..ABAACA.ACAACA.","...A..A...A..A.."]},
{"name":"Cozy Mug","w":18,"h":18,"palette":{"A":"#5a4632","B":"#e0784f","C":"#b95d38","H":"#f2a878","d":"#8a5a3a","e":"#c9895c","m":"#d8d2c8"},"rows":["....m...m.........",".....m...m........","......m....m......","......m....m......",".....m....m.......",".....AAAAA........","...AAdddddAA......","...AHdddddBA.AAAA.","...AHHHHBBBBAA.AA.","...AHHHBBBBBA...AA","...AHHHBBBBCA...AA","...AHHBBBBCCA...AA","...AHBBBBCCCA...A.","...ABBBBCCCA.AAAA.","...ABBBBCCCA..AA..","...ABBBCCCCA......","....AAAAAAA.......",".................."]}
];
function buildPixelsFromRows(rows, palette, w, h){
var pixels = new Array(w * h).fill(null);
for (var y = 0; y < h; y++) {
var row = rows[y];
for (var x = 0; x < w; x++) {
var ch = row[x];
pixels[y * w + x] = (ch === '.') ? null : (palette[ch] || null);
}
}
return pixels;
}
var PRESETS = RAW_PRESETS.map(function(p){
return { name: p.name, source: 'preset', w: p.w, h: p.h, pixels: buildPixelsFromRows(p.rows, p.palette, p.w, p.h) };
});
function rgbToHex(r, g, b){
function h(v){ return v.toString(16).padStart(2, '0'); }
return '#' + h(r) + h(g) + h(b);
}
function downsampleImageFile(file, gridSize){
return new Promise(function(resolve, reject){
var reader = new FileReader();
reader.onerror = function(){ reject(new Error('read-failed')); };
reader.onload = function(){
var img = new Image();
img.onerror = function(){ reject(new Error('decode-failed')); };
img.onload = function(){
try {
var off = document.createElement('canvas');
off.width = gridSize; off.height = gridSize;
var ctx = off.getContext('2d');
ctx.imageSmoothingEnabled = true;
var sw = img.naturalWidth || img.width, sh = img.naturalHeight || img.height;
var side = Math.min(sw, sh);
var sx = (sw - side) / 2, sy = (sh - side) / 2;
ctx.drawImage(img, sx, sy, side, side, 0, 0, gridSize, gridSize);
var data = ctx.getImageData(0, 0, gridSize, gridSize).data;
var pixels = new Array(gridSize * gridSize);
for (var i = 0; i < gridSize * gridSize; i++) {
var o = i * 4, a = data[o + 3];
pixels[i] = (a < 128) ? null : rgbToHex(data[o], data[o + 1], data[o + 2]);
}
resolve({ w: gridSize, h: gridSize, pixels: pixels });
} catch (e) { reject(e); }
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
/* ==========================================================================
RENDERING — canvas layout, static pattern, pop-in animation, confetti
========================================================================== */
function getCssVar(name){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
function prefersReducedMotion(){
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
var artLayout = null; // { ctx, cell, w, h, dpr, bg, staticA, staticB }
var popping = new Map(); // idx -> { start, duration }
var popRAF = null;
function layoutArtCanvas(canvasEl, w, h, maxCssWidth){
var containerWidth = canvasEl.parentElement ? canvasEl.parentElement.clientWidth : 320;
var maxW = Math.min(containerWidth, maxCssWidth || 420);
var cell = Math.floor(maxW / w);
cell = Math.max(4, Math.min(cell, 26));
var dpr = window.devicePixelRatio || 1;
var cssW = cell * w, cssH = cell * h;
if (artLayout && artLayout.canvasEl === canvasEl && artLayout.w === w && artLayout.h === h &&
artLayout.cell === cell && artLayout.dpr === dpr) {
return artLayout;
}
canvasEl.style.width = cssW + 'px';
canvasEl.style.height = cssH + 'px';
canvasEl.width = Math.round(cssW * dpr);
canvasEl.height = Math.round(cssH * dpr);
var ctx = canvasEl.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;
artLayout = {
canvasEl: canvasEl, ctx: ctx, cell: cell, w: w, h: h, dpr: dpr,
bg: getCssVar('--canvas-bg'), staticA: getCssVar('--canvas-static-a'), staticB: getCssVar('--canvas-static-b')
};
return artLayout;
}
function drawStaticCell(ctx, x, y, cell, a, b){
var half = cell / 2;
ctx.fillStyle = a;
ctx.fillRect(x, y, cell, cell);
ctx.fillStyle = b;
ctx.fillRect(x, y, half, half);
ctx.fillRect(x + half, y + half, cell - half, cell - half);
}
function easeOutBackClamped(t){
var c1 = 1.70158, c3 = c1 + 1;
var v = 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
return Math.max(0, Math.min(1, v));
}
// The image the canvas should currently display: the active artwork, or the
// just-finished snapshot while its celebration is still pending.
function displayImage(){
return state.image || pendingCelebration;
}
function renderArt(){
var emptyEl = document.getElementById('artworkEmpty');
var activeEl = document.getElementById('artworkActive');
var img = displayImage();
updateFavicon();
if (!img) {
emptyEl.hidden = false;
activeEl.hidden = true;
return;
}
emptyEl.hidden = true;
activeEl.hidden = false;
var canvasEl = document.getElementById('artCanvas');
var layout = layoutArtCanvas(canvasEl, img.w, img.h, 420);
var ctx = layout.ctx, cell = layout.cell;
// For the active image, reveal per the seeded order; for a finished
// snapshot (celebration pending) every opaque pixel is revealed.
var opaque = 0, revealedCount, revealedSet;
if (state.image) {
var info = getRevealInfo(img);
opaque = info.opaque; revealedCount = info.revealedCount; revealedSet = info.set;
} else {
for (var k = 0; k < img.pixels.length; k++) if (img.pixels[k] != null) opaque++;
revealedCount = opaque;
revealedSet = null; // null = "all revealed"
}
ctx.fillStyle = layout.bg;
ctx.fillRect(0, 0, cell * img.w, cell * img.h);
for (var i = 0; i < img.pixels.length; i++) {
var color = img.pixels[i];
if (color == null) continue;
var x = (i % img.w) * cell, y = Math.floor(i / img.w) * cell;
if (popping.has(i)) {
drawStaticCell(ctx, x, y, cell, layout.staticA, layout.staticB);
} else if (revealedSet === null || revealedSet.has(i)) {
ctx.fillStyle = color;
ctx.fillRect(x, y, cell, cell);
} else {
drawStaticCell(ctx, x, y, cell, layout.staticA, layout.staticB);
}
}
var pct = opaque > 0 ? Math.round((revealedCount / opaque) * 100) : 0;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent =
revealedCount + ' / ' + opaque + ' pixels · ' + pct + '%';
canvasEl.setAttribute('role', 'img');
canvasEl.setAttribute('aria-label',
img.name + ' — ' + revealedCount + ' of ' + opaque + ' pixels revealed (' + pct + '%).');
if (popping.size > 0 && !popRAF) popRAF = requestAnimationFrame(popTick);
}
function triggerPixelPop(indices){
if (!indices || !indices.length) return;
if (prefersReducedMotion()) return; // instantly shown as solid via normal render, no animation
var now = performance.now();
for (var k = 0; k < indices.length; k++) {
popping.set(indices[k], { start: now + Math.random() * 180, duration: 380 });
}
if (!popRAF) popRAF = requestAnimationFrame(popTick);
}
function popTick(now){
popRAF = null;
var img = displayImage();
if (!img || !artLayout) { popping.clear(); return; }
var layout = artLayout;
var ctx = layout.ctx, cell = layout.cell;
var done = [];
popping.forEach(function(info, idx){
var x = (idx % img.w) * cell, y = Math.floor(idx / img.w) * cell;
if (now < info.start) {
drawStaticCell(ctx, x, y, cell, layout.staticA, layout.staticB);
return;
}
var t = Math.min(1, (now - info.start) / info.duration);
var s = easeOutBackClamped(t);
ctx.fillStyle = layout.bg;
ctx.fillRect(x, y, cell, cell);
var size = cell * s, off = (cell - size) / 2;
var color = img.pixels[idx];
if (color != null) { ctx.fillStyle = color; ctx.fillRect(x + off, y + off, size, size); }
if (t >= 1) done.push(idx);
});
done.forEach(function(idx){ popping.delete(idx); });
if (popping.size > 0) popRAF = requestAnimationFrame(popTick);
}
function renderThumbnail(canvasEl, imgLike, maxCssSize){
var w = imgLike.w, h = imgLike.h;
var cell = Math.max(1, Math.floor(maxCssSize / Math.max(w, h)));
var dpr = window.devicePixelRatio || 1;
canvasEl.style.width = (cell * w) + 'px';
canvasEl.style.height = (cell * h) + 'px';
canvasEl.width = Math.round(cell * w * dpr);
canvasEl.height = Math.round(cell * h * dpr);
var ctx = canvasEl.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false;
var bg = getCssVar('--canvas-bg');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, cell * w, cell * h);
for (var i = 0; i < imgLike.pixels.length; i++) {
var c = imgLike.pixels[i];
if (c == null) continue;
var x = (i % w) * cell, y = Math.floor(i / w) * cell;
ctx.fillStyle = c;
ctx.fillRect(x, y, cell, cell);
}
}
/* --- Favicon ------------------------------------------------------------
A miniature of the main canvas: revealed pixels solid, unrevealed ones a
two-tone checker so the tab icon reads as a partially-loaded image. Drawn
into an offscreen canvas and handed to the <link> tags as a data URL, so
the page still makes zero network requests. */
var FAVICON_SIZE = 64;
var faviconCanvas = null, faviconPending = false, faviconLast = '';
function updateFavicon(){
// Coalesce the bursts of renderArt() calls a single check-off produces.
if (faviconPending) return;
faviconPending = true;
requestAnimationFrame(function(){
faviconPending = false;
drawFavicon();
});
}
function drawFavicon(){
if (!faviconCanvas) {
faviconCanvas = document.createElement('canvas');
faviconCanvas.width = faviconCanvas.height = FAVICON_SIZE;
}
var ctx = faviconCanvas.getContext('2d');
var bg = getCssVar('--canvas-bg');
var staticA = getCssVar('--canvas-static-a'), staticB = getCssVar('--canvas-static-b');
ctx.clearRect(0, 0, FAVICON_SIZE, FAVICON_SIZE);
ctx.fillStyle = bg;
ctx.fillRect(0, 0, FAVICON_SIZE, FAVICON_SIZE);
var img = displayImage();
// No artwork yet: an empty checkerboard placeholder.
var w = img ? img.w : 8, h = img ? img.h : 8;
var revealedSet = null; // null = every opaque pixel is revealed
if (img && state.image) revealedSet = getRevealInfo(img).set;
// Fit the grid inside the icon, letterboxed and centred on whole pixels.
var cell = Math.max(1, Math.floor(FAVICON_SIZE / Math.max(w, h)));
var ox = Math.floor((FAVICON_SIZE - cell * w) / 2);
var oy = Math.floor((FAVICON_SIZE - cell * h) / 2);
for (var i = 0; i < w * h; i++) {
var color = img ? img.pixels[i] : null;
if (img && color == null) continue; // transparent pixel: leave the background
var cx = ox + (i % w) * cell, cy = oy + Math.floor(i / w) * cell;
if (img && (revealedSet === null || revealedSet.has(i))) {
ctx.fillStyle = color;
} else {
ctx.fillStyle = ((i % w) + Math.floor(i / w)) % 2 === 0 ? staticA : staticB;
}
ctx.fillRect(cx, cy, cell, cell);
}
var url = faviconCanvas.toDataURL('image/png');
if (url === faviconLast) return;
faviconLast = url;
document.getElementById('favicon').href = url;
document.getElementById('faviconApple').href = url;
}
function showToast(n){
if (!n || n <= 0) return;
var layer = document.getElementById('toastLayer');
var el = document.createElement('div');
el.className = 'toast';
el.textContent = '+' + n + ' pixel' + (n === 1 ? '' : 's') + ' ✨';
layer.appendChild(el);
setTimeout(function(){ el.remove(); }, 1600);
}
function launchConfetti(){
if (prefersReducedMotion()) return;
var canvas = document.getElementById('confettiCanvas');
canvas.hidden = false;
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(window.innerWidth * dpr);
canvas.height = Math.round(window.innerHeight * dpr);
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
var ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var colors = ['#ff6f91', '#ffce8a', '#8fd99a', '#82b8ec', '#b394e0', '#f4c430'];
var particles = [];
for (var i = 0; i < 120; i++) {
particles.push({
x: Math.random() * window.innerWidth,
y: -20 - Math.random() * window.innerHeight * 0.3,
vx: (Math.random() - 0.5) * 3,
vy: 2 + Math.random() * 3,
size: 4 + Math.random() * 5,
color: colors[i % colors.length],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.3
});
}
var start = performance.now();
var durationMs = 2400;
function frame(now){
var elapsed = now - start;
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
var fade = elapsed > durationMs - 500 ? Math.max(0, (durationMs - elapsed) / 500) : 1;
particles.forEach(function(p){
p.x += p.vx; p.y += p.vy; p.vy += 0.05; p.rot += p.vr;
ctx.save();
ctx.globalAlpha = fade;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
ctx.restore();
});
if (elapsed < durationMs) {
requestAnimationFrame(frame);
} else {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
canvas.hidden = true;
}
}
requestAnimationFrame(frame);
}
/* ==========================================================================
UI — DOM refs, rendering, wiring, dialogs, onboarding, settings
========================================================================== */
var DEFAULT_EMOJIS = ['💧','🏃','📖','🧘','🥗','😴','🦷','🧹','💊','🎨','🎸','📝','🚶','🏋️','🚴','🧺','🌱','☕','📵','💰','🙏','🧠','🎯','🛏️'];
var els = {};
function q(id){ return document.getElementById(id); }
function debounce(fn, ms){
var t = null;
return function(){
var args = arguments;
if (t) clearTimeout(t);
t = setTimeout(function(){ fn.apply(null, args); }, ms);
};
}
function populateEmojiSelect(sel){
sel.innerHTML = '';
var auto = document.createElement('option');
auto.value = ''; auto.textContent = '🎲 Auto';
sel.appendChild(auto);
DEFAULT_EMOJIS.forEach(function(e){
var opt = document.createElement('option');
opt.value = e; opt.textContent = e;
sel.appendChild(opt);
});
}
function addHabit(name, emoji){
name = (name || '').trim().slice(0, 40);
if (!name) return false;
if (state.habits.length >= 12) return false;
var id = 'h_' + Math.random().toString(36).slice(2, 8);
var em = emoji || DEFAULT_EMOJIS[state.habits.length % DEFAULT_EMOJIS.length];
state.habits.push({ id: id, name: name, emoji: em });
scheduleSave();
return true;
}
function deleteHabit(id){
state.habits = state.habits.filter(function(h){ return h.id !== id; });
scheduleSave();
}
function renameHabit(id, newName){
var h = state.habits.find(function(h){ return h.id === id; });
if (h) {
var n = (newName || '').trim().slice(0, 40);
if (n) h.name = n;
scheduleSave();
}
}
/* ---- theme ---- */
var darkMQ = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
function applyTheme(){
var eff = state.theme === 'auto' ? ((darkMQ && darkMQ.matches) ? 'dark' : 'light') : state.theme;
document.documentElement.setAttribute('data-theme', eff);
var btn = q('themeToggle');
btn.textContent = state.theme === 'auto' ? '🌓' : (state.theme === 'light' ? '☀️' : '🌙');
artLayout = null; // colors changed; force relayout on next render
if (displayImage()) renderArt();
updateFavicon(); // background/static tones are theme-dependent
}
function toggleTheme(){
var order = ['auto', 'light', 'dark'];
var i = order.indexOf(state.theme);
state.theme = order[(i + 1) % order.length];
applyTheme();
scheduleSave();
}
if (darkMQ) {
var onSchemeChange = function(){ if (state.theme === 'auto') applyTheme(); };
if (darkMQ.addEventListener) darkMQ.addEventListener('change', onSchemeChange);
else if (darkMQ.addListener) darkMQ.addListener(onSchemeChange);
}
/* ---- main render ---- */
function isTrueFirstRun(){
return isFirstRun && state.habits.length === 0 && !state.image && state.gallery.length === 0;
}
function render(){
var first = isTrueFirstRun();
q('onboarding').hidden = !first;
q('mainView').hidden = first;
if (!first) {
renderArt();
renderHabits();
renderStats();
renderTodayHeader();
} else {
renderOnboardingStep();
}
}
function renderTodayHeader(){
var today = todayStr();
var d = new Date();
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(){
var list = q('habitList');
var today = todayStr();
var checkedToday = state.log[today] || [];
list.innerHTML = '';
q('emptyHabits').hidden = state.habits.length > 0;
state.habits.forEach(function(h){
var li = document.createElement('li');
li.className = 'habit-row';
var isChecked = checkedToday.indexOf(h.id) !== -1;
if (isChecked) li.classList.add('checked');
var label = document.createElement('label');
label.className = 'habit-label';
label.setAttribute('for', 'hb_' + h.id);
var emojiSpan = document.createElement('span');
emojiSpan.className = 'habit-emoji';
emojiSpan.textContent = h.emoji;
var nameSpan = document.createElement('span');
nameSpan.className = 'habit-name';
nameSpan.textContent = h.name;
label.appendChild(emojiSpan);
label.appendChild(nameSpan);
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'habit-checkbox';
cb.id = 'hb_' + h.id;
cb.checked = isChecked;
cb.setAttribute('aria-label', h.name);
cb.addEventListener('change', function(){
onHabitToggle(h.id, cb.checked, li);
});
li.appendChild(label);
li.appendChild(cb);
list.appendChild(li);
});
}
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;
}
function onHabitToggle(habitId, wantChecked, rowEl){
var today = todayStr();
if (!state.log[today]) state.log[today] = [];
var list = state.log[today];
var idx = list.indexOf(habitId);
var already = idx !== -1;
if (wantChecked && !already) {
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 afterInfo = getRevealInfo(state.image);
delta = afterInfo.revealedCount - before;
if (delta > 0) triggerPixelPop(afterInfo.order.slice(before, afterInfo.revealedCount));
}
saveNow();
renderHabits(); renderArt(); renderStats(); renderTodayHeader();
if (rowEl) {
rowEl.classList.add('squish');
rowEl.addEventListener('animationend', function(){ rowEl.classList.remove('squish'); }, { once: true });
}
if (delta > 0) showToast(delta);
var finished = completeImageIfDone();
if (finished) {
// renderArt now draws the finished snapshot (pendingCelebration), so
// the canvas stays on the completed art; the delay lets the final
// pixel pops (≤180ms stagger + 380ms pop) finish before the modal.
renderArt(); renderStats();
setTimeout(function(){ showCelebration(finished); launchConfetti(); }, prefersReducedMotion() ? 0 : 700);
}
} else if (!wantChecked && already) {
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.
saveNow();
renderHabits(); renderArt(); renderStats(); renderTodayHeader();
}
}
/* ---- celebration ---- */
function showCelebration(finished){
q('celebrationTitle').textContent = 'You painted ' + finished.name + '!';
q('celebrationSub').textContent = finished.days + ' day' + (finished.days === 1 ? '' : 's') + ' of showing up. Nicely done.';
var canvas = q('celebrationCanvas');
renderThumbnail(canvas, finished, 160);
canvas.classList.remove('wiggle');
void canvas.offsetWidth;
if (!prefersReducedMotion()) canvas.classList.add('wiggle');
q('celebration').hidden = false;
}
function hideCelebration(){
q('celebration').hidden = true;
// Celebration over: drop the finished-image snapshot and reset the canvas
// runtime, then re-render (shows the "choose artwork" empty state).
resetArtRuntime(); // also clears pendingCelebration
render();
}
/* ---- artwork pickers (shared by onboarding + settings) ---- */
function buildArtTile(def, onPick, selected){
var tile = document.createElement('div');
tile.className = 'art-tile' + (selected ? ' selected' : '');
tile.tabIndex = 0;
tile.setAttribute('role', 'button');
tile.setAttribute('aria-label', 'Choose ' + def.name);
var canvas = document.createElement('canvas');
renderThumbnail(canvas, def, 72);
var lbl = document.createElement('div');
lbl.className = 'lbl';
lbl.textContent = def.name;
tile.appendChild(canvas);
tile.appendChild(lbl);
tile.addEventListener('click', function(){ onPick(def, tile); });
tile.addEventListener('keydown', function(ev){
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onPick(def, tile); }
});
return tile;
}
/* ---- onboarding ---- */
var obSelectedDef = null; // chosen artwork def (preset or upload)
var obUploadResult = null; // last successful upload downsample { w, h, pixels }
function renderOnboardingStep(){
populateEmojiSelect(q('obHabitEmoji'));
renderObPendingList();
}
function renderObPendingList(){
var ul = q('obPendingList');
ul.innerHTML = '';
state.habits.forEach(function(h){
var li = document.createElement('li');
var em = document.createElement('span'); em.textContent = h.emoji;
var nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = h.name;
var rm = document.createElement('button');
rm.type = 'button'; rm.className = 'btn btn-sm'; rm.textContent = 'Remove';
rm.addEventListener('click', function(){ deleteHabit(h.id); renderObPendingList(); updateObStep1Gate(); });
li.appendChild(em); li.appendChild(nm); li.appendChild(rm);
ul.appendChild(li);
});
updateObStep1Gate();
}
function updateObStep1Gate(){
var ok = state.habits.length > 0;
var atCap = state.habits.length >= 12;
q('obToStep2').disabled = !ok;
q('obAddHabit').disabled = atCap;
q('obStep1Hint').textContent = atCap
? "That's the full dozen! Remove one to add another."
: ok
? state.habits.length + ' habit' + (state.habits.length === 1 ? '' : 's') + ' ready'
: 'Add at least one habit to continue';
q('dotStep1').classList.add('active');
}
function renderObArtGrid(){
var grid = q('obArtGrid');
grid.innerHTML = '';
PRESETS.forEach(function(def){
grid.appendChild(buildArtTile(def, function(d, tile){
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;
}, 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(); }
});
q('obAddHabit').addEventListener('click', function(){
var name = q('obHabitName').value;
var emoji = q('obHabitEmoji').value || null;
if (addHabit(name, emoji)) {
q('obHabitName').value = '';
q('obHabitEmoji').value = '';
renderObPendingList();
}
});
q('obToStep2').addEventListener('click', function(){
q('obStep1').hidden = true; q('obStep2').hidden = false;
q('dotStep2').classList.add('active');
renderObArtGrid();
});
q('obBackTo1').addEventListener('click', function(){
q('obStep2').hidden = true; q('obStep1').hidden = false;
q('dotStep2').classList.remove('active');
});
q('obStart').addEventListener('click', function(){
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();
obSelectedDef = null; obUploadResult = null;
render();
});
/* ---- settings dialog ---- */
var settingsDialog = q('settingsDialog');
function openSettings(tab){
renderSettingsPanels();
settingsDialog.showModal();
if (tab) activateTab(tab);
}
q('settingsBtn').addEventListener('click', function(){ openSettings(); });
q('closeSettings').addEventListener('click', function(){ settingsDialog.close(); });
q('pickArtworkBtn').addEventListener('click', function(){ openSettings('artwork'); });
function activateTab(name){
document.querySelectorAll('.tab-btn').forEach(function(btn){
var active = btn.getAttribute('data-tab') === name;
btn.setAttribute('aria-selected', active ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(function(panel){
panel.hidden = panel.id !== 'panel-' + name;
});
}
document.querySelectorAll('.tab-btn').forEach(function(btn){
btn.addEventListener('click', function(){ activateTab(btn.getAttribute('data-tab')); });
});
function renderSettingsPanels(){
renderHabitEditList();
populateEmojiSelect(q('newHabitEmoji'));
renderArtworkPanel();
}
function renderHabitEditList(){
var wrap = q('habitEditList');
wrap.innerHTML = '';
state.habits.forEach(function(h){
var row = document.createElement('div');
row.className = 'habit-edit-row';
var em = document.createElement('span'); em.textContent = h.emoji;
var nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = h.name;
var renameBtn = document.createElement('button');
renameBtn.type = 'button'; renameBtn.className = 'btn btn-sm'; renameBtn.textContent = 'Rename';
renameBtn.addEventListener('click', function(){
var next = window.prompt('Rename habit', h.name);
if (next != null) { renameHabit(h.id, next); renderHabitEditList(); renderHabits(); }
});
var delBtn = document.createElement('button');
delBtn.type = 'button'; delBtn.className = 'btn btn-sm btn-danger'; delBtn.textContent = 'Delete';
delBtn.addEventListener('click', function(){
if (window.confirm('Delete "' + h.name + '"? Its history stays counted toward your artwork, but it will leave your daily list.')) {
deleteHabit(h.id);
renderHabitEditList(); renderHabits(); renderStats();
}
});
row.appendChild(em); row.appendChild(nm); row.appendChild(renameBtn); row.appendChild(delBtn);
wrap.appendChild(row);
});
q('habitCapHint').hidden = state.habits.length < 12;
}
q('addHabitBtn').addEventListener('click', function(){
var name = q('newHabitName').value;
var emoji = q('newHabitEmoji').value || null;
if (addHabit(name, emoji)) {
q('newHabitName').value = '';
q('newHabitEmoji').value = '';
renderHabitEditList(); renderHabits(); renderStats();
}
});
function currentImagePercent(){
if (!state.image) return 0;
var info = getRevealInfo(state.image);
return info.opaque > 0 ? Math.round((info.revealedCount / info.opaque) * 100) : 0;
}
function renderArtworkPanel(){
var info = q('currentArtworkInfo');
info.textContent = state.image
? (state.image.name + ' — ' + currentImagePercent() + '% revealed')
: 'No artwork in progress — pick one below.';
var grid = q('settingsArtGrid');
grid.innerHTML = '';
PRESETS.forEach(function(def){
grid.appendChild(buildArtTile(def, function(d){
chooseArtwork(d);
}, state.image && state.image.source === 'preset' && state.image.name === def.name));
});
var galleryGrid = q('galleryGrid');
galleryGrid.innerHTML = '';
q('galleryEmpty').hidden = state.gallery.length > 0;
state.gallery.slice().reverse().forEach(function(g){
var tile = document.createElement('div');
tile.className = 'gallery-tile';
var c = document.createElement('canvas');
renderThumbnail(c, g, 56);
var lbl = document.createElement('div');
lbl.textContent = g.name + (g.complete ? ' ✓' : ' (unfinished)');
tile.appendChild(c); tile.appendChild(lbl);
galleryGrid.appendChild(tile);
});
}
function chooseArtwork(def){
if (state.image) {
var pct = currentImagePercent();
var ok = window.confirm('Switch artwork? "' + state.image.name + '" is ' + pct +
'% done — it will move to your gallery as unfinished. Continue?');
if (!ok) return;
}
startNewImage(def);
saveNow();
renderArtworkPanel(); render();
}
var lastUploadResult = null;
function updateUploadPreview(){
var file = q('uploadFile').files[0];
var gridSize = parseInt(q('uploadGridSize').value, 10);
if (!file) return;
downsampleImageFile(file, gridSize).then(function(result){
q('uploadPreviewRow').hidden = false;
renderThumbnail(q('uploadPreviewCanvas'), result, 96);
var opaque = result.pixels.filter(function(p){ return p != null; }).length;
if (opaque === 0) {
// A fully-transparent grid could never be completed — block it.
lastUploadResult = null;
q('uploadEstimate').textContent =
'This one came out fully transparent — nothing to reveal! Try a different image or a bigger grid.';
q('useUploadBtn').disabled = true;
return;
}
lastUploadResult = result;
var habitCount = Math.max(1, state.habits.length);
var ppc = pixelsPerCheckFor(opaque, 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;
}).catch(function(){
lastUploadResult = null;
q('uploadPreviewRow').hidden = true;
q('useUploadBtn').disabled = true;
window.alert("Hmm, that didn't look like an image Paint by Habits could read. Try a different file?");
});
}
q('uploadFile').addEventListener('change', updateUploadPreview);
q('uploadGridSize').addEventListener('change', updateUploadPreview);
q('useUploadBtn').addEventListener('click', function(){
if (!lastUploadResult) return;
var name = (q('uploadName').value || '').trim().slice(0, 40) || 'My Upload';
var def = { name: name, source: 'upload', w: lastUploadResult.w, h: lastUploadResult.h, pixels: lastUploadResult.pixels };
chooseArtwork(def);
q('uploadFile').value = '';
q('uploadName').value = '';
q('uploadPreviewRow').hidden = true;
q('useUploadBtn').disabled = true;
lastUploadResult = null;
});
q('celebrationNext').addEventListener('click', function(){
hideCelebration();
openSettings('artwork');
});
q('celebrationClose').addEventListener('click', hideCelebration);
/* ---- backup: export / import ---- */
function exportFilename(){ return 'paint-by-habits-backup-' + todayStr() + '.json'; }
q('exportBtn').addEventListener('click', function(){
var json = JSON.stringify(state, null, 2);
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = exportFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function(){ URL.revokeObjectURL(url); }, 1000);
});
q('copyBtn').addEventListener('click', function(){
var json = JSON.stringify(state, null, 2);
function ok(){ window.alert('Backup copied to clipboard.'); }
function fail(){
try {
var ta = document.createElement('textarea');
ta.value = json;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
window.alert('Backup copied to clipboard.');
} catch (e) {
window.prompt('Copy this backup manually:', json);
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(json).then(ok, fail);
} else {
fail();
}
});
q('importFile').addEventListener('change', function(){
var file = q('importFile').files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(){
var parsed;
try {
parsed = JSON.parse(reader.result);
if (!validateShape(parsed) || parsed.version !== 1) throw new Error('bad shape');
} catch (e) {
window.alert("That file doesn't look like a valid Paint by Habits backup. Nothing was changed.");
q('importFile').value = '';
return;
}
var normalized = normalizeState(parsed);
var checks = totalChecksAllTime(normalized.log);
var summary = normalized.habits.length + ' habit' + (normalized.habits.length === 1 ? '' : 's') + ', ' +
checks + ' check' + (checks === 1 ? '' : 's') + ', ' +
(normalized.image ? '1 artwork in progress' : 'no artwork in progress') + '. Replace current data?';
if (window.confirm(summary)) {
state = normalized;
isFirstRun = false;
resetArtRuntime();
saveNow();
applyTheme();
render();
renderSettingsPanels();
window.alert('Backup restored.');
}
q('importFile').value = '';
};
reader.onerror = function(){
window.alert("Couldn't read that file. Nothing was changed.");
q('importFile').value = '';
};
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(){
var t = todayStr();
if (t !== currentDay) {
currentDay = t;
render();
}
}
q('themeToggle').addEventListener('click', toggleTheme);
/* ==========================================================================
INIT
========================================================================== */
function init(){
state = loadState();
if (!storageAvailable) setStorageWarning(true);
applyTheme();
render();
setInterval(checkRollover, 30000);
document.addEventListener('visibilitychange', function(){ if (!document.hidden) checkRollover(); });
window.addEventListener('resize', debounce(function(){
artLayout = null;
if (displayImage()) renderArt();
}, 150));
}
init();
})();
</script>
</body>
</html>