mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #1169 from MBombeck/feat/coach-panel-full-height
chore(release): v1.40.5
This commit is contained in:
20 files changed
+442
-160
No files matched your search
@@ -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,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
@@ -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");
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
"mood": "Stimmung",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Zum Inhalt springen",
|
||||
"breadcrumb": "Du bist hier",
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Hauptnavigation",
|
||||
"mobileNavigation": "Mobile Navigation",
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
"mood": "Mood",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Skip to content",
|
||||
"breadcrumb": "You are here",
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
"mood": "Umore",
|
||||
"coach": "Coach",
|
||||
"skipToContent": "Vai al contenuto",
|
||||
"breadcrumb": "Sei qui",
|
||||
"sidebar": "Barra laterale",
|
||||
"mainNavigation": "Navigazione principale",
|
||||
"mobileNavigation": "Navigazione mobile",
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
"mood": "기분",
|
||||
"coach": "코치",
|
||||
"skipToContent": "본문으로 건너뛰기",
|
||||
"breadcrumb": "현재 위치",
|
||||
"sidebar": "사이드바",
|
||||
"mainNavigation": "주 메뉴",
|
||||
"mobileNavigation": "모바일 메뉴",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user