Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit f83fcb88aca738ebe9c75dfe98ba6a99d686b297
parent 695cec67147f4d3b004dd0dcfc794dd0843cc770
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 18:48:01 -0400

export: the hub in the homepage's register — a plain H1, "Official instances" with the homepage's figures per card (hub-summary.json), "Archives you added", the live line reworded; specs follow the new H1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/components/siteRegistry.ts | 2+-
Mexport/app/(workspace)/ask/page.tsx | 2+-
Mexport/app/components/hub/AddArchive.tsx | 13+++++++------
Mexport/app/components/hub/ArchiveShelf.tsx | 235++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mexport/app/components/hub/HubHome.tsx | 5+++--
Mexport/app/components/hub/HubStats.tsx | 38+++++++++++++++++++-------------------
Aexport/app/components/hub/useHubSummary.ts | 34++++++++++++++++++++++++++++++++++
Mexport/e2e-2origin/federation.spec.ts | 2+-
Mexport/e2e-hub/ask-grounding.spec.ts | 2+-
Mexport/e2e-hub/ask.spec.ts | 2+-
Mexport/e2e-hub/federation.spec.ts | 11+++++++++--
Aexport/e2e-hub/official-instances.spec.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 376 insertions(+), 102 deletions(-)

diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts @@ -224,7 +224,7 @@ export type ValidateResult = // add-flow states. const BAD_URL = "Enter a full site URL, like https://archive.example.com."; const MIXED = "That site is served over http, so a secure hub can't read it."; -const ALREADY = "That archive is already on your shelf."; +const ALREADY = "That archive is already on this hub."; const UNREADABLE = "Couldn't read that site — it may not be an Archilyzer archive, or it isn't sharing its data."; const WRONG_CONTRACT = diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx @@ -37,7 +37,7 @@ export default function AskPage() { <Link href="/use-with-ai" className="text-brand hover:underline"> Use with AI </Link>{" "} - for other ways to use this archive. + for other ways to use {isHub ? "every archive on this hub" : "this archive"}. </p> </header> diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx @@ -1,10 +1,11 @@ "use client"; -// The "add a volume to the shelf" control: paste an archive URL, it validates -// against the federation contract, and on success the archive slides onto the -// shelf. Written as direction — validating → success, or a plain reason it -// couldn't be read. Self-contained: owns its input + status and commits to the -// shared registry. +// The add-an-archive control: paste an archive URL, it validates against the +// federation contract, and on success the archive joins "Archives you added". +// Written as direction — validating → success, or a plain reason it couldn't +// be read. Self-contained: owns its input + status and commits to the shared +// registry. The button is the family's CTA — ink on ground, not a coloured +// fill (tokens.css, the archilyzer block). import { useState } from "react"; import { @@ -65,7 +66,7 @@ export default function AddArchive() { <button type="submit" disabled={busy || !url.trim()} - className="rounded-md bg-brand px-4 py-2 text-sm font-medium text-brand-ink transition-colors hover:bg-brand-strong disabled:opacity-50" + className="rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition-colors hover:bg-brand hover:text-brand-ink disabled:opacity-50 disabled:hover:bg-foreground disabled:hover:text-background" > {busy ? "Validating…" : "Add"} </button> diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx @@ -1,11 +1,22 @@ "use client"; -// The hub's hero: a shelf of archives, not a KPI wall. Each federated archive is -// a first-class, identity-bearing spine in its OWN accent — the registry itself -// is the thing you curate, so it leads. Adding a site slides a new volume onto -// the shelf. Chrome stays neutral/brass so the per-site accents are the only -// color; the load stagger (globals.css .shelf-spine) is the only motion and -// respects reduced-motion. +// The hub's front: what it is, the official instances it searches, the +// archives a visitor added, and the form to add another — in the homepage's +// register (homepage/app/page.tsx): short declarative sentences, no eyebrow +// over a band, no subtitle under a heading, and each heading after the H1 +// names what is under it. +// +// Two kinds of number, deliberately kept apart. An official card shows the +// homepage's own figures for that archive, composed at hub build time +// (/hub-summary.json, useHubSummary) — the same numbers the homepage prints, +// omitted one by one when the file lacks them. An added archive has no such +// file behind it, so its card shows only the live channel count its +// descriptor gave. The live federation total is HubStats, under the shelf. +// +// Each card wears its archive's OWN accent (tokens.css, the archilyzer family: +// the tool has no colour, the archives do). The homepage stripes its cards by +// chart series instead, because there each card is also a chart legend; the +// hub has no chart, so it keeps the archive's identity. import { X } from "lucide-react"; import { Badge } from "yt-dlp-transcript-common/components/ui/badge"; @@ -13,107 +24,195 @@ import { useRegistry, type RegisteredSite, } from "yt-dlp-transcript-common/components/siteRegistry"; +import { + hubSummarySiteFor, + type HubSummarySite, +} from "yt-dlp-transcript-common/lib/hubSummary"; import AddArchive from "./AddArchive"; +import { useHubSummary } from "./useHubSummary"; -// Fall back to the family brass when a site declares no accent. +// Fall back to the family's signal colour when a site declares no accent. const FALLBACK_ACCENT = "var(--brand)"; -function Spine({ +function Figure({ value, unit }: { value: number | undefined; unit: string }) { + if (value === undefined) return null; + return ( + <div className="flex min-w-0 flex-col-reverse justify-end gap-1"> + <dt className="text-xs text-faint">{unit}</dt> + <dd className="text-[0.9375rem] tabular-nums text-foreground"> + {value.toLocaleString()} + </dd> + </div> + ); +} + +function ArchiveCard({ site, + figures, index, onRemove, }: { site: RegisteredSite; + // Official: the build-time figures (null until/unless the summary loads). + // Added: undefined — the card shows the descriptor's channel count only. + figures?: HubSummarySite | null; index: number; - onRemove: (origin: string) => void; + onRemove?: (origin: string) => void; }) { - const accent = site.accent || FALLBACK_ACCENT; - const member = site.kind === "builtin"; + const accent = site.accent || figures?.accent || FALLBACK_ACCENT; + const href = site.siteUrl || site.origin; + const description = figures?.siteDescription; + const channels = + figures === undefined ? site.channelCount : figures?.channels; + const anyFigure = + channels !== undefined || + figures?.recordings !== undefined || + figures?.transcripts !== undefined || + figures?.hoursArchived !== undefined; return ( <li data-testid="shelf-spine" - className="shelf-spine group relative flex w-full min-w-0 max-w-full flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3 sm:w-auto sm:min-w-52 sm:max-w-64" + className="shelf-spine group relative flex min-w-0 flex-col gap-4 border-r border-b border-border bg-surface p-5 pl-6" style={{ animationDelay: `${Math.min(index, 8) * 45}ms` }} > - {/* The spine: a thick accent edge in the site's own color. */} <span aria-hidden="true" - className="absolute inset-y-1.5 left-0 w-1 rounded-full" + className="absolute inset-y-0 left-0 w-[3px]" style={{ background: accent }} /> - <div className="flex items-start justify-between gap-2"> - <span className="font-display text-base font-semibold leading-tight tracking-tight text-foreground"> - {site.siteTitle} - </span> - {site.kind === "external" && ( - <button - type="button" - onClick={() => onRemove(site.origin)} - aria-label={`Remove ${site.siteTitle}`} - // Reveal-on-hover is not an affordance on a touch screen: a device - // with no hover shows it always. `hover:` never gates a function. - className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" - > - <X className="size-3.5" aria-hidden="true" /> - </button> + <div className="flex flex-col gap-1.5"> + <div className="flex items-start justify-between gap-2"> + <h3 className="font-display text-lg font-semibold text-foreground"> + <a + href={href} + target="_blank" + rel="noopener noreferrer" + className="underline decoration-transparent underline-offset-4 transition-colors hover:decoration-border-strong" + > + {site.siteTitle} + <span aria-hidden="true" className="ml-1.5 text-sm text-faint"> + ↗ + </span> + </a> + </h3> + {onRemove && ( + <button + type="button" + onClick={() => onRemove(site.origin)} + aria-label={`Remove ${site.siteTitle}`} + // Reveal-on-hover is not an affordance on a touch screen: a device + // with no hover shows it always. `hover:` never gates a function. + className="-mr-1 mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100" + > + <X className="size-3.5" aria-hidden="true" /> + </button> + )} + </div> + {description && ( + <p className="text-sm leading-relaxed text-muted-foreground"> + {description} + </p> )} </div> - {typeof site.channelCount === "number" && ( - <span className="font-mono text-[0.7rem] uppercase tracking-[0.12em] text-muted-foreground"> - {site.channelCount} channel{site.channelCount === 1 ? "" : "s"} - </span> - )} - <div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1"> - <Badge variant={member ? "secondary" : "outline"}> - {member ? "Member" : "Added"} - </Badge> - <Badge variant={site.pwa ? "info" : "ghost"}> - {site.pwa ? "Installable" : "Data-only"} - </Badge> + <div className="mt-auto flex flex-col gap-3"> + {anyFigure && ( + <dl className="grid grid-cols-4 gap-3"> + <Figure value={channels} unit="channels" /> + <Figure value={figures?.recordings} unit="recordings" /> + <Figure value={figures?.transcripts} unit="transcripts" /> + <Figure value={figures?.hoursArchived} unit="hours" /> + </dl> + )} + <div> + <Badge variant={site.pwa ? "info" : "ghost"}> + {site.pwa ? "Installable" : "Data-only"} + </Badge> + </div> </div> </li> ); } +const GRID = + "grid list-none grid-cols-1 border-t border-l border-border sm:grid-cols-2 lg:grid-cols-3"; + export default function ArchiveShelf() { const { sites, removeSite } = useRegistry(); + const summary = useHubSummary(); + const official = sites.filter((s) => s.kind === "builtin"); + const added = sites.filter((s) => s.kind === "external"); + const hours = summary?.official?.hoursArchived; return ( - <section className="flex flex-col gap-4"> - <div className="flex flex-col gap-1"> - <span className="font-mono text-xs uppercase tracking-[0.16em] text-muted-foreground"> - The shelf - </span> - <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground"> - Your archives + <section className="flex flex-col gap-10 pb-4"> + <div className="flex flex-col gap-4"> + <h1 className="font-display text-[1.75rem] font-semibold leading-[1.12] tracking-[-0.01em] text-foreground sm:text-[2.25rem]"> + {hours ? ( + <> + <span className="font-medium tabular-nums"> + {hours.toLocaleString()} + </span>{" "} + hours of speech from every official archive, searchable at once. + </> + ) : ( + <>Every official archive, searchable at once.</> + )} </h1> - <p className="max-w-prose text-sm text-muted-foreground"> - Every archive you add is searchable together below. Paste a site&apos;s - URL to shelve it. + <p className="max-w-2xl text-[1.0625rem] leading-relaxed text-muted-foreground"> + One search runs across all of them, in your browser. Any Archilyzer + archive can join it: add its address below. </p> </div> - <AddArchive /> - - {sites.length === 0 ? ( - <div className="rounded-lg border border-dashed border-border/70 px-5 py-8 text-center"> - <p className="text-sm text-muted-foreground"> - The shelf is empty. Add an archive above to start searching across - it. + {official.length > 0 ? ( + <div> + <h2 className="font-display text-2xl font-semibold text-foreground"> + Official instances + </h2> + <p className="mt-2 mb-6 max-w-xl leading-relaxed text-muted-foreground"> + The archives I run. Anyone can run their own. </p> + <ul className={GRID}> + {official.map((site, i) => ( + <ArchiveCard + key={site.origin || site.siteId} + site={site} + figures={hubSummarySiteFor(summary, site)} + index={i} + /> + ))} + </ul> </div> ) : ( - <ul className="flex list-none flex-wrap gap-3 border-b-2 border-border/70 pb-4"> - {sites.map((site, i) => ( - <Spine - key={site.origin || site.siteId} - site={site} - index={i} - onRemove={removeSite} - /> - ))} - </ul> + <p className="max-w-xl leading-relaxed text-muted-foreground"> + This hub has no official instances. Add an archive to search it. + </p> )} + + <div> + {added.length > 0 && ( + <> + <h2 className="font-display text-2xl font-semibold text-foreground"> + Archives you added + </h2> + <ul className={`${GRID} mt-6 mb-6`}> + {added.map((site, i) => ( + <ArchiveCard + key={site.origin || site.siteId} + site={site} + index={i} + onRemove={removeSite} + /> + ))} + </ul> + </> + )} + <p className="mb-3 max-w-xl leading-relaxed text-muted-foreground"> + Add an archive by its address. It stays in this browser until you + remove it. + </p> + <AddArchive /> + </div> </section> ); } diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx @@ -1,7 +1,8 @@ "use client"; -// The hub home: the archive shelf (hero + registry) above one unified search -// across every shelved archive. The shelf/add-flow live in ArchiveShelf; this +// The hub home: the archives (official instances, the visitor's added ones, +// the add form) above one unified search across all of them. The cards and +// add-flow live in ArchiveShelf; this // component wires the registry into the multi-origin data source so the shared // TranscriptSearch renders one merged, origin-qualified view. diff --git a/export/app/components/hub/HubStats.tsx b/export/app/components/hub/HubStats.tsx @@ -1,15 +1,22 @@ "use client"; -// A demoted stats line, not a KPI wall: the shelf is the hero, so the totals sit -// quietly beneath it as a single mono strip. Numbers are computed from the LIVE -// merged data across the shelved archives (not a build-time summary), so they -// always reflect exactly what's federated right now. +// One line under the shelf: what this browser is searching right now. These +// numbers are the LIVE federation — computed from the merged data of every +// archive on the hub, the visitor's added ones included — so they are not the +// cards' numbers and are not meant to match them. The official cards show the +// build-time figures the homepage prints (hub-summary.json); this line shows +// what actually loaded. import { useSearchData } from "yt-dlp-transcript-common/components/SearchDataContext"; import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry"; -function fmt(n: number): string { - return n.toLocaleString(); +function part(n: number, one: string, many: string) { + return ( + <> + <span className="tabular-nums text-foreground">{n.toLocaleString()}</span>{" "} + {n === 1 ? one : many} + </> + ); } export default function HubStats() { @@ -19,20 +26,13 @@ export default function HubStats() { if (sites.length === 0) return null; - const parts: [number, string][] = [ - [sites.length, sites.length === 1 ? "archive" : "archives"], - [channels.length, channels.length === 1 ? "channel" : "channels"], - [transcripts, transcripts === 1 ? "transcript" : "transcripts"], - ]; - return ( - <p className="font-mono text-xs uppercase tracking-[0.12em] text-muted-foreground"> - {parts.map(([n, label], i) => ( - <span key={label}> - {i > 0 && <span className="mx-2 text-muted-foreground/40">·</span>} - <span className="text-foreground tabular-nums">{fmt(n)}</span> {label} - </span> - ))} + <p className="text-sm text-muted-foreground"> + Searching {part(sites.length, "archive", "archives")} + <span className="mx-2 text-muted-foreground/40">·</span> + {part(channels.length, "channel", "channels")} + <span className="mx-2 text-muted-foreground/40">·</span> + {part(transcripts, "transcript", "transcripts")} right now. </p> ); } diff --git a/export/app/components/hub/useHubSummary.ts b/export/app/components/hub/useHubSummary.ts @@ -0,0 +1,34 @@ +"use client"; + +// The official instances' numbers, composed at hub build time +// (common/bin/compose-hub.ts -> /hub-summary.json) from the same summary the +// homepage renders. Same-origin, fetched once. OPTIONAL: an older hub build or +// a hub composed with no index has no file, and every failure — 404, bad JSON, +// an unknown version — reads as "no numbers", never as an error on the page. + +import { useQuery } from "@tanstack/react-query"; +import { + HUB_SUMMARY_FILE, + parseHubSummary, + type HubSummary, +} from "yt-dlp-transcript-common/lib/hubSummary"; + +async function fetchHubSummary(): Promise<HubSummary | null> { + try { + const res = await fetch(`/${HUB_SUMMARY_FILE}`); + if (!res.ok) return null; + return parseHubSummary(await res.json()); + } catch { + return null; + } +} + +export function useHubSummary(): HubSummary | null { + const { data } = useQuery({ + queryKey: ["hub-summary"], + queryFn: fetchHubSummary, + staleTime: Infinity, + retry: false, + }); + return data ?? null; +} diff --git a/export/e2e-2origin/federation.spec.ts b/export/e2e-2origin/federation.spec.ts @@ -19,7 +19,7 @@ test.describe("real two-origin federation (CORS + service worker)", () => { }) => { await page.goto("/"); await expect( - page.getByRole("heading", { name: "Your archives" }), + page.getByRole("heading", { level: 1, name: /searchable at once\./ }), ).toBeVisible(); await addArchive(page, ORIGIN_B); // The spine's title came from Origin B's /site.json, fetched cross-origin — diff --git a/export/e2e-hub/ask-grounding.spec.ts b/export/e2e-hub/ask-grounding.spec.ts @@ -124,7 +124,7 @@ test("hub: a search run on / still grounds the chat after the per-card Ask", asy await stubBuiltins(page); await mockOriginB(page); await page.goto("/"); - await expect(page.getByRole("heading", { name: "Your archives" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: /searchable at once\./ })).toBeVisible(); await addArchive(page, ORIGIN_B); await expect( page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }), diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts @@ -10,7 +10,7 @@ import { expect, test, type Page } from "@playwright/test"; // This spec is the cheap guard on the rendered page; the build itself is the // other half, covered by the 2-origin suite which builds the hub for real. -// No built-in pool: this route has to stand up on a hub with an empty shelf, +// No built-in pool: this route has to stand up on a hub with no archives, // which is what a fresh hub is. async function stubBuiltins(page: Page) { await page.route("**/hub-sites.json", (r) => diff --git a/export/e2e-hub/federation.spec.ts b/export/e2e-hub/federation.spec.ts @@ -122,7 +122,7 @@ test.describe("hub federation — cross-origin browse + search", () => { await stubBuiltins(page); await page.goto("/"); await expect( - page.getByRole("heading", { name: "Your archives" }), + page.getByRole("heading", { level: 1, name: /searchable at once\./ }), ).toBeVisible(); }); @@ -139,13 +139,20 @@ test.describe("hub federation — cross-origin browse + search", () => { await expect(builtWith).toHaveAttribute("href", PROJECT_URL); }); - test("adds an archive by URL and shows it on the shelf", async ({ page }) => { + test("adds an archive by URL and shows it under Archives you added", async ({ page }) => { await mockOriginB(page); await addArchive(page, ORIGIN_B); // Spine carries the descriptor's siteTitle → the site was read cross-origin. await expect( page.getByTestId("shelf-spine").filter({ hasText: "Origin B" }), ).toBeVisible(); + // An added archive gets its own section, and its own Remove control. + await expect( + page.getByRole("heading", { level: 2, name: "Archives you added" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Remove Origin B" }), + ).toBeAttached(); }); test("searches across the added origin and returns origin-qualified hits", async ({ diff --git a/export/e2e-hub/official-instances.spec.ts b/export/e2e-hub/official-instances.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +// The hub's "Official instances": one card per built-in member +// (hub-sites.json), carrying the homepage's own figures for it when the hub was +// composed with them (hub-summary.json, written by compose-hub from the same +// buildHomepageSummary call the homepage makes). The summary is OPTIONAL — an +// older build or a hub composed with no index ships none — and then the card +// renders with no figures, never an error. + +// A member origin that is never really served: its data is 404 (with CORS so +// the browser reports a clean 404, not a blocked read). The cards come from +// hub-sites.json + hub-summary.json alone, so nothing here needs its data. +const MEMBER = "http://localhost:4598"; + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }); +} + +async function stubMember(page: Page) { + await page.route("**/hub-sites.json", (r) => + fulfillJson(r, [ + { + siteId: "member", + siteTitle: "Member Archive", + siteUrl: MEMBER, + pwa: false, + contract: 1, + }, + ]), + ); + await page.route(`${MEMBER}/**`, (r) => + r.fulfill({ status: 404, headers: { "access-control-allow-origin": "*" }, body: "" }), + ); +} + +const SUMMARY = { + version: 1, + generatedAt: "2026-09-25T00:00:00.000Z", + official: { + sites: 1, + channels: 12, + recordings: 3456, + transcripts: 2345, + hoursArchived: 7890, + gone: 3, + }, + sites: [ + { + siteId: "member", + siteTitle: "Member Archive", + siteDescription: "Every word the member said.", + siteUrl: MEMBER, + channels: 12, + recordings: 3456, + transcripts: 2345, + hoursArchived: 7890, + gone: 3, + }, + ], +}; + +test.describe("hub official instances", () => { + test("with hub-summary.json, an official card shows its four figures", async ({ + page, + }) => { + await stubMember(page); + await page.route("**/hub-summary.json", (r) => fulfillJson(r, SUMMARY)); + await page.goto("/"); + + await expect( + page.getByRole("heading", { + level: 1, + name: "7,890 hours of speech from every official archive, searchable at once.", + }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Official instances" }), + ).toBeVisible(); + + const card = page + .getByTestId("shelf-spine") + .filter({ hasText: "Member Archive" }); + await expect(card).toBeVisible(); + await expect( + card.getByRole("link", { name: /Member Archive/ }), + ).toHaveAttribute("target", "_blank"); + await expect(card).toContainText("Every word the member said."); + for (const [value, unit] of [ + ["12", "channels"], + ["3,456", "recordings"], + ["2,345", "transcripts"], + ["7,890", "hours"], + ]) { + await expect(card.locator("dd", { hasText: value })).toBeVisible(); + await expect(card.locator("dt", { hasText: unit })).toBeVisible(); + } + // A built-in member is not the visitor's to remove, and there is no + // "Archives you added" section until they add one. + await expect(card.getByRole("button", { name: /^Remove/ })).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "Archives you added" }), + ).toHaveCount(0); + }); + + test("without hub-summary.json, the card renders with no figures", async ({ + page, + }) => { + await stubMember(page); + await page.route("**/hub-summary.json", (r) => + r.fulfill({ status: 404, body: "" }), + ); + await page.goto("/"); + + await expect( + page.getByRole("heading", { + level: 1, + name: "Every official archive, searchable at once.", + }), + ).toBeVisible(); + const card = page + .getByTestId("shelf-spine") + .filter({ hasText: "Member Archive" }); + await expect(card).toBeVisible(); + await expect(card.getByText("Data-only")).toBeVisible(); + await expect(card.locator("dl")).toHaveCount(0); + }); +});