|
|
<!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; align-items:center; gap:var(--space-2); }
|
|
|
.today-label{ font-weight:700; flex:1; text-align:center; }
|
|
|
.today-label.past{ color:var(--accent-strong); }
|
|
|
.day-nav{ width:34px; height:34px; flex-shrink:0; display:flex; align-items:center; justify-content:center;
|
|
|
border-radius:var(--radius-md); border:1px solid var(--border); background:var(--bg-sunken);
|
|
|
color:var(--text); cursor:pointer; font-size:1.2rem; line-height:1; padding:0; }
|
|
|
.day-nav:hover{ filter:brightness(1.05); }
|
|
|
.day-nav:disabled{ opacity:0.3; cursor:not-allowed; filter:none; }
|
|
|
.today-back{ align-self:center; font-size:0.78rem; color:var(--accent-strong); background:none; border:none;
|
|
|
cursor:pointer; text-decoration:underline; padding:2px; }
|
|
|
.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; flex:1; }
|
|
|
.habit-streak{ color:var(--accent-strong); font-weight:700; font-size:0.85rem; flex-shrink:0; }
|
|
|
.habit-checkbox{ width:26px; height:26px; margin:0 var(--space-3) 0 0; accent-color:var(--accent-strong); cursor:pointer; flex-shrink:0; }
|
|
|
.empty-state{ color:var(--text-dim); font-style:italic; text-align:center; margin:var(--space-2) 0; }
|
|
|
|
|
|
/* ---- 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); }
|
|
|
.changelog-entry{ margin-bottom:var(--space-4); }
|
|
|
.changelog-date{ font-weight:700; font-size:0.9rem; color:var(--accent-strong); margin-bottom:var(--space-2); }
|
|
|
.changelog-list{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:var(--space-2); }
|
|
|
.changelog-list li{ color:var(--text-dim); font-size:0.9rem; line-height:1.5;
|
|
|
padding-left:var(--space-3); border-left:2px solid var(--accent-soft); }
|
|
|
|
|
|
/* ---- 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">
|
|
|
<button id="dayPrev" class="day-nav" type="button" aria-label="Previous day" title="Previous day">‹</button>
|
|
|
<span id="todayLabel" class="today-label">Today</span>
|
|
|
<button id="dayNext" class="day-nav" type="button" aria-label="Next day" title="Next day">›</button>
|
|
|
</div>
|
|
|
<button id="dayBackToday" class="today-back" type="button" hidden>↩ Back to today</button>
|
|
|
<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="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">
|
|
|
Created by <a href="https://elijah.run">Elijah Voigt</a>
|
|
|
Made using Claude Code · Sonnet 5, Opus 4.8 & 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>
|
|
|
<button class="tab-btn" data-tab="changelog" role="tab" aria-selected="false" type="button">Changelog</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>Each habit has its own streak.</strong> A habit's pixel value is its current streak
|
|
|
length — check it off once, that's 1 pixel; keep checking it every consecutive day and it's
|
|
|
worth 2, then 3, then 4, and so on. Missing a day resets that habit's streak back to 1 the next
|
|
|
time you check it, but never erases pixels you've already earned.</p>
|
|
|
<p>Every habit scores on its own — there's no bonus for doing them all in one day, just each
|
|
|
one quietly climbing the longer you keep it up.</p>
|
|
|
<p><strong>Forgot to check off yesterday?</strong> Use the ‹ / › arrows above your habit list to
|
|
|
step back to an earlier day and tick it off — the streak mends and the pixels you missed
|
|
|
paint right in.</p>
|
|
|
<p>Everything lives only in this browser's local storage, on this device. Nothing is ever sent
|
|
|
anywhere.</p>
|
|
|
<p>
|
|
|
Created by <a href="https://elijah.run">Elijah Voigt</a>
|
|
|
Made using Claude Code · Sonnet 5, Opus 4.8 & Fable 5
|
|
|
Feedback? Email <a href="mailto:pop@elijah.run">pop@elijah.run</a>
|
|
|
</p>
|
|
|
</div>
|
|
|
<div class="tab-panel" id="panel-changelog" hidden role="tabpanel">
|
|
|
<div class="section-title">What's new</div>
|
|
|
<div id="changelogList"></div>
|
|
|
</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)),
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
// Per-habit streaks, 1 unit = 1 pixel: each habit tracks its own streak of
|
|
|
// consecutive days it was checked. Checking a habit reveals pixels equal to
|
|
|
// its streak length as of that check: 1 pixel the first day, 2 the next
|
|
|
// consecutive day, 3 the day after, and so on. There is no cross-habit
|
|
|
// multiplier — every habit scores purely on its own history.
|
|
|
// Revealed pixels are re-derived from the log every time rather than stored,
|
|
|
// so undo/redo and reloads stay perfectly consistent and past days can never
|
|
|
// be un-revealed by unchecking today (a habit's streak on a given day counts
|
|
|
// backwards from that day, so it is unaffected by checking or unchecking
|
|
|
// that habit on later days).
|
|
|
|
|
|
// A single habit's streak ending on (and including) `day`: consecutive days
|
|
|
// with that habit checked. 0 if the habit wasn't checked on `day` itself.
|
|
|
function habitStreakEndingOn(log, habitId, day){
|
|
|
var streak = 0, cursor = day;
|
|
|
while (log[cursor] && log[cursor].indexOf(habitId) !== -1) {
|
|
|
streak++;
|
|
|
cursor = addDaysStr(cursor, -1);
|
|
|
}
|
|
|
return streak;
|
|
|
}
|
|
|
|
|
|
function revealedUnits(image){
|
|
|
var units = 0;
|
|
|
for (var d in state.log) {
|
|
|
if (d >= image.startedAt) {
|
|
|
var checked = state.log[d] || [];
|
|
|
for (var i = 0; i < checked.length; i++) {
|
|
|
units += habitStreakEndingOn(state.log, checked[i], d);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return units;
|
|
|
}
|
|
|
|
|
|
function getRevealInfo(image){
|
|
|
var order = getRevealOrder(image);
|
|
|
var opaque = order.length;
|
|
|
var revealedCount = Math.min(opaque, revealedUnits(image));
|
|
|
var set = new Set(order.slice(0, revealedCount));
|
|
|
return { order: order, opaque: opaque, revealedCount: revealedCount, set: set };
|
|
|
}
|
|
|
|
|
|
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){
|
|
|
return {
|
|
|
name: def.name, source: def.source, w: def.w, h: def.h,
|
|
|
pixels: def.pixels.slice(),
|
|
|
seed: randomSeed(),
|
|
|
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]);
|
|
|
}
|
|
|
|
|
|
// A habit's current streak for display: same as habitStreakEndingOn, but if
|
|
|
// the habit hasn't been checked yet today the streak is still shown as
|
|
|
// "alive" (counted through yesterday) rather than dropping to 0 the instant
|
|
|
// the day rolls over — it only truly breaks once a day passes with no check.
|
|
|
function currentHabitStreak(log, habitId, today){
|
|
|
var day = habitStreakEndingOn(log, habitId, today) > 0 ? today : addDaysStr(today, -1);
|
|
|
return habitStreakEndingOn(log, habitId, day);
|
|
|
}
|
|
|
|
|
|
function isPerfectDay(log, today, habits){
|
|
|
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();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// The day currently shown in the Today card. Defaults to today, but the ‹/›
|
|
|
// nav lets the user step back to check off a day they forgot. Reveal is
|
|
|
// derived from the log with each habit's streak counted backwards from its
|
|
|
// day, so backfilling a missed day correctly re-bridges the streak and
|
|
|
// repaints the pixels it earns — no special-casing needed.
|
|
|
var selectedDay = todayStr();
|
|
|
|
|
|
// Earliest day the user may edit: the active image's start. Editing days
|
|
|
// before the current picture existed can't paint any pixels, so we don't
|
|
|
// allow wandering there; with no active image the only editable day is today.
|
|
|
function earliestEditableDay(){
|
|
|
return (state.image && state.image.startedAt) ? state.image.startedAt : todayStr();
|
|
|
}
|
|
|
|
|
|
// Keep the viewed day inside [earliestEditableDay, today]. Called before any
|
|
|
// render so a rollover, image completion, or import can't strand the view on
|
|
|
// an out-of-range day.
|
|
|
function clampSelectedDay(){
|
|
|
var today = todayStr();
|
|
|
if (selectedDay > today) selectedDay = today;
|
|
|
var min = earliestEditableDay();
|
|
|
if (selectedDay < min) selectedDay = min;
|
|
|
}
|
|
|
|
|
|
function renderTodayHeader(){
|
|
|
clampSelectedDay();
|
|
|
var today = todayStr();
|
|
|
var isToday = selectedDay === today;
|
|
|
var d = parseDateLocal(selectedDay);
|
|
|
var dateText = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
|
|
var label = (isToday ? 'Today · ' : '') + dateText;
|
|
|
if (isPerfectDay(state.log, selectedDay, state.habits)) label += ' ✨';
|
|
|
var labelEl = q('todayLabel');
|
|
|
labelEl.textContent = label;
|
|
|
labelEl.classList.toggle('past', !isToday);
|
|
|
q('dayPrev').disabled = selectedDay <= earliestEditableDay();
|
|
|
q('dayNext').disabled = isToday;
|
|
|
q('dayBackToday').hidden = isToday;
|
|
|
}
|
|
|
|
|
|
function renderHabits(){
|
|
|
clampSelectedDay();
|
|
|
var list = q('habitList');
|
|
|
var today = todayStr();
|
|
|
var viewDay = selectedDay;
|
|
|
var checkedDay = state.log[viewDay] || [];
|
|
|
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 = checkedDay.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);
|
|
|
// On today, show the "alive through yesterday" streak; on a past day,
|
|
|
// show the streak as it actually stood ending on that day.
|
|
|
var streak = (viewDay === today)
|
|
|
? currentHabitStreak(state.log, h.id, viewDay)
|
|
|
: habitStreakEndingOn(state.log, h.id, viewDay);
|
|
|
if (streak > 0) {
|
|
|
var streakSpan = document.createElement('span');
|
|
|
streakSpan.className = 'habit-streak';
|
|
|
streakSpan.textContent = '🔥' + streak;
|
|
|
label.appendChild(streakSpan);
|
|
|
}
|
|
|
|
|
|
var cb = document.createElement('input');
|
|
|
cb.type = 'checkbox';
|
|
|
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('statChecks').textContent = totalChecksAllTime(state.log);
|
|
|
q('statImages').textContent = state.gallery.filter(function(g){ return g.complete; }).length;
|
|
|
}
|
|
|
|
|
|
function onHabitToggle(habitId, wantChecked, rowEl){
|
|
|
// Edits apply to the day being viewed (today, or a past day the user
|
|
|
// navigated to). Reveal is recomputed from the whole log, so the delta
|
|
|
// below correctly accounts for streaks a backfilled day bridges.
|
|
|
var day = selectedDay;
|
|
|
if (!state.log[day]) state.log[day] = [];
|
|
|
var list = state.log[day];
|
|
|
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[day]; // don't keep empty day arrays around
|
|
|
// Reveal is derived from the log, so unchecking recomputes the whole
|
|
|
// painting from scratch — the pixels that check was worth (including any
|
|
|
// streak it bridged) come back off.
|
|
|
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 };
|
|
|
q('obUploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
|
|
|
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();
|
|
|
renderChangelog();
|
|
|
}
|
|
|
|
|
|
// Release history, newest first, distilled from the project's beans issues —
|
|
|
// one concise sentence per shipped change. Keep this in sync when a
|
|
|
// user-facing feature or fix lands.
|
|
|
var CHANGELOG = [
|
|
|
{ date: '2026-07-18', items: [
|
|
|
'Added a day navigator so you can step back to an earlier day and check off a habit you forgot — the pixels you missed paint in.'
|
|
|
] },
|
|
|
{ date: '2026-07-16', items: [
|
|
|
'Reworked streaks to be per-habit: each habit now tracks and shows its own streak instead of one shared number.'
|
|
|
] },
|
|
|
{ date: '2026-07-15', items: [
|
|
|
'Added a Data tab to export, import, and wipe your data, a page footer, and streak-boosted pixel rewards.'
|
|
|
] },
|
|
|
{ date: '2026-07-14', items: [
|
|
|
'Made daily rewards escalate so each additional habit checked in a day reveals more pixels.',
|
|
|
'Added a live browser-tab favicon that fills in to mirror your artwork’s progress.',
|
|
|
'Renamed the app to “Paint by Habits” and refreshed its visual design.',
|
|
|
'Fixed the celebration screen getting stuck on-screen after finishing a picture.',
|
|
|
'Initial release: a single-file pixel-art habit tracker that reveals artwork as you build habits.'
|
|
|
] }
|
|
|
];
|
|
|
|
|
|
function renderChangelog(){
|
|
|
var host = q('changelogList');
|
|
|
host.innerHTML = '';
|
|
|
CHANGELOG.forEach(function(entry){
|
|
|
var wrap = document.createElement('div');
|
|
|
wrap.className = 'changelog-entry';
|
|
|
var dateEl = document.createElement('div');
|
|
|
dateEl.className = 'changelog-date';
|
|
|
dateEl.textContent = parseDateLocal(entry.date)
|
|
|
.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
|
|
|
var ul = document.createElement('ul');
|
|
|
ul.className = 'changelog-list';
|
|
|
entry.items.forEach(function(text){
|
|
|
var li = document.createElement('li');
|
|
|
li.textContent = text;
|
|
|
ul.appendChild(li);
|
|
|
});
|
|
|
wrap.appendChild(dateEl);
|
|
|
wrap.appendChild(ul);
|
|
|
host.appendChild(wrap);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
q('uploadEstimate').textContent = opaque + ' opaque pixel' + (opaque === 1 ? '' : 's') + ' to reveal.';
|
|
|
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) {
|
|
|
// If the user was viewing "today", carry them to the new today; if they
|
|
|
// were editing a past day, leave them there.
|
|
|
if (selectedDay === currentDay) selectedDay = t;
|
|
|
currentDay = t;
|
|
|
render();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
q('themeToggle').addEventListener('click', toggleTheme);
|
|
|
|
|
|
/* ---- day navigation (backfill missed days) ---- */
|
|
|
function gotoDay(day){
|
|
|
selectedDay = day;
|
|
|
clampSelectedDay();
|
|
|
renderHabits();
|
|
|
renderTodayHeader();
|
|
|
}
|
|
|
q('dayPrev').addEventListener('click', function(){ gotoDay(addDaysStr(selectedDay, -1)); });
|
|
|
q('dayNext').addEventListener('click', function(){ gotoDay(addDaysStr(selectedDay, 1)); });
|
|
|
q('dayBackToday').addEventListener('click', function(){ gotoDay(todayStr()); });
|
|
|
|
|
|
/* ==========================================================================
|
|
|
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>
|