* fix(web): keep server cards a fixed height (#1528) Two causes made server cards ragged-height: 1. The command/URL box wrapped long content onto multiple lines, growing the card. Add a `nowrap` Code variant (single line, ellipsis on overflow) and a `wrap` prop on ContentViewer (default true); ServerCard passes `wrap={false}`. The full value stays available via the copy button. 2. At ~1000-1500px the grid packed cards narrow enough that a connected card's action row (Clone/Edit/Remove + Connection Info/Settings) wrapped and stacked. Switch the SimpleGrid to container queries with thresholds that keep every card >= ~500px wide at each column count, so it drops to fewer/wider columns instead of wrapping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): address review — correct threshold comment, single-line note, title tooltip - Correct the column-threshold comment in ServerListScreen: the values (1040/1560) use N gaps, not N−1; restate the formula accurately so the numbers aren't "fixed" to match a wrong formula later. - Note in the ContentViewer `wrap` JSDoc that `false` is for single-line values only (nowrap collapses embedded newlines). - Add a native `title` tooltip to the non-wrapping Code box so a clipped command/URL is readable on hover, not only via copy. Covered by tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6d07f6ec75
commit
d04a7fb9c8
5 changed files with 70 additions and 2 deletions
|
|
@ -120,6 +120,34 @@ describe("ContentViewer", () => {
|
||||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders plain text in a wrapping code block by default", () => {
|
||||||
|
const block: ContentBlock = { type: "text", text: "some long command" };
|
||||||
|
const { container } = renderWithMantine(<ContentViewer block={block} />);
|
||||||
|
expect(container.querySelector(".mantine-Code-root")).toHaveAttribute(
|
||||||
|
"data-variant",
|
||||||
|
"wrapping",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders plain text in a non-wrapping code block when wrap is false", () => {
|
||||||
|
const block: ContentBlock = { type: "text", text: "some long command" };
|
||||||
|
const { container } = renderWithMantine(
|
||||||
|
<ContentViewer block={block} wrap={false} />,
|
||||||
|
);
|
||||||
|
const code = container.querySelector(".mantine-Code-root");
|
||||||
|
expect(code).toHaveAttribute("data-variant", "nowrap");
|
||||||
|
// Full value exposed on hover since it may be clipped with an ellipsis.
|
||||||
|
expect(code).toHaveAttribute("title", "some long command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set a title tooltip when wrapping (default)", () => {
|
||||||
|
const block: ContentBlock = { type: "text", text: "some long command" };
|
||||||
|
const { container } = renderWithMantine(<ContentViewer block={block} />);
|
||||||
|
expect(container.querySelector(".mantine-Code-root")).not.toHaveAttribute(
|
||||||
|
"title",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders text as markdown when mimeType is text/markdown", () => {
|
it("renders text as markdown when mimeType is text/markdown", () => {
|
||||||
const block: ContentBlock = {
|
const block: ContentBlock = {
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ export interface ContentViewerProps {
|
||||||
* instead of as preformatted code.
|
* instead of as preformatted code.
|
||||||
*/
|
*/
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
/**
|
||||||
|
* Whether long plain-text content wraps onto multiple lines. When `false`,
|
||||||
|
* text is kept to a single line (overflow clipped with an ellipsis) so the
|
||||||
|
* viewer keeps a fixed height — used by hosts like the server card where the
|
||||||
|
* box height must stay constant regardless of command/URL length. The full
|
||||||
|
* value remains available via the copy button (and a native `title`
|
||||||
|
* tooltip). Defaults to `true`.
|
||||||
|
*
|
||||||
|
* Intended for single-line values only: `false` applies `white-space:
|
||||||
|
* nowrap`, which collapses embedded newlines (e.g. pretty-printed JSON) onto
|
||||||
|
* one line — don't pass it for multi-line content.
|
||||||
|
*/
|
||||||
|
wrap?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatJson(content: string): string {
|
function formatJson(content: string): string {
|
||||||
|
|
@ -66,6 +79,7 @@ export function ContentViewer({
|
||||||
block,
|
block,
|
||||||
copyable = false,
|
copyable = false,
|
||||||
mimeType,
|
mimeType,
|
||||||
|
wrap = true,
|
||||||
}: ContentViewerProps) {
|
}: ContentViewerProps) {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case "text": {
|
case "text": {
|
||||||
|
|
@ -93,7 +107,14 @@ export function ContentViewer({
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<ContentWrapper>
|
<ContentWrapper>
|
||||||
<Code block p={36} variant="wrapping">
|
<Code
|
||||||
|
block
|
||||||
|
p={36}
|
||||||
|
variant={wrap ? "wrapping" : "nowrap"}
|
||||||
|
// When not wrapping, the value may be clipped with an ellipsis;
|
||||||
|
// expose the full text on hover so it's readable without copying.
|
||||||
|
title={wrap ? undefined : displayText}
|
||||||
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</Code>
|
</Code>
|
||||||
{copyable && (
|
{copyable && (
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,7 @@ export function ServerCard({
|
||||||
<ContentViewer
|
<ContentViewer
|
||||||
block={{ type: "text", text: commandOrUrl }}
|
block={{ type: "text", text: commandOrUrl }}
|
||||||
copyable
|
copyable
|
||||||
|
wrap={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,17 @@ export function ServerListScreen({
|
||||||
|
|
||||||
const grid = (
|
const grid = (
|
||||||
<SimpleGrid
|
<SimpleGrid
|
||||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
// Container queries (not viewport) so column count tracks the actual
|
||||||
|
// space the grid occupies. The 2- and 3-column thresholds (1040px /
|
||||||
|
// 1560px) keep each card ≥ ~505px wide at the switch point: container =
|
||||||
|
// N·card + (N−1)·gap with gap = lg spacing (20px), i.e. 1040 = 2·510+20
|
||||||
|
// and 1560 = 3·507+40. Below ~500px a connected card's action row
|
||||||
|
// (Clone/Edit/Remove + Connection Info/Settings, ~440px with padding)
|
||||||
|
// wraps and stacks, making that card taller than its neighbours; dropping
|
||||||
|
// to fewer, wider columns instead keeps every card the same height.
|
||||||
|
// (#1528)
|
||||||
|
type="container"
|
||||||
|
cols={{ base: 1, "1040px": 2, "1560px": 3 }}
|
||||||
spacing="lg"
|
spacing="lg"
|
||||||
className="grid-align-start"
|
className="grid-align-start"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ export const ThemeCode = Code.extend({
|
||||||
root.wordBreak = "break-all";
|
root.wordBreak = "break-all";
|
||||||
root.whiteSpace = "pre-wrap";
|
root.whiteSpace = "pre-wrap";
|
||||||
}
|
}
|
||||||
|
if (props.variant === "nowrap") {
|
||||||
|
// Single line, fixed height: clip overflow with an ellipsis rather than
|
||||||
|
// wrapping onto new lines (which would grow the container's height). The
|
||||||
|
// full value stays available via the adjacent copy button.
|
||||||
|
root.whiteSpace = "nowrap";
|
||||||
|
root.overflow = "hidden";
|
||||||
|
root.textOverflow = "ellipsis";
|
||||||
|
}
|
||||||
return { root };
|
return { root };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue