diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b714aae..0017353d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a35d16e6b..59e63e736 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index bf65e6ed7..a4b432ab8 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -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"); diff --git a/messages/de.json b/messages/de.json index c5947cd3f..cc16bbd08 100644 --- a/messages/de.json +++ b/messages/de.json @@ -407,6 +407,7 @@ "mood": "Stimmung", "coach": "Coach", "skipToContent": "Zum Inhalt springen", + "breadcrumb": "Du bist hier", "sidebar": "Sidebar", "mainNavigation": "Hauptnavigation", "mobileNavigation": "Mobile Navigation", diff --git a/messages/en.json b/messages/en.json index ecb67bc15..9fd033567 100644 --- a/messages/en.json +++ b/messages/en.json @@ -407,6 +407,7 @@ "mood": "Mood", "coach": "Coach", "skipToContent": "Skip to content", + "breadcrumb": "You are here", "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", diff --git a/messages/es.json b/messages/es.json index 9f192a8e5..47605ac34 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8fe0245ee..87a42569d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 03989123d..8a150e8a1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -407,6 +407,7 @@ "mood": "Umore", "coach": "Coach", "skipToContent": "Vai al contenuto", + "breadcrumb": "Sei qui", "sidebar": "Barra laterale", "mainNavigation": "Navigazione principale", "mobileNavigation": "Navigazione mobile", diff --git a/messages/ko.json b/messages/ko.json index f569b77b4..7c6d3661f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -407,6 +407,7 @@ "mood": "기분", "coach": "코치", "skipToContent": "본문으로 건너뛰기", + "breadcrumb": "현재 위치", "sidebar": "사이드바", "mainNavigation": "주 메뉴", "mobileNavigation": "모바일 메뉴", diff --git a/messages/pl.json b/messages/pl.json index 0cc83a5e2..c96054e1a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/package.json b/package.json index 8b28e3063..e1a6e9449 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 50cd9111c..dea9a680e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index ff6265743..131f88d11 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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} + {!heroActive ?

{title}

: null} {heroActive ? ( diff --git a/src/components/insights/coach-panel/coach-top-bar-trail.tsx b/src/components/insights/coach-panel/coach-top-bar-trail.tsx new file mode 100644 index 000000000..a1e1c81e1 --- /dev/null +++ b/src/components/insights/coach-panel/coach-top-bar-trail.tsx @@ -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) `

`. + */ +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 ( + + + + ); +} diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index d43b951fa..eb756f365 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -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; + expanded: boolean; + label: string; + onToggle: () => void; + className?: string; +}) { + const Icon = expanded ? PanelRightOpen : PanelRightClose; + return ( + + + + + {label} + + ); +} + export function ConversationsPanel({ activeId, onSelect, @@ -156,7 +209,20 @@ export function ConversationsPanel({ const header = (inSheet: boolean) => (
{inSheet ? ( @@ -224,87 +290,73 @@ export function ConversationsPanel({ onUndoDeleteActive={(id) => onSelect(id)} className="min-h-0 flex-1" /> - + {/* 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. */} + + + + + + {t("insights.coach.newChat")} + + ); return ( - <> + - + expanded={expanded} + label={toggleLabel} + onToggle={toggle} + /> {docked ? ( - + + + ) : ( )} - + ); } diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 213d0a2e1..19ddea110 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -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({
+ {/* A page's side panel (the Coach's docked conversations) is a + column of this row, beside the top bar and `
`: 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. */} + diff --git a/src/components/layout/shell-side-panel.tsx b/src/components/layout/shell-side-panel.tsx new file mode 100644 index 000000000..761fbebda --- /dev/null +++ b/src/components/layout/shell-side-panel.tsx @@ -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 + * `
` rather than inside `
`. 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; diff --git a/src/components/layout/shell-slot.tsx b/src/components/layout/shell-slot.tsx new file mode 100644 index 000000000..fe54677eb --- /dev/null +++ b/src/components/layout/shell-slot.tsx @@ -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
; + } + + function Portal({ children }: { children: React.ReactNode }) { + const target = useSyncExternalStore( + subscribe, + () => outlet, + () => null, + ); + if (!target) return null; + return createPortal(children, target); + } + + return { Outlet, Portal }; +} diff --git a/src/components/layout/top-bar-actions.tsx b/src/components/layout/top-bar-actions.tsx index c053846e3..9eb88ba5a 100644 --- a/src/components/layout/top-bar-actions.tsx +++ b/src/components/layout/top-bar-actions.tsx @@ -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 - * `` anywhere in its own tree and the content is portalled - * into ``. 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 `` anywhere in its own + * tree and lands in ``. 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 ( -
- ); -} +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; diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 71436d8b5..2f06d89d9 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -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() { HealthLog - {/* Desktop: empty spacer (user controls are in sidebar) */} -
+ {/* Desktop: the page's context, where a page gives one (the Coach's + trail); otherwise an empty spacer. User controls are in the + sidebar. */} + {/* 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. */} - + {/* 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. */} + {/* Mobile-only auth section (desktop uses sidebar user section) */}