Merge pull request #1169 from MBombeck/feat/coach-panel-full-height

chore(release): v1.40.5
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-05 20:05:49 +02:00
commit 6cea4be2a1
20 files changed
+442 -160

No files matched your search

+13
View File
@@ -1,5 +1,18 @@
# Changelog
## [1.40.5] — 2026-10-05
The Coach's conversation panel runs the full height of the window.
### Changed
- **Coach panel.** Docked, the panel runs from the top of the window to the
bottom, beside the top bar instead of below it, and its header line
continues the top bar's. Shut, nothing of it remains on screen.
- **Top bar on the Coach.** It names the page and the open conversation, and
the panel toggle sits at its right end, against the panel.
- **New chat.** A round button with a plus at the panel's bottom right.
## [1.40.4] — 2026-10-05
The Coach's conversation panel docks to the right edge; model and tokens
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.40.4
version: 1.40.5
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+107 -24
View File
@@ -16,7 +16,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* and the reading column (thread and composer) is exactly as wide with
* the panel open as with it closed. The choice survives a reload. The
* panel is flush with the viewport's right edge (no scrollbar gutter
* beside it) and runs from the top bar to the bottom edge.
* beside it) and runs from the top of the window to the bottom; the
* top bar ends at its left edge and the panel's header border is the
* top bar's. The toggle is the bar's last item, within 16 px of its
* right edge, open and shut; shut, no pixel of the panel shows and the
* bar runs to the window's edge. New chat is a round 48 px plus.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
@@ -28,7 +32,7 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* 5. axe finds nothing on the open panel or the open settings, in the
* light and the dark theme.
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
* right edge; Escape closes it and focus returns to the toggle. The gear
* right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. Nothing
* scrolls sideways.
*
@@ -233,10 +237,10 @@ async function expectNoSidewaysScroll(page: Page) {
}
/**
* The docked panel is flush with the viewport: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top bar's
* bottom edge to the bottom of the viewport, and its own border is the left
* one only.
* The docked panel is flush with the window: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top of the
* window to the bottom, the top bar ends at its left edge, its header row's
* bottom border is the top bar's, and its own border is the left one only.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
@@ -244,15 +248,22 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
const header = aside.querySelector(
'[data-slot="coach-conversations-panel-header"]',
)!;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
const barBox = bar.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
barBottom: bar.getBoundingClientRect().bottom,
left: box.left,
headerBottom: header.getBoundingClientRect().bottom,
barBottom: barBox.bottom,
barRight: barBox.right,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
@@ -263,12 +274,60 @@ async function expectPanelFlush(page: Page, width: number, height: number) {
};
});
expect(geometry.right).toBe(width);
expect(geometry.top).toBe(geometry.barBottom);
expect(geometry.top).toBe(0);
expect(geometry.bottom).toBe(height);
expect(geometry.barRight).toBe(geometry.left);
expect(geometry.headerBottom).toBe(geometry.barBottom);
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
/**
* The toggle is the last item of the top bar (the account menu that follows
* it on phones is not rendered from `md`), and its right edge is within
* 16 px of the bar's right edge.
*/
async function expectToggleAtBarEnd(page: Page) {
const at = await page.evaluate(() => {
const bar = document.querySelector('[data-slot="top-bar"]')!;
const shown = [...bar.querySelectorAll("*")].filter(
(el) => el.getClientRects().length > 0,
);
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
const rightmost = Math.max(
...shown.map((el) => el.getBoundingClientRect().right),
);
return {
gap:
bar.getBoundingClientRect().right -
toggle.getBoundingClientRect().right,
last: toggle.getBoundingClientRect().right >= rightmost,
lastChild:
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
toggle,
};
});
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
expect(at.lastChild).toBe(true);
expect(at.gap).toBeGreaterThanOrEqual(0);
expect(at.gap).toBeLessThanOrEqual(16);
}
/** New chat: a round 48 px button with the plus glyph. */
async function expectFab(page: Page) {
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const box = (await fab.boundingBox())!;
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
expect(
await fab.evaluate((el) =>
parseFloat(getComputedStyle(el).borderTopLeftRadius),
),
"round",
).toBeGreaterThanOrEqual(24);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await expect(fab).toHaveAttribute("aria-label", "New chat");
}
/** Rendered heights of the docked panel's header buttons and first row. */
async function panelControlHeights(page: Page) {
const height = async (slot: string) =>
@@ -374,12 +433,13 @@ test.describe("Coach page frame", () => {
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
// Open, the toggle stands beside the panel's leading edge.
const openToggle = (await toggle(page).boundingBox())!;
expect(openToggle.x + openToggle.width).toBeLessThanOrEqual(aside.x);
expect(aside.x - (openToggle.x + openToggle.width)).toBeLessThanOrEqual(
24,
);
// Open, the toggle is the top bar's last item, against the panel.
await expectToggleAtBarEnd(page);
await expectFab(page);
// The bar says where the reader is: the page, then the conversation.
await expect(
page.locator('[data-slot="coach-top-bar-trail"]'),
).toHaveText(/Coach.*Blood pressure this week/);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
@@ -388,13 +448,26 @@ test.describe("Coach page frame", () => {
await waitForWidth(panel(page), 0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
const closed = await columnWidths(page);
// Closed, it returns to the trailing edge of the top bar.
// Closed, the panel is entirely off-screen, the top bar runs to the
// window's edge and the toggle is still its last item.
await expect
.poll(async () => {
const box = (await toggle(page).boundingBox())!;
return Math.round(width - (box.x + box.width));
})
.toBeLessThanOrEqual(24);
.poll(() =>
page.evaluate(
() =>
document
.querySelector('[data-slot="top-bar"]')!
.getBoundingClientRect().right,
),
)
.toBe(width);
expect(
await panel(page).evaluate((el) => {
const box = el.getBoundingClientRect();
return box.width === 0 || box.left >= window.innerWidth;
}),
"no pixel of the shut panel shows",
).toBe(true);
await expectToggleAtBarEnd(page);
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
// Closed, nothing inside the panel is reachable.
@@ -475,10 +548,14 @@ test.describe("Coach page frame", () => {
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
const asideBox = (await panel(page).boundingBox())!;
// 24 px in from the panel's corner from `md`, 16 px on a phone.
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
16,
24,
0,
);
expect(
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
).toBeCloseTo(24, 0);
await fab.click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
@@ -705,9 +782,15 @@ test.describe("Coach page frame", () => {
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
expect(390 - (fabBox.x + fabBox.width)).toBeCloseTo(16, 0);
expect(fabBox.width).toBeGreaterThanOrEqual(44);
// Measured once the sheet has slid in.
await expect
.poll(async () => {
const box = (await fab.boundingBox())!;
return Math.round(390 - (box.x + box.width));
})
.toBe(16);
expect((await fab.boundingBox())!.width).toBe(48);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
+1
View File
@@ -407,6 +407,7 @@
"mood": "Stimmung",
"coach": "Coach",
"skipToContent": "Zum Inhalt springen",
"breadcrumb": "Du bist hier",
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
"mobileNavigation": "Mobile Navigation",
+1
View File
@@ -407,6 +407,7 @@
"mood": "Mood",
"coach": "Coach",
"skipToContent": "Skip to content",
"breadcrumb": "You are here",
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
"mobileNavigation": "Mobile navigation",
+1
View File
@@ -407,6 +407,7 @@
"mood": "Estado de ánimo",
"coach": "Coach",
"skipToContent": "Saltar al contenido",
"breadcrumb": "Estás aquí",
"sidebar": "Barra lateral",
"mainNavigation": "Navegación principal",
"mobileNavigation": "Navegación móvil",
+1
View File
@@ -407,6 +407,7 @@
"mood": "Humeur",
"coach": "Coach",
"skipToContent": "Aller au contenu",
"breadcrumb": "Vous êtes ici",
"sidebar": "Barre latérale",
"mainNavigation": "Navigation principale",
"mobileNavigation": "Navigation mobile",
+1
View File
@@ -407,6 +407,7 @@
"mood": "Umore",
"coach": "Coach",
"skipToContent": "Vai al contenuto",
"breadcrumb": "Sei qui",
"sidebar": "Barra laterale",
"mainNavigation": "Navigazione principale",
"mobileNavigation": "Navigazione mobile",
+1
View File
@@ -407,6 +407,7 @@
"mood": "기분",
"coach": "코치",
"skipToContent": "본문으로 건너뛰기",
"breadcrumb": "현재 위치",
"sidebar": "사이드바",
"mainNavigation": "주 메뉴",
"mobileNavigation": "모바일 메뉴",
+1
View File
@@ -407,6 +407,7 @@
"mood": "Nastrój",
"coach": "Coach",
"skipToContent": "Przejdź do treści",
"breadcrumb": "Jesteś tutaj",
"sidebar": "Pasek boczny",
"mainNavigation": "Nawigacja główna",
"mobileNavigation": "Nawigacja mobilna",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.40.4",
"version": "1.40.5",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.40.4";
/* @sw-version-fallback */ "v1.40.5";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -55,6 +55,7 @@ import {
} from "./message-thread";
import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
@@ -1202,6 +1203,11 @@ export function CoachConversation({
className="flex min-h-0 min-w-0 flex-1 flex-col"
>
{docScopeBanner}
<CoachTopBarTrail
conversationTitle={
heroActive ? null : (conversation?.title ?? null)
}
/>
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
@@ -0,0 +1,56 @@
"use client";
import { ChevronRight } from "lucide-react";
import { TopBarContext } from "@/components/layout/top-bar-actions";
import { useTranslations } from "@/lib/i18n/context";
/**
* Where the reader is, at the leading edge of the top bar on the Coach page
* (desktop): "Coach", and the open conversation's title after it. The page
* name is meta (muted); the current item is content (foreground, medium),
* truncated before it would push into the toggle. On the new-chat hero the
* trail is the page name alone.
*
* Not a heading: the page keeps its own (screen-reader) `<h1>`.
*/
export function CoachTopBarTrail({
conversationTitle,
}: {
/** The open conversation's title; null on the new-chat hero. */
conversationTitle: string | null;
}) {
const { t } = useTranslations();
const page = t("nav.coach");
return (
<TopBarContext>
<nav
aria-label={t("nav.breadcrumb")}
data-slot="coach-top-bar-trail"
className="min-w-0"
>
<ol className="flex min-w-0 items-center gap-1.5 text-sm">
{conversationTitle ? (
<>
<li className="text-muted-foreground shrink-0">{page}</li>
<li aria-hidden="true" className="text-muted-foreground shrink-0">
<ChevronRight className="size-4" />
</li>
<li
aria-current="page"
title={conversationTitle}
className="text-foreground min-w-0 truncate font-medium"
>
{conversationTitle}
</li>
</>
) : (
<li aria-current="page" className="text-foreground font-medium">
{page}
</li>
)}
</ol>
</nav>
</TopBarContext>
);
}
@@ -2,13 +2,7 @@
import { useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import {
PanelRightClose,
PanelRightOpen,
SquarePen,
Target,
X,
} from "lucide-react";
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -18,10 +12,18 @@ import {
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
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";
import { useCoachPanelOpen } from "@/hooks/use-coach-panel-open";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { HistoryRail } from "./history-rail";
@@ -37,10 +39,15 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Below 1280 px it is a sheet from the right that opens when asked and
* closes again when a conversation is picked or a new chat starts.
*
* The toggle sits at the trailing edge of the top bar and, while the docked
* panel is open, beside the panel's leading edge. The panel header
* carries the title, a link to Plans, the settings gear and, in the sheet,
* a close button. The round New chat button sits at the bottom right.
* 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. Shut, it is gone entirely (no rail)
* and the top bar runs to the window's edge. The toggle is the last item of
* the top bar, so it sits against the open panel and at the window's edge
* when the panel is shut. The panel header carries the title, a link to
* Plans, the settings gear and, in the sheet, a close button. The round New
* chat button sits at the panel's bottom right.
*
* Keyboard: Escape closes the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
@@ -88,6 +95,52 @@ export interface ConversationsPanelProps {
openSettingsOnData?: boolean;
}
/**
* The panel's open/shut toggle, the last item of the top bar. 28 px beside a
* fine pointer, the 44 px touch floor otherwise; the tooltip names it.
*/
function PanelToggle({
ref,
expanded,
label,
onToggle,
className,
}: {
ref: React.Ref<HTMLButtonElement>;
expanded: boolean;
label: string;
onToggle: () => void;
className?: string;
}) {
const Icon = expanded ? PanelRightOpen : PanelRightClose;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={ref}
type="button"
variant="ghost"
size="icon"
onClick={onToggle}
aria-controls={COACH_PANEL_ID}
aria-expanded={expanded}
aria-label={label}
data-slot="coach-panel-toggle"
className={cn(PANEL_HEADER_BUTTON, className)}
>
{/* Mirrored, so the chevron points the way the panel moves on a
click: right to shut it, left to open it. */}
<Icon
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
export function ConversationsPanel({
activeId,
onSelect,
@@ -156,7 +209,20 @@ export function ConversationsPanel({
const header = (inSheet: boolean) => (
<div
data-slot="coach-conversations-panel-header"
className="border-border flex shrink-0 items-center gap-2 border-b p-3"
// Docked, the row reserves the safe-area inset the top bar reserves,
// so a standalone PWA on a tablet does not tuck it under the status
// bar; the band keeps its height (border-box), like the top bar.
style={
inSheet ? undefined : { paddingTop: "env(safe-area-inset-top, 0px)" }
}
// Docked, the row is the top bar's band (height and bottom border
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
className={cn(
"flex shrink-0 items-center gap-2",
inSheet
? "border-border border-b p-3"
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
)}
>
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
@@ -224,87 +290,73 @@ export function ConversationsPanel({
onUndoDeleteActive={(id) => onSelect(id)}
className="min-h-0 flex-1"
/>
<Button
type="button"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="absolute right-4 bottom-4 size-12 rounded-full shadow-md"
>
<SquarePen className="size-5" aria-hidden="true" />
</Button>
{/* New chat: a round filled button floating at the panel's bottom
right; the list pads its own scroll area so the last row clears it. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
className="absolute right-4 bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] size-12 rounded-full shadow-lg md:right-6 md:bottom-[calc(1.5rem+env(safe-area-inset-bottom,0px))]"
>
<Plus className="size-6" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("insights.coach.newChat")}
</TooltipContent>
</Tooltip>
</>
);
return (
<>
<TooltipProvider delayDuration={300}>
<TopBarActions>
<Button
<PanelToggle
ref={toggleRef}
type="button"
variant="ghost"
size="icon"
onClick={toggle}
aria-controls={COACH_PANEL_ID}
aria-expanded={expanded}
aria-label={toggleLabel}
title={toggleLabel}
data-slot="coach-panel-toggle"
className={cn(
"text-muted-foreground hover:text-foreground size-11",
// Docked and open, the toggle keeps the panel's width as its
// right margin, so it stays beside the panel's leading edge and
// travels with it as the panel opens and shuts.
"transition-[margin] duration-200 ease-linear motion-reduce:transition-none",
docked && dockedOpen && "mr-72",
)}
>
{/* Mirrored, so the chevron points the way the panel moves on a
click: right to shut it, left to open it. */}
{expanded ? (
<PanelRightOpen
className="size-5 -scale-x-100"
aria-hidden="true"
/>
) : (
<PanelRightClose
className="size-5 -scale-x-100"
aria-hidden="true"
/>
)}
</Button>
expanded={expanded}
label={toggleLabel}
onToggle={toggle}
/>
</TopBarActions>
{docked ? (
<aside
id={COACH_PANEL_ID}
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
inert={!dockedOpen}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
closeDocked();
}}
className={cn(
// The width animates; the inner column keeps 18rem and is pinned
// to the panel's left edge, so it slides out to the right instead
// of squeezing. The thread column beside it never changes width.
"bg-background relative shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
)}
>
<div className="border-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
</aside>
<ShellSidePanel>
<aside
id={COACH_PANEL_ID}
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
inert={!dockedOpen}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
closeDocked();
}}
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. The width animates (offcanvas: shut, nothing of it
// shows); the inner column
// keeps 18rem and is pinned to the panel's left edge, so it
// slides out to the right instead of squeezing. The thread column
// keeps its width; only its centring moves.
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
)}
>
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
</aside>
</ShellSidePanel>
) : (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
@@ -347,6 +399,6 @@ export function ConversationsPanel({
</SheetContent>
</Sheet>
)}
</>
</TooltipProvider>
);
}
+7
View File
@@ -26,6 +26,7 @@ import { BottomNav } from "./bottom-nav";
import { DemoBanner } from "./demo-banner";
import { OfflineBanner } from "./offline-banner";
import { SharedRecordBanner } from "./shared-record-banner";
import { ShellSidePanelOutlet } from "./shell-side-panel";
import { SharedRecordUnavailable } from "./shared-record-unavailable";
import {
ModulePageGate,
@@ -507,6 +508,12 @@ export function AuthShell({
</div>
</main>
</div>
{/* A page's side panel (the Coach's docked conversations) is a
column of this row, beside the top bar and `<main>`: it runs
the row's full height, from the top of the window to the
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" />
<BottomNav />
</div>
</div>
@@ -0,0 +1,19 @@
"use client";
import { createShellSlot } from "./shell-slot";
/**
* A page-owned column on the right of the shell, beside the top bar and
* `<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`.
*/
const slot = createShellSlot("shell-side-panel");
/** Where the shell paints a page's side panel. Empty unless a page fills it. */
export const ShellSidePanelOutlet = slot.Outlet;
/** Render `children` into the shell's side-panel column. */
export const ShellSidePanel = slot.Portal;
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/**
* A page-owned slot in the shell's chrome.
*
* The shell knows nothing about pages; a page that needs to paint into the
* chrome (the Coach's panel toggle in the top bar, its docked conversations
* panel beside the content column) renders the slot's `Portal` anywhere in
* its own tree and the content is portalled into the slot's `Outlet`.
* Portalling keeps the content inside the page's React tree, so it reads
* the page's state and context directly and no node has to be handed across
* a context on every render.
*
* The slot empties itself when the page unmounts: the portal goes with the
* component that rendered it, so no other route ever shows stale content.
*
* The outlet's element is held in a module-level store rather than a context
* provider. The shell mounts each outlet exactly once, so a store keeps the
* shell's tree untouched. On the server, and before the outlet has mounted,
* there is no target and the portal renders nothing.
*/
export function createShellSlot(slot: string) {
let outlet: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOutlet(node: HTMLElement | null) {
if (outlet === node) return;
outlet = node;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function Outlet({ className }: { className?: string }) {
return <div ref={setOutlet} data-slot={slot} className={className} />;
}
function Portal({ children }: { children: React.ReactNode }) {
const target = useSyncExternalStore(
subscribe,
() => outlet,
() => null,
);
if (!target) return null;
return createPortal(children, target);
}
return { Outlet, Portal };
}
+20 -44
View File
@@ -1,53 +1,29 @@
"use client";
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { createShellSlot } from "./shell-slot";
/**
* A page-owned slot at the trailing edge of the top bar.
*
* The top bar is shell chrome and knows nothing about pages; a page that
* needs a control up there (the Coach's conversations-panel toggle) renders
* `<TopBarActions>` anywhere in its own tree and the content is portalled
* into `<TopBarActionsOutlet>`. Portalling keeps the control inside the
* page's React tree, so it reads the page's state and context directly and
* no node has to be handed across a context on every render.
*
* The slot empties itself when the page unmounts: the portal goes with the
* component that rendered it, so no other route ever shows a stale control.
*
* The outlet's element is held in a module-level store rather than a context
* provider. The shell mounts exactly one top bar, so there is one outlet, and
* a store keeps the shell's tree untouched.
* A page-owned slot at the trailing edge of the top bar: the Coach's
* conversations-panel toggle renders `<TopBarActions>` anywhere in its own
* tree and lands in `<TopBarActionsOutlet>`. See `shell-slot.tsx`.
*/
let outlet: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOutlet(node: HTMLElement | null) {
if (outlet === node) return;
outlet = node;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
const slot = createShellSlot("top-bar-actions");
/** Where the top bar paints page actions. Empty unless a page fills it. */
export function TopBarActionsOutlet({ className }: { className?: string }) {
return (
<div ref={setOutlet} data-slot="top-bar-actions" className={className} />
);
}
export const TopBarActionsOutlet = slot.Outlet;
/** Render `children` into the top bar's action slot. */
export function TopBarActions({ children }: { children: React.ReactNode }) {
const target = useSyncExternalStore(
subscribe,
() => outlet,
() => null,
);
if (!target) return null;
return createPortal(children, target);
}
export const TopBarActions = slot.Portal;
/**
* A page-owned slot at the leading edge of the top bar on desktop: where
* the page says where the reader is (the Coach's "Coach › conversation").
* Phones keep the logo there.
*/
const context = createShellSlot("top-bar-context");
/** Where the top bar paints the page's context. Empty unless a page fills it. */
export const TopBarContextOutlet = context.Outlet;
/** Render `children` into the top bar's context slot. */
export const TopBarContext = context.Portal;
+12 -4
View File
@@ -15,7 +15,10 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { TopBarActionsOutlet } from "@/components/layout/top-bar-actions";
import {
TopBarActionsOutlet,
TopBarContextOutlet,
} from "@/components/layout/top-bar-actions";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -104,14 +107,19 @@ export function TopBar() {
<span className="font-bold tracking-tight">HealthLog</span>
</Link>
{/* Desktop: empty spacer (user controls are in sidebar) */}
<div className="hidden md:block" />
{/* Desktop: the page's context, where a page gives one (the Coach's
trail); otherwise an empty spacer. User controls are in the
sidebar. */}
<TopBarContextOutlet className="hidden min-w-0 flex-1 items-center md:flex" />
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
keeps them at the trailing edge, right before the mobile avatar
menu; `empty:hidden` takes the slot out of the row on every page
that leaves it empty, so the header lays out exactly as before. */}
<TopBarActionsOutlet className="ml-auto flex items-center gap-1 empty:hidden" />
{/* On desktop the last action sits 12 px from the bar's trailing edge
(`md:-mr-3` against `md:px-6`), so a panel toggle stands against the
panel it controls. */}
<TopBarActionsOutlet className="ml-auto flex shrink-0 items-center gap-1 empty:hidden md:-mr-3" />
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">