// Coastal Cruise — soft seafoam + deep navy, breezy serif, wave accents
// One iOS app, full state, persists packing + tab to localStorage.
const C = {
// soft seafoam → cream gradient body, with deep navy ink
bg: '#EAF1EE',
paper: '#FFFFFF',
ink: '#0F2A3F',
ink2: '#3D5A6C',
muted: '#7A8E99',
hair: '#D8E2DC',
// primary water blue; warm sand accent
sea: '#3F6A82',
seaDk: '#234357',
sand: '#E8C9A0',
coral: '#D26E5E',
green: '#4F8A7A',
red: '#B94A3C',
serif: '"Cormorant Garamond", "Cormorant", "Playfair Display", Georgia, serif',
sans: '-apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif',
};
// ── Wave SVG (animated subtle horizontal drift) ────────────────────────
function CoastalWave({ height = 28, color = C.sea, opacity = 0.18, speed = 18 }) {
const id = React.useId();
return (
);
}
function CoastalCompass({ size = 14, color = C.muted }) {
return (
);
}
function CoastalShip({ size = 16, color = C.sea }) {
return (
);
}
// ── Helpers ────────────────────────────────────────────────────────────
function CoastalChip({ children, color = C.sea, light, style }) {
return (
{children}
);
}
function CoastalCountdown({ label, dateIso, color }) {
const days = window.daysUntil(dateIso);
const text = days < 0 ? 'In progress' : days === 0 ? 'Today' : `${days} days`;
return (
{label}
{days >= 0 ? days : '·'}
{text}
);
}
// ── HOME / TODAY ───────────────────────────────────────────────────────
function CoastalToday({ leg }) {
const T = window.TRIP;
const cruiseDays = window.daysUntil(T.legs.cruise.start);
const gradDays = window.daysUntil(T.legs.grad.start);
return (
{/* hero */}
Summer 2026
Two weeks at sea &
shore .
Logan, Bonich & Baby Kai · May 10 – 23
{/* countdown row */}
{/* "What's next" card */}
Setting sail
Quantum of the Seas
4 nights · Catalina + Ensenada
Dep. Port of LA · May 11
Stateroom
Interior · ZI GTY
{/* highlights */}
Highlights of the trip
{[
{ icon: '⚓︎', t: 'Catalina Island', d: 'May 12 · 7AM – 6PM' },
{ icon: '✦', t: 'Ensenada port day', d: 'May 13 · use Costco Visa' },
{ icon: '🎓', t: "Bonich's JHU graduation", d: 'May 18 · Homewood Field' },
{ icon: '✺', t: 'Family in Williamsburg', d: 'May 19 – 22' },
].map((h, i) => (
))}
);
}
// ── TIMELINE ───────────────────────────────────────────────────────────
function CoastalTimeline({ leg, onPickDay }) {
const T = window.TRIP;
const days = T.days.filter(d => leg === 'all' || d.leg === leg);
return (
{/* vertical rope */}
{days.map((d, i) => {
const dotColor = d.leg === 'cruise' ? C.sea : C.coral;
const status = d.status;
return (
onPickDay(T.days.indexOf(d))}
style={{
position: 'relative', display: 'block',
width: '100%', textAlign: 'left',
padding: '10px 0 10px 16px', marginLeft: -6,
background: 'transparent', border: 'none',
cursor: 'pointer', fontFamily: C.sans,
}}>
{/* dot */}
{d.flag && ★ }
{window.formatDate(d.date)}
{d.dow}
{status === 'paid' &&
Paid }
{status === 'book' &&
Book now }
{status === 'partial'&&
Partial }
{status === 'rest' &&
Rest }
{status === 'family' &&
Family }
{d.title}
{d.sub}
);
})}
);
}
// ── DAY DETAIL SHEET ───────────────────────────────────────────────────
function CoastalDaySheet({ day, onClose }) {
if (!day) return null;
const dotColor = day.leg === 'cruise' ? C.sea : C.coral;
const [allNotes, setAllNotes] = window.tripSync.useShared('notes', {});
const note = allNotes[day.date] || '';
const setNote = (v) => setAllNotes(prev => ({ ...prev, [day.date]: v }));
return (
e.stopPropagation()}
style={{
background: '#fff', width: '100%', borderRadius: '22px 22px 0 0',
padding: '8px 0 38px', maxHeight: '80%', overflow: 'auto',
animation: 'coastalSheet 280ms cubic-bezier(.2,.8,.3,1)',
position: 'relative',
}}>
{day.leg === 'cruise' ? 'The Cruise' : 'Graduation'}
{window.formatDate(day.date, { long: true })}
{day.title}
{day.sub}
Cost
{day.cost > 0 ? `$${day.cost.toLocaleString()}` : '—'}
{day.details.map((d, i) => (
{d}
))}
{/* Notes / journal */}
);
}
// ── BUDGET ─────────────────────────────────────────────────────────────
function CoastalBudget({ leg }) {
const T = window.TRIP;
const showCruise = leg !== 'grad';
const showGrad = leg !== 'cruise';
const totalEst = (showCruise ? T.legs.cruise.estimated : 0) + (showGrad ? T.legs.grad.estimated : 0);
const totalPaid = (showCruise ? T.legs.cruise.paid : 0) + (showGrad ? T.legs.grad.paid : 0);
const variance = totalEst - totalPaid;
return (
Total spent
${totalPaid.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}
of ${totalEst.toFixed(2)} estimated ·
{variance < 0 ? '−' : '+'}${Math.abs(variance).toFixed(2)} {variance < 0 ? 'over' : 'remaining'}
{/* by leg */}
{showCruise && (
)}
{showGrad && (
)}
{/* by category */}
By category
{T.budget.categories.map((c, i) => {
const total = (showCruise ? c.cruise : 0) + (showGrad ? c.grad : 0);
if (total === 0) return null;
const max = Math.max(...T.budget.categories.map(x => x.cruise + x.grad));
const pct = total / max;
return (
{c.label}
${total.toFixed(0)}
);
})}
);
}
function CoastalLegCard({ title, est, paid, color }) {
const v = est - paid;
return (
{title}
${paid.toFixed(0)}
{v >= 0 ? `$${v.toFixed(0)} under` : `$${Math.abs(v).toFixed(0)} over`} est.
);
}
// ── PACKING ────────────────────────────────────────────────────────────
function CoastalPacking() {
const T = window.TRIP;
const cats = Object.keys(T.packing);
const [active, setActive] = React.useState(cats[0]);
const [packed, setPacked] = window.tripSync.useShared('packed', {});
// Per-category list overrides. Once a category is touched (add/edit/delete),
// its full list is shadowed here and replaces T.packing[cat] for everyone.
const [itemsMap, setItemsMap] = window.tripSync.useShared('items', {});
const [editing, setEditing] = React.useState(null); // item being edited
const [editValue, setEditValue] = React.useState('');
const [adding, setAdding] = React.useState(false);
const [addValue, setAddValue] = React.useState('');
const items = (itemsMap && itemsMap[active]) || T.packing[active];
const done = items.filter(i => packed[i]).length;
const writeList = (newList) => {
setItemsMap(m => ({ ...(m || {}), [active]: newList }));
};
const toggle = (item) => setPacked(p => ({ ...p, [item]: !p[item] }));
const addItem = (name) => {
const v = (name || '').trim();
if (!v || items.includes(v)) return;
writeList([...items, v]);
};
const removeItem = (name) => {
writeList(items.filter(i => i !== name));
setPacked(p => { const n = { ...p }; delete n[name]; return n; });
};
const renameItem = (oldName, newName) => {
const v = (newName || '').trim();
if (!v || v === oldName || items.includes(v)) return;
writeList(items.map(i => (i === oldName ? v : i)));
setPacked(p => {
const n = { ...p };
if (n[oldName] !== undefined) { n[v] = n[oldName]; delete n[oldName]; }
return n;
});
};
const startEdit = (it) => { setEditing(it); setEditValue(it); };
const cancelEdit = () => { setEditing(null); setEditValue(''); };
const saveEdit = () => { if (editing) renameItem(editing, editValue); cancelEdit(); };
const inputStyle = {
flex: 1, minWidth: 0, fontFamily: C.sans, fontSize: 14,
padding: '8px 10px', border: `1px solid ${C.hair}`, borderRadius: 8,
color: C.ink, background: '#fff', outline: 'none',
};
const pillBtn = (color, bg) => ({
border: 'none', background: bg || 'transparent', color,
fontFamily: C.sans, fontSize: 12, fontWeight: 700,
padding: '6px 10px', borderRadius: 6, cursor: 'pointer',
});
return (
{cats.map(c => (
setActive(c)} style={{
background: active === c ? C.ink : '#fff',
color: active === c ? '#fff' : C.ink2,
fontFamily: C.sans, fontSize: 12, fontWeight: 600,
padding: '6px 12px', borderRadius: 999,
border: `1px solid ${active === c ? C.ink : C.hair}`,
cursor: 'pointer', whiteSpace: 'nowrap',
}}>{c}
))}
{active}
{done} / {items.length} packed
{items.map((it, i) => {
const on = !!packed[it];
const isEditing = editing === it;
const isLast = i === items.length - 1;
const rowBorder = isLast && !adding ? 'none' : `1px solid ${C.hair}`;
if (isEditing) {
return (
setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}}
style={inputStyle}/>
Save
{ removeItem(it); cancelEdit(); }}
style={pillBtn('#fff', C.red)}>Delete
×
);
}
return (
toggle(it)} style={{
display: 'flex', alignItems: 'center', gap: 12,
flex: 1, padding: '12px 14px', textAlign: 'left',
background: 'transparent', border: 'none', cursor: 'pointer',
}}>
{it}
startEdit(it)}
aria-label={`Edit ${it}`}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '0 14px', color: C.muted,
display: 'flex', alignItems: 'center',
}}>
);
})}
{adding ? (
setAddValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { addItem(addValue); setAddValue(''); }
if (e.key === 'Escape') { setAdding(false); setAddValue(''); }
}}
style={inputStyle}/>
{ addItem(addValue); setAddValue(''); }}
style={pillBtn('#fff', C.sea)}>Add
{ setAdding(false); setAddValue(''); }}
style={pillBtn(C.muted)}>Done
) : (
setAdding(true)} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '12px 14px', background: 'transparent',
border: 'none', borderTop: items.length ? `1px solid ${C.hair}` : 'none',
fontFamily: C.sans, fontSize: 13, fontWeight: 600,
color: C.sea, cursor: 'pointer',
}}>
+
Add item
)}
);
}
// ── MAP ────────────────────────────────────────────────────────────────
// Stops with hand-calibrated pixel positions on north-america.webp (512×512).
// (Using a linear lat/lng projection didn't match the stylized map's
// non-uniform scaling, so each city is pinned by eye to its actual location.)
const COASTAL_STOPS = [
{ id: 'pvu', name: 'Provo', sub: 'May 10 · home', px: 194, py: 292, leg: 'both', anchor: 'right', dx: 10, dy: -10 },
{ id: 'lax', name: 'Los Angeles', sub: 'May 10–11 · embark', px: 161, py: 322, leg: 'cruise', anchor: 'left', dx: -10, dy: -4 },
{ id: 'cat', name: 'Catalina I.', sub: 'May 12 · port day', px: 149, py: 334, leg: 'cruise', anchor: 'left', dx: -10, dy: 18 },
{ id: 'ens', name: 'Ensenada', sub: 'May 13 · margaritas', px: 165, py: 344, leg: 'cruise', anchor: 'left', dx: -10, dy: 38 },
{ id: 'bwi', name: 'Baltimore', sub: 'May 17–18 · graduation', px: 354, py: 296, leg: 'grad', anchor: 'right', dx: 10, dy: -8 },
{ id: 'wms', name: 'Williamsburg', sub: 'May 19–22 · family time', px: 352, py: 312, leg: 'grad', anchor: 'right', dx: 10, dy: 14 },
];
// Path of travel, in order
const COASTAL_ROUTE = ['pvu','lax','cat','ens','lax','bwi','wms','pvu'];
function CoastalMap({ leg }) {
const visible = (s) => leg === 'all' || s.leg === leg || s.leg === 'both';
const stopsById = Object.fromEntries(COASTAL_STOPS.map(s => [s.id, s]));
const pathPts = COASTAL_ROUTE
.map(id => stopsById[id])
.filter(visible)
.map(s => [s.px, s.py]);
return (
The route
{leg === 'cruise' ? 'Provo → LA → Catalina → Ensenada → home'
: leg === 'grad' ? 'Provo → Baltimore → Williamsburg → home'
: 'Coast to coast, with a side of Mexico'}
{/* base map image — read src from a hidden so the bundler can inline it */}
{/* dashed route */}
{pathPts.length > 1 && (
(i === 0 ? `M${p[0]} ${p[1]}` : `L${p[0]} ${p[1]}`)).join(' ')}
fill="none" stroke={C.ink} strokeWidth="1.6"
strokeDasharray="3 3" strokeLinecap="round" opacity="0.85"/>
)}
{/* stops */}
{COASTAL_STOPS.filter(visible).map((s) => {
const x = s.px, y = s.py;
const c = s.leg === 'cruise' ? C.sea
: s.leg === 'grad' ? C.coral
: C.ink;
const align = s.anchor === 'left' ? 'end' : 'start';
return (
{/* outer halo */}
{/* label */}
{s.name}
{s.sub.toUpperCase()}
);
})}
{/* compass rose, top right */}
N
{/* legend */}
Cruise leg
Graduation leg
Home
{/* travel hacks */}
Tips for the road
{window.TRIP.hacks.map((h, i) => (
))}
);
}
// ── HEADER + LEG TOGGLE ────────────────────────────────────────────────
function CoastalHeader({ leg, setLeg, showLegToggle = true }) {
const T = window.TRIP;
const tabs = [
{ id: 'all', label: 'Full Trip' },
{ id: 'cruise', label: 'Cruise' },
{ id: 'grad', label: 'Grad' },
];
return (
{showLegToggle && (
{tabs.map(t => (
setLeg(t.id)} style={{
flex: 1, padding: '7px 4px',
background: leg === t.id ? C.ink : 'transparent',
color: leg === t.id ? '#fff' : C.ink2,
border: 'none', borderRadius: 999,
fontFamily: C.sans, fontSize: 12.5, fontWeight: 600,
cursor: 'pointer', transition: 'all 200ms',
}}>{t.label}
))}
)}
);
}
// ── BOTTOM TAB BAR ─────────────────────────────────────────────────────
function CoastalTabBar({ tab, setTab }) {
const tabs = [
{ id: 'today', label: 'Today', icon: },
{ id: 'timeline', label: 'Trip', icon: <> > },
{ id: 'budget', label: 'Budget', icon: <> > },
{ id: 'pack', label: 'Pack', icon: <> > },
{ id: 'map', label: 'Map', icon: <> > },
];
return (
{tabs.map(t => {
const on = tab === t.id;
return (
setTab(t.id)} style={{
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '6px 4px 4px', display: 'flex', flexDirection: 'column',
alignItems: 'center', gap: 2, fontFamily: C.sans,
color: on ? C.sea : C.muted,
}}>
{t.icon}
{t.label}
);
})}
);
}
// ── ROOT APP ───────────────────────────────────────────────────────────
function CoastalApp() {
const T = window.TRIP;
// Tab + leg are local-only (per-device UI state, not synced)
const [tab, setTab] = React.useState(() => localStorage.getItem('coastal:tab') || 'today');
const [leg, setLeg] = React.useState(() => localStorage.getItem('coastal:leg') || 'all');
const [openDay, setOpenDay] = React.useState(null);
React.useEffect(() => { try { localStorage.setItem('coastal:tab', tab); } catch{} }, [tab]);
React.useEffect(() => { try { localStorage.setItem('coastal:leg', leg); } catch{} }, [leg]);
return (
{tab === 'today' && }
{tab === 'timeline' && setOpenDay(i)}/>}
{tab === 'budget' && }
{tab === 'pack' && }
{tab === 'map' && }
setOpenDay(null)}/>
);
}
window.CoastalApp = CoastalApp;