web: give every card on a page one size
This commit is contained in:
parent
165d78175e
commit
bce802ded0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLElement | null>(null);
|
||||
$effect(() => uniformCards(page));
|
||||
|
||||
let crate = $state<ApiCrate | null>(null);
|
||||
let item = $state<(ApiItem & { crate: string; layer: string }) | null>(null);
|
||||
let notFound = $state<string | null>(null);
|
||||
|
|
@ -135,6 +144,7 @@
|
|||
|
||||
</script>
|
||||
|
||||
<div bind:this={page} class="page-root">
|
||||
<div class="page-header">
|
||||
<h1>{tr('api.title', lang)}</h1>
|
||||
<p>{tr('api.intro', lang)}</p>
|
||||
|
|
@ -265,4 +275,6 @@
|
|||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>(null);
|
||||
$effect(() => uniformCards(page));
|
||||
</script>
|
||||
|
||||
<div bind:this={page} class="page-root">
|
||||
<div class="page-header">
|
||||
<h1>{tr('docs.title', lang)}</h1>
|
||||
<p>{tr('docs.intro', lang)}</p>
|
||||
|
|
@ -52,6 +62,8 @@
|
|||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement | null>(null);
|
||||
$effect(() => uniformCards(page));
|
||||
|
||||
let tool = $state<Tool | null>(null);
|
||||
let values = $state<Record<string, string>>({});
|
||||
let result = $state<ToolResult | null>(null);
|
||||
|
|
@ -120,6 +129,7 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={page} class="page-root">
|
||||
<div class="page-header">
|
||||
<h1>{tr('tools.title', lang)}</h1>
|
||||
<p>{tr('tools.intro', lang)}</p>
|
||||
|
|
@ -239,3 +249,4 @@
|
|||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue