<tosi-virta> — the board

The 2D board: one filter, tasks grouped into lanes by the value of one tag key (status by default — the empty lane is the inbox), a card view with the progressively-disclosed transcript, and keyboard navigation. Every view is a composition of the one primitive: tag filter plus sort.

Point it at a store and it binds. The store below is seeded with a few hundred tasks so the board is exercised at a realistic size; the demo identity writes to an in-memory log, so play freely.

<tosi-virta lane="status" color="sky" style="width: 100%; height: 100%"></tosi-virta>
import { MemoryLog, VirtaStore, seedEvents } from 'tosijs-virta'

const store = new VirtaStore({ log: new MemoryLog(), identity: 'you' })
await store.load()
await store.import(seedEvents({ tasks: 300, seed: 3 }))
preview.querySelector('tosi-virta').store = store
const board = preview.querySelector('tosi-virta')
await new Promise((r) => setTimeout(r, 100))
test('the board renders lanes and cards', () => {
  expect(board.querySelectorAll('.lane').length).toBeGreaterThan(2)
  expect(board.querySelectorAll('.card').length).toBeGreaterThan(50)
})
test('typing a filter narrows the board', async () => {
  const input = board.querySelector('input[part="filter"]')
  input.value = 'project:tosijs-ui'
  input.dispatchEvent(new Event('change', { bubbles: true }))
  await new Promise((r) => setTimeout(r, 100))
  const cards = board.querySelectorAll('.card').length
  expect(cards).toBeGreaterThan(0)
  expect(cards).toBeLessThan(150)
})

The card window

The title, the body rendered as markdown, and an editable tag list: add a tag by typing it or picking from every tag on the board, remove one with its ×. Each edit is one tag/untag event, so the transcript shows who changed what. Below that: move, close/reopen, work on this, and the transcript.

Working on it

Anyone — a person or an agent — can say what they are working on: the work verb (or the button in the card window) puts a working:<identity> tag on the task and takes it off everything else that identity was working on, since you work on one thing at a time. Say also: true to keep the others. The card shows a small jiggling avatar per worker; working:owner is an ordinary tag, so working:owner is a filter and working is a board of everything in progress.

Keys

key action
j / k or / next / previous card
Enter open the selected card
Escape close the card view
/ focus the filter

Attributes

Lanes

status: lanes come in a fixed order — inbox, freezer, backlog, ready, in-progress, review, done, archive; every one is shown even when empty, so there is always somewhere to drop. The inbox is the implicit status: no tag is written for it, status:inbox in a filter finds it, and dropping a card there removes its status. Other values a board meets follow alphabetically. The row of toggles under the filter switches lanes on and off; freezer and archive start off.

Overview

The toolbar's view switch (the tiles icon) swaps the lanes for one tile per project: its icon, color or background, version, a one-line description, open / in-progress / blocked counts, the latest activity, and links to its site, npm package, and repo. Click a tile to open that project's board; the ⚙ opens its settings — only what cannot be derived: the repo, and the look (icon, color, background). Description, site, package, and version come from the repo and the tasks. Settings are stored as project events, so a project has a transcript like a task does. Unset projects get a color and initials from their name. view is part of the URL state. A tile also counts what the project is waiting on elsewhere: open tasks moved to other projects that were marked as blocking or delaying it. Tasks with no project get a derived no project tile, first, only while any exist — and "no project" joins the scope menu for the same span. Nothing is written for it: project:none is the implicit value, as status:inbox is for status.

Import from GitHub (the box above the tiles, or a project's "refresh from GitHub"): point it at a repo and its description, homepage, package, version, and issues arrive — see GitHub import. Repeating it only adds what is new.

Connecting to a host

<tosi-virta host="https://us-central1-<project>.cloudfunctions.net" poll="10" hash></tosi-virta>

A board with a host syncs with a service-compris host. The avatar at the toolbar's right end is the account: signed out it offers Sign in with Google, Sign in with email…, and, for agents, Use an agent token…; signed in it shows your initials, and its menu has who you are and sign out. Every write is then that person — the host resolves their roles from the ID token on each request. The Firebase SDK and the project's public web config load on demand from the host's own project (see sign-in); nothing is bundled, and the session persists in this browser until sign-out. An agent token is kept in this browser's storage, never in markup or the URL. The auth attribute lists what the menu offers (google password token by default). The board loads from its local replica — an IndexedDB named for the host, or db as the name's base, always keyed by the credential (replicaDbName) — and pulls the delta; writes go to the host and come back through the replica; other writers' events arrive by polling the delta every poll seconds (10 by default), when the tab comes back into view, and after each write. The toolbar's right edge says live with who you are, or offline: <reason>. seed is ignored on a host board — a shared host is not a demo. (The sign-in UI belongs upstream — tosijs-ui#177 asks for it; this is the interim.)

Size

Teams that estimate write size:<points> on the Fibonacci scale (1 2 3 5 8 13 21; stop wherever the team stops — the top value means "or more"). It is a tag like any other: filter on it, color it, pick it from the menu. When any card in a lane carries a size the lane header shows the lane's points beside its count, and the sort menu offers biggest / smallest first, unsized last. size:8 is orange and size:13 and up red — big tasks call themselves out as candidates for breaking down. Nothing asks for a size: an untagged task is a normal task, not a lapse.

Continue

The card window's continue button is "yes, and": a new task that carries on from this one, linked both ways (continues:#n on the new, continued-by:#m on the old, plus a comment) and inheriting only the obvious tags — project, kind, area, priority — never status, size, or who is working. It opens with its title selected so the first keystrokes rename it. The continue verb does the same for agents.

The toolbar: view, mode, scope

The top line reads left to right: a view switch (tiles = overview, columns = board), a mode select — project, status, owner — and a scope select the mode labels. In project mode the scope is a project or all projects, and the lanes are statuses: the default board. In status mode the scope is one status and the lanes are projects — "what is in progress, everywhere". In owner mode the scope is one status and the lanes are owners — who is carrying what, the view for balancing a team. Only project mode has "all". Dragging between lanes moves along whichever axis is showing; between project lanes it asks the question the card's project select does (below). axis and scope are attributes and URL state; lane still accepts any other tag key directly.

Moving a task to another project

The card window's project select moves a task upstream or downstream — "this should not be done here". Leaving a project asks what that project is waiting on: it blocks it, it delays it, or neither. The move is one commit: project: changes, from:<old> keeps the provenance, blocks:<old> or delays:<old> records the dependency, and a comment says so. The old project's tile counts what it is waiting on; blocks:<name> as a filter lists it. A support ticket handed to engineering is exactly this: two tags change, nothing is cloned, and the transcript keeps the whole history.

Nothing stops a task living in several projects: it is just two project: tags, and in project lanes the card shows in both. Alt-drag a card onto a project lane to add it there without leaving its own (the verb's also), or add the tag in the card's tag list.

Sort, state, and the URL

Cards sort by priority by default: priority:criticallow, then unprioritized; blocked tasks first within a priority; then oldest in the lane first. The other sorts: oldest / newest in lane (age is time since the last status: change), recently updated, recently created, number, title.

filter, project, sort, and hide are attributes, so a board's view can be written in markup. With the boolean hash attribute the same four are mirrored into the URL hash (namespace virta, defaults written as absent), plus task=<num> for the open card — so a URL can point at a card. It runs both ways: every control writes the hash, and editing the hash (or the back button) moves every control.

Tag colors come from one registry: chips on cards, chips in the card window, and the dots in the filter menu all agree.

The filter

Two controls that make one filter. The search box takes free text — each word must appear in the title or body — and also accepts terms typed directly (project:tosijs-ui, -status, #38). The tag list beside it holds the terms; its menu is hierarchical: every tag key as a submenu of its values (plus "any" and "no"), the bare tags, and a closed-tasks switch. The filter attribute always holds the composed result, so a board can be saved as one string.

Drag a card to another column to move it (one setTag, the same verb the "move to" select uses). Drag it out of the browser and it lands as a markdown file — see markdown interchange for the shape. Drop a markdown, text, or HTML file — or a dragged selection of text — on a column and it becomes a task there (a .md that came from a board moves instead of duplicating). Try dragging some of this paragraph onto the board above.

Columns scroll sideways; each column scrolls its own cards. Opening a card floats it in a window over the board; click the backdrop, the ×, or press Escape to put it back.

The component never talks to the log itself. It calls the store's verbs — the same ones the agent API exposes — so a human and an agent editing the same task produce indistinguishable events.