diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 2d99b20..bf14afd 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -17,7 +17,12 @@
SearchResponse,
} from './lib/types';
import { getJson, queryString } from './lib/api';
- import { currentRoute, onRouteChange, navigate } from './lib/router';
+ import {
+ currentRoute,
+ navigate,
+ onRouteChange,
+ scrollToTopOnNavigation,
+ } from './lib/router';
import { language, pick, setLanguage, tr, type Language } from './lib/i18n';
import { theme, toggleTheme } from './lib/theme';
import { untrack } from 'svelte';
@@ -134,10 +139,15 @@
$effect(() => {
void loadShared();
+ // A new page starts at the top; going back keeps the position.
+ const stopNavigation = scrollToTopOnNavigation();
const stop = onRouteChange(() => {
route = currentRoute();
});
- return stop;
+ return () => {
+ stop();
+ stopNavigation();
+ };
});
$effect(() => {
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 52545eb..163bff8 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -653,6 +653,44 @@ button.ghost:hover:not(:disabled) {
overflow-wrap: anywhere;
}
+/*
+ * A card on the interface index shows what a crate is, in one or two lines.
+ *
+ * The cards there are in a grid with three columns on a wide screen, so a
+ * single long summary made every card in its row as tall as the longest one.
+ * The text is cut rather than the card stretched, because the rest of the
+ * summary is on the page the card leads to.
+ */
+.crate-grid .card-clamp {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+ margin: 0.4rem 0 0;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
+/* A layer heading on the interface index. */
+.api-layer-heading {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin: 1.75rem 0 0;
+ font-size: 1.05rem;
+}
+
+.api-layer-heading a {
+ color: var(--text);
+}
+
+.api-layer-summary {
+ margin: 0.25rem 0 0;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+}
+
.grid {
display: grid;
gap: 1rem;
diff --git a/frontend/src/lib/ResultView.svelte b/frontend/src/lib/ResultView.svelte
index 93bc6d9..c0eb22f 100644
--- a/frontend/src/lib/ResultView.svelte
+++ b/frontend/src/lib/ResultView.svelte
@@ -30,7 +30,9 @@
{pick(scalar.label, lang)}
- {scalar.value}{#if scalar.unit}{scalar.unit}{/if}
+ {scalar.value}{#if scalar.unit}{#if scalar.unit_html}{@html scalar.unit_html}{:else}{scalar.unit}{/if}{/if}
{/each}
@@ -48,7 +50,9 @@
{#each result.table.columns as column}
|
- {pick(column.label, lang)}{#if column.unit} [{column.unit}]{/if}
+ {pick(column.label, lang)}{#if column.unit} [{#if column.unit_html}{@html column.unit_html}{:else}{column.unit}{/if}]{/if}
|
{/each}
@@ -58,7 +62,18 @@
{#each row as cell, index}
|
- {formatCell(cell, result.table.columns[index]?.numeric ?? false)}
+ {#if typeof cell === 'object' && cell !== null && cell.html}
+
+ {@html cell.html}
+ {:else}
+ {formatCell(
+ cell,
+ result.table.columns[index]?.numeric ?? false,
+ lang,
+ )}
+ {/if}
|
{/each}
@@ -69,9 +84,11 @@
{/if}
{#if result.notes.length > 0}
-
- {#each result.notes as note}
-
{pick(note, lang)}
+
+ {#each result.notes as note, index (index)}
+
+ {@html pick(result.notes_html?.[index] ?? { zh: note.zh, en: note.en }, lang)}
{/each}
{/if}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 1e82428..c2f7e7c 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -5,6 +5,9 @@
* number is written the same way everywhere.
*/
+import type { Bilingual } from './types';
+import { pick, type Language } from './i18n';
+
/** Formats a number with a sensible number of decimals for its magnitude. */
export function formatNumber(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(value)) {
@@ -21,11 +24,21 @@ export function formatNumber(value: number | null | undefined): string {
}
/** Formats a table cell according to the column type. */
-export function formatCell(value: string | number | null, numeric: boolean): string {
+export function formatCell(
+ value: string | number | Bilingual | null,
+ numeric: boolean,
+ lang: Language,
+): string {
if (value === null || value === undefined) return '—';
if (typeof value === 'number') {
return numeric ? formatNumber(value) : String(value);
}
+ if (typeof value === 'object') {
+ // A text cell may carry a translation of a fixed phrase, such as the
+ // meaning of a field. A cell holding data rather than prose carries the
+ // same string in both languages and needs no lookup.
+ return pick(value, lang);
+ }
return value;
}
diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts
index 944688d..3cb88d9 100644
--- a/frontend/src/lib/router.ts
+++ b/frontend/src/lib/router.ts
@@ -60,6 +60,43 @@ export function onRouteChange(listener: () => void): () => void {
return () => window.removeEventListener('hashchange', listener);
}
+/**
+ * Returns the page to the top when the route changes.
+ *
+ * A hash change does not move the scroll position, so a reader who has
+ * scrolled to the bottom of a long document and follows a link lands in the
+ * middle of the next page, with nothing to show that it is a different one.
+ *
+ * The scroll follows the path rather than the fragment, so that a search or a
+ * filter applied to the page a reader is already on leaves them where they
+ * are. Only a move to another page starts at the top.
+ *
+ * Going back also starts at the top rather than where the reader left. The
+ * browser would restore that position itself, but it cannot be told apart
+ * from an ordinary link: a fragment navigation creates a history entry, and
+ * the browser fires `popstate` for it as well as for a traversal, so there is
+ * no event that means "this was a traversal" on its own. Restoring the
+ * position by watching the path would mean keeping a history of positions,
+ * which is more machinery than the gain is worth here.
+ */
+export function scrollToTopOnNavigation(): () => void {
+ let previous: string | null = null;
+
+ const onChange = () => {
+ const path = window.location.hash.split('?')[0];
+ if (path === previous) return;
+ previous = path;
+ window.scrollTo(0, 0);
+ };
+
+ // The first page is already at the top, but the position is recorded so
+ // that moving away and back is treated as two changes rather than one.
+ previous = window.location.hash.split('?')[0];
+
+ window.addEventListener('hashchange', onChange);
+ return () => window.removeEventListener('hashchange', onChange);
+}
+
/**
* Intercepts clicks on internal links so that the router handles them.
*
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index e229651..e0b947c 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -202,6 +202,11 @@ export interface BilingualSummary {
en: string;
plain: Bilingual;
html: Bilingual;
+ /**
+ * The opening sentence, for a card that links onward. The rest of the
+ * summary belongs on the page the card leads to.
+ */
+ brief?: Bilingual;
}
/** One interface item. */
@@ -214,11 +219,20 @@ export interface ApiItem {
associated: boolean;
}
+/** One layer of the library, used to organize the interface index. */
+export interface ApiLayer {
+ id: string;
+ title: Bilingual;
+ summary: Bilingual;
+}
+
/** The interface catalog index. */
export interface ApiIndex {
generated_from: string[];
crate_count: number;
item_count: number;
+ /** The layers, in reading order, that the crates are grouped by. */
+ layers: ApiLayer[];
crates: ApiCrateSummary[];
items: { name: string; kind: string; crate: string }[];
}
@@ -244,6 +258,8 @@ export interface ToolParam {
name: string;
label: Bilingual;
help: Bilingual;
+ /** The help rendered from Markdown, so that a formula in it is set. */
+ help_html?: Bilingual;
kind: 'number' | 'integer' | 'text' | 'multiline' | 'select';
required: boolean;
default: string | null;
@@ -256,6 +272,8 @@ export interface Tool {
name: Bilingual;
summary: Bilingual;
description: Bilingual;
+ /** The description rendered from Markdown, so that a formula in it is set. */
+ description_html?: Bilingual;
category: Bilingual;
params: ToolParam[];
api_items: string[];
@@ -272,13 +290,36 @@ export interface ResultScalar {
label: Bilingual;
value: string;
unit: string;
+ /** The unit rendered, so that a power in it reads as a power. */
+ unit_html?: string;
monospace: boolean;
}
+/** A column of a result table. */
+export interface ResultColumn {
+ key: string;
+ label: Bilingual;
+ unit: string;
+ /** The unit rendered, so that a power in it reads as a power. */
+ unit_html?: string;
+ numeric: boolean;
+}
+
+/**
+ * One cell of a result table.
+ *
+ * A cell is usually a number. A text cell may carry a translation, because a
+ * cell holding a fixed phrase, such as the meaning of a field, is written
+ * once and shown in the reader's language. Such a cell may also carry the
+ * text rendered, which is how a unit written with a power in it, such as
+ * `rev/day^2`, is shown as a power.
+ */
+export type ResultCell = string | number | (Bilingual & { html?: string }) | null;
+
/** A result table. */
export interface ResultTable {
- columns: { key: string; label: Bilingual; unit: string; numeric: boolean }[];
- rows: (string | number | null)[][];
+ columns: ResultColumn[];
+ rows: ResultCell[][];
}
/** A numeric series. */
@@ -296,6 +337,8 @@ export interface ToolResult {
table: ResultTable;
series: ResultSeries[];
notes: Bilingual[];
+ /** The notes rendered from Markdown, which is how a formula in one is set. */
+ notes_html?: Bilingual[];
axis?: { name: Bilingual; unit: string; values: number[] };
}
diff --git a/frontend/src/pages/Api.svelte b/frontend/src/pages/Api.svelte
index 4e777aa..e4d214e 100644
--- a/frontend/src/pages/Api.svelte
+++ b/frontend/src/pages/Api.svelte
@@ -119,6 +119,35 @@
return names ? pick(names, language) : kind;
}
+ /**
+ * The crates of the index, grouped by the layer they belong to.
+ *
+ * The index is organized by layer for the same reason the concept
+ * documentation is: a reader looking for a type is looking for the subject
+ * it belongs to, and one flat list of fifteen crates gives no hint of where
+ * to look. The layers come from the concept documentation, so the order and
+ * the titles are the same in both.
+ *
+ * A layer the catalog names but the documentation does not declare would
+ * leave its crates with no heading, so those are collected at the end.
+ */
+ const layers = $derived.by(() => {
+ const current = index;
+ if (!current) return [];
+ const declared = current.layers ?? [];
+ const grouped = declared
+ .map((layer) => ({
+ ...layer,
+ crates: current.crates.filter((krate) => krate.layer === layer.id),
+ }))
+ .filter((layer) => layer.crates.length > 0);
+ const placed = new Set(declared.map((layer) => layer.id));
+ const rest = current.crates.filter((krate) => !placed.has(krate.layer));
+ return rest.length > 0
+ ? [...grouped, { id: '', title: { zh: '其他', en: 'Others' }, summary: { zh: '', en: '' }, crates: rest }]
+ : grouped;
+ });
+
/** Opens an item route. */
function openItem(name: string) {
navigate(`/api/${name}`);
@@ -263,18 +292,28 @@
{/if}
-
+ {#each layers as layer (layer.id)}
+
+ {#if pick(layer.summary, lang)}
+
{pick(layer.summary, lang)}
+ {/if}
+
+ {/each}
{/if}
diff --git a/frontend/src/pages/Tools.svelte b/frontend/src/pages/Tools.svelte
index 371f4c0..ebeb2d4 100644
--- a/frontend/src/pages/Tools.svelte
+++ b/frontend/src/pages/Tools.svelte
@@ -148,7 +148,12 @@
{pick(tool.name, lang)}
{pick(tool.category, lang)}
- {pick(tool.description, lang)}
+
+
+ {@html pick(tool.description_html ?? { zh: tool.description.zh, en: tool.description.en }, lang)}
+