From 60be63bcd1b8a900ec496a7b173c4dcca049254b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 13:03:15 +0200
Subject: [PATCH] feat(ui): InlineAction for small text actions, with a 44 px
touch floor
Thirty-three hand-rolled underlined text links and buttons (Open
schedule, Select all, View all, Retry, Learn more, the device-code copy
action, ...) carried three different focus rings, none on half of them,
and a 16 px target on a phone. They now render through one InlineAction
primitive: tone primary / muted / foreground, size xs / sm, a focus ring,
and min-h-11 on a coarse pointer so the desktop layout does not move. A
structural test refuses a new hand-rolled copy.
---
src/__tests__/inline-action-guard.test.ts | 121 ++++++++++++++++++
src/app/mood/page-client.tsx | 15 +--
.../admin/central-codex-section.tsx | 9 +-
.../admin/module-availability-section.tsx | 12 +-
src/components/admin/recent-audit-preview.tsx | 14 +-
.../dashboard/key-backup-banner.tsx | 12 +-
.../sources/document-source-picker.tsx | 9 +-
.../insights/coach-panel/chat-bubble.tsx | 13 +-
src/components/insights/health-score-card.tsx | 18 +--
.../insights/medication-efficacy-summary.tsx | 16 ++-
.../insights/metric-target-summary.tsx | 24 ++--
.../insights/sleep/chronotype-card.tsx | 9 +-
src/components/insights/trends-row.tsx | 22 ++--
.../insights/workout-detail/stats.tsx | 18 +--
src/components/links/entity-link-picker.tsx | 8 +-
.../card-parts/medication-compliance-bars.tsx | 11 +-
.../detail/medication-detail-tabs.tsx | 8 +-
.../sections/inventory-section.tsx | 24 ++--
src/components/mood/mood-context-fields.tsx | 22 ++--
src/components/mood/mood-list.tsx | 8 +-
src/components/onboarding/done-screen.tsx | 42 +++---
.../settings/access/handover-decision.tsx | 16 ++-
.../settings/ai/codex-provider-form.tsx | 9 +-
.../settings/ai/openai-provider-form.tsx | 9 +-
.../integrations/oauth-integration-card.tsx | 22 ++--
src/components/settings/low-stock-card.tsx | 13 +-
src/components/settings/module-toggle-row.tsx | 14 +-
.../report-selection/selection-panel.tsx | 14 +-
.../settings/security-section/totp-card.tsx | 16 +--
.../ui/__tests__/inline-action.test.tsx | 50 ++++++++
src/components/ui/inline-action.tsx | 72 +++++++++++
src/components/ui/learn-more-link.tsx | 27 ++--
.../vaccinations/vaccination-form.tsx | 9 +-
33 files changed, 484 insertions(+), 222 deletions(-)
create mode 100644 src/__tests__/inline-action-guard.test.ts
create mode 100644 src/components/ui/__tests__/inline-action.test.tsx
create mode 100644 src/components/ui/inline-action.tsx
diff --git a/src/__tests__/inline-action-guard.test.ts b/src/__tests__/inline-action-guard.test.ts
new file mode 100644
index 000000000..82b6fccba
--- /dev/null
+++ b/src/__tests__/inline-action-guard.test.ts
@@ -0,0 +1,121 @@
+/**
+ * A small text action inside a line of content goes through `InlineAction`
+ * (`src/components/ui/inline-action.tsx`).
+ *
+ * Thirty-odd hand-rolled copies of the same "underlined small text link"
+ * carried three different focus rings, none at all on half of them, and a
+ * 16 px touch target on a phone. The primitive owns the 44 px floor on a
+ * coarse pointer and the ring; this guard keeps a new copy from appearing
+ * beside it.
+ *
+ * Matched: a ``, ` ` or `` whose static `className` combines
+ * the inline-action signature — an underline (`underline` / `hover:underline`)
+ * with a small text size (`text-xs` / `text-sm`) and an action tone
+ * (`text-primary` / `text-muted-foreground`). A full-width row link or a
+ * framed button does not have that combination.
+ *
+ * Not matched (stated, not hidden): a class list assembled at runtime, and a
+ * copy without an underline. The guard catches the idiom, not every way to
+ * draw a small link.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(__dirname, "..");
+
+/**
+ * Sites that look like an inline action but are something else, each with
+ * the reason. Keyed by `path:tag`.
+ */
+const ALLOWED: Record = {
+ // A full-width footer row of the glucose clinical panel: icon plus a
+ // wrapping sentence under a border, read as part of the card body.
+ "components/insights/glucose/glucose-clinical-panel.tsx:Link":
+ "full-width footer row, not an inline action",
+};
+
+const OPENER_RE = /<(button|Link|a)\b((?:[^>{]|\{[^}]*\})*?)>/g;
+const CLASS_RE = /className=(?:"([^"]*)"|\{cn\(\s*"([^"]*)")/;
+
+function isInlineActionShape(cls: string): boolean {
+ return (
+ /(?:^|\s)(?:hover:)?underline(?:\s|$)/.test(cls) &&
+ /(?:^|\s)text-(?:xs|sm)(?:\s|$)/.test(cls) &&
+ /(?:^|\s)text-(?:primary|muted-foreground)(?:\s|$)/.test(cls)
+ );
+}
+
+function findHandRolledInlineActions(
+ files: { path: string; source: string }[],
+): string[] {
+ const hits: string[] = [];
+ for (const { path, source } of files) {
+ for (const match of source.matchAll(OPENER_RE)) {
+ const cls = CLASS_RE.exec(match[2]);
+ if (!cls) continue;
+ if (!isInlineActionShape(cls[1] ?? cls[2] ?? "")) continue;
+ hits.push(`${path}:${match[1]}`);
+ }
+ }
+ return hits;
+}
+
+describe("small text actions go through InlineAction", () => {
+ const files = walkSourceFiles(SRC, { floor: 1200, extensions: [".tsx"] })
+ .filter(
+ (p) =>
+ (p.startsWith("components/") || p.startsWith("app/")) &&
+ !p.startsWith("components/ui/") &&
+ !p.includes("__tests__/"),
+ )
+ .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") }));
+
+ it("walks the UI tree", () => {
+ expect(files.length).toBeGreaterThan(800);
+ });
+
+ it("has no hand-rolled copy outside the allowlist", () => {
+ const hits = findHandRolledInlineActions(files).filter(
+ (key) => !(key in ALLOWED),
+ );
+ expect(hits).toEqual([]);
+ });
+
+ it("keeps every allowlist entry live", () => {
+ const hits = new Set(findHandRolledInlineActions(files));
+ for (const key of Object.keys(ALLOWED)) {
+ expect(hits.has(key), key).toBe(true);
+ }
+ });
+
+ it("recognises the idiom it exists to refuse", () => {
+ const planted = [
+ {
+ path: "components/x.tsx",
+ source:
+ ' Go',
+ },
+ {
+ path: "components/y.tsx",
+ source:
+ ' go()} className="text-muted-foreground hover:text-foreground text-sm underline">Go ',
+ },
+ {
+ path: "components/z.tsx",
+ source: 'Go ',
+ },
+ ];
+ expect(findHandRolledInlineActions(planted)).toEqual([
+ "components/x.tsx:Link",
+ "components/y.tsx:button",
+ ]);
+ });
+
+ it("is used where the copies were", () => {
+ const users = files.filter((f) => f.source.includes("
- {t("mood.mentalWellbeingLink")}
-
-
+
+
+ {t("mood.mentalWellbeingLink")}
+
+
+
) : null}
);
diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx
index 7ae0ffb97..c9d9e1734 100644
--- a/src/components/admin/central-codex-section.tsx
+++ b/src/components/admin/central-codex-section.tsx
@@ -36,6 +36,7 @@ import {
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
+import { InlineAction } from "@/components/ui/inline-action";
interface CentralCodexStatus {
status: string;
@@ -228,15 +229,15 @@ export function CentralCodexSection() {
{t("settings.ai.deviceCodeStep2")}
{deviceCode.userCode}
-
navigator.clipboard?.writeText(deviceCode.userCode)
}
- className="text-muted-foreground hover:text-foreground text-xs underline"
>
{t("settings.ai.deviceCodeCopy")}
-
+
{t("settings.ai.deviceCodeStep3")}
diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx
index df5442811..f57122c64 100644
--- a/src/components/admin/module-availability-section.tsx
+++ b/src/components/admin/module-availability-section.tsx
@@ -23,6 +23,7 @@ import {
import type { ModuleKey } from "@/lib/modules/registry";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* v1.18.0 — operator-side panel for server-wide module availability, the
@@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() {
onCheckedChange={() => {}}
disabled
/>
-
- {t("admin.section.coach.title")}
-
+
+
+ {t("admin.section.coach.title")}
+
+
);
}
diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx
index cab8589b7..0fb5ac6e4 100644
--- a/src/components/admin/recent-audit-preview.tsx
+++ b/src/components/admin/recent-audit-preview.tsx
@@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
import { apiGet } from "@/lib/api/api-fetch";
+import { InlineAction } from "@/components/ui/inline-action";
const PREVIEW_LIMIT = 10;
@@ -61,13 +62,12 @@ export function RecentAuditPreview() {
titleId="admin-overview-audit-heading"
title={t("admin.overview.auditTitle")}
status={
-
- {t("admin.overview.auditViewAll")}
-
-
+
+
+ {t("admin.overview.auditViewAll")}
+
+
+
}
/>
diff --git a/src/components/dashboard/key-backup-banner.tsx b/src/components/dashboard/key-backup-banner.tsx
index f2e73b5cc..8b8901b2b 100644
--- a/src/components/dashboard/key-backup-banner.tsx
+++ b/src/components/dashboard/key-backup-banner.tsx
@@ -11,6 +11,7 @@ import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
+import { InlineAction } from "@/components/ui/inline-action";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
@@ -29,12 +30,11 @@ export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
{t("dashboard.keyBackupBanner.body")}
-
- {t("dashboard.keyBackupBanner.action")}
-
+
+
+ {t("dashboard.keyBackupBanner.action")}
+
+
);
}
diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx
index ba50192d0..236c430e9 100644
--- a/src/components/documents/sources/document-source-picker.tsx
+++ b/src/components/documents/sources/document-source-picker.tsx
@@ -83,6 +83,7 @@ import {
} from "./source-errors";
import { SegmentedChoice } from "./segmented-choice";
import { systemName } from "./use-document-sources";
+import { InlineAction } from "@/components/ui/inline-action";
export interface DocumentPickerLink {
kind: DocumentPickerLinkKind;
@@ -485,19 +486,19 @@ export function DocumentSourcePicker({
{hasFilters ? (
- {
startOver();
setTag("");
setFrom("");
setTo("");
}}
- className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
+ className="min-h-8"
>
{t("documents.sourcePicker.clearFilters")}
-
+
) : null}
{finished ? (
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 0c0cec5b3..81c6345a3 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -39,6 +39,7 @@ import type {
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
import { translateOrNull } from "@/lib/i18n/translate-code";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* Per-message bubble renderer for the Coach thread, split out of
@@ -775,13 +776,11 @@ function ChatBubbleImpl({
setup instead of inviting an endless retry. */}
{(errorCode === "coach.provider.none" ||
errorCode === "ai.provider.none") && (
-
- {t("insights.coach.errorNoProviderAction")}
-
+
+
+ {t("insights.coach.errorNoProviderAction")}
+
+
)}
{/* v1.18.1 — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index ffd2f3c5b..eaf1715bf 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -41,6 +41,7 @@ import type {
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { SCORE_PILLAR_LABEL_KEYS } from "@/lib/score-config/labels";
import { cn } from "@/lib/utils";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* The Health Score panel: the hero band's right-hand column.
@@ -441,14 +442,15 @@ export function HealthScoreCard({
) : null}
{/* v1.42 — the way to the score's course over time. */}
-
-
- {t("insights.healthScore.historyLink")}
-
+
+
+
+ {t("insights.healthScore.historyLink")}
+
+
diff --git a/src/components/insights/medication-efficacy-summary.tsx b/src/components/insights/medication-efficacy-summary.tsx
index a68d5ae54..5be57af72 100644
--- a/src/components/insights/medication-efficacy-summary.tsx
+++ b/src/components/insights/medication-efficacy-summary.tsx
@@ -24,6 +24,7 @@ import type {
MedicationEfficacyDTO,
EfficacyTargetView,
} from "@/lib/medications/efficacy/build-efficacy";
+import { InlineAction } from "@/components/ui/inline-action";
function primaryWithDelta(
dto: MedicationEfficacyDTO,
@@ -108,13 +109,14 @@ export function MedicationEfficacySummary({
-
- {t("medications.efficacy.insights.link")}
-
+
+
+ {t("medications.efficacy.insights.link")}
+
+
);
})}
diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx
index 0e7d85126..bc2b2f748 100644
--- a/src/components/insights/metric-target-summary.tsx
+++ b/src/components/insights/metric-target-summary.tsx
@@ -18,6 +18,7 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { getTargetSourceLink } from "@/lib/targets/source-link";
import { apiGet } from "@/lib/api/api-fetch";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
@@ -416,19 +417,16 @@ function TargetReferencePanel({
panel registers itself as that gear's edit target above. */}
{sourceLink ? (
-
-
- {t("targets.sourceLabel", {
- source: sourceName(target.source, t),
- })}
-
-
-
+
+
+
+ {t("targets.sourceLabel", {
+ source: sourceName(target.source, t),
+ })}
+
+
+
+
) : (
{t("targets.sourceLabel", {
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx
index 14c0e66be..3220acd6c 100644
--- a/src/components/insights/sleep/chronotype-card.tsx
+++ b/src/components/insights/sleep/chronotype-card.tsx
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { TileHeader } from "@/components/insights/tile-header";
import type { ChronotypeDto } from "./use-sleep-rhythm";
+import { InlineAction } from "@/components/ui/inline-action";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
function clockLabel(minutesOfDay: number): string {
@@ -90,12 +91,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
)}
- setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
aria-controls="chronotype-advanced"
- className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
>
{showAdvanced ? (
@@ -103,7 +104,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
)}
{t("insights.sleep.chronotype.advancedToggle")}
-
+
{showAdvanced && (
diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx
index b62e8e06b..eda495290 100644
--- a/src/components/insights/trends-row.tsx
+++ b/src/components/insights/trends-row.tsx
@@ -23,6 +23,7 @@ import {
type TrendAnnotationStatus,
} from "./trend-annotation";
import { useUnitDisplay } from "@/hooks/use-unit-display";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* Trends row — a small chart per metric above a one-sentence
@@ -339,15 +340,20 @@ export function TrendsRow({
is used but was unreachable from Insights. The link sits below
the caption so the chart keeps its own interactions. */}
{config.detailHref ? (
-
- {t("insights.trendsRow.viewDetail", { metric: title })}
-
-
+
+ {t("insights.trendsRow.viewDetail", { metric: title })}
+
+
+
) : null}
);
diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx
index 36002d3bd..4aa93b56c 100644
--- a/src/components/insights/workout-detail/stats.tsx
+++ b/src/components/insights/workout-detail/stats.tsx
@@ -28,6 +28,7 @@ import {
formatPace,
formatDurationMinutes,
} from "./format";
+import { InlineAction } from "@/components/ui/inline-action";
interface StatTileProps {
icon: ReactNode;
@@ -199,14 +200,15 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
)}
{workout.previousWorkoutId ? (
-
- {t("insights.workouts.detail.previousSessionLink")}
-
-
+
+
+ {t("insights.workouts.detail.previousSessionLink")}
+
+
+
) : null}
) : null}
diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx
index 3c532b649..b49bc93bf 100644
--- a/src/components/links/entity-link-picker.tsx
+++ b/src/components/links/entity-link-picker.tsx
@@ -54,6 +54,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { formatPeriod, type PeriodQuery } from "./period-query";
+import { InlineAction } from "@/components/ui/inline-action";
export interface EntityLinkOption {
id: string;
@@ -546,16 +547,15 @@ export function EntityLinkPicker({
>
{group.label}
-
change(toggleAll(selected, groupIds), group.options)
}
- className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
-
+
) : null}
diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx
index 2f70c9964..99bd9fe02 100644
--- a/src/components/medications/card-parts/medication-compliance-bars.tsx
+++ b/src/components/medications/card-parts/medication-compliance-bars.tsx
@@ -3,6 +3,7 @@ import { Flame, RotateCw } from "lucide-react";
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
import { Progress } from "@/components/ui/progress";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { InlineAction } from "@/components/ui/inline-action";
interface MedicationComplianceBarsProps {
rate7: number;
@@ -286,14 +287,10 @@ export function MedicationComplianceError({
-
-
+
+
{t("common.retry")}
-
+
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index bb34d5411..2f0128495 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -104,6 +104,7 @@ import { AutoMarkTakenBody } from "@/components/medications/sections/auto-mark-t
import { IntakeTrackingBody } from "@/components/medications/sections/intake-tracking-section";
import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
import type { RelatedLink } from "@/lib/links/related";
+import { InlineAction } from "@/components/ui/inline-action";
// v1.15.20 — Recharts stays out of the detail page's initial bundle:
// both chart bodies load through `next/dynamic` (client-only) with the
@@ -1097,14 +1098,13 @@ function StatusRow({
{label}
{value}
-
{jumpLabel}
-
+
);
}
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index 7af96d638..1bab5744a 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -79,6 +79,7 @@ import {
type InventoryItem,
type InventoryState,
} from "./inventory-shared";
+import { InlineAction } from "@/components/ui/inline-action";
export { AddInventoryDialog, AdjustInventoryDialog } from "./inventory-dialogs";
export { invalidateSupplyQueries } from "./inventory-shared";
@@ -314,17 +315,18 @@ export function InventorySection({
of what was shared" panel. */}
{!inSharedRecord && (
-
- {lowStockDays !== null
- ? t("medications.detail.bestand.lowStockLinkOn", {
- days: lowStockDays,
- })
- : t("medications.detail.bestand.lowStockLinkOff")}
-
+
+
+ {lowStockDays !== null
+ ? t("medications.detail.bestand.lowStockLinkOn", {
+ days: lowStockDays,
+ })
+ : t("medications.detail.bestand.lowStockLinkOff")}
+
+
)}
diff --git a/src/components/mood/mood-context-fields.tsx b/src/components/mood/mood-context-fields.tsx
index 533e04384..35f587b64 100644
--- a/src/components/mood/mood-context-fields.tsx
+++ b/src/components/mood/mood-context-fields.tsx
@@ -55,6 +55,7 @@ import {
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context";
+import { InlineAction } from "@/components/ui/inline-action";
const CONTEXT_NOTE_MAX_LENGTH = 500;
@@ -683,16 +684,17 @@ export function MoodLinkedDaySection({
{bodyLine}
-
- {t("mood.dayContext.openIllness")}
-
+
+
+ {t("mood.dayContext.openIllness")}
+
+
) : null}
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 5e46b84c4..f5fc5a9a0 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -118,6 +118,7 @@ import {
droppedAnyLinkKeys,
type DroppedLinkKeysWire,
} from "@/lib/mood/dropped-link-keys";
+import { InlineAction } from "@/components/ui/inline-action";
// Re-export the score map under the legacy local name to keep the
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
@@ -1361,16 +1362,15 @@ function MoodNoteText({
{note}
{(expanded || clamped) && (
-
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
-
+
)}
);
diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx
index ece0c72b8..39a6d2f80 100644
--- a/src/components/onboarding/done-screen.tsx
+++ b/src/components/onboarding/done-screen.tsx
@@ -32,6 +32,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { markChecklistExpanded } from "@/lib/onboarding/checklist-storage";
import type { OnboardingStateDto } from "@/lib/onboarding/needs";
import { accountLabel } from "@/lib/sharing/account-access-view";
+import { InlineAction } from "@/components/ui/inline-action";
interface AiProviderStatus {
/** Origin of the provider that would serve this user, if any. */
@@ -376,12 +377,9 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
-
- {t("onboarding.ai.setupCta")}
-
+
+ {t("onboarding.ai.setupCta")}
+
) : null}
@@ -459,20 +457,28 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
new account one honest pointer to Settings → Modules before they
leave the wizard. */}
-
-
- {t("onboarding.done.importCta")}
-
-
+
+ {t("onboarding.done.importCta")}
+
+
+
-
- {t("onboarding.done.modulesCta")}
-
+
+
+ {t("onboarding.done.modulesCta")}
+
+
);
diff --git a/src/components/settings/access/handover-decision.tsx b/src/components/settings/access/handover-decision.tsx
index 0dc356357..a3f619edb 100644
--- a/src/components/settings/access/handover-decision.tsx
+++ b/src/components/settings/access/handover-decision.tsx
@@ -22,6 +22,7 @@ import {
useHandoverDecision,
type HandoverDecisionState,
} from "@/lib/queries/use-handover-decision";
+import { InlineAction } from "@/components/ui/inline-action";
type PendingDecision = NonNullable;
@@ -191,13 +192,14 @@ export function HandoverNotificationHint() {
{t("recordSharing.handoverDecision.notificationsBody")}
-
- {t("recordSharing.handoverDecision.notificationsAction")}
-
+
+
+ {t("recordSharing.handoverDecision.notificationsAction")}
+
+
);
diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx
index eaf4098d2..78bfce68f 100644
--- a/src/components/settings/ai/codex-provider-form.tsx
+++ b/src/components/settings/ai/codex-provider-form.tsx
@@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
import type { InsightsSettings } from "./shared";
+import { InlineAction } from "@/components/ui/inline-action";
export function CodexProviderForm({
settings,
@@ -241,15 +242,15 @@ export function CodexProviderForm({
{t("settings.ai.deviceCodeStep2")}
{deviceCode.userCode}
-
navigator.clipboard?.writeText(deviceCode.userCode)
}
- className="text-muted-foreground hover:text-foreground text-xs underline"
+ tone="muted"
+ className="underline"
>
{t("settings.ai.deviceCodeCopy")}
-
+
{t("settings.ai.deviceCodeStep3")}
diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx
index b598f9514..1d0f077e0 100644
--- a/src/components/settings/ai/openai-provider-form.tsx
+++ b/src/components/settings/ai/openai-provider-form.tsx
@@ -27,6 +27,7 @@ import {
uiToLegacyProviderEnum,
type UserAIProvider,
} from "./shared";
+import { InlineAction } from "@/components/ui/inline-action";
export function OpenAIProviderForm({
userProvider,
@@ -172,15 +173,15 @@ export function OpenAIProviderForm({
)}
-
setShowAdvanced((v) => !v)}
- className="text-muted-foreground hover:text-foreground text-xs underline"
>
{showAdvanced
? t("settings.ai.openai.hideAdvanced")
: t("settings.ai.openai.showAdvanced")}
-
+
{showAdvanced && (
diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx
index d8909b2d1..9af68d69e 100644
--- a/src/components/settings/integrations/oauth-integration-card.tsx
+++ b/src/components/settings/integrations/oauth-integration-card.tsx
@@ -65,6 +65,7 @@ import {
type IntegrationDocsProvider,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
+import { InlineAction } from "@/components/ui/inline-action";
export type OAuthIntegrationKey =
| "withings"
@@ -489,16 +490,17 @@ export function OAuthIntegrationCardView({
details || planLimited || viewModel?.metricFreshness?.length ? (
<>
{planLimited ? (
-
- {t("settings.integrations.withings.plan.link")}
-
-
+
+
+ {t("settings.integrations.withings.plan.link")}
+
+
+
) : null}
{details}
diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx
index 389c2f221..caa967fb7 100644
--- a/src/components/settings/low-stock-card.tsx
+++ b/src/components/settings/low-stock-card.tsx
@@ -13,6 +13,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token";
+import { InlineAction } from "@/components/ui/inline-action";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
@@ -320,13 +321,11 @@ export function LowStockCard({
{/* v1.16.11 — back-link to the medications list, the surface the
alert is about (the supply tab links here the same way). */}
-
- {t("notifications.lowStock.medicationsLink")}
-
+
+
+ {t("notifications.lowStock.medicationsLink")}
+
+
);
diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx
index 557fa074b..6fc032a41 100644
--- a/src/components/settings/module-toggle-row.tsx
+++ b/src/components/settings/module-toggle-row.tsx
@@ -36,6 +36,7 @@ import type { LucideIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { InlineAction } from "@/components/ui/inline-action";
export interface ModuleToggleRowProps {
/** Stable key — used for the input id + the test query handle. */
@@ -112,13 +113,12 @@ export function ModuleToggleRow({
{disabledReason}
) : null}
{manageLink ? (
-
- {manageLink.label}
-
-
+
+
+ {manageLink.label}
+
+
+
) : null}
diff --git a/src/components/settings/report-selection/selection-panel.tsx b/src/components/settings/report-selection/selection-panel.tsx
index 158d45cdf..7cebc2ebd 100644
--- a/src/components/settings/report-selection/selection-panel.tsx
+++ b/src/components/settings/report-selection/selection-panel.tsx
@@ -74,6 +74,7 @@ import {
shiftDateKey,
validTimezoneOr,
} from "@/lib/tz/format";
+import { InlineAction } from "@/components/ui/inline-action";
type ExportFormat = "pdf" | "fhir" | "package";
@@ -310,13 +311,12 @@ export function HealthRecordExportPanel() {
{/* Entry point into the document vault for the report period
(navigation only). Only rendered when the module is enabled. */}
{user?.modules?.inboundDocuments ? (
-
-
- {t("settings.healthRecord.documentsLink")}
-
+
+
+
+ {t("settings.healthRecord.documentsLink")}
+
+
) : null}
diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx
index 0c3b85f6a..17e62753f 100644
--- a/src/components/settings/security-section/totp-card.tsx
+++ b/src/components/settings/security-section/totp-card.tsx
@@ -46,6 +46,7 @@ import {
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
+import { InlineAction } from "@/components/ui/inline-action";
interface SetupData {
otpauthUri: string;
@@ -430,13 +431,9 @@ export function TotpCard({
{t("settings.security.totp.verify")}
-
+
{t("common.cancel")}
-
+
)}
@@ -564,9 +561,8 @@ export function TotpCard({
}
className="font-mono"
/>
-
setDisableMethod((m) =>
m === "totp" ? "recovery" : "totp",
@@ -576,7 +572,7 @@ export function TotpCard({
{disableMethod === "totp"
? t("settings.security.useRecoveryCode")
: t("settings.security.useAuthenticator")}
-
+
{t("common.cancel")}
diff --git a/src/components/ui/__tests__/inline-action.test.tsx b/src/components/ui/__tests__/inline-action.test.tsx
new file mode 100644
index 000000000..247b19e1e
--- /dev/null
+++ b/src/components/ui/__tests__/inline-action.test.tsx
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { InlineAction } from "../inline-action";
+
+describe("", () => {
+ it("is a non-submitting button with the touch floor and a focus ring", () => {
+ const html = renderToStaticMarkup(
+ Open schedule ,
+ );
+ expect(html).toMatch(/^]*type="button"/);
+ expect(html).toContain("pointer-coarse:min-h-11");
+ expect(html).toContain("focus-visible:ring-[3px]");
+ expect(html).toContain("text-primary");
+ expect(html).toContain("text-xs");
+ });
+
+ it("keeps an explicit submit type", () => {
+ const html = renderToStaticMarkup(
+ Send ,
+ );
+ expect(html).toContain('type="submit"');
+ });
+
+ it("maps the tones and sizes", () => {
+ const muted = renderToStaticMarkup(
+
+ x
+ ,
+ );
+ expect(muted).toContain("text-muted-foreground");
+ expect(muted).toContain("hover:text-foreground");
+ expect(muted).toContain("text-sm");
+ expect(muted).not.toContain("text-primary");
+ });
+
+ it("renders a link through asChild without a button type", () => {
+ const html = renderToStaticMarkup(
+
+
+ History
+
+ ,
+ );
+ expect(html).toMatch(/^` through `asChild`. A plain button gets
+ * `type="button"` so it never submits the form it sits in.
+ */
+const inlineActionVariants = cva(
+ "inline-flex items-center gap-1 rounded-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 pointer-coarse:min-h-11 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
+ {
+ variants: {
+ tone: {
+ primary: "text-primary",
+ muted: "text-muted-foreground hover:text-foreground",
+ foreground: "text-foreground/80 hover:text-foreground",
+ },
+ size: {
+ xs: "text-xs",
+ sm: "text-sm",
+ },
+ },
+ defaultVariants: {
+ tone: "primary",
+ size: "xs",
+ },
+ },
+);
+
+type InlineActionProps = React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ };
+
+function InlineAction({
+ className,
+ tone,
+ size,
+ asChild = false,
+ type,
+ ...props
+}: InlineActionProps) {
+ const Comp = asChild ? Slot.Root : "button";
+ return (
+
+ );
+}
+
+export { InlineAction, inlineActionVariants };
diff --git a/src/components/ui/learn-more-link.tsx b/src/components/ui/learn-more-link.tsx
index a5a4ee51a..a49b0af34 100644
--- a/src/components/ui/learn-more-link.tsx
+++ b/src/components/ui/learn-more-link.tsx
@@ -5,6 +5,7 @@ import { ExternalLink } from "lucide-react";
import { learnLinkForMetric, learnUrl } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
+import { InlineAction } from "@/components/ui/inline-action";
import { cn } from "@/lib/utils";
/**
@@ -40,20 +41,18 @@ export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
const label = t("common.learnMore");
return (
-
- {label}
-
-
+
+
+ {label}
+
+
+
);
}
diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx
index af771e78b..361657bba 100644
--- a/src/components/vaccinations/vaccination-form.tsx
+++ b/src/components/vaccinations/vaccination-form.tsx
@@ -44,6 +44,7 @@ import { CatalogPicker } from "./catalog-picker";
import { CustomVaccineSheet } from "./custom-vaccine-sheet";
import { VaccinationDocumentPicker } from "./vaccination-document-picker";
import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only";
+import { InlineAction } from "@/components/ui/inline-action";
/** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */
export const VACCINATION_SITES = [
@@ -312,14 +313,14 @@ export function VaccinationForm({
) : (
- setSeriesOpen(true)}
>
{t("vaccinations.form.seriesToggle")}
-
+
)}