Whole working apps: tasks, calendar, timer and notes, plus their widgets.
The product kit: tools that save to the browser until you connect a backend, with keyboard shortcuts, both themes and phone layouts.
20 parts
The whole task tracker: a keyboard-first list and board, a detail view, bulk edits, and undo for every change.
import { useRouter } from '@rchursin/product/lib/router'import type { KitSlug } from '@rchursin/product/shell/pages'import { TasksApp } from '@rchursin/product/tasks'
/** The window's tool links go to each tool's page in this Library (Today has none, so it shows as text) */const toolPages: Partial<Record<KitSlug, string>> = { tasks: '/library/tools/tasks-app', calendar: '/library/tools/calendar-app', timer: '/library/tools/timer-app', notes: '/library/tools/notes-app',}
/** * The whole tracker on sample data. Click into the list, then try J and K, X to select, * S for status, Ctrl+B (⌘B) for the board, or ? for every shortcut. * Changes save to this browser; "Reset sample data" brings the twelve tasks back. */export default function TasksAppExample() { const { href } = useRouter() const hrefFor = (slug: KitSlug) => { const path = toolPages[slug] return path && href(path) } return <TasksApp hrefFor={hrefFor} />}The task list as a data grid: one Tab stop, arrows to move, pickers in the cells, and groups that collapse.
import { useState } from 'react'import { sampleTasks } from '@rchursin/product/data/sample'import { DEFAULT_PREFS, groupTasks, labelIndex, PickerHost, TaskGrid, usePick, type ActiveCell, type PickChange, type Task } from '@rchursin/product/tasks'import { useToast } from '@rchursin/product/ui'
/** A fixed day, so the due dates read the same on every visit */const NOW = new Date(2026, 9, 13, 9, 30)const labels = labelIndex([])
/** Apply a picker change to the local list */function apply(tasks: Task[], ids: string[], change: PickChange): Task[] { return tasks.map((t) => { if (!ids.includes(t.id)) return t if (change.kind === 'labels') return { ...t, labels: [...t.labels.filter((l) => !change.remove.includes(l)), ...change.add.filter((l) => !t.labels.includes(l))] } return { ...t, [change.kind]: change.value } })}
/** * The list on its own copy of the sample tasks. One Tab stop: arrows or J and K move,A column per status, with cards moved by dragging, by keys on their grip or from a menu, and every move announced.
import { useState } from 'react'import type { StatusId } from '@rchursin/product/data/sample'import { CreateTaskDialog, DEFAULT_PREFS, labelIndex, TaskBoard, useTaskActions, useTaskState } from '@rchursin/product/tasks'import { useToast } from '@rchursin/product/ui'
/** * The board on the shared task store (the same saved tasks as the Tasks app), so every move * gets a toast with Undo. Drag a card, use its grip with Space and the arrows, or its “…” menu. */export default function TaskBoardExample() { const { tasks, labels } = useTaskState() const actions = useTaskActions() const toast = useToast() const [active, setActive] = useState<string | null>(null) const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set()) const [createIn, setCreateIn] = useState<StatusId | null>(null) const now = new Date()
function toggle(id: string) { const next = new Set(selected)Everything about one task: an editable title and description, and each property as a picker button.
import { useRef, useState } from 'react'import { labelIndex, StorePickerHost, taskStore, TaskDetail, useTaskActions, useTaskState } from '@rchursin/product/tasks'import { Button } from '@rchursin/product/ui'
/** * The side panel the Tasks app shows next to a wide list, on the shared task store. * Edit the title or description (they save when you leave the field), change a property, * or step through tasks with the arrows in its header. Esc closes it. */export default function TaskDetailPanel() { return ( <StorePickerHost> <Panel /> </StorePickerHost> )}
function Panel() { const { tasks, labels: custom } = useTaskState() // The sample labels plus any the visitor createdThe New task dialog, with a draft that survives Esc, and the dialog that lists every Tasks shortcut.
import { useState } from 'react'import { CreateTaskDialog, StatusIcon, useTaskState } from '@rchursin/product/tasks'import { Button } from '@rchursin/product/ui'
const number = (id: string) => Number(id.split('-')[1]) || 0
/** * Creates a real task in the shared sample workspace (with Undo in the toast). Only the title * is required. Esc keeps what you typed as a draft; Ctrl+Enter (⌘Enter) creates. */export default function CreateTaskExample() { const { tasks } = useTaskState() const [open, setOpen] = useState(false) const newest = [...tasks].sort((a, b) => number(b.id) - number(a.id)).slice(0, 3)
return ( <div className="flex w-full max-w-sm flex-col gap-5"> <Button icon="plus" className="self-start" onClick={() => setOpen(true)}> New task </Button> <div className="flex flex-col gap-2"> <p className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Newest in the sample workspace</p> <ul className="flex flex-col"> {newest.map((t) => ( <li key={t.id} className="flex h-9 min-w-0 items-center gap-2 border-b border-line text-[13px] last:border-0"> <StatusIcon status={t.status} /> <span className="w-12 shrink-0 font-mono text-[11px] text-fg-3 tabular-nums">{t.id}</span> <span className="truncate text-fg">{t.title}</span> </li> ))} </ul> </div> <CreateTaskDialog open={open} onClose={() => setOpen(false)} draftKey="tasks:draft-docs" /> </div> )}A dark floating toolbar for a selection: the count, clear, five property pickers and delete.
import { useRef, useState } from 'react'import { sampleTasks } from '@rchursin/product/data/sample'import { AssigneeAvatar, BulkBar, labelIndex, PickerHost, PriorityIcon, StatusIcon, usePick, type PickChange, type Task } from '@rchursin/product/tasks'import { Button } from '@rchursin/product/ui'
const NOW = new Date(2026, 9, 13, 9, 30)const IDS = ['RC-127', 'RC-123', 'RC-118']const labels = labelIndex([])const fresh = () => sampleTasks(NOW).filter((t) => IDS.includes(t.id))
/** Apply a picker change to every selected task */function apply(tasks: Task[], ids: string[], change: PickChange): Task[] { return tasks.map((t) => { if (!ids.includes(t.id)) return t if (change.kind === 'labels') return { ...t, labels: [...t.labels.filter((l) => !change.remove.includes(l)), ...change.add.filter((l) => !t.labels.includes(l))] } return { ...t, [change.kind]: change.value } })}
/** Three selected tasks on their own copy of the sample data. Each button changes all three at once. */Live search and multi-select quick filters for tasks, and the Display popover for grouping, ordering and properties.
import { useRef, useState } from 'react'import { sampleTasks } from '@rchursin/product/data/sample'import { FilterBar, labelIndex, matchesFilters, NO_FILTERS, PriorityIcon, StatusIcon, type Filters } from '@rchursin/product/tasks'
const NOW = new Date(2026, 9, 13, 9, 30)const tasks = sampleTasks(NOW)const labels = labelIndex([])
/** * Search and quick filters over the twelve sample tasks, starting with Urgent and High. * Esc clears the search, then hands focus to the results. */export default function FilterBarExample() { const [filters, setFilters] = useState<Filters>({ ...NO_FILTERS, priorities: [1, 2] }) const search = useRef<HTMLInputElement>(null) const results = useRef<HTMLUListElement>(null) const matches = tasks.filter((t) => matchesFilters(t, filters, labels.name))
return ( <div className="w-full overflow-hidden rounded-xl bg-surface ring-1 ring-line">Your open tasks for a dashboard, soonest due first: tick one to finish it, or change its status from its icon.
import { useRouter } from '@rchursin/product/lib/router'import { TasksTodayWidget } from '@rchursin/product/tasks'
/** * The widget brings no card of its own: the dashboard around it does. It reads the shared * task store, so ticking a task here marks it done in the Tasks app too (with Undo). * href sends “Open Tasks” to your own page; here, the tool’s page in this Library. */export default function TasksWidgetExample() { const { href } = useRouter() return ( <div className="lux-card w-full max-w-sm rounded-2xl bg-raised p-5"> <TasksTodayWidget href={href('/library/tools/tasks-app')} /> </div> )}The whole calendar: week, day, month and agenda views, calendars told apart by pattern, and create, edit and delete.
import { CalendarApp } from '@rchursin/product/calendar'import { useRouter } from '@rchursin/product/lib/router'import type { KitSlug } from '@rchursin/product/shell/pages'
/** The window's tool links, and an event's linked task, go to each tool's page in this Library */const toolPages: Partial<Record<KitSlug, string>> = { tasks: '/library/tools/tasks-app', calendar: '/library/tools/calendar-app', timer: '/library/tools/timer-app', notes: '/library/tools/notes-app',}
/** * The whole calendar on this week's sample events. Click inside it, then press T for today, * ← → to move, M W D A for the views, N for a new event, or ? for the list. * Changes save to this browser; "Reset sample data" brings the sample week back. */export default function CalendarAppExample() { const { href } = useRouter() const hrefFor = (slug: KitSlug) => { const path = toolPages[slug] return path && href(path) } return <CalendarApp hrefFor={hrefFor} />}Days across, hours down: a time grid with an all-day row, overlaps side by side, and a line at the current time.
import { useState } from 'react'import { sampleEvents, type CalendarEvent } from '@rchursin/product/data/sample'import { TimeGrid } from '@rchursin/product/calendar'import { formatDateRange, speakable } from '@rchursin/product/calendar/dates'import { eventTime } from '@rchursin/product/calendar/events'import { addDays, addMinutes, formatDay, startOfWeek } from '@rchursin/product/lib/dates'import { Button, useToast } from '@rchursin/product/ui'
/** A fixed moment in the sample week, so the page reads the same on every visit */const NOW = new Date(2026, 9, 13, 10, 40)
/** * A week on its own copy of the sample events. Click an empty slot to add an hour-long event * there (snapped to 15 minutes), or use the button; open an event from its block. */export default function TimeGridWeek() { const toast = useToast() const [events, setEvents] = useState(() => sampleEvents(NOW)) const days = Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(NOW), i))
The month as a keyboard grid: up to three events a day, “+N more” for the rest, and pattern squares on phones.
import { useState } from 'react'import { sampleEvents, type CalendarEvent } from '@rchursin/product/data/sample'import { AgendaList, MonthView } from '@rchursin/product/calendar'import { eventTime } from '@rchursin/product/calendar/events'import { addMonths, at, formatDayLong, formatMonth, startOfDay } from '@rchursin/product/lib/dates'import { IconButton, useToast } from '@rchursin/product/ui'
/** A fixed day in the sample week, so the page reads the same on every visit */const NOW = new Date(2026, 9, 13, 10, 40)
/** * The month on its own copy of the sample events. Arrows move between days, Enter opens a * day (its agenda shows below), Tab reaches a day’s events. Click empty space in a day to add one. */export default function MonthViewExample() { const toast = useToast() const [cursor, setCursor] = useState(() => startOfDay(NOW)) const [events, setEvents] = useState(() => sampleEvents(NOW)) const [day, setDay] = useState<Date | null>(null) const title = formatMonth(cursor)A mini month with event markers and the chosen day’s agenda, for a dashboard.
import { AgendaWidget } from '@rchursin/product/calendar'import { useRouter } from '@rchursin/product/lib/router'
/** * A mini month with event squares and the chosen day’s agenda. It reads the same saved events * as the Calendar app, so an event added there shows up here. * href sends “Open Calendar” to your own page; here, the tool’s page in this Library. */export default function AgendaWidgetExample() { const { href } = useRouter() return ( <div className="lux-card w-full max-w-[20rem] rounded-2xl bg-raised p-5"> <AgendaWidget href={href('/library/tools/calendar-app')} /> </div> )}The create and edit form for an event, in a dialog, and the details popover that leads to it.
import { useRef, useState } from 'react'import { sampleEvents } from '@rchursin/product/data/sample'import { draftFromEvent, EventBlock, EventDetails, EventDialog, eventFromDraft, HOUR_PX } from '@rchursin/product/calendar'import { Button, Popover, usePopover, useToast } from '@rchursin/product/ui'
/** “Weekly skill review” from the sample week */const sample = sampleEvents(new Date(2026, 9, 13)).find((e) => e.id === 'e11')!
/** * The calendar’s flow on one event: open it for its details, then Edit opens the form and * Delete removes it (with Undo). Focus goes back to the event after each step. */export default function EventDialogEdit() { const toast = useToast() const details = usePopover() const heading = useRef<HTMLHeadingElement>(null) const block = useRef<HTMLDivElement>(null) const undo = useRef<HTMLButtonElement>(null) const [event, setEvent] = useState(sample) const [editing, setEditing] = useState(false)The Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
import { useRouter } from '@rchursin/product/lib/router'import type { KitSlug } from '@rchursin/product/shell/pages'import { FocusProvider, TimerApp } from '@rchursin/product/timer'
/** The window's tool links go to each tool's page in this Library (Today has none, so it shows as text) */const toolPages: Partial<Record<KitSlug, string>> = { tasks: '/library/tools/tasks-app', calendar: '/library/tools/calendar-app', timer: '/library/tools/timer-app', notes: '/library/tools/notes-app',}
/** * The whole Focus screen on a page of its own, on sample data. TimerApp reads the session from the * FocusProvider above it. Click inside, then 1, 2 and 3 switch between Focus, Stopwatch and Countdown, * Space starts and pauses the one you are in, and F finishes a focus session. * Everything saves to this browser; "Reset sample data" puts it back. */export default function TimerAppPage() { const { href } = useRouter() const hrefFor = (slug: KitSlug) => { const path = toolPages[slug] return path && href(path) } return ( <main className="min-h-dvh bg-sunken/40 px-4 pt-6 pb-24 sm:px-8 sm:pt-10"> <div className="mx-auto flex max-w-6xl flex-col gap-5"> <header className="flex flex-col gap-1"> <h1 className="text-xl font-semibold tracking-[-0.02em] text-balance">Timer</h1> <p className="text-[13px] text-pretty text-fg-3">A sample workspace. Everything you do here stays in this browser.</p> </header> {/* Keeps the outline in order: the app's own headings (the time log) are h3 */} <h2 className="sr-only">Focus screen</h2> <FocusProvider> <TimerApp hrefFor={hrefFor} /> </FocusProvider> </div> </main> )}A Pomodoro session: choose a task, say what you’ll finish, then focus and break in phases, with overtime and an inline Abandon.
import { SampleBadge } from '@rchursin/product/shell/layout'import { FocusProvider, FocusSession } from '@rchursin/product/timer'
/** * Pick a task, say what you will get done, press Start. Click the background of the session first * and Space starts and pauses, F finishes and logs. This example keeps its own session * (storagePrefix), so it never shares a run with the Timer app. */export default function FocusSessionExample() { return ( <FocusProvider storagePrefix="library:focus-session"> {/* isolate + overflow-hidden keep the soft halo behind the clock inside the frame */} <div className="relative isolate flex w-full flex-col items-center gap-6 overflow-hidden px-4 py-8 sm:px-8 sm:py-10"> <SampleBadge /> <FocusSession /> </div> </FocusProvider> )}The focus session for a dashboard: choose a task, start or pause, see the time left in the square and today’s total.
import { useRouter } from '@rchursin/product/lib/router'import { SampleBadge } from '@rchursin/product/shell/layout'import { FocusProvider, FocusWidget } from '@rchursin/product/timer'
/** * The widget brings no card of its own: the dashboard around it does. Choose a task, start a * session, and the square drains as the time runs. Its own session here (storagePrefix); * in an app, put it under the same FocusProvider as the full Focus screen. * href sends “Open Focus” to your own page; here, the tool’s page in this Library. */export default function FocusWidgetExample() { const { href } = useRouter() return ( <FocusProvider storagePrefix="library:focus-widget"> <div className="flex w-full max-w-sm flex-col gap-3"> <SampleBadge className="self-start" /> <div className="lux-card rounded-2xl bg-raised p-5"> <FocusWidget href={href('/library/tools/timer-app')} /> </div> </div> </FocusProvider> )}What was focused on, by day with daily totals and a seven-day chart, with time added by hand and Undo on delete.
import type { TimeEntry } from '@rchursin/product/data/sample'import { SampleBadge } from '@rchursin/product/shell/layout'import { TimeLogList } from '@rchursin/product/timer'
/** A past week, read on the Monday after: fixed dates, so the list reads the same on every visit */const NOW = new Date(2026, 8, 14, 9, 0)const at = (day: number, h: number, m: number) => new Date(2026, 8, day, h, m).getTime()
const entries: TimeEntry[] = [ { id: 'w1', taskId: 'RC-122', label: 'Release-notes agent drafts in the wrong tone', start: at(11, 10, 0), end: at(11, 10, 25), kind: 'focus' }, { id: 'w2', taskId: null, label: 'Break', start: at(11, 10, 25), end: at(11, 10, 30), kind: 'break' }, { id: 'w3', taskId: 'RC-122', label: 'Release-notes agent drafts in the wrong tone', start: at(11, 10, 30), end: at(11, 10, 55), kind: 'focus' }, { id: 'w4', taskId: 'RC-117', label: 'Context tax over 8k tokens per turn', start: at(10, 15, 0), end: at(10, 15, 40), kind: 'manual' }, { id: 'w5', taskId: 'RC-123', label: 'Write the skill naming guide', start: at(10, 9, 30), end: at(10, 9, 55), kind: 'focus' }, { id: 'w6', taskId: 'RC-125', label: 'Review pr-review prompt cost', start: at(8, 13, 0), end: at(8, 13, 45), kind: 'manual' }, { id: 'w7', taskId: 'RC-127', label: 'Cut api-docs skill: no runs in 45 days', start: at(8, 16, 10), end: at(8, 16, 35), kind: 'focus' },]
/** * The list on its own, without a provider: pass the entries and a way to look up their tasks. * No onDelete, so the rows are read-only, as in a report. initialDays keeps older days behind a button. */export default function TimeLogDays() { return ( <div className="flex w-full max-w-md flex-col gap-4"> {/* The day headings are h4, so the list sits under an h3 */} <div className="flex items-center justify-between gap-3"> <h3 className="text-[15px] font-semibold tracking-[-0.01em]">Week of Sep 7</h3> <SampleBadge /> </div> <TimeLogList entries={entries} taskById={() => null} now={NOW} initialDays={2} /> </div> )}Notes and reminders in one window: a searchable list beside a block editor, and reminders typed in plain words.
import { useRouter } from '@rchursin/product/lib/router'import { NotesApp } from '@rchursin/product/notes'import type { KitSlug } from '@rchursin/product/shell/pages'
/** The window's tool links go to each tool's page in this Library (Today has none, so it shows as text) */const toolPages: Partial<Record<KitSlug, string>> = { tasks: '/library/tools/tasks-app', calendar: '/library/tools/calendar-app', timer: '/library/tools/timer-app', notes: '/library/tools/notes-app',}
/** * Notes and reminders on a page of their own, on sample data. Click inside, then N starts a note * and R jumps to a new reminder. In the list, ↑ ↓ move between notes, Enter opens one, P pins it. * Everything saves to this browser; "Reset sample data" in the display options puts it back. */export default function NotesAppPage() { const { href } = useRouter() const hrefFor = (slug: KitSlug) => { const path = toolPages[slug] return path && href(path) } return ( <main className="min-h-dvh bg-sunken/40 px-4 pt-6 pb-24 sm:px-8 sm:pt-10"> <div className="mx-auto flex max-w-6xl flex-col gap-5"> <header className="flex flex-col gap-1"> <h1 className="text-xl font-semibold tracking-[-0.02em] text-balance">Notes</h1> <p className="text-[13px] text-pretty text-fg-3">A sample workspace. Everything you do here stays in this browser.</p> </header> {/* Keeps the outline in order: the app's own headings (the open note, the reminder groups) are h3 */} <h2 className="sr-only">Notes and reminders</h2> <NotesApp hrefFor={hrefFor} /> </div> </main> )}A note’s title, a Markdown block body, tags, word count and an honest save status; it saves 800 ms after typing stops.
import { useEffect, useState } from 'react'import type { Note } from '@rchursin/product/data/sample'import { NoteEditor } from '@rchursin/product/notes'import { SampleBadge } from '@rchursin/product/shell/layout'
const EDITED = new Date(2026, 9, 13, 9, 41).getTime()
const sample: Note = { id: 'release', title: 'Release checklist', body: 'Ship the skill cleanup on Thursday.\n\n- [x] Changelog entry\n- [ ] Rerun the scan after merging skills\n- [ ] Post the before / after cost\n\n> Every description loads into every turn.', pinned: false, tags: ['release', 'skills'], updatedAt: EDITED,}
/** * The editor on one note you own. Type and it saves 800 ms after you stop (the status in the * footer says so). Try "- " or "[] " at the start of a line, Ctrl+Enter (⌘↵) to tick an item. */Reminders grouped from Overdue to Done, checked off, snoozed or deleted with Undo, plus a compact widget for a dashboard.
import { useRouter } from '@rchursin/product/lib/router'import { RemindersWidget } from '@rchursin/product/notes'
/** * Type a reminder in plain words ("check the cost report every Monday at 9") and press Enter. * The next four open reminders sit under it, overdue first. It reads the same saved reminders * as the Notes app, so a change in one shows up in the other. * href sends “Open Notes” to your own page; here, the tool’s page in this Library. */export default function RemindersWidgetExample() { const { href } = useRouter() return ( <div className="lux-card w-full max-w-sm rounded-2xl bg-raised p-5"> <RemindersWidget href={href('/library/tools/notes-app')} /> </div> )}