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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:03:15 +02:00
1 parent ce8074ad08
commit 60be63bcd1
33 files changed
+484 -222

No files matched your search

+121
View File
@@ -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 `<button>`, `<Link>` or `<a>` 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<string, string> = {
// 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:
'<Link href="/a" className="text-primary text-xs underline-offset-2 hover:underline">Go</Link>',
},
{
path: "components/y.tsx",
source:
'<button type="button" onClick={() => go()} className="text-muted-foreground hover:text-foreground text-sm underline">Go</button>',
},
{
path: "components/z.tsx",
source: '<InlineAction tone="muted">Go</InlineAction>',
},
];
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("<InlineAction"));
expect(users.length).toBeGreaterThanOrEqual(25);
});
});
+7 -8
View File
@@ -18,6 +18,7 @@ import { ArrowRight, Plus, Wrench } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { InlineAction } from "@/components/ui/inline-action";
export default function MoodPageClient() {
const { user, isAuthenticated, isLoading } = useAuth();
@@ -197,14 +198,12 @@ export default function MoodPageClient() {
here (module-gated — no nav change) rather than a merge: capture
vs. screener stay two defensible surfaces, just cross-linked. */}
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild tone="muted" size="sm" className="gap-1.5">
<Link href="/mental-wellbeing" data-slot="mood-mental-wellbeing-link">
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
);
@@ -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")}
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
{deviceCode.userCode}
<button
type="button"
<InlineAction
tone="muted"
className="underline"
onClick={() =>
navigator.clipboard?.writeText(deviceCode.userCode)
}
className="text-muted-foreground hover:text-foreground text-xs underline"
>
{t("settings.ai.deviceCodeCopy")}
</button>
</InlineAction>
</div>
</li>
<li>{t("settings.ai.deviceCodeStep3")}</li>
@@ -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
/>
<Link
href="/admin/coach"
className="text-primary text-xs underline-offset-4 hover:underline"
>
{t("admin.section.coach.title")}
</Link>
<InlineAction asChild>
<Link href="/admin/coach">
{t("admin.section.coach.title")}
</Link>
</InlineAction>
</div>
);
}
@@ -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={
<Link
href="/admin/login-overview"
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild size="sm">
<Link href="/admin/login-overview">
{t("admin.overview.auditViewAll")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
}
/>
@@ -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 }) {
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
</div>
<Link
href="/admin/encryption"
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
>
{t("dashboard.keyBackupBanner.action")}
</Link>
<InlineAction asChild size="sm" className="min-h-11 font-medium">
<Link href="/admin/encryption">
{t("dashboard.keyBackupBanner.action")}
</Link>
</InlineAction>
</div>
);
}
@@ -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({
</div>
</div>
{hasFilters ? (
<button
type="button"
<InlineAction
tone="muted"
onClick={() => {
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"
>
<X className="size-3.5" aria-hidden />
{t("documents.sourcePicker.clearFilters")}
</button>
</InlineAction>
) : null}
{finished ? (
@@ -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") && (
<Link
href="/settings/ai"
data-slot="coach-no-provider-cta"
className="text-primary text-xs font-medium underline-offset-4 hover:underline"
>
{t("insights.coach.errorNoProviderAction")}
</Link>
<InlineAction asChild className="font-medium">
<Link href="/settings/ai" data-slot="coach-no-provider-cta">
{t("insights.coach.errorNoProviderAction")}
</Link>
</InlineAction>
)}
{/* v1.18.1 — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
+10 -8
View File
@@ -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({
</span>
) : null}
{/* v1.42 — the way to the score's course over time. */}
<Link
href="/insights/health-score"
data-slot="health-score-history-link"
className="text-foreground/80 hover:text-foreground focus-visible:ring-ring/50 -my-3 inline-flex items-center gap-1 rounded-md py-3 text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
<History className="size-3.5" aria-hidden="true" />
{t("insights.healthScore.historyLink")}
</Link>
<InlineAction asChild tone="foreground">
<Link
href="/insights/health-score"
data-slot="health-score-history-link"
>
<History className="size-3.5" aria-hidden="true" />
{t("insights.healthScore.historyLink")}
</Link>
</InlineAction>
</div>
</div>
@@ -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({
</span>
</p>
</div>
<Link
href={`/medications/${med.id}?tab=wirkung`}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
</Link>
<InlineAction asChild className="shrink-0">
<Link
href={`/medications/${med.id}?tab=wirkung`}
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
</Link>
</InlineAction>
</li>
);
})}
@@ -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. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>
{t("targets.sourceLabel", {
source: sourceName(target.source, t),
})}
</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
<InlineAction asChild tone="muted">
<a href={sourceLink} target="_blank" rel="noopener noreferrer">
<span>
{t("targets.sourceLabel", {
source: sourceName(target.source, t),
})}
</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", {
@@ -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 }) {
)}
</div>
<button
type="button"
<InlineAction
tone="muted"
className="font-medium"
onClick={() => 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 ? (
<ChevronUp className="h-3.5 w-3.5" />
@@ -103,7 +104,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
<ChevronDown className="h-3.5 w-3.5" />
)}
{t("insights.sleep.chronotype.advancedToggle")}
</button>
</InlineAction>
{showAdvanced && (
<div id="chronotype-advanced" className="space-y-2 border-t pt-3">
+14 -8
View File
@@ -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 ? (
<Link
href={config.detailHref}
data-slot="trends-row-detail-link"
data-metric={config.metric}
className="text-muted-foreground hover:text-foreground mt-auto inline-flex items-center gap-1 self-start text-xs font-medium transition-colors"
<InlineAction
asChild
tone="muted"
className="mt-auto self-start font-medium"
>
{t("insights.trendsRow.viewDetail", { metric: title })}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<Link
href={config.detailHref}
data-slot="trends-row-detail-link"
data-metric={config.metric}
>
{t("insights.trendsRow.viewDetail", { metric: title })}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
);
@@ -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) {
<span className="min-w-0 flex-1" />
)}
{workout.previousWorkoutId ? (
<Link
href={`/insights/workouts/${workout.previousWorkoutId}`}
data-slot="workout-detail-previous-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.workouts.detail.previousSessionLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild tone="muted" className="shrink-0 gap-1.5">
<Link
href={`/insights/workouts/${workout.previousWorkoutId}`}
data-slot="workout-detail-previous-link"
>
{t("insights.workouts.detail.previousSessionLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
) : null}
+4 -4
View File
@@ -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}
</span>
<button
type="button"
<InlineAction
tone="muted"
data-slot={`${slot}-group-select`}
onClick={() =>
change(toggleAll(selected, groupIds), group.options)
}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
</button>
</InlineAction>
</div>
) : null}
<ul className="space-y-1">
@@ -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({
</div>
<div className="space-y-1.5">
<div className="flex h-5 items-center">
<button
type="button"
onClick={onRetry}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
<RotateCw className="h-3 w-3" aria-hidden="true" />
<InlineAction tone="muted" onClick={onRetry}>
<RotateCw className="size-3" aria-hidden="true" />
{t("common.retry")}
</button>
</InlineAction>
</div>
<div className="bg-muted/40 h-2 rounded" />
</div>
@@ -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({
<p className="text-muted-foreground text-xs font-medium">{label}</p>
<p className="text-foreground text-sm">{value}</p>
</div>
<button
type="button"
<InlineAction
onClick={onJump}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
className="shrink-0"
data-slot={`${dataSlot}-jump`}
>
{jumpLabel}
</button>
</InlineAction>
</li>
);
}
@@ -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 && (
<div className="py-2">
<Link
href="/settings/notifications#low-stock"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="inventory-low-stock-link"
>
{lowStockDays !== null
? t("medications.detail.bestand.lowStockLinkOn", {
days: lowStockDays,
})
: t("medications.detail.bestand.lowStockLinkOff")}
</Link>
<InlineAction asChild tone="muted">
<Link
href="/settings/notifications#low-stock"
data-slot="inventory-low-stock-link"
>
{lowStockDays !== null
? t("medications.detail.bestand.lowStockLinkOn", {
days: lowStockDays,
})
: t("medications.detail.bestand.lowStockLinkOff")}
</Link>
</InlineAction>
</div>
)}
</SettingsGroup>
+12 -10
View File
@@ -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({
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
{bodyLine}
</span>
<a
href={
linked.body.available && linked.body.episodeId
? `/illness/${linked.body.episodeId}`
: "/illness"
}
className="text-primary text-xs underline underline-offset-2"
>
{t("mood.dayContext.openIllness")}
</a>
<InlineAction asChild className="underline">
<a
href={
linked.body.available && linked.body.episodeId
? `/illness/${linked.body.episodeId}`
: "/illness"
}
>
{t("mood.dayContext.openIllness")}
</a>
</InlineAction>
</div>
) : null}
</div>
+4 -4
View File
@@ -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}
</p>
{(expanded || clamped) && (
<button
type="button"
<InlineAction
className="font-medium"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={noteId}
data-testid="mood-note-toggle"
className="text-primary cursor-pointer text-xs font-medium"
>
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</button>
</InlineAction>
)}
</div>
);
+24 -18
View File
@@ -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 }) {
<MedicalDisclaimer variant="dataPosture" />
<Link
href="/settings/ai"
className="text-primary text-sm font-medium underline underline-offset-4"
>
{t("onboarding.ai.setupCta")}
</Link>
<InlineAction asChild size="sm" className="font-medium underline">
<Link href="/settings/ai">{t("onboarding.ai.setupCta")}</Link>
</InlineAction>
</section>
) : null}
@@ -459,20 +457,28 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) {
new account one honest pointer to Settings → Modules before they
leave the wizard. */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
<InlineAction
asChild
tone="muted"
size="sm"
className="gap-1.5 underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
<Link
href="/settings/modules"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline underline-offset-4"
<Link href="/settings/export">
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
</InlineAction>
<InlineAction
asChild
tone="muted"
size="sm"
className="gap-1.5 underline"
>
<Settings2 className="size-3.5" />
{t("onboarding.done.modulesCta")}
</Link>
<Link href="/settings/modules">
<Settings2 className="size-3.5" />
{t("onboarding.done.modulesCta")}
</Link>
</InlineAction>
</div>
</section>
);
@@ -22,6 +22,7 @@ import {
useHandoverDecision,
type HandoverDecisionState,
} from "@/lib/queries/use-handover-decision";
import { InlineAction } from "@/components/ui/inline-action";
type PendingDecision = NonNullable<HandoverDecisionState["pending"]>;
@@ -191,13 +192,14 @@ export function HandoverNotificationHint() {
<p className="text-sm">
{t("recordSharing.handoverDecision.notificationsBody")}
</p>
<Link
href="/settings/notifications"
className="text-primary text-sm underline"
data-slot="handover-notification-link"
>
{t("recordSharing.handoverDecision.notificationsAction")}
</Link>
<InlineAction asChild size="sm" className="underline">
<Link
href="/settings/notifications"
data-slot="handover-notification-link"
>
{t("recordSharing.handoverDecision.notificationsAction")}
</Link>
</InlineAction>
</div>
</div>
);
@@ -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")}
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
{deviceCode.userCode}
<button
type="button"
<InlineAction
onClick={() =>
navigator.clipboard?.writeText(deviceCode.userCode)
}
className="text-muted-foreground hover:text-foreground text-xs underline"
tone="muted"
className="underline"
>
{t("settings.ai.deviceCodeCopy")}
</button>
</InlineAction>
</div>
</li>
<li>{t("settings.ai.deviceCodeStep3")}</li>
@@ -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({
)}
<div>
<button
type="button"
<InlineAction
tone="muted"
className="underline"
onClick={() => setShowAdvanced((v) => !v)}
className="text-muted-foreground hover:text-foreground text-xs underline"
>
{showAdvanced
? t("settings.ai.openai.hideAdvanced")
: t("settings.ai.openai.showAdvanced")}
</button>
</InlineAction>
{showAdvanced && (
<div className="mt-2 space-y-1">
<Label htmlFor="ai-openai-base-url">
@@ -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 ? (
<a
href={WITHINGS_API_PLANS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="withings-plan-link"
className="text-primary inline-flex min-h-11 items-center gap-1 text-sm underline-offset-2 hover:underline sm:min-h-0"
>
{t("settings.integrations.withings.plan.link")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
<InlineAction asChild size="sm">
<a
href={WITHINGS_API_PLANS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="withings-plan-link"
>
{t("settings.integrations.withings.plan.link")}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
) : null}
{details}
<MetricFreshnessList entries={viewModel?.metricFreshness} />
+6 -7
View File
@@ -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). */}
<p>
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="low-stock-medications-link"
>
{t("notifications.lowStock.medicationsLink")}
</Link>
<InlineAction asChild tone="muted">
<Link href="/medications" data-slot="low-stock-medications-link">
{t("notifications.lowStock.medicationsLink")}
</Link>
</InlineAction>
</p>
</SettingsCard>
);
@@ -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({
<p className="text-muted-foreground text-xs">{disabledReason}</p>
) : null}
{manageLink ? (
<Link
href={manageLink.href}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
>
{manageLink.label}
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild tone="muted">
<Link href={manageLink.href}>
{manageLink.label}
<ChevronRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
</div>
@@ -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 ? (
<Link
href={`/documents?year=${new Date().getFullYear()}`}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
<InlineAction asChild tone="muted" className="gap-1.5">
<Link href={`/documents?year=${new Date().getFullYear()}`}>
<FolderOpen className="size-3.5" aria-hidden />
{t("settings.healthRecord.documentsLink")}
</Link>
</InlineAction>
) : null}
</div>
</div>
@@ -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")}
</Button>
</div>
<button
type="button"
onClick={resetWizard}
className="text-muted-foreground hover:text-foreground text-xs"
>
<InlineAction tone="muted" onClick={resetWizard}>
{t("common.cancel")}
</button>
</InlineAction>
</form>
</div>
)}
@@ -564,9 +561,8 @@ export function TotpCard({
}
className="font-mono"
/>
<button
type="button"
className="text-muted-foreground hover:text-foreground text-xs"
<InlineAction
tone="muted"
onClick={() =>
setDisableMethod((m) =>
m === "totp" ? "recovery" : "totp",
@@ -576,7 +572,7 @@ export function TotpCard({
{disableMethod === "totp"
? t("settings.security.useRecoveryCode")
: t("settings.security.useAuthenticator")}
</button>
</InlineAction>
</div>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { InlineAction } from "../inline-action";
describe("<InlineAction>", () => {
it("is a non-submitting button with the touch floor and a focus ring", () => {
const html = renderToStaticMarkup(
<InlineAction>Open schedule</InlineAction>,
);
expect(html).toMatch(/^<button[^>]*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(
<InlineAction type="submit">Send</InlineAction>,
);
expect(html).toContain('type="submit"');
});
it("maps the tones and sizes", () => {
const muted = renderToStaticMarkup(
<InlineAction tone="muted" size="sm">
x
</InlineAction>,
);
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(
<InlineAction asChild tone="foreground">
<a href="/insights/health-score" data-slot="history-link">
History
</a>
</InlineAction>,
);
expect(html).toMatch(/^<a /);
expect(html).not.toContain("type=");
expect(html).toContain('data-slot="history-link"');
expect(html).toContain("pointer-coarse:min-h-11");
});
});
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* A small text action that sits inside a line of content: "Open schedule"
* beside a value, "Select all" beside a group label, "View all" in a card
* header, a "Learn more" link under a hint. Not a button with a frame (that
* is `Button`), not a content link inside prose.
*
* It owns three things the hand-rolled copies kept getting wrong:
*
* - **The touch target.** On a coarse pointer the action is at least 44 px
* tall (`pointer-coarse:min-h-11`, the same floor `Button` carries), so a
* 12 px "Edit" link is not a 16 px target on a phone. A fine pointer keeps
* the line height, so the desktop layout does not move.
* - **The focus ring**, which several copies had none of.
* - **The tone vocabulary**: `primary` for the one forward action of a row,
* `muted` for a secondary way out, `foreground` for an action that reads as
* part of the content.
*
* Render a `Link` or `<a>` 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<typeof inlineActionVariants> & {
asChild?: boolean;
};
function InlineAction({
className,
tone,
size,
asChild = false,
type,
...props
}: InlineActionProps) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="inline-action"
type={asChild ? undefined : (type ?? "button")}
className={cn(inlineActionVariants({ tone, size }), className)}
{...props}
/>
);
}
export { InlineAction, inlineActionVariants };
+13 -14
View File
@@ -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 (
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline",
className,
)}
>
{label}
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
<InlineAction asChild tone="muted" className={className}>
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
>
{label}
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</InlineAction>
);
}
@@ -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({
</FieldGroup>
</div>
) : (
<button
type="button"
<InlineAction
tone="muted"
size="sm"
data-slot="vaccination-series-reveal"
className="text-muted-foreground hover:text-foreground text-sm underline-offset-2 hover:underline"
onClick={() => setSeriesOpen(true)}
>
{t("vaccinations.form.seriesToggle")}
</button>
</InlineAction>
)}
<FieldGroup htmlFor="vaccination-lot" label={t("vaccinations.form.lot")}>