// desktop.jsx — Chrono for Mac, full-window calendar workspace function AIEmptyState({ theme }) { return (
Ask Chrono anything
Plan your week, block focus time, or move things around. Start by typing below.
); } function ChronoDesktop({ theme, personality, initialView, fullBleed }) { useEvents(); const [active, setActive] = React.useState(initialView || 'week'); // Allow the guided tutorial to drive the active view. useTutorialView(React.useCallback((v) => { if (v) setActive(v); }, [])); // Assistant popout is hidden by default — the user opens it from the // title-bar icon. The guided tour forces it open (and closed on exit) via // the tutorial-AI channel so new users see it during onboarding. const [aiOpen, setAiOpen] = React.useState(false); useTutorialAI(React.useCallback((open) => setAiOpen(open), [])); // When the Trips planner finishes, it jumps the Week view to the trip's // first week so the user lands right on the new itinerary. const [weekJump, setWeekJump] = React.useState(null); const goToWeek = React.useCallback((wkOff) => { setWeekJump(wkOff); setActive('week'); }, []); // Top-bar search: jump the Week view to a found event and flash it. const [searchTarget, setSearchTarget] = React.useState(null); const goToEvent = React.useCallback((off, ev) => { const weekStartsOn = (window.settingsStore && window.settingsStore.get().weekStartsOn) || 0; const wkStart = off - ((dateAtOffset(off).getDay() - weekStartsOn + 7) % 7); setWeekJump(wkStart); setActive('week'); setSearchTarget({ ev, off, ts: Date.now() }); }, []); const [selected, setSelected] = React.useState(null); const [composerOpen, setComposerOpen] = React.useState(false); const [composerSeed, setComposerSeed] = React.useState(null); const [paywallOpen, setPaywallOpen] = React.useState(false); React.useEffect(() => { if (initialView) setActive(initialView); }, [initialView]); const openComposer = (seed = null) => { setComposerSeed(seed); setComposerOpen(true); }; const closeComposer = () => setComposerOpen(false); return (
{/* Title bar */} setAiOpen(o=>!o)} onSearchSelect={goToEvent}/> {/* Sidebar */} {/* Main */}
{active === 'week' && } {active === 'month' && } {active === 'focus' && } {active === 'settings' && } {active === 'ai' && } {active === 'categories' && } {active === 'trips' && } {active === 'canvas' && } {active === 'profile' && setPaywallOpen(true)}/>}
{aiOpen && active !== 'ai' && ( setAiOpen(false)}/> )}
{selected && setSelected(null)} onEdit={()=>{ openComposer(selected); setSelected(null); }}/>} {composerOpen && } {paywallOpen && setPaywallOpen(false)}/>} {/* One-time beta welcome, shown once the tutorial is complete */} {/* Global futurism overlay for desktop */}
); } // One-time "welcome to the beta" notice. Appears once the user has finished // onboarding (so it lands right after the tutorial), then never again. function BetaNotice({ theme }) { const onboarding = useOnboarding(); if (!onboarding.done || onboarding.betaNoticeSeen) return null; const dismiss = () => setOnboarding({ betaNoticeSeen: true }); return (
e.stopPropagation()} style={{ width: 440, padding: 28, borderRadius: 16, background: theme.isDark ? 'rgba(20,22,32,0.97)' : 'rgba(255,255,255,0.99)', border: `0.5px solid ${theme.accentColor}55`, boxShadow: theme.isDark ? `0 30px 80px rgba(0,0,0,0.6), 0 0 60px ${theme.accentColor}33` : '0 30px 80px rgba(10,12,20,0.2)', animation: `chrono-modal-in 300ms ${Spring.std} both`, }}>
Private beta
Welcome to the Chrono beta
You're testing an early build, so expect a few rough edges. Every account has Pro unlocked while we test. Hit a bug or have an idea? Use Send feedback on your Profile — it goes straight to the team.
Let's go
); } // Top-bar search — finds events by title/note/location/category. Selecting a // result jumps the Week view to that event and flashes it (see goToEvent). function DesktopSearch({ theme, onSelect }) { const [q, setQ] = React.useState(''); const [open, setOpen] = React.useState(false); const [hi, setHi] = React.useState(0); useEvents(); // refresh results when the calendar changes const wrapRef = React.useRef(null); React.useEffect(() => { const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, []); const term = q.trim().toLowerCase(); const results = []; if (term) { for (const k of Object.keys(EVENTS_BY_OFFSET)) { const off = Number(k); for (const e of EVENTS_BY_OFFSET[k]) { const hay = `${e.title || ''} ${e.note || ''} ${e.location || ''} ${e.category || ''}`.toLowerCase(); if (hay.includes(term)) results.push({ off, ev: e }); } } // Nearest to today first, then chronological. results.sort((a, b) => Math.abs(a.off) - Math.abs(b.off) || a.off - b.off); } const shown = results.slice(0, 8); const dot = (c) => (ChronoTokens.events[c] || ChronoTokens.events.grey).glow; const choose = (r) => { if (!r) return; onSelect && onSelect(r.off, r.ev); setQ(''); setOpen(false); }; const onKey = (e) => { if (e.key === 'Escape') { setQ(''); setOpen(false); e.currentTarget.blur(); } else if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(h + 1, shown.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(h - 1, 0)); } else if (e.key === 'Enter') { choose(shown[hi] || shown[0]); } }; return (
{ setQ(e.target.value); setOpen(true); setHi(0); }} onFocus={() => setOpen(true)} onKeyDown={onKey} placeholder="Search events…" style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', color: theme.text, fontFamily: ChronoTokens.font.ui, fontSize: 12, }}/> {q ? ( ) : ( ⌘K )}
{open && term && (
{shown.length === 0 ? (
No events match “{q}”.
) : shown.map((r, i) => { const d = dateAtOffset(r.off); const when = `${DAY_NAMES[d.getDay()]}, ${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}`; return ( ); })}
)}
); } function DesktopTitleBar({ theme, aiOpen, onToggleAI, onSearchSelect }) { return (
{['#FF5F57','#FEBC2E','#28C840'].map((c, i) => (
))}
Chrono
v3.2 · Pro
{/* Search — left of the AI button; finds events and flashes them on the Week view */} {/* Assistant toggle — a labeled "AI" pill in a signature violet so it reads as the AI assistant, distinct from the accent-colored "+" manual event button beside it. */} {onToggleAI && (() => { const v = ChronoTokens.events.purple; // AI signature color return ( ); })()}
); } function DesktopAmbient({ theme }) { if (theme.bg === 'true-black') return null; return ( <>
); } // ── Sidebar ─────────────────────────────────────────────────────────────── function DesktopSidebar({ theme, active, setActive }) { const items = [ { id: 'week', label: 'Week', icon: }, { id: 'month', label: 'Month', icon: }, { id: 'focus', label: 'Focus', icon: }, { id: 'trips', label: 'Trips', icon: }, { id: 'ai', label: 'Assistant', icon: }, { id: 'categories', label: 'Categories', icon: }, { id: 'canvas', label: 'Canvas LMS', icon: }, { id: 'profile', label: 'Profile', icon: }, { id: 'settings', label: 'Settings', icon: }, ]; const [catsOpen, setCatsOpen] = React.useState(true); // Live per-category counts — walks the event store and re-renders on any // event mutation (manual add/edit/delete or AI-created), so the sidebar // tallies stay in sync with what's actually on the calendar. const catCounts = useCategories(); return (
{/* Profile chip */} {/* Categories — top, collapsible, default expanded */} {catsOpen && (
{catCounts.map(c => { const ec = ChronoTokens.events[c.color]; return (
{c.name} {c.count}
); })}
)} Workspace {items.map(it => { const a = active === it.id; return ( ); })}
); } function SidebarLabel({ theme, children, style }) { return (
{children}
); } // ── Week View ───────────────────────────────────────────────────────────── // Compact time label for narrow event pills — "9:30 AM" → "9:30a", "6:00 PM" // → "6p". Saves horizontal space so the title shows in side-by-side columns. function fmtTimeShort(hhmm) { const [h, m] = String(hhmm).split(':').map(Number); const period = h >= 12 ? 'p' : 'a'; const h12 = h % 12 || 12; return m === 0 ? `${h12}${period}` : `${h12}:${String(m).padStart(2,'0')}${period}`; } // Lay out a day's events into non-overlapping side-by-side columns (à la // Google Calendar). Two events "conflict" if their VISUAL spans overlap — // visual span = [start, max(realEnd, start + minMinutes)] — so a tiny 5-min // event whose min-height pill would cover the next block gets its own column // instead of being drawn on top of it. Returns Map(index → {col, cols, top, // dispH, compact}). function layoutDayEvents(evs, hourH, hour0, minHeightPx) { const pxPerMin = hourH / 60; const minMin = minHeightPx / pxPerMin; const items = evs.map((e, i) => { const s = tm(e.start), en = tm(e.end); return { i, s, en, vEnd: Math.max(en, s + minMin) }; }).sort((a, b) => a.s - b.s || a.vEnd - b.vEnd); const out = new Map(); let cluster = [], clusterEnd = -Infinity; const flush = () => { const colEnds = []; // running vEnd per column for (const it of cluster) { let c = 0; while (c < colEnds.length && it.s < colEnds[c]) c++; colEnds[c] = it.vEnd; it.col = c; } const cols = colEnds.length; for (const it of cluster) { const top = (it.s - hour0 * 60) / 60 * hourH; const dispH = Math.max(minHeightPx, (it.en - it.s) / 60 * hourH - 2); out.set(it.i, { col: it.col, cols, top, dispH, compact: dispH < 40 }); } cluster = []; }; for (const it of items) { if (cluster.length && it.s >= clusterEnd) { flush(); clusterEnd = -Infinity; } cluster.push(it); clusterEnd = Math.max(clusterEnd, it.vEnd); } flush(); return out; } function DesktopWeek({ theme, onSelect, onNewEvent, initialWeekOff, highlightTarget }) { useEvents(); const settings = useSettings(); const MIN_HOUR_H = 64; const HOURS = Array.from({length: 24}, (_, i) => i); // 12am – 12am (full 24h) // Offset (from CHRONO_TODAY) of the first column of today's week, honoring the // "Week starts on" setting: 0=Sunday, 1=Monday. Rolls back to whichever weekday // the user chose as the start. const currentWeekStartOffset = -((CHRONO_TODAY.getDay() - settings.weekStartsOn + 7) % 7); const [weekStart, setWeekStart] = React.useState( initialWeekOff != null ? initialWeekOff : currentWeekStartOffset); // Jump to a specific week when asked (e.g. the Trips planner lands here on the // trip's first week). React.useEffect(() => { if (initialWeekOff != null) setWeekStart(initialWeekOff); }, [initialWeekOff]); // When "Week starts on" changes, re-align the displayed grid to the new start // weekday while keeping the user on the same week they were viewing. const prevWeekStartsOn = React.useRef(settings.weekStartsOn); React.useEffect(() => { const oldOff = -((CHRONO_TODAY.getDay() - prevWeekStartsOn.current + 7) % 7); const newOff = -((CHRONO_TODAY.getDay() - settings.weekStartsOn + 7) % 7); if (oldOff !== newOff) setWeekStart(ws => ws + (newOff - oldOff)); prevWeekStartsOn.current = settings.weekStartsOn; }, [settings.weekStartsOn]); const days = [0,1,2,3,4,5,6].map(i => weekStart + i); // Search "find" flash: when highlightTarget changes, scroll the matching // event into view and pulse it for a couple of seconds so the user spots it. const [flashEv, setFlashEv] = React.useState(null); const flashRef = React.useRef(null); const flashClearRef = React.useRef(null); React.useEffect(() => { if (!highlightTarget || !highlightTarget.ev) return; setFlashEv(highlightTarget.ev); const scrollT = setTimeout(() => { const el = flashRef.current; if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 90); // let the matched element mount (and the week switch, if any) before scrolling if (flashClearRef.current) clearTimeout(flashClearRef.current); flashClearRef.current = setTimeout(() => setFlashEv(null), 2600); return () => { clearTimeout(scrollT); if (flashClearRef.current) clearTimeout(flashClearRef.current); }; }, [highlightTarget && highlightTarget.ts]); // Adaptive hour height: floor(scrollerHeight/24) + 1 guarantees content > scroller // (always scrollable) AND fills the box on large viewports with a minimal residual. const scrollerRef = React.useRef(null); const [hourH, setHourH] = React.useState(MIN_HOUR_H); const didInitialScroll = React.useRef(false); React.useLayoutEffect(() => { const el = scrollerRef.current; if (!el) return; const measure = () => { const avail = el.clientHeight; if (avail <= 0) return; const next = Math.max(MIN_HOUR_H, Math.floor(avail / 24) + 1); setHourH(prev => prev === next ? prev : next); if (!didInitialScroll.current) { el.scrollTop = 7 * next; didInitialScroll.current = true; } }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); // Header label: spans the week range. Single month → "June 2026"; cross-month → "Jun – Jul 2026". const firstDate = dateAtOffset(weekStart); const lastDate = dateAtOffset(weekStart + 6); const weekLabel = firstDate.getMonth() === lastDate.getMonth() ? `${MONTH_NAMES[firstDate.getMonth()]} ${firstDate.getFullYear()}` : `${MONTH_NAMES[firstDate.getMonth()].slice(0,3)} – ${MONTH_NAMES[lastDate.getMonth()].slice(0,3)} ${lastDate.getFullYear()}`; // ISO-ish week number (counts weeks since Jan 1). const jan1 = new Date(firstDate.getFullYear(), 0, 1); const weekNum = Math.floor(((firstDate - jan1) / 86400000 + jan1.getDay()) / 7) + 1; const now = new Date(); const nowMin = now.getHours()*60 + now.getMinutes(); const nowOff = (nowMin - HOURS[0]*60) / 60 * hourH; const totalH = HOURS.length * hourH; // ── Drag-to-move & drag-to-resize ────────────────────────────────────── // Pointer math lives in stable callbacks that read live values from refs, // so the window listeners we attach/detach are the same instances (reliable // cleanup). The store is only mutated on mouse-up — during the drag we keep // a transient preview in dragRef and re-render a "ghost" block. Because the // commit goes through updateEventByRef, the next AI prompt's snapshot picks // up the new day/time automatically. const SNAP = 15, MAX_MIN = 1439; const gridRef = React.useRef(null); const dragRef = React.useRef(null); const [, forceDrag] = React.useReducer(x => x + 1, 0); const liveRef = React.useRef({}); liveRef.current = { hourH, weekStart }; const onSelectRef = React.useRef(onSelect); onSelectRef.current = onSelect; const onDragMove = React.useCallback((e) => { const d = dragRef.current; if (!d) return; const { hourH: hH, weekStart: wStart } = liveRef.current; // Under the dynamic UI zoom, clientX/Y and getBoundingClientRect are in // scaled (visual) px while hourH/colW are layout px — divide by the scale. const sc = window.__chronoScale || 1; const pxPerMin = hH / 60; const dyMin = Math.round((e.clientY - d.pointerY0) / sc / pxPerMin / SNAP) * SNAP; const dur = d.originEndMin - d.originStartMin; let min0 = d.originStartMin, min1 = d.originEndMin, off = d.originOff; if (d.mode === 'move') { min0 = Math.max(0, Math.min(MAX_MIN - dur, d.originStartMin + dyMin)); min1 = min0 + dur; const grid = gridRef.current; if (grid) { const r = grid.getBoundingClientRect(); const colW = (r.width / sc - 64) / 7; let ci = Math.floor(((e.clientX - r.left) / sc - 64) / colW); off = wStart + Math.max(0, Math.min(6, ci)); } } else if (d.mode === 'resize-start') { min0 = Math.max(0, Math.min(d.originEndMin - SNAP, d.originStartMin + dyMin)); min1 = d.originEndMin; } else { // resize-end min1 = Math.max(d.originStartMin + SNAP, Math.min(MAX_MIN, d.originEndMin + dyMin)); min0 = d.originStartMin; } const moved = d.moved || Math.abs(e.clientX - d.pointerX0) > 3 || Math.abs(e.clientY - d.pointerY0) > 3; dragRef.current = { ...d, off, min0, min1, moved }; forceDrag(); }, []); const onDragUp = React.useCallback(() => { window.removeEventListener('mousemove', onDragMove); window.removeEventListener('mouseup', onDragUp); const d = dragRef.current; dragRef.current = null; if (d) { if (d.moved) { // Commit move/resize. updateEventByRef relocates across day arrays and // swaps in fresh start/end — keeps every category/note/priority field. updateEventByRef(d.ev, d.off, { ...d.ev, start: fmtHM(d.min0), end: fmtHM(d.min1) }); } else { onSelectRef.current && onSelectRef.current(d.ev); // a plain click → open details } } forceDrag(); }, [onDragMove]); const beginDrag = (e, ev, off, mode) => { e.preventDefault(); e.stopPropagation(); dragRef.current = { ev, mode, originOff: off, originStartMin: tm(ev.start), originEndMin: tm(ev.end), pointerX0: e.clientX, pointerY0: e.clientY, off, min0: tm(ev.start), min1: tm(ev.end), moved: false, }; window.addEventListener('mousemove', onDragMove); window.addEventListener('mouseup', onDragUp); forceDrag(); }; React.useEffect(() => () => { window.removeEventListener('mousemove', onDragMove); window.removeEventListener('mouseup', onDragUp); }, [onDragMove, onDragUp]); const dragging = dragRef.current; return (
setWeekStart(o => o - 7)}> setWeekStart(currentWeekStartOffset)}>Today setWeekStart(o => o + 7)}>
New event } /> {/* Day header */}
{days.map(off => { const d = dateAtOffset(off); const isToday = off === 0; const holiday = settings.showHolidays ? holidayForOffset(off) : null; return (
{DAY_FULL[d.getDay()].slice(0,3)}
{d.getDate()}
{holiday ? ( {holiday} ) : ( {eventsForOffset(off).length} ev )}
); })}
{/* Scroller */}
{/* Hour labels — one cell per hour row in column 1 */} {HOURS.map((h, i) => (
{fmtHour(h)}
))} {/* Day columns: one wrapper per day spanning all 24 rows */} {days.map((off, ci) => { const evs = eventsForOffset(off); const isToday = off === 0; // Side-by-side column layout so overlapping / clamped-tiny events // (e.g. 5-min breaks) sit beside their neighbours instead of on top. const MIN_EVENT_H = 18; const dayLayout = layoutDayEvents(evs, hourH, HOURS[0], MIN_EVENT_H); return (
{HOURS.map((h, i) => (
))} {evs.map((e, i) => { const lay = dayLayout.get(i); if (!lay) return null; const { col, cols, top, dispH, compact } = lay; if (top < 0 || top > totalH) return null; const c = ChronoTokens.events[e.color] || ChronoTokens.events.grey; // If this event is the one being dragged, fade it as a // placeholder; the live position renders as a ghost below. const isDragSource = dragging && dragging.ev === e && dragging.moved; const isFlash = flashEv && e === flashEv; const handleH = compact ? 5 : 7; const leftPct = (col / cols) * 100; const widthPct = (1 / cols) * 100; return (
beginDrag(ev, e, off, 'move')} style={{ position: 'absolute', left: `calc(${leftPct}% + 3px)`, width: `calc(${widthPct}% - 6px)`, top, height: dispH, boxSizing: 'border-box', cursor: 'grab', textAlign: 'left', zIndex: isFlash ? 9 : undefined, animation: isFlash ? 'chrono-find-flash 1.2s ease-out 2' : undefined, background: theme.isDark ? `linear-gradient(135deg, ${c.base}33, ${c.glow}1F)` : `linear-gradient(135deg, ${c.base}26, ${c.glow}14)`, border: `0.5px solid ${c.base}55`, borderLeft: `2.5px solid ${c.glow}`, borderRadius: 6, padding: compact ? '0 6px' : '4px 8px', display: compact ? 'flex' : 'block', alignItems: compact ? 'center' : undefined, gap: compact ? 5 : undefined, backdropFilter: 'blur(8px)', boxShadow: theme.isDark ? `0 2px 6px ${c.glow}33` : 'none', overflow: 'hidden', opacity: isDragSource ? 0.32 : 1, }}> {/* top resize handle */}
beginDrag(ev, e, off, 'resize-start')} style={{ position: 'absolute', top: 0, left: 0, right: 0, height: handleH, cursor: 'ns-resize', zIndex: 2, }}/> {compact ? ( // Single-line pill — title + time inline, like GCal's // "break, 6pm" so a 5-min event is fully legible. <> {e.title} {/* Inline time only when the pill is full-width — in narrow side-by-side columns the title gets all the room (time is still one click away). */} {cols === 1 && {fmtTimeShort(e.start)}} ) : ( <>
{e.title}
{fmtTime(e.start)}
)} {/* bottom resize handle */}
beginDrag(ev, e, off, 'resize-end')} style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: handleH, cursor: 'ns-resize', zIndex: 2, }}/>
); })} {/* Drag ghost — the live preview of the event being moved/resized, rendered in whichever day column the pointer is over. */} {dragging && dragging.moved && dragging.off === off && (() => { const gTop = (dragging.min0 - HOURS[0]*60) / 60 * hourH; const gH = Math.max(28, (dragging.min1 - dragging.min0) / 60 * hourH - 2); const gc = ChronoTokens.events[dragging.ev.color] || ChronoTokens.events.grey; return (
{dragging.ev.title}
{fmtTime(fmtHM(dragging.min0))}–{fmtTime(fmtHM(dragging.min1))}
); })()} {isToday && nowOff >= 0 && nowOff < totalH && (
)}
); })}
); } function DesktopHeader({ theme, title, eyebrow, right }) { return (
{eyebrow}
{title}
{right}
); } function DeskBtn({ children, primary, theme, onClick }) { return ( ); } // ── Month View (full bleed) ─────────────────────────────────────────────── function DesktopMonth({ theme, onSelect, onNewEvent }) { useEvents(); const settings = useSettings(); // monthDelta = months from current real month (0 = current). const [monthDelta, setMonthDelta] = React.useState(0); const todayRef = CHRONO_TODAY; const ref = new Date(todayRef.getFullYear(), todayRef.getMonth() + monthDelta, 1); const year = ref.getFullYear(), month = ref.getMonth(); const first = new Date(year, month, 1); // Back up to the configured first-day-of-week (0=Sun, 1=Mon) before the 1st. const leadDays = (first.getDay() - settings.weekStartsOn + 7) % 7; const gridStart = new Date(first); gridStart.setDate(1 - leadDays); // Weekday header labels rotated to match the configured week start. const dowLabels = DAY_NAMES.map((_, i) => DAY_NAMES[(i + settings.weekStartsOn) % 7]); const cells = []; for (let i = 0; i < 42; i++) { // full 6-week grid → entire month always visible const d = new Date(gridStart); d.setDate(gridStart.getDate()+i); cells.push(d); } const [popKey, setPopKey] = React.useState(null); // index of cell with open popover const closeAll = () => setPopKey(null); React.useEffect(() => { if (popKey === null) return; const onKey = e => { if (e.key === 'Escape') closeAll(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [popKey]); return (
setMonthDelta(d => d - 1)}> setMonthDelta(0)}>Today setMonthDelta(d => d + 1)}>
New event } />
{dowLabels.map(d =>
{d}
)}
{cells.map((d, i) => { const off = offsetForDate(d); const inMonth = d.getMonth() === month; const isToday = off === 0; const evs = eventsForOffset(off); const holiday = settings.showHolidays ? holidayForDate(d) : null; const MAX_VIS = 2; // prioritize seeing the full month const visible = evs.slice(0, MAX_VIS); const hidden = evs.length - visible.length; const isPopOpen = popKey === i; // Anchor popover left/right based on column to avoid clipping const col = i % 7; const popRight = col >= 4; return (
= 7 ? `0.5px solid ${theme.hairline}` : 'none', opacity: inMonth ? 1 : 0.4, minHeight: 0, overflow: 'visible', display: 'flex', flexDirection: 'column', gap: 3, }}>
{d.getDate()}
{holiday && ( {holiday} )}
{visible.map((e, j) => { const c = ChronoTokens.events[e.color]; return ( ); })} {hidden > 0 && ( )} {isPopOpen && (
ev.stopPropagation()} style={{ position: 'absolute', zIndex: 40, top: '100%', marginTop: 4, [popRight ? 'right' : 'left']: 0, width: 220, padding: 10, borderRadius: 12, background: theme.isDark ? 'rgba(20,22,32,0.96)' : 'rgba(255,255,255,0.98)', border: `0.5px solid ${theme.hairline}`, boxShadow: '0 16px 40px rgba(0,0,0,0.45)', backdropFilter: 'blur(40px)', animation: `chrono-scale-in 180ms ${Spring.std}`, display: 'flex', flexDirection: 'column', gap: 4, transformOrigin: popRight ? 'top right' : 'top left', }}>
{d.getDate()} {DAY_FULL[d.getDay()]} {evs.length} events
{evs.map((e, j) => { const c = ChronoTokens.events[e.color]; return ( ); })}
)}
); })}
); } // ── Focus full screen ──────────────────────────────────────────────────── function DesktopFocus({ theme }) { useEvents(); const [selectedEvent, setSelectedEvent] = React.useState(null); const [pickerOpen, setPickerOpen] = React.useState(false); // Session length: event duration if picked, otherwise 25 min. const TOTAL = selectedEvent ? Math.max(60, (tm(selectedEvent.end) - tm(selectedEvent.start)) * 60) : 25 * 60; const [el, setEl] = React.useState(0); const [running, setRunning] = React.useState(false); // Reset timer when the chosen event (and therefore TOTAL) changes. React.useEffect(() => { setEl(0); setRunning(false); }, [selectedEvent]); React.useEffect(() => { if (!running) return; const id = setInterval(()=>setEl(e=>Math.min(TOTAL, e+1)), 1000); return () => clearInterval(id); }, [running, TOTAL]); const remaining = TOTAL - el; const mm = Math.floor(remaining/60); const ss = remaining % 60; const progress = el / TOTAL; const huePulse = Math.floor(progress * 60); // Upcoming events from CHRONO_TODAY → 30 days out. const upcoming = []; for (let off = 0; off <= 30; off++) { const evs = eventsForOffset(off); evs.forEach(ev => upcoming.push({ ev, off })); } upcoming.sort((a, b) => (a.off - b.off) || tm(a.ev.start) - tm(b.ev.start)); const totalMinLabel = Math.round(TOTAL / 60); const sel = selectedEvent; const selColor = sel ? ChronoTokens.events[sel.color] : null; return (
Focus · {running ? 'In session' : (sel ? 'Ready' : 'Pick an event')}
{sel && (
)} {sel ? sel.title : 'No event selected'}
{/* Picker trigger + dropdown */}
{pickerOpen && (
{upcoming.length === 0 ? (
No upcoming events. Create one in the calendar first.
) : upcoming.map(({ ev, off }, i) => { const c = ChronoTokens.events[ev.color]; const d = dateAtOffset(off); const dayLabel = off === 0 ? 'Today' : off === 1 ? 'Tomorrow' : `${DAY_NAMES[d.getDay()]} · ${MONTH_NAMES[d.getMonth()].slice(0,3)} ${d.getDate()}`; const dur = tm(ev.end) - tm(ev.start); return ( ); })}
)}
{mm.toString().padStart(2,'0')}:{ss.toString().padStart(2,'0')}
{ setEl(0); setRunning(false); }}> setEl(TOTAL)}>
{sel ? `Press play to start a ${totalMinLabel}-minute session` : `Press play to start a 25-minute session`}
); } // ── Settings full ──────────────────────────────────────────────────────── function DesktopSettings({ theme }) { const settings = useSettings(); const integrations = useIntegrations(); const [connectingCanvas, setConnectingCanvas] = React.useState(false); const set = (k, v) => setSettings({ [k]: v }); const durationOpts = ['15','30','45','60','90','120'].map(n => ({ value:n, label:`${n} min` })); const leadOpts = ['0','5','10','15','30','60'].map(n => ({ value:n, label: n === '0' ? 'At start' : `${n} min` })); const bufferOpts = ['0','5','10','15','30'].map(n => ({ value:n, label: n === '0' ? 'None' : `${n} min` })); return (
set('weekStartsOn', parseInt(v, 10)) }}/> set('timeFormat', v) }}/> set('defaultDuration', parseInt(v, 10)) }}/> set('showHolidays', v) }}/> set('aiFollowups', v) }}/> set('aiBufferMin', parseInt(v, 10)) }}/> set('aiShowReasoning', v) }}/> set('notifLead', parseInt(v, 10)) }}/> set('sounds', v) }}/> { if (confirm('Disconnect Canvas? Imported assignments will be removed from your calendar.')) { clearCanvasEvents(); setIntegrations({ canvas: false, canvasSchool: '', canvasCourses: [] }); } } : () => setConnectingCanvas(true)}/> { if (confirm('Reset all settings to defaults?')) resetSettings(); }}/> { if (confirm('Re-run the onboarding survey and tutorial?')) resetOnboarding(); }}/>
{connectingCanvas && setConnectingCanvas(false)}/>}
); } // Settings group: a section title, optional one-line description, and a rounded // card holding the rows. Spotify-style — everything visible at once, no tabs. function DeskGroup({ theme, title, desc, children }) { return (
{title}
{desc && (
{desc}
)}
{children}
); } // A single settings row: label + description on the left, an inline control on // the right (a switch, a dropdown, or a tappable badge for actions / status). function DeskRow({ theme, k, desc, help, editor, onClick, muted, badge, badgeTone, last }) { const isToggle = editor && editor.kind === 'toggle'; // A toggle is operated by its own switch; the row itself isn't clickable. const rowClickable = !muted && !!onClick && !isToggle; // Hover-to-learn: after the pointer rests on a setting for 5 seconds, reveal // a fuller description of what it does. Settings-only (DeskRow is used only // here). Clears immediately on mouse-out so it never lingers. const tipText = help || desc; const rowRef = React.useRef(null); const [showTip, setShowTip] = React.useState(false); const [placeBelow, setPlaceBelow] = React.useState(false); const tipTimer = React.useRef(null); const HOVER_DELAY_MS = 450; // snappy: brief intentional pause, then reveal const startTip = () => { if (!tipText) return; clearTimeout(tipTimer.current); tipTimer.current = setTimeout(() => { // Flip the tooltip below the row when there isn't enough room above it // within the scrolling settings panel, so it never gets cut off. const el = rowRef.current; if (el) { const scroller = el.closest('.chrono-scrollbar'); const top = el.getBoundingClientRect().top; const bound = scroller ? scroller.getBoundingClientRect().top : 0; setPlaceBelow((top - bound) < 140); } setShowTip(true); }, HOVER_DELAY_MS); }; const endTip = () => { clearTimeout(tipTimer.current); setShowTip(false); }; React.useEffect(() => () => clearTimeout(tipTimer.current), []); return (
{ if (rowClickable) e.currentTarget.style.background = theme.isDark ? 'rgba(255,255,255,0.03)' : 'rgba(10,12,20,0.02)'; startTip(); }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; endTip(); }} >
{k}
{desc && (
{desc}
)}
{editor ? : }
{/* Hover tooltip — opaque card with a gap from the row, flipped above or below depending on room, so it never bleeds into or gets clipped by the neighbouring settings or the panel edge. */} {showTip && tipText && (
{k}
{tipText}
)}
); } // Inline status / action chip used on the right of a row. function DeskBadge({ theme, label, tone, muted }) { if (!label) return null; const palette = { on: { fg: '#52d094', bd: '#52d09455', bg: 'rgba(82,208,148,0.12)' }, action: { fg: theme.accentColor, bd: `${theme.accentColor}55`, bg: `${theme.accentColor}14` }, danger: { fg: ChronoTokens.events.red.base, bd: `${ChronoTokens.events.red.base}55`, bg: `${ChronoTokens.events.red.base}14` }, muted: { fg: theme.textFaint, bd: theme.hairline, bg: 'transparent' }, }; const p = muted ? palette.muted : (palette[tone] || palette.action); return ( {label} ); } // Inline control — a switch for toggles, a native dropdown for selects. Both // commit immediately (no expand-then-confirm step), the way Spotify does it. function DeskControl({ theme, editor }) { if (editor.kind === 'toggle') { const on = !!editor.value; return ( ); } if (editor.kind === 'select') { return (
); } return null; } // ── AI Panel (right side) ──────────────────────────────────────────────── function DesktopAIPanel({ theme, personality, onClose }) { return (
Assistant
{personality} · live
); } function DesktopAIFull({ theme, personality }) { return (
); } // ── Chat sub-components ────────────────────────────────────────────── function ChatBubble({ m, theme, onResolveSuggestion }) { const isUser = m.role === 'user'; const text = m.text || m.content || ''; const offer = m.suggestion; const ec = offer && ChronoTokens.events[offer.color] ? ChronoTokens.events[offer.color] : ChronoTokens.events.grey; return (
{text || (m.role === 'assistant' && m.error ? {m.error} : null)} {/* Category offer — Yes/No while unresolved, status line once answered. */} {offer && !m.suggestionResolved && (
)} {offer && m.suggestionResolved && (
{m.suggestionResolved === 'accepted' ? `✓ Created “${offer.name}” and tagged the event${(m.suggestRefs || []).length === 1 ? '' : 's'}.` : '· Left uncategorized.'}
)} {m.meta && (
{m.meta}
)}
); } function StreamingBubble({ theme, text }) { return (
{text || ''}
); } // Map a day label or ISO date from the model into a day-offset from CHRONO_TODAY. function offsetFromDayLabel(label) { if (!label) return 0; const s = String(label).trim(); const lower = s.toLowerCase(); if (lower === 'today') return 0; if (lower === 'tomorrow') return 1; const DOW = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; const idx = DOW.indexOf(lower); if (idx >= 0) { // Resolve weekday → nearest upcoming occurrence INCLUDING today. When // today is Monday and the model says "Monday", that means today, not // a week from now. (The model is instructed to use ISO dates for // unambiguous cases.) const today = CHRONO_TODAY.getDay(); return ((idx - today) + 7) % 7; } // ISO YYYY-MM-DD if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { const [y, mo, d] = s.split('-').map(n => parseInt(n, 10)); const target = new Date(y, mo - 1, d); target.setHours(0,0,0,0); return Math.round((target - CHRONO_TODAY) / 86400000); } return 0; } // Flatten the runtime event store into a model-readable shape, with stable ids. function snapshotEventsForAI() { const out = []; Object.keys(EVENTS_BY_OFFSET).forEach(k => { const off = parseInt(k, 10); const d = dateAtOffset(off); const ymd = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; EVENTS_BY_OFFSET[k].forEach((e, i) => { out.push({ id: `ev-${off}-${i}`, date: ymd, start: e.start, end: e.end, title: e.title, color: e.color, category: e.category || null, _ref: e, // server never sees this; used locally for delete }); }); }); return out; } function DesktopAIBody({ theme, personality }) { const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [streaming, setStreaming] = React.useState(false); const [streamText, setStreamText] = React.useState(''); const scrollRef = React.useRef(null); const abortRef = React.useRef(null); const onboarding = useOnboarding(); const settings = useSettings(); React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages, streaming, streamText]); React.useEffect(() => () => { if (abortRef.current) abortRef.current.abort(); }, []); const send = async (text) => { const msg = (text || input).trim(); if (!msg || streaming) return; setInput(''); const newHistory = [...messages, { role: 'user', text: msg }]; setMessages(newHistory); setStreaming(true); setStreamText(''); // Bail out if the streamer isn't on the page (e.g. served without the // proxy running). Surface a clear message. if (typeof window.streamAssistant !== 'function') { setMessages(m => [...m, { role: 'assistant', error: 'AI client not loaded. Make sure lib/ai-client.jsx is included and the proxy is running on http://localhost:3001.', }]); setStreaming(false); return; } abortRef.current = new AbortController(); const eventsSnapshot = snapshotEventsForAI(); const eventsForServer = eventsSnapshot.map(({ _ref, ...rest }) => rest); const a = onboarding.answers || {}; const userContext = { role: a.role, domains: a.domains, goals: a.goals, gymGoal: a.gymGoal, procrastination: a.procrastination, peak: a.peak, sessionLen: a.sessionLen, wake: a.wake, bed: a.bed, density: a.density, timeFormat: settings.timeFormat, bufferMin: settings.aiBufferMin, showReasoning: settings.aiShowReasoning, followups: settings.aiFollowups, categories: CATEGORIES.map(c => ({ id: c.id, name: c.name, color: c.color })), }; let buffer = ''; let metaInfo = null; try { await window.streamAssistant({ messages: newHistory.map(m => ({ role: m.role, content: m.text || m.content || '' })), personality, events: eventsForServer, userContext, signal: abortRef.current.signal, onMeta: (m) => { metaInfo = m; }, onDelta: (d) => { buffer += d.text || ''; setStreamText(buffer); }, onDone: (final) => { const replyText = (final && final.text) || buffer || ''; // Refs of events created THIS turn that landed uncategorized — if the // user accepts an AI-proposed category, we re-tag exactly these. const uncategorizedRefs = []; // Apply / mutations to the local store. `events` // is always an array — empty when the model proposed nothing, // single-element for a one-event add, multi-element for week plans. if (final && Array.isArray(final.events)) { final.events.forEach(ev => { if (!ev || typeof ev !== 'object') return; const off = offsetFromDayLabel(ev.day || ev.date); // Resolve category and color together. Priority: // 1. Explicit category id that matches an existing category. // 2. Explicit color that matches an existing category's color. // 3. Otherwise leave UNCATEGORIZED → grey. We never invent a // category here; the model asks first via . let category = null, color = 'grey'; if (ev.category && CATEGORIES.some(c => c.id === ev.category)) { category = ev.category; color = ev.color && ev.color !== 'grey' ? ev.color : CATEGORIES.find(c => c.id === category).color; } else if (ev.color && ev.color !== 'grey') { // Color-only: adopt it ONLY if it maps to an existing category. // Otherwise the event is uncategorized and must render grey. const m = CATEGORIES.find(c => c.color === ev.color); if (m) { category = m.id; color = ev.color; } else { category = null; color = 'grey'; } } const ref = addEventAtOffset(off, { title: ev.title || 'Untitled', start: ev.start || '09:00', end: ev.end || '10:00', color, category, note: ev.note || '', }); if (!category) uncategorizedRefs.push(ref); }); } if (final && Array.isArray(final.removeIds) && final.removeIds.length) { // Map the model's ids back to our local _ref using the snapshot. const byId = new Map(eventsSnapshot.map(e => [e.id, e._ref])); final.removeIds.forEach(id => { const ref = byId.get(id); if (ref) deleteEventByRef(ref); }); } const meta = metaInfo ? `${metaInfo.provider || ''}${metaInfo.provider ? ':' : ''}${metaInfo.model || ''} · ${final?.latencyMs ?? '?'}ms` : null; // If the model proposed creating a category for these events, attach // the offer + the just-created refs so the bubble can render Yes/No. const suggestion = final && final.suggestion && final.suggestion.name ? { name: String(final.suggestion.name).slice(0, 40), color: final.suggestion.color || 'grey' } : null; const assistantMsg = { role: 'assistant', text: replyText, meta }; if (suggestion && uncategorizedRefs.length) { assistantMsg.suggestion = suggestion; assistantMsg.suggestRefs = uncategorizedRefs; } setMessages(m => [...m, assistantMsg]); setStreaming(false); setStreamText(''); }, onError: (e) => { setMessages(m => [...m, { role: 'assistant', error: e?.message || 'Assistant request failed.', }]); setStreaming(false); setStreamText(''); }, }); } catch (err) { setMessages(m => [...m, { role: 'assistant', error: err?.message || 'Unexpected error.' }]); setStreaming(false); setStreamText(''); } }; // Resolve an AI category offer attached to message #i. Accept → create the // category and re-tag the events it was offered for. Decline → leave them // grey/uncategorized. Either way the offer is marked resolved so the // Yes/No chips collapse to a status line. const resolveSuggestion = (i, accept) => { setMessages(prev => prev.map((m, idx) => { if (idx !== i || !m.suggestion || m.suggestionResolved) return m; if (accept) { const cat = addCategory({ name: m.suggestion.name, color: m.suggestion.color }); (m.suggestRefs || []).forEach(ref => setEventCategory(ref, cat.id, cat.color)); return { ...m, suggestionResolved: 'accepted', suggestion: { ...m.suggestion, name: cat.name } }; } return { ...m, suggestionResolved: 'declined' }; })); }; // Starter prompts tailored to the user's mix. const starters = (() => { const a = onboarding.answers || {}; const domains = Array.isArray(a.domains) ? a.domains : []; const out = []; if (domains.includes('school')) out.push('Plan study blocks this week'); if (domains.includes('sidebuild'))out.push('Block 2h deep work tomorrow'); if (domains.includes('gym')) out.push('Add my gym sessions this week'); if (domains.includes('pm')) out.push('Schedule sprint planning Monday'); if (Array.isArray(a.procrastination) && a.procrastination.length) { out.push("I'm stuck — give me a 15-min starter"); } while (out.length < 3) out.push(['Plan my week','Move my 1:1 to Friday','What\'s on tomorrow?'][out.length] || 'Plan my week'); return out.slice(0, 3); })(); return (
{messages.length === 0 && !streaming && } {messages.map((m, i) => resolveSuggestion(i, accept)}/>)} {streaming && }
{starters.map(s => ( ))}
setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && send()} placeholder="Ask Chrono…" maxLength={4000} style={{ flex: 1, appearance: 'none', border: 'none', outline: 'none', background: 'transparent', color: theme.text, fontFamily: ChronoTokens.font.ui, fontSize: 13, padding: '6px 10px', }}/>
); } // ── Event details popover ──────────────────────────────────────────────── function DesktopEventDetails({ theme, ev, onClose, onEdit }) { const c = ChronoTokens.events[ev.color]; const [rescheduling, setRescheduling] = React.useState(false); // Locate the event's current offset so the reschedule form opens with the right date. const currentOff = React.useMemo(() => { for (const k of Object.keys(EVENTS_BY_OFFSET)) { if (EVENTS_BY_OFFSET[k].indexOf(ev) >= 0) return parseInt(k, 10); } return 0; }, [ev]); const initialDate = dateToYMD(dateAtOffset(currentOff)); const [date, setDate] = React.useState(initialDate); const [start, setStart] = React.useState(ev.start); const [end, setEnd] = React.useState(ev.end); const doReschedule = () => { const [y, m, d] = date.split('-').map(n => +n); const picked = new Date(y, m - 1, d); picked.setHours(0,0,0,0); const anchor = new Date(CHRONO_TODAY); anchor.setHours(0,0,0,0); const off = Math.round((picked - anchor) / 86400000); updateEventByRef(ev, off, { ...ev, start, end }); onClose(); }; const doDelete = () => { deleteEventByRef(ev); onClose(); }; const inputStyle = { appearance: 'none', border: `0.5px solid ${theme.hairline}`, background: theme.isDark ? 'rgba(255,255,255,0.04)' : 'rgba(10,12,20,0.03)', color: theme.text, fontFamily: ChronoTokens.font.mono, fontSize: 12, fontWeight: 600, padding: '6px 8px', borderRadius: 8, outline: 'none', colorScheme: theme.isDark ? 'dark' : 'light', }; return (
{ev.title}
{fmtTime(ev.start)} → {fmtTime(ev.end)}
{ev.category || '—'}
{ev.location &&
{ev.location}
} {ev.priority &&
{ev.priority} priority
} {ev.note &&
{ev.note}
}
{rescheduling && (
Reschedule
setDate(e.target.value)} style={{ ...inputStyle, flex: 1 }}/>
setStart(e.target.value)} style={{ ...inputStyle, flex: 1 }}/> setEnd(e.target.value)} style={{ ...inputStyle, flex: 1 }}/>
)}
{rescheduling ? ( <> Save { setRescheduling(false); setDate(initialDate); setStart(ev.start); setEnd(ev.end); }}>Cancel ) : ( <> Edit setRescheduling(true)}> Reschedule
)}
); } // ── Event Composer Modal (Manual New event) ──────────────────────────── function DesktopEventComposer({ theme, seed, onClose }) { const initial = seed || {}; const settings = useSettings(); const categoryList = useCategoryList(); // re-render when categories are added/removed // Resolve initial category: explicit on seed, else the category whose color // matches the seed. A brand-new event (or one with no matching category) // stays UNCATEGORIZED (null) and renders grey. const initialCategory = React.useMemo(() => { if (initial.category && CATEGORIES.some(c => c.id === initial.category)) return initial.category; if (initial.color) { const m = CATEGORIES.find(c => c.color === initial.color); if (m) return m.id; } return null; }, []); const [category, setCategoryRaw] = React.useState(initialCategory); const [title, setTitle] = React.useState(initial.title || ''); const [color, setColor] = React.useState( initial.color || CATEGORIES.find(c => c.id === initialCategory)?.color || 'grey'); // Track whether the user has overridden the auto-color from the category. // While false, swapping categories also swaps color. const [colorOverridden, setColorOverridden] = React.useState(!!initial.color && initial.category && CATEGORIES.find(c => c.id === initial.category)?.color !== initial.color); const setCategory = (id) => { setCategoryRaw(id); if (!colorOverridden) { const c = CATEGORIES.find(c => c.id === id); setColor(c ? c.color : 'grey'); // null category → grey } }; const setColorByUser = (k) => { setColor(k); setColorOverridden(true); }; const [allDay, setAllDay] = React.useState(false); const [date, setDate] = React.useState(() => dateToYMD(new Date(CHRONO_TODAY))); const [start, setStart] = React.useState(initial.start || '09:00'); const [end, setEnd] = React.useState(initial.end || (() => { const startMin = parseHM(initial.start || '09:00'); return fmtHM(startMin + (settings.defaultDuration || 60)); })()); const [location, setLocation] = React.useState(initial.location || ''); const [note, setNote] = React.useState(initial.note || ''); const [priority, setPriority] = React.useState(initial.priority || 'normal'); const [repeat, setRepeat] = React.useState('none'); const [reminder, setReminder] = React.useState('10'); const [closing, setClosing] = React.useState(false); const titleRef = React.useRef(null); // Focus the title ONCE on open. (Previously this lived in the keydown effect // below with no dep array, so it re-ran on every render and yanked the cursor // back to the title whenever you typed in Notes or any other field.) React.useEffect(() => { titleRef.current?.focus(); }, []); // Keydown shortcuts — no dep array so doClose/doSave always see current state. React.useEffect(() => { const onKey = e => { if (e.key === 'Escape') doClose(); if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') doSave(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }); const doClose = () => { setClosing(true); setTimeout(onClose, 160); }; const doSave = () => { const d = ymdToDate(date); d.setHours(0,0,0,0); const anchor = new Date(CHRONO_TODAY); anchor.setHours(0,0,0,0); const off = Math.round((d - anchor) / 86400000); const ev = { title: title.trim() || 'Untitled', start: allDay ? '00:00' : start, end: allDay ? '23:59' : end, color, priority, category, location: location.trim() || undefined, note: note.trim() || undefined, }; if (seed) updateEventByRef(seed, off, ev); else addEventAtOffset(off, ev); doClose(); }; const colors = ['blue','purple','pink','orange','yellow','green','red']; const c = ChronoTokens.events[color]; return (
e.stopPropagation()} style={{ width: 600, maxHeight: '90%', overflow: 'auto', borderRadius: 18, background: theme.isDark ? 'rgba(20,22,32,0.96)' : 'rgba(255,255,255,0.98)', border: `0.5px solid ${theme.hairline}`, boxShadow: theme.isDark ? `0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04), 0 0 60px ${theme.accentColor}22` : `0 30px 80px rgba(10,12,20,0.18), 0 0 0 1px rgba(10,12,20,0.04)`, backdropFilter: 'blur(40px)', animation: closing ? `chrono-modal-in 200ms ${Spring.snap} reverse forwards` : `chrono-modal-in 320ms ${Spring.std} both`, position: 'relative', }} className="chrono-scrollbar"> {/* Accent gradient strip top */}
{/* Header */}
{seed ? 'Edit event' : 'New event'}
setTitle(e.target.value)} placeholder="Event title…" style={{ width: '100%', appearance: 'none', border: 'none', outline: 'none', background: 'transparent', color: theme.text, fontFamily: ChronoTokens.font.display, fontSize: 22, fontWeight: 700, letterSpacing: '-0.025em', padding: 0, }}/>
{/* Category row */} }>
{/* None — uncategorized, renders grey. */} {(() => { const sel = !category; const gc = ChronoTokens.events.grey; return ( ); })()} {categoryList.map(cat => { const ec = ChronoTokens.events[cat.color]; const sel = category === cat.id; return ( ); })}
{/* Color row */} }>
{colors.map(k => { const ec = ChronoTokens.events[k]; const sel = color === k; return (
{/* Date / time — 7-day strip + start/end */} }> {/* Options — all-day + recurrence */} }>
{/* Location */} }> setLocation(e.target.value)} placeholder="Add location, video link, or address" style={composerInputStyle(theme)}/> {/* Priority */} }> {/* Reminder */} }> {/* Notes */} }>