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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue