diff --git a/AGENTS.md b/AGENTS.md index df3da320c..ed03359f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,8 +251,9 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. - In a component's file, for sub-components: - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. - - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if a component has two or more props. - - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. + - The following **cannot** be expressed via `.withProps()` and so stay inline (like `Box` below), each with a one-line comment saying why: **`Accordion`** (a compound, `multiple`-discriminated generic — `.withProps({ multiple: true, … })` loses its JSX call signature and fails to type); **headless, non-`factory()` Mantine components** such as **`Transition`** (plain function components with no Styles API — they have no `.withProps` static at all, e.g. `Transition.withProps` is a TS2339); and **`data-*` attributes** (not part of a component's typed props object, so excess-property-checked out of a `withProps` literal — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (a `react-icons` glyph, another library's component) **and** a first-party component that isn't a Mantine factory (a dumb `export function` like `ContentViewer`, which has no `.withProps` static of its own). + - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. A `Box` that genuinely needs a non-flex primitive it can't provide — `component="iframe"`, or `display="grid"` (no Mantine flex primitive is a CSS grid) — stays a `Box` inline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. - CSS classes are ONLY acceptable on subcomponents for styles that cannot be expressed as flat CSS-in-JS properties in the theme — specifically: pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships (`.parent:hover .child`), nested child-element selectors (`.wrapper p`, `.wrapper code`), `@keyframes` definitions, and native HTML elements (`img`, `iframe`) that are not Mantine components. - When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components. diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 9b2784312..fb5d1aab0 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -266,15 +266,6 @@ body { } } -@keyframes inspector-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - /* Both motions travel downward: enter descends from above into place, exit descends from place to below — used for the header crossfade (#1450). */ @keyframes inspector-fade-slide-in { @@ -318,10 +309,6 @@ body { animation: inspector-pulse 1.5s ease-in-out infinite; } -.inspector-spin { - animation: inspector-spin 1s linear infinite; -} - /* Applied (via the `tabGlow` Text variant) to a tab label; the glow starts full and fades, running only while `data-glow="on"` on a freshly-appeared tab. */ .tab-glow[data-glow="on"] { @@ -343,13 +330,6 @@ body { animation: inspector-fade-slide-out 300ms ease both; } -/* The title and tab bar additionally share one grid cell so one replaces the - other in place during the center crossfade. */ -.header-stack-cell { - grid-area: 1 / 1; - place-self: center; -} - /* Honor reduced-motion for the header animations this PR adds (#1450): skip the fade/slide and the new-tab glow, leaving the elements at their resting state. Selectors match the originals' specificity so they win inside the query. */ @@ -528,12 +508,6 @@ body { background-color: var(--mantine-primary-color-light-hover); } -/* ── Grid alignment ─────────────────────────────────────── */ - -.grid-align-start { - align-items: start; -} - /* ── Markdown content (third-party HTML from react-markdown) ───── */ .markdown-content { @@ -583,16 +557,6 @@ body { cursor: grabbing; } -/* - * Sticky re-auth banner bar. Positioning (pos/top) and background are set via - * Mantine props on the Box; z-index and box-shadow have no Mantine style-prop - * shorthand, so they live here. - */ -.reauth-banner-bar { - z-index: 200; - box-shadow: var(--mantine-shadow-sm); -} - /* * Draggable vertical divider between the primary screen area and the pinned * monitoring column (#1616). The col-resize cursor, the hover/active accent, diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 8faaebfdd..f9ff42ef0 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3,6 +3,7 @@ import { Anchor, Box, List, + Paper, Stack, Text, useComputedColorScheme, @@ -270,10 +271,12 @@ function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] { for (const m of messages) { if (m.direction !== "notification") continue; // MessageEntry.message is a JSONRPC union; notifications have `method` - // but not `id`. Narrow with an `in` check before the cast. + // but not `id`. Narrow with an `in` check, then confirm the method. if (!("method" in m.message)) continue; if (m.message.method !== "notifications/message") continue; - const params = (m.message as unknown as LoggingMessageNotification).params; + // The method check pins this to a logging notification; its `params` are + // only generically typed on the JSONRPC union, so cast just that value. + const params = m.message.params as LoggingMessageNotification["params"]; out.push({ receivedAt: m.timestamp, params, @@ -381,6 +384,34 @@ function bodyDroppedToastId(serverId: string): string { const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error"; +// Shared "list of likely causes" styling for the warning-toast bodies below. +const ToastCauseList = List.withProps({ size: "sm", spacing: 2 }); + +// The "open the relevant settings/details" link rendered at the bottom of each +// warning-toast body. Same static shape across all three toasts; each passes +// its own `onClick`. +const ToastLinkButton = Anchor.withProps({ + component: "button", + type: "button", + size: "sm", +}); + +// Sticky re-auth banner bar. A `Paper` so every static style is a prop: `shadow` +// emits `var(--mantine-shadow-sm)` (identical to the old CSS), and the stacking +// order goes through `styles.root` since Mantine has no `z` prop. +const ReAuthBannerBar = Paper.withProps({ + px: "md", + pt: "xs", + pos: "sticky", + top: 60, + bg: "var(--mantine-color-body)", + shadow: "sm", + styles: { root: { zIndex: 200 } }, + // Paper's default `radius: "md"` would round this full-bleed sticky bar's + // corners; the bar it replaced (a Box) had none. + radius: 0, +}); + // Body of the "response body dropped" warning toast: a one-line summary of what // happened, the likely causes, and a link that opens this server's settings // (on the Options section) so the user can raise the Network Log Size if it's @@ -399,7 +430,7 @@ const FetchBodyDroppedToastMessage = ({ out (the log hit its {maxFetchRequests}-request limit), so the body couldn't be shown. This usually indicates: - + a chatty or misbehaving server (notification storms, rapid polling) @@ -410,10 +441,10 @@ const FetchBodyDroppedToastMessage = ({ the Network Log Size set too low for this server's traffic - - + + Adjust Network Log Size for this server - + ); @@ -431,9 +462,9 @@ const OutputValidationToastMessage = ({ tool's outputSchema. The inspector renders it anyway, but strict MCP clients may not. - + View validation details - + ); @@ -450,9 +481,9 @@ const UrlElicitationErrorToastMessage = ({ The server reported a URLElicitationRequired error but listed no required elicitations, so there's nothing to open. - + View error details - + ); @@ -4183,20 +4214,13 @@ function App() { <> {reAuthBanner ? ( - + setReAuthBanner(null)} /> - + ) : null} = { 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 ( - - {formatLabel(facet, value)} - - ); + return {formatLabel(facet, value)}; } diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index bddcade2b..19eb85838 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -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. - + {`[Binary content (${mimeType}) — preview not supported]`} - + ); diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx index b17ab0a18..fac6000f1 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx @@ -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 ( - {displayText} - + ); } @@ -351,11 +354,11 @@ function BlockContent({ return ( - + {"text" in block.resource ? block.resource.text : `[blob: ${block.resource.uri}]`} - + ); diff --git a/clients/web/src/components/elements/ContentViewer/CsvTable.tsx b/clients/web/src/components/elements/ContentViewer/CsvTable.tsx index f9e090077..81bf4fb68 100644 --- a/clients/web/src/components/elements/ContentViewer/CsvTable.tsx +++ b/clients/web/src/components/elements/ContentViewer/CsvTable.tsx @@ -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 ( - + {truncated && ( {`Showing first ${MAX_ROWS} of ${parsed.total} rows`} @@ -71,6 +78,6 @@ export function CsvTable({ text }: CsvTableProps) { ))} -
+ ); } diff --git a/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx b/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx index 601268cb4..228b9a562 100644 --- a/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx +++ b/clients/web/src/components/elements/ContentViewer/HtmlFrame.tsx @@ -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. ; } return ( + // Box+iframe is a native element (not a Mantine primitive), so the + // `.withProps()` extraction rule doesn't apply. {({ copied, copy }) => ( - {copied ? "\u2713" : "\u2398"} - + )} diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx index 0ad636754..ad9e87f9f 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -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 ( - - + + {children} - - + + ); } return ( - {children} - + ); } diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx index 0d578281d..6ff611541 100644 --- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.tsx @@ -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 ( - - + ); } diff --git a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx index 1f7af2e05..92e217c6d 100644 --- a/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx +++ b/clients/web/src/components/elements/FilterToggleButton/FilterToggleButton.tsx @@ -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 ( - onToggle(!active)} - > + onToggle(!active)}> {label} - + ); } diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx index b1cf69cd5..5a1efcc62 100644 --- a/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx @@ -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 ( - + - + ); } - // `size={36}` matches the header's theme / client-settings ActionIcons so the - // toolbar's toggle reads as the same size icon button. return ( - + - + ); } diff --git a/clients/web/src/components/elements/LogEntry/LogEntry.tsx b/clients/web/src/components/elements/LogEntry/LogEntry.tsx index 5f431e480..3fb53a649 100644 --- a/clients/web/src/components/elements/LogEntry/LogEntry.tsx +++ b/clients/web/src/components/elements/LogEntry/LogEntry.tsx @@ -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 ( - + {formatTimestamp(receivedAt)} {logger} {message} - + ); } diff --git a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx index 8e3fa30ce..2cbee7f51 100644 --- a/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx +++ b/clients/web/src/components/elements/LogLevelBadge/LogLevelBadge.tsx @@ -19,19 +19,19 @@ const levelColor: Record = { const boldLevels: Set = 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 ( - + {level} - + ); } diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx index e7913b224..5e5cd624f 100644 --- a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx @@ -20,6 +20,21 @@ const COLOR_BY_CODE: Record = { [-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 = { */ export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) { const badge = ( - + {code} {name} - + ); if (!description) return badge; - return ( - - {badge} - - ); + return {badge}; } diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx index 130601d84..b1ea6a496 100644 --- a/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.tsx @@ -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 ( - - {method} - - ); + return {method}; } diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx index a7143a59f..c7079d91f 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.tsx @@ -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 ( - + - + ); } diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx index c4aa7c58d..432d01bf8 100644 --- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx +++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx @@ -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 ( - - input_required - + input_required {note} ); diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.tsx index 0886f47a6..3336d9f19 100644 --- a/clients/web/src/components/elements/PinToggle/PinToggle.tsx +++ b/clients/web/src/components/elements/PinToggle/PinToggle.tsx @@ -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 ( - + - + ); } diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx index 7d6f48a55..9d2fdc664 100644 --- a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx @@ -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 ( - + - + ); } diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx index 77545a5e6..5a6100311 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -4,7 +4,17 @@ import { type KeyboardEvent, type PointerEvent, } from "react"; -import { Box } from "@mantine/core"; +import { Flex } from "@mantine/core"; + +// Childless interactive separator: a single positioned layer, so a Flex +// primitive (Box can't use `.withProps()`). The dynamic aria-value*/aria-label +// and pointer/key handlers are passed at the call site. +const ResizeSeparator = Flex.withProps({ + role: "separator", + "aria-orientation": "vertical", + tabIndex: 0, + className: "resize-handle", +}); export interface ResizeHandleProps { /** Current width (px) of the panel this handle resizes. */ @@ -100,15 +110,11 @@ export function ResizeHandle({ } return ( - = { + title: "Elements/ScreenStage", + component: ScreenStage, +}; + +export default meta; +type Story = StoryObj; + +// ScreenStage positions its screen absolutely, so it must be rendered inside a +// `position: relative` host. A fixed width lets the play function assert layout. +const HOST_WIDTH = 600; + +// A stage child with negligible intrinsic width (a single "x") — like +// ServerListScreen's `container`-typed grid, whose inline size is computed +// without regard to its contents. In a *row* flex such a child collapses to its +// content width; only a column flex (align-items: stretch) makes it fill. So its +// rendered width is the direct tell for the #1762 `Box → Flex` regression. +const narrowChild = ( + + x + +); + +/** + * Regression guard for the ScreenStage `Box → Flex` conversion (#1762): the + * child screen must fill the stage's width the way it did under the old + * block-level `Box`. A row flex would content-size it (a few px wide here); the + * `direction: "column"` on `StageLayer` puts the width on the stretch axis so it + * fills. Play functions run in real Chromium, so this measures actual layout — + * unlike happy-dom, which does none. + */ +export const FillsHostWidth: Story = { + render: () => ( + + {narrowChild} + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const child = await canvas.findByTestId("stage-child"); + // The child fills its stage layer — asserted relative to the layer's actual + // width, not a literal, so it can't break on host-sizing quirks (and is a + // sharper discriminator: a row flex leaves the layer full-width while + // collapsing the child to the single "x"). + const layer = child.parentElement as HTMLElement; + expect(child.getBoundingClientRect().width).toBeCloseTo( + layer.getBoundingClientRect().width, + 0, + ); + }, +}; + +/** + * The `fill` variant additionally anchors `bottom: 0`, stretching the *stage + * layer* to the host's full height (so a screen that relies on the parent for + * height — e.g. an inner ScrollArea with `flex: 1` — has a definite-height box to + * fill). Note this stretches the layer, not the plain child: `align-items: + * stretch` fills the cross axis (width), while the main axis (height) stays + * content-sized unless the child itself grows. + */ +export const FillVariant: Story = { + render: () => ( + + + {narrowChild} + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const child = await canvas.findByTestId("stage-child"); + // Width still stretches onto the child (relative to the layer, as above); + // the `fill` height comes from `bottom: 0` making the layer span the host. + const layer = child.parentElement as HTMLElement; + expect(child.getBoundingClientRect().width).toBeCloseTo( + layer.getBoundingClientRect().width, + 0, + ); + expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4); + }, +}; + +/** + * Inactive stages are unmounted (Transition's default `keepMounted={false}`), so + * the screen — and its local state — is gone until it becomes active again. + */ +export const Inactive: Story = { + render: () => ( + + {narrowChild} + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx index 3f54622c9..c3193ff07 100644 --- a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx @@ -1,10 +1,26 @@ import type { ReactNode } from "react"; -import { Box, Transition } from "@mantine/core"; +import { Flex, Transition } from "@mantine/core"; /** Screen enter / exit durations for the shared `fade-up` stage transition. */ export const SCREEN_ENTER_MS = 350; export const SCREEN_EXIT_MS = 250; +// A single absolutely-positioned layer, so a Flex primitive (Box can't use +// `.withProps()`). `direction: "column"` is load-bearing: the child screen must +// fill the layer's width the way it did under the old block-level `Box`. A +// column flex puts the width on the cross axis, where Mantine's default +// `align-items: stretch` makes the single child fill it (a row flex would size +// the child to its content, collapsing screens whose width is content-agnostic — +// e.g. ServerListScreen's `container`-typed grid). The dynamic transition +// `style` and the `bottom` anchor are passed at the call site. +const StageLayer = Flex.withProps({ + direction: "column", + pos: "absolute", + top: 0, + left: 0, + right: 0, +}); + export interface ScreenStageProps { /** True when this stage's screen is the active one. */ active: boolean; @@ -35,6 +51,8 @@ export function ScreenStage({ fill = false, }: ScreenStageProps) { return ( + // Stays inline: Transition is a headless, non-`factory()` Mantine component, + // so it has no `.withProps` static at all (same tooling limit as Box). ( // `style={styles}` is the runtime transition state from Mantine's // Transition API — interpolated values, not static styling. - + {children} - + )} ); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx index d48272088..de77d345f 100644 --- a/clients/web/src/components/elements/SortToggle/SortToggle.tsx +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -18,6 +18,13 @@ function isSortDirection(value: string | null): value is SortDirection { return value === "oldest-first" || value === "newest-first"; } +const SortSelect = Select.withProps({ + size: "sm", + w: 150, + allowDeselect: false, + withCheckIcon: false, +}); + export function SortToggle({ value, onChange, @@ -25,9 +32,7 @@ export function SortToggle({ }: SortToggleProps) { const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; return ( -