diff --git a/README.md b/README.md index 3c97c23..314f358 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/images/agenda.png b/docs/images/agenda.png index b3cf591..04bd995 100644 Binary files a/docs/images/agenda.png and b/docs/images/agenda.png differ diff --git a/docs/images/board.png b/docs/images/board.png index 64bd84a..e5a2a74 100644 Binary files a/docs/images/board.png and b/docs/images/board.png differ diff --git a/docs/images/calendar.png b/docs/images/calendar.png index 2222752..37d03d3 100644 Binary files a/docs/images/calendar.png and b/docs/images/calendar.png differ diff --git a/docs/images/document.png b/docs/images/document.png index ebdc38d..bd96b9e 100644 Binary files a/docs/images/document.png and b/docs/images/document.png differ diff --git a/docs/images/editor.png b/docs/images/editor.png index 9e4c6fa..f7d87b9 100644 Binary files a/docs/images/editor.png and b/docs/images/editor.png differ diff --git a/docs/images/file-certificate.png b/docs/images/file-certificate.png index 712c231..17ab278 100644 Binary files a/docs/images/file-certificate.png and b/docs/images/file-certificate.png differ diff --git a/docs/images/file-table.png b/docs/images/file-table.png index cce4063..54c12e7 100644 Binary files a/docs/images/file-table.png and b/docs/images/file-table.png differ diff --git a/docs/images/help.png b/docs/images/help.png index b079701..13ad68a 100644 Binary files a/docs/images/help.png and b/docs/images/help.png differ diff --git a/docs/images/history.png b/docs/images/history.png index 6060872..8e3aa67 100644 Binary files a/docs/images/history.png and b/docs/images/history.png differ diff --git a/docs/images/login.png b/docs/images/login.png index f2f1743..28f4324 100644 Binary files a/docs/images/login.png and b/docs/images/login.png differ diff --git a/docs/images/math.png b/docs/images/math.png index ec3ea04..fe28e7b 100644 Binary files a/docs/images/math.png and b/docs/images/math.png differ diff --git a/docs/images/mobile-document.png b/docs/images/mobile-document.png index c39ebe4..a665ef0 100644 Binary files a/docs/images/mobile-document.png and b/docs/images/mobile-document.png differ diff --git a/docs/images/mobile-tree.png b/docs/images/mobile-tree.png index 4d886ab..41a230f 100644 Binary files a/docs/images/mobile-tree.png and b/docs/images/mobile-tree.png differ diff --git a/docs/images/overview.png b/docs/images/overview.png index e48e6b5..642c6a4 100644 Binary files a/docs/images/overview.png and b/docs/images/overview.png differ diff --git a/docs/images/search.png b/docs/images/search.png index 39d997f..b9e625a 100644 Binary files a/docs/images/search.png and b/docs/images/search.png differ diff --git a/docs/images/tiles.png b/docs/images/tiles.png index f37b61f..3d73d9c 100644 Binary files a/docs/images/tiles.png and b/docs/images/tiles.png differ diff --git a/docs/wiki/Boards-and-Agenda.md b/docs/wiki/Boards-and-Agenda.md index 7b4652d..67de9ba 100644 --- a/docs/wiki/Boards-and-Agenda.md +++ b/docs/wiki/Boards-and-Agenda.md @@ -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 diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index d2171a8..651e607 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -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! `, `: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 ` | 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 | diff --git a/docs/wiki/Diary.md b/docs/wiki/Diary.md index b8eec0e..4f740de 100644 --- a/docs/wiki/Diary.md +++ b/docs/wiki/Diary.md @@ -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. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 0e1e031..cbaba9b 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -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. diff --git a/docs/wiki/Guide.md b/docs/wiki/Guide.md index fa1db4c..aa2fb2b 100644 --- a/docs/wiki/Guide.md +++ b/docs/wiki/Guide.md @@ -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) diff --git a/web/src/App.svelte b/web/src/App.svelte index 686496d..43941cc 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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> diff --git a/web/src/lib/Agenda.svelte b/web/src/lib/Agenda.svelte index 79c71c7..c55b407 100644 --- a/web/src/lib/Agenda.svelte +++ b/web/src/lib/Agenda.svelte @@ -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} diff --git a/web/src/lib/Bar.svelte b/web/src/lib/Bar.svelte index 65c5fcc..43306e5 100644 --- a/web/src/lib/Bar.svelte +++ b/web/src/lib/Bar.svelte @@ -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" diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 93a44f2..90aff30 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -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 <name> 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 <name> 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; diff --git a/web/src/lib/Calendar.svelte b/web/src/lib/Calendar.svelte index 8de51c4..54ff604 100644 --- a/web/src/lib/Calendar.svelte +++ b/web/src/lib/Calendar.svelte @@ -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"> diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 2a57dc1..057f2d6 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -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> = { diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 1949306..df4e8db 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -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 ') }, ], diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 56fc801..aa52b0d 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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())) } diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 85571e0..1ea1490 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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`) }