feat(shell): give each docked panel a fixed strip at the right edge

From 1280 px the day and, on /coach, the conversations each keep a
narrow strip at the right edge, open or not. A click on the strip opens
its panel left of the strips and a second click closes it; the strip
never moves. Strips start below the top bar's band, side by side,
conversations first. Below 1600 px one panel is open at a time.

The day slides like the conversations (width, 200 ms, none under reduced
motion), stays open through a page change instead of folding and
reopening, and does not slide in on arrival. Its content loads when a day
first opens, so the scores, sections and calendar leave the shell's first
load: every watched route falls back under its unchanged ceiling. The
aggregate cap is re-measured for the chunk double count this causes.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 18:22:11 +02:00
1 parent 41a7f4daea
commit d8fb922228
17 files changed
+860 -320

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
"use client";
import type { DateKey } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
/**
* "Saturday, 3 January 2026", for the day's header, its spoken name and its
* strip. Its own module, so the shell's day layer can name a day without
* loading the day view.
*/
export function useLongDayLabel(
length: "long" | "short" = "long",
): (date: DateKey) => string {
const { locale } = useTranslations();
const intl = resolveIntlLocale(locale);
return (date: DateKey) =>
new Intl.DateTimeFormat(intl, {
weekday: length,
day: "numeric",
month: length,
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00.000Z`));
}
+179 -113
View File
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { usePathname, useSearchParams } from "next/navigation";
import {
Suspense,
@@ -12,11 +13,17 @@ import {
useSyncExternalStore,
} from "react";
import { PanelRightOpen } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
DOCK_SLIDE,
DOCK_SLIDE_MS,
DockStrip,
ShellStrip,
useLingering,
} from "@/components/layout/shell-dock";
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
@@ -48,13 +55,14 @@ import {
takeDayYield,
useDayFocus,
} from "./day-layer-controller";
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
import {
DayView,
PANEL_TOGGLE,
PANEL_TOGGLE_ICON,
useLongDayLabel,
} from "./day-view";
parseDayParam,
shiftDateKey,
stripDayOf,
withDayHref,
} from "./day-url";
import { useLongDayLabel } from "./day-label";
import type { DayViewProps } from "./day-view";
import { usePrefetchDay } from "./use-day";
import { useTodayKey } from "./use-today-key";
@@ -67,10 +75,12 @@ import { useTodayKey } from "./use-today-key";
* - from 1280 px, a column docked beside the page, the same place and the
* same breakpoint as the Coach's conversations panel. Not modal: the
* chart beside it stays usable, and another point swaps the day. It is a
* landmark (`complementary`) named by the date. It collapses like a
* sidebar instead of going away: hidden, a narrow edge stays on the
* right with a button that brings the last day back, on this page or any
* other (`last-day.ts` remembers it per browser and account).
* landmark (`complementary`) named by the date. Its strip stays at the
* right edge of the window whether the day is open or not
* (`shell-dock.tsx`): it names the day and opens or closes it, on this
* page or any other (`last-day.ts` remembers the day per browser and
* account; with nothing remembered the strip holds today). The column
* slides open and shut left of the strip the way the conversations do.
* - from 768 px, a sheet from the right, modal.
* - on a phone, a sheet from the bottom at a bit over half the height, so
* the chart stays in view above it; dragging the handle up (or tapping
@@ -78,14 +88,36 @@ import { useTodayKey } from "./use-today-key";
*
* On `/coach` the day docks too, right of the Coach's conversations: page,
* conversations, day. Below 1600 px only one of the two is open at a time;
* opening the day folds the conversations to their edge, and opening the
* conversations folds the day to its edge (`yieldDay`).
* opening the day closes the conversations, and opening the conversations
* closes the day (`yieldDay`).
*
* The parameter is the state. A date that is not a calendar date, or lies in
* the future, is removed from the URL without a word. Collapsing the docked
* day removes the parameter like closing does; only the remembered date
* stays, and expanding opens it again.
*/
export const DAY_PANEL_ID = "day-docked-panel";
/**
* The day's content loads when a day first opens (or when the pointer
* reaches the strip), not with the shell: the scores, the calendar and the
* sections are no part of a page until a day is open. Until it arrives the
* frame shows its header band and nothing else.
*/
const loadDayView = () => import("./day-view").then((m) => m.DayView);
function preloadDayView() {
void loadDayView();
}
const DayView = dynamic(loadDayView, {
ssr: false,
loading: () => (
<div
data-slot="day-view-loading"
className={cn(SHELL_HEADER_BAND, "border-border shrink-0")}
/>
),
});
export function DayLayerMount() {
// `useSearchParams` suspends a statically rendered route until the client
// has the URL; the boundary keeps that from blanking the page around it.
@@ -205,19 +237,48 @@ function DayLayer() {
const shortLabel = useLongDayLabel("short");
const { t } = useTranslations();
const titleId = useId();
const titleRef = useRef<HTMLHeadingElement>(null);
const titleRef = useRef<HTMLHeadingElement | null>(null);
// Focus asked for before the day's content had loaded: the heading takes
// it the moment it mounts.
const titleFocusPending = useRef(false);
const attachTitle = useCallback((el: HTMLHeadingElement | null) => {
titleRef.current = el;
if (el && titleFocusPending.current) {
titleFocusPending.current = false;
el.focus({ preventScroll: true });
}
}, []);
const focusTitle = useCallback(() => {
const el = titleRef.current;
if (el) el.focus({ preventScroll: true });
else titleFocusPending.current = true;
}, []);
const panelRef = useRef<HTMLElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// Set by the collapse control: focus then lands on the control that
// brings the day back, in the same place, as a sidebar's toggle does.
const stripRef = useRef<HTMLButtonElement>(null);
// Set by a fold (the header's control, the strip): focus then lands on the
// strip, which brings the day back from the same place.
const collapsing = useRef(false);
// The collapsed edge: the docked frame only, and only with a day to bring
// back that is still a day the layer would open.
// The remembered day, while the docked day is shut, if it is still a day
// the layer would open.
const railDay =
shell === "docked" && date === null && lastDay !== null
? parseDayParam(lastDay, today)
: null;
// A page change drops `?day=` before the new page puts the remembered day
// back (below); a day left open stays open through that gap instead of
// sliding shut and open again.
const heldOpen = shell === "docked" && date === null && leftOpen;
const dockedDay =
shell === "docked" ? (date ?? (heldOpen ? railDay : null)) : null;
// The docked column keeps painting the day it showed while it slides shut.
const shownDay = useLingering(dockedDay);
// No slide on arrival: a day restored with the page is simply there.
const [settled, setSettled] = useState(false);
useEffect(() => {
const id = requestAnimationFrame(() => setSettled(true));
return () => cancelAnimationFrame(id);
}, []);
const collapse = useCallback(() => {
collapsing.current = true;
@@ -253,7 +314,7 @@ function DayLayer() {
// (on the arrow the person pressed), and so does a day restored on a
// new page.
if (before === null && shell === "docked" && !restoring.current) {
titleRef.current?.focus({ preventScroll: true });
focusTitle();
}
restoring.current = false;
return;
@@ -272,7 +333,7 @@ function DayLayer() {
collapsing.current = false;
return;
}
const rail = expandRef.current;
const rail = stripRef.current;
if (collapsing.current && rail) {
takeDayTrigger();
rail.focus({ preventScroll: true });
@@ -281,7 +342,7 @@ function DayLayer() {
}
}
collapsing.current = false;
}, [date, shell, owner]);
}, [date, shell, owner, focusTitle]);
const onStep = useCallback(
(delta: number) => {
@@ -347,71 +408,13 @@ function DayLayer() {
</p>
);
if (date === null) {
if (railDay === null) return live;
const label = t("day.showPanel", { date: longLabel(railDay) });
const expand = () =>
openDay(railDay, { trigger: expandRef.current ?? undefined });
return (
<>
{live}
<ShellSidePanel>
<div
data-slot="day-rail"
data-day={railDay}
className="bg-card text-card-foreground border-border order-2 flex h-full w-12 shrink-0 flex-col border-l"
>
{/* The top bar's band, so the two bottom borders draw one line;
the control sits where the open day keeps its own. */}
<div
className={cn(
SHELL_HEADER_BAND,
"border-border flex shrink-0 items-center justify-center",
)}
>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
data-slot="day-expand"
aria-expanded={false}
aria-label={label}
title={label}
onClick={expand}
className={PANEL_TOGGLE}
>
<PanelRightOpen
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</div>
{/* The rest of the edge takes a click too and names the day it
holds; the keyboard has the button above. */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={expand}
className="hover:bg-muted/60 text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
>
<span className="[writing-mode:vertical-rl]">
{shortLabel(railDay)}
</span>
</button>
</div>
</ShellSidePanel>
</>
);
}
const view = (
Title: Parameters<typeof DayView>[0]["Title"],
extra?: Partial<Parameters<typeof DayView>[0]>,
shown: DateKey,
Title: DayViewProps["Title"],
extra?: Partial<DayViewProps>,
) => (
<DayView
date={date}
date={shown}
today={today}
focus={focus}
shell={shell}
@@ -420,46 +423,92 @@ function DayLayer() {
onPick={onPick}
Title={Title}
titleId={titleId}
titleRef={titleRef}
titleRef={attachTitle}
{...extra}
/>
);
if (shell === "docked") {
// What the strip holds: the open day, else the remembered one, else today.
const stripDay = stripDayOf(date, railDay, today);
const open = dockedDay !== null;
const toggle = () => {
if (open) {
collapse();
return;
}
openDay(stripDay, { trigger: stripRef.current ?? undefined });
};
return (
<>
{live}
<ShellSidePanel>
<aside
ref={panelRef}
aria-labelledby={titleId}
data-slot="day-panel"
data-shell="docked"
// A short fade, not a slide: the column takes its width at once
// (the page beside it reflows in the same frame either way), so
// only its content eases in. None under reduced motion.
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
<div
id={DAY_PANEL_ID}
data-slot="day-dock"
data-state={open ? "open" : "closed"}
className={cn(
DOCK_SLIDE,
"order-2",
open ? "w-105" : "w-0",
!settled && "transition-none",
)}
>
{view(DockedTitle, {
// No status-bar inset here: the docked column sits inside
// the app shell, which takes it (`shell-safe-area`).
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
})}
</aside>
{shownDay !== null ? (
<aside
ref={panelRef}
aria-labelledby={titleId}
data-slot="day-panel"
data-shell="docked"
data-state={open ? "open" : "closing"}
// Sliding shut, it paints the day it showed and takes no
// input.
inert={!open}
className="bg-card text-card-foreground border-border absolute inset-y-0 left-0 flex w-105 flex-col border-l"
>
{view(shownDay, DockedTitle, {
// No status-bar inset here: the docked column sits inside
// the app shell, which takes it (`shell-safe-area`).
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
})}
</aside>
) : null}
</div>
</ShellSidePanel>
<ShellStrip>
<DockStrip
ref={stripRef}
slot="day-strip"
order={2}
controls={DAY_PANEL_ID}
expanded={open}
label={shortLabel(stripDay)}
actionLabel={
open
? t("day.hidePanel")
: t("day.showPanel", { date: longLabel(stripDay) })
}
icon={CalendarDays}
onToggle={toggle}
onPreload={preloadDayView}
data={{ "data-day": stripDay }}
/>
</ShellStrip>
</>
);
}
if (date === null) return live;
return (
<>
{live}
<DaySheet
shell={shell}
panelRef={panelRef}
titleRef={titleRef}
focusTitle={focusTitle}
onDismiss={closeByPerson}
render={(extra) => view(SheetTitle, extra)}
render={(extra) => view(date, SheetTitle, extra)}
/>
</>
);
@@ -472,10 +521,13 @@ function DayLayer() {
* and there the chart the person clicked slid away under the pointer by the
* height the reflow added above it. Measured after layout, so where the
* browser already anchored, the correction is zero and nothing moves twice.
* The column slides, so the page reflows on every frame of the slide; the
* correction follows it frame by frame until the slide has run.
*/
function useKeepAnchorInView(active: boolean) {
const anchor = useRef<{ el: HTMLElement; top: number } | null>(null);
const wasActive = useRef(false);
const frame = useRef(0);
// Before paint, so the corrected position is the first one drawn.
useLayoutEffect(() => {
@@ -491,15 +543,28 @@ function useKeepAnchorInView(active: boolean) {
? { el: opened.el, top: opened.top }
: null;
}
const a = anchor.current;
if (a && a.el.isConnected) {
const delta = a.el.getBoundingClientRect().top - a.top;
if (Math.abs(delta) > 1) main.scrollTop += delta;
a.top = a.el.getBoundingClientRect().top;
}
const held = anchor.current;
if (!active) anchor.current = null;
if (!held) return;
const correct = () => {
if (!held.el.isConnected) return;
const delta = held.el.getBoundingClientRect().top - held.top;
if (Math.abs(delta) > 1) main.scrollTop += delta;
held.top = held.el.getBoundingClientRect().top;
};
correct();
cancelAnimationFrame(frame.current);
const until = performance.now() + DOCK_SLIDE_MS + 50;
const tick = () => {
correct();
if (performance.now() < until)
frame.current = requestAnimationFrame(tick);
};
frame.current = requestAnimationFrame(tick);
}, [active]);
useEffect(() => () => cancelAnimationFrame(frame.current), []);
// While the column is open, the page may scroll; the anchor follows.
useEffect(() => {
if (!active) return;
@@ -552,16 +617,17 @@ function returnFocus(fallback?: HTMLElement | null) {
function DaySheet({
shell,
panelRef,
titleRef,
focusTitle,
onDismiss,
render,
}: {
shell: "sheet" | "bottom";
panelRef: React.RefObject<HTMLElement | null>;
titleRef: React.RefObject<HTMLHeadingElement | null>;
/** Focus the day's heading, now or as soon as it has loaded. */
focusTitle: () => void;
/** The person closed the sheet (a swipe down, Escape, the overlay). */
onDismiss: () => void;
render: (extra: Partial<Parameters<typeof DayView>[0]>) => React.ReactNode;
render: (extra: Partial<DayViewProps>) => React.ReactNode;
}) {
const { t } = useTranslations();
const [full, setFull] = useState(false);
@@ -625,7 +691,7 @@ function DaySheet({
data-full={bottom && full ? "true" : undefined}
onOpenAutoFocus={(event) => {
event.preventDefault();
titleRef.current?.focus({ preventScroll: true });
focusTitle();
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
+13
View File
@@ -55,6 +55,19 @@ export function parseDayParam(
return isOpenableDay(raw, todayKey) ? raw : null;
}
/**
* The day the docked day's strip holds: the open day, else the remembered
* one while the layer would still open it, else today. The strip always
* names a day, so it always has something to open.
*/
export function stripDayOf(
open: DateKey | null,
remembered: string | null,
todayKey: DateKey,
): DateKey {
return open ?? parseDayParam(remembered, todayKey) ?? todayKey;
}
/** `key` moved by `delta` calendar days. */
export function shiftDateKey(key: DateKey, delta: number): DateKey {
const anchor = new Date(`${key}T12:00:00.000Z`);
+1 -17
View File
@@ -26,10 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useLongDayLabel } from "./day-label";
import { DayDatePicker } from "./day-date-picker";
import type { DayFocus } from "./day-layer-controller";
import {
@@ -55,22 +55,6 @@ import { useDayValueFormat } from "./use-day-value-format";
* three frames cannot drift apart.
*/
/** "Saturday, 3 January 2026", for the header and the spoken name. */
export function useLongDayLabel(
length: "long" | "short" = "long",
): (date: DateKey) => string {
const { locale } = useTranslations();
const intl = resolveIntlLocale(locale);
return (date: DateKey) =>
new Intl.DateTimeFormat(intl, {
weekday: length,
day: "numeric",
month: length,
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${date}T12:00:00.000Z`));
}
export interface DayViewProps {
date: DateKey;
today: DateKey;
@@ -61,7 +61,7 @@ function VerbLabel({ children }: { children: string }) {
// Icon-only again while a day is docked: the bar then shares the width
// with the day panel and the labelled row would no longer fit one line.
return (
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
<span className="hidden lg:inline [body:has([data-slot=day-strip][data-state=open])_&]:hidden">
{children}
</span>
);
@@ -116,10 +116,11 @@ export function DocumentBulkBar({
// the bar shows, which is the shell's call, not a width's: a phone
// held sideways is wider than `md` and still has the bar.
"bg-card border-border shell-desktop:bottom-6 fixed bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] left-1/2 z-40 -translate-x-1/2",
// v1.42 — while a day is docked on the right, the bar sits against
// the page's right edge instead of the window's centre, clear of the
// day's footer actions and of an expanded sidebar.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
// v1.42 — while a day is docked on the right (its strip open), the
// bar sits against the page's right edge instead of the window's
// centre, clear of the day's footer actions, its strip and an
// expanded sidebar.
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+1rem)] [body:has([data-slot=day-strip][data-state=open])_&]:left-auto [body:has([data-slot=day-strip][data-state=open])_&]:max-w-[calc(100vw-46.75rem)] [body:has([data-slot=day-strip][data-state=open])_&]:translate-x-0",
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
)}
>
@@ -67,10 +67,11 @@ describe("the folded list", () => {
"utf8",
);
it("keeps an edge with its button, and sits left of the day", () => {
expect(source).toContain('data-slot="coach-panel-rail"');
expect(source).toContain('data-slot="coach-panel-expand"');
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
it("slides to nothing, keeps its own strip, and sits left of the day", () => {
expect(source).toContain('slot="coach-panel-strip"');
expect(source).toContain("order={1}");
expect(source).toContain('dockedOpen ? "w-72" : "w-0"');
expect(source).toContain("DOCK_SLIDE");
expect(source).toMatch(/order-1/);
});
@@ -2,7 +2,13 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
import {
MessagesSquare,
PanelRightClose,
PanelRightOpen,
Target,
X,
} from "lucide-react";
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
import { Button } from "@/components/ui/button";
@@ -13,6 +19,12 @@ import {
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import {
DOCK_SLIDE,
DockStrip,
ShellStrip,
useLingering,
} from "@/components/layout/shell-dock";
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { TopBarActions } from "@/components/layout/top-bar-actions";
@@ -43,25 +55,27 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Docked, the panel is a column of the shell beside the content column
* (top bar and page): it runs the full height of the window, and the top bar
* ends at its left edge. The panel's header row is the top bar's band, so
* the two bottom borders draw one line. It folds like the day beside it: the
* header's first control folds it to a narrow edge, and the edge's button
* (in the same place) opens it again. Folded, the edge names the list.
* the two bottom borders draw one line. Its strip stays at the right edge of
* the window, left of the day's (`shell-dock.tsx`), open or not: a click
* on the strip opens the panel left of the strips and a second click shuts
* it; the header's first control shuts it too. The panel slides open and
* shut (a 200 ms width transition, none under reduced motion).
* The header carries the title, a link to Plans, the settings gear and, in
* the sheet, a close button. New conversation is not here: it floats in the
* conversation itself (`NewChatFab`).
*
* The day docks right of the panel on this page too (page, conversations,
* day). Below 1600 px only one of the two is open: a day opening folds the
* list to its edge without touching the remembered choice, and opening the
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
* list without touching the remembered choice, and opening the list closes
* the day (`yieldDay`). From 1600 px both stay open.
*
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
* the top bar.
*
* Keyboard: Escape folds the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
* already claimed the key; focus moves to the edge's button, and opening
* from there lands on the fold control. In the sheet Radix owns Escape, the
* already claimed the key; focus moves to the strip, and opening from the
* strip lands on the fold control. In the sheet Radix owns Escape, the
* focus trap and the return.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
@@ -121,6 +135,17 @@ export function listYields(state: {
);
}
/**
* Whether opening the docked list closes an open day: below 1600 px only one
* of the two is open, and the one the person just opened wins.
*/
export function dayYields(state: {
fitsBoth: boolean;
dayOpen: boolean;
}): boolean {
return !state.fitsBoth && state.dayOpen;
}
/**
* Whether the phone sheet is open. A settings deep link opens it only once
* the viewport is known: the server and hydration renders take the narrow
@@ -263,6 +288,8 @@ export function ConversationsPanel({
const expanded = docked ? dockedOpen : sheetOpen;
const collapseRef = useRef<HTMLButtonElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// The list keeps painting while it slides shut.
const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false;
// Where focus goes once the docked panel has folded or opened.
const focusAfter = useRef<"collapse" | "expand" | null>(null);
useEffect(() => {
@@ -282,7 +309,7 @@ export function ConversationsPanel({
setRevealed(false);
setOpen(true);
// Below 1600 px the day makes room: it folds to its edge.
if (!fitsBoth && openDay !== null) yieldDay();
if (dayYields({ fitsBoth, dayOpen: openDay !== null })) yieldDay();
}
function collapseDocked() {
@@ -425,89 +452,65 @@ export function ConversationsPanel({
)}
{docked ? (
<ShellSidePanel>
<aside
id={COACH_PANEL_ID}
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
onKeyDown={(event) => {
if (
!dockedOpen ||
event.key !== "Escape" ||
event.defaultPrevented
) {
return;
<>
<ShellSidePanel>
<div
id={COACH_PANEL_ID}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
onKeyDown={(event) => {
if (
!dockedOpen ||
event.key !== "Escape" ||
event.defaultPrevented
) {
return;
}
event.preventDefault();
collapseDocked();
}}
className={cn(
// A column of the shell's content row (`ShellSidePanel`), so
// it runs from the top of the window to the bottom beside the
// top bar, left of a docked day (`order-1`, the day is
// `order-2`). The width slides between the open column and
// nothing; the content keeps 18rem, pinned left, so it slides
// instead of squeezing (`DOCK_SLIDE`).
DOCK_SLIDE,
"bg-sidebar text-sidebar-foreground order-1",
dockedOpen ? "w-72" : "w-0",
)}
>
{shownOpen ? (
<aside
aria-label={t("insights.coach.historyTitle")}
// Sliding shut, it paints the list and takes no input.
inert={!dockedOpen}
className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l"
>
{body(false)}
</aside>
) : null}
</div>
</ShellSidePanel>
<ShellStrip>
<DockStrip
ref={expandRef}
slot="coach-panel-strip"
order={1}
controls={COACH_PANEL_ID}
expanded={dockedOpen}
label={t("insights.coach.historyTitle")}
actionLabel={
dockedOpen
? t("insights.coach.frame.hidePanel")
: t("insights.coach.frame.showPanel")
}
event.preventDefault();
collapseDocked();
}}
className={cn(
// A column of the shell's content row (`ShellSidePanel`), so it
// runs from the top of the window to the bottom beside the top
// bar, left of a docked day (`order-1`, the day is `order-2`).
// The width animates between the open column and the folded
// edge; the open column keeps 18rem and is pinned to the
// panel's left edge, so it slides instead of squeezing.
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-12",
)}
>
{dockedOpen ? (
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
) : (
<div
data-slot="coach-panel-rail"
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
>
{/* The top bar's band, so the bottom borders draw one line;
the button sits where the open panel keeps its fold
control. */}
<div
className={cn(
SHELL_HEADER_BAND,
"border-sidebar-border flex shrink-0 items-center justify-center",
)}
>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
onClick={expandDocked}
data-slot="coach-panel-expand"
aria-controls={COACH_PANEL_ID}
aria-expanded={false}
aria-label={t("insights.coach.frame.showPanel")}
title={t("insights.coach.frame.showPanel")}
className={PANEL_HEADER_BUTTON}
>
<PanelRightOpen
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</div>
{/* The rest of the edge takes a click too and names the
list; the keyboard has the button above. */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={expandDocked}
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
>
<span className="[writing-mode:vertical-rl]">
{t("insights.coach.historyTitle")}
</span>
</button>
</div>
)}
</aside>
</ShellSidePanel>
icon={MessagesSquare}
onToggle={dockedOpen ? collapseDocked : expandDocked}
/>
</ShellStrip>
</>
) : (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
+5 -6
View File
@@ -256,14 +256,13 @@ export function LayoutCoachFab() {
// the offset ring draws a clear halo around the circle.
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
"transition-[opacity,right] duration-150 motion-reduce:transition-none",
// v1.42 — while a day is docked on the right (from 1280 px), the
// v1.42 — from 1280 px the day's 2.5 rem strip stays at the right
// edge; the launcher stays clear of it. While the day is open, the
// launcher moves left with the page's own edge, so it stays over
// the page and never over the day's footer actions. The `:has()`
// gate keys off the docked panel's slot, like the yields below.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
// Collapsed, the day keeps a 3 rem edge on the right; the launcher
// stays clear of it the same way.
"[body:has([data-slot=day-rail])_&]:right-[calc(3rem+2rem)]",
// gates key off the strip's state, so exactly one of them holds.
"[body:has([data-slot=day-strip][data-state=closed])_&]:right-[calc(2.5rem+2rem)]",
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+2rem)]",
// Yield to the data-list selection bar: its delete action lands
// in the same lower-right band, and the destructive control
// wins. The `:has()` gate keys off the bar's `data-slot`.
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { CalendarDays } from "lucide-react";
import { stripDayOf } from "@/components/day/day-url";
import {
dayYields,
listYields,
BOTH_PANELS_QUERY,
} from "@/components/insights/coach-panel/conversations-panel";
import { DOCK_SLIDE, DockStrip } from "../shell-dock";
/**
* The docked panels' strips (v1.42). Each docked panel keeps its own strip
* at the right edge; the strip is a toggle for its panel, never moves, and
* below 1600 px only one panel is open. The click paths, focus and the
* geometry run in `e2e/dock-strips.spec.ts`.
*/
function strip(expanded: boolean) {
return renderToStaticMarkup(
<DockStrip
slot="day-strip"
order={2}
controls="day-docked-panel"
expanded={expanded}
label="Thu, 4 Jun 2026"
actionLabel={expanded ? "Hide day" : "Show Thursday, 4 June 2026"}
icon={CalendarDays}
onToggle={() => {}}
data={{ "data-day": "2026-06-04" }}
/>,
);
}
describe("DockStrip", () => {
it("is a toggle button for its panel, closed", () => {
const html = strip(false);
expect(html).toContain('data-slot="day-strip"');
expect(html).toContain('data-state="closed"');
expect(html).toContain('data-day="2026-06-04"');
expect(html).toMatch(/<button[^>]*type="button"/);
expect(html).toContain('data-slot="day-strip-toggle"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain('aria-controls="day-docked-panel"');
expect(html).toContain('aria-label="Show Thursday, 4 June 2026"');
// The label reads top to bottom.
expect(html).toContain("[writing-mode:vertical-rl]");
expect(html).toContain("Thu, 4 Jun 2026");
});
it("says it is open and what a click does then", () => {
const html = strip(true);
expect(html).toContain('data-state="open"');
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('aria-label="Hide day"');
});
it("keeps the same shape open and closed, below the top bar's band", () => {
const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1];
expect(shape(strip(true))).toBe(shape(strip(false)));
expect(shape(strip(false))).toContain("w-10");
// The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden.
expect(strip(false)).toMatch(
/^<div[^>]*><div aria-hidden="true" class="h-16 border-b/,
);
});
it("orders the strips like their panels: conversations, then the day", () => {
const list = renderToStaticMarkup(
<DockStrip
slot="coach-panel-strip"
order={1}
controls="coach-conversations-panel"
expanded={false}
label="Conversations"
actionLabel="Show conversations"
icon={CalendarDays}
onToggle={() => {}}
/>,
);
expect(list).toMatch(/^<div[^>]*class="[^"]*\border-1\b/);
expect(strip(false)).toMatch(/^<div[^>]*class="[^"]*\border-2\b/);
});
it("slides by width, with no motion under reduced motion", () => {
expect(DOCK_SLIDE).toContain("transition-[width]");
expect(DOCK_SLIDE).toContain("duration-200");
expect(DOCK_SLIDE).toContain("motion-reduce:transition-none");
expect(DOCK_SLIDE).toContain("overflow-hidden");
});
});
describe("one panel open below 1600 px", () => {
it("closes the day when the list opens below 1600 px", () => {
expect(dayYields({ fitsBoth: false, dayOpen: true })).toBe(true);
});
it("keeps the day open beside the list from 1600 px", () => {
expect(dayYields({ fitsBoth: true, dayOpen: true })).toBe(false);
});
it("has nothing to close without an open day", () => {
expect(dayYields({ fitsBoth: false, dayOpen: false })).toBe(false);
});
it("closes the list when a day opens below 1600 px, and not from 1600 px", () => {
const base = {
docked: true,
listOpen: true,
dayOpen: true,
dayOpened: true,
narrowed: false,
};
expect(listYields({ ...base, fitsBoth: false })).toBe(true);
expect(listYields({ ...base, fitsBoth: true })).toBe(false);
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
});
});
describe("the day strip's day", () => {
const today = "2026-06-04";
it("is the open day while one is open", () => {
expect(stripDayOf("2026-05-30", "2026-05-01", today)).toBe("2026-05-30");
});
it("is the remembered day while the day is shut, across pages", () => {
expect(stripDayOf(null, "2026-05-01", today)).toBe("2026-05-01");
});
it("is today when nothing is remembered, or the memory is not a day to open", () => {
expect(stripDayOf(null, null, today)).toBe(today);
expect(stripDayOf(null, "2999-01-01", today)).toBe(today);
expect(stripDayOf(null, "garbage", today)).toBe(today);
});
});
+4
View File
@@ -29,6 +29,7 @@ import { CommandPaletteMount } from "@/components/command-palette/command-palett
import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { ShellStripOutlet } from "./shell-dock";
import { ShellSidePanelOutlet } from "./shell-side-panel";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import {
@@ -540,6 +541,9 @@ export function AuthShell({
bottom, and the top bar ends at its left edge. Empty and out of
the row on every other page. */}
<ShellSidePanelOutlet className="flex min-h-0 shrink-0 empty:hidden" />
{/* The docked panels' strips, right of the panels: one per panel,
always shown from 1280 px (`shell-dock.tsx`). */}
<ShellStripOutlet className="flex min-h-0 shrink-0 empty:hidden" />
<BottomNav />
</div>
</div>
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { SHELL_HEADER_BAND } from "./shell-metrics";
import { createShellSlot } from "./shell-slot";
/**
* The docked panels' strips (v1.42): from 1280 px every docked panel (the
* day on every page, the Coach's conversations on `/coach`) keeps its own
* narrow vertical strip at the right edge of the window, visible whether the
* panel is open or not. The strips stand side by side in one column of the
* shell, right of the panels (`ShellSidePanel`), in the same order as their
* panels: conversations (`order-1`), then the day (`order-2`). A panel opens
* to the left of the strips; the strips never move or change shape.
*
* Each strip starts below the top bar's band. The band above them is empty
* and draws the top bar's bottom line on through, so nothing in the strips
* meets the command palette at the end of the top bar.
*/
const strips = createShellSlot("shell-strips");
/** Where the shell paints the strips. Empty unless a docked panel fills it. */
export const ShellStripOutlet = strips.Outlet;
/** Render a strip into the shell's strip column. */
export const ShellStrip = strips.Portal;
/**
* The slide every docked panel shares: the panel's box animates its width
* between the open column and nothing, and its content keeps the open width,
* pinned to the box's left edge, so it slides out under the strip instead of
* squeezing. The Coach's conversations introduced it; the day takes the same
* duration and easing. No motion under reduced motion.
*/
export const DOCK_SLIDE =
"relative h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-linear motion-reduce:transition-none";
/** The slide's length in milliseconds, for content that waits it out. */
export const DOCK_SLIDE_MS = 200;
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* `value` while it is set, and for the length of the slide after it is
* cleared: a closing panel keeps painting what it showed while its width
* runs to nothing. Cleared at once under reduced motion, where nothing
* slides.
*/
export function useLingering<T>(value: T | null): T | null {
const [held, setHeld] = useState<T | null>(value);
if (value !== null && held !== value) setHeld(value);
useEffect(() => {
if (value !== null || held === null) return;
const id = window.setTimeout(
() => setHeld(null),
prefersReducedMotion() ? 0 : DOCK_SLIDE_MS,
);
return () => window.clearTimeout(id);
}, [value, held]);
return value ?? held;
}
export interface DockStripProps {
ref?: React.Ref<HTMLButtonElement>;
/** `data-slot` of the strip; its button is `<slot>-toggle`. */
slot: string;
/** Left to right: conversations 1, day 2. */
order: 1 | 2;
/** The id of the panel the strip opens and closes. */
controls: string;
expanded: boolean;
/** The strip's visible, vertical label. */
label: string;
/** The button's accessible name (what a click does). */
actionLabel: string;
icon: LucideIcon;
onToggle: () => void;
/** Called when the pointer or focus reaches the strip (load ahead). */
onPreload?: () => void;
/** Extra attributes for the strip (`data-day`). */
data?: Record<`data-${string}`, string>;
}
/**
* One strip: an empty cell of the top bar's band, then a button the rest of
* the window's height that opens and closes its panel. The label reads
* top to bottom; the strip is marked while its panel is open.
*/
export function DockStrip({
ref,
slot,
order,
controls,
expanded,
label,
actionLabel,
icon: Icon,
onToggle,
onPreload,
data,
}: DockStripProps) {
return (
<div
data-slot={slot}
data-state={expanded ? "open" : "closed"}
{...data}
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-10 shrink-0 flex-col",
order === 1 ? "order-1" : "order-2",
)}
>
<div
aria-hidden="true"
className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")}
/>
<button
ref={ref}
type="button"
data-slot={`${slot}-toggle`}
aria-expanded={expanded}
aria-controls={controls}
aria-label={actionLabel}
title={actionLabel}
onClick={onToggle}
onPointerEnter={onPreload}
onFocus={onPreload}
className={cn(
"border-sidebar-border flex min-h-0 flex-1 cursor-pointer flex-col items-center gap-2 border-l py-3 text-xs font-medium transition-colors outline-none",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:ring-inset",
expanded
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate [writing-mode:vertical-rl]">{label}</span>
</button>
</div>
);
}
+3 -2
View File
@@ -7,8 +7,9 @@ import { createShellSlot } from "./shell-slot";
* `<main>` rather than inside `<main>`. A panel portalled here runs the full
* height of the content row, from the top of the window (below any banner
* strip) to the bottom, and the top bar ends at its left edge instead of
* running underneath it. The Coach's docked conversations panel is the one
* user. See `shell-slot.tsx`.
* running underneath it. The docked day and the Coach's docked
* conversations live here; their strips sit right of this column
* (`shell-dock.tsx`). See `shell-slot.tsx`.
*/
const slot = createShellSlot("shell-side-panel");