Finishes round 1. Part 1 took the runtime bugs; this takes the prose that had gone false and the three "the comment says so" gaps behind it. CLAUDE.md is auto-loaded into every agent session and ui/README.md is not, so an architecture paragraph that still described a flat components bag was the most expensive stale sentence in the repo: the next agent adding a popover reads it, sees no platform layer, and writes the generic component into components/ — the invisible-layer failure this epic exists to end. It now names components/ui/ and says the boundary is lint-enforced. The canonical-patterns table gained the three rows the epic established: ui/Button over a hand-rolled <button>, a keymap row over a private keydown listener, and disabled-when-busy vs inert-when-inapplicable. ui/README.md's inventory was written in this branch's first commit and never learned about CommandPalette, ContextMenu/useContextMenu, KeyboardHelp or gridColumns — in a section whose own closing rule is that a second component painting the same thing is how the last inconsistency happened. It also claimed "everything is rem-derived" while VirtualGrid's minCol/GRID_GAP are deliberately px; that is now stated as the exception it is, and a new section records which promises here are tests rather than sentences. Three contracts stopped being prose: - A text-less Button must carry a name. "Required for iconOnly" was a doc comment, which is why the migration shipped two nameless icon buttons through eight green lanes. It is a union now, covering both spellings (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or title. It caught one more on the way in: Banner's dismiss took an optional dismissTitle, so it could render unnamed — it falls back to common.close now. - gridColumns' gutter arithmetic. Every existing assertion survived replacing floor((w + GAP) / (minCol + GAP)) with the naive floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are cap-bound, and the loops compare columnsFor against itself. Pinned at the exact-fit boundaries where the two first disagree (536 and 828). - ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem button between two 1.75rem ones — and on a coarse pointer .touch-target grew only the migrated one to 2.75rem while the destructive Revoke stayed small. Also: the maxCols docstring said 8 columns of 384px where the test it shipped with asserts 369.
77 lines
2.4 KiB
TypeScript
77 lines
2.4 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { X } from "lucide-react";
|
|
import { Button } from "./Button";
|
|
|
|
type BannerTone = "accent" | "warning";
|
|
|
|
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
|
|
accent: {
|
|
bar: "bg-accent/15 border-accent/30",
|
|
icon: "text-accent",
|
|
action: "btn-primary",
|
|
},
|
|
warning: {
|
|
bar: "bg-warn/15 border-warn/30",
|
|
icon: "text-warn",
|
|
action: "btn-warn",
|
|
},
|
|
};
|
|
|
|
// Shared top-of-app notification bar. VersionBanner and DemoBanner are thin wrappers over this;
|
|
// a future news-ticker variant can build on the same shell (rotating content as children).
|
|
export default function Banner({
|
|
tone,
|
|
icon: Icon,
|
|
children,
|
|
action,
|
|
onDismiss,
|
|
dismissTitle,
|
|
role,
|
|
}: {
|
|
tone: BannerTone;
|
|
icon: LucideIcon;
|
|
children: ReactNode;
|
|
action?: { label: string; onClick: () => void };
|
|
onDismiss?: () => void;
|
|
/** Accessible name (and tooltip) for the dismiss button. Optional at the call site; the component
|
|
* falls back to the generic "Close" rather than shipping an unnamed icon button — a nameless
|
|
* control is not an acceptable default, and `Button`'s type now says so. */
|
|
dismissTitle?: string;
|
|
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
|
|
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
|
|
* page when it loads, and announcing them on every visit is noise, not information. */
|
|
role?: "status";
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const c = TONES[tone];
|
|
return (
|
|
<div
|
|
role={role}
|
|
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
|
|
>
|
|
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
|
<span className="min-w-0">{children}</span>
|
|
{action && (
|
|
<button
|
|
onClick={action.onClick}
|
|
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold transition ${c.action}`}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
)}
|
|
{onDismiss && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="ml-auto"
|
|
onClick={onDismiss}
|
|
title={dismissTitle ?? t("common.close")}
|
|
icon={<X className="w-4 h-4" />}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|