<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))
// a lane builds only the cards on screen, so count what the lanes hold
const held = () =>
[...board.querySelectorAll('.lane h3 .lane-count')].reduce(
(n, el) => n + parseInt(el.textContent, 10),
0
)
const all = held()
test('the board renders lanes and cards', () => {
expect(board.querySelectorAll('.lane').length).toBeGreaterThan(2)
expect(board.querySelectorAll('.card').length).toBeGreaterThan(0)
expect(all).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))
expect(board.querySelectorAll('.card').length).toBeGreaterThan(0)
expect(held()).toBeGreaterThan(0)
expect(held()).toBeLessThan(all)
})
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 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. Working and in-progress
imply each other: working on a task that isn't started moves it to
in-progress, moving a card into in-progress (a drag, or the card's
status select) puts you on it, and moving it out takes everyone off.
So the card window needs no work button, and no close button either:
status done closes a task, and moving it out of done reopens it
(board #173). 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
lane— the tag key that becomes the lanes (defaultstatus). A task with no status is in the inbox — the implicitstatus:inbox; moving it out adds a status, moving it back removes it.filter— the initial filter text; the input writes it back.color— a palette name for the board background (defaultsky). Boards are saved filters, so each one gets its own color.hide— lane values to leave out, space-separated (defaultfreezer obsolete rejected); the toggle row under the filter writes it back.show— a project board's view toggles, space-separated:no-ownleaves out the project's own tasks,no-othersleaves out related tasks (owned by another project, upstream, downstream or blocking this one), andstakeholderskeeps only own tasks that another project cares about. The "show" control (own | related | archived, #765) and the filter menu write it. Own and related can't both be off. Archived tasks are the filter's business: hidden unless the filter saysarchived(only those) orarchived:any(those too), which is the filter menu's "archived tasks" submenu.db— with nostoreset, open (or create) an IndexedDB log with this name as the base (on a host board the name joins the host and is keyed by the credential);snapshot— a URL of a JSON list of events to fill an empty log with (the demo board's real projects);seed— else seed that many generated tasks if the log is empty;identity— who the board writes as (defaultyou). Together they make a board that needs no code: see the full-screen board.
Lanes
status: lanes come in a fixed order — inbox, freezer, backlog, ready,
in-progress, review, then the endings: done, obsolete, rejected. 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 starts off, and so do obsolete and rejected, whose tasks are
archived anyway.
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
settings button opens its settings — only what cannot be derived: the
repo, the site, and the look (icon, color, background — each a text field,
with a file or color picker beside it that only fills the field in).
Description, 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. A
retired project (the settings panel's retire) leaves the slice menu and
trails the overview, muted; its history is untouched, and unretire brings it
back. 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 slice 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 account menu's Import from GitHub…, 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); docs adds a
Docs item that goes there, for a page without the site header (the hosted
board's). 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, or offline: <reason>; who you are is the
avatar's tooltip. A replica only asks for what comes after its cursor, so a
host whose log was reset needs the account menu's Reload from server: it
drops the local copy and loads the log again, keeping the credential.
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, and the slice menu
The top line reads left to right: a view switch (tiles = overview,
columns = board), then one slice menu — the mode and the slice of it:
project › all projects or one project, status › one status,
owner › one status. Each mode has its own icon on its leaves (a project
shows its own), so the button reads compactly: a project's icon and name,
or ⚑ ready. In project mode the lanes are statuses: the default board. In
status mode they are projects — "what is in progress, everywhere". In owner
mode they are owners — who is carrying what, the view for balancing a team.
Only project mode has "all". One menu rather than two keeps the toolbar
narrow: the slice and the tag list give way on a small screen, so the view
switch and the account button always stay on it. 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.
Find (board #4048), at the toolbar's end in every view once signed
in, jumps rather than filters: click it, press ⌘⇧K (Ctrl Shift K), or
press / where the filter isn't shown, and type a card number (4001 or #4001, any project),
words from a title or a body, or a project's name. Choosing a card opens
it where it lives; a project opens its board; the last row filters the
board by the words instead. On the board (where the filter shows), the
board filters as you type, throttled: closing find keeps that filter,
choosing a card or a project puts the board's own back. A card with only
some of the words still shows, below the ones with all of them. A link does the same from outside:
/host/?4001 (on virta's own site, virta.tosijs.net/?4001 or /4001)
opens #4001 the way #?virta.task=4001 does.
Moving a task to another project
A task has one owner: one project:. The card window's project select
(or a drag between project lanes) hands it upstream or downstream — "this
should not be done here". No question is asked: the project it leaves stays
interested (it still sees the task on its board). Delete the
interested: tag to end that, or change it to blocks: / delays: when
the old project is waiting on it (the move verb takes the relation
directly). The move is one commit: project: changes, from:<old> keeps
the provenance, interested:<old> records the old project's interest, and
a comment says so. The old project's board shows the task,
marked as the new owner's; its 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.
Sort, state, and the URL
Cards sort by priority by default: priority:critical … low, 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.