// ════════════════════════════════════════════════════════════ // Caulis — badges: definitions, ambient decoration, interactive shelf // ════════════════════════════════════════════════════════════ // ── badge glyphs — same stroke-based line language as the core icon set ── function BadgeIconSprout({ s = 22, c = C.forest }) { return (); } function BadgeIconCluster({ s = 22, c = C.forest }) { return (); } function BadgeIconArboretum({ s = 22, c = C.forest }) { return (); } function BadgeIconSteady({ s = 22, c = C.forest }) { return (); } function BadgeIconSpecies({ s = 22, c = C.forest }) { return (); } function BadgeIconRooms({ s = 22, c = C.forest }) { return (); } function BadgeIconThriving({ s = 22, c = C.forest }) { return (); } function BadgeIconPropagate({ s = 22, c = C.forest }) { return (); } function BadgeIconPortrait({ s = 22, c = C.forest }) { return (); } function BadgeIconCalendar({ s = 22, c = C.forest }) { return (); } function BadgeIconLantern({ s = 22, c = C.forest }) { return (); } function BadgeIconDroplet({ s = 22, c = C.forest }) { return (); } function BadgeIconCamel({ s = 22, c = C.forest }) { return (); } function BadgeIconMoon({ s = 22, c = C.forest }) { return (); } function BadgeIconKey({ s = 22, c = C.forest }) { return (); } function BadgeIconGamepad({ s = 22, c = C.forest }) { return (); } function BadgeIconViewfinder({ s = 22, c = C.forest }) { return (); } function BadgeIconCompass({ s = 22, c = C.forest }) { return (); } function BadgeIconSunrise({ s = 22, c = C.forest }) { return (); } function BadgeIconClover({ s = 22, c = C.forest }) { return (); } function BadgeIconStarburst({ s = 22, c = C.forest }) { return (); } function BadgeIconSpectrum({ s = 22, c = C.forest }) { return (); } function BadgeIconYinLeaf({ s = 22, c = C.forest }) { return (); } function BadgeIconPrism({ s = 22, c = C.forest }) { return (); } function BadgeIconHouseFull({ s = 22, c = C.forest }) { return (); } function BadgeIconMap({ s = 22, c = C.forest }) { return (); } function BadgeIconScale({ s = 22, c = C.forest }) { return (); } function BadgeIconOldFriend({ s = 22, c = C.forest }) { return (); } function BadgeIconSproutFace({ s = 22, c = C.forest }) { return (); } function BadgeIconSeal({ s = 22, c = C.forest }) { return (); } // ── badge definitions ───────────────────────────────────────── // check(state) receives { plants, locations, roomLight } — the same read of // state that already exists elsewhere (gardenHealthScore, milestone toast) // so nothing new needs to be tracked just for badges. const BADGE_MILESTONES = [ { n: 10, id: 'plants-10', name: 'Budding Collector', text: 'Ten plants deep, no signs of stopping.', Icon: BadgeIconSprout }, { n: 25, id: 'plants-25', name: 'Greenhouse Keeper', text: 'Twenty-five and counting.', Icon: BadgeIconCluster }, { n: 50, id: 'plants-50', name: 'Botanist', text: 'Fifty plants under one roof.', Icon: BadgeIconArboretum }, { n: 100, id: 'plants-100', name: 'The Arboretum', text: 'A hundred plants. This is a lifestyle now.', Icon: BadgeIconArboretum }, { n: 200, id: 'plants-200', name: 'Botanical Garden', text: 'Two hundred plants. There is no more room.', Icon: BadgeIconArboretum }, ]; // a plant is "on schedule" for a stretch of history if no gap between // consecutive waterings ran meaningfully past its own interval — a little // slack (15%) so an ordinary day-early/day-late doesn't break the streak function longestOnScheduleStreak(plant) { const h = Array.isArray(plant.history) ? plant.history : []; if (h.length < 2 || !plant.every) return h.length >= 8 ? h.length : 0; const slack = plant.every * 1.15; let best = 1, cur = 1; for (let i = 1; i < h.length; i++) { const gapDays = (midnightFromStamp(h[i]) - midnightFromStamp(h[i - 1])) / DAY_MS; if (gapDays <= slack) { cur++; best = Math.max(best, cur); } else cur = 1; } return best; } const BADGE_DEFS = [ { id: 'first-sprig', name: 'First Sprig', text: 'The one that started it all.', Icon: BadgeIconSprout, check: ({ plants }) => plants.length >= 1 }, ...BADGE_MILESTONES.map(m => ({ id: m.id, name: m.name, text: m.text, Icon: m.Icon, check: ({ plants }) => plants.length >= m.n })), { id: 'on-schedule', name: 'Steady Hand', text: 'Eight waterings, never once late.', Icon: BadgeIconSteady, check: ({ plants }) => plants.some(p => longestOnScheduleStreak(p) >= 8) }, { id: 'variety-species', name: "Collector's Eye", text: 'Eight distinct species, no two alike.', Icon: BadgeIconSpecies, check: ({ plants }) => new Set(plants.map(p => (p.latin || '').trim().toLowerCase()).filter(v => v && v !== '—')).size >= 8 }, { id: 'variety-rooms', name: 'Room to Room', text: 'Every room has something green.', Icon: BadgeIconRooms, check: ({ plants, locations }) => { if (!locations || locations.length < 3) return false; const withPlants = new Set(plants.map(p => p.location).filter(Boolean)); return locations.every(l => withPlants.has(l)); } }, { id: 'thriving-garden', name: "Everything's Thriving", text: 'Not one plant asking for water. Rare, and worth noting.', Icon: BadgeIconThriving, check: ({ plants }) => plants.length >= 5 && plants.every(p => statusOf(p.days, p.every, p.snoozedUntil) !== 'needs') }, { id: 'propagator', name: 'Cutting Edge', text: 'A new plant, grown from an old one.', Icon: BadgeIconPropagate, check: ({ plants }) => plants.some(p => p.propagatedFrom != null) }, { id: 'documented', name: 'Portrait Mode', text: 'You gave a plant its own photograph.', Icon: BadgeIconPortrait, check: ({ plants }) => plants.some(p => p.userImage || (p.photos && p.photos.length)) }, { id: 'documented-5', name: 'Full Gallery', text: 'Five plants, five photographs.', Icon: BadgeIconPortrait, check: ({ plants }) => plants.filter(p => p.userImage || (p.photos && p.photos.length)).length >= 5 }, { id: 'variety-species-15', name: 'The Herbarium', text: 'Fifteen distinct species under one roof.', Icon: BadgeIconSpecies, check: ({ plants }) => new Set(plants.map(p => (p.latin || '').trim().toLowerCase()).filter(v => v && v !== '—')).size >= 15 }, { id: 'on-schedule-16', name: 'Iron Watering Can', text: 'Sixteen waterings, never once late.', Icon: BadgeIconSteady, check: ({ plants }) => plants.some(p => longestOnScheduleStreak(p) >= 16) }, { id: 'thriving-10', name: 'Green Thumb', text: 'Ten plants thriving, not one asking for water.', Icon: BadgeIconThriving, check: ({ plants }) => plants.length >= 10 && plants.every(p => statusOf(p.days, p.every, p.snoozedUntil) !== 'needs') }, { id: 'garden-anniversary', name: 'One Year In', text: 'A full year of keeping something alive.', Icon: BadgeIconCalendar, check: ({ plants }) => { const stamps = plants.flatMap(p => (Array.isArray(p.history) ? p.history : [])); if (!stamps.length) return false; const earliest = stamps.reduce((min, s) => (s < min ? s : min), stamps[0]); return (Date.now() - earliest) >= 365 * DAY_MS; } }, { id: 'plant-anniversary', name: 'One Year Together', text: 'One plant, one full year, still on the windowsill.', Icon: BadgeIconCalendar, check: ({ plants }) => plants.some(p => p.addedAt && (Date.now() - p.addedAt) >= 365 * DAY_MS) }, { id: 'well-lit', name: 'Sun Room', text: 'Every plant matched to a room with the right light.', Icon: BadgeIconLantern, check: ({ plants, roomLight }) => plants.length >= 5 && !!roomLight && plants.every(p => roomLight[p.location] && !roomLightMismatch(p, roomLight[p.location])) }, // ── secret: not shown in the locked list until earned, discoverable only // by stumbling into the exact condition. Real conditions, no fake gating. { id: 'secret-night-owl', name: 'Night Owl', text: 'Tending the garden well past midnight.', Icon: BadgeIconMoon, secret: true, check: () => { const h = new Date().getHours(); return h >= 1 && h < 4; } }, { id: 'secret-thirsty', name: 'High Maintenance', text: 'A plant that needs water every single day.', Icon: BadgeIconDroplet, secret: true, check: ({ plants }) => plants.some(p => p.every <= 1) }, { id: 'secret-camel', name: 'Camel Plant', text: 'A plant that barely needs water at all.', Icon: BadgeIconCamel, secret: true, check: ({ plants }) => plants.some(p => p.every >= 45) }, { id: 'secret-konami', name: 'Old Habits', text: 'Some codes never leave muscle memory.', Icon: BadgeIconGamepad, secret: true, check: () => { try { return localStorage.getItem('caulis_egg_konami') === '1'; } catch(e) { return false; } } }, { id: 'secret-sprig', name: 'Leaf Me Alone', text: "Found what the corner sprig does when you won't stop.", Icon: BadgeIconSprout, secret: true, check: () => { try { return localStorage.getItem('caulis_egg_sprig') === '1'; } catch(e) { return false; } } }, { id: 'secret-viewfinder', name: 'Nothing to Scan', text: 'Tapped the scanner viewfinder until it talked back.', Icon: BadgeIconViewfinder, secret: true, check: () => { try { return localStorage.getItem('caulis_egg_viewfinder') === '1'; } catch(e) { return false; } } }, { id: 'variety-species-25', name: 'Living Encyclopedia', text: 'Twenty-five distinct species. You could write a field guide.', Icon: BadgeIconSpecies, check: ({ plants }) => new Set(plants.map(p => (p.latin || '').trim().toLowerCase()).filter(v => v && v !== '—')).size >= 25 }, { id: 'on-schedule-30', name: 'Clockwork Gardener', text: 'Thirty waterings, never once late.', Icon: BadgeIconSteady, check: ({ plants }) => plants.some(p => longestOnScheduleStreak(p) >= 30) }, // ── secret: same rules as above — real conditions, not shown until earned. // This batch leans hard into "tried everything" / "caught in the act" / // "found every other secret" territory, on purpose — the more of these a // returning user stumbles into, the more the ambient layer has to show. { id: 'secret-completionist', name: 'Every Corner Turned', text: 'Found every other hidden thing this app has to offer.', Icon: BadgeIconStarburst, secret: true, check: () => { try { return localStorage.getItem('caulis_egg_konami') === '1' && localStorage.getItem('caulis_egg_sprig') === '1' && localStorage.getItem('caulis_egg_viewfinder') === '1'; } catch(e) { return false; } } }, { id: 'secret-dawn', name: 'Dawn Patrol', text: 'Out in the garden before the sun’s properly up.', Icon: BadgeIconSunrise, secret: true, check: () => { const h = new Date().getHours(); return h >= 4 && h < 6; } }, { id: 'secret-friday13', name: 'Unlucky Sprout', text: 'Tending the garden on a Friday the 13th.', Icon: BadgeIconClover, secret: true, check: () => { const d = new Date(); return d.getDay() === 5 && d.getDate() === 13; } }, { id: 'secret-leap', name: 'Leap Day Gardener', text: 'A watering logged on the rarest date on the calendar.', Icon: BadgeIconStarburst, secret: true, check: () => { const d = new Date(); return d.getMonth() === 1 && d.getDate() === 29; } }, { id: 'secret-spectrum', name: 'Full Spectrum', text: 'Tried on every accent color in the wardrobe.', Icon: BadgeIconSpectrum, secret: true, check: () => { try { const seen = JSON.parse(localStorage.getItem('caulis_seen_accents') || '[]'); return ACCENT_ORDER.every(a => seen.includes(a)); } catch(e) { return false; } } }, { id: 'secret-two-sides', name: 'Two Sides of the Leaf', text: 'Seen the garden in both light and dark.', Icon: BadgeIconYinLeaf, secret: true, check: () => { try { const seen = JSON.parse(localStorage.getItem('caulis_seen_modes') || '[]'); return seen.includes('light') && seen.includes('dark'); } catch(e) { return false; } } }, { id: 'secret-shapeshifter', name: 'Shape Shifter', text: 'Tried every corner on the radius scale.', Icon: BadgeIconPrism, secret: true, check: () => { try { const seen = JSON.parse(localStorage.getItem('caulis_seen_radius') || '[]'); return RADIUS_ORDER.every(r => seen.includes(r)); } catch(e) { return false; } } }, { id: 'secret-monoroom', name: 'One Room Empire', text: 'Eight plants, one room, no compromise.', Icon: BadgeIconHouseFull, secret: true, check: ({ plants }) => { if (plants.length < 8) return false; const locs = new Set(plants.map(p => p.location).filter(Boolean)); return locs.size === 1; } }, { id: 'secret-sprawl', name: 'Sprawling Estate', text: 'Ten different rooms, all of them green.', Icon: BadgeIconMap, secret: true, check: ({ plants }) => new Set(plants.map(p => p.location).filter(Boolean)).size >= 10 }, { id: 'secret-opposites', name: 'Opposites Thrive', text: 'One plant that barely drinks, one that never stops asking.', Icon: BadgeIconScale, secret: true, check: ({ plants }) => plants.some(p => p.every <= 1) && plants.some(p => p.every >= 45) }, { id: 'secret-old-friend', name: 'Old Friend', text: 'Thirty waterings for one plant. That’s a relationship.', Icon: BadgeIconOldFriend, secret: true, check: ({ plants }) => plants.some(p => Array.isArray(p.history) && p.history.length >= 30) }, { id: 'secret-well-traveled', name: 'Well Traveled', text: 'Visited three different gardens from one device.', Icon: BadgeIconCompass, secret: true, check: () => { try { const hist = JSON.parse(localStorage.getItem('caulis_gardens') || '[]'); return Array.isArray(hist) && hist.length >= 3; } catch(e) { return false; } } }, { id: 'secret-its-alive', name: 'It’s Alive', text: 'A plant named after the most famous carnivore in fiction.', Icon: BadgeIconSproutFace, secret: true, check: ({ plants }) => plants.some(p => ['audrey', 'audrey ii', 'seymour'].includes((p.name || '').trim().toLowerCase())) }, // ── admin-only: check() always false — never earned through normal play, // only ever granted/revoked from the Admin panel's badge tool. { id: 'admin-verified', name: 'Verified by the Gardener', text: 'A stamp of approval, personally handed out.', Icon: BadgeIconSeal, adminOnly: true, check: () => false }, { id: 'admin-beta', name: 'Beta Sprout', text: 'Here before it was finished.', Icon: BadgeIconKey, adminOnly: true, check: () => false }, ]; const BADGE_BY_ID = Object.fromEntries(BADGE_DEFS.map(d => [d.id, d])); // deterministic pseudo-random from a string — same badge always lands in the // same ambient spot for a given viewport class, no layout jitter on re-render function _hash(str) { let h = 0; for (let i = 0; i < str.length; i++) { h = (h * 31 + str.charCodeAt(i)) | 0; } return Math.abs(h); } // ════════════════════════════════════════════════════════════ // Ambient decorative layer — Sprig-tier watermark texture, never // interactive (pointer-events:none throughout). Deliberately `fixed` to // the viewport rather than living inside the scrolling content: it reads // as wallpaper behind the garden, not a decoration on any one row, so it // has to stay visible no matter how far the plant grid scrolls — an // absolutely-positioned band anchored to the top of the content used to // scroll away with the first screenful and leave nothing behind it. // ════════════════════════════════════════════════════════════ // The layer itself, and every decorative element inside it (positioning // wrapper, drift animation wrapper, connecting nothing-else), stays // `pointer-events:none` throughout — that's what makes it safe to render // underneath the entire app without risking the July gesture-bleed bug // (a reorder-drag hit-region bleeding into a card's own swipe tracking). // The ONLY element that ever gets `pointer-events:auto` is the small // icon-sized hit target below (`_BadgeHit`), sized exactly to the icon it // wraps — never the layer, the positioning wrapper, or anything larger. // `clickable` is decided by the parent layer's occlusion check — this // component never assumes it's safe to receive taps on its own. Until that // check confirms nothing real (anything the codebase already marks // `cursor:pointer` — its existing signature for "this is a control") sits // at this exact spot, the icon stays exactly as inert as the old pure-CSS // watermark: no pointer-events, no handler, no role. function _BadgeHit({ def, size, dur, delay, clickable }) { const [bump, setBump] = useState(false); const [tip, setTip] = useState(false); const tipTimer = useRef(null); const bumpTimer = useRef(null); const tap = (e) => { e.stopPropagation(); if (!_badgeReduceMotion()) { setBump(false); requestAnimationFrame(() => setBump(true)); if (bumpTimer.current) clearTimeout(bumpTimer.current); bumpTimer.current = setTimeout(() => setBump(false), 520); } setTip(true); if (tipTimer.current) clearTimeout(tipTimer.current); tipTimer.current = setTimeout(() => setTip(false), 1700); }; useEffect(() => () => { if (tipTimer.current) clearTimeout(tipTimer.current); if (bumpTimer.current) clearTimeout(bumpTimer.current); }, []); const active = bump || tip; // C.brown carries far more contrast against near-black dark backgrounds // than against the cream light one at the same alpha — matching the // number made dark mode read noticeably busier than light mode at // identical config. Same signature check as the theme-color meta tag // update in app.jsx (C.bg === '#111610' <=> dark). const isDark = C.bg === '#111610'; const idleOp = isDark ? 0.07 : 0.12, activeOp = isDark ? 0.55 : 0.85; return (