// ════════════════════════════════════════════════════════════ // 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 (
{/* C.brown (Sprig's own watermark tone), not C.sage — sage at this stroke weight/size reads as one of the app's own live UI icons (search, sync, garden) sitting inline in a settings row, not decoration. Brown is already the established "this is background texture" signal in both themes. */}
{tip && (
{def.name}
)}
); } // Every screen in this app wraps its real content — headers, plant grids, // settings accordions — in an ancestor with an explicit z-index (a // pre-existing convention to stay above the decorative Sprig watermark). // That means simple z-index tuning can't make this layer "sometimes on top, // safely" — either it loses everywhere (badges permanently unreachable, // even in genuinely empty gaps) or it wins everywhere in a given subtree // (which would let a badge that happens to land on a real card or button // steal that tap — exactly what must never happen). // // So reachability is decided empirically instead of geometrically: after // each badge is laid out, check what's actually painted at its own center // with the hit target still fully inert (pointer-events:none, so the probe // can't influence its own result). If the real element there — or a nearby // ancestor, walking up a few levels — carries `cursor:pointer` (this // codebase's own consistent signature for "this is a control": every card, // row, toggle and button in Caulis sets it), the badge stays permanently // non-interactive, forever, exactly like the old pure-CSS watermark. Only // when the probe finds nothing clickable there does the badge get promoted // to a real tap target. Re-probed on resize and on every screen change, // since the same fixed coordinate shows completely different content once // the tab underneath switches. function _looksClickable(el, layerEl) { let cur = el, depth = 0; while (cur && depth < 6) { if (layerEl && layerEl.contains(cur)) return false; if (typeof getComputedStyle === 'function' && getComputedStyle(cur).cursor === 'pointer') return true; cur = cur.parentElement; depth++; } return false; } // a handful of badge icons are literal functional glyphs elsewhere in the // app's own UI language (a clock face next to a "watered N days ago" row, a // camera/photo-frame icon) — faithful in the Badges shelf, but read as a // false affordance drifting near a real timestamp in the ambient layer. // Excluded from ambient rotation only; still fully visible/earnable in the // Badges view. const AMBIENT_EXCLUDE_ICONS = new Set([BadgeIconSteady, BadgeIconPortrait, BadgeIconCalendar]); function AmbientBadgeLayer({ badges, enabled, density, isDesktop, screenKey }) { const held0 = badges ? badges.filter(b => !b.revoked) : []; const held = held0.filter(b => { const def = BADGE_BY_ID[b.id]; return def && !AMBIENT_EXCLUDE_ICONS.has(def.Icon); }); const wantVisible = !!enabled && held.length > 0; const [mounted, setMounted] = useState(wantVisible); const [shown_, setShown_] = useState(wantVisible); useEffect(() => { let t; if (wantVisible) { setMounted(true); t = setTimeout(() => setShown_(true), 20); } else { setShown_(false); t = setTimeout(() => setMounted(false), MOTION.base); } return () => clearTimeout(t); }, [wantVisible]); const layerRef = useRef(null); const itemRefs = useRef({}); const [clickableIds, setClickableIds] = useState(() => new Set()); const cap = { few: 3, normal: 6, many: 10 }[density] || 6; const list = [...held].sort((a, b) => b.earnedAt - a.earnedAt).slice(0, cap); const bandH = (typeof window !== 'undefined' && window.innerHeight) || (isDesktop ? 900 : 700); const listKey = list.map(b => b.id).join(','); useEffect(() => { if (!mounted) return; let cancelled = false; const probe = () => { if (cancelled || !layerRef.current) return; // force every hit target inert for the duration of the probe — a badge // already promoted clickable from a previous pass must not shadow // itself and short-circuit its own re-check into a false positive const entries = Object.entries(itemRefs.current).filter(([, el]) => el); const restore = entries.map(([, el]) => { const hit = el.querySelector('[data-badge-hit]'); const prev = hit ? hit.style.pointerEvents : null; if (hit) hit.style.pointerEvents = 'none'; return () => { if (hit) hit.style.pointerEvents = prev; }; }); const next = new Set(); entries.forEach(([id, el]) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return; const cx = r.left + r.width / 2, cy = r.top + r.height / 2; if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return; const top = document.elementFromPoint(cx, cy); if (top && !_looksClickable(top, layerRef.current)) next.add(id); }); restore.forEach(fn => fn()); if (!cancelled) setClickableIds(next); }; // wait out the ~280-320ms tab-slide animation so the probe measures the // settled layout, not a mid-transform frame const t = setTimeout(probe, 340); window.addEventListener('resize', probe); return () => { cancelled = true; clearTimeout(t); window.removeEventListener('resize', probe); }; }, [mounted, listKey, screenKey, isDesktop]); if (!mounted) return null; return ( ); } // ════════════════════════════════════════════════════════════ // Interactive badge shelf — real drag physics (velocity + damping, // spring-back to rest), isolated to its own strip so pointer events never // reach the plant grid underneath. Mirrors the feel of BrassBound's machine // drag: velocity captured while dragging, then a spring integrates it back // to rest on release via requestAnimationFrame — not a CSS transition. // ════════════════════════════════════════════════════════════ const BADGE_DRAG_LIMIT = 20; const BADGE_K_SPRING = 0.22; const BADGE_K_DAMP = 0.74; function _badgeReduceMotion() { try { if (document.documentElement.getAttribute('data-rm') === '1') return true; } catch (e) {} try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } } function useBadgeDragPhysics() { const elRef = useRef(null); const pos = useRef({ x: 0, y: 0 }); const vel = useRef({ x: 0, y: 0 }); const dragging = useRef(false); const rafId = useRef(null); const grabAt = useRef({ x: 0, y: 0 }); const paint = () => { if (elRef.current) elRef.current.style.transform = `translate(${pos.current.x}px, ${pos.current.y}px)`; }; const tick = () => { if (!dragging.current) { const p = pos.current, v = vel.current; if (_badgeReduceMotion()) { p.x = 0; p.y = 0; v.x = 0; v.y = 0; paint(); rafId.current = null; return; } v.x = (v.x + (0 - p.x) * BADGE_K_SPRING) * BADGE_K_DAMP; v.y = (v.y + (0 - p.y) * BADGE_K_SPRING) * BADGE_K_DAMP; p.x += v.x; p.y += v.y; paint(); if (Math.abs(v.x) < 0.03 && Math.abs(v.y) < 0.03 && Math.abs(p.x) < 0.05 && Math.abs(p.y) < 0.05) { p.x = 0; p.y = 0; paint(); rafId.current = null; return; } } rafId.current = requestAnimationFrame(tick); }; const ensureLoop = () => { if (rafId.current == null) rafId.current = requestAnimationFrame(tick); }; useEffect(() => () => { dragging.current = false; if (rafId.current != null) { cancelAnimationFrame(rafId.current); rafId.current = null; } }, []); const onPointerDown = (e) => { dragging.current = true; vel.current = { x: 0, y: 0 }; grabAt.current = { x: e.clientX - pos.current.x, y: e.clientY - pos.current.y }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} if (elRef.current) elRef.current.style.transition = 'none'; ensureLoop(); }; const onPointerMove = (e) => { if (!dragging.current) return; const nx = e.clientX - grabAt.current.x, ny = e.clientY - grabAt.current.y; const dist = Math.sqrt(nx * nx + ny * ny); const clamp = dist > BADGE_DRAG_LIMIT ? BADGE_DRAG_LIMIT / dist : 1; const px = nx * clamp, py = ny * clamp; vel.current = { x: px - pos.current.x, y: py - pos.current.y }; pos.current = { x: px, y: py }; paint(); }; const release = () => { dragging.current = false; ensureLoop(); }; return { elRef, onPointerDown, onPointerMove, onPointerUp: release, onPointerCancel: release }; } function BadgeMedallion({ badge, def }) { const drag = useBadgeDragPhysics(); return (
); } function LockedMedallion({ def }) { const hidden = !!def.secret; return (
{hidden ?
?
:
}
); } function BadgeShelf({ badges, curatedIds, isDesktop }) { const [open, setOpen] = useState(() => GS.get('caulis_badge_shelf_open', true)); const toggle = () => setOpen(o => { GS.set('caulis_badge_shelf_open', !o); return !o; }); if (!badges) return null; // "held" = has an un-revoked entry — what actually counts as earned for // display. A revoked entry stays in `badges` on purpose (see // toggleAdminBadge in caulis-screens.jsx) so the auto-unlock effect's // plain id-presence check doesn't silently re-grant it the next time its // check() re-evaluates true, which for almost every non-secret badge is // a permanent predicate that's already still true. const held = badges.filter(b => !b.revoked); const heldIds = new Set(held.map(b => b.id)); const shownIds = Array.isArray(curatedIds) && curatedIds.length ? curatedIds.filter(id => heldIds.has(id)) : [...heldIds]; const shownEarned = shownIds.map(id => held.find(b => b.id === id)).filter(Boolean).sort((a, b) => a.earnedAt - b.earnedAt); const locked = BADGE_DEFS.filter(d => !heldIds.has(d.id) && !d.adminOnly); if (!held.length) return null; return (
Badges
{held.length} of {BADGE_DEFS.length} earned
{shownEarned.map(b => { const def = BADGE_BY_ID[b.id]; return def ? : null; })} {locked.map(def => )}
); } // syncs current garden state against every badge definition and returns the // ids currently satisfied — used both by the unlock-detection effect (app.jsx) // and the admin panel (to show earned/not-earned per definition) function computeSatisfiedBadgeIds(state) { return BADGE_DEFS.filter(d => { try { return d.check(state); } catch (e) { return false; } }).map(d => d.id); } // ════════════════════════════════════════════════════════════ // Dedicated badges view — mirrors WeeklyDigest's pattern exactly: a // full-screen slide-up overlay reached from a small entry point, rather // than a card living permanently in the Garden screen. // ════════════════════════════════════════════════════════════ function BadgesView({ badges, onBack, isDesktop }) { // "held" = has an un-revoked entry — see toggleAdminBadge in // caulis-screens.jsx for why a revoked badge stays in the array (flagged) // instead of being removed outright. const earned = (badges || []).filter(b => !b.revoked); const earnedIds = new Set(earned.map(b => b.id)); const earnedByDef = BADGE_DEFS.filter(d => earnedIds.has(d.id)) .map(d => ({ def: d, at: earned.find(b => b.id === d.id).earnedAt })) .sort((a, b) => b.at - a.at); // adminOnly badges are never earnable through play (check() always // false) — they don't belong in a "here's what you could still earn" // list at all, admin-granted or not. Hiding them here (not just as a "?" // secret placeholder) is what actually matches the adminOnly intent; // leaving them in `locked` was showing their real name/icon/text to // every user who hadn't been personally granted one. const locked = BADGE_DEFS.filter(d => !earnedIds.has(d.id) && !d.adminOnly); return (
Badges
{earned.length} of {BADGE_DEFS.length} earned
{earnedByDef.length > 0 && (
Earned
{earnedByDef.map(({ def, at }, i) => (
{def.name}
{def.text}
{new Date(at).toLocaleDateString('en-US', { month:'short', day:'numeric' })}
))}
)} {locked.length > 0 && (
Locked
{locked.map((def, i) => { const hidden = !!def.secret; return (
{hidden ?
?
:
}
{hidden ? 'Secret badge' : def.name}
{hidden ? 'Keep exploring to find this one.' : def.text}
); })}
)}
); } Object.assign(window, { BADGE_DEFS, BADGE_BY_ID, computeSatisfiedBadgeIds, AmbientBadgeLayer, BadgeShelf, BadgesView, });