Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui

# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:54:08 +02:00
commit 879473305c
115 files changed
+5037 -599

No files matched your search

@@ -0,0 +1,119 @@
/**
* The authenticated shell's geometry decisions, which no browser under test
* can show going wrong: Playwright's WebKit and Chromium have no floating
* toolbar, no rotation and no home indicator, so `100dvh` always equals the
* window there. On an iPad it does not, and that disagreement was the strip
* under the shell and the cut-off card. These hold the decisions that take
* the disagreement out; `e2e/tablet-shell.spec.ts` measures the result.
*/
import { describe, expect, it } from "vitest";
import { readSource } from "@/__tests__/helpers/source-files";
import {
MAIN_SCROLL_CLASS,
SHELL_ROOT_CLASS,
SUB_SHELL_GRID_FLOOR,
} from "../shell-metrics";
const shellSource = () =>
readSource("src/components/layout/auth-shell.tsx", [
"SHELL_ROOT_CLASS",
'data-slot="main-content-wrapper"',
]);
/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */
const MAIN_CONTENT_WRAPPER_CLASS =
/"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1];
const classes = (value: string) => value.split(/\s+/).filter(Boolean);
/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */
function rem(token: string): number {
const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token);
if (calc) return Number(calc[1]);
const step = /-(\d+(?:\.\d+)?)$/.exec(token);
if (step) return Number(step[1]) / 4;
throw new Error(`no length in ${token}`);
}
describe("the shell takes the window's edges", () => {
it("takes its height from the initial containing block, never from a viewport unit", () => {
const shell = classes(SHELL_ROOT_CLASS);
expect(shell).toEqual(
expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]),
);
// `fixed` re-rasterises the whole app in its own layer and traps the
// bottom bar's z-index in a stacking context.
expect(shell).not.toContain("fixed");
expect(SHELL_ROOT_CLASS).not.toMatch(
/\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/,
);
expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/);
});
it("AuthShell renders that box and flags the document for it", () => {
const source = shellSource();
expect(source).toContain("<div className={SHELL_ROOT_CLASS}>");
expect(source).not.toMatch(/shell-safe-area flex h-dvh/);
expect(source).toMatch(
/useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/,
);
const css = readSource("src/app/globals.css", '[data-scroll="shell"]');
expect(css).toMatch(
/html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/,
);
});
});
describe("the bottom edge is cleared in both shells", () => {
const fab = readSource(
"src/components/insights/layout-coach-fab.tsx",
'data-slot="coach-fab"',
);
const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]);
const phoneFabBottom = rem(
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const desktopFabBottom = rem(
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
fab,
)![0],
);
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
const main = classes(MAIN_SCROLL_CLASS);
it("the desktop shell's main keeps the home indicator off the last line", () => {
expect(main).not.toContain("shell-desktop:pb-0");
expect(main).toContain(
"shell-desktop:pb-[env(safe-area-inset-bottom,0px)]",
);
});
it("the last line scrolls clear of the Coach button in the phone shell", () => {
const mainPad = Number(
/^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec(
main.find((c) => c.startsWith("pb-[calc("))!,
)![1],
);
const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!);
expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize);
});
it("the last line scrolls clear of the Coach button in the desktop shell", () => {
const wrapperPad = rem(
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
);
expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize);
});
it("the sub-shell floor follows the desktop shell's padding", () => {
// Top bar 4rem + pt-6 + the wrapper's bottom padding.
const desktopPad = rem(
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
);
expect(SUB_SHELL_GRID_FLOOR).toContain(
`shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`,
);
});
});
@@ -63,6 +63,7 @@ import {
readSidebarCollapsedPref,
writeSidebarCollapsedPref,
} from "../sidebar-nav";
import { RAIL_NAV_SCROLL, RailScrollCue } from "../sidebar-nav-scroll";
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
import { visibleUtilityDestinations } from "../nav-model";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
@@ -577,3 +578,64 @@ describe("<SidebarNav> carries no rules for a viewport it never shows on", () =>
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
});
});
describe("<SidebarNav> keeps every destination reachable on a touch tablet", () => {
function navClass(html: string): string {
return (
/<nav[^>]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? ""
);
}
it("scrolls the list inside the rail and keeps the swipe there", () => {
const nav = navClass(render()).replaceAll("&amp;", "&").split(" ");
expect(RAIL_NAV_SCROLL.split(" ")).toEqual(
expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]),
);
for (const cls of RAIL_NAV_SCROLL.split(" ")) expect(nav).toContain(cls);
// Without it a swipe at either end chained to the document on iOS.
expect(nav).toContain("pointer-coarse:overscroll-contain");
// A focused row lands clear of the 44 px cue under a finger.
expect(nav).toContain("pointer-coarse:scroll-py-12");
});
it("gives the rail the home indicator's inset, so the avatar row stays clear", () => {
const aside = /<aside[^>]*class="([^"]*)"/.exec(render())?.[1] ?? "";
expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]");
});
it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => {
for (const direction of ["up", "down"] as const) {
const html = renderToStaticMarkup(
<RailScrollCue direction={direction} navRef={{ current: null }} />,
);
expect(html).toContain('data-slot="sidebar-nav-scroll-cue"');
expect(html).toContain(`data-direction="${direction}"`);
expect(html).toContain('tabindex="-1"');
expect(html).toContain('aria-hidden="true"');
const cls = /class="([^"]*)"/.exec(html)![1].split(" ");
expect(cls).toEqual(
expect.arrayContaining(["hidden", "pointer-coarse:flex"]),
);
// 44 px touch floor.
expect(cls).toContain("h-11");
}
});
it("scrolls the list most of a screen on per tap", () => {
const calls: ScrollToOptions[] = [];
const nav = {
clientHeight: 400,
scrollBy: (o: ScrollToOptions) => calls.push(o),
} as unknown as HTMLElement;
vi.stubGlobal("window", {
matchMedia: () => ({ matches: true }),
});
const element = RailScrollCue({
direction: "down",
navRef: { current: nav },
}) as React.ReactElement<{ onClick: () => void }>;
element.props.onClick();
vi.unstubAllGlobals();
expect(calls).toEqual([{ top: 300, behavior: "auto" }]);
});
});
+23 -23
View File
@@ -38,6 +38,8 @@ import {
} from "@/components/layout/module-page-gate";
import { cn } from "@/lib/utils";
import { isFullBleedPage } from "./full-bleed";
import { useDocumentScrollMode } from "./document-scroll-mode";
import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics";
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
import { SidebarNav } from "./sidebar-nav";
@@ -166,25 +168,18 @@ export function AuthShell({
const isOnboardingPage = isOnboardingPathname(pathname);
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
// only for the body-scrolled shells (login / register / onboarding /
// standalone legal pages). The authenticated branch is height-locked and
// scrolls inside its own `<main>`, which reserves its own gutter; applying
// the document gutter there too produced a second, never-painted gutter on
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
// so the CSS rule scopes to them.
// Who scrolls the page, on `<html>` (`document-scroll-mode.ts`).
const isBodyScrolled = isPublicPage || isOnboardingPage;
useEffect(() => {
const root = document.documentElement;
if (isBodyScrolled) {
root.setAttribute("data-scroll", "body");
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [isBodyScrolled]);
const rendersAppShell =
!isBodyScrolled &&
!accessRefused &&
!isLoading &&
isAuthenticated &&
recordSessionTransition.phase === "ready" &&
!recordSessionPending;
useDocumentScrollMode(
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
);
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
// mounted across every route change, so its scroll offset survives
@@ -438,9 +433,13 @@ export function AuthShell({
five pixels of content box and pushed the logo and the account menu
across its bottom border. `shell-safe-area` paints the strip behind
the status bar in the chrome colour (globals.css). The bottom inset
stays with the bottom nav and `<main>`, which own that edge.
stays with the bottom nav, `<main>` and the rail, which own that edge.
The shell takes the window's edges rather than `h-dvh`, and the
document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in
`shell-metrics.ts` says why.
*/}
<div className="shell-safe-area flex h-dvh flex-col">
<div className={SHELL_ROOT_CLASS}>
{/*
v1.4.43 — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
@@ -500,7 +499,7 @@ export function AuthShell({
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className={cn(
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
MAIN_SCROLL_CLASS,
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
)}
>
@@ -516,7 +515,8 @@ export function AuthShell({
v1.16.8 — the bottom padding clears the always-on Coach
FAB (48 px button + its offset above the bottom-nav band /
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
can always scroll out from under the floating button; the
desktop shell's sits 5.5rem up, so `pb-24` there.
*/}
<div
data-slot="main-content-wrapper"
@@ -524,7 +524,7 @@ export function AuthShell({
className={
fullBleed
? "flex min-h-0 flex-1 flex-col"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
{outsideSharedRecord ? (
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
/**
* Who scrolls the page, published on `<html>` as `data-scroll` for the rules
* in `globals.css`.
*
* - `"body"`: the body-scrolled shells (login / register / onboarding /
* standalone legal pages), where the document itself overflows. They get
* the document-level `scrollbar-gutter`. The authenticated branch
* scrolls inside its own `<main>`, which reserves its own gutter; the
* document gutter there too produced a second, never-painted gutter on
* classic-scrollbar platforms.
* - `"shell"`: the app shell. It takes the window's edges and `<main>` is its
* one vertical scroller, so the document gets no scroll and no rubber band
* of its own. On iOS a bounce of the document held the next swipe, and the
* list under the finger did not move.
* - `null`: anything else (the refusal panel, the loading states) keeps the
* browser's defaults.
*/
export type DocumentScrollMode = "body" | "shell" | null;
export function useDocumentScrollMode(mode: DocumentScrollMode): void {
useEffect(() => {
const root = document.documentElement;
if (mode) {
root.setAttribute("data-scroll", mode);
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [mode]);
}
+40 -4
View File
@@ -18,15 +18,51 @@
* never drift — Admin previously carried a stale, all-breakpoint
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
*
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom
* padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) =
* 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here —
* and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `<main>`;
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
* over-reserve. A fully self-accounting `min-h-full` chain would need the
* AuthShell content wrapper to pass a definite height through, which is out of
* scope for the shell-only over-scroll fix.)
*/
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
export const SUB_SHELL_GRID_FLOOR =
"md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]";
/**
* The authenticated shell's outer box. It takes the window's edges
* (`absolute inset-0` against the initial containing block) rather than
* `h-dvh`, and the top and side safe-area insets (`shell-safe-area`,
* globals.css); the bottom inset belongs to whatever owns the bottom edge in
* each shell.
*
* Not a viewport unit: `h-dvh` is the browser's idea of the visible height,
* and on iPadOS Safari that idea and the area it lays out disagree after a
* rotation and under the floating toolbars. A short value left a strip of
* page colour under the shell with the last card cut off above it; a tall one
* made the document scroll by the difference, and that document scroll took
* the swipes meant for `<main>`. The initial containing block is the layout
* itself, recomputed with every rotation and resize, and the document never
* scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box
* cannot drift from the window either.
*
* Not `fixed`: a fixed box gets a compositing layer of its own, which
* re-rasterised the text of every page (the antialiasing moved on every
* screen), and forms a stacking context that would trap the bottom bar's
* z-index under the Coach button and the toasts.
*/
export const SHELL_ROOT_CLASS =
"shell-safe-area absolute inset-0 flex flex-col";
/**
* `<main>`, the shell's one vertical scroller. Phone shell: it clears the
* bottom nav and the home indicator. Desktop shell: there is no bar, and it
* clears the home indicator of an installed app on a tablet (zero in a
* browser tab), so the last line never sits under it.
*/
export const MAIN_SCROLL_CLASS =
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]";
/**
* The app-chrome header band: one 4rem strip whose bottom border draws the
@@ -0,0 +1,192 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* The rail's destination list as a scroll region: the one part of the rail
* that gives up height, between the pinned logo band above and the pinned
* footer group and account row below (`sidebar-nav.tsx`).
*/
/**
* The nav list's scroll region. `scroll-py-6` keeps a focused row clear of
* the edge fades; under a finger `scroll-py-12` keeps it clear of the scroll
* cue as well, which is 44 px tall. `overscroll-contain` is the own-scroll
* list rule (ui-standards §2), taken under a finger, where the swipe that
* reaches an end is the problem. A mouse or trackpad has nothing to chain
* to (the app shell's document neither scrolls nor bounces), and on Chromium
* the property moves the list into a layer of its own, which re-rasterised
* every label in the rail by a shade.
*/
export const RAIL_NAV_SCROLL =
"min-h-0 flex-1 scroll-py-6 pointer-coarse:scroll-py-12 overflow-y-auto pointer-coarse:overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
/**
* The scroll cue at an edge of the nav list that has more destinations
* beyond it, under a finger only.
*
* A touch tablet keeps the 44 px rows, so in landscape (an iPad is 670 to
* 830 px tall in Safari) three to seven destinations sit below the fold, and
* the list has no scrollbar: iOS paints none on an element at rest. The fade
* alone did not read as "swipe here", and the rail looked as if it simply
* ended. The cue says so and is a way there in itself: a tap scrolls the
* list most of a screen on.
*
* It is a pointer shortcut and nothing else. The keyboard reaches every row
* by Tab (focus scrolls the row into view) and a screen reader reads the
* list whole, so the cue stays out of the focus order and the accessibility
* tree. A mouse scrolls with the wheel and never sees it.
*/
export function RailScrollCue({
direction,
navRef,
}: {
direction: "up" | "down";
navRef: React.RefObject<HTMLElement | null>;
}) {
const Icon = direction === "down" ? ChevronDown : ChevronUp;
return (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
data-slot="sidebar-nav-scroll-cue"
data-direction={direction}
onClick={() => {
const nav = navRef.current;
if (!nav) return;
const reduce = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
nav.scrollBy({
top: (direction === "down" ? 1 : -1) * nav.clientHeight * 0.75,
behavior: reduce ? "auto" : "smooth",
});
}}
className={cn(
"absolute inset-x-0 z-10 hidden h-11 justify-center outline-none pointer-coarse:flex",
direction === "down"
? "bottom-0 items-end pb-1"
: "top-0 items-start pt-1",
)}
>
<span className="bg-sidebar border-sidebar-border text-foreground flex size-7 items-center justify-center rounded-full border shadow-sm">
<Icon className="size-4" />
</span>
</button>
);
}
/** Height of the fade at a scroll edge that has more content beyond it. */
const RAIL_FADE = "1.5rem";
/**
* The fade mask for the list's scroll edges: a top fade while there is
* content above, a bottom fade while there is content below. A mask paints
* alpha only, so it reads the same in light and dark and never moves layout.
*/
function railFadeMask(
top: boolean,
bottom: boolean,
): React.CSSProperties | undefined {
if (!top && !bottom) return undefined;
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
return { maskImage: image, WebkitMaskImage: image };
}
/**
* Whether the nav list is taller than the room the rail leaves it, i.e.
* whether it scrolls, and which of its edges have more content beyond them.
* Re-measured when the nav (viewport height) or the list (entries arriving
* as modules resolve) changes size, and on scroll.
*/
export function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
overflows: boolean;
moreAbove: boolean;
moreBelow: boolean;
} {
const [edges, setEdges] = useState({
overflows: false,
moreAbove: false,
moreBelow: false,
});
useEffect(() => {
const nav = navRef.current;
if (!nav || typeof ResizeObserver === "undefined") return;
// Strict: any overflow, the same test that makes the list scroll.
const update = () => {
const overflows = nav.scrollHeight > nav.clientHeight;
const moreAbove = overflows && nav.scrollTop > 0.5;
const moreBelow =
overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
setEdges((prev) =>
prev.overflows === overflows &&
prev.moreAbove === moreAbove &&
prev.moreBelow === moreBelow
? prev
: { overflows, moreAbove, moreBelow },
);
};
const observer = new ResizeObserver(update);
observer.observe(nav);
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
nav.addEventListener("scroll", update, { passive: true });
update();
return () => {
observer.disconnect();
nav.removeEventListener("scroll", update);
};
}, [navRef]);
return edges;
}
/**
* The list's region: the `<nav>` that scrolls, with its edge fades, and the
* touch cues over its edges.
*
* The list scrolls without a native scrollbar (a classic scrollbar with
* arrow buttons read as a foreign body in the rail); fades at the edges say
* there is more, and focus keeps the focused row clear of them.
* `overscroll-contain` (under a finger) keeps a swipe that reaches either end
* inside the rail: on iOS it chained to the document, which rubber-banded and
* held the next swipe, so the list seemed not to scroll.
*/
export function RailNavList({
navRef,
edges,
label,
className,
children,
}: {
navRef: React.RefObject<HTMLElement | null>;
edges: { moreAbove: boolean; moreBelow: boolean };
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div
data-slot="sidebar-nav-region"
className="relative flex min-h-0 flex-1 flex-col"
>
<nav
ref={navRef}
aria-label={label}
className={cn(RAIL_NAV_SCROLL, className)}
style={railFadeMask(edges.moreAbove, edges.moreBelow)}
data-fade-top={edges.moreAbove ? "true" : undefined}
data-fade-bottom={edges.moreBelow ? "true" : undefined}
onFocus={(event) => {
event.target.scrollIntoView?.({ block: "nearest" });
}}
>
{children}
</nav>
{edges.moreAbove && <RailScrollCue direction="up" navRef={navRef} />}
{edges.moreBelow && <RailScrollCue direction="down" navRef={navRef} />}
</div>
);
}
+14 -89
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import {
Bell,
Keyboard,
@@ -27,6 +27,10 @@ import {
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RailNavList,
useNavOverflows,
} from "@/components/layout/sidebar-nav-scroll";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
@@ -367,69 +371,6 @@ function railRowClass(collapsed: boolean): string {
);
}
/** Height of the fade at a scroll edge that has more content beyond it. */
const RAIL_FADE = "1.5rem";
/**
* The fade mask for the list's scroll edges: a top fade while there is
* content above, a bottom fade while there is content below. A mask paints
* alpha only, so it reads the same in light and dark and never moves layout.
*/
function railFadeMask(
top: boolean,
bottom: boolean,
): React.CSSProperties | undefined {
if (!top && !bottom) return undefined;
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
return { maskImage: image, WebkitMaskImage: image };
}
/**
* Whether the nav list is taller than the room the rail leaves it, i.e.
* whether it scrolls, and which of its edges have more content beyond them.
* Re-measured when the nav (viewport height) or the list (entries arriving
* as modules resolve) changes size, and on scroll.
*/
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
overflows: boolean;
moreAbove: boolean;
moreBelow: boolean;
} {
const [edges, setEdges] = useState({
overflows: false,
moreAbove: false,
moreBelow: false,
});
useEffect(() => {
const nav = navRef.current;
if (!nav || typeof ResizeObserver === "undefined") return;
// Strict: any overflow, the same test that makes the list scroll.
const update = () => {
const overflows = nav.scrollHeight > nav.clientHeight;
const moreAbove = overflows && nav.scrollTop > 0.5;
const moreBelow =
overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
setEdges((prev) =>
prev.overflows === overflows &&
prev.moreAbove === moreAbove &&
prev.moreBelow === moreBelow
? prev
: { overflows, moreAbove, moreBelow },
);
};
const observer = new ResizeObserver(update);
observer.observe(nav);
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
nav.addEventListener("scroll", update, { passive: true });
update();
return () => {
observer.disconnect();
nav.removeEventListener("scroll", update);
};
}, [navRef]);
return edges;
}
/**
* The sidebar's collapse / expand control, drawn as a footer nav row.
*
@@ -547,11 +488,8 @@ export function SidebarNav() {
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
const navRef = useRef<HTMLElement | null>(null);
const {
overflows: navOverflows,
moreAbove: navMoreAbove,
moreBelow: navMoreBelow,
} = useNavOverflows(navRef);
const navEdges = useNavOverflows(navRef);
const navOverflows = navEdges.overflows;
function toggleCollapsed() {
const next = !collapsed;
@@ -689,7 +627,7 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none",
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-[env(safe-area-inset-bottom,0px)] transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? "w-16" : "w-64",
)}
>
@@ -737,24 +675,11 @@ export function SidebarNav() {
</Link>
</div>
<nav
ref={navRef}
aria-label={t("nav.mainNavigation")}
// The list scrolls without a native scrollbar (a classic
// scrollbar with arrow buttons read as a foreign body in the
// rail); fades at the edges say there is more, and focus keeps
// the focused row clear of them.
className={cn(
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
collapsed ? "p-1.5" : "p-3",
RAIL_SHORT_PY,
)}
style={railFadeMask(navMoreAbove, navMoreBelow)}
data-fade-top={navMoreAbove ? "true" : undefined}
data-fade-bottom={navMoreBelow ? "true" : undefined}
onFocus={(event) => {
event.target.scrollIntoView?.({ block: "nearest" });
}}
<RailNavList
navRef={navRef}
edges={navEdges}
label={t("nav.mainNavigation")}
className={cn(collapsed ? "p-1.5" : "p-3", RAIL_SHORT_PY)}
>
<div className={RAIL_LIST_GAP}>
{visibleNavItems.map((item) => {
@@ -811,7 +736,7 @@ export function SidebarNav() {
);
})}
</div>
</nav>
</RailNavList>
{/* Bottom utility links — the shared utility tail (minus
Notifications, which lives in the avatar menu) with the
@@ -0,0 +1,124 @@
/**
* The Apple Health write-back controls on the API settings (#1272).
*
* SSR-only, as the rest of this directory: the render pins which token rows
* carry the switch and in which state, when the notice about readings that
* belong to no token shows, and the write the switch sends is pinned against
* a stubbed `fetch`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
import {
AppleHealthMirrorOptIn,
AppleHealthMirrorSwitch,
UnattributedReadingsNotice,
patchTokenMirror,
} from "../apple-health-mirror-token";
function render(node: React.ReactNode, client = new QueryClient()) {
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
const token = (permissions: string[], mirrorToAppleHealth = false) => ({
id: "tok-1",
name: "Scale",
permissions,
mirrorToAppleHealth,
});
describe("the switch on a token row", () => {
it("appears on a measurement token only", () => {
expect(
render(<AppleHealthMirrorSwitch token={token(["measurements:write"])} />),
).toContain('data-slot="apple-health-mirror-switch"');
for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) {
expect(
render(<AppleHealthMirrorSwitch token={token(permissions)} />),
).toBe("");
}
});
it("shows the stored setting and names the token", () => {
const on = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"], true)} />,
);
expect(on).toContain('aria-checked="true"');
expect(on).toContain("Write readings from Scale to Apple Health");
const off = render(
<AppleHealthMirrorSwitch token={token(["measurements:write"])} />,
);
expect(off).toContain('aria-checked="false"');
});
});
describe("the opt-in on the mint form", () => {
it("reads as the setting with its explanation", () => {
const html = render(
<AppleHealthMirrorOptIn checked={false} onCheckedChange={() => {}} />,
);
expect(html).toContain("Write readings to Apple Health");
expect(html).toContain("adds this token&#x27;s readings to Apple Health");
});
});
describe("the notice about readings no token reaches", () => {
function withSummary(unattributedReadings: number) {
const client = new QueryClient();
client.setQueryData(queryKeys.measurementTokenSummary(), {
unattributedReadings,
});
return render(<UnattributedReadingsNotice />, client);
}
it("shows only when there are such readings", () => {
expect(withSummary(3)).toContain(
"Readings sent before this setting existed can&#x27;t be matched to a token",
);
expect(withSummary(0)).toBe("");
expect(render(<UnattributedReadingsNotice />)).toBe("");
});
});
describe("the write", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
data: { id: "tok 1", mirrorToAppleHealth: true },
error: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => vi.unstubAllGlobals());
it("patches the token with the one field", async () => {
await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({
id: "tok 1",
mirrorToAppleHealth: true,
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("/api/tokens/tok%201");
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body as string)).toEqual({
mirrorToAppleHealth: true,
});
});
});
@@ -0,0 +1,130 @@
"use client";
import { Key } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
/** The ingest endpoints a token reaches, with the header and a body sketch. */
export function ApiEndpointsCard() {
const { t } = useTranslations();
// Dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
// so adding a row remains a one-line edit on both surfaces — which is
// what the two measurement ingest rows below cost.
const endpoints = [
{
method: "POST",
path: "/api/ingest/medication",
auth: "Authorization: Bearer hlk_...",
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
},
{
method: "POST",
path: "/api/measurements",
auth: "Authorization: Bearer hlk_...",
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
},
{
method: "POST",
path: "/api/measurements/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/workouts/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
auth: "Authorization: Bearer hlk_...",
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
},
];
return (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointMethod")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointPath")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointAuth")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointExample")}
</th>
</tr>
</thead>
<tbody>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
+142 -225
View File
@@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card";
import {
AppleHealthMirrorOptIn,
AppleHealthMirrorSwitch,
UnattributedReadingsNotice,
} from "@/components/settings/apple-health-mirror-token";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -45,6 +51,7 @@ interface ApiTokenInfo {
expiresAt: string | null;
createdAt: string;
revoked: boolean;
mirrorToAppleHealth: boolean;
}
export function ApiSection() {
@@ -78,6 +85,8 @@ function MeasurementTokensCard() {
icon={Upload}
slot="settings-measurements-token-created"
anchor="measurements-token"
mirrorOptIn
footer={<UnattributedReadingsNotice />}
copy={{
title: t("settings.measurementsToken.title"),
description: t("settings.measurementsToken.description"),
@@ -147,7 +156,12 @@ function IngestTokenCard({
slot,
anchor,
copy,
mirrorOptIn = false,
footer,
}: {
/** Offer the Apple Health write-back on the mint (measurement tokens). */
mirrorOptIn?: boolean;
footer?: React.ReactNode;
/** Deep-link anchor for the card (`/settings/api#<anchor>`). */
anchor: string;
endpoint:
@@ -172,6 +186,7 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const [mirror, setMirror] = useState(false);
// The token outlives the session, so minting one asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
@@ -184,7 +199,10 @@ function IngestTokenCard({
try {
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
const body = JSON.stringify({ name: newName.trim() });
const body = JSON.stringify({
name: newName.trim(),
...(mirrorOptIn && { mirrorToAppleHealth: mirror }),
});
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(endpoint, {
@@ -198,6 +216,7 @@ function IngestTokenCard({
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
setMirror(false);
// The sibling card lists this token, so it has to hear about it.
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} else {
@@ -263,6 +282,12 @@ function IngestTokenCard({
{t("common.create")}
</Button>
</form>
{mirrorOptIn && (
<AppleHealthMirrorOptIn
checked={mirror}
onCheckedChange={setMirror}
/>
)}
{newToken && (
<div
@@ -295,137 +320,16 @@ function IngestTokenCard({
)}
{tokenMsg && <p className="text-destructive text-sm">{tokenMsg}</p>}
{footer}
</div>
</SettingsCard>
);
}
function ApiEndpointsCard() {
const { t } = useTranslations();
// v1.4.27 — dual-render: <md gets a stacked card list, md+
// keeps the original table. Mobile cards wrap inside their box and
// never trigger horizontal scroll, mirroring the pattern from
// `<ApiTokenOverviewSection>`. The endpoint catalogue is array-driven
// so adding a row remains a one-line edit on both surfaces — which is
// what the two measurement ingest rows below cost.
const endpoints = [
{
method: "POST",
path: "/api/ingest/medication",
auth: "Authorization: Bearer hlk_...",
example: `{ "medicationName": "...", "scheduledFor": "..." }`,
},
{
method: "POST",
path: "/api/measurements",
auth: "Authorization: Bearer hlk_...",
example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
},
{
method: "POST",
path: "/api/measurements/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
},
{
method: "POST",
path: "/api/workouts/batch",
auth: "Authorization: Bearer hlk_...",
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
},
{
method: "POST",
path: "/api/documents/inbound",
auth: "Authorization: Bearer hlk_...",
example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
},
];
return (
<SettingsCard>
<SettingsCardHeader
anchor="api-endpoints"
icon={Key}
title={t("settings.apiEndpointsTitle")}
description={t("settings.apiEndpointsDescription")}
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointMethod")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointPath")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointAuth")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointExample")}
</th>
</tr>
</thead>
<tbody>
{endpoints.map((endpoint) => (
<tr key={`${endpoint.method} ${endpoint.path}`}>
<td className="px-3 py-2 font-medium">{endpoint.method}</td>
<td className="px-3 py-2 font-mono">{endpoint.path}</td>
<td className="px-3 py-2 font-mono">{endpoint.auth}</td>
<td className="px-3 py-2 font-mono">{endpoint.example}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
<li
key={`${endpoint.method} ${endpoint.path}`}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3 text-xs"
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-xs">
{endpoint.method}
</Badge>
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
{endpoint.example}
</code>
</div>
</li>
))}
</ul>
</SettingsCard>
);
}
function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const {
data: tokens,
@@ -557,7 +461,10 @@ function ApiTokensCard() {
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="px-3 py-2 font-medium">
{tok.name}
<AppleHealthMirrorSwitch token={tok} />
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
@@ -660,6 +567,7 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
@@ -679,107 +587,7 @@ function ApiTokensCard() {
</div>
{revokedTokens.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">
{tok.name}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">
{tok.name}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
</li>
))}
</ul>
</>
)}
</div>
<RevokedTokens tokens={revokedTokens} />
)}
</>
)}
@@ -787,3 +595,112 @@ function ApiTokensCard() {
</SettingsCard>
);
}
/** The revoked tokens, folded away until asked for. Read-only but for the switch. */
function RevokedTokens({ tokens: revokedTokens }: { tokens: ApiTokenInfo[] }) {
const { t } = useTranslations();
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border relative hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-sm md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">
{tok.name}
<AppleHealthMirrorSwitch token={tok} />
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card list — stacked meta layout, no
horizontal scroll. Revoked tokens are read-only so
no action footer is needed. */}
<ul
className="space-y-2 md:hidden"
data-testid="settings-api-tokens-revoked-mobile-list"
>
{revokedTokens.map((tok) => (
<li
key={tok.id}
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
>
<p className="text-sm font-medium break-words">{tok.name}</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AppleHealthMirrorSwitch token={tok} />
</li>
))}
</ul>
</>
)}
</div>
);
}
@@ -0,0 +1,138 @@
"use client";
/**
* A measurement token's one setting (#1272): whether the HealthLog app on the
* iPhone writes the readings that token sent into Apple Health.
*
* The readings keep their EXTERNAL source either way; the server resolves
* the decision per reading and the app follows it. Three pieces, used by the
* API settings: the switch on a measurement token's row, the opt-in on the
* mint form, and the notice about readings that belong to no token.
*/
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/use-auth";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/** The scope a measurement token carries, and nothing else. */
const MEASUREMENTS_WRITE = "measurements:write";
export function isMeasurementTokenInfo(permissions: readonly string[]) {
return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE;
}
/** `PATCH /api/tokens/{id}`: the one write behind the switch. */
export function patchTokenMirror(
tokenId: string,
mirrorToAppleHealth: boolean,
) {
return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>(
`/api/tokens/${encodeURIComponent(tokenId)}`,
{ mirrorToAppleHealth },
);
}
/** The switch on a measurement token's row; nothing on any other token. */
export function AppleHealthMirrorSwitch({
token,
}: {
token: {
id: string;
name: string;
permissions: string[];
mirrorToAppleHealth: boolean;
};
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
if (!isMeasurementTokenInfo(token.permissions)) return null;
async function handleChange(next: boolean) {
setSaving(true);
try {
await patchTokenMirror(token.id, next);
await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} catch (failure) {
showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), {
cause: failure,
});
} finally {
setSaving(false);
}
}
return (
<label
className="flex min-h-11 items-center gap-2 sm:min-h-9"
data-slot="apple-health-mirror-switch"
>
<Switch
checked={token.mirrorToAppleHealth}
onCheckedChange={(next) => void handleChange(next)}
disabled={saving}
aria-label={t("settings.measurementsToken.appleHealth.toggleAria", {
name: token.name,
})}
/>
<span className="text-xs">
{t("settings.measurementsToken.appleHealth.label")}
</span>
</label>
);
}
/** The opt-in on the measurement mint form. Off unless the person ticks it. */
export function AppleHealthMirrorOptIn({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (next: boolean) => void;
}) {
const { t } = useTranslations();
return (
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-slot="apple-health-mirror-opt-in"
checked={checked}
onCheckedChange={(next) => onCheckedChange(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.measurementsToken.appleHealth.label")}
<span className="text-muted-foreground block text-xs">
{t("settings.measurementsToken.appleHealth.createHint")}
</span>
</span>
</label>
);
}
/**
* Shown only when the account holds EXTERNAL readings no token reaches: sent
* before readings were linked to their token, by an account that held more
* than one, so no setting here can put them into Apple Health.
*/
export function UnattributedReadingsNotice() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.measurementTokenSummary(),
queryFn: () =>
apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"),
enabled: isAuthenticated,
});
if (!data || !(data.unattributedReadings > 0)) return null;
return (
<p className="text-sm" data-slot="apple-health-unattributed-notice">
{t("settings.measurementsToken.appleHealth.unattributed")}
</p>
);
}
+17
View File
@@ -30,4 +30,21 @@ describe("<TabsList>", () => {
// Horizontal scroll behaviour is still intact for long strips.
expect(list![0]).toMatch(/\boverflow-x-auto\b/);
});
it("leaves the vertical pan to the page", () => {
// `touch-pan-x` (or `touch-none`) on the strip refuses a vertical pan
// that starts on it, so a swipe up or down over a tab strip scrolled
// nothing. The strip pans sideways through its own overflow.
const html = renderToStaticMarkup(
<Tabs defaultValue="a">
<TabsList>
<TabsTrigger value="a">A</TabsTrigger>
</TabsList>
</Tabs>,
);
const list = html.match(/<div\b[^>]*data-slot="tabs-list"[^>]*>/)![0];
expect(list).not.toMatch(
/\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/,
);
});
});
+8 -3
View File
@@ -29,8 +29,8 @@ function Tabs({
// mobile when more than ~3 tabs render at typical label widths
// (`/insights`, `/admin`, `/settings/*`). Tab labels are i18n-driven
// so we cannot truncate them deterministically. Instead, the list
// becomes horizontally scrollable on mobile via `overflow-x-auto`
// plus `touch-pan-x` for the iOS PWA, and the wrapping tabs preserve
// becomes horizontally scrollable on mobile via `overflow-x-auto`, and the
// wrapping tabs preserve
// `whitespace-nowrap` so labels never word-wrap mid-tab. Desktop
// looks identical because the inline-flex shrink-wraps inside the
// wider container.
@@ -47,10 +47,15 @@ function Tabs({
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
// (`focus-visible:ring-[3px]`) on triggers stays inside the
// `overflow-y-hidden` clamp without clipping the bottom of the ring.
// No `touch-action` on the strip: `overflow-x-auto` already pans it sideways
// under a finger, and the `touch-pan-x` it once carried also refused the
// vertical pan, so a swipe up or down that began on a tab strip scrolled
// nothing at all (on a tablet the strip is 52 px of the page under the 44 px
// touch floor).
// Under a coarse pointer the strip sizes to its triggers, which take the
// 44 px touch floor; a mouse keeps the compact 36 px strip.
const tabsListVariants = cva(
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
{
variants: {
variant: {