/dwaːn/ — Frisian for "to do, to act, to carry out"
A task management application built without any UI framework. No React, no Vue, no Svelte. The entire rendering stack — virtual DOM, signals, reactivity, routing, storage — is written from scratch in vanilla TypeScript.
This project is deliberately built without a UI framework for two reasons:
The result is a stack that borrows the best ideas from modern frameworks (JSX, signals, fine-grained reactivity) while staying 100% vanilla TypeScript with no runtime dependencies beyond Vite for bundling.
# Install Bun (https://bun.sh) and Rust (https://rustup.rs) first
./scripts/prerequisites.sh # automated setup, or install manually per README
bun install
bun run dev # web dev server (Vite)
bun run tauri dev # desktop app (requires Rust + Tauri CLI)
Other useful commands:
bun run build # typecheck + production build
bun run typecheck # tsc --noEmit
bun run check # Biome lint
bun run fix # Biome lint + auto-fix
bun test # run tests
bun test <file> # run a single test file
The apps/ directory contains two sub-projects:
bun run dev:docs # documentation site
bun run dev:playground # component playground
src/
├── app/ boot and singleton state store
├── domain/ pure types: Task, Project, Pane, Layout, tree helpers
├── mutations/ pure functions that return new state (no side effects)
├── repos/ persistence: load from storage → state, save state → storage
├── helpers/ stateless utilities (filter, search, drag, keybindings)
├── ui/ TSX components
│ └── styles/ modular CSS (base, layout, components, motion, dnd)
├── types/ shared primitives (ID, Time) and global.d.ts
└── vendor/ internal libraries (see below)
├── jsx/ custom VDOM and renderer
├── flow/ signal library (val, computed, effects)
├── database/ IndexedDB wrapper
├── storage/ storage adapter interface
├── motion/ View Transitions API integration
├── key/ keyboard shortcut manager
├── router/ URL search-param state sync
└── dnd/ drag-and-drop utilities
The application is a uni-directional data flow pipeline:
user event → mutation → setState → subscribers → re-render
└── repo.save (debounced)
└── syncUrl
Location: src/vendor/jsx
A minimal virtual DOM with diffing and patching. The TypeScript compiler is configured to use this as the JSX transform (jsxImportSource: "@/vendor/jsx"), so .tsx files work naturally.
h() builds a VNode tree. render(vnode, container) mounts it. On re-render it calls _patch(), which walks both old and new VNode trees and makes targeted DOM mutations.
type VNode = {
type: string | Component | typeof Fragment
props: Props
children: VNode[]
key?: string | number
dom?: Node
}
Signal (Val<T>) values can be passed as props or children. The renderer detects them via isVal() and subscribes directly — bypassing the full VDOM diff for those attributes. This is fine-grained reactivity at the attribute level.
const label = val('hello')
<button class={label}>click</button> // updates only the class attr
_reconcileChildren does a positional diff (index-based, not key-based for most lists). Key-based reconciliation is supported when key props are provided.
useEffect, useRef, or lifecycle hooks — components are plain functions called once per rendersetState triggers synchronous re-render (wrapped in View Transitions)_dispose on nodes)Location: src/vendor/flow
A push-based reactive primitive inspired by SolidJS signals. The core class is Signal<T> with dependency tracking via a currentSubscriber global.
import { val } from '@/vendor/flow'
const count = val(0) // Signal<number>
count() // read → 0
count(5) // write → notifies subscribers
const double = count.to(n => n * 2) // Computed (read-only derived signal)
const stop = count.do(n => log(n)) // Effect (runs immediately, returns disposer)
const label = count
.if(n => n < 0).then('negative')
.elif(n => n === 0).then('zero')
.else('positive') // ConditionalChain → Val<string>
Computed and Effect set currentSubscriber to themselves before running their function. Any signal.get() call during that run adds currentSubscriber to the signal's subscriber set. When the signal's value changes it calls subscriber.update() on each subscriber.
Fine-grained list and conditional rendering that bypasses parent re-renders:
import { For, Show } from '@/vendor/flow'
// List — only diffs items, not the parent
<For each={items$}>
{(item, i) => <li>{item.name}</li>}
</For>
// Conditional
<Show when={isLoaded$} fallback={<Spinner />}>
<Content />
</Show>
Both components render a display: contents wrapper div and subscribe to the signal directly, patching their own children without involvement from the parent component's render.
Effect.dispose() does not remove the effect from its source signals (bidirectional linking not yet implemented — marked as MVP)<For> is index-based; reordering items with stable keys will cause full re-renders of moved items<Switch>/<Match> implementation is partialLocation: src/app/app-state.ts
A singleton pub/sub store. No external library.
getState() // read current State
setState(prev => next) // immutable update, notifies all subscribers
subscribe(fn) // returns unsubscribe function
State is a flat record-based structure intentionally designed for O(1) lookups:
type State = {
workspaces: Workspace[]
projects: Record<ID, Project>
tasks: Record<ID, Task>
panes: Record<ID, Pane>
layout: Layout | null
activeWorkspace: ID | null
activePane: ID | null
searchQuery?: string
}
All state mutations live in src/mutations/. Each mutation is a pure function: takes current state, returns new state. UI components call setState with these functions.
// Example mutation usage
setState(prev => createTask(prev, { title: 'New task', projectId }))
Location: src/vendor/storage, src/vendor/database
An adapter pattern decouples the application from any specific storage backend.
type StorageAdapter = {
get<T>(key: string): Promise<T | undefined>
set<T>(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
}
Two adapters exist:
| Adapter | File | Used when |
|---|---|---|
IndexedDB |
src/vendor/storage/indexeddb.ts |
Web browser (default) |
Tauri FS |
src/vendor/storage/tauri.ts |
Desktop app |
The adapter is set once during boot in app-init.ts:
setStorageAdapter(createIndexedDBAdapter())
All application code reads and writes through the storage facade — never the adapter directly:
import { storage } from '@/vendor/storage'
await storage.set('key', value)
await storage.get<MyType>('key')
The database layer (src/vendor/database) owns the raw IndexedDB connection, store creation, and versioning. Currently five object stores: workspaces, projects, tasks, layout, storage.
Location: src/domain/layout.ts, src/mutations/layout.ts
The pane layout is a persistent binary tree — the same model used by terminal multiplexers like tmux.
type LayoutNode =
| { kind: 'pane'; paneId: PaneId; size: number }
| { kind: 'split'; dir: 'h' | 'v'; a: LayoutNode; b: LayoutNode }
type Layout = { root: LayoutNode; focus: PaneId }
Operations (layoutSplit, layoutClose, layoutFocus, layoutResize) are pure functions on this tree — no mutation. The result is fed back into setState.
Keyboard shortcuts:
| Key | Action |
|---|---|
Alt+H |
Split pane horizontally |
Alt+V |
Split pane vertically |
Alt+W |
Close focused pane |
` |
Cycle through panes |
Cmd/Ctrl+K |
Focus search |
| Module | Purpose |
|---|---|
vendor/jsx |
VDOM, reconciler, h(), render(), Fragment |
vendor/flow |
Signals (val), computed, effects, <For>, <Show> |
vendor/database |
Raw IndexedDB operations (dbGet, dbSet, dbDelete) |
vendor/storage |
Adapter interface + storage facade |
vendor/motion |
View Transitions API wrapper (vt(fn)) with configurable speed/style |
vendor/key |
Chainable keyboard bindings: Key.bind('Alt+h').preventDefault().to(fn) |
vendor/router |
URL search-param sync (getUrlState, setUrlState, onPopState) |
vendor/dnd |
Drag-and-drop helpers for task reordering |
None of these are published packages — they live in the repo and can be modified freely.
src/domain/.State in src/domain/state.ts and initialise it in createInitialState in src/app/app-state.ts.src/mutations/.src/repos/ (load on boot, save on change).app-init.ts.Mutations are pure functions in src/mutations/. They take state and parameters, return new state. No side effects inside a mutation.
// src/mutations/my-mutation.ts
import type { State } from '@/domain/state'
export function doSomething(state: State, param: string): State {
return {
...state,
// ...changes
}
}
// Usage in a component:
setState(prev => doSomething(prev, value))
Components are functions that take props and return VNode. They are plain functions — no class, no lifecycle hooks.
// src/ui/MyComponent.tsx
import type { VNode } from '@/vendor/jsx'
type Props = { label: string }
export function MyComponent({ label }: Props): VNode {
return <div class="my-component">{label}</div>
}
Use <For> and <Show> from @/vendor/flow when rendering signals. Use plain map/ternary for static data.
Add bindings in src/app/app-init.ts bindKeyboard() or inside a component's ref callback:
import { Key, isEditing } from '@/vendor/key'
Key.bind('Ctrl+d')
.preventDefault()
.to((e) => {
if (isEditing(e.target)) return
// handle
})
Implement StorageAdapter, call setStorageAdapter() before loadPersistedState() in app-init.ts:
import { setStorageAdapter } from '@/vendor/storage'
import { createTauriAdapter } from '@/vendor/storage/tauri'
setStorageAdapter(createTauriAdapter())
Extend UrlState in src/vendor/router/index.ts, read it in getUrlState, write it in setUrlState. Wire into applyUrlState and syncUrlFromState in app-init.ts.
backlog/active/done), priority, due date, categoryEffect disposal — Effect.dispose() is a no-op; signal subscriptions on removed DOM nodes leak (low impact for now, marked as TODO in vendor/flow/index.ts)<Switch>/<Match> — implementation in vendor/flow/components.tsx is a stub; use <Show> chains instead<For> keying — currently index-based; stable-key reconciliation not yet implementedsrc/vendor/storage/tauri.ts exists but the desktop build does not yet select it automatically; Tauri commands are not wiredWorkspace type exists in state but no UI or mutations for creating/switching workspacesTaskTree component does not use Flow signals internally; re-render granularity is at the full-app levelapps/docs and apps/playground — scaffolded but largely emptyTaskTree and ProjectView using <For>build:$PLATFORM)MIT License — Remco Stoeten