/* ============================================================================
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 */}
{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 */}
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 (
pH
);
}
/* ─── 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 (
{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 (
{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,
});