// ════════════════════════════════════════════════════════════ // 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) => ( ); return ( {leaf(150,232,38)}{leaf(133,188,-34)}{leaf(126,150,30)} {leaf(128,112,-28)}{leaf(146,76,22)}{leaf(170,46,-18)} ); } // ════════════════════════════════════════════════════════════ // UI icons (simple line set) // ════════════════════════════════════════════════════════════ function IconGarden({ s = 24, c = C.ink, a = 1 }) { return ( ); } function IconDrop({ s = 24, c = C.ink, a = 1, fill = false }) { return ( ); } function IconScan({ s = 26, c = '#fff' }) { return ( ); } function IconPrint({ s = 24, c = C.ink, a = 1 }) { return ( ); } function IconGear({ s = 24, c = C.ink, a = 1 }) { return ( ); } function IconPlus({ s = 16, c = C.forest, w = 1.7 }) { return (); } function IconBack({ s = 20, c = C.forest }) { return (); } function IconMore({ s = 24, c = C.ink, a = 1 }) { return (); } function IconDoctor({ s = 24, c = C.ink, a = 1 }) { return ( ); } function IconCheck({ s = 18, c = '#fff', w = 2 }) { return (); } function IconPin({ s = 13, c = C.brown }) { return ( ); } // ════════════════════════════════════════════════════════════ // Small shared components // ════════════════════════════════════════════════════════════ function StatusDot({ status, size = 9 }) { const s = STATUS[status]; return
; } function LocationPill({ label }) { return ( {label} ); } function StatusTag({ status }) { const s = STATUS[status]; return ( {s.label} ); } // Specimen image placeholder OR real photo (tinted block w/ leaf motif) --- function Specimen({ tint, height, radius = 15, leafSize = 46, caption, image }) { const [failed, setFailed] = useState(false); const [loaded, setLoaded] = useState(false); const imgRef = useRef(null); // cached images can finish loading before React attaches onLoad — catch that useEffect(() => { setLoaded(false); const el = imgRef.current; if (el && el.complete && el.naturalWidth > 0) setLoaded(true); }, [image]); const showImg = image && !failed; return (
{showImg && ( setFailed(true)} onLoad={()=>setLoaded(true)} style={{ position:'absolute', inset:0, width:'100%', height:'100%', objectFit:'cover', display:'block', opacity: loaded?1:0, transform: loaded?'none':'scale(1.06)', filter: loaded?'none':'blur(14px)', transition:`opacity ${MOTION.base}ms ${MOTION.out}, transform ${MOTION.slow}ms ${MOTION.out}, filter ${MOTION.slow}ms ${MOTION.out}` }}/> )} {showImg && (
)} {caption && !showImg && ( {caption} )}
); } // ════════════════════════════════════════════════════════════ // Data — locations (plants are built in caulis-perenual.jsx) // ════════════════════════════════════════════════════════════ const SEED_LOCATIONS = ['Living room','Bedroom','Kitchen windowsill','Bathroom','Office','Balcony']; // ── customizable bottom navigation ─────────────────────────── const NAV_ACTIONS = { garden: { label:'Garden', Icon:IconGarden, tab:true }, needs: { label:'Water', Icon:IconDrop, tab:true }, scanner: { label:'Scan', Icon:IconScan, tab:true }, print: { label:'Queue', Icon:IconPrint, tab:true }, settings: { label:'Settings', Icon:IconGear, tab:true }, add: { label:'Add', Icon:IconPlus, tab:false }, doctor: { label:'Doctor', Icon:IconDoctor, tab:false }, more: { label:'More', Icon:IconMore, tab:false }, }; const NAV_ORDER = ['garden','needs','scanner','print','settings','add','doctor','more']; const NAV_MAX = 7; const DEFAULT_NAV = [ { action:'garden' }, { action:'needs' }, { action:'scanner', center:true }, { action:'print' }, { action:'settings' }, ]; function normalizeNav(cfg) { if (!Array.isArray(cfg) || !cfg.length) return DEFAULT_NAV.map(s => ({ ...s })); const slots = cfg.slice(0, NAV_MAX).map(s => { const out = { action: NAV_ACTIONS[s && s.action] ? s.action : 'empty', center: !!(s && s.center) }; if (s && typeof s.label === 'string' && s.label.trim()) out.label = s.label.slice(0, 18); if (s && typeof s.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(s.color)) out.color = s.color; return out; }); if (!slots.length) return DEFAULT_NAV.map(s => ({ ...s })); if (!slots.some(s => s.center)) { const i = slots.findIndex(s => s.action !== 'empty'); if (i >= 0) slots[i].center = true; } let seen = false; for (const s of slots) { if (s.center && !seen) seen = true; else s.center = false; } return slots; } const navLabel = (s) => (s && s.label) || (NAV_ACTIONS[s && s.action] ? NAV_ACTIONS[s.action].label : ''); const navColor = (s) => (s && s.color) || C.forest; // ordered tab actions present in the bar — what swipes and launch-tab respect function navTabOrder(cfg) { const seen = new Set(); const order = normalizeNav(cfg) .filter(s => s.action !== 'empty' && NAV_ACTIONS[s.action] && NAV_ACTIONS[s.action].tab) .map(s => s.action) .filter(a => (seen.has(a) ? false : seen.add(a))); return order.length ? order : ['garden']; } // export to window for other babel scripts ------------------- Object.assign(window, { C, FONT_SERIF, FONT_SANS, qrUrl, TINTS, statusOf, STATUS, agoLabel, todayGreeting, fmtLocalDate, wateringStats, todayMidnight, midnightFromStamp, daysSinceMidnight, deriveWateredAt, WATER_SCHEMA, NAV_ACTIONS, NAV_ORDER, NAV_MAX, DEFAULT_NAV, normalizeNav, navTabOrder, navLabel, navColor, PALETTES, PALETTE_ORDER, Leaf, LeafOutline, Sprig, IconGarden, IconDrop, IconScan, IconPrint, IconGear, IconPlus, IconBack, IconCheck, IconPin, IconDoctor, IconMore, StatusDot, LocationPill, StatusTag, Specimen, SEED_LOCATIONS, useWindowWidth, DESKTOP_BP, PLANT_QR_URL, applyTheme, APP_VERSION, MOTION, });