Merge pull request #447 from MBombeck/release/v1.28.3

release/v1.28.3 — consistent spacing and alignment
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-09 12:28:24 +02:00
commit 45b7eb53ff
27 files changed
+276 -195

No files matched your search

+16
View File
@@ -2,6 +2,22 @@
## [Unreleased]
## [1.28.3] — 2026-07-09 — Consistent spacing and alignment
A consistency pass across the app, driven by a layout audit. Insight cards now
share one left edge — headings and body text line up instead of drifting from
card to card (worst on mobile). Settings and admin pages no longer scroll past
their content into empty space. On mobile, Settings and Notifications live only
in the account menu instead of being duplicated in the overflow menu. The
dashboard greeting keeps its action beside the text rather than pushing it onto
its own line. Preventive-care, cycle, and onboarding cards adopt the shared card
anatomy. The spacing and alignment rules behind these are written down so they
hold going forward.
Also folds in the document PDF rasterization runtime: the image renderer now
loads in the container, so a scanned PDF is read on an image-only AI provider
(previously it quietly fell back to text-only).
## [1.28.2] — 2026-07-09 — Automatic AI document reading (opt-in)
One opt-in in AI settings: read every uploaded document automatically with your
+14
View File
@@ -81,6 +81,20 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
# native binary's package into the standalone tree but NOT the pnpm symlink that
# @napi-rs/canvas's loader resolves it through, so at runtime it fails with
# "Failed to load native binding" and rasterization silently degrades. Copy the
# prebuilt .node into the canvas package dir itself so the loader's built-in
# local-file fallback (`require('./skia.<triple>.node')`) resolves it. Per-arch
# buildx installs only the matching musl triple, so the find is arch-correct.
RUN CANVAS_DIR="$(find ./node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
NODE_BIN="$(find ./node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
if [ -n "$CANVAS_DIR" ] && [ -n "$NODE_BIN" ]; then \
cp "$NODE_BIN" "$CANVAS_DIR/" && chown nextjs:nodejs "$CANVAS_DIR/$(basename "$NODE_BIN")" && \
echo "canvas native binding staged: $(basename "$NODE_BIN") -> $CANVAS_DIR"; \
else echo "WARN: canvas native binding not found; PDF rasterization will degrade to local text"; fi
# Copy Prisma for migrations (schema, migration SQL, config, engines)
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.2
version: 1.28.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+30 -11
View File
@@ -13,18 +13,28 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* the shell's own budget and reopens the dark-band-below-the-last-card
* bug.
*
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content
* element + the shell-owned padding + tolerance. Runs across the
* settings routes (short hub, long sortable-list subpages, long form
* section) at desktop AND phone-shaped viewports, because the two shell
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real
* content element + the shell-owned padding + tolerance. "Real content"
* is measured against each element's CONTENT-box bottom, not its padding-
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
* no longer folds that gutter into `contentBottom`, so a redundant reserve
* FAILS the assertion instead of being absorbed by it. Runs across both
* two-column sub-shells — Settings AND Admin (they share the grid-floor
* source) — at desktop AND phone-shaped viewports, because the two shell
* paddings differ per breakpoint.
*/
const ROUTES = [
// Settings sub-shell — short hub, long sortable-list subpages, long form.
"/settings/layout",
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/account",
"/settings/notifications",
// Admin sub-shell — short overview + a longer list page. Guards that the
// pre-#154 column reserve stays retired on every admin breakpoint.
"/admin",
"/admin/login-overview",
"/admin/system-status",
] as const;
const VIEWPORTS = [
@@ -32,7 +42,7 @@ const VIEWPORTS = [
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
test.describe("settings vertical over-scroll guard", () => {
test.describe("settings + admin vertical over-scroll guard", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
@@ -56,11 +66,17 @@ test.describe("settings vertical over-scroll guard", () => {
if (!main) return null;
const wrapper = main.firstElementChild as HTMLElement | null;
if (!wrapper) return null;
// Lowest content edge: max bottom over the wrapper's visible,
// non-fixed descendants, in the scroll container's coordinate
// space. Elements inside nested scroll containers are clipped
// by their own overflow and never add page scroll height, so
// skip anything whose scrollable ancestor is not `main`.
// Lowest content edge: max CONTENT-box bottom over the wrapper's
// visible, non-fixed descendants, in the scroll container's
// coordinate space. Using the content box (padding-box bottom
// minus the element's own bottom padding + border) means a
// layout column's own `pb-*` gutter is NOT counted — its last
// real child (a card) is still measured through the column's
// content box, so a redundant sub-shell bottom gutter shows up
// as pure over-scroll instead of inflating the allowed budget.
// Elements inside nested scroll containers are clipped by their
// own overflow and never add page scroll height, so skip
// anything whose scrollable ancestor is not `main`.
let maxBottom = 0;
const mainTop = main.getBoundingClientRect().top;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
@@ -84,7 +100,10 @@ test.describe("settings vertical over-scroll guard", () => {
p = p.parentElement;
}
if (inner) continue;
const bottom = rect.bottom + main.scrollTop - mainTop;
const padBottom = parseFloat(cs.paddingBottom) || 0;
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
const bottom =
rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop;
if (bottom > maxBottom) maxBottom = bottom;
}
return {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.2",
"version": "1.28.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.2";
/* @sw-version-fallback */ "v1.28.3";
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/*`
+15 -7
View File
@@ -37,6 +37,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
@@ -304,7 +305,12 @@ export function AdminShell({ active, children }: AdminShellProps) {
col 1, cards in row 2 / col 2. The nav's first item starts the same
grid row as the first card, so they align by construction across
locales and however the heading wraps — no fixed-height spacer. */}
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
<div
className={cn(
"grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]",
SUB_SHELL_GRID_FLOOR,
)}
>
{/* Heading — desktop only (mobile renders it above the strip). */}
<div className="hidden md:col-start-2 md:row-start-1 md:block">
{headingBlock}
@@ -366,12 +372,14 @@ export function AdminShell({ active, children }: AdminShellProps) {
</ul>
</aside>
{/* Main column (cards) — row 2 / col 2. Same
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
so navigating between admin sub-pages (e.g. system-status
→ login-overview) does not jump the page height while the
new section is still fetching. */}
<main className="min-h-[calc(100dvh-12rem)] min-w-0 md:col-start-2 md:row-start-2">
{/* Main column (cards) — row 2 / col 2, the grid's `1fr` row, so it
stretches to fill the shared `SUB_SHELL_GRID_FLOOR` on the grid
(matches `<SettingsShell>`) without over-shooting. The loading-jump
floor lives on the GRID, not here: a column-level
`min-h-[calc(100dvh-12rem)]` reserve (the pre-#154 shape this used
to carry) over-reserves on short sections and on mobile, scrolling
the page into a dark band below the last card. */}
<main className="min-w-0 md:col-start-2 md:row-start-2">
{children}
</main>
</div>
@@ -114,7 +114,7 @@ export function CycleInsightSummaryCard() {
data-slot="cycle-insight-summary"
data-phase={phase ?? "none"}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
className="wellness-tile wellness-tile-rise rounded-xl p-4 md:p-6"
>
{/* Zone 1 — the current phase / cycle-day read. */}
<div className="min-w-0">
+1 -1
View File
@@ -109,7 +109,7 @@ export function CycleRingTile({ className }: { className?: string }) {
// per-phase hue arc inside the ring is the only saturated thing.
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-4 focus-visible:ring-2 focus-visible:outline-none md:p-6",
play && "wellness-tile-rise",
className,
)}
+4 -4
View File
@@ -219,13 +219,13 @@ export function CycleView() {
: undefined
}
className={cn(
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5",
"wellness-tile flex flex-col items-center gap-3 rounded-xl p-4 md:p-6",
play && "wellness-tile-rise",
)}
>
{loading ? (
<div className="flex h-[220px] items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
) : calendarError ? (
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
@@ -347,7 +347,7 @@ export function CycleView() {
className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm"
>
<Sparkles
className="text-primary mt-0.5 h-4 w-4 shrink-0"
className="text-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span>{t("cycle.calendar.learning")}</span>
@@ -429,7 +429,7 @@ export function CycleView() {
</Card>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data ? (
<CycleSettings
@@ -119,7 +119,7 @@ export function PhaseEducationCard({
aria-label={t("cycle.phaseEducation.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
"wellness-tile rounded-xl px-5 py-5",
"wellness-tile rounded-xl p-4 md:p-6",
animate && "wellness-tile-rise",
className,
)}
@@ -127,8 +127,7 @@ export function PhaseEducationCard({
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
className="text-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -386,7 +386,7 @@ export function DashboardHero({
drifting to the vertical centre of the taller ring column. */}
<div className="flex h-full flex-col gap-4 md:gap-2">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div className="min-w-0 flex-1 space-y-2">
{/* Greeting leads the typographic hierarchy: larger + heavier
than the verdict so the personalised line reads first.
The verdict keeps its medium weight one step down — still
@@ -399,13 +399,17 @@ export function DashboardHero({
>
{welcomeText}
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
{/* Text left, action right, one row — the PageHeader/TileHeader
inline-action pattern. The sentence takes the available width and
wraps internally (`min-w-0 flex-1`); the button never wraps to its
own line (`shrink-0`), so the action stays beside the text. */}
<div className="flex items-start justify-between gap-3">
{sentenceHref ? (
<Link
href={sentenceHref}
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 hover:text-foreground text-base font-medium transition-colors"
className="text-foreground/90 hover:text-foreground min-w-0 flex-1 text-base font-medium transition-colors"
>
{sentence}
</Link>
@@ -413,7 +417,7 @@ export function DashboardHero({
<p
data-slot="dashboard-hero-verdict"
data-verdict-variant={verdict.variant}
className="text-foreground/90 text-base font-medium"
className="text-foreground/90 min-w-0 flex-1 text-base font-medium"
>
{sentence}
</p>
@@ -424,7 +428,7 @@ export function DashboardHero({
asChild
size="default"
variant="outline"
className="min-h-11 sm:min-h-9"
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
@@ -432,7 +436,7 @@ export function DashboardHero({
) : (
<Button
size="default"
className="min-h-11 sm:min-h-9"
className="min-h-11 shrink-0 sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
@@ -10,6 +10,7 @@ import type {
DerivedCoverage,
DerivedProvenance,
} from "@/lib/insights/derived/types";
import { TileHeader } from "@/components/insights/tile-header";
import { ScoreRing } from "./score-ring";
import { CoverageMeter } from "./coverage-meter";
import {
@@ -126,20 +127,19 @@ export function ScoreAnatomyView({
: undefined
}
className={cn(
"flex flex-col gap-5 rounded-xl border p-5",
"flex flex-col gap-4 rounded-xl border p-4 md:p-6",
hue ? "wellness-detail-card" : "border-border bg-card",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
>
{/* Hero: title + ring + caption */}
{/* Left-aligned canonical tile header — sits on the same reading edge as
the sibling assessment card that stacks beneath it on a score
sub-page, instead of the old centred uppercase title that floated
toward the middle and shared no left line with any card. */}
<TileHeader title={title} />
{/* Hero: ring + caption — a centred identity block below the header. */}
<div className="flex flex-col items-center gap-3 text-center">
<h2
data-slot="score-anatomy-title"
className="text-foreground text-sm font-semibold tracking-wide uppercase"
>
{title}
</h2>
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is
never clipped top/bottom by the hero container. */}
<div className="py-2">
@@ -176,7 +176,7 @@ export function ScoreAnatomyView({
/>
) : (
<div data-slot="score-anatomy-contributors" className="space-y-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
{t("insights.derived.anatomy.contributorsLabel")}
</p>
<ul className="space-y-2.5">
@@ -9,6 +9,7 @@ import {
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import { TileHeader } from "@/components/insights/tile-header";
/**
* v1.10.0 — the Apple-Health-Highlights grid tile.
@@ -138,27 +139,26 @@ export function SparklineDeltaTile({
className,
)}
>
{/* Header row matches the canonical TileHeader language: a leading
foreground icon + a foreground `CardTitle`-weight heading — never
the old muted-uppercase caption. Kept on one line for the dense
grid; provenance trails to the right edge. */}
<div className="flex min-w-0 items-center gap-2">
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<span
className="text-foreground min-w-0 flex-1 truncate text-base leading-none font-semibold whitespace-nowrap"
data-slot="sparkline-delta-tile-label"
>
{label}
</span>
{provenance ? (
<span
data-slot="sparkline-delta-tile-provenance"
className="ml-auto shrink-0"
>
{provenance}
</span>
) : null}
</div>
{/* The canonical tile-header primitive (was a hand-rolled copy of it):
leading foreground icon + foreground `CardTitle` heading, provenance
in the `right` slot. Routing through the primitive keeps this dense
grid tile from drifting out of the header contract. */}
<TileHeader
icon={Icon}
title={label}
className="min-w-0"
titleClassName="min-w-0 truncate whitespace-nowrap"
right={
provenance ? (
<span
data-slot="sparkline-delta-tile-provenance"
className="shrink-0"
>
{provenance}
</span>
) : undefined
}
/>
<div
className="mt-2 flex flex-wrap items-baseline gap-x-1.5"
@@ -133,7 +133,7 @@ function RingTile({
} as React.CSSProperties
}
className={cn(
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-4 focus-visible:ring-2 focus-visible:outline-none md:p-6",
animate && "wellness-tile-rise",
)}
>
+26 -26
View File
@@ -6,16 +6,11 @@ import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import { SectionHeading } from "@/components/insights/section-heading";
import { TileHeader } from "@/components/insights/tile-header";
/**
* v1.17.1 — calm, data-gated readout for a device-native score that has no
@@ -100,26 +95,31 @@ export function DeviceScoreTile({
className={className}
>
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{title}</span>
</CardTitle>
{latest != null ? (
<CardAction
data-slot="device-score-latest"
className="text-foreground self-baseline text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
{/* Canonical compact tile header — foreground `h-4` icon + `text-sm`
title on the shared inset; the latest reading rides the `right`
slot so it stays pinned to the row's right edge. */}
<TileHeader
size="sm"
icon={Icon}
title={title}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
latest != null ? (
<span
data-slot="device-score-latest"
className="text-foreground text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
</span>
) : null}
</span>
) : null}
</CardAction>
) : null}
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-3">
{isLearning ? (
@@ -4,15 +4,10 @@ import { ShieldCheck } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { TileHeader } from "@/components/insights/tile-header";
/**
* v1.19.2 — calm readout for Oura's daily resilience level.
@@ -96,21 +91,25 @@ export function ResilienceTile() {
return (
<Card data-slot="resilience-tile" data-metric="RESILIENCE">
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<ShieldCheck
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{t("measurements.typeResilience")}</span>
</CardTitle>
{bandKey != null ? (
<CardAction
data-slot="resilience-band"
className="text-foreground self-baseline text-lg font-semibold"
>
{t(bandKey)}
</CardAction>
) : null}
{/* Canonical compact tile header — foreground `h-4` icon + `text-sm`
title; the current band rides the `right` slot. */}
<TileHeader
size="sm"
icon={ShieldCheck}
title={t("measurements.typeResilience")}
className="min-w-0"
titleClassName="min-w-0 truncate"
right={
bandKey != null ? (
<span
data-slot="resilience-band"
className="text-foreground text-lg font-semibold"
>
{t(bandKey)}
</span>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-foreground text-sm leading-relaxed">
+7 -4
View File
@@ -218,10 +218,13 @@ export function SubPageShell({
<div data-slot="insights-subpage" className="space-y-3 md:space-y-4">
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
{backLink}
{/* The heading + explainer indent to the cards' inner text edge
(`px-6`, the Card content padding), so the page head reads as
part of the card column instead of hanging outside it. */}
<header className="space-y-1.5 px-6">
{/* The heading + explainer indent to the cards' inner text edge so
the page head reads as part of the card column instead of hanging
outside it. Track the card slot inset EXACTLY (`px-4 md:px-6`) —
never a flat `px-6`, which sat 8 px right of every card body on
mobile and reopened the heading-vs-body step (insights left-edge
rule: one reading edge across breakpoints). */}
<header className="space-y-1.5 px-4 md:px-6">
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
title / nudge / badge wrap together on the left.
@@ -160,8 +160,8 @@ describe("visibleNavDestinations module gate", () => {
});
});
describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only hub, N-2)", () => {
it("is exactly the visible feature destinations minus the primary slots (no utility tail)", () => {
const opts = {
modules: { cycle: false } as const,
};
@@ -170,9 +170,9 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
const expectedFeatures = visibleNavDestinations(opts.modules)
.map((d) => d.href)
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
// Feature destinations only — the account utilities never ride the hub.
expect(hub).toEqual(expectedFeatures);
});
it("never contains a primary slot (Home / Meds / Insights)", () => {
@@ -184,7 +184,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
}
});
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
it("keeps the feature destinations reachable but excludes the account utilities", () => {
const hub = mobileMoreHubDestinations({
modules: { cycle: false },
}).map((d) => d.href);
@@ -192,9 +192,10 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
expect(hub).toContain("/measurements");
expect(hub).toContain("/mood");
expect(hub).toContain("/checkups");
// The shared utility tail rides at the end.
expect(hub).toContain("/settings/account");
expect(hub).toContain("/notifications");
// Settings + Notifications are account utilities — they live ONLY in the
// user/avatar menu, never duplicated into the mobile More hub.
expect(hub).not.toContain("/settings/account");
expect(hub).not.toContain("/notifications");
});
it("gates Cycle by the module map, same as the sidebar", () => {
+8 -12
View File
@@ -300,26 +300,22 @@ export interface MobileMoreHubEntry {
/**
* The ordered "More" hub for the mobile bottom-nav: every visible feature
* destination that isn't a primary slot, in model order, followed by the
* shared utility tail (Settings, Notifications). The desktop sidebar renders
* the same feature list inline and consumes the same utility tail in its
* footer + avatar menu, so the two bars cannot drift into two hand-curated
* lists.
* destination that isn't a primary slot, in model order. Feature destinations
* only — the account utilities (Settings, Notifications) are NOT appended here.
* They live solely in the user/avatar menu (mobile top-bar dropdown; desktop
* sidebar avatar menu + footer), so surfacing them in the More hub too would
* duplicate a utility across two menus reachable from the same screen. The
* desktop sidebar renders the same feature list inline, so the two bars cannot
* drift into two hand-curated feature lists.
*/
export function mobileMoreHubDestinations(opts: {
modules: ModuleVisibilityMap | undefined;
/** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */
mounted?: boolean;
}): MobileMoreHubEntry[] {
const features = visibleNavDestinations(opts.modules, opts.mounted ?? true)
return visibleNavDestinations(opts.modules, opts.mounted ?? true)
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
const tail = visibleUtilityDestinations().map((d) => ({
href: d.href,
tKey: d.tKey,
icon: d.icon,
}));
return [...features, ...tail];
}
/**
+29
View File
@@ -0,0 +1,29 @@
/**
* Shared scroll-floor for the two-column sub-shells (Settings, Admin).
*
* Scroll height is shell-owned (over-scroll class, #154 lineage): only
* `AuthShell` owns the scroll container's height and bottom padding — the
* `<main>` clears the mobile bottom-nav and its wrapper clears the Coach FAB.
* A sub-shell must NEVER re-declare a bottom gutter (`pb-*`) or a viewport
* reserve (`min-h-[calc(100dvh-…)]`) on its own content column: a nested
* reserve stacks on the shell budget and scrolls past the last card.
*
* The one sanctioned reserve is this loading-jump floor, applied to the
* sub-shell GRID (not to a column): it keeps the page height stable while a
* freshly-navigated section is still fetching, without over-shooting the
* viewport on a short section. The `<main>`/cards column is the grid's `1fr`
* row, so it stretches to fill exactly and never over-scrolls.
*
* Both sub-shells derive the floor from THIS single constant so the two can
* 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>`;
* 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)]";
+2 -2
View File
@@ -81,7 +81,7 @@ export function AnamnesisCard({
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={panelId}
className="flex w-full items-center gap-3 rounded-xl p-5 text-left"
className="flex w-full items-center gap-3 rounded-xl p-4 text-left md:p-6"
>
<span
aria-hidden="true"
@@ -107,7 +107,7 @@ export function AnamnesisCard({
</button>
{expanded ? (
<div id={panelId} className="space-y-5 px-5 pb-5">
<div id={panelId} className="space-y-4 px-4 pb-4 md:px-6 md:pb-6">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("onboarding.anamnesis.intro")}
</p>
+1 -1
View File
@@ -167,7 +167,7 @@ export function BaselineForm() {
</p>
</header>
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<FieldGroup
@@ -311,7 +311,7 @@ export function GettingStartedChecklist() {
<section
data-testid="onboarding-card"
aria-labelledby="getting-started-title"
className="bg-card border-border space-y-4 rounded-2xl border p-5 sm:p-6"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
{/* v1.4.27 MB7 / CF-44 — `flex-wrap` on the header lets the
dismiss button drop to its own row when the German title +
+2 -2
View File
@@ -643,8 +643,8 @@ export function OnboardingTour({
// centred above the bottom edge on wide ones (`mx-auto` +
// `max-w`). Used on EVERY viewport when no anchored
// placement keeps the footer buttons inside the viewport.
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
}
style={
asSheet
+28 -40
View File
@@ -18,13 +18,10 @@
* record where it belongs.
*/
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { HeartPulse, ShieldAlert, Users } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
@@ -38,42 +35,33 @@ export function AnamnesisSection() {
return (
<div className="space-y-6">
{coachEnabled && (
<Card>
<CardHeader>
<CardTitle>{t("records.conditions.cardTitle")}</CardTitle>
<CardDescription>
{t("records.conditions.cardDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<ConditionsManager />
</CardContent>
</Card>
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={HeartPulse}
title={t("records.conditions.cardTitle")}
description={t("records.conditions.cardDescription")}
/>
<ConditionsManager />
</SettingsCard>
)}
<Card>
<CardHeader>
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
<CardDescription>
{t("records.allergies.cardDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<AllergyManager />
</CardContent>
</Card>
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={ShieldAlert}
title={t("records.allergies.cardTitle")}
description={t("records.allergies.cardDescription")}
/>
<AllergyManager />
</SettingsCard>
<Card>
<CardHeader>
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
<CardDescription>
{t("records.family.cardDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<FamilyHistoryManager />
</CardContent>
</Card>
<SettingsCard className="space-y-4">
<SettingsCardHeader
icon={Users}
title={t("records.family.cardTitle")}
description={t("records.family.cardDescription")}
/>
<FamilyHistoryManager />
</SettingsCard>
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
</div>
+15 -10
View File
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
@@ -506,7 +507,12 @@ export function SettingsShell({
holds across locales and however the title or subtitle wraps. No
fixed-height spacer to guess. The `gap-6` between rows reproduces
the `space-y-6` the heading-to-first-card gap used to carry. */}
<div className="grid gap-6 md:min-h-[calc(100dvh-10.5rem)] md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
<div
className={cn(
"grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]",
SUB_SHELL_GRID_FLOOR,
)}
>
{/* Heading — desktop only here (mobile renders it above the strip). */}
{headingBlock ? (
<div className="hidden md:col-start-2 md:row-start-1 md:block">
@@ -570,19 +576,18 @@ export function SettingsShell({
still prevents the short-loading-state → long-list height jump
the old reserve guarded, just measured against the real budget.
v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter
on `<md` so the last form field on
`/settings/account` (Geschlecht / Gender) and
`/settings/ai` (Aktiver Provider) is not eaten by the
floating mobile bottom-nav. Desktop reverts to the parent's
own padding because the bottom-nav doesn't render on `md+`.
The column carries NO bottom gutter of its own. AuthShell's
`<main>` already reserves `pb-[calc(4rem+safe-area)]` on `<md` to
clear the floating bottom-nav, and its wrapper reserves `pb-20` for
the Coach FAB. The old `pb-24 md:pb-0` (v1.4.33 F14) double-counted
that same 64 px nav clearance and stacked ~96 px of dead scroll
below the last card on mobile — removed so the shell owns all
bottom-space (over-scroll class, #154 lineage).
v1.16.4 — a `<div>`, not `<main>`: the surrounding AuthShell
already provides the page's single `<main>` landmark and a
nested second one is an a11y violation. */}
<div className="min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
{children}
</div>
<div className="min-w-0 md:col-start-2 md:row-start-2">{children}</div>
</div>
</div>
);