refactor(web): sweep AGENTS.md TypeScript + Mantine rule violations (#1771)

Codebase-wide sweep bringing the web client into strict compliance with the AGENTS.md TypeScript and Mantine/React conventions: ~170 inline 2+-static-prop elements extracted to named .withProps() constants across ~77 files, the AGENTS.md rule wording tightened to match (static-prop boundary + Accordion/Transition/data-*/Box exemptions), a real ScreenStage Box->Flex layout regression fixed (direction: column) with a Storybook regression guard, and an App.tsx double-cast tightened. Closes #1762.
This commit is contained in:
Cliff Hall 2026-07-25 11:38:49 -04:00 • committed by GitHub
commit da37692485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1567 additions and 991 deletions

View file

@ -26,6 +26,12 @@ const colorMap: Record<AnnotationFacet, string> = {
longRunHint: "yellow",
};
const FilledBadge = Badge.withProps({
variant: "filled",
fw: 500,
autoContrast: true,
});
function formatLabel(
facet: AnnotationFacet,
value: Role[] | number | boolean,
@ -59,9 +65,5 @@ export function AnnotationBadge({ facet, value }: AnnotationBadgeProps) {
// fills and the darker dark-mode `-filled` shades — unlike a fixed
// scheme→black/white mapping, which inverted the contrast in dark mode.
// Amber fills are pinned to shade 5 first (see `filledBadgeColor`).
return (
<Badge color={color} variant="filled" fw={500} autoContrast>
{formatLabel(facet, value)}
</Badge>
);
return <FilledBadge color={color}>{formatLabel(facet, value)}</FilledBadge>;
}

View file

@ -522,6 +522,8 @@ export function AppRenderer({
// iframe. Sandboxing this outer frame would block the postMessage bridge
// that `AppBridge` relies on.
return (
// Box+iframe is a native element (not a Mantine primitive), so the
// `.withProps()` extraction rule doesn't apply.
<Box
component="iframe"
ref={iframeRef}

View file

@ -5,6 +5,8 @@ const ContentWrapper = Flex.withProps({
direction: "column",
});
const NoticeCode = Code.withProps({ block: true, p: 36 });
/**
* Fallback shown when content can't be previewed — an unsupported binary MIME
* type, or a blob whose base64 fails to decode. Kept in its own module so blob
@ -15,9 +17,9 @@ export function BinaryNotice({ mimeType }: { mimeType: string }) {
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
<NoticeCode>
{`[Binary content (${mimeType}) — preview not supported]`}
</Code>
</NoticeCode>
</ContentWrapper>
</Stack>
);

View file

@ -86,6 +86,11 @@ const PreviewImage = Image.withProps({
radius: "md",
});
const CodeBlock = Code.withProps({
block: true,
p: 36,
});
// Markdown anchors are constrained to a safe-scheme allowlist: a non-matching
// href (e.g. `javascript:`, protocol-relative `//evil.com`) renders as inert
// text so user-supplied markdown can't smuggle a script-bearing link.
@ -168,9 +173,7 @@ function PlainTextContent({
const displayText = looksLikeJson(text) ? formatJson(text) : text;
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
<Code
block
p={36}
<CodeBlock
// A `block` Code has `overflow: auto`, so make it keyboard-scrollable
// (WCAG SC 2.1.1) — it holds only text, with no focusable child.
tabIndex={0}
@ -180,7 +183,7 @@ function PlainTextContent({
title={wrap ? undefined : displayText}
>
{displayText}
</Code>
</CodeBlock>
</CopyableWrapper>
);
}
@ -351,11 +354,11 @@ function BlockContent({
return (
<Stack gap="xs">
<ContentWrapper>
<Code block p={36}>
<CodeBlock>
{"text" in block.resource
? block.resource.text
: `[blob: ${block.resource.uri}]`}
</Code>
</CodeBlock>
</ContentWrapper>
</Stack>
);

View file

@ -18,6 +18,13 @@ export const MAX_ROWS = 100;
const PlainCode = Code.withProps({ block: true, variant: "wrapping" });
const CsvGrid = Table.withProps({
striped: true,
highlightOnHover: true,
withTableBorder: true,
withColumnBorders: true,
});
interface ParsedCsv {
fields: string[];
rows: string[][];
@ -49,7 +56,7 @@ export function CsvTable({ text }: CsvTableProps) {
const truncated = parsed.total > MAX_ROWS;
return (
<Table striped highlightOnHover withTableBorder withColumnBorders>
<CsvGrid>
{truncated && (
<Table.Caption>
{`Showing first ${MAX_ROWS} of ${parsed.total} rows`}
@ -71,6 +78,6 @@ export function CsvTable({ text }: CsvTableProps) {
</Table.Tr>
))}
</Table.Tbody>
</Table>
</CsvGrid>
);
}

View file

@ -25,6 +25,8 @@ export function HtmlFrame({ html }: HtmlFrameProps) {
);
const url = useObjectUrl(blob);
return (
// Box+iframe is a native element (not a Mantine primitive), so the
// `.withProps()` extraction rule doesn't apply.
<Box
component="iframe"
title="HTML preview"

View file

@ -30,6 +30,8 @@ export function PdfFrame({ data }: PdfFrameProps) {
return <BinaryNotice mimeType="application/pdf" />;
}
return (
// Box+iframe is a native element (not a Mantine primitive), so the
// `.withProps()` extraction rule doesn't apply.
<Box
component="iframe"
title="PDF preview"

View file

@ -13,21 +13,24 @@ export interface CopyButtonProps {
flush?: boolean;
}
const CopyActionIcon = ActionIcon.withProps({
variant: "subtle",
fz: 24,
});
export function CopyButton({ value, flush = false }: CopyButtonProps) {
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copied" : "Copy"}>
<ActionIcon
variant="subtle"
<CopyActionIcon
color={copied ? "green" : "var(--inspector-text-primary)"}
onClick={copy}
fz={24}
aria-label={copied ? "Copied" : "Copy"}
{...(flush && { p: 0, h: "auto", w: "auto" })}
>
{copied ? "\u2713" : "\u2398"}
</ActionIcon>
</CopyActionIcon>
</Tooltip>
)}
</MantineCopyButton>

View file

@ -34,6 +34,18 @@ export interface EmbeddableScrollAreaProps {
// grow wider than its viewport; see `constrainContentWidth`.
const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const;
// Shared scroll region for both hosts; the differing props (viewportRef, mah,
// styles) are passed at each call site.
const StreamScrollArea = ScrollArea.Autosize.withProps({
type: "scroll",
offsetScrollbars: true,
viewportProps: { tabIndex: 0 },
});
// Fill-height wrapper for the embedded host, so the inner scroll region can claim
// the remaining space and scroll instead of overflowing.
const EmbeddedColumn = Stack.withProps({ flex: 1, mih: 0, gap: 0 });
/**
* The scroll region shared by the Logs / Protocol / Network stream panels, which
* render both full-size (their own tab) and embedded (the monitoring sidebar).
@ -48,30 +60,20 @@ export function EmbeddableScrollArea({
const styles = constrainContentWidth ? CONSTRAIN_CONTENT_STYLES : undefined;
if (embedded) {
return (
<Stack flex={1} mih={0} gap={0}>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah="100%"
type="scroll"
offsetScrollbars
styles={styles}
viewportProps={{ tabIndex: 0 }}
>
<EmbeddedColumn>
<StreamScrollArea viewportRef={viewportRef} mah="100%" styles={styles}>
{children}
</ScrollArea.Autosize>
</Stack>
</StreamScrollArea>
</EmbeddedColumn>
);
}
return (
<ScrollArea.Autosize
<StreamScrollArea
viewportRef={viewportRef}
mah={FULLSIZE_MAH}
type="scroll"
offsetScrollbars
styles={styles}
viewportProps={{ tabIndex: 0 }}
>
{children}
</ScrollArea.Autosize>
</StreamScrollArea>
);
}

View file

@ -14,6 +14,12 @@ export interface ExpandToggleProps {
ariaLabel?: string;
}
const ExpandActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
});
/**
* Icon toggle for a per-entry expand/collapse control (Protocol, Network, and
* Task cards). Uses the same expand/collapse-vertical icons as the list-level
@ -31,16 +37,13 @@ export function ExpandToggle({
const label = expanded ? "Collapse" : "Expand";
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
<ExpandActionIcon
aria-label={ariaLabel ?? label}
aria-expanded={expanded}
onClick={onToggle}
>
<Icon size={16} />
</ActionIcon>
</ExpandActionIcon>
</Tooltip>
);
}

View file

@ -17,6 +17,12 @@ const ToggleLabel = Text.withProps({
fw: 500,
});
const ToggleButton = UnstyledButton.withProps({
w: "100%",
p: "sm",
variant: "filterToggle",
});
/**
* A single full-width filter toggle used by the Logging, Protocol, and Network
* controls. The `filterToggle` theme variant + `.filter-toggle` rules own the
@ -32,14 +38,8 @@ export function FilterToggleButton({
onToggle,
}: FilterToggleButtonProps) {
return (
<UnstyledButton
w="100%"
p="sm"
variant="filterToggle"
aria-pressed={active}
onClick={() => onToggle(!active)}
>
<ToggleButton aria-pressed={active} onClick={() => onToggle(!active)}>
<ToggleLabel c={accessibleTextColor(color)}>{label}</ToggleLabel>
</UnstyledButton>
</ToggleButton>
);
}

View file

@ -7,6 +7,19 @@ export interface ListToggleProps {
variant?: "default" | "subtle";
}
const SubtleActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
});
// `size={36}` matches the header's theme / client-settings ActionIcons so the
// toolbar's toggle reads as the same size icon button.
const ToolbarActionIcon = ActionIcon.withProps({
variant: "subtle",
size: 36,
});
export function ListToggle({
compact,
onToggle,
@ -18,31 +31,18 @@ export function ListToggle({
if (variant === "subtle") {
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={label}
onClick={onToggle}
>
<SubtleActionIcon aria-label={label} onClick={onToggle}>
<Icon size={16} />
</ActionIcon>
</SubtleActionIcon>
</Tooltip>
);
}
// `size={36}` matches the header's theme / client-settings ActionIcons so the
// toolbar's toggle reads as the same size icon button.
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
size={36}
aria-label={label}
onClick={onToggle}
>
<ToolbarActionIcon aria-label={label} onClick={onToggle}>
<Icon size={20} />
</ActionIcon>
</ToolbarActionIcon>
</Tooltip>
);
}

View file

@ -81,6 +81,12 @@ const MetaRow = Group.withProps({
align: "center",
});
// The single-line row (full Logs screen): meta + message on one row.
const LogRow = Group.withProps({
gap: "sm",
wrap: "nowrap",
});
export function LogEntry({ entry, compact = false }: LogEntryProps) {
const { receivedAt, params } = entry;
const message = formatData(params.data);
@ -106,13 +112,13 @@ export function LogEntry({ entry, compact = false }: LogEntryProps) {
}
return (
<Group gap="sm" wrap="nowrap">
<LogRow>
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
<LogLevelBadge level={params.level} />
{logger}
<MessageText c={accessibleTextColor(levelMessageColor[params.level])}>
{message}
</MessageText>
</Group>
</LogRow>
);
}

View file

@ -19,19 +19,19 @@ const levelColor: Record<LoggingLevel, string> = {
const boldLevels: Set<LoggingLevel> = new Set(["alert", "emergency"]);
const FilledBadge = Badge.withProps({
variant: "filled",
autoContrast: true,
});
export function LogLevelBadge({ level }: LogLevelBadgeProps) {
const fw = boldLevels.has(level) ? 500 : undefined;
// `autoContrast` keeps the label legible (WCAG AA) on both the light-mode
// fills and the darker dark-mode `-filled` shades — see AnnotationBadge.
return (
<Badge
color={filledBadgeColor(levelColor[level])}
variant="filled"
fw={fw}
autoContrast
>
<FilledBadge color={filledBadgeColor(levelColor[level])} fw={fw}>
{level}
</Badge>
</FilledBadge>
);
}

View file

@ -20,6 +20,21 @@ const COLOR_BY_CODE: Record<number, string> = {
[-32601]: "yellow", // MethodNotFound (modern 404)
};
const SpecErrorBadge = Badge.withProps({
variant: "filled",
autoContrast: true,
// Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion")
// rather than Mantine's default uppercase, which runs these long
// PascalCase names together and hurts readability.
tt: "none",
});
const DescriptionTooltip = Tooltip.withProps({
multiline: true,
w: 280,
withArrow: true,
});
/**
* Distinct badge for one of the modern Streamable HTTP spec error codes shown in
* the Protocol tab. Labels the code and spec name (e.g. "-32020 HeaderMismatch")
@ -31,22 +46,10 @@ const COLOR_BY_CODE: Record<number, string> = {
*/
export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) {
const badge = (
<Badge
color={filledBadgeColor(COLOR_BY_CODE[code] ?? "red")}
variant="filled"
autoContrast
// Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion")
// rather than Mantine's default uppercase, which runs these long
// PascalCase names together and hurts readability.
tt="none"
>
<SpecErrorBadge color={filledBadgeColor(COLOR_BY_CODE[code] ?? "red")}>
{code} {name}
</Badge>
</SpecErrorBadge>
);
if (!description) return badge;
return (
<Tooltip label={description} multiline w={280} withArrow>
{badge}
</Tooltip>
);
return <DescriptionTooltip label={description}>{badge}</DescriptionTooltip>;
}

View file

@ -5,19 +5,17 @@ export interface MethodBadgeProps {
method: string;
}
const MethodChip = Badge.withProps({
autoContrast: false,
bg: "var(--inspector-badge-method-bg)",
c: "var(--inspector-badge-method-fg)",
});
/**
* Badge labelling a Protocol/Network entry's method. A neutral charcoal chip with
* light text, driven by `--inspector-badge-method-*` so it stays legible (not a
* washed-out pale fill) in dark mode. Shared by `ProtocolEntry` and `NetworkEntry`.
*/
export function MethodBadge({ method }: MethodBadgeProps) {
return (
<Badge
autoContrast={false}
bg="var(--inspector-badge-method-bg)"
c="var(--inspector-badge-method-fg)"
>
{method}
</Badge>
);
return <MethodChip>{method}</MethodChip>;
}

View file

@ -11,6 +11,12 @@ export interface MonitoringToggleProps {
onToggle: () => void;
}
// `size={36}` matches the header's theme / client-settings ActionIcons.
const MonitoringActionIcon = ActionIcon.withProps({
variant: "subtle",
size: 36,
});
/**
* The single header affordance for the monitoring sidebar (#1661). It replaces
* the per-screen pin buttons, the server-list open-sidebar button, and the
@ -19,7 +25,7 @@ export interface MonitoringToggleProps {
* label reflect the current state (expand when closed, collapse when open). The
* caller only mounts it when the sidebar is available (connected, or a failed
* connect attempt, on a wide viewport), so it never appears with nothing to
* toggle. `size={36}` matches the header's theme / client-settings ActionIcons.
* toggle.
*/
export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) {
const Icon = open ? TbLayoutSidebarRightCollapse : TbLayoutSidebarRightExpand;
@ -27,14 +33,9 @@ export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) {
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
size={36}
aria-label={label}
onClick={onToggle}
>
<MonitoringActionIcon aria-label={label} onClick={onToggle}>
<Icon size={20} />
</ActionIcon>
</MonitoringActionIcon>
</Tooltip>
);
}

View file

@ -23,6 +23,11 @@ const NoteText = Text.withProps({
c: "dimmed",
});
const OriginBadge = Badge.withProps({
variant: "outline",
color: "blue",
});
// The two modern origins differ in HOW the answer is delivered: an MRTR round
// retries the original call with the answer (SEP-2322); a task round submits it
// via a separate `tasks/update` request (SEP-2663). The note is accurate to each
@ -50,9 +55,7 @@ export function MrtrOriginNote({
if (!note) return null;
return (
<NoteRow>
<Badge variant="outline" color="blue">
input_required
</Badge>
<OriginBadge>input_required</OriginBadge>
<NoteText>{note}</NoteText>
</NoteRow>
);

View file

@ -7,6 +7,12 @@ export interface PinToggleProps {
onToggle: () => void;
}
const PinActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
});
/**
* Icon toggle for pinning an entry. Unpinned shows an outline pin; pinned shows
* a filled pin. The aria-label stays "Pin"/"Unpin" so it reads the same as the
@ -17,15 +23,9 @@ export function PinToggle({ pinned, onToggle }: PinToggleProps) {
const label = pinned ? "Unpin" : "Pin";
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label={label}
onClick={onToggle}
>
<PinActionIcon aria-label={label} onClick={onToggle}>
<Icon size={18} />
</ActionIcon>
</PinActionIcon>
</Tooltip>
);
}

View file

@ -6,6 +6,13 @@ export interface ReplayButtonProps {
onReplay: () => void;
}
const ReplayActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
"aria-label": "Replay",
});
/**
* Icon form of the "Replay" action, used in the compact (column) ProtocolEntry
* layout where the text button is replaced by a replay icon sitting next to the
@ -14,15 +21,9 @@ export interface ReplayButtonProps {
export function ReplayButton({ onReplay }: ReplayButtonProps) {
return (
<Tooltip label="Replay">
<ActionIcon
variant="subtle"
color="gray"
size="md"
aria-label="Replay"
onClick={onReplay}
>
<ReplayActionIcon onClick={onReplay}>
<MdReplay size={18} />
</ActionIcon>
</ReplayActionIcon>
</Tooltip>
);
}

Some files were not shown because too many files have changed in this diff Show more