Skip to main content

max / goingson

Describe the shell: where you can go, and the tab that stays lit The last thing between the port and the document swap. Every screen has been described since 2026-08-21 and nothing on the described side drew a tab bar, so `a0617c03`'s one-commit swap had nowhere to land. `quasi::shell` is the app's furniture. Its nav is `navigation.js`'s TAB_GROUPS and TAB_DEFAULTS transcribed rather than reinvented: Work over Tasks, Projects and Problems; Time over Day, Week, Month, Timer and Events; Messages over Email and Contacts; Settings beside them with nothing inside it. A tab's own action is the view it opens in the shipped app, because a group with nowhere to go would be a control that does nothing. Three of the eleven views in TAB_GROUPS are not places, each for its own reason and each written down. The dashboards and the task drawer are details reached from a row, and TAB_GROUPS only listed them to know which tab stays lit, which is what Screen::at_place says here instead. task-graph is bespoke and stays JavaScript, so it is left out rather than pointed at a route that does not exist and would be a NotFound the first time it was pressed. The board is not a view at all: index.html draws it behind a data-mode toggle inside Tasks, so it marks Tasks while it is showing. Sixteen screens name their place. The coherence check is the test worth reading: it walks every route that answers a screen and demands the nav hold the key it named. A key misspelled at either end is a tab that quietly never lights, the screen still renders and the nav still draws, and nothing else in this tree would see it. Two pieces of the header are unsaid and both are recorded on the module rather than dropped. The sync indicator is exactly a Role::Status panel and has nothing describable to put in one, because its content is settings-sync.js and every sync command awaits a network client. The `?` overlay needs a container quasi-webview emits only alongside a chrome binding, quasicoherent 858be2a6, and binding a key to a route that does not exist to obtain one is the trick that module already refuses. time_tracking::chrome takes the chrome and gives it back now, the way routes takes the router, since the band is no longer the whole of the furniture. The stylesheet draws the two levels the way the shipped header draws them, a tab row then an underline strip, per the 2026-07-28 call against two competing tab rows; it needs no positioning because the renderer emits the nav before the content. navigation.js is paired with the module in the drift table, at zero esc sites. It is the one file in the shell remainder a description replaces rather than makes redundant, and pairing it puts a tab added to the JS and not to the description in front of the check. Closes goingson 24aa5833 and quasicoherent 71aa29b4.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 16:23 UTC
Signed with PGP, not checked
Commit: 8ebbe29b736a9f82452a35c8d81c87c289d11914
Parent: c183ef2
21 files changed, +504 insertions, -31 deletions
@@ -9718,6 +9718,84 @@
9718 9718
9719 9719 /* 63. Chrome Panel (the described side's running-timer band) */
9720 9720
9721 +
9722 + /* --- The described shell's navigation ------------------------------------ */
9723 + /* quasi's Chrome::nav says what the places are and declines to say where they
9724 + sit, because a terminal has no header. A browser has a stylesheet, and this
9725 + is it. The two levels are drawn as the shipped header draws them: a tab row,
9726 + then an underline strip, which is the call made on 2026-07-28 about not
9727 + putting two competing "this contains what follows" claims on one screen.
9728 +
9729 + The markup is one nested list, because that is the structure the description
9730 + stated. The outer list is the tabs and each item's inner list is its places,
9731 + so only the current tab's strip is on screen and the rest are folded away by
9732 + the :has() below rather than by anything the app said.
9733 +
9734 + No positioning: the renderer emits the nav before <main>, so it is already
9735 + the first thing in the document and does not have to be lifted there. That
9736 + is the difference from .chrome-panel below, which is emitted after the
9737 + content because it is a band over it. */
9738 + .chrome-nav {
9739 + border-bottom: var(--border-width) solid var(--border);
9740 + padding: 0 var(--gap-page);
9741 + }
9742 +
9743 + .chrome-nav ul {
9744 + display: flex;
9745 + align-items: flex-end;
9746 + gap: var(--gap-bound);
9747 + margin: 0;
9748 + padding: 0;
9749 + list-style: none;
9750 + }
9751 +
9752 + .chrome-place {
9753 + display: block;
9754 + padding: var(--gap-peer) var(--gap-section);
9755 + text-decoration: none;
9756 + color: var(--content-secondary);
9757 + font-weight: 600;
9758 + }
9759 +
9760 + .chrome-place:hover {
9761 + color: var(--content);
9762 + }
9763 +
9764 + .chrome-place[aria-current="page"] {
9765 + color: var(--content);
9766 + }
9767 +
9768 + /* The second level, and only under the tab the user is on. A tab whose places
9769 + are not showing keeps them in the document: the description gave one tree and
9770 + hiding a branch is this stylesheet's answer, not a second description. */
9771 + .chrome-nav > ul > li > ul {
9772 + display: none;
9773 + }
9774 +
9775 + .chrome-nav > ul > li:has(> .chrome-place[aria-current="page"]) > ul {
9776 + display: flex;
9777 + gap: var(--gap-section);
9778 + border-top: var(--border-width) solid var(--border);
9779 + margin-top: var(--gap-peer);
9780 + }
9781 +
9782 + .chrome-nav > ul > li > ul .chrome-place {
9783 + padding: var(--gap-bound) var(--gap-peer);
9784 + border-bottom: calc(var(--border-width) * 2) solid transparent;
9785 + margin-bottom: calc(-1 * var(--border-width));
9786 + font-weight: 400;
9787 + }
9788 +
9789 + .chrome-nav > ul > li > ul .chrome-place[aria-current="page"] {
9790 + border-bottom-color: var(--action);
9791 + }
9792 +
9793 + /* Settings has no places inside it, so it is the one tab that can go to the
9794 + far end without leaving a gap in the strip under the others. */
9795 + .chrome-nav > ul > li:last-child {
9796 + margin-left: auto;
9797 + }
9798 +
9721 9799 /* Where the app's persistent panel sits. quasi's description declines to say,
9722 9800 because a terminal has no floating; a browser has a stylesheet, and this is
9723 9801 it. Same furniture as .timer-widget above, which is the JS widget this
@@ -9729,7 +9807,7 @@
9729 9807 an empty band must not draw a bar across the bottom of the screen. The clock
9730 9808 is the marker because it is what the running state is made of: no timer, no
9731 9809 readout, no panel. */
9732 - .chrome-panel {
9810 + .chrome-panel[data-role="activity"] {
9733 9811 position: fixed;
9734 9812 bottom: 0;
9735 9813 left: 0;
@@ -9741,7 +9819,7 @@
9741 9819 padding: var(--gap-peer) var(--gap-group);
9742 9820 }
9743 9821
9744 - .chrome-panel:not(:has([data-clock])) {
9822 + .chrome-panel[data-role="activity"]:not(:has([data-clock])) {
9745 9823 display: none;
9746 9824 }
9747 9825
@@ -216,7 +216,11 @@
216 216
217 217 /// The whole board.
218 218 fn board(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
219 + // The board is a mode of the Tasks place rather than a place of its
220 + // own: `index.html` draws it behind a `data-mode="board"` toggle inside
221 + // Tasks, so the Tasks tab stays lit while it is showing.
219 222 Ok(Screen::list_detail("Board", false)
223 + .at_place(super::shell::TASKS)
220 224 .with(board_region(state)?)
221 225 .into())
222 226 }
@@ -220,6 +220,7 @@
220 220 }
221 221
222 222 Ok(Screen::list_detail("Contacts", false)
223 + .at_place(super::shell::CONTACTS)
223 224 .with(band)
224 225 .with(Slot::new("contacts-grid", RegionKind::Pane).with(grid(state, search, tag)?))
225 226 .with(Slot::new("contacts-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
@@ -1028,7 +1028,10 @@
1028 1028
1029 1029 /// The whole screen.
1030 1030 fn screen(state: &AppState) -> Result<Screen, RouteError> {
1031 + // Reached from the settings sidebar, which is how a person gets here,
1032 + // so Settings is the place that stays lit. See `settings::Section::at`.
1031 1033 Ok(Screen::list_detail("Import & Export", false)
1034 + .at_place(super::shell::SETTINGS)
1032 1035 .with(Slot::new("data-band", RegionKind::Band).with(Node::page("Import & Export")))
1033 1036 .with(import_region())
1034 1037 .with(Slot::new(PREVIEW, RegionKind::Pane).with(no_preview()))
@@ -302,6 +302,7 @@
302 302 axis = axis.with(timeline(&response, date));
303 303
304 304 Ok(Screen::list_detail("Day", false)
305 + .at_place(super::shell::DAY)
305 306 .with(band(date, &response))
306 307 .with(axis)
307 308 // The sidebar's tracked-time panel, which is `super::time_tracking`'s
@@ -686,6 +686,7 @@
686 686 };
687 687
688 688 Ok(Screen::list_detail("Emails", false)
689 + .at_place(super::shell::EMAILS)
689 690 .selecting(SELECTION)
690 691 .with(band)
691 692 .with(Slot::new(LIST, RegionKind::Pane).with(list(state, view, open)?))
@@ -232,6 +232,7 @@
232 232 }
233 233
234 234 Ok(Screen::list_detail("Events", false)
235 + .at_place(super::shell::EVENTS)
235 236 .with(band)
236 237 .with(pane)
237 238 .with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
@@ -42,7 +42,7 @@
42 42 //! exist and both are counted. The count starts falling at the flip. Progress is
43 43 //! the first list, not the number.
44 44 //!
45 - //! ## Described, and retires at the flip (35 files, 259 sites)
45 + //! ## Described, and retires at the flip (36 files, 259 sites)
46 46 //!
47 47 //! | Module | JS counterpart | Sites |
48 48 //! |---|---|---|
@@ -60,10 +60,19 @@
60 60 //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 |
61 61 //! | [`time_tracking`] | `time-tracking.js` 6, `time-summary.js` 3 | 9 |
62 62 //! | [`events`] | `events.js` | 10 |
63 + //! | [`shell`] | `navigation.js` 0 | 0 |
63 64 //!
64 65 //! Two rows carry a second screen as a submodule: [`projects`] its dashboard,
65 - //! and [`settings`] its email accounts section. So the fourteen rows here are
66 - //! sixteen described screens.
66 + //! and [`settings`] its email accounts section. So the fifteen rows here are
67 + //! sixteen described screens and one that is no screen at all.
68 + //!
69 + //! [`shell`] is that last one, and it is the row that carries no `esc()` sites.
70 + //! It describes the app's furniture rather than a screen: the tabs, the places
71 + //! under them, and the running-timer band it asks [`time_tracking`] for.
72 + //! `navigation.js` is paired with it because that is the file the nav replaces,
73 + //! and pairing it is what puts a tab added to the JS and not to the description
74 + //! in front of the drift check. Its zero is honest: the file concatenates
75 + //! nothing, which is why the shell was invisible to this page until 2026-08-22.
67 76 //!
68 77 //! [`time_tracking`] is the one row here that is two things: the app's chrome,
69 78 //! described because the floating widget belongs to no screen, and the Timer
@@ -159,30 +168,39 @@
159 168 //! Together with the 40 above, these 67 sites are what the done-condition
160 169 //! actually has to answer for. Porting every candidate screen leaves them.
161 170 //!
162 - //! ## The shell, which no row above reaches (38 files, 9,114 lines)
171 + //! ## The shell, which no row above reaches (37 files, 8,569 lines)
163 172 //!
164 173 //! Measured 2026-08-22, and it is the reason a table that looks complete is
165 174 //! not. The four categories above account for every `esc()` call site in the
166 - //! tree and for 49 of the 87 files under `frontend/js/`. The other 38 are 9,114
175 + //! tree and for 50 of the 87 files under `frontend/js/`. The other 37 are 8,569
167 176 //! lines and appear in no category, because the accounting here is `esc()`-
168 177 //! centric and the shell concatenates almost nothing: it wires, routes, and
169 178 //! switches panes. A file with no call site was invisible to every count on
170 179 //! this page, so the remainder never read as missing.
171 180 //!
181 + //! `navigation.js` left this list the same day, for [`shell`]'s row above. It
182 + //! is the only one of them a description replaces rather than makes redundant:
183 + //! the rest go when `index.html` goes.
184 + //!
172 185 //! The largest of them, and what each is: `utils.js` 704, `components.js` 594
173 - //! (context menu, action sheet), `navigation.js` 545 (view switching, the tab
174 - //! bar, window title), `api.js` 500, `app.js` 471 (bootstrap, the Tauri menu),
186 + //! (context menu, action sheet), `api.js` 500, `app.js` 471 (bootstrap, the
187 + //! Tauri menu),
175 188 //! `virtual-scroller.js` 453, `mobile.js` 447 and `touch.js` 444,
176 189 //! `keyboard.js` 279 (the shortcut table and the help overlay), `themes.js`
177 190 //! 277, `router.js` 107, `dispatch.js` 109, `state.js` 158.
178 191 //!
179 - //! Nothing on the described side draws any of it. [`quasi_router::Chrome`]
180 - //! carries key bindings and one panel, and goingson declares the panel as
181 - //! [`time_tracking::chrome`], the running-timer band. That is the whole of the
182 - //! app furniture that exists on this side. A tab bar is a second always-present
183 - //! thing and there is no second panel, which is a vocabulary question rather
184 - //! than a porting one: quasicoherent `71aa29b4`, with the goingson half on
185 - //! `24aa5833`.
192 + //! The navigation is described as of 2026-08-22 and the rest of that list is
193 + //! not. [`quasi_router::Chrome`] grew `nav` and roled panels under
194 + //! quasicoherent `71aa29b4`, ruled (e) by Max, and [`shell`] is this app's
195 + //! answer: the three tabs, the places under them, Settings, and the band.
196 + //!
197 + //! Two pieces of the shipped header are still unsaid, and neither is a gap in
198 + //! the nav. The sync indicator wants a `Role::Status` panel and has nothing
199 + //! describable to put in one, because its content is `settings-sync.js` and
200 + //! that stays JavaScript for the reason the by-decision list gives. The `?`
201 + //! shortcut overlay wants a container quasi-webview emits only alongside a
202 + //! chrome binding, which is quasicoherent `858be2a6`. [`shell`]'s header
203 + //! records both.
186 204 //!
187 205 //! Four files in the remainder are screen controllers rather than shell, and
188 206 //! are candidates for a pairing row once somebody reads them against the
@@ -274,6 +292,7 @@
274 292 pub mod problems;
275 293 pub mod projects;
276 294 pub mod settings;
295 + pub mod shell;
277 296 pub mod task_list;
278 297 pub mod tasks;
279 298 pub mod time_tracking;
@@ -521,10 +540,9 @@
521 540 // Not vendored, so asking for it would be one 404 per
522 541 // document. Nothing described here uses it yet.
523 542 .without_hyperscript()
524 - // The running-timer band, which belongs to the app rather
525 - // than to any screen it is drawn over. See
526 - // [`time_tracking`].
527 - .with_chrome(time_tracking::chrome()),
543 + // The app's own furniture: where you can go, and the
544 + // running-timer band. See [`shell`].
545 + .with_chrome(shell::chrome()),
528 546 ),
529 547 ),
530 548 );
@@ -504,6 +504,7 @@
504 504 pane = pane.extend(reflection(&data, month));
505 505
506 506 Ok(Screen::sidebar_content("Monthly Review")
507 + .at_place(super::shell::MONTH)
507 508 .with(band)
508 509 .with(pane))
509 510 }
@@ -411,6 +411,7 @@
411 411 }
412 412
413 413 Ok(Screen::list_detail("Problems", false)
414 + .at_place(super::shell::PROBLEMS)
414 415 .with(band)
415 416 .with(Slot::new("problems-list", RegionKind::Pane).with(ranked(state, status, source)?))
416 417 .into())
@@ -317,6 +317,7 @@
317 317 }
318 318
319 319 Ok(Screen::list_detail("Projects", false)
320 + .at_place(super::shell::PROJECTS)
320 321 .with(band)
321 322 .with(Slot::new("projects-grid", RegionKind::Pane).with(grid(
322 323 state,
@@ -487,7 +487,10 @@
487 487 _ => appearance(state, &config),
488 488 });
489 489
490 - Ok(Screen::sidebar_content("Settings").with(nav).with(pane))
490 + Ok(Screen::sidebar_content("Settings")
491 + .at_place(super::shell::SETTINGS)
492 + .with(nav)
493 + .with(pane))
491 494 }
492 495
493 496 /// Appearance, which is where the JS opens.
@@ -1095,6 +1095,7 @@
1095 1095 .extend(filters(state, &view)?);
1096 1096
1097 1097 Ok(Screen::list_detail("Tasks", false)
1098 + .at_place(super::shell::TASKS)
1098 1099 .selecting(SELECTION)
1099 1100 .with(band)
1100 1101 .with(Slot::new("tasks-bulk", RegionKind::Band).extend(bulk(state, &view)?))