mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(ui): trim document-read copy and de-duplicate recovery headings
Remove the two per-document AI copy blocks in the document detail sheet: the external-egress notice and the "read this document" explainer. The settings-toggle honesty confirm already states the egress trade, so the per-document repeat is redundant. Drop the now-unused `egressExternal` prop and the `documents.ai.egressNotice` + `documents.ai.available` keys across all six locales. Keep the capability `egress` DTO field — the API still reports it. On /insights/recovery, the metric block rendered an outer heading plus an explainer above a tile whose own header repeated the same title. Move the explainer inside the tile as a `description` beneath its `TileHeader` and promote the tile title to a real `<h2>` (new `titleAs` affordance), so each metric shows one heading with the blurb under it and the h1->h2 outline stays nested. Applies to every metric the block renders.
This commit is contained in:
1 parent
1e2b1d2c6a
commit
97f8cb4303
13 files changed
+88
-85
No files matched your search
@@ -171,20 +171,19 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
|
||||
test.beforeEach(() => test.slow());
|
||||
|
||||
test("the vault shows the per-egress notice once and the explicit read action", async ({
|
||||
test("the vault shows the explicit read action for an external provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
// External egress (the codex/OAuth or any non-local provider case).
|
||||
// External egress (the codex/OAuth or any non-local provider case). The
|
||||
// per-document egress notice was retired — the settings-toggle honesty
|
||||
// confirm already covers the egress trade — so no per-document notice
|
||||
// renders here regardless of egress mode.
|
||||
await mockDocumentCapability(page, { egress: "external" });
|
||||
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The vendor-blind "leaves your machine" notice is shown exactly once.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
).toHaveCount(1);
|
||||
// The per-document read is the explicit path — the action is present and the
|
||||
// user must tap it (auto-read never removes the affordance, only the tap).
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
@@ -218,8 +217,12 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
expect(indexCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("a local-egress read shows no external notice", async ({ page }) => {
|
||||
// A self-hosted local model never leaves the machine — no egress notice.
|
||||
test("a local-egress provider shows the explicit read action", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A self-hosted local model never leaves the machine; the read action is
|
||||
// offered the same way as for an external provider (no per-document notice
|
||||
// in either mode after the notice was retired).
|
||||
await mockDocumentCapability(page, {
|
||||
egress: "local",
|
||||
pdfSupported: false,
|
||||
@@ -229,8 +232,6 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast.",
|
||||
"blockTitle": "Mit KI auslesen",
|
||||
"available": "Lass die KI dieses Dokument lesen — Titel, Art und Datum vorschlagen, zusammenfassen oder den ganzen Text durchsuchbar machen.",
|
||||
"read": "Mit KI auslesen",
|
||||
"reread": "Erneut lesen",
|
||||
"reading": "Liest…",
|
||||
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Hide"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there.",
|
||||
"blockTitle": "Read with AI",
|
||||
"available": "Let the AI read this document — suggest a title, type and date, summarise it, or make its full text searchable.",
|
||||
"read": "Read with AI",
|
||||
"reread": "Read again",
|
||||
"reading": "Reading…",
|
||||
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí.",
|
||||
"blockTitle": "Leer con IA",
|
||||
"available": "Deja que la IA lea este documento: sugerir un título, tipo y fecha, resumirlo o hacer que todo su texto sea buscable.",
|
||||
"read": "Leer con IA",
|
||||
"reread": "Leer de nuevo",
|
||||
"reading": "Leyendo…",
|
||||
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Masquer"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas.",
|
||||
"blockTitle": "Lire avec l’IA",
|
||||
"available": "Laissez l’IA lire ce document — suggérer un titre, un type et une date, le résumer ou rendre tout son texte consultable.",
|
||||
"read": "Lire avec l’IA",
|
||||
"reread": "Relire",
|
||||
"reading": "Lecture…",
|
||||
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì.",
|
||||
"blockTitle": "Leggi con l’IA",
|
||||
"available": "Lascia che l’IA legga questo documento: suggerire un titolo, un tipo e una data, riassumerlo o rendere ricercabile tutto il testo.",
|
||||
"read": "Leggi con l’IA",
|
||||
"reread": "Leggi di nuovo",
|
||||
"reading": "Lettura…",
|
||||
|
||||
@@ -8226,9 +8226,7 @@
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam.",
|
||||
"blockTitle": "Odczytaj z AI",
|
||||
"available": "Pozwól AI odczytać ten dokument — zaproponować tytuł, typ i datę, streścić go lub udostępnić cały tekst do wyszukiwania.",
|
||||
"read": "Odczytaj z AI",
|
||||
"reread": "Odczytaj ponownie",
|
||||
"reading": "Odczytywanie…",
|
||||
|
||||
@@ -34,7 +34,6 @@ function base(
|
||||
aiEnabled: true,
|
||||
indexEnabled: true,
|
||||
unavailableReason: null,
|
||||
egressExternal: false,
|
||||
actionsDisabled: false,
|
||||
onSuggest: noop,
|
||||
suggestPending: false,
|
||||
@@ -143,20 +142,14 @@ describe("<DocumentAiSection>", () => {
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("shows the vendor-blind egress notice before an external document read", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: true }));
|
||||
expect(html).toContain('data-slot="document-ai-egress-notice"');
|
||||
expect(html).toContain("third-party AI service");
|
||||
// Vendor-blind: never names a specific AI vendor in the document surface.
|
||||
expect(html).not.toContain("OpenAI");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
// The actions are still offered — the notice informs, it does not block.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
});
|
||||
|
||||
it("omits the egress notice when the read stays on the local machine", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: false }));
|
||||
it("renders no per-document egress notice — the settings confirm covers it", () => {
|
||||
// The per-document egress notice was retired; the settings-toggle honesty
|
||||
// confirm is now the single place the egress trade is stated.
|
||||
const html = render(base({ aiEnabled: true }));
|
||||
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
|
||||
// The actions are still offered.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
expect(html).toContain('data-slot="document-read-ai"');
|
||||
});
|
||||
|
||||
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* status pill stays, honestly reflecting any local auto-index, and the manual
|
||||
* form below stays fully usable.
|
||||
*/
|
||||
import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react";
|
||||
import { FileText, ScanText, WandSparkles } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,7 +47,6 @@ export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
egressExternal,
|
||||
actionsDisabled,
|
||||
onSuggest,
|
||||
suggestPending,
|
||||
@@ -75,12 +74,6 @@ export function DocumentAiSection({
|
||||
aiEnabled: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/**
|
||||
* True when a document read will egress to a third-party AI service (any
|
||||
* provider other than a self-hosted local model). Drives the vendor-blind
|
||||
* "this leaves your machine" notice shown before the AI actions.
|
||||
*/
|
||||
egressExternal: boolean;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
@@ -143,23 +136,8 @@ export function DocumentAiSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{egressExternal ? (
|
||||
<div
|
||||
data-slot="document-ai-egress-notice"
|
||||
role="note"
|
||||
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("documents.ai.egressNotice")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.ai.available")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{indexEnabled ? (
|
||||
<Button
|
||||
|
||||
@@ -506,7 +506,6 @@ export function DocumentDetailSheet({
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
egressExternal={capability.data?.egress === "external"}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Sigma } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -102,6 +102,21 @@ export interface MetricStatSeries {
|
||||
* all-time central value. Omit it to keep the generic label.
|
||||
*/
|
||||
medianLabel?: string;
|
||||
/**
|
||||
* v1.28.4 — optional descriptive line rendered INSIDE the tile, directly
|
||||
* under the `<TileHeader>` and above the four-stat grid. Lets a metric block
|
||||
* own its heading + blurb in a single tile rather than duplicating the
|
||||
* heading in an outer section above it. Muted supporting copy; omit it for
|
||||
* the numbers-only header the other subpages lead with.
|
||||
*/
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Render the series' `<TileHeader>` title as a real heading element (e.g.
|
||||
* "h2") rather than the presentational `CardTitle` div. Used where the tile
|
||||
* carries the ONLY heading on its subpage branch (the recovery blocks) so the
|
||||
* `h1 → h2` outline under `SubPageShell` stays nested with no skipped level.
|
||||
*/
|
||||
titleAs?: "h2";
|
||||
}
|
||||
|
||||
type MetricStatStripProps = Partial<MetricStatSeries> & {
|
||||
@@ -140,6 +155,8 @@ function SeriesBlock({
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
description,
|
||||
titleAs,
|
||||
}: MetricStatSeries) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -183,7 +200,16 @@ function SeriesBlock({
|
||||
className="space-y-2"
|
||||
>
|
||||
{seriesLabel ? (
|
||||
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
|
||||
<TileHeader
|
||||
icon={icon ?? Sigma}
|
||||
title={seriesLabel}
|
||||
titleAs={titleAs}
|
||||
/>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
|
||||
{cells.map((cell) => (
|
||||
@@ -215,6 +241,8 @@ export function MetricStatStrip({
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
description,
|
||||
titleAs,
|
||||
series,
|
||||
groupLabel,
|
||||
}: MetricStatStripProps) {
|
||||
@@ -277,6 +305,8 @@ export function MetricStatStrip({
|
||||
windowStats={windowStats}
|
||||
dataKey={dataKey}
|
||||
medianLabel={medianLabel}
|
||||
description={description}
|
||||
titleAs={titleAs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -71,28 +71,20 @@ export function RecoveryMetricBlock({
|
||||
|
||||
return (
|
||||
<section data-slot={`recovery-block-${type}`} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* v1.18.6 — block title is an `<h2>` (at `text-base`): the per-group
|
||||
sub-headings were removed, so each block now sits directly under
|
||||
the page `<h1>` from `SubPageShell`. Promoting it from `<h3>` keeps
|
||||
the recovery page's heading outline strictly nested (h1 → h2)
|
||||
with no skipped level. */}
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
</div>
|
||||
<p className="text-foreground text-sm leading-relaxed">{explainer}</p>
|
||||
</div>
|
||||
|
||||
{/* v1.28.4 — the block no longer wraps the tile in an outer `<h2>` +
|
||||
explainer that the stat-strip's own `TileHeader` then repeated. The
|
||||
tile owns the single heading: `seriesLabel` becomes the block's `<h2>`
|
||||
(via `titleAs`, preserving the h1 → h2 nesting under `SubPageShell`)
|
||||
and the explainer renders inside the tile beneath it as `description`.
|
||||
The chart tile keeps its own canonical `TileHeader` naming itself. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={unit}
|
||||
fractionDigits={fractionDigits}
|
||||
seriesLabel={title}
|
||||
icon={Icon}
|
||||
titleAs="h2"
|
||||
description={explainer}
|
||||
windowStats={statsByType?.[type] ?? null}
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ interface TileHeaderProps {
|
||||
titleClassName?: string;
|
||||
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
|
||||
id?: string;
|
||||
/**
|
||||
* Render the title as a real heading element (e.g. `"h2"`) instead of the
|
||||
* default presentational `CardTitle` div, keeping the identical CardTitle
|
||||
* styling. Use only where the tile owns the ONLY heading on its subpage
|
||||
* branch (the recovery blocks) so the `h1 → h2` outline stays nested with no
|
||||
* skipped level. Default (unset) keeps every other tile a plain CardTitle.
|
||||
*/
|
||||
titleAs?: "h2";
|
||||
}
|
||||
|
||||
export function TileHeader({
|
||||
@@ -61,7 +69,12 @@ export function TileHeader({
|
||||
className,
|
||||
titleClassName,
|
||||
id,
|
||||
titleAs,
|
||||
}: TileHeaderProps) {
|
||||
const titleClassNames = cn(
|
||||
size === "sm" ? "text-sm" : "text-base",
|
||||
titleClassName,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="tile-header"
|
||||
@@ -78,12 +91,21 @@ export function TileHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<CardTitle
|
||||
id={id}
|
||||
className={cn(size === "sm" ? "text-sm" : "text-base", titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{titleAs === "h2" ? (
|
||||
// Heading semantics with the exact CardTitle visual treatment so the
|
||||
// tile can carry a real `<h2>` where it owns the subpage's heading.
|
||||
<h2
|
||||
id={id}
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", titleClassNames)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
) : (
|
||||
<CardTitle id={id} className={titleClassNames}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
{right ? <div className="ml-auto flex items-center">{right}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user