From 829825ac733c12a09716eb3103ac6ca521bc6646 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 09:38:16 +0800 Subject: [PATCH] web: give every card on a page one size --- docs/design.md | 23 +++++++++-- frontend/src/app.css | 40 +++++++++++++++--- frontend/src/lib/uniformCards.ts | 70 ++++++++++++++++++++++++++++++++ frontend/src/pages/Api.svelte | 12 ++++++ frontend/src/pages/Docs.svelte | 12 ++++++ frontend/src/pages/Tools.svelte | 11 +++++ 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 frontend/src/lib/uniformCards.ts diff --git a/docs/design.md b/docs/design.md index ece1367..7690438 100644 --- a/docs/design.md +++ b/docs/design.md @@ -287,10 +287,25 @@ contents beside the text; the width of the pair is capped so the two stay together. A graph is the exception and uses the whole width, because a wide screen should show more of the graph rather than more margin. -A grid of cards **stretches every card in a row** to the height of the -tallest. A card is therefore a column, its action is pushed to the bottom, -and its summary is clamped, so the actions of a row line up and one long -paragraph cannot stretch a card past its neighbours. +A grid of cards has **one card size on the whole page**, not one per row. + +A grid makes the cards of a single row equal, which is not enough: each row is +measured on its own, so a page built from several grids steps from one row +height to the next and the column of cards looks ragged. The tallest card +across every grid is therefore measured in the browser and applied to all of +them, with a `ResizeObserver` so that the measurement follows the column +count and the language. + +The columns are **a fixed count rather than as many as fit**. Sizing columns +with `auto-fill` makes the column width depend on how many cards are in the +row, so a section of three and a section of four produce different card +widths on the same page. A fixed count keeps one rhythm down the page; a +section that does not fill a row leaves the gap at the end instead of +stretching its cards. + +A card is a column, so its **action sits on the bottom line of every card** +whatever the length of the text above it. The summary is not truncated: a +uniform height means a card with a longer summary simply carries more lines. The sidebar is **a sticky element inside a frame**. The frame carries the surface and the rule so the column is drawn for the whole page; the sidebar diff --git a/frontend/src/app.css b/frontend/src/app.css index b48c591..2015e68 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -420,6 +420,12 @@ button.ghost:hover:not(:disabled) { margin-top: 0; } +/* The root of a page that renders cards. It carries no style of its own; + it exists so that the cards under it can be measured together. */ +.page-root { + display: block; +} + /* A grid of cards stretches every card in a row to the height of the tallest. The card is a column so that its action sits at the bottom of every card rather than floating at the height of the text above it. */ @@ -451,11 +457,10 @@ button.ghost:hover:not(:disabled) { /* A summary is clamped so that one long paragraph does not stretch its card far past the cards beside it. */ .card-clamp { - display: -webkit-box; - -webkit-line-clamp: 4; - line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; + /* The text is not truncated: the card height is uniform across the page, + so a card with a longer summary simply carries more lines and the + action stays on the bottom line of every card. */ + overflow-wrap: anywhere; } .grid { @@ -463,8 +468,19 @@ button.ghost:hover:not(:disabled) { gap: 1rem; } +/* + * A card grid has a fixed number of columns rather than as many as fit. + * + * `auto-fill` sizes the columns to the width available *and* to the number + * of cards in the row, so a section of three cards and a section of four end + * up with different card widths on the same page. A fixed column count keeps + * one rhythm down the page: every card is the same width, and a section that + * does not fill a row leaves the gap at the end rather than widening the + * cards. The columns collapse on a narrow screen, where a fluid width is the + * right answer. + */ .grid.cards { - grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } .grid.metrics { @@ -1158,6 +1174,18 @@ tbody tr:hover { /* Responsive */ +@media (max-width: 1180px) { + .grid.cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .grid.cards { + grid-template-columns: minmax(0, 1fr); + } +} + @media (max-width: 700px) { /* The counters are a summary, not navigation, so they are the first thing to go when the bar is tight. */ diff --git a/frontend/src/lib/uniformCards.ts b/frontend/src/lib/uniformCards.ts new file mode 100644 index 0000000..d9ad75c --- /dev/null +++ b/frontend/src/lib/uniformCards.ts @@ -0,0 +1,70 @@ +/** + * Gives every card in a container the height of the tallest one. + * + * A grid makes the cards of one row equal, but each row is measured on its + * own, so a page built from several grids steps from one row height to the + * next and the column of cards looks ragged. Measuring the tallest card + * across every grid and applying it to all of them removes the step. + * + * The measurement runs when the content or the language changes, and on a + * resize, because the number of columns and therefore the natural height of + * a card both depend on the width. Nothing is measured on the server: the + * height is a property of the rendering, not of the content. + */ + +/** + * Returns the height of the tallest card under `root`, in pixels, or `0` when + * there is no card. + */ +export function tallestCardHeight(root: ParentNode): number { + let tallest = 0; + // The card is laid out at its natural height here, because the uniform + // height is not applied yet. + for (const card of root.querySelectorAll('.grid.cards > .card')) { + tallest = Math.max(tallest, card.getBoundingClientRect().height); + } + return Math.ceil(tallest); +} + +/** + * Applies the tallest card height to every card under `root`, and removes the + * inline height when there is no card. + */ +export function applyUniformHeight(root: ParentNode): void { + const cards = [...root.querySelectorAll('.grid.cards > .card')]; + if (cards.length === 0) { + return; + } + const tallest = tallestCardHeight(root); + for (const card of cards) { + card.dataset.uniformHeight = '1'; + card.style.height = `${tallest}px`; + } +} + +/** + * Measures the cards and keeps them equal, returning a stop function. + * + * The returned function detaches the listener. + */ +export function uniformCards(node: HTMLElement | null): () => void { + if (!node) return () => {}; + let frame = 0; + const update = () => { + frame = 0; + applyUniformHeight(node); + }; + const schedule = () => { + if (frame !== 0) return; + frame = requestAnimationFrame(update); + }; + schedule(); + const observer = new ResizeObserver(schedule); + observer.observe(node); + window.addEventListener('resize', schedule); + return () => { + if (frame !== 0) cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener('resize', schedule); + }; +} diff --git a/frontend/src/pages/Api.svelte b/frontend/src/pages/Api.svelte index 4b999a3..4e777aa 100644 --- a/frontend/src/pages/Api.svelte +++ b/frontend/src/pages/Api.svelte @@ -16,6 +16,7 @@ import { getJson } from '../lib/api'; import { navigate } from '../lib/router'; import { language, pick, tr } from '../lib/i18n'; + import { uniformCards } from '../lib/uniformCards'; import { KIND_GROUPS } from '../lib/i18n'; import Signature from '../lib/Signature.svelte'; @@ -31,6 +32,14 @@ /** The current language, read so that the text re-renders on a switch. */ let lang = $derived($language); + /** + * The page root, measured so that every card shares one height. The cards + * of a page are spread over several grids, so the measurement has to start + * from the element that contains all of them. + */ + let page = $state(null); + $effect(() => uniformCards(page)); + let crate = $state(null); let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null); let notFound = $state(null); @@ -135,6 +144,7 @@ +
+ {/if} +
diff --git a/frontend/src/pages/Docs.svelte b/frontend/src/pages/Docs.svelte index 5204c4c..d51dac1 100644 --- a/frontend/src/pages/Docs.svelte +++ b/frontend/src/pages/Docs.svelte @@ -8,6 +8,7 @@ import type { DocumentIndex } from '../lib/types'; import { navigate } from '../lib/router'; import { language, pick, pickList, tr } from '../lib/i18n'; + import { uniformCards } from '../lib/uniformCards'; interface Props { index: DocumentIndex | null; @@ -17,8 +18,17 @@ /** The current language, read so that the text re-renders on a switch. */ let lang = $derived($language); + + /** + * The page root, measured so that every card shares one height. The cards + * of a page are spread over several grids, so the measurement has to start + * from the element that contains all of them. + */ + let page = $state(null); + $effect(() => uniformCards(page)); +
+ {/each} {/if} +
diff --git a/frontend/src/pages/Tools.svelte b/frontend/src/pages/Tools.svelte index dab6539..7420403 100644 --- a/frontend/src/pages/Tools.svelte +++ b/frontend/src/pages/Tools.svelte @@ -10,6 +10,7 @@ import type { Tool, ToolRegistry, ToolResult, ToolRunResponse } from '../lib/types'; import { postJson, ApiError } from '../lib/api'; import { language, pick, tr } from '../lib/i18n'; + import { uniformCards } from '../lib/uniformCards'; import ResultView from '../lib/ResultView.svelte'; interface Props { @@ -22,6 +23,14 @@ /** The current language, read so that the text re-renders on a switch. */ let lang = $derived($language); + /** + * The page root, measured so that every tool card shares one height. The + * cards are spread over one grid per category, so the measurement has to + * start from the element that contains all of them. + */ + let page = $state(null); + $effect(() => uniformCards(page)); + let tool = $state(null); let values = $state>({}); let result = $state(null); @@ -120,6 +129,7 @@ }); +