Skip to content
Merged
21 changes: 21 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildSecurityHeaders, resolveRuntimeFlags } from "./src/lib/security-headers";
import { expectedSupabaseProject } from "./src/lib/supabase/project";

const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const requestedDistDir = process.env.NEXT_DIST_DIR?.trim();
Expand Down Expand Up @@ -47,6 +48,26 @@ const nextConfig: NextConfig = {
proxyClientMaxBodySize: "151mb",
},
poweredByHeader: false,
images: {
// Prefer AVIF (~20-30% smaller than WebP), falling back to WebP, for any
// next/image output.
formats: ["image/avif", "image/webp"],
// Permit optimizing Supabase Storage signed URLs (private document/image
// previews) through next/image. Scoped to this app's exact production and
// (when configured) staging project hostnames, not the wildcard *.supabase.co.
remotePatterns: (() => {
const allowedHostnames = [expectedSupabaseProject.ref + ".supabase.co"];
const stagingRef = process.env.SUPABASE_STAGING_PROJECT_REF?.trim();
if (stagingRef) {
allowedHostnames.push(stagingRef + ".supabase.co");
}
return allowedHostnames.map((hostname) => ({
protocol: "https" as const,
hostname,
pathname: "/storage/v1/object/**",
}));
})(),
},
turbopack: {
root: projectRoot,
},
Expand Down
6 changes: 5 additions & 1 deletion src/app/forms/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ export async function generateMetadata({ params }: FormRouteProps): Promise<Meta

export default async function FormRoute({ params }: FormRouteProps) {
const { slug } = await params;
return <FormDetailClient slug={slug} />;
// Hand the public fixture record to the client so the detail view paints real
// content on first load instead of a centered spinner; the owner-aware record
// refreshes in place. Absent from the fixtures (owner-only slug) → null.
const fallbackRecord = getFormRecord(slug);
return <FormDetailClient slug={slug} fallbackRecord={fallbackRecord} />;
}
3 changes: 2 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -525,8 +525,9 @@ summary {
transition-timing-function: var(--ease-out-soft);
}

/* Tactile press only on button-like controls. Applying it to every `a` nudged
inline body/citation links down 1px on tap, which reads oddly for text links. */
button:active,
a:active,
summary:active {
transform: translateY(1px);
}
Expand Down
5 changes: 5 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const geistSans = Geist({
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
// The mono face is only used deep in the UI (tabular figures, `kbd`, code) and
// never in initial/LCP text, so don't preload it on every route — it competes
// for the critical-path connection. It still loads on-demand via `swap` when
// first painted. The sans face keeps the default preload.
preload: false,
});

const baseMetadata: Metadata = {
Expand Down
19 changes: 18 additions & 1 deletion src/app/medications/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";

import { MedicationRecordPage } from "@/components/clinical-dashboard/medication-record-page";
import { deriveGovernanceFromSections } from "@/lib/medication-records";
import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot";

type MedicationPageProps = {
Expand Down Expand Up @@ -31,5 +32,21 @@ export async function generateMetadata({ params }: MedicationPageProps): Promise
export default async function MedicationPage({ params }: MedicationPageProps) {
const { slug } = await params;

return <MedicationRecordPage slug={slug} />;
// The public snapshot record is already in hand server-side. Pass it (plus the
// governance derived exactly as the public API does) so the page paints real
// clinical content on first load instead of a skeleton, while the client hook
// refreshes in place for authenticated owner records. Slugs absent from the
// snapshot (owner-only medications) pass `undefined` and keep the loading path.
const record = getMedicationRecord(slug);
const fallbackGovernance = record
? (() => {
const derived = deriveGovernanceFromSections(record);
// Validation/review status is a governance decision that must come from
// the live/authoritative response, not a hard-coded guess used only for
// the pre-fetch content-first paint.
return { sourceStatus: derived.source_status, validationStatus: "unverified" as const };
})()
: undefined;

return <MedicationRecordPage slug={slug} fallbackRecord={record} fallbackGovernance={fallbackGovernance} />;
}
6 changes: 5 additions & 1 deletion src/app/services/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ export async function generateMetadata({ params }: ServiceRouteProps): Promise<M

export default async function ServiceRoute({ params }: ServiceRouteProps) {
const { slug } = await params;
return <ServiceDetailClient slug={slug} />;
// Hand the public fixture record to the client so the detail view paints real
// content on first load instead of a centered spinner; the owner-aware record
// refreshes in place. Absent from the fixtures (owner-only slug) → null.
const fallbackRecord = getServiceRecord(slug);
return <ServiceDetailClient slug={slug} fallbackRecord={fallbackRecord} />;
}
4 changes: 2 additions & 2 deletions src/components/clinical-dashboard/DocumentManagerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ export function UploadPanel({
className="h-1.5 w-full overflow-hidden rounded-full bg-[color:var(--surface-inset)]"
>
<div
className="h-full rounded-full bg-[color:var(--clinical-accent)] transition-[width] duration-200 ease-out motion-reduce:transition-none"
style={{ width: `${uploadPercent}%` }}
className="h-full w-full origin-left rounded-full bg-[color:var(--clinical-accent)] transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{ transform: `scaleX(${uploadPercent / 100})` }}
/>
</div>
</div>
Expand Down
27 changes: 22 additions & 5 deletions src/components/clinical-dashboard/medication-record-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,25 @@ function MedicationRecordDetail({
);
}

export function MedicationRecordPage({ slug }: { slug: string }) {
export function MedicationRecordPage({
slug,
fallbackRecord,
fallbackGovernance,
}: {
slug: string;
fallbackRecord?: MedicationRecord;
fallbackGovernance?: MedicationGovernance;
}) {
const { data, loading, error } = useMedicationDetail(slug);
// Content-first: render the SSR fallback immediately, then swap in the live
// (owner-aware) record once the hook resolves. Only fall back to the skeleton
// when there is no server record to show (owner-only slugs) and the fetch is
// still in flight; the error state applies only when nothing renderable exists.
const record = data?.record ?? fallbackRecord ?? null;
// Only trust the SSR fallback governance while the live fetch is still in
// flight. A failed request means the authoritative status is unknown, so
// don't keep presenting the fixture-derived guess as if it were confirmed.
const governance = data?.governance ?? (error ? undefined : fallbackGovernance);

return (
<main className="min-h-[calc(100dvh-4rem)] text-[color:var(--text)]" data-testid={`medication-page-${slug}`}>
Expand All @@ -491,19 +508,19 @@ export function MedicationRecordPage({ slug }: { slug: string }) {
</Link>
</div>
<div className="px-3 py-3 sm:px-6 lg:px-8">
{loading ? (
{record ? (
<MedicationRecordDetail record={record} governance={governance} />
) : loading ? (
<div className="mx-auto max-w-7xl">
<LoadingPanel label="Loading medication reference…" variant="skeleton" lines={6} />
</div>
) : error || !data?.record ? (
) : (
<div className="rounded-lg border border-[color:var(--danger-border)] bg-[color:var(--danger-bg)] p-4 text-sm text-[color:var(--danger-text)]">
<div className="flex items-start gap-2">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{error ?? "Medication not found."}</p>
</div>
</div>
) : (
<MedicationRecordDetail record={data.record} governance={data.governance} />
)}
</div>
<footer className="mx-auto max-w-7xl px-4 pb-4 text-center text-2xs font-medium text-[color:var(--text-muted)]">
Expand Down
5 changes: 4 additions & 1 deletion src/components/clinical-dashboard/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,10 @@ export function SettingsDialog({
edge-to-edge. On lg it reverts to a static in-panel title bar. */}
<header
className={cn(
"edge-glass-header sticky top-0 z-30 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] transition-transform duration-300 will-change-transform motion-reduce:transition-none lg:static lg:z-auto lg:translate-y-0 lg:pb-0 lg:pt-6 lg:bg-transparent! lg:px-0!",
// No permanent `will-change-transform`: it keeps a compositor layer
// alive at rest for a header that only transforms during scroll-hide.
// `transition-transform` already hints the browser for the animation.
"edge-glass-header sticky top-0 z-30 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] transition-transform duration-300 motion-reduce:transition-none lg:static lg:z-auto lg:translate-y-0 lg:pb-0 lg:pt-6 lg:bg-transparent! lg:px-0!",
headerHidden ? "-translate-y-full" : "translate-y-0",
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ type AsyncState<T> = {
};

async function fetchJson<T>(url: string, headers?: HeadersInit): Promise<T> {
const response = await fetch(url, { cache: "no-store", headers });
// Use the default cache mode (not `no-store`) so public responses honor the
// API's `public, max-age=300, s-maxage=3600, stale-while-revalidate` headers.
// Owner responses are served `private, no-store` with `Vary: Authorization`,
// so the browser never caches them across auth states — matching the sibling
// registry/differential hooks, which also fetch with the default cache mode.
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Request failed (${response.status})`);
}
Expand Down
5 changes: 3 additions & 2 deletions src/components/forms/form-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import { RegistryRecordLoader } from "@/components/registry-record-loader";
import { FormDetailPage } from "@/components/forms/form-detail-page";
import type { ServiceRecord } from "@/lib/services";

export function FormDetailClient({ slug }: { slug: string }) {
export function FormDetailClient({ slug, fallbackRecord }: { slug: string; fallbackRecord?: ServiceRecord | null }) {
return (
<RegistryRecordLoader kind="form" slug={slug}>
<RegistryRecordLoader kind="form" slug={slug} fallbackRecord={fallbackRecord}>
{(record) => <FormDetailPage form={record} />}
</RegistryRecordLoader>
);
Expand Down
18 changes: 18 additions & 0 deletions src/components/registry-record-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,34 @@ function StatePanel({
export function RegistryRecordLoader({
kind,
slug,
fallbackRecord,
children,
}: {
kind: RegistryRecordKind;
slug: string;
fallbackRecord?: ServiceRecord | null;
children: (record: ServiceRecord) => ReactNode;
}) {
const { status, record, governance } = useRegistryRecord(kind, slug);
const copy = kindCopy[kind];

if (status === "loading") {
// Content-first: when the server supplied the public fixture record, paint
// it immediately instead of a centered spinner; the owner-aware live record
// swaps in when the fetch resolves. Owner-only slugs (no fixture) still show
// the spinner. The fallback is public content, so showing it before an
// eventual unauthorized/not-found state leaks nothing owner-scoped.
if (fallbackRecord) {
// Do not assert an authoritative "locally verified" badge before the
// `ready` branch reconciles it against live governance — neutralize the
// fixture's verified flag for this provisional paint so a stale verified
// badge cannot flash in (mirrors the governance reconciliation below).
const provisional =
fallbackRecord.verification?.locallyVerified === true
? { ...fallbackRecord, verification: { ...fallbackRecord.verification, locallyVerified: false } }
: fallbackRecord;
return <>{children(provisional)}</>;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return (
<main className="grid min-h-[60dvh] place-items-center" aria-busy="true">
<div className={cn("inline-flex items-center gap-2 text-sm font-semibold", textMuted)}>
Expand Down
5 changes: 3 additions & 2 deletions src/components/services/service-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import { RegistryRecordLoader } from "@/components/registry-record-loader";
import { ServiceDetailPage } from "@/components/services/service-detail-page";
import type { ServiceRecord } from "@/lib/services";

export function ServiceDetailClient({ slug }: { slug: string }) {
export function ServiceDetailClient({ slug, fallbackRecord }: { slug: string; fallbackRecord?: ServiceRecord | null }) {
return (
<RegistryRecordLoader kind="service" slug={slug}>
<RegistryRecordLoader kind="service" slug={slug} fallbackRecord={fallbackRecord}>
{(record) => <ServiceDetailPage service={record} />}
</RegistryRecordLoader>
);
Expand Down
9 changes: 6 additions & 3 deletions src/components/therapy-compass/therapy-compass.css
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,15 @@
border-radius: 50%;
background: var(--surface-raised);
box-shadow: var(--shadow-tight);
transform: translateY(-50%);
transition: left 150ms ease;
/* Animate the horizontal move on the compositor (transform) rather than `left`,
which forces layout each frame. The 18px offset matches the knob width so the
resting/active positions are visually identical to the old left-based values. */
transform: translate(0, -50%);
transition: transform 150ms ease;
}

.tc-clinician-knob.tc-is-active {
left: 50%;
transform: translate(18px, -50%);
}

.tc-paper {
Expand Down