/* ============================================================================ MOWDEW — Kit primitives Icon sprite (Lucide-style, 2px outline) + sprout mascot + logo + the shared components every screen leans on: Tag, Chip, Amount, RailChip, Avatar, IconBadge, PhotoTile, money formatting. Exposed on window. Load AFTER React + Babel, BEFORE any screen file. ========================================================================= */ const TN_ICONS = { // nav + chrome home: '', route: '', feed: '', file: '', ledger: '', user: '', users: '', settings: '', bell: '', // location / verification pin: '', navigation: '', target: '', shield: '', verified: '', clock: '', // capture / media camera: '', image: '', notes: '', // money / rails invoice: '', send: '', wallet: '', link: '', // actions plus: '', check: '', 'check-circle': '', x: '', search: '', 'arrow-right': '', 'arrow-left': '', 'chevron-right': '', 'chevron-down': '', calendar: '', copy: '', phone: '', share: '', star: '', 'thumbs-up': '', 'thumbs-down': '', // domain leaf: '', droplet: '', can: '', zap: '', more: '', list: '', history: '', // pool + chemistry pool: '', flask: '', testtube: '', thermometer: '', waves: '', gauge: '', sparkle: '', message: '', 'calendar-check': '', grid: '', truck: '', dollar: '', nudge: '', filter: '', 'trend-up': '', 'trend-down': '', lock: '', 'eye-off': '', map: '', inbox: '', beaker: '', hand: '', briefcase: '', sprout: '', }; function Icon({ name, size = 22, strokeWidth = 2, style = {}, className = '' }) { return ( ); } /* ─── Money ─────────────────────────────────────────────────────────── */ function fmtMoney(cents) { return '$' + Math.abs(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function Amount({ cents, size = 16, tone = 'plain', weight = 800 }) { const color = tone === 'paid' ? 'var(--green-700)' : tone === 'due' ? 'var(--alert-700)' : tone === 'muted' ? 'var(--ink-500)' : 'var(--ink-900)'; return ( {fmtMoney(cents)} ); } /* ─── Sprout mascot — "Sprig" ─────────────────────────────────────────── A friendly two-leaf sprout with a face. mood: happy | wave | wink | zzz. Rendered as flat shapes in brand green + sky pot + sunny cheeks. */ function Sprout({ size = 96, mood = 'happy', style = {} }) { const blush = '#FF9E7D'; return ( ); } /* ─── Dewy — the aqua mascot (pool's companion to Sprig the sprout) ────── A cheerful water droplet that rises out of a little pool ripple. mood: happy | wave | wink | zzz. */ function Dewy({ size = 96, mood = 'happy', style = {} }) { const blush = '#7FE0E8'; return ( ); } /* ─── Mascot — pick by vertical: garden -> Sprig, pool -> Dewy ───────── */ function Mascot({ vertical = 'garden', ...props }) { return vertical === 'pool' ? : ; } function Logo({ size = 40, withWord = false, word = 'Mowdew', color = 'var(--ink-900)' }) { const rid = (React.useId ? React.useId() : 'tnlg').replace(/[:]/g, ''); const g = 'tnlg-' + rid, sh = 'tnsh-' + rid; const mark = ( ); if (!withWord) return mark; return ( {mark} {word} ); } /* ─── Tag — Mowdew "record mark": line glyph + sentence label + hand-drawn underline. No pill, no oval. The underline is a sketchy marker stroke in the tone accent; the mark defaults to the mascot motif (leaf garden / droplet pool) or any glyph via `icon`. */ const TAG_TONE = { green: { mark: 'leaf', accent: '#21A84F' }, aqua: { mark: 'droplet', accent: '#12BCC9' }, blue: { mark: 'droplet', accent: '#46B6F0' }, yellow: { mark: 'leaf', accent: '#E0A92E' }, alert: { mark: 'bell', accent: '#F2683C' }, violet: { mark: 'flask', accent: '#7C6FF0' }, ink: { mark: 'leaf', accent: 'var(--ink-700)' }, }; function Tag({ children, tone = 'green', icon, onDark = false, style = {} }) { const t = TAG_TONE[tone] || TAG_TONE.green; const accent = onDark ? '#fff' : t.accent; const labelColor = onDark ? '#fff' : 'var(--ink-900)'; return ( {children} ); } /* ─── SectionHead — the "ledger entry" header for product surfaces ─────── A line-mark + sentence-case title (hand-drawn underline) and a dotted ledger rule that runs to the edge, optionally meeting a count or action on the right. Reads like a line filed into a record — ownable, not an AI eyebrow. */ function HandUnderline({ color, height = 5, strokeWidth = 2.4 }) { return ( ); } function SectionHead({ children, tone = 'green', icon, meta, rule = true, onDark = false, size = 15, style = {} }) { const t = TAG_TONE[tone] || TAG_TONE.green; const accent = onDark ? '#fff' : t.accent; const titleColor = onDark ? '#fff' : 'var(--ink-900)'; const ruleColor = onDark ? 'rgba(255,255,255,.22)' : 'var(--ink-200)'; const metaColor = onDark ? 'rgba(255,255,255,.7)' : 'var(--ink-500)'; return (
{children} {rule && } {meta && {meta}}
); } /* ─── Status chip — pill with dot ───────────────────────────────────── */ const CHIP_STYLE = { done: { bg: 'var(--green-50)', fg: 'var(--green-700)', bd: 'var(--green-100)', dot: 'var(--green-500)' }, live: { bg: 'var(--blue-50)', fg: 'var(--blue-700)', bd: 'var(--blue-100)', dot: 'var(--blue-500)' }, pool: { bg: 'var(--aqua-50)', fg: 'var(--aqua-700)', bd: 'var(--aqua-100)', dot: 'var(--aqua-500)' }, soon: { bg: 'var(--yellow-50)', fg: 'var(--yellow-700)', bd: 'var(--yellow-100)', dot: 'var(--yellow-500)' }, alert: { bg: 'var(--alert-50)', fg: 'var(--alert-700)', bd: '#F8D3C5', dot: 'var(--alert-500)' }, violet: { bg: 'var(--chem-50)', fg: '#5847d6', bd: '#DBD6FB', dot: 'var(--chem-violet)' }, neutral: { bg: 'var(--ink-50)', fg: 'var(--ink-700)', bd: 'var(--ink-100)', dot: 'var(--ink-300)' }, }; function Chip({ children, tone = 'neutral', icon, pulse = false, style = {} }) { const s = CHIP_STYLE[tone] || CHIP_STYLE.neutral; return ( {icon ? : } {children} ); } /* ─── Mono badge ────────────────────────────────────────────────────── */ function Mono({ children, tone = 'green', icon }) { const styles = { green: { color: 'var(--green-800)', background: 'var(--green-50)', border: '1px solid var(--green-100)' }, blue: { color: 'var(--blue-700)', background: 'var(--blue-50)', border: '1px solid var(--blue-100)' }, aqua: { color: 'var(--aqua-700)', background: 'var(--aqua-50)', border: '1px solid var(--aqua-100)' }, ink: { color: 'var(--ink-700)', background: 'var(--ink-50)', border: '1px solid var(--ink-100)' }, }[tone]; return ( {icon && }{children} ); } /* ─── Icon badge — white icon in a solid rounded square ─────────────── */ const BADGE_BG = { green: 'var(--green-500)', blue: 'var(--blue-500)', aqua: 'var(--aqua-500)', yellow: 'var(--yellow-500)', ink: 'var(--ink-900)', alert: 'var(--alert-500)', violet: 'var(--chem-violet)' }; function IconBadge({ icon, tone = 'green', size = 44, radius }) { const dark = tone === 'yellow'; return ( ); } /* ─── Wallet rail chip ──────────────────────────────────────────────── */ const RAIL = { venmo: { bg: '#008CFF', mark: 'V', label: 'Venmo' }, paypal: { bg: '#003087', mark: 'P', label: 'PayPal' }, zelle: { bg: '#6D1ED4', mark: 'Z', label: 'Zelle' }, cash: { bg: 'var(--green-700)', mark: '$', label: 'Cash' }, }; function RailChip({ rail, size = 28 }) { const r = RAIL[rail]; return ( {r.mark} ); } /* ─── Brand wallet buttons — real-wordmark pay rails ───────────────────── Approximated brand wordmarks/colors (real marks are trademarked): Venmo blue, PayPal yellow w/ two-tone italic logotype, Zelle purple. */ const RAIL_BRAND = { venmo: { bg: '#008CFF', fg: '#fff', handle: PROVIDER => PROVIDER.venmo }, paypal: { bg: '#FFC439', fg: '#003087' }, zelle: { bg: '#6D1ED4', fg: '#fff' }, }; function RailWordmark({ rail, size = 19 }) { if (rail === 'paypal') { return ( PayPal ); } const label = rail === 'venmo' ? 'venmo' : rail === 'zelle' ? 'zelle' : RAIL[rail].label; return {label}; } function RailPayButton({ rail, onClick, height = 54, size = 20 }) { const b = RAIL_BRAND[rail] || RAIL_BRAND.zelle; return ( ); } /* ─── Avatar — initials, deterministic warm color ───────────────────── */ const AV_COLORS = ['#21A84F', '#46B6F0', '#F2A03D', '#15803C', '#1379B5', '#E08A3C']; function hashStr(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } function Avatar({ name, size = 40, ring = false }) { const initials = (name || '?').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); const c = AV_COLORS[hashStr(name || '') % AV_COLORS.length]; return ( {initials} ); } /* ─── Real generated sample photos (canvas-rendered) override gradients ── */ const SCENE_IMG = { 'lawn-before': 'assets/photos/15_lawn_before.jpg', 'lawn-after': 'assets/photos/16_lawn_after.jpg', 'hedge': 'assets/scenes/hedge.png', 'beds': 'assets/scenes/beds.png', 'pool-clean': 'assets/photos/14_pool_after.jpg', 'pool-green': 'assets/photos/13_pool_before.jpg', 'pool-before': 'assets/photos/13_pool_before.jpg', 'pool-after': 'assets/photos/14_pool_after.jpg', 'pool-before-2': ['assets/photos/06_pool_before_after_diptych.jpg', 'left center'], 'pool-after-2': ['assets/photos/06_pool_before_after_diptych.jpg', 'right center'], 'lawn-before-2': ['assets/photos/07_lawn_before_after_diptych.jpg', 'left center'], 'lawn-after-2': ['assets/photos/07_lawn_before_after_diptych.jpg', 'right center'], }; /* ─── Photo tile — real sample photos w/ on-brand CSS fallback ────────── scene: lawn-before|lawn-after|pool-before|pool-after|hedge|beds|empty */ const SCENES = { 'lawn-before': { bg: 'linear-gradient(160deg,#7C8B4A,#5E6E36 55%,#4C5C2C)', tex: 'repeating-linear-gradient(120deg, rgba(0,0,0,.10) 0 7px, rgba(255,255,255,.05) 7px 14px)' }, 'lawn-after': { bg: 'linear-gradient(160deg,#3FA85E,#2E8C49)', tex: 'repeating-linear-gradient(90deg, rgba(255,255,255,.16) 0 13px, rgba(0,0,0,.06) 13px 26px)' }, 'pool-before': { bg: 'linear-gradient(160deg,#5E8C76,#3F6E5C)', tex: 'radial-gradient(circle at 30% 30%, rgba(255,255,255,.14), transparent 45%)' }, 'pool-after': { bg: 'linear-gradient(160deg,#56C2EE,#2C9BD6)', tex: 'radial-gradient(circle at 70% 25%, rgba(255,255,255,.4), transparent 40%), repeating-linear-gradient(115deg, rgba(255,255,255,.10) 0 10px, transparent 10px 22px)' }, 'pool-clean': { bg: 'linear-gradient(160deg,#2FD0D8,#0FA4B0)', tex: 'radial-gradient(circle at 70% 25%, rgba(255,255,255,.45), transparent 40%), repeating-linear-gradient(115deg, rgba(255,255,255,.12) 0 9px, transparent 9px 20px)' }, 'pool-green': { bg: 'linear-gradient(160deg,#6FA45E,#4E7A3F)', tex: 'radial-gradient(circle at 35% 35%, rgba(255,255,255,.12), transparent 50%)' }, 'test-strip': { bg: 'linear-gradient(160deg,#1A1830,#0E0C1E)', tex: 'radial-gradient(circle at 50% 38%, rgba(124,111,240,.4), transparent 55%)' }, 'hedge': { bg: 'linear-gradient(160deg,#2F9E55,#1E7B3F)', tex: 'repeating-linear-gradient(60deg, rgba(0,0,0,.10) 0 6px, rgba(255,255,255,.06) 6px 12px)' }, 'beds': { bg: 'linear-gradient(160deg,#9A6B3C,#6E4A28)', tex: 'radial-gradient(circle at 60% 40%, rgba(33,168,79,.45), transparent 35%)' }, 'empty': { bg: 'var(--ink-50)', tex: 'none' }, 'pool-before-2': { bg: 'linear-gradient(160deg,#5E8C76,#3F6E5C)', tex: 'none' }, 'pool-after-2': { bg: 'linear-gradient(160deg,#56C2EE,#2C9BD6)', tex: 'none' }, 'lawn-before-2': { bg: 'linear-gradient(160deg,#7C8B4A,#4C5C2C)', tex: 'none' }, 'lawn-after-2': { bg: 'linear-gradient(160deg,#3FA85E,#2E8C49)', tex: 'none' }, }; function PhotoTile({ scene = 'empty', label, time, height = 120, radius = 'var(--radius-lg)', style = {}, children, icon }) { const s = SCENES[scene] || SCENES.empty; const blank = scene === 'empty'; const entry = SCENE_IMG[scene]; const img = Array.isArray(entry) ? entry[0] : entry; const imgPos = Array.isArray(entry) ? entry[1] : 'center'; const imgSize = Array.isArray(entry) ? '200% auto' : 'cover'; return (
{img &&
} {!blank && !img &&
} {blank && (
{children && {children}}
)} {label && ( {label} )} {time && ( {time} )}
); } /* ─── Mini static map — CSS-drawn neighborhood with a check-in pin ──── */ function MiniMap({ height = 150, radius = 'var(--radius-lg)', pin = true, label, style = {} }) { return (
{/* parcels */}
{/* green spaces */} {/* roads */} {/* blocks */} {pin && (
)} {label && ( {label} )}
); } /* ─── Vertical helper — garden=green, pool=aqua ─────────────────────── */ function VERT(vertical) { return vertical === 'pool' ? { name: 'Pool', icon: 'droplet', tone: 'aqua', c500: 'var(--aqua-500)', c700: 'var(--aqua-700)', c50: 'var(--aqua-50)', c100: 'var(--aqua-100)' } : { name: 'Garden', icon: 'leaf', tone: 'green', c500: 'var(--green-500)', c700: 'var(--green-700)', c50: 'var(--green-50)', c100: 'var(--green-100)' }; } /* ─── Chemistry reading definitions — ideal bands ───────────────────── */ const READINGS = { ph: { label: 'pH', abbr: 'pH', unit: '', min: 6.8, max: 8.2, lo: 7.2, hi: 7.6, dp: 1, band: '7.2–7.6' }, cl: { label: 'Free Chlorine', abbr: 'FC', unit: 'ppm', min: 0, max: 5, lo: 1.0, hi: 3.0, dp: 1, band: '1–3 ppm' }, alk: { label: 'Total Alkalinity', abbr: 'TA', unit: 'ppm', min: 40, max: 180, lo: 80, hi: 120, dp: 0, band: '80–120 ppm' }, cya: { label: 'CYA', abbr: 'CYA', unit: 'ppm', min: 0, max: 100, lo: 30, hi: 50, dp: 0, band: '30–50 ppm' }, salt: { label: 'Salt', abbr: 'SALT', unit: 'ppm', min: 0, max: 5000, lo: 2700, hi: 3400, dp: 0, band: '2700–3400 ppm' }, ch: { label: 'Calcium Hardness', abbr: 'CH', unit: 'ppm', min: 0, max: 600, lo: 200, hi: 400, dp: 0, band: '200–400 ppm' }, temp: { label: 'Water Temp', abbr: 'TEMP', unit: '°F', min: 50, max: 100, lo: 78, hi: 88, dp: 0, band: '78–88°F', soft: true }, }; function chemState(key, val) { const r = READINGS[key]; if (val == null || isNaN(val)) return 'empty'; if (val < r.lo) return 'low'; if (val > r.hi) return 'high'; return 'ideal'; } /* Langelier Saturation Index — credible pool-balance estimate. LSI = pH + TF + CF + AF − 12.1, with industry factor approximations. */ function computeLSI({ ph, temp = 80, ch = 300, alk = 100 }) { if (ph == null) return null; const tf = Math.max(0, Math.min(0.9, 0.0117 * temp - 0.4)); // temperature factor const cf = Math.log10(Math.max(25, ch)) - 0.4; // calcium factor const af = Math.log10(Math.max(25, alk)); // alkalinity factor return ph + tf + cf + af - 12.1; } function lsiVerdict(lsi) { if (lsi == null) return { key: 'unknown', label: 'Not enough data', tone: 'ink' }; if (lsi < -0.3) return { key: 'corrosive', label: 'Slightly corrosive', tone: 'soon' }; if (lsi > 0.3) return { key: 'scaling', label: 'Scaling', tone: 'violet' }; return { key: 'balanced', label: 'Balanced', tone: 'done' }; } /* Dosing suggestions for out-of-range readings (≈15,000 gal pool). Estimates. */ function dosingFor(readings) { const out = []; const { ph, cl, alk } = readings; if (ph != null && ph < READINGS.ph.lo) out.push({ key: 'ph', text: 'Add ~2 lb soda ash to raise pH', why: `pH ${ph.toFixed(1)} · below ${READINGS.ph.lo}` }); else if (ph != null && ph > READINGS.ph.hi) out.push({ key: 'ph', text: 'Add ~12 oz muriatic acid to lower pH', why: `pH ${ph.toFixed(1)} · above ${READINGS.ph.hi}` }); if (cl != null && cl < READINGS.cl.lo) out.push({ key: 'cl', text: 'Add ~16 oz liquid chlorine', why: `Free chlorine ${cl.toFixed(1)} · below ${READINGS.cl.lo} ppm` }); if (alk != null && alk < READINGS.alk.lo) out.push({ key: 'alk', text: 'Add ~4 lb sodium bicarb to raise alkalinity', why: `Alkalinity ${alk} · below ${READINGS.alk.lo} ppm` }); else if (alk != null && alk > READINGS.alk.hi) out.push({ key: 'alk', text: 'Add ~1 qt muriatic acid to lower alkalinity', why: `Alkalinity ${alk} · above ${READINGS.alk.hi} ppm` }); return out; } /* ─── pH gauge — horizontal scale, ideal band, value marker ─────────── */ function PhGauge({ value = 7.4, height = 14 }) { const r = READINGS.ph; const pct = (v) => Math.max(0, Math.min(100, ((v - r.min) / (r.max - r.min)) * 100)); const state = chemState('ph', value); const col = state === 'ideal' ? 'var(--ph-ideal)' : state === 'low' ? 'var(--ph-low)' : 'var(--ph-high)'; return (
{/* ideal band overlay markers */}
{/* marker */}
{value.toFixed(1)}
6.8 ACID7.2–7.6 IDEAL8.2 BASIC
); } /* ─── pH badge — a compact, color-coded pH reading icon (droplet + "pH") ── Replaces the dark test-strip thumbnail in reading-history rows. Tint encodes in-range (green) / low (amber) / high (violet). */ const PH_TONE = { ideal: { bg: 'var(--green-50)', fg: 'var(--green-700)' }, low: { bg: 'var(--yellow-50)', fg: 'var(--yellow-700)' }, high: { bg: 'var(--chem-50)', fg: '#5847d6' }, }; function PhBadge({ value = 7.4, size = 42 }) { const t = PH_TONE[chemState('ph', value)] || PH_TONE.ideal; return ( ); } /* ─── Chem readout — a single reading row (label, value, state pill) ── */ function ChemReadout({ rkey, value, sub }) { const r = READINGS[rkey]; const state = chemState(rkey, value); const tone = state === 'ideal' ? 'done' : state === 'low' ? 'soon' : 'violet'; const txt = state === 'ideal' ? 'In range' : state === 'low' ? 'Low' : 'High'; return (
{r.label}
{sub &&
{sub}
}
{value.toFixed(r.dp)}{r.unit ? {r.unit} : null} {txt}
); } /* ─── Chem trend — small SVG area/line chart of a reading over time ─── */ function ChemTrend({ points = [7.6, 7.5, 7.2, 7.8, 7.4, 7.5], labels = [], height = 92, ideal = [7.2, 7.6], rangeMin = 6.8, rangeMax = 8.2, color = 'var(--aqua-500)' }) { const W = 300, H = height, pad = 8; const n = points.length; const x = (i) => pad + (i / (n - 1)) * (W - pad * 2); const y = (v) => H - pad - ((v - rangeMin) / (rangeMax - rangeMin)) * (H - pad * 2); const line = points.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(v).toFixed(1)}`).join(' '); const area = `${line} L${x(n - 1)} ${H - pad} L${x(0)} ${H - pad} Z`; return (
{/* ideal band */} {points.map((v, i) => { const st = v < ideal[0] ? 'var(--ph-low)' : v > ideal[1] ? 'var(--ph-high)' : 'var(--green-500)'; return ; })} {labels.length > 0 && (
{labels.map((l, i) => {l})}
)}
); } Object.assign(window, { Icon, TN_ICONS, fmtMoney, Amount, Sprout, Dewy, Mascot, Logo, Tag, SectionHead, HandUnderline, Chip, Mono, IconBadge, RailChip, Avatar, PhotoTile, SCENE_IMG, MiniMap, RAIL, hashStr, VERT, PhGauge, PhBadge, ChemReadout, ChemTrend, READINGS, chemState, computeLSI, lsiVerdict, dosingFor, RAIL_BRAND, RailWordmark, RailPayButton, });