// ════════════════════════════════════════════════════════════
// Caulis — core: palette, icons, data, shared primitives
// ════════════════════════════════════════════════════════════
const { useState, useEffect, useRef } = React;
function useWindowWidth() {
const [w, setW] = useState(() => window.innerWidth);
useEffect(() => {
const handler = () => setW(window.innerWidth);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return w;
}
const DESKTOP_BP = 900;
const APP_VERSION = '93'; // keep in sync with sw.js CACHE
// motion tokens — one scale for every transition so the app feels consistent
const MOTION = {
out: 'cubic-bezier(.2,.8,.2,1)', // standard ease-out
spring: 'cubic-bezier(.34,1.56,.64,1)', // playful overshoot
fast: 160, base: 240, slow: 320,
};
const C = {
bg: '#FAFAF7',
panel: '#FFFFFF',
forest: '#2D5016',
sage: '#7A9E4E',
brown: '#6B4C2A',
ink: '#2A2A26',
line: 'rgba(45,80,22,0.08)',
hair: '0.5px solid rgba(45,80,22,0.08)',
};
const C_LIGHT = { ...C, input: '#F2F2EE', toast: '#2A2A26' };
const C_DARK = {
bg: '#111610',
panel: '#192115',
forest: '#7EC870',
sage: '#A0C876',
brown: '#C4A882',
ink: '#DCE8CC',
line: 'rgba(255,255,255,0.07)',
hair: '0.5px solid rgba(255,255,255,0.08)',
input: '#1E2A1A',
toast: '#243019',
};
C.input = C_LIGHT.input;
C.toast = C_LIGHT.toast;
const FONT_SERIF = '"Cormorant Garamond", serif';
const FONT_SANS = '"DM Sans", sans-serif';
// QR generator (forest-green ink on warm ground) ------------
function qrUrl(data, size = 240) {
return `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&margin=0&qzone=1&color=2D5016&bgcolor=FAFAF7&data=${encodeURIComponent(data)}`;
}
const PLANT_QR_URL = id => {
let g = '';
try { g = localStorage.getItem('caulis_garden_node') || ''; } catch(e) {}
return `https://cybutr.github.io/Caulis/?plant=${id}${g ? '&g='+encodeURIComponent(g) : ''}`;
};
// soft specimen tints ---------------------------------------
const TINTS_LIGHT = ['#E7EDDE','#EEEAE0','#E3EAD6','#ECE7DC','#E9EEE2','#EDE9DF','#E6ECE0','#EFE9DE'];
const TINTS_DARK = ['#1A2416','#201C12','#182210','#1E1C14','#1A2014','#201E14','#182016','#201A12'];
const TINTS = [...TINTS_LIGHT];
// curated accent palettes — only the accent pair shifts, paper/ink stay (max consistency)
const PALETTES = {
forest: { label:'Forest', swatch:'#2D5016', light:{ forest:'#2D5016', sage:'#7A9E4E' }, dark:{ forest:'#7EC870', sage:'#A0C876' } },
teal: { label:'Teal', swatch:'#15605A', light:{ forest:'#15605A', sage:'#3E9E92' }, dark:{ forest:'#5FC7BC', sage:'#76C8BE' } },
plum: { label:'Plum', swatch:'#5A2456', light:{ forest:'#5A2456', sage:'#9E4E92' }, dark:{ forest:'#C870BC', sage:'#C876BE' } },
clay: { label:'Clay', swatch:'#8A3A1E', light:{ forest:'#8A3A1E', sage:'#C07A4E' }, dark:{ forest:'#D4885F', sage:'#D8A074' } },
};
const PALETTE_ORDER = ['forest','teal','plum','clay'];
let activePalette = 'forest';
function applyTheme(dark, palette) {
if (palette && PALETTES[palette]) activePalette = palette;
const src = dark ? C_DARK : C_LIGHT;
Object.assign(C, src);
const pal = PALETTES[activePalette] || PALETTES.forest;
Object.assign(C, dark ? pal.dark : pal.light);
const ss = dark ? STATUS_DARK : STATUS_LIGHT;
Object.assign(STATUS.ok, ss.ok);
Object.assign(STATUS.soon, ss.soon);
Object.assign(STATUS.needs, ss.needs);
const ts = dark ? TINTS_DARK : TINTS_LIGHT;
ts.forEach((t, i) => { TINTS[i] = t; });
if (typeof document !== 'undefined') {
let el = document.getElementById('caulis-theme');
if (!el) { el = document.createElement('style'); el.id = 'caulis-theme'; document.head.appendChild(el); }
el.textContent = dark
? 'input::placeholder,textarea::placeholder{color:rgba(220,232,204,0.32)!important}'
: 'input::placeholder,textarea::placeholder{color:rgba(42,42,38,0.34)!important}';
}
}
// ── status from days-since vs interval ────────────────────
function statusOf(days, every) {
const r = days / every;
if (r >= 1) return 'needs';
if (r >= 0.7) return 'soon';
return 'ok';
}
const STATUS_LIGHT = {
ok: { dot: '#6E9A3E', ring: 'rgba(110,154,62,0.18)', soft: 'rgba(110,154,62,0.12)', label: 'Healthy' },
soon: { dot: '#C98A2B', ring: 'rgba(201,138,43,0.18)', soft: 'rgba(201,138,43,0.12)', label: 'Water soon' },
needs: { dot: '#B4472E', ring: 'rgba(180,71,46,0.18)', soft: 'rgba(180,71,46,0.12)', label: 'Needs water' },
};
const STATUS_DARK = {
ok: { dot: '#72C050', ring: 'rgba(114,192,80,0.22)', soft: 'rgba(114,192,80,0.16)', label: 'Healthy' },
soon: { dot: '#D4962E', ring: 'rgba(212,150,46,0.22)', soft: 'rgba(212,150,46,0.16)', label: 'Water soon' },
needs: { dot: '#D45840', ring: 'rgba(212,88,64,0.22)', soft: 'rgba(212,88,64,0.16)', label: 'Needs water' },
};
const STATUS = {
ok: { ...STATUS_LIGHT.ok },
soon: { ...STATUS_LIGHT.soon },
needs: { ...STATUS_LIGHT.needs },
};
function agoLabel(days) {
if (days <= 0) return 'Watered today';
if (days === 1) return 'Watered yesterday';
return `Watered ${days} days ago`;
}
// local YYYY-MM-DD (never UTC — avoids off-by-one in +UTC timezones)
function fmtLocalDate(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
// ── elapsed-days from an absolute watered timestamp (the real clock) ──
const DAY_MS = 86400000;
function todayMidnight() { const d = new Date(); d.setHours(0,0,0,0); return d.getTime(); }
function midnightFromStamp(stamp) { const [y,m,d] = String(stamp).split('-').map(Number); const dt = new Date(y, (m||1)-1, d||1); dt.setHours(0,0,0,0); return dt.getTime(); }
function daysSinceMidnight(ms) { return Math.max(0, Math.round((todayMidnight() - ms) / DAY_MS)); }
// absolute watered timestamp for a plant. trust an existing wateredAt only once
// the plant carries the current schema marker (wv) — earlier builds wrote a bad
// "today" stamp, so unmarked plants are recomputed from history, else from days
// plus a 5-day legacy backfill. idempotent: re-runs until the marker is stamped.
const WATER_SCHEMA = 3;
function deriveWateredAt(p) {
if (p.wv === WATER_SCHEMA && typeof p.wateredAt === 'number') return p.wateredAt;
const h = Array.isArray(p.history) ? p.history : [];
if (h.length) return midnightFromStamp(h[h.length - 1]);
return todayMidnight() - (p.days || 0) * DAY_MS;
}
// watering log summary from an array of 'YYYY-MM-DD' strings (newest last)
function wateringStats(history) {
const h = Array.isArray(history) ? history : [];
const cutoff = new Date(); cutoff.setHours(0,0,0,0); cutoff.setDate(cutoff.getDate() - 30);
const count30 = h.filter(s => { const [y,m,d] = s.split('-').map(Number); return new Date(y, m-1, d) >= cutoff; }).length;
return { total: h.length, count30, last: h.length ? h[h.length-1] : null };
}
// live weekday + part-of-day, e.g. "Saturday morning"
function todayGreeting() {
const d = new Date();
const wd = d.toLocaleDateString('en-US', { weekday: 'long' });
const h = d.getHours();
const part = h < 12 ? 'morning' : h < 18 ? 'afternoon' : 'evening';
return `${wd} ${part}`;
}
// ════════════════════════════════════════════════════════════
// Botanical glyphs
// ════════════════════════════════════════════════════════════
function Leaf({ size = 22, color = C.forest, opacity = 1 }) {
return (
);
}
function LeafOutline({ size = 22, color = C.forest, sw = 1.4 }) {
return (
);
}
function Sprig({ w = 260, h = 300, right = -26, bottom = -22, opacity = 0.2 }) {
const leaf = (cx, cy, rot) => (