Something went wrong. Try again.
a local-first atmospheric chalky task planner
Something went wrong. Try again.
44 kB · 974 lines
Svelte
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975<script lang="ts"> import { onMount } from "svelte"; import Header from "./lib/components/Header.svelte"; import Toolbar from "./lib/components/Toolbar.svelte"; import WeekGrid from "./lib/components/WeekGrid.svelte"; import MonthView from "./lib/components/MonthView.svelte"; import DayView from "./lib/components/DayView.svelte"; import ThreeDayView from "./lib/components/ThreeDayView.svelte"; import Someday from "./lib/components/Someday.svelte"; import SelectLayer from "./lib/components/SelectLayer.svelte"; import LoginScreen from "./lib/components/LoginScreen.svelte"; import LandingModal from "./lib/components/LandingModal.svelte"; import CreditsModal from "./lib/components/CreditsModal.svelte"; import StatsModal from "./lib/components/StatsModal.svelte"; import ControlsModal from "./lib/components/ControlsModal.svelte"; import SettingsModal from "./lib/components/SettingsModal.svelte"; import DataModeModal from "./lib/components/DataModeModal.svelte"; import MigrationBanner from "./lib/components/MigrationBanner.svelte"; import DetailsPanel from "./lib/components/DetailsPanel.svelte"; import ContextMenu from "./lib/components/ContextMenu.svelte"; import BootLoader from "./lib/components/BootLoader.svelte"; import { store } from "./lib/store/tasks.svelte"; import { ui } from "./lib/store/ui.svelte"; import { settings } from "./lib/store/settings.svelte"; import { inkFor } from "./lib/palette"; import { config } from "./lib/store/config.svelte"; import { migration } from "./lib/store/migration.svelte"; import { readUrl, writeUrl, hasPendingOAuthCallback, type UrlState } from "./lib/url"; import { initSession, signOut, clearSessionExpired, reopenSession, isSessionDead, isUnconfirmedDelete, lastHandle, consumeDeliberateSignOut, TransientRestoreError, SignInFailedError, type Session, } from "./lib/atproto/auth"; import { authNotice } from "./lib/store/auth-notice.svelte"; import { profile } from "./lib/store/profile.svelte"; import SignedOutNotice from "./lib/components/SignedOutNotice.svelte"; import SpacesUnsupportedNotice from "./lib/components/SpacesUnsupportedNotice.svelte"; import { clearLegacyHappyViewSession } from "./lib/atproto/legacy-happyview"; import { readConfig, type DataMode } from "./lib/atproto/config"; import { SyncEngine, migrateRecordsTo, clearRecordsIn, liveRecordCount, reseedPrivateIfNeeded, } from "./lib/atproto/sync"; import { PdsRepo, SpaceRepo, SpacesUnsupportedError } from "./lib/atproto/repo"; import { getMeta } from "./lib/db/idb"; import { setCursorDebug, logCursor, probeCursor } from "./lib/debug-cursor"; import { slog } from "./lib/sync-log"; import { setSyncDebug } from "./lib/sync-log"; import { NO_COLOR } from "./lib/types"; import { swipeNav } from "./lib/swipe";
let session = $state<Session | null>(null); let showLogin = $state(false);
// First-time welcome modal: shown once, then never again. `localStorage` // read is guarded (private-browsing/quota can throw) the same way the // settings store guards its own reads/writes. const LANDING_SEEN_KEY = "chalky:landing-seen"; function hasSeenLanding(): boolean { try { return localStorage.getItem(LANDING_SEEN_KEY) === "1"; } catch { return true; // can't persist the flag → don't nag on every load } } let landingOpen = $state(!hasSeenLanding()); function dismissLanding() { landingOpen = false; try { localStorage.setItem(LANDING_SEEN_KEY, "1"); } catch { /* ignore */ } } // First-run: set when a signed-in user has no config record yet. let modeChoiceSession = $state<Session | null>(null); let engine: SyncEngine | null = null;
// Boot loader: covers the board while a launch's first sync pulls changes // made elsewhere, so the open doesn't show this device's stale copy and then // reshuffle it. It is never shown on spec — only once the engine has seen // changes this device doesn't hold yet (`onCatchUp`; see sync.ts), and // only for the first engine of the page's life, so a later data-mode switch // never blanks the screen. A pull that finishes inside the grace period (an // ordinary op-log delta, applied locally in milliseconds) never shows it; once up it stays long enough not to // read as a flicker, and gives up after the cap rather than holding the board // hostage to a slow snapshot. const BOOT_LOADER_GRACE_MS = 300; const BOOT_LOADER_MIN_MS = 500; const BOOT_LOADER_CAP_MS = 12_000; let bootLoader = $state(false); let bootEngineStarted = false; let bootLoaderTimer: ReturnType<typeof setTimeout> | null = null; let bootLoaderShownAt = 0; function onBootCatchUp(busy: boolean) { if (bootLoaderTimer) clearTimeout(bootLoaderTimer); bootLoaderTimer = null; if (busy) { bootLoaderTimer = setTimeout(() => { bootLoader = true; bootLoaderShownAt = Date.now(); bootLoaderTimer = setTimeout(() => onBootCatchUp(false), BOOT_LOADER_CAP_MS); }, BOOT_LOADER_GRACE_MS); return; } if (!bootLoader) return; const left = BOOT_LOADER_MIN_MS - (Date.now() - bootLoaderShownAt); if (left > 0) bootLoaderTimer = setTimeout(() => (bootLoader = false), left); else bootLoader = false; }
// Auto-dismiss the migration banner a few seconds after a successful move; the // error banner stays until the user retries or dismisses it. $effect(() => { if (migration.phase !== "done") return; const t = setTimeout(() => migration.dismiss(), 4000); return () => clearTimeout(t); });
// A session dying mid-visit — expiry, revocation, a replayed refresh token // — is normally caught the moment it happens (auth.ts's `onSessionDeleted` // calls `markSessionExpired`, which flips `authNotice.expired`) or by the // sync engine noticing the same thing in a cycle (`onSessionDead` below). // Either way lands here: drop the app back to the signed-out state exactly // as a boot-time expiry would, instead of leaving a dead session's engine // spinning against a repo that can no longer authenticate. Idempotent — // clearing `session` (a dependency) stops this from re-running. $effect(() => { if (!authNotice.expired) return; profile.clear(); if (!session) return; engine?.stop(); engine = null; config.detach(); session = null; modeChoiceSession = null; store.status = "idle"; });
// Restore view / color filter / brush mode from the URL before first paint. function applyUrl() { const s = readUrl(); ui.view = s.view ?? "day"; if (s.anchor) ui.anchorKey = s.anchor; store.colorFilter = s.color ?? null; ui.colorMode = s.brush?.on ?? false; if (s.brush) ui.paintColor = s.brush.color; ui.controlsOpen = s.panel === "controls"; ui.creditsOpen = s.panel === "credits"; ui.statsOpen = s.panel === "stats" && !settings.hideStats; ui.settingsOpen = s.panel === "settings"; } applyUrl(); // Reconcile "today" against localStorage right after the anchor is restored // from the URL (see the note in ui.svelte.ts) — covers a cold load where the // tab, or a PWA relaunched from its cached shell, has sat since before // midnight. ui.checkRollover();
// Keep the URL in step with those pieces of state as they change — but never // while an OAuth callback is still in the address bar, or we'd strip the // `?code=&state=` params before the OAuth client can consume them. $effect(() => { const next: UrlState = { view: ui.view, anchor: ui.anchorKey, color: store.colorFilter, brush: { on: ui.colorMode, color: ui.paintColor }, panel: ui.controlsOpen ? "controls" : ui.creditsOpen ? "credits" : ui.settingsOpen ? "settings" : ui.statsOpen ? "stats" : null, }; if (hasPendingOAuthCallback()) return; writeUrl(next); });
// While any drag is in flight — a single task (svelte-dnd-action) or a lifted // selection group — force the "grabbing" (closed hand) cursor across the whole // page. A root class beats the per-element rules (the dragged clone otherwise // shows its resting `grab`, and the select overlay its target reticle). // Turn the cursor debug trail on/off as the signed-in handle changes — only // the debug handle (see debug-cursor.ts) gets a log trail; everyone else pays // nothing. Runs on sign-in, sign-out, and handle changes. $effect(() => { setCursorDebug(session?.handle); // Same gate for the persistent sync trail (recording + console echo). A // null handle leaves the last remembered decision in force, so the debug // account's boot-time events are still captured (see sync-log.ts). setSyncDebug(session?.handle); });
// Inject every palette entry — including hidden ones, so a task that still // references a removed color keeps rendering its tint — as a `--hl-<id>` // custom property on the root. This is what makes a user's custom/edited // colors actually render; the static `--hl-<slug>` vars in app.css remain // only as the pre-customization fallback for the five defaults. Vars for // ids no longer in the palette (e.g. after an id was regenerated) are // removed so stale custom properties don't linger forever. // Alongside `--hl-<id>`, also inject `--hl-ink-<id>`: the chip-ink color // (dark or light) that contrasts best against that entry's hex, per // `inkFor` — so a dark custom tint still gets readable text. Computed here // (not in CSS) since it depends on the arbitrary hex, not the theme. let appliedPaletteVars = new Set<string>(); $effect(() => { const root = document.documentElement; const ids = new Set(settings.palette.map((c) => c.id)); for (const c of settings.palette) { root.style.setProperty(`--hl-${c.id}`, c.hex); root.style.setProperty(`--hl-ink-${c.id}`, inkFor(c.hex)); } for (const id of appliedPaletteVars) { if (!ids.has(id)) { root.style.removeProperty(`--hl-${id}`); root.style.removeProperty(`--hl-ink-${id}`); } } appliedPaletteVars = ids; });
// If the active brush color or the color filter ends up pointing at a color // that's no longer active (hidden by the user, or hidden by a remote edit // that just hydrated in), fall back gracefully rather than leaving a picker // stuck on a swatch that no longer shows anywhere: the brush resets to the // first active color, the filter just clears. $effect(() => { const activeIds = new Set(settings.activePalette.map((c) => c.id)); if (ui.paintColor !== null && !activeIds.has(ui.paintColor)) { ui.paintColor = settings.activePalette[0]?.id ?? null; } }); $effect(() => { const activeIds = new Set(settings.activePalette.map((c) => c.id)); // NO_COLOR is a filter sentinel, not a palette id — it never appears in // (or drops out of) activePalette, so it's exempt from this sweep. if ( store.colorFilter !== null && store.colorFilter !== NO_COLOR && !activeIds.has(store.colorFilter) ) { store.colorFilter = null; } });
// A view switch starts the new view at the top. Week → Day can shrink the page // by thousands of px; WebKit on iOS drops the scroller to 0 by itself, but // macOS Safari has been seen keeping the stale scrollTop past the new end — // the header and board above the fold, nothing below "idk when" — until the // tab was hidden and shown again. Setting it outright leaves nothing to clamp. let mainEl = $state<HTMLElement | null>(null); $effect(() => { ui.view; if (mainEl) mainEl.scrollTop = 0; });
$effect(() => { const on = ui.dragging || ui.draggingSelection; const root = document.documentElement; const was = root.classList.contains("dragging"); root.classList.toggle("dragging", on); // Log only real transitions so the trail shows drag start/stop, not every // reactive re-run. Deferred a frame: the class flip has to hit the DOM before // getComputedStyle can report the cursor it now resolves to. if (was !== on) requestAnimationFrame(() => logCursor(`dragging class ${on ? "ON" : "OFF"}`, { dragging: ui.dragging, draggingSelection: ui.draggingSelection, }, lastPointer), ); });
// Held modifiers change what a click does (shift = rename, ctrl+shift = // delete), so preview that action with the cursor while the key is down by // toggling a root class the CSS keys off. The gates mirror the click handlers: // neither gesture applies in color/select mode, mid-drag, or while editing, so // we don't flash a misleading cursor there. let mods = $state({ shift: false, ctrl: false }); $effect(() => { const idle = !ui.colorMode && !ui.selectMode && !ui.dragging && !ui.draggingSelection && !ui.editingId; const root = document.documentElement; const edit = idle && mods.shift && !mods.ctrl; const del = idle && mods.shift && mods.ctrl; const changed = root.classList.contains("mod-edit") !== edit || root.classList.contains("mod-delete") !== del; root.classList.toggle("mod-edit", edit); root.classList.toggle("mod-delete", del); // A stuck pencil/cross is the classic failure here (a dropped keyup leaving a // modifier "on"), so log the full gate on every transition: which mod is held // and which `idle` sub-condition is in play. Deferred a frame so the resolved // cursor reflects the class just written. if (changed) requestAnimationFrame(() => logCursor(`mod class -> ${edit ? "edit" : del ? "delete" : "none"}`, { shift: mods.shift, ctrl: mods.ctrl, idle, colorMode: ui.colorMode, selectMode: ui.selectMode, dragging: ui.dragging, draggingSelection: ui.draggingSelection, editingId: ui.editingId, }, lastPointer), ); });
// Last pointer position, kept fresh on every move (see onMount). Used to // report the cursor under the pointer in the debug trail and the probe. const lastPointer = { x: 0, y: 0, seen: false };
// NOTHING here writes a `cursor` value. That is deliberate, and it is the // whole fix for the stuck-cursor family of bugs. // // We used to run a `nudgeCursor()`: browsers only re-resolve the CSS `cursor` // on the next pointer hit-test, so toggling a cursor-defining mode from the // keyboard (leaving select mode with `s`/Esc, toggling the brush with `c`) // while the pointer sat still left the *previous* glyph painted. The nudge // poked the element under the pointer by writing an inline `cursor: auto` onto // it and handing the original value back a frame later. // // Borrowing a style you have to give back is a bug generator, and it kept // generating: the give-back lived entirely inside requestAnimationFrame, which // simply does not fire in a backgrounded or throttled tab. Flip a mode and // switch away — to devtools, another window, another Space — and the borrowed // `cursor: auto` is pinned inline with nothing left to ever restore it. Inline // styles outrank every non-important rule in app.css, so that element is stuck // showing the *native* arrow permanently, unreachable by any state change. A // previous round added a hand-off protocol between overlapping nudges to close // a narrower version of the same hole; this is the version that doesn't need // one. // // So the cursor is now purely a function of CSS plus the root classes below. // The cost is the original cosmetic lag: after a *keyboard* mode toggle the // old glyph stays painted until the pointer next moves — one pixel of movement // clears it, and every pointer-driven change is correct immediately. That is a // strictly better trade than a cursor that can stick forever. If it ever needs // closing, close it without writing `cursor` from JS.
// Cursor re-prime: bounce every computed cursor through native `auto` for a // frame (via a root class — still no JS-written `cursor`), forcing WebKit to // re-select and repaint the custom glyphs. This targets the one stuck-cursor // mechanism that survived the state-machine fixes above: paint-side staleness // in a long-lived tab, where the CSS resolves correctly (so the probe chain // reads clean and no state change ever heals it) but the glyph on screen is // whatever was painted last — historically the pencil, since the day bodies // are the largest hover surface. Fired on the tab coming back to life, the // moment that staleness shows up. See the matching rule in app.css. // // Unlike the retired nudge, a failed give-back here is benign: the class // stuck on means working native cursors (and one trail line saying so), not // a pinned wrong glyph. Removal is belt-and-braces anyway — double-rAF for // the normal path, a timeout for a tab that gets hidden again before rAF runs. let reprimeTimer: ReturnType<typeof setTimeout> | null = null; function reprimeCursor() { const root = document.documentElement; if (root.classList.contains("cursor-reprime")) return; root.classList.add("cursor-reprime"); const off = () => { if (reprimeTimer) clearTimeout(reprimeTimer); reprimeTimer = null; if (!root.classList.contains("cursor-reprime")) return; root.classList.remove("cursor-reprime"); logCursor("cursor re-primed", {}, lastPointer); }; requestAnimationFrame(() => requestAnimationFrame(off)); reprimeTimer = setTimeout(off, 400); }
// Everything that could be driving the cursor, for the on-demand probe. Kept // next to the root-class effects above so the two stay in step about what // "the state that should be driving the cursor" means. function cursorState() { return { dragging: ui.dragging, draggingSelection: ui.draggingSelection, selectMode: ui.selectMode, colorMode: ui.colorMode, editingId: ui.editingId, shift: mods.shift, ctrl: mods.ctrl, }; }
// Global keyboard shortcuts. Ignored while typing in a field or with a modifier // held (so browser/OS combos and text entry are never hijacked). function onKey(e: KeyboardEvent) { if (e.defaultPrevented) return; const el = e.target as HTMLElement | null; const typing = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable);
// Undo / redo: ⌘Z / ⌘⇧Z (⌘Y also redoes), Ctrl on non-Mac. Skipped while // typing so the browser's native in-field text undo still works. if (!typing && (e.metaKey || e.ctrlKey) && !e.altKey) { const k = e.key.toLowerCase(); if (k === "z" && !e.shiftKey) { store.undo(); e.preventDefault(); return; } if ((k === "z" && e.shiftKey) || k === "y") { store.redo(); e.preventDefault(); return; } }
if (e.metaKey || e.ctrlKey || e.altKey || typing) return;
// "0" toggles the "only uncolored" filter (NO_COLOR); "1"-"9" set it to // that position in the active palette (toggling off if it's already the // active filter — same as clicking an active swatch in the toolbar, see // Toolbar's pickSwatch). "a" clears the filter back to showing all. if (/^[0-9]$/.test(e.key)) { if (e.key === "0") { store.colorFilter = store.colorFilter === NO_COLOR ? null : NO_COLOR; } else { const c = settings.activePalette[Number(e.key) - 1]; if (c) store.colorFilter = store.colorFilter === c.id ? null : c.id; } e.preventDefault(); return; } switch (e.key) { case "w": ui.view = "week"; break; case "m": ui.view = "month"; break; case "d": ui.view = "day"; break; // "e" (extended day), not "3": the 0-9 digit block above owns every // digit for color filtering and returns before this switch is reached. case "e": ui.view = "3day"; break; case "c": ui.toggleColorMode(); break; case "t": ui.goToday(); break; case "n": ui.addSomedayTask(); break; case "a": store.colorFilter = null; break; case "ArrowLeft": ui.shift(-1); break; case "ArrowRight": ui.shift(1); break; case "?": ui.controlsOpen = !ui.controlsOpen; break; case ",": ui.settingsOpen = !ui.settingsOpen; break; case "s": ui.toggleSelectMode(); break; // Snapshot the cursor state while it's stuck. A key, not a click, so the // pointer stays exactly where the cursor is wrong — moving it is the one // thing that can heal the failure before it's been recorded. case "`": probeCursor(cursorState(), lastPointer); break; case "Delete": case "Backspace": if (ui.selectMode && ui.selection.size) { store.removeCells([...ui.selection]); ui.clearSelection(); break; } return; case "Escape": if (landingOpen) dismissLanding(); else if (ui.settingsOpen) ui.settingsOpen = false; else if (ui.controlsOpen) ui.controlsOpen = false; else if (ui.creditsOpen) ui.creditsOpen = false; else if (ui.statsOpen) ui.statsOpen = false; else if (ui.menuOpen) ui.menuOpen = false; else if (ui.selection.size) ui.clearSelection(); else if (ui.selectMode) ui.selectMode = false; else return; break; default: return; } e.preventDefault(); }
onMount(() => { const online = () => engine && (store.status = "idle"); const offline = () => (store.status = "offline"); const onPop = () => applyUrl(); // Track shift/ctrl for the modifier-preview cursors. keydown/keyup give the // immediate response to pressing the key; pointermove is the self-heal. // Inferring the state from key events alone is fragile: a keyup dropped while // the page lacks keyboard focus (Cmd/Alt-Tab away and release there, a // shift-bearing OS/browser shortcut, focus in browser chrome or devtools) // used to leave `mods.shift` stuck true — and so the pencil preview stuck on // every task — with nothing but a blur or refresh to clear it, since no // pointer event ever reconciled it. Every real event carries the true current // modifier state, so read it back off pointer moves too: the first mouse move // now clears a stuck modifier (and repaints the cursor as a side effect). const syncMods = (e: KeyboardEvent | PointerEvent) => { const changed = mods.shift !== e.shiftKey || mods.ctrl !== e.ctrlKey; if (mods.shift !== e.shiftKey) mods.shift = e.shiftKey; if (mods.ctrl !== e.ctrlKey) mods.ctrl = e.ctrlKey; // `via` names which event reconciled the modifiers — a `pointermove` line // here is the self-heal firing, i.e. a keyup that got dropped and left a // modifier (and its cursor) stuck until the mouse moved. if (changed) logCursor("mods synced", { via: e.type, shift: mods.shift, ctrl: mods.ctrl }, lastPointer); }; const onPointer = (e: PointerEvent) => { lastPointer.x = e.clientX; lastPointer.y = e.clientY; lastPointer.seen = true; syncMods(e); }; const clearMods = () => { const had = mods.shift || mods.ctrl; mods.shift = false; mods.ctrl = false; if (had) logCursor("mods cleared", { shift: false, ctrl: false }, lastPointer); }; // A tab hidden mid-key (its keyup will land elsewhere) can't be trusted to // still be holding anything — drop the modifiers so nothing carries over. // Coming back the other way, re-prime the cursor paint (see reprimeCursor). const onVisibility = () => { if (document.hidden) { clearMods(); // Tab backgrounded or about to unload (a refresh fires this before the // page actually goes away): send any debounced settings edit now // rather than losing it to the 800ms window closing mid-navigation. config.flush(); } else { reprimeCursor(); // Coming back to a visible tab is the main way a day rollover is ever // noticed by a page that's been left open — check every time. ui.checkRollover(); } }; // `focus` covers the switch between two visible windows, where visibility // never changes; `pageshow` covers a BFCache restore, which resurrects the // page (and whatever cursor WebKit last painted for it) without a load. const onFocus = () => { reprimeCursor(); ui.checkRollover(); }; const onPageShow = () => { reprimeCursor(); ui.checkRollover(); }; // Backstop for a tab that just stays open and visible straight through // midnight (a pinned tab, an always-on display) — cheap enough to poll. const rolloverInterval = setInterval(() => ui.checkRollover(), 60_000); // Belt-and-suspenders for `visibilitychange`: some browsers (notably // Safari on a same-tab reload) go straight to `pagehide` without a // preceding hidden transition. const onPageHide = () => config.flush(); window.addEventListener("online", online); window.addEventListener("offline", offline); window.addEventListener("keydown", onKey); window.addEventListener("keydown", syncMods); window.addEventListener("keyup", syncMods); // pointerdown too: a click that arrives without any preceding movement (tap, // trackpad tap-to-click after an app switch) must also reconcile a stuck // modifier before its click handler reads the cursor-preview state. window.addEventListener("pointerdown", syncMods); window.addEventListener("pointermove", onPointer, { passive: true }); window.addEventListener("blur", clearMods); window.addEventListener("focus", onFocus); window.addEventListener("pageshow", onPageShow); window.addEventListener("pagehide", onPageHide); document.addEventListener("visibilitychange", onVisibility); window.addEventListener("popstate", onPop);
// Same probe as the backtick key, reachable from the console — for when the // cursor is stuck over browser chrome or a devtools panel and the page can't // see the keystroke. It reads the *last in-page* pointer position, so it // still reports the spot where the cursor is wrong. (window as unknown as Record<string, unknown>).__cursor = () => probeCursor(cursorState(), lastPointer);
// Local data is available immediately; auth/sync bootstraps in the background. (async () => { // Drop the retired HappyView auth records before touching auth, so the // new client never sees (or trips over) the old stack's leftovers. clearLegacyHappyViewSession(); await store.load(); let transient = false; try { const s = await initSession(); // Carry-over deliberately does NOT run here. It rewrites the `day` of // every unfinished past task, and running it against what this device // happens to have cached — before a single byte has been read back from // the other device — is how one device's stale copy ends up asserted // over another's newer work. `startSync` hands it to the engine, which // fires it once the first cycle has reconciled. if (s) return void (await onSignedIn(s)); } catch (e) { console.error("[sync] sign-in restore failed", e); transient = e instanceof TransientRestoreError; if (e instanceof SignInFailedError) authNotice.failSignIn(e.message); } // Signed out, or the session could not be restored. Only a device that // has NEVER synced (no record carries a cid) may treat local data as all // there is and release the startup sweep now. Synced records prove a // remote copy exists that this cache may be far behind — the fortnight // OAuth expiry lands exactly here — and the carry-over sweep re-stamps // `updatedAt` on every task it moves, stamps that win last-write-wins // after the next sign-in and undo completions made on other devices. So // hold the sweep until a signed-in engine has actually reconciled. const dirty = store.dirtyCount(); if (store.hasSyncedRecords()) { slog("reconcile.deferredSignedOut", { dirty }); } else { slog("reconciled", { via: "local-only" }); settings.markLocalOnly(); } // `initSession()` returning `null` here should mean "never signed in" // or "deliberately signed out" — both silent by design. But a session // the OAuth library deleted out from under a previous visit (see // auth.ts's `onSessionDeleted`) also reads as a bare `null`, with // nothing thrown for `initSession`'s own catch to see: the library // clears its "who's signed in" pointer the moment it deletes the // session, so the very next `init()` just finds nothing to restore. // `onSessionDeleted` normally shows the toast itself, right when it // happens — this is the fallback for whatever slips past that (the tab // was closed before the event fired, `localStorage` write failed, // etc). A device that remembers who it was signed in as AND has a // remote copy to be behind or local edits that never went out cannot // be trusted to have signed out on purpose, so show the toast rather // than silently running on local data. `consumeDeliberateSignOut` is // the one exception: the reload right after the user hit "sign out" // looks identical from here (remembered handle, synced records) but // must stay silent — that flag is what tells the two apart, and it's // always consumed exactly once per boot regardless of the outcome. const deliberate = consumeDeliberateSignOut(); // The menu painted the cached avatar optimistically; a boot that ends // signed out (rather than merely offline) takes it back down. if (!transient) profile.clear(); if (transient) { // A session exists; it just couldn't be restored right now (offline, // a flaky network at launch). Not a sign-out — no toast. Keep trying // until it comes back, then carry on exactly as a normal boot would. retryRestore(); } else if (!deliberate && lastHandle() && (store.hasSyncedRecords() || dirty > 0)) { authNotice.show(); } })();
// Backoff for `retryRestore`; the `online` event short-circuits the wait. let restoreTimer: ReturnType<typeof setTimeout> | null = null; let restoreAttempt = 0; function retryRestore() { if (restoreTimer) clearTimeout(restoreTimer); const delay = Math.min(5_000 * 2 ** restoreAttempt++, 120_000); restoreTimer = setTimeout(attemptRestore, delay); } async function attemptRestore() { restoreTimer = null; if (session) return; // signed in some other way meanwhile try { const s = await initSession(); if (s && !session) await onSignedIn(s); } catch (e) { if (e instanceof TransientRestoreError) retryRestore(); else console.error("[sync] sign-in restore failed", e); } } const onlineRestore = () => { if (restoreTimer) { restoreAttempt = 0; clearTimeout(restoreTimer); void attemptRestore(); } }; window.addEventListener("online", onlineRestore);
return () => { window.removeEventListener("online", online); window.removeEventListener("online", onlineRestore); if (restoreTimer) clearTimeout(restoreTimer); window.removeEventListener("offline", offline); window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", syncMods); window.removeEventListener("keyup", syncMods); window.removeEventListener("pointerdown", syncMods); window.removeEventListener("pointermove", onPointer); window.removeEventListener("blur", clearMods); window.removeEventListener("focus", onFocus); window.removeEventListener("pageshow", onPageShow); window.removeEventListener("pagehide", onPageHide); document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("popstate", onPop); if (reprimeTimer) clearTimeout(reprimeTimer); clearInterval(rolloverInterval); }; });
// After a session is (re)established: read the config record to learn where // task data lives. The record itself now obeys the mode it declares, so we // don't know which store holds it until we've read it — `readConfig` checks // both. No record in either ⇒ first sign-in ⇒ force the public/private choice // before any sync. Otherwise hydrate prefs and start the right repo. async function onSignedIn(s: Session) { session = s; let found; try { found = await readConfig({ public: repoFor("public", s), private: repoFor("private", s), }); } catch (e) { // A PDS with no Spaces support fails the private-store probe; that must // not read as a sync fault. No config in the public store either ⇒ treat // as first sign-in (the mode modal's private option probes and explains). if (!(e instanceof SpacesUnsupportedError)) throw e; slog("config.privateProbeUnsupported", {}); found = null; } if (!found) { // A device that was in private mode before the HappyView → PDS-space // migration finds no config record anywhere: the record lives in the // unreachable old backend. The private watermark plus synced local // records prove that history, so re-assert private mode (prefs are // cached locally) instead of re-prompting — the reseed in `startSync` // then moves the data itself. const privateMark = await getMeta("sync:private:watermark"); if (privateMark && store.hasSyncedRecords()) { slog("config.inferredPrivate", {}); config.attach((mode) => repoFor(mode, s)); try { await config.choose("private"); } catch (e) { if (!(e instanceof SpacesUnsupportedError)) throw e; // Can't write the config record until the PDS grows Spaces support; // the banner explains, and the engine's retries pick it up later. } void startSync("private", s); return; } modeChoiceSession = s; return; } config.hydrate(found.cfg); config.attach((mode) => repoFor(mode, s)); // Found somewhere other than where the mode says it belongs: either an // account from before the config record obeyed the data mode (private mode, // record still in the PDS — this is the backfill), or a switch that died // between the write and the delete. Either way, move it home so a private // user's config isn't sitting in a world-readable repo. Non-blocking: sync // shouldn't wait on it, and a failure just means we retry next sign-in. if (found.foundIn !== found.cfg.dataMode) { void config .relocate(found.foundIn) .catch((err) => console.error("[config] relocation failed", err)); } void startSync(found.cfg.dataMode, s); }
// First-run choice from DataModeModal: persist it, then start syncing. async function chooseMode(mode: DataMode) { const s = modeChoiceSession; if (!s) return; config.attach((m) => repoFor(m, s)); await config.choose(mode); // writes town.chalky.config with current prefs/theme modeChoiceSession = null; void startSync(mode, s); }
// One session serves both stores, so choosing a repo is now just a branch — // there is no separate private-storage connection to establish or prompt for. function repoFor(mode: DataMode, s: Session): PdsRepo | SpaceRepo { return mode === "private" ? new SpaceRepo(s.agent, s.did) : new PdsRepo(s.agent, s.did); }
async function startSync(mode: DataMode, s: Session) { engine?.stop(); const eng = new SyncEngine(repoFor(mode, s), { // Recovery path for a cycle that fails or hangs on the OAuth client's // dead IndexedDB connection (see sync.ts's AUTH_DB_ERROR_SIGNATURE and // auth.ts's reopenSession) — rebuild the session through the same // `did`, keep the app's own `session` in step so every other read of it // (switchMode, sign-out, the header) sees the live agent too, then hand // the engine a repo built from it exactly as `startSync` itself would. reopenAuth: async () => { const fresh = await reopenSession(s.did); session = fresh; return repoFor(mode, fresh); }, // Belt-and-suspenders for a dead-session error that reaches a cycle // directly (see sync.ts) — in the ordinary case `authNotice.expired` is // already true by the time this fires, from auth.ts's own // `onSessionDeleted` hook, and the `$effect` above has already torn the // engine down. This just makes sure the engine itself never keeps // retrying a session that is provably gone. // Minus the library's session-store miss ("deleted by another // process"): a dead IndexedDB connection says that about a session // that is still stored. auth.ts's `onSessionDeleted` checks the store // and flags a real loss; the engine just reopens. deadSession: (err) => isSessionDead(err) && !isUnconfirmedDelete(err), onSessionDead: () => authNotice.show(), }); engine = eng; if (!bootEngineStarted) { bootEngineStarted = true; eng.onCatchUp = onBootCatchUp; } if (mode === "private") { // One-time HappyView → PDS-space reseed, before the first cycle. A // failure (unsupported PDS, offline) leaves its flag unset so a later // boot retries; starting the engine anyway is safe — with the old // watermark the worst a cycle can do is a snapshot that changes nothing // (every local record is clean, and the empty-read guard blocks // deletions against a not-yet-seeded space). try { await reseedPrivateIfNeeded(repoFor(mode, s)); } catch (e) { if (e instanceof SpacesUnsupportedError) slog("reseed.unsupported", {}); else console.error("[sync] private-store reseed failed; will retry next load", e); } } // A mode switch may have started a different engine while we awaited. if (engine !== eng) return; // Startup cleanup that has to see the whole picture, not just this device's // copy of it. The engine reports every successful cycle's start day and the // sweep runs at most once per day, so a later `startSync` (a data-mode // switch) can't sweep a second time, and a new day waits for a fresh pull. eng.onReconciled = (day) => settings.markReconciled(day); eng.start(); }
// Settings switcher: move task data to the other repo after first run. This is // a move, not a copy: we seed the new repo from local IndexedDB (the source of // truth), record the new mode, then empty the old store so nothing is left // behind. Local tasks are never touched, so a failure part-way is recoverable. async function switchMode(mode: DataMode) { const s = session; const prev = config.dataMode; if (!s || prev === mode) return; ui.settingsOpen = false; engine?.stop(); engine = null; store.status = "syncing"; const live = liveRecordCount(); migration.begin(mode, live); try { await migrateRecordsTo(repoFor(mode, s), (done) => migration.setCopied(done)); // copy local → new repo await config.setMode(mode); // moves the config record too, before we clear the old store if (prev) { // empty the store we left await clearRecordsIn(repoFor(prev, s), (done, total) => migration.setClearing(done, total)); } migration.finish(); } catch (e) { migration.fail(e instanceof Error ? e.message : "The move didn't finish."); // Keep syncing whatever mode is actually on record so edits still flow. if (config.dataMode) void startSync(config.dataMode, s); throw e; } void startSync(mode, s); }
// Retry a failed move from the migration banner's "Try again". async function retryMigration() { const target = migration.target; if (!target) return; try { await switchMode(target); } catch { /* the banner already reflects the outcome */ } }
async function handleSignOut() { engine?.stop(); engine = null; config.detach(); const did = session?.did; session = null; profile.clear(); modeChoiceSession = null; store.status = "idle"; // A deliberate sign-out, not an expiry — if a stale "you were signed out" // toast happened to be showing (e.g. from an earlier tab), it no longer // applies once the user has done it on purpose. clearSessionExpired(); if (did) { try { await signOut(did); } catch (e) { console.error("[sync] sign-out failed", e); } } }</script>
<main bind:this={mainEl} inert={bootLoader}> <Header {session} onConnect={() => (showLogin = true)} onSignOut={handleSignOut} /> <SignedOutNotice onOpenLogin={() => (showLogin = true)} /> <SpacesUnsupportedNotice /> {#if migration.active} <MigrationBanner onRetry={retryMigration} onDismiss={() => migration.dismiss()} /> {/if} <Toolbar /> <div class="stage"> <!-- Sideways flick pages the view (see lib/swipe.ts); Someday sits outside it, so swiping there never moves the board. --> <div {@attach swipeNav}> {#if ui.view === "month"} <MonthView /> {:else if ui.view === "day"} <DayView /> {:else if ui.view === "3day"} <ThreeDayView /> {:else} <WeekGrid /> {/if} </div>
<Someday />
<SelectLayer /> </div></main>
<DetailsPanel />
<ContextMenu />
{#if showLogin} <LoginScreen onClose={() => (showLogin = false)} />{/if}
{#if landingOpen} <LandingModal onClose={dismissLanding} />{/if}
{#if ui.creditsOpen} <CreditsModal onClose={() => (ui.creditsOpen = false)} />{/if}{#if ui.statsOpen && !settings.hideStats} <StatsModal onClose={() => (ui.statsOpen = false)} />{/if}
{#if ui.controlsOpen} <ControlsModal onClose={() => (ui.controlsOpen = false)} />{/if}
{#if ui.settingsOpen} <SettingsModal {session} onSwitchMode={switchMode} onClose={() => (ui.settingsOpen = false)} />{/if}
{#if modeChoiceSession} <DataModeModal onChoose={chooseMode} />{/if}
{#if bootLoader} <BootLoader />{/if}
<style> main { height: 100%; overflow-y: auto; /* Hard guarantee: the app never scrolls horizontally. The page scroller clips any stray horizontal overflow (e.g. color-emoji glyph ink that renders wider than its layout box) so a rogue element can never produce a horizontal scrollbar. Vertical scrolling is unaffected. */ overflow-x: clip; /* Always reserve the vertical scrollbar's lane. A drag grows the target day by the placeholder while the origin day is pinned at its rest height (DayColumn's min-height), so a board that just fit tips into overflow mid-drag — with classic desktop scrollbars the bar popped in and shoved every column sideways. Overlay scrollbars and touch (scrollbars hidden in app.css) reserve nothing, so this is a no-op there. */ scrollbar-gutter: stable; }
/* Positioning context for the select-area overlay, which fills the whole grid region (all views + Someday) so a marquee can sweep across it. */ .stage { position: relative; }</style>