special: the board, the agenda and the calendar in a workspace of their own

The board swapped out the tree, and the agenda and the calendar were
overlays over it, each with a key to open and a key to close. They are now
three tiles of one special workspace, as the desktop keeps its scratchpad:
the board on the left, the agenda over the calendar on the right.

S puts it up and away from anywhere. b, t and C go straight to their tile,
and put the workspace away when that tile already has focus. Alt-h/j/k/l
move the focus between the tiles, f gives the focused one the porthole,
and the tiles without focus keep their cursors, dimmed. A digit switches
space as everywhere, and the board follows; the board's head names every
space that has one. Enter on an agenda card goes to it on the board beside.
A space without a board says so in the board's tile instead of refusing.

The rail offers the three tiles as doors, so a phone, which shows one at a
time, can walk between them. The help, the README, the wiki and the
screenshots say how it works now.
This commit is contained in:
nvrl committed 2026-10-21 15:04:58 +02:00
1 parent 304c67151c
commit 73ac961751
31 files changed
+321 -117

No files matched your search

+6 -4
View File
@@ -136,8 +136,9 @@ it; in the document pane it is that document. In the browse pane:
| `R` | rename what is highlighted — a document, a folder or a file — starting from its name (`:name!` renames the space) |
| `m` / `p` | pick nodes up — `m` again puts one back, `M` takes the whole folder — then drop them all into the folder you walk to |
| `Ctrl`/`Shift`-click | pick a row up / every row from the cursor to it; drag picked-up rows onto a folder, a crumb or a space |
| `b` | the board of this space |
| `t` | the agenda: what is late, due this week, or in hand, on every board |
| `S` | the special workspace: the board, the agenda and the calendar side by side; `Alt-h`/`j`/`k`/`l` move between them, `f` gives one the screen |
| `b` | the board of this space, in the special workspace; a digit there shows another space's |
| `t` | the agenda: what is late, due this week, or in hand, on every board, in the special workspace |
| `T` | today's diary entry, started if there is none |
| `C` | the diary by month (`:cal`) — see [The diary](#the-diary) |
| `[d` / `]d` | the diary entry before / after the one open, by date |
@@ -240,8 +241,9 @@ start as `sftp_host_key` in the data directory.
## Boards
`b`, the board door on the rail, or `:board` swaps the tree for the board
of the space being browsed, and swaps it back. Every space has one except the
`b`, the board door on the rail, or `:board` swaps the tree for the special
workspace — the board of the space being browsed, with the agenda and the
calendar beside it — and swaps it back. Every space has one except the
diary — a journal has no backlog — and `:set board` / `:set noboard` changes
that for the space you are in (the dimmed board door on the rail does the
same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+31 -8
View File
@@ -1,11 +1,33 @@
# Boards and the agenda
## The special workspace
The board, the agenda and the [calendar](Diary.md#the-calendar) share one
workspace of their own, as the desktop's special workspace holds what is
looked at in passing. `S` (or `:special`) puts it up and away again. It is
three tiles: the board on the left, the agenda over the calendar on the right.
![The special workspace](../images/board.png)
| key | what it does |
| --- | --- |
| `S` | put the special workspace up, or away |
| `b`, `t`, `C` | straight to the board, the agenda or the calendar; again on the one with focus puts the workspace away |
| `Alt-h` / `Alt-j` / `Alt-k` / `Alt-l` | focus the tile that way |
| `f` | the focused tile fills the screen; again or `Esc` to leave |
| `1` … `9` | switch space, and the board with it |
| `q`, `Esc` | back to the tree |
The keys go to the tile with focus, which wears the light border; the other
tiles keep their cursors, dimmed. Everything in it is read again each time it
comes up.
## The board
Every space except the diary has a board. `b`, the board button on the rail, or
`:board` swaps the tree for it, and `q` swaps back.
![The board of a space](../images/board.png)
`:board` shows it in the special workspace, and `q` goes back to the tree. The
head of the board names every space that has one, with the digit that goes
there.
A new board has three lists: `backlog`, `doing` and `done`. The lists mean
something by where they stand, not by their names:
@@ -33,11 +55,11 @@ a week fold away until `z` shows them.
| `z` | show done cards older than a week |
| `F` | a letter over every card; type it to jump there |
| `x`, `dd` | delete the card |
| `q`, `Esc` | back to the tree |
| `q`, `Esc` | put the special workspace away |
### Capturing a card
`c` anywhere outside the board opens `:card ` on the command line. The card goes
`c` anywhere outside the special workspace opens `:card ` on the command line. The card goes
into the inbox of the space you are in, or of the first space with a board, and
you stay where you were. From outside the browser, see [Capture](Capture.md).
@@ -86,7 +108,8 @@ does not make a second copy. The board and the agenda show the rule as
## The agenda
`t` (or `:agenda`) gathers what needs you from every board at once.
`t` (or `:agenda`) gathers what needs you from every board at once, in the
special workspace beside the board.
![The agenda](../images/agenda.png)
@@ -99,13 +122,13 @@ does not make a second copy. The board and the agenda show the rule as
| key | what it does |
| --- | --- |
| `j` / `k` | down / up, on into the diary entries |
| `Enter` | to the card on its own board, or open the entry |
| `Enter` | to the card on its own board, in the tile beside, or open the entry |
| `Space` | finish the card where it stands |
| `D` | due date |
| `H` / `L` | to the list left / right on its board |
| `:` | the command line with the agenda kept up, so `:repeat` acts on the card |
| `T` | today's diary entry |
| `t`, `q`, `Esc` | close |
| `t`, `q`, `Esc` | put the special workspace away |
The agenda opens by itself once a day when something is late or due. "Today"
is the day in `WORKSPACE_TIMEZONE`, the same as for the mails, so a browser in
+10 -7
View File
@@ -42,12 +42,14 @@ arguments and `↑` / `↓` recall earlier lines.
| `m / p` | pick nodes up, then drop them all into the folder you walk to |
| `M` | pick up the whole folder — again puts it all back |
| `Ctrl / Shift-click` | pick a row up · every row up to it — drag them onto a folder |
| `t` | agenda — late, due this week, in hand, on every board |
| `S` | the special workspace: the board, the agenda and the calendar side by side — again puts it away |
| `Alt-h/j/k/l · f` | in the special workspace: to the board, the agenda or the calendar — f gives one the screen |
| `t` | agenda — late, due this week, in hand, on every board — in the special workspace |
| `T` | today's diary entry, started if there is none — from templates/day when the diary has one |
| `C` | the diary by month: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day |
| `C` | the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day |
| `[d / ]d` | the diary entry before / after the one open, by date |
| `Ctrl-P` | find anything, in every space — Enter goes to the result lit |
| `b` | open this space's board |
| `b` | this space's board, in the special workspace — 1 … 9 there shows another space's |
| `c` | capture a card into this space's inbox, without leaving here |
| `Esc · q` | close the document, and its tile when there are several |
| `/` | full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one |
@@ -82,20 +84,20 @@ The first list is the inbox, the last list is done.
| `F` | a letter over every card in view — type it to put the cursor there |
| `r` | read the board again, for changes made elsewhere |
| `x · dd` | delete the card (asks y/N) |
| `q · Esc` | back to the tree |
| `q · Esc` | put the special workspace away |
### In the agenda
| key | what it does |
| --- | --- |
| `j / k` | down / up the cards, then what was written on this day in earlier years |
| `Enter` | to the card, on its own board — or to the entry |
| `Enter` | to the card, on its own board beside — or to the entry |
| `Space` | done, where it stands |
| `D` | due date: fri, tm, +3d … — :due on the line |
| `:` | the line, with the agenda kept up — :repeat weekly acts on the card |
| `H / L` | to the list left / right on its board |
| `T` | today's diary entry |
| `t · q · Esc` | close the agenda |
| `t · q · Esc` | put the special workspace away |
## Commands
@@ -121,7 +123,8 @@ do: `:w!`, `:space! <name>`, `:import!`.
| command | what it does |
| --- | --- |
| `:board` | show this space's board, or go back to the tree |
| `:board` | this space's board, in the special workspace — again puts it away |
| `:special` | the special workspace: the board, the agenda and the calendar side by side — S toggles it, Alt-h/j/k/l moves between them, 1 … 9 picks the board |
| `:card, :c <title>` | add a card — on the board to the list under the cursor, elsewhere to this space's inbox |
| `:card!` | add a card linked to the open document — Enter on it opens the document |
| `:due <when\|->` | due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears |
+4 -2
View File
@@ -13,7 +13,9 @@ an entry written after midnight on a trip still lands on the right day.
## The calendar
`C` (or `:cal`) shows the diary a month at a time.
`C` (or `:cal`) shows the diary a month at a time, in the
[special workspace](Boards-and-Agenda.md#the-special-workspace) under the
agenda.
![The diary by month](../images/calendar.png)
@@ -24,7 +26,7 @@ an entry written after midnight on a trip still lands on the right day.
| `H` / `L` | a month back / forward |
| `.` | today |
| `Enter` | open the day's entry, or start one dated that day |
| `C`, `Esc` | close |
| `C`, `Esc` | put the special workspace away |
A dot marks a day with an entry. A number says how many when there are more
than one.
+2 -1
View File
@@ -72,7 +72,8 @@ The rail of buttons on the right does the same as the keys, for a mouse.
`h` comes back up.
6. Press `c`, type `try the board`, and press `Enter`. That is a card in the
inbox of this space's board. Press `b` to see the board, and `b` again to go
back.
back. It shares a special workspace with the agenda and the calendar: `S`
puts it up, and `Alt-h`/`Alt-l` move between them.
7. Press `T`. That is today's diary entry, started for you.
8. Press `?` for the list of every key and command. `h` / `l` walk its columns, `/` narrows it down, and `Esc` closes it.
+7 -4
View File
@@ -11,7 +11,9 @@ The keys are the desktop's. On a phone, every one of these has a button; see
## Morning: what needs you
The first time you open the workspace each day, the **agenda** opens by itself
when anything is late or due. Any other time, press `t`.
when anything is late or due. Any other time, press `t`. It opens in the
**special workspace**, beside the board and the calendar; `S` puts that up and
away from anywhere, and `Alt-h`/`Alt-l` move between its tiles.
![The agenda](../images/agenda.png)
@@ -31,9 +33,9 @@ Walk it with `j` and `k`. On a card:
- `Space` finishes it, where it stands;
- `D` gives it a due date: `fri`, `tm`, `+3d`, `10-31`;
- `H` and `L` move it to the list left or right on its board;
- `Enter` goes to the card on its own board.
- `Enter` goes to the card on its own board, in the tile beside.
`Esc` or `t` closes the agenda. The bar keeps showing the `late` and `today`
`Esc` or `t` puts the special workspace away. The bar keeps showing the `late` and `today`
counts while either is above zero. More in [Boards and the agenda](Boards-and-Agenda.md).
## At work: spaces, documents and tiles
@@ -84,7 +86,8 @@ In a meeting someone asks you to renew a certificate. Press `c`, type
`Renew the gateway certificate`, and `Enter`. The card is in this space's
inbox, and you have not left the document you were reading.
Press `b` to see the **board**.
Press `b` to see the **board**, in the special workspace. A digit there
shows the board of another space.
![The board](../images/board.png)
+118 -20
View File
@@ -25,7 +25,7 @@
import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { workspace } from './lib/store.svelte'
import { workspace, type SpecialTile } from './lib/store.svelte'
import { dwindle, formatDay, neighbour, type Direction } from './lib/util'
/*
@@ -220,8 +220,8 @@
[['j', 'k'], 'browse, l opens, L opens beside'],
[['1…9'], 'switch space, Tab cycles'],
[['w'], 'overview of every space'],
[['b'], "this space's board"],
[['t'], 'agenda — late, due, in hand'],
[['S'], 'board, agenda and calendar'],
[['b', 't', 'C'], 'straight to one of them'],
[['c'], 'capture a card to the inbox'],
[['⌃ P'], 'find in every space'],
[['/'], 'search this space'],
@@ -313,7 +313,7 @@
case 'Enter':
if (card) void workspace.guard(() => workspace.goToCard(card))
else if (past) {
workspace.overlay = 'none'
workspace.closeSpecial()
void workspace.guard(() => workspace.openPath(past.path))
}
return true
@@ -337,12 +337,12 @@
if (card) void workspace.guard(() => workspace.sendAgendaCard(card, event.key === 'H' ? -1 : 1))
return true
case 'T':
workspace.overlay = 'none'
workspace.closeSpecial()
void runCommand('today')
return true
case 't':
case 'q':
workspace.overlay = 'none'
workspace.closeSpecial()
return true
default:
return false
@@ -371,7 +371,7 @@
return true
case 'C':
case 'q':
workspace.overlay = 'none'
workspace.closeSpecial()
return true
default:
return false
@@ -584,7 +584,22 @@
* too; left of the leftmost tile is the list. With Shift the focused tile
* goes that way instead, swapping places with the one it lands on.
*/
/*
* The special workspace is three tiles: the board on the left, the agenda
* over the calendar on the right. Alt-h/j/k/l moves between them.
*/
const specialNeighbours: Record<SpecialTile, Partial<Record<Direction, SpecialTile>>> = {
board: { l: 'agenda' },
agenda: { h: 'board', j: 'calendar' },
calendar: { h: 'board', k: 'agenda' },
}
function tileKey(direction: Direction, shift: boolean) {
if (workspace.view === 'special') {
const next = specialNeighbours[workspace.specialFocus][direction]
if (next && !shift) workspace.specialFocus = next
return
}
if (workspace.pane === 'list' || !workspace.open) {
if (direction === 'l' && !shift) focusPane('document')
return
@@ -681,7 +696,7 @@
return true
case 'q':
case 'Escape':
workspace.closeBoard()
workspace.closeSpecial()
return true
default:
return false
@@ -734,7 +749,10 @@
return
}
}
if (workspace.overlay === 'agenda' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
// The special workspace's agenda and calendar read their own keys while
// they have focus; what they leave falls through to the rest below.
const special = workspace.overlay === 'none' && workspace.view === 'special' && listening && !event.ctrlKey && !event.metaKey && !event.altKey
if (special && workspace.specialFocus === 'agenda') {
if (agendaKey(event)) {
event.preventDefault()
return
@@ -764,7 +782,7 @@
return
}
}
if (workspace.overlay === 'calendar' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (special && workspace.specialFocus === 'calendar') {
if (calendarKey(event)) {
event.preventDefault()
return
@@ -842,7 +860,7 @@
}
// `gt` / `gT` walk the tiles, as they walk vim's tab pages — before `t`
// and `T` on their own are taken for the agenda and today's entry.
if (previous === 'g' && (key === 't' || key === 'T') && workspace.view !== 'board') {
if (previous === 'g' && (key === 't' || key === 'T') && workspace.view !== 'special') {
event.preventDefault()
void cycleTile(key === 't' ? 1 : -1)
return
@@ -869,8 +887,31 @@
void runCommand('cal')
return
}
// `S`, as the desktop's special workspace is bound: the board, the
// agenda and the calendar, put up and away again.
if (key === 'S') {
event.preventDefault()
void runCommand('special')
return
}
if (workspace.view === 'board') {
if (workspace.view === 'special') {
if (workspace.specialFocus !== 'board') {
// The agenda and the calendar have had their keys; nothing reaches
// the board's from them, since it is not the tile with focus.
if (key === 'b') void runCommand('board')
else if (key === 'Escape') workspace.closeSpecial()
else if (key === ':') cmdline = ':'
else if (key === '?') void runCommand('help')
else return
event.preventDefault()
return
}
if (key === 'b') {
event.preventDefault()
workspace.closeSpecial()
return
}
if (previous === 'g' && key === 'g') {
event.preventDefault()
workspace.cardCursor = 0
@@ -1142,10 +1183,26 @@
<!-- Keyed on the space, so a switch slides the new one in, as the desktop
does between workspaces: from below when moving right, above when left. -->
{#key workspace.spaceId}
{#if workspace.view === 'board'}
<!-- The board takes the whole width: its columns are the two panes. -->
<div class="panes board switch" style="--dir: {workspace.switchDirection}">
<div class="window focused" bind:this={boardEl}><Board /></div>
{#if workspace.view === 'special'}
<!-- The special workspace: the board, and the agenda over the
calendar beside it. `f` gives the focused one the whole porthole. -->
<div class="panes special switch" class:full={workspace.fullscreen} data-focus={workspace.specialFocus} style="--dir: {workspace.switchDirection}">
{#each ['board', 'agenda', 'calendar'] as const as tile (tile)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="window {tile}"
class:focused={workspace.specialFocus === tile}
onpointerdown={() => (workspace.specialFocus = tile)}
>
{#if tile === 'board'}
<div class="fill" bind:this={boardEl}><Board /></div>
{:else if tile === 'agenda'}
<Agenda />
{:else}
<Calendar />
{/if}
</div>
{/each}
</div>
{:else}
<div
@@ -1218,7 +1275,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else if workspace.overlay === 'calendar'}<Calendar />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else if workspace.overlay === 'outline'}<Headings />{:else if workspace.overlay === 'trash'}<Trash />{:else if workspace.overlay === 'history'}<History />{:else if workspace.overlay === 'backlinks'}<Backlinks />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
@@ -1237,12 +1294,49 @@
<style>
.boot { display: grid; place-items: center; height: 100%; }
.panes.board { grid-template-columns: 1fr; }
/*
* The special workspace: the board takes what the right column leaves,
* the agenda takes what the calendar leaves under it.
*/
.panes.special {
grid-template-columns: minmax(0, 1fr) minmax(44ch, 30vw);
grid-template-rows: minmax(0, 1fr) auto;
grid-template-areas: 'board agenda' 'board calendar';
}
.special > .board { grid-area: board; }
.special > .agenda { grid-area: agenda; }
.special > .calendar { grid-area: calendar; }
.special .fill { height: 100%; }
/* One cursor is lit at a time: the other tiles keep theirs, dimmed. */
.special > .window:not(.focused) :global(:is(.card.active, .row.at, .day.at)) {
background: var(--surface-alt);
color: var(--light);
}
.special > .window:not(.focused) :global(.row.at :is(.where, .due)) { color: var(--muted); }
.special > .window:not(.focused) :global(.card.active :is(.meta, .doc)) { color: var(--muted); }
.special > .window:not(.focused) :global(:is(.card.active .today, .today .row.at .due)) { color: var(--light); }
.special > .window:not(.focused) :global(:is(.card.active .overdue, .overdue .row.at .due)) { color: var(--alarm); }
.special > .window:not(.focused) :global(.card.active .tick) { border-color: var(--outline); }
.special > .window:not(.focused) :global(.day.at :is(.dot)) { background: var(--light); }
.special > .window:not(.focused) :global(.day.at .many) { color: var(--light); }
.panes.special.full { grid-template-columns: 1fr; grid-template-rows: 1fr; grid-template-areas: none; }
.panes.special.full > .window:not(.focused) { display: none; }
.panes.special.full > .window { grid-area: auto; }
/* The agenda and the calendar are sheets of their own as overlays; as tiles
the window is their sheet. */
.special > .window :global(.sheet) {
width: 100%;
height: 100%;
max-height: none;
border: 0;
border-radius: 0;
background: none;
}
.window.list { --pane-bg: color-mix(in oklab, var(--surface) 60%, var(--base)); }
/* Only one pane on screen: nothing to tell apart, so no active border. */
.panes:not(.reading) .window.list,
.panes.board .window {
.panes:not(.reading) .window.list {
background:
linear-gradient(var(--pane-bg), var(--pane-bg)) padding-box,
linear-gradient(var(--overlay), var(--overlay)) border-box;
@@ -1311,5 +1405,9 @@
.tiles > .window:not(.current) { display: none; }
.tiles > .window.current { inset: calc(var(--gap) / 2); width: auto; height: auto; }
.overlay { padding: 12px; }
/* A phone shows the special workspace's focused tile alone. */
.panes.special { grid-template-columns: 1fr; grid-template-rows: 1fr; grid-template-areas: none; }
.panes.special > .window { grid-area: auto; }
.panes.special > .window:not(.focused) { display: none; }
}
</style>
+2 -2
View File
@@ -25,7 +25,7 @@
/** An entry from an earlier year, in place in the diary. */
function open(entry: DiaryEntry) {
workspace.overlay = 'none'
workspace.closeSpecial()
void workspace.guard(() => workspace.openPath(entry.path))
}
@@ -38,7 +38,7 @@
<div class="sheet" bind:this={sheet}>
<header>
<span class="label rule"><span class="marker"></span><span class="title">agenda</span> {total} open</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
<button onclick={() => workspace.closeSpecial()}>esc</button>
</header>
{#if workspace.agenda.diary_today === false}
+2 -2
View File
@@ -40,8 +40,8 @@
<span class="divider path"></span>
<nav class="crumbs" aria-label="path">
{#if workspace.view === 'board'}
<span class="here">board</span>
{#if workspace.view === 'special'}
<span class="here">special:{workspace.specialFocus}</span>
{:else}
<button
class="crumb"
+38 -3
View File
@@ -43,6 +43,11 @@
return index === 0 ? 'inbox' : index === lastIndex ? 'done' : ''
}
/** The spaces that have a board, with the digit that switches to each. */
const boards = $derived(
workspace.spaces.flatMap((space, index) => (space.has_board && index < 10 ? [{ space, digit: (index + 1) % 10 }] : [])),
)
function pick(list: number, card: number) {
workspace.listCursor = list
workspace.cardCursor = card
@@ -54,7 +59,18 @@
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? ''}</span>
<span>board</span>
<span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>
{#if workspace.board}<span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>{/if}
<!-- Every board is a digit away: the digit switches space, and the board
follows the space. -->
{#if boards.length > 1}
<span class="boards">
{#each boards as { space, digit } (space.id)}
<button class="other" class:here={space.id === workspace.spaceId} onclick={() => workspace.guard(() => workspace.switchSpace(digit === 0 ? 9 : digit - 1))}>
<span class="n">{digit}</span>{space.slug}
</button>
{/each}
</span>
{/if}
</div>
<div class="columns" bind:this={columns}>
@@ -116,7 +132,11 @@
</section>
{:else}
<div class="empty muted">
no lists yet — :list &lt;name&gt; makes a column. then o adds a card to it.
{#if !workspace.space?.has_board}
{workspace.space?.slug ?? 'this space'} has no board — :set board turns one on{#if boards.length}, or a digit goes to one that has{/if}.
{:else}
no lists yet — :list &lt;name&gt; makes a column. then o adds a card to it.
{/if}
</div>
{/each}
</div>
@@ -131,7 +151,7 @@
<span><span class="kbd">D</span> due</span>
<span><span class="kbd">R</span> rename</span>
<span><span class="kbd">dd</span> delete</span>
<span><span class="kbd">q</span> tree</span>
<span><span class="kbd">1</span>…<span class="kbd">9</span> board</span>
<span class="rule-hint">first list is the inbox · last list is done</span>
</footer>
</div>
@@ -151,6 +171,21 @@
}
.head .marker { background: var(--light); }
.head .name { color: var(--light); }
.boards { display: flex; gap: 4px; margin-left: 8px; }
.other {
display: flex;
gap: 6px;
padding: 2px 8px;
border: 0;
border-radius: var(--radius-s);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: none;
color: var(--muted);
}
.other .n { color: var(--outline); }
.other.here { background: var(--surface-alt); color: var(--light); }
.other.here .n { color: var(--light-dim); }
.columns {
flex: 1;
+1 -1
View File
@@ -42,7 +42,7 @@
<span class="label rule"><span class="marker"></span><span class="title">{title}</span> {workspace.calendarEntries.length} entries</span>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, -1))} aria-label="previous month">‹</button>
<button onclick={() => workspace.guard(() => workspace.moveCalendar(0, 1))} aria-label="next month">›</button>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
<button onclick={() => workspace.closeSpecial()}>esc</button>
</header>
<ol class="grid">
+8 -6
View File
@@ -72,12 +72,14 @@
['m / p', 'pick nodes up, then drop them all into the folder you walk to'],
['M', 'pick up the whole folder — again puts it all back'],
['Ctrl / Shift-click', 'pick a row up · every row up to it — drag them onto a folder'],
['t', 'agenda — late, due this week, in hand, on every board'],
['S', 'the special workspace: the board, the agenda and the calendar side by side — again puts it away'],
['Alt-h/j/k/l · f', 'in the special workspace: to the board, the agenda or the calendar — f gives one the screen'],
['t', 'agenda — late, due this week, in hand, on every board — in the special workspace'],
['T', "today's diary entry, started if there is none — from templates/day when the diary has one"],
['C', 'the diary by month: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'],
['C', 'the diary by month, in the special workspace: h/l a day, j/k a week, H/L a month, . today, Enter opens or starts the day'],
['[d / ]d', 'the diary entry before / after the one open, by date'],
['Ctrl-P', 'find anything, in every space — Enter goes to the result lit'],
['b', "open this space's board"],
['b', "this space's board, in the special workspace — 1 … 9 there shows another space's"],
['c', "capture a card into this space's inbox, without leaving here"],
['Esc · q', 'close the document, and its tile when there are several'],
['/', 'full-text search, inside the current space and its text files — Tab or Ctrl-N/P walk the results, Enter opens one'],
@@ -112,18 +114,18 @@
['F', 'a letter over every card in view — type it to put the cursor there'],
['r', 'read the board again, for changes made elsewhere'],
['x · dd', 'delete the card (asks y/N)'],
['q · Esc', 'back to the tree'],
['q · Esc', 'put the special workspace away'],
]
const agendaKeys: [string, string][] = [
['j / k', 'down / up the cards, then what was written on this day in earlier years'],
['Enter', 'to the card, on its own board — or to the entry'],
['Enter', 'to the card, on its own board beside — or to the entry'],
['Space', 'done, where it stands'],
['D', 'due date: fri, tm, +3d … — :due on the line'],
[':', 'the line, with the agenda kept up — :repeat weekly acts on the card'],
['H / L', 'to the list left / right on its board'],
['T', "today's diary entry"],
['t · q · Esc', 'close the agenda'],
['t · q · Esc', 'put the special workspace away'],
]
const titles: Record<Group, string> = {
+12 -2
View File
@@ -30,15 +30,24 @@
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') }
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help') }
if (workspace.view === 'board') {
if (workspace.view === 'special') {
// The tiles of the special workspace are doors, so a phone — which
// shows one at a time — can walk between them.
const tiles: Door[] = [
{ icon: 'board', label: 'Board', keys: ['b'], run: () => (workspace.specialFocus = 'board'), on: workspace.specialFocus === 'board' },
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => (workspace.specialFocus = 'agenda'), on: workspace.specialFocus === 'agenda' },
{ icon: 'calendar', label: 'Calendar', keys: ['C'], run: () => (workspace.specialFocus = 'calendar'), on: workspace.specialFocus === 'calendar' },
{ icon: 'list', label: 'Back to the tree', keys: ['S', 'q'], run: () => workspace.closeSpecial() },
]
if (workspace.specialFocus !== 'board') return [tiles, [cmd, help]]
// The card verbs are doors too, so a phone — which has no H, L or
// Space — can still move work along.
const card = !!workspace.card
return [
tiles,
[
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') },
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
],
[
{ icon: 'left', label: 'Send the card left', keys: ['H'], run: () => workspace.guard(() => workspace.sendCard(-1)), dim: !card },
@@ -79,6 +88,7 @@
],
[
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') },
{ icon: 'calendar', label: 'Calendar — the diary by month', keys: ['C'], run: () => runCommand('cal') },
{ icon: 'today', label: "Today's diary entry", keys: ['T'], run: () => runCommand('today') },
{ icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') },
],
+22 -17
View File
@@ -429,8 +429,14 @@ export const commands: CommandSpec[] = [
{
name: 'board',
group: 'board',
help: "show this space's board, or go back to the tree",
run: () => workspace.guard(() => workspace.toggleBoardView()),
help: "this space's board, in the special workspace — again puts it away",
run: () => workspace.guard(() => workspace.toggleSpecial('board')),
},
{
name: 'special',
group: 'board',
help: 'the special workspace: the board, the agenda and the calendar side by side — S toggles it, Alt-h/j/k/l moves between them, 1 … 9 picks the board',
run: () => workspace.guard(() => workspace.toggleSpecial()),
},
{
name: 'card',
@@ -447,7 +453,7 @@ export const commands: CommandSpec[] = [
run: async (arg) => {
// On the board the tree is not on screen, so only the open document
// can be meant; elsewhere it is whatever has focus.
const node = workspace.view === 'board' ? workspace.open : workspace.target
const node = workspace.view === 'special' ? workspace.open : workspace.target
if (!node || node.kind !== 'document') {
return workspace.say('no document to link — open one first', 'error')
}
@@ -463,8 +469,8 @@ export const commands: CommandSpec[] = [
help: 'due date of the card — on the board, or highlighted in the agenda: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
complete: () => ['today', 'tm', 'fri', '+3d'],
run: async (arg) => {
const agenda = workspace.overlay === 'agenda'
if (workspace.view !== 'board' && !agenda) return workspace.say('no board open — :board', 'error')
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
const set = (due_at: number | null) =>
agenda ? workspace.patchAgendaCard({ due_at }) : workspace.patchCard({ due_at })
const target = arg.trim()
@@ -482,8 +488,8 @@ export const commands: CommandSpec[] = [
help: 'once the card is done, a copy comes back in the inbox: daily, weekly, monthly, yearly, +3d, +2w, +6m — - stops it',
complete: () => ['daily', 'weekly', 'monthly', 'yearly', '+2w', '-'],
run: async (arg) => {
const agenda = workspace.overlay === 'agenda'
if (workspace.view !== 'board' && !agenda) return workspace.say('no board open — :board', 'error')
const agenda = workspace.view === 'special' && workspace.specialFocus === 'agenda'
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
const rule = arg.trim()
const repeat = rule === '-' || rule === '' ? null : rule
await workspace.guard(async () => {
@@ -498,7 +504,7 @@ export const commands: CommandSpec[] = [
group: 'board',
help: 'move the selected card into the done list, or back out to the inbox',
run: () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
},
},
@@ -508,14 +514,14 @@ export const commands: CommandSpec[] = [
group: 'board',
help: 'add a column to the board',
run: async (arg) => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
if (!arg.trim()) return workspace.say('usage: :list <name>', 'error')
await workspace.guard(() => workspace.createList(arg.trim()))
},
bang: {
help: 'delete the column under the cursor and every card on it',
run: async () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
if (workspace.view !== 'special') return workspace.say('no board open — :board', 'error')
const list = workspace.list
if (!list) return workspace.say('no list selected', 'error')
const cards = list.cards.length
@@ -593,7 +599,7 @@ export const commands: CommandSpec[] = [
help: 'the diary by month — h/l a day, j/k a week, H/L a month, Enter opens the day or starts it',
run: () =>
workspace.guard(async () => {
if (workspace.overlay === 'calendar') return void (workspace.overlay = 'none')
if (workspace.view === 'special') return workspace.toggleSpecial('calendar')
await workspace.openCalendar()
}),
},
@@ -604,9 +610,8 @@ export const commands: CommandSpec[] = [
help: 'what is late, due this week, or in hand — across every board',
run: () =>
workspace.guard(async () => {
await workspace.loadAgenda()
workspace.agendaCursor = 0
workspace.overlay = workspace.overlay === 'agenda' ? 'none' : 'agenda'
if (workspace.view !== 'special') workspace.agendaCursor = 0
await workspace.toggleSpecial('agenda')
}),
},
{
@@ -765,7 +770,7 @@ export const commands: CommandSpec[] = [
help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back',
run: async (arg) => {
// On the board the thing under the cursor is a card, not a node.
if (workspace.view === 'board') {
if (workspace.view === 'special') {
const card = workspace.card
if (!card) return workspace.say('no card selected', 'error')
if (!(await workspace.ask(`delete the card "${card.title}"?`))) return
@@ -793,7 +798,7 @@ export const commands: CommandSpec[] = [
help: 'rename what is under the cursor — document, folder, or card on a board; bare, while writing, into the title',
run: async (arg) => {
if (!arg.trim() && workspace.editing && workspace.pane === 'document') return hooks.focusTitle()
if (workspace.view === 'board') {
if (workspace.view === 'special') {
if (!arg.trim()) return workspace.say('usage: :name <text>', 'error')
return workspace.guard(() => workspace.patchCard({ title: arg.trim() }))
}
@@ -814,7 +819,7 @@ export const commands: CommandSpec[] = [
bang: {
help: 'rename the space you are in — on the board, the list under the cursor',
run: async (arg) => {
if (workspace.view === 'board') {
if (workspace.view === 'special') {
if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error')
return workspace.guard(() => workspace.renameList(arg.trim()))
}
+58 -38
View File
@@ -23,8 +23,14 @@ import {
import { formatBytes, formatDay, formatStamp, formatWeekday, isTouchDevice, parseDay, sealedNote, type Heading } from './util'
export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
export type View = 'tree' | 'board'
/**
* The tree of documents, or the special workspace: the board of the space
* being browsed, the agenda and the calendar, tiled side by side and a key
* away from anywhere, like the desktop's scratchpad.
*/
export type View = 'tree' | 'special'
/** The tile of the special workspace that has focus. */
export type SpecialTile = 'board' | 'agenda' | 'calendar'
export type Flash = { text: string; kind: 'info' | 'error' } | null
/**
@@ -290,7 +296,9 @@ class Workspace {
pane = $state<Pane>('list')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks' | 'calendar'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'actions' | 'verify' | 'outline' | 'trash' | 'history' | 'backlinks'>('none')
/** Which of the special workspace's tiles the keys go to. */
specialFocus = $state<SpecialTile>('board')
/** `gO`: the headings of what is on screen, to jump between from the keyboard. */
outline = $state.raw<Heading[]>([])
outlineCursor = $state(0)
@@ -813,14 +821,14 @@ class Workspace {
this.cursor = Math.min(spot.cursor, Math.max(this.nodes.length - 1, 0))
}
}
// The board belongs to the space, so switching space while looking at one
// shows the new space's board — or drops back to the tree for a space that
// has none, rather than leaving the previous board on screen.
if (this.view === 'board') {
// The board belongs to the space, so switching space with the special
// workspace up shows the new space's board — or says it has none, rather
// than leaving the previous board on screen.
if (this.view === 'special') {
this.listCursor = 0
this.cardCursor = 0
if (this.space?.has_board) await this.reloadBoard(id, undefined)
else this.closeBoard()
else this.rawBoard = null
}
}
@@ -862,7 +870,7 @@ class Workspace {
async search(query: string, global = this.globalSearch) {
// The results are rows in the list, which the board would hide.
if (query && this.view === 'board') this.closeBoard()
if (query && this.view === 'special') this.closeSpecial()
this.query = query
this.globalSearch = global && query !== ''
this.cursor = 0
@@ -898,8 +906,8 @@ class Workspace {
const node = await api.getNode(id)
// What is asked for from the board — `:12`, `T`, a search — is shown, not
// opened behind it; closing a document goes back to the board.
if (this.view === 'board' && !underBoard) {
if (isContainer(node)) this.closeBoard()
if (this.view === 'special' && !underBoard) {
if (isContainer(node)) this.closeSpecial()
else {
this.view = 'tree'
this.returnTo = 'board'
@@ -1314,7 +1322,7 @@ class Workspace {
if (late + due === 0) return
localStorage.setItem('diary:agenda-seen', today)
this.agendaCursor = 0
this.overlay = 'agenda'
await this.openSpecial('agenda')
}
/** `C`: the calendar, on the day of the entry being read or else today. */
@@ -1322,8 +1330,7 @@ class Workspace {
const open = this.open
const reading = open && this.spaces.find((space) => space.id === open.path[0]?.id)?.role === 'diary'
this.calendarDay = formatDay(reading ? open.created_at : Math.floor(Date.now() / 1000))
await this.loadCalendar()
this.overlay = 'calendar'
await this.openSpecial('calendar')
}
async loadCalendar() {
@@ -1344,7 +1351,7 @@ class Workspace {
async openCalendarDay() {
const day = this.calendarDay
const entry = this.calendarEntries.find((e) => e.day === day)
this.overlay = 'none'
this.closeSpecial()
if (entry) return this.openPath(entry.path)
const home = this.spaces.find((space) => space.role === 'diary')
if (!home) return this.say('there is no diary to write in', 'error')
@@ -1363,12 +1370,12 @@ class Workspace {
this.say(entry.day)
}
/** Enter in the agenda: to the card, on its own board. */
/** Enter in the agenda: to the card, on its own board, beside the agenda. */
async goToCard(card: AgendaCard) {
this.overlay = 'none'
if (this.spaceId !== card.space_id) await this.enterSpace(card.space_id)
await this.reloadBoard(card.space_id, card.id)
this.view = 'board'
this.view = 'special'
this.specialFocus = 'board'
}
/** Space in the agenda: into that board's done list, from wherever it is. */
@@ -1377,7 +1384,7 @@ class Workspace {
if (!done) return
const moved = await api.moveCard(card.id, done.id, done.cards.length)
await this.loadAgenda()
if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard()
if (this.view === 'special' && this.spaceId === card.space_id) await this.reloadBoard()
this.say(doneSaid(moved))
}
@@ -1390,7 +1397,7 @@ class Workspace {
// It may have changed group; the cursor goes with it.
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard()
if (this.view === 'special' && this.spaceId === card.space_id) await this.reloadBoard()
return updated
}
@@ -1407,7 +1414,7 @@ class Workspace {
await this.loadAgenda()
const at = this.agendaCards.findIndex((c) => c.id === card.id)
if (at >= 0) this.agendaCursor = at
if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard()
if (this.view === 'special' && this.spaceId === card.space_id) await this.reloadBoard()
this.say(from + delta === lists.length - 1 ? doneSaid(moved) : `"${card.title}" → ${target.name}`)
}
@@ -1475,24 +1482,40 @@ class Workspace {
* where a card is would be worse than a round trip.
*/
async openBoard() {
/**
* The special workspace, with `focus` the tile the keys go to. Everything
* in it is read again on the way in: it is looked at in passing, and what
* it says should be what is true now.
*/
async openSpecial(focus: SpecialTile = this.specialFocus) {
const space = this.space
if (!space) return
if (!space.has_board) {
return this.say(`${space.slug} has no board — :set board turns one on`, 'error')
}
await this.reloadBoard(space.id)
this.view = 'board'
if (space?.has_board) await this.reloadBoard(space.id)
else this.rawBoard = null
if (!this.calendarDay) this.calendarDay = formatDay(Math.floor(Date.now() / 1000))
await Promise.all([this.loadAgenda(), this.loadCalendar()])
this.view = 'special'
this.specialFocus = focus
if (focus === 'board' && space && !space.has_board) this.say(`${space.slug} has no board — :set board turns one on`, 'error')
}
closeBoard() {
closeSpecial() {
this.view = 'tree'
this.returnTo = null
}
async toggleBoardView() {
if (this.view === 'board') this.closeBoard()
else await this.openBoard()
/**
* `b`, `t`, `C` and `S`: the special workspace on that tile. A key whose
* tile already has focus puts the workspace away again, as the desktop
* toggles its scratchpad; any other moves the focus there.
*/
async toggleSpecial(focus?: SpecialTile) {
if (this.view !== 'special') return this.openSpecial(focus)
if (focus === undefined || focus === this.specialFocus) return this.closeSpecial()
this.specialFocus = focus
}
async openBoard() {
await this.openSpecial('board')
}
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
@@ -1582,7 +1605,7 @@ class Workspace {
* it goes on the list under the cursor instead.
*/
async createCard(title: string, nodeId: number | null = null) {
if (this.view === 'board') {
if (this.view === 'special') {
const list = this.list
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
@@ -1704,7 +1727,7 @@ class Workspace {
this.closeTile()
this.pane = 'list'
await this.reloadBoard()
this.view = 'board'
this.view = 'special'
}
/** Turn a space's board on or off. The diary ships with it off. */
@@ -1714,10 +1737,7 @@ class Workspace {
await api.updateNode(space.id, { has_board: on })
this.spaces = await api.listSpaces()
if (on) await this.openBoard()
else {
this.closeBoard()
this.rawBoard = null
}
else this.rawBoard = null
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
}