web: give every card on a page one size

This commit is contained in:
dev 2026-10-01 09:38:16 +08:00
parent 165d78175e
commit bce802ded0
6 changed files with 158 additions and 10 deletions

View File

@ -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 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. 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 A grid of cards has **one card size on the whole page**, not one per row.
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 A grid makes the cards of a single row equal, which is not enough: each row is
paragraph cannot stretch a card past its neighbours. 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 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 surface and the rule so the column is drawn for the whole page; the sidebar

View File

@ -420,6 +420,12 @@ button.ghost:hover:not(:disabled) {
margin-top: 0; 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 /* 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 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. */ 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 /* A summary is clamped so that one long paragraph does not stretch its card
far past the cards beside it. */ far past the cards beside it. */
.card-clamp { .card-clamp {
display: -webkit-box; /* The text is not truncated: the card height is uniform across the page,
-webkit-line-clamp: 4; so a card with a longer summary simply carries more lines and the
line-clamp: 4; action stays on the bottom line of every card. */
-webkit-box-orient: vertical; overflow-wrap: anywhere;
overflow: hidden;
} }
.grid { .grid {
@ -463,8 +468,19 @@ button.ghost:hover:not(:disabled) {
gap: 1rem; 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.cards {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.grid.metrics { .grid.metrics {
@ -1158,6 +1174,18 @@ tbody tr:hover {
/* Responsive */ /* 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) { @media (max-width: 700px) {
/* The counters are a summary, not navigation, so they are the first /* The counters are a summary, not navigation, so they are the first
thing to go when the bar is tight. */ thing to go when the bar is tight. */

View File

@ -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<HTMLElement>('.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<HTMLElement>('.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);
};
}

View File

@ -16,6 +16,7 @@
import { getJson } from '../lib/api'; import { getJson } from '../lib/api';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, pick, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
import { uniformCards } from '../lib/uniformCards';
import { KIND_GROUPS } from '../lib/i18n'; import { KIND_GROUPS } from '../lib/i18n';
import Signature from '../lib/Signature.svelte'; import Signature from '../lib/Signature.svelte';
@ -31,6 +32,14 @@
/** The current language, read so that the text re-renders on a switch. */ /** The current language, read so that the text re-renders on a switch. */
let lang = $derived($language); 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<HTMLElement | null>(null);
$effect(() => uniformCards(page));
let crate = $state<ApiCrate | null>(null); let crate = $state<ApiCrate | null>(null);
let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null); let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null);
let notFound = $state<string | null>(null); let notFound = $state<string | null>(null);
@ -135,6 +144,7 @@
</script> </script>
<div bind:this={page} class="page-root">
<div class="page-header"> <div class="page-header">
<h1>{tr('api.title', lang)}</h1> <h1>{tr('api.title', lang)}</h1>
<p>{tr('api.intro', lang)}</p> <p>{tr('api.intro', lang)}</p>
@ -265,4 +275,6 @@
</a> </a>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>

View File

@ -8,6 +8,7 @@
import type { DocumentIndex } from '../lib/types'; import type { DocumentIndex } from '../lib/types';
import { navigate } from '../lib/router'; import { navigate } from '../lib/router';
import { language, pick, pickList, tr } from '../lib/i18n'; import { language, pick, pickList, tr } from '../lib/i18n';
import { uniformCards } from '../lib/uniformCards';
interface Props { interface Props {
index: DocumentIndex | null; index: DocumentIndex | null;
@ -17,8 +18,17 @@
/** The current language, read so that the text re-renders on a switch. */ /** The current language, read so that the text re-renders on a switch. */
let lang = $derived($language); 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<HTMLElement | null>(null);
$effect(() => uniformCards(page));
</script> </script>
<div bind:this={page} class="page-root">
<div class="page-header"> <div class="page-header">
<h1>{tr('docs.title', lang)}</h1> <h1>{tr('docs.title', lang)}</h1>
<p>{tr('docs.intro', lang)}</p> <p>{tr('docs.intro', lang)}</p>
@ -52,6 +62,8 @@
</article> </article>
{/each} {/each}
</div> </div>
</section> </section>
{/each} {/each}
{/if} {/if}
</div>

View File

@ -10,6 +10,7 @@
import type { Tool, ToolRegistry, ToolResult, ToolRunResponse } from '../lib/types'; import type { Tool, ToolRegistry, ToolResult, ToolRunResponse } from '../lib/types';
import { postJson, ApiError } from '../lib/api'; import { postJson, ApiError } from '../lib/api';
import { language, pick, tr } from '../lib/i18n'; import { language, pick, tr } from '../lib/i18n';
import { uniformCards } from '../lib/uniformCards';
import ResultView from '../lib/ResultView.svelte'; import ResultView from '../lib/ResultView.svelte';
interface Props { interface Props {
@ -22,6 +23,14 @@
/** The current language, read so that the text re-renders on a switch. */ /** The current language, read so that the text re-renders on a switch. */
let lang = $derived($language); 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<HTMLElement | null>(null);
$effect(() => uniformCards(page));
let tool = $state<Tool | null>(null); let tool = $state<Tool | null>(null);
let values = $state<Record<string, string>>({}); let values = $state<Record<string, string>>({});
let result = $state<ToolResult | null>(null); let result = $state<ToolResult | null>(null);
@ -120,6 +129,7 @@
}); });
</script> </script>
<div bind:this={page} class="page-root">
<div class="page-header"> <div class="page-header">
<h1>{tr('tools.title', lang)}</h1> <h1>{tr('tools.title', lang)}</h1>
<p>{tr('tools.intro', lang)}</p> <p>{tr('tools.intro', lang)}</p>
@ -239,3 +249,4 @@
{/if} {/if}
{/if} {/if}
{/if} {/if}
</div>