Imported from mohokh67/thisORthat (
AGENTS.md). Install upstream withnpx skills add mohokh67/thisORthat. Copyright stays with the author.
AGENTS.md
Guidance for coding agents working in this repository.
Agent skills
Issue tracker
Issues and specs live as GitHub issues in mohokh67/thisORthat, managed with the gh CLI. See docs/agents/issue-tracker.md.
Triage labels
Default vocabulary: needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix. See docs/agents/triage-labels.md.
Domain docs
Single-context: CONTEXT.md and docs/adr/ at the repo root. See docs/agents/domain.md.
Development
- Stack and commands are in
README.md. Vite + React + TS, Supabase, Vitest. - Hosting / custom domain: the site is a GitHub Pages deploy (Actions,
.github/workflows/deploy.yml) served at the root of the custom domainthisorthat.koolstuff.app.vite.config.tssetsbase: '/';public/CNAME(→dist/CNAME) binds the domain on each deploy. DNS is a CloudflareCNAME thisorthat → mohokh67.github.io(DNS-only / grey cloud so GitHub can issue the cert).koolstuff.apphosts multiple unrelated apps, one subdomain + one repo each, for origin isolation (storage, cookies, service-worker scope). The old project-path URL (mohokh67.github.io/thisORthat/) is dead. - Routing / SPA fallback: History API, not hash.
src/routing/parsePath.tsmapslocation.pathnameto aRoute(/→ landing,/b/<id>→ board);src/routing/useRoute.tshasuseRoute()(subscribes topopstate) andnavigate(path)(pushState+ a manualpopstatedispatch, sincepushStatedoes not fire it). Internal links go throughsrc/components/Link.tsx(real<a href>, plain left-click intercepted). Deep links like/b/<id>hit Pages as real paths with no file, so thespa-fallback-404plugin invite.config.tscopiesdist/index.html→dist/404.html; Pages serves that and the SPA re-routes fromlocation.pathname. Old#/b/<id>links are not migrated — they load the landing page. - Build-time env (
VITE_SUPABASE_URL,VITE_SUPABASE_ANON_KEY) is validated insrc/lib/config.tsand unit-tested there;src/lib/supabase.tsbuilds the shared client from it. - Tests cover pure logic only (see the spec's Testing Decisions). No component or E2E tests.
- Tests must not import
src/lib/supabase.ts(directly or transitively) without stubbingimport.meta.env— it validates env and throws at module load. - SQL migrations live in
supabase/migrations/; each table ships permissiveanonRLS (ADR-0001) and is added to thesupabase_realtimepublication. Apply them by pasting into the Supabase SQL Editor (no CLI in this environment). - Realtime gotcha: a Postgres Changes subscription filtered on a non-PK column (e.g.
board_id=eq.…) drops DELETE events unless the table hasREPLICA IDENTITY FULL(default replica identity only puts the PK in the delete payload).columns,notes,participantsare set to FULL. Also:SUBSCRIBEDfires ~1-2s before the replication bindings are actually live, souseBoardrefetches on everylivetransition to close that gap. - Layout: pure domain logic (the test seam) in
src/lib/*.tswith a.test.tsbeside it (templates,identity,position,linkify,config,sortLens,sortLensStore,activity,relativeTime,lastSeenStore,logFilter,exportBoard,recentBoards); Supabase adapters also insrc/lib/(boards,notes,participants,events,supabase); browser-only glue insrc/lib/(download— object-URL anchor, not tested); React insrc/pages/andsrc/components/; History API routing insrc/routing/(parsePath+.test.ts,useRoute); device-local view state hooks insrc/hooks/(useSortLenses,useActivityLog). - Fractional
position(double precision) orders columns and notes; new notes go to the top viapositionAtStart. Usesrc/lib/position.tshelpers, never ad-hoc arithmetic. - Drag-and-drop: one
DndContextinBoardColumnscovers both column drags and note drags (nested contexts are a dnd-kit footgun). Draggables carrydata: { type: 'column' | 'note', columnId };handleDragEndbranches ontypeand resolves the target column fromover.data.current.columnId(a note's or a column section's). Note drags translate toboard.moveNote(noteId, toColumnId, targetIndex)wheretargetIndexis an index into the target column's Custom (position) order — the only ordermoveNotewrites. Sensors areMouseSensor(4px distance) +TouchSensor(200ms hold) +KeyboardSensor— notPointerSensor, which would let a note drag hijack touch swipe-scroll of the column strip. Drag handles carrytouch-action: none(.note-dragalways,.column-dragonly under@media (hover: none)). - Sort lens (#11):
src/lib/sortLens.tsorderNotesis a pure view-only reorder (never touchesposition);customispositionasc, every other lens breaks ties onpositionasc for a stable shared order. The per-column choice is device-local:src/lib/sortLensStore.ts(localStorage,customnever stored) via theuseSortLenseshook. Under a non-Custom lens,handleDragEnddrops in-column note reorders but still allows dragging a note out to another column. - Responsive / mobile (#17): all responsive rules are two media blocks at the end of
src/index.css.@media (max-width: 640px)is layout only (phone): columns become ascroll-snap-type: x mandatorystrip with oneflex: 0 0 calc(100% - 2.5rem)column visible at a time, header rows wrap, the log drawer + scrim goinset: 0full-screen,.boardswitches to100dvh.@media (hover: none)is touch affordance only (keyed on capability, not width, so a no-hover tablet keeps its grid but gets usable controls):.note-actionsun-hidden, tap targets grown to ~2.25–2.5rem, drag strip widened. Desktop/tablet grid layout is untouched. - Log filter/search/pagination (#14):
src/lib/logFilter.tsis pure —categoryOf(action)maps an action slug tonotes|columns|votes|board;filterEvents(events, {types, query})narrows by category set (empty = no narrowing) and a case-insensitive substring overactorName + describeEvent(event).useActivityLogfetches oneEVENT_PAGE_SIZE(50) page on load and exposesloadMore/hasMore/loadingMore. Every keyset query inevents.tsorders by(created_at desc, id desc)and the cursor is inclusive (fetchEventsBeforeuses.lte('created_at', …)), with callers de-duping by id —created_atis not unique, so an exclusive.ltcursor could skip a row that shares the boundary timestamp.fetchAllEvents(JSON/CSV export) walksEXPORT_PAGE_SIZE(1000) pages, stopping on a short page or one that adds nothing new. Filtering is client-side over loaded pages only, so a reconnect refetch collapses back to the first page (matches the existinguseActivityLogrefresh-on-livebehaviour). - Export (#15):
src/lib/exportBoard.tsis pure —boardToMarkdown({title, sections})(caller supplies columns in on-screen order, each with notes pre-ordered viaorderNotesand annotated with points/priority/author — points always, priority only when set, note text collapsed to one line, empty column →_No notes._);boardToJson({board, columns, notes, votes, events})echoes the arrays;eventsToCsv(events)→Timestamp,Actor,Action,DetailwithdescribeEventas the detail and RFC-style quote escaping;exportFilename(title, date, ext)slugifies the title →<slug>-YYYY-MM-DD.<ext>.useBoardnow also returns rawnotes/votes(for JSON) andpushToast(Share + export-failure toasts — one shared toast stack). JSON/CSV pull the full history withfetchAllEvents(paged walk) at click time.src/lib/download.tstriggerDownloadis the browser save glue. - Share + recent boards (#16):
src/lib/recentBoards.ts— pureparseRecent/recordOpen(newest-first, capped atRECENT_LIMIT= 13, immutable) plusloadRecentBoards/touchRecentBoardlocalStorage wrappers (thisorthat.recent-boards, never throw).BoardPagecallstouchRecentBoardon board load and whenever the title changes; Share copieswindow.location.hrefvia the Clipboard API and toasts.LandingPagerenders the list (links viaboardPath+Link, relative "last opened" time) with an empty state. - Activity log (#13): client-written (ADR-0002).
optimisticMutate/patchColumn/patchNoteinuseBoardtake an optionalevent?: ActivityInput; on a successful write they calllogEvent(buildEvent(event), …)best-effort (no retry, no toast).src/lib/activity.tsis pure:buildEventturns a mutation into{action, targetType, targetId, detail}withsnippet()-truncated text;describeEventrenders a stored event as a sentence fromdetailalone (readable after the target is gone). Within-column note nudges are not logged; cross-columnmoveNoteis. Read side:useActivityLoghook (firstEVENT_PAGE_SIZEpage +subscribeToEventsINSERT-only channel, see #14 below); the header dot counts other people's events sincelastSeenStore(localStorage per board). - Theme toggle: the whole palette is built on the CSS system colours (
Canvas,CanvasText,currentColor), so a light/dark switch needs no palette rules —src/index.cssjust pinscolor-schemeunder:root[data-theme="light"|"dark"], and bare:rootkeepslight dark(= follow the OS, live). Do not add@media (prefers-color-scheme)blocks:color-schemedoes not override them, so the toggle would half-break.src/lib/theme.tsis the device-local store (thisorthat.theme, pureparseTheme,loadThemefalls back tomatchMediaOS pref, never throws);applyThemewritesdocument.documentElement.dataset.theme.index.htmlhas a tiny pre-paint script that setsdata-themeonly when a stored override exists (no-choice users need nothing — bare:rootalready handles them flash-free); keep itsthisorthat.themekey in sync withtheme.ts.useThemehook +ThemeToggle(leftmost in.board-header-right) flip and persist it. - Column Sort row + note card controls:
NoteComposer("+ Add note") lives inside.column-sortnext to the lens<select>, not in.column-body;.column-sortis aflex-wraprow so the expanded composer textarea drops to its own full-width line..note-actions(edit / delete) is an in-flow right-aligned row at the top of the card (not an absolute overlay), so a long first line never collides with the controls; it is stillopacity: 0until:hover/:focus-within(and force-shown under@media (hover: none)).