feat(shell): run the docked strips the full height and make them the only fold

The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.

The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
This commit is contained in:
Marc-André Bombeck committed 2026-10-10 20:04:19 +02:00
1 parent 023115b05c
commit 39fc4e4da2
26 files changed
+561 -229

No files matched your search

@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { visibleNavDestinations } from "@/components/layout/nav-model";
import {
DAY_TOGGLE_KEY,
GO_TO_SHORTCUTS,
SEQUENCE_TIMEOUT_MS,
blockedByModifier,
createShortcutReader,
@@ -181,6 +183,40 @@ describe("sequence timing", () => {
});
});
describe("g p, the day", () => {
it("opens or closes the day from the page", () => {
const reader = createShortcutReader();
expect(reader.read(key("g"), "page", 0)).toBe("pending");
expect(reader.read(key("p"), "page", 10)).toEqual({ type: "day-toggle" });
});
it("collides with no destination", () => {
expect(DAY_TOGGLE_KEY).toBe("p");
const keys = GO_TO_SHORTCUTS.map((s) => s.key as string);
expect(keys).not.toContain(DAY_TOGGLE_KEY);
expect(new Set(keys).size).toBe(keys.length);
});
it("needs the g, within the window", () => {
const reader = createShortcutReader();
expect(reader.read(key("p"), "page", 0)).toBeNull();
reader.read(key("g"), "page", 0);
expect(reader.read(key("p"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull();
});
it("is not read from a field, under a modifier or over another dialog", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
expect(reader.read(key("p", { target: input }), "page", 10)).toBeNull();
reader.read(key("g"), "page", 20);
expect(reader.read(key("p", { metaKey: true }), "page", 30)).toBeNull();
reader.read(key("g"), "page", 40);
expect(reader.read(key("p"), "off", 50)).toBeNull();
expect(reader.read(key("g"), "off", 60)).toBeNull();
expect(reader.read(key("p"), "page", 70)).toBeNull();
});
});
describe("scope", () => {
it("an open dialog, menu or popover takes every key", () => {
const reader = createShortcutReader();
@@ -191,7 +227,7 @@ describe("scope", () => {
expect(reader.read(key("d"), "off", 10)).toBeNull();
});
it("over the day sheet only [ and ] act", () => {
it("over the day sheet only [, ] and g p act", () => {
const reader = createShortcutReader();
expect(reader.read(key("["), "day-sheet", 0)).toEqual({
type: "day",
@@ -202,7 +238,14 @@ describe("scope", () => {
delta: 1,
});
expect(reader.read(key("n"), "day-sheet", 0)).toBeNull();
expect(reader.read(key("g"), "day-sheet", 0)).toBeNull();
expect(reader.read(key("?"), "day-sheet", 0)).toBeNull();
// g waits, but only p completes it there: no page leaves under a sheet.
expect(reader.read(key("g"), "day-sheet", 0)).toBe("pending");
expect(reader.read(key("d"), "day-sheet", 10)).toBeNull();
reader.read(key("g"), "day-sheet", 20);
expect(reader.read(key("p"), "day-sheet", 30)).toEqual({
type: "day-toggle",
});
});
it("Escape is never ours", () => {
+27 -7
View File
@@ -15,7 +15,8 @@
* Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because
* for that person there is no other way to press it;
* - while a dialog, sheet, menu or popover is open the keys are its own,
* except over the day sheet, where `[` and `]` step the day it shows.
* except over the day sheet, where `[` and `]` step the day it shows and
* `g p` closes it.
*
* `g` starts a two-key sequence; the second key has to follow within
* `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes
@@ -43,17 +44,25 @@ export const GO_TO_SHORTCUTS = [
export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"];
/**
* `g` + `p` opens or closes the day, the way a click on the day's strip does
* (a sheet below the docking width). Not a destination, and no `g` key above.
*/
export const DAY_TOGGLE_KEY = "p";
export type ShortcutAction =
| { type: "go"; key: GoToKey }
| { type: "palette" }
| { type: "capture" }
| { type: "help" }
| { type: "day"; delta: -1 | 1 };
| { type: "day"; delta: -1 | 1 }
| { type: "day-toggle" };
/**
* Where the keyboard is:
* - `page`: nothing modal is open, every shortcut applies;
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply;
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` and
* `g p` apply;
* - `off`: another dialog, sheet, menu or popover has the keys.
*/
export type ShortcutScope = "page" | "day-sheet" | "off";
@@ -205,12 +214,23 @@ export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) {
if (key === "[" || key === "]") {
return { type: "day", delta: key === "[" ? -1 : 1 };
}
if (scope !== "page") return null;
if (pending !== null && now - pending <= timeoutMs) {
// The second key of a sequence: a destination, or nothing at all, so
// a mistyped `g n` does not open the add menu.
const inSequence = pending !== null && now - pending <= timeoutMs;
if (scope !== "page") {
// Over the day sheet only `g p` (which closes it) is a sequence.
if (inSequence && key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
if (key === "g") {
pendingSince = now;
return "pending";
}
return null;
}
if (inSequence) {
// The second key of a sequence: a destination, the day, or nothing at
// all, so a mistyped `g n` does not open the add menu.
if (isGoToKey(key)) return { type: "go", key };
if (key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
if (key !== "g") return null;
}
if (key === "g") {