Files
homepage/src/routes/hikes/route-builder/+page.svelte
T
Alexander 603240bf93 feat(route-builder): stats bar, waypoint detail panel, elevation refactor
Work-in-progress route-builder checkpoint:

- New RouteStatsBar and WaypointDetailPanel components.
- EditMap / ImageDropzone / WaypointTable / builderStore updates.
- Hoist the elevation gain/loss/range helpers out of build-hikes.ts into
  src/lib/hikes/elevation.ts so the builder and the build share one
  implementation.

Also bundled here (same file, couldn't be split cleanly): build-hikes.ts
now detects each hike's country at build time — 'CH' when a Swiss canton
matched, otherwise an OSM/Nominatim reverse-geocode — and writes it to the
manifest, feeding the new Kanton/Land filter.
2026-05-22 13:07:24 +02:00

841 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { untrack } from 'svelte';
import Seo from '$lib/components/Seo.svelte';
import EditMap from '$lib/components/hikes/route-builder/EditMap.svelte';
import WaypointTable from '$lib/components/hikes/route-builder/WaypointTable.svelte';
import WaypointDetailPanel from '$lib/components/hikes/route-builder/WaypointDetailPanel.svelte';
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
import RouteStatsBar from '$lib/components/hikes/route-builder/RouteStatsBar.svelte';
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
import {
builder,
focusWaypoint,
mapView,
setRoutedSegments,
setElevations,
clearDraft,
reconcileSegments,
densifyLinearSegments,
importGpx
} from '$lib/components/hikes/route-builder/builderStore.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Crosshair from '@lucide/svelte/icons/crosshair';
import Download from '@lucide/svelte/icons/download';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import CheckCircle2 from '@lucide/svelte/icons/circle-check-big';
import Toggle from '$lib/components/Toggle.svelte';
let busy = $state(false);
let error = $state<string | null>(null);
let routeRequestId = 0;
/**
* Pull elevations from Swisstopo for every point of the current
* `routedSegments` that lacks one, then fold the values back into the
* segment arrays. Shared by the snap path (where BRouter sometimes
* doesn't return elevations) and the manual / off-trail path (where
* we densify a straight line then need its profile).
*
* Returns silently if every point already has an altitude — handy when
* BRouter snapped the route and embedded elevations inline.
*/
async function enrichMissingElevations(reqId: number): Promise<void> {
const flat = builder.routedSegments.flat();
if (flat.length === 0) return;
if (!flat.some((p) => typeof p[2] !== 'number')) return;
const elevRes = await fetch('/api/hikes/route-builder/elevation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ coordinates: flat.map((p) => [p[0], p[1]]) })
});
if (reqId !== routeRequestId) return;
if (!elevRes.ok) return;
const { elevations } = (await elevRes.json()) as { elevations: (number | null)[] };
setElevations(elevations);
}
async function snapToRoute() {
const placed = builder.waypoints.filter((w) => !w.unplaced);
if (placed.length < 2) {
setRoutedSegments([]);
return;
}
const reqId = ++routeRequestId;
busy = true;
error = null;
try {
const res = await fetch('/api/hikes/route-builder/route', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
waypoints: placed.map((w) => ({ lat: w.lat, lng: w.lng })),
profile: builder.profile
})
});
if (reqId !== routeRequestId) return; // stale response
if (!res.ok) {
error = `Routing fehlgeschlagen (HTTP ${res.status}).`;
return;
}
const data = (await res.json()) as {
segments: Array<Array<[number, number, number?]>>;
source: string;
};
if (reqId !== routeRequestId) return;
setRoutedSegments(data.segments);
// BRouter usually embeds elevations inline; OSRM / linear
// fallbacks don't. Single helper handles both cases.
await enrichMissingElevations(reqId);
} catch (err) {
if (reqId !== routeRequestId) return;
error = (err as Error).message;
} finally {
if (reqId === routeRequestId) busy = false;
}
}
// Reactor: any structural change (waypoints, profile, autoSnap toggle)
// reconciles `routedSegments` against the current waypoint list first —
// matching pairs keep their snapped data, new pairs get a straight-line
// placeholder. After that, if autoSnap is on, fire a debounced API call to
// refresh; server-side per-pair caching makes cached pairs free.
let snapDebounce: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// Track waypoint structure + flags as deps.
builder.profile;
builder.autoSnap;
builder.waypoints.length;
for (const w of builder.waypoints) {
w.id; w.lat; w.lng;
}
// All side-effects that read/write `routedSegments` and `segmentSourceIds`
// must be untracked — otherwise the effect's own writes would feed back
// into its dependency set and trigger an update loop.
untrack(() => {
reconcileSegments();
if (snapDebounce) clearTimeout(snapDebounce);
if (builder.autoSnap) {
snapDebounce = setTimeout(() => snapToRoute(), 250);
} else {
// Manual / off-trail mode: keep already-snapped pairs intact
// (reconcileSegments preserved them); but for any fresh
// two-point linear placeholder, densify to ~25 m spacing and
// pull a Swisstopo elevation profile so the GPX carries
// per-trkpt `<ele>` even when the user chose not to snap.
// Cancel any in-flight snap request so a late response can't
// overwrite what we're about to densify.
const reqId = ++routeRequestId;
snapDebounce = setTimeout(async () => {
busy = true;
try {
densifyLinearSegments(25);
await enrichMissingElevations(reqId);
} catch (err) {
if (reqId === routeRequestId) error = (err as Error).message;
} finally {
if (reqId === routeRequestId) busy = false;
}
}, 250);
}
});
});
function downloadGpx() {
const unplaced = builder.waypoints.filter((w) => w.unplaced);
if (unplaced.length > 0) {
error = `${unplaced.length} Bild${unplaced.length === 1 ? '' : 'er'} ohne Position. Bitte in der Wegpunkt-Liste platzieren oder entfernen.`;
return;
}
const placed = builder.waypoints.filter((w) => !w.unplaced);
const assembled = assembleTrackPoints({
waypoints: placed.map((w) => ({
lat: w.lat,
lng: w.lng,
altitude: w.altitude,
timestamp: w.timestamp ?? null
})),
routedSegments: builder.routedSegments
});
if (!assembled.ok) {
error = assembled.error;
return;
}
error = null;
// Look up altitude for a placed-waypoint index from the routed segments.
// Mirrors the trkpt fallback in `assembleTrackPoints`: image waypoints
// almost never carry their own `altitude` (EXIF GPSAltitude is too noisy
// to trust), so without this fallback the GPX `<wpt>` would emit no
// `<ele>` and downstream tools render it as 0.
function routedAltitudeAt(idx: number): number | undefined {
const segs = builder.routedSegments;
const startSeg = segs[idx];
if (startSeg && startSeg.length > 0 && typeof startSeg[0][2] === 'number') {
return startSeg[0][2];
}
const prevSeg = idx > 0 ? segs[idx - 1] : undefined;
if (prevSeg && prevSeg.length > 0) {
const last = prevSeg[prevSeg.length - 1];
if (typeof last[2] === 'number') return last[2];
}
return undefined;
}
const imageWaypoints: GpxImageWaypoint[] = placed
.map((w, idx) => ({ w, idx }))
.filter(
(e): e is { w: typeof e.w & { imageHash: string }; idx: number } =>
typeof e.w.imageHash === 'string'
)
.map(({ w, idx }) => ({
lat: w.lat,
lng: w.lng,
altitude: typeof w.altitude === 'number' ? w.altitude : routedAltitudeAt(idx),
timestamp: w.timestamp ?? null,
hash: w.imageHash,
visibility: w.imageVisibility === 'private' ? 'private' : 'public'
}));
const gpx = buildGpx({
name: builder.name || 'Neue Wanderung',
trackPoints: assembled.points,
imageWaypoints
});
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const slug = (builder.name || 'route').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'route';
const a = document.createElement('a');
a.href = url;
a.download = `${slug}.gpx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// GPX import: handed off to ImageDropzone's FAB picker, which forwards
// any `.gpx` file in the picked set here. Imported route REPLACES the
// current draft, so confirm first when there's existing work.
async function importGpxFile(file: File) {
if (
builder.waypoints.length > 0 &&
!confirm(
'Bestehenden Entwurf durch importierte GPX ersetzen? Aktuelle Wegpunkte gehen verloren.'
)
) {
return;
}
try {
const xml = await file.text();
const result = importGpx(xml);
if (!result.ok) {
error = result.error;
return;
}
error = null;
// Cancel any in-flight enrichment so it doesn't overwrite the
// freshly-imported geometry.
routeRequestId++;
} catch (err) {
error = `GPX-Import fehlgeschlagen: ${(err as Error).message}`;
}
}
// Placement coordination: which unplaced waypoint is currently waiting for
// a click on the map?
let pendingPlacementId = $state<string | null>(null);
function startPlacement(waypointId: string) {
pendingPlacementId = waypointId;
}
function cancelPlacement() {
pendingPlacementId = null;
}
// --- Prev / next waypoint navigation -------------------------------------
// Derived list of placed-only waypoints (the only ones that can sit on the
// map). The nav bar walks this list in display order so jumping with
// chevrons follows the table's numbering.
const placedWaypoints = $derived(builder.waypoints.filter((w) => !w.unplaced));
const focusedIdx = $derived.by(() => {
if (!mapView.focusId) return -1;
return placedWaypoints.findIndex((w) => w.id === mapView.focusId);
});
function focusByIdx(idx: number) {
const wp = placedWaypoints[idx];
if (!wp) return;
focusWaypoint(wp.id);
}
function focusPrev() {
if (placedWaypoints.length === 0) return;
if (focusedIdx <= 0) {
// No focus yet, or already at first → land on the last waypoint
// (typical "step backwards through the route" intent).
focusByIdx(focusedIdx === -1 ? placedWaypoints.length - 1 : 0);
return;
}
focusByIdx(focusedIdx - 1);
}
function focusNext() {
if (placedWaypoints.length === 0) return;
if (focusedIdx === -1) {
focusByIdx(0);
return;
}
if (focusedIdx >= placedWaypoints.length - 1) {
focusByIdx(placedWaypoints.length - 1);
return;
}
focusByIdx(focusedIdx + 1);
}
function refocus() {
// Re-center the map on the currently focused waypoint (handy after
// the user pans away).
if (focusedIdx === -1) focusByIdx(0);
else focusByIdx(focusedIdx);
}
// Keyboard shortcuts for power users: ← / → step through waypoints
// (when not focused on an input).
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
if (target?.isContentEditable) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
focusPrev();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
focusNext();
}
}
</script>
<svelte:window onkeydown={onKey} />
<Seo title="Routen-Builder · Wanderungen" description="Eigene Wanderrouten erstellen, exportieren und teilen." lang="de" />
<svelte:head>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
</svelte:head>
<section class="builder">
<header class="header">
<div class="header-primary">
<input
class="name-input"
type="text"
placeholder="Name der Tour…"
bind:value={builder.name}
/>
<button
type="button"
class="download-cta"
onclick={downloadGpx}
disabled={busy}
title={busy ? 'Warten bis Route + Höhenprofil berechnet sind' : 'GPX herunterladen'}
>
<Download size={16} strokeWidth={2.2} />
<span>GPX herunterladen</span>
</button>
<button
type="button"
class="reset-btn"
onclick={clearDraft}
aria-label="Entwurf zurücksetzen"
title="Entwurf zurücksetzen"
>
<RotateCcw size={16} strokeWidth={2} />
</button>
</div>
<div class="header-settings">
<div class="setting">
<span class="setting-label">Routing</span>
<select
class="profile-select"
bind:value={builder.profile}
disabled={!builder.autoSnap}
>
<option value="hiking-mountain">Wandern (Berg)</option>
<option value="trekking">Trekking</option>
<option value="road">Strasse</option>
</select>
</div>
<div class="setting setting-toggle">
<Toggle bind:checked={builder.autoSnap} label="Auf Wege snappen" />
</div>
<!-- Mode-agnostic snap status. Spinner while the route or
elevation profile is still resolving; subtle check when idle. -->
<span class="snap-status" class:busy aria-live="polite" aria-atomic="true">
{#if busy}
<LoaderCircle size={14} strokeWidth={2.4} class="spin" />
<span>Berechne Route + Höhenprofil…</span>
{:else}
<CheckCircle2 size={14} strokeWidth={2} />
<span>Bereit</span>
{/if}
</span>
</div>
</header>
<RouteStatsBar {busy} />
{#if error}
<p class="err">{error}</p>
{/if}
<div class="map-row">
<div class="map-stage">
<EditMap
{pendingPlacementId}
onPlacementCancel={cancelPlacement}
onPlacementComplete={cancelPlacement}
/>
{#if placedWaypoints.length > 0}
<div class="map-nav" role="group" aria-label="Wegpunkt-Navigation">
<button
type="button"
onclick={focusPrev}
aria-label="Vorheriger Wegpunkt"
title="Vorheriger Wegpunkt (←)"
>
<ChevronLeft size={18} strokeWidth={2.2} />
</button>
<span class="map-nav-label" aria-live="polite">
{focusedIdx === -1 ? '' : focusedIdx + 1}
<span class="sep">/</span>
{placedWaypoints.length}
</span>
<button
type="button"
class="recenter"
onclick={refocus}
aria-label="Auf aktuellen Wegpunkt zentrieren"
title="Zentrieren"
>
<Crosshair size={16} strokeWidth={2.2} />
</button>
<button
type="button"
onclick={focusNext}
aria-label="Nächster Wegpunkt"
title="Nächster Wegpunkt (→)"
>
<ChevronRight size={18} strokeWidth={2.2} />
</button>
</div>
{/if}
</div>
<div class="side-col">
{#if placedWaypoints.length > 0}
<nav class="step-nav" aria-label="Zwischen Wegpunkten wechseln">
<button
type="button"
onclick={focusPrev}
disabled={focusedIdx === 0}
aria-label="Vorheriger Wegpunkt"
>
<ChevronLeft size={16} strokeWidth={2} />
<span>Zurück</span>
</button>
<span class="step-pos">
{focusedIdx === -1 ? '' : focusedIdx + 1}
<span class="sep">/</span>
{placedWaypoints.length}
</span>
<button
type="button"
onclick={focusNext}
disabled={focusedIdx === placedWaypoints.length - 1}
aria-label="Nächster Wegpunkt"
>
<span>Weiter</span>
<ChevronRight size={16} strokeWidth={2} />
</button>
</nav>
{/if}
<WaypointDetailPanel onCancelPlacement={cancelPlacement} />
</div>
</div>
<WaypointTable
{pendingPlacementId}
onRequestPlacement={startPlacement}
onCancelPlacement={cancelPlacement}
/>
<p class="hint">
Tipp: Klicke auf die Karte, um Wegpunkte hinzuzufügen. Bilder mit GPS-EXIF werden
automatisch als Wegpunkte verwendet. Mit den Pfeiltasten ← → kannst du die Wegpunkte
nacheinander auf der Karte anspringen. Der GPX-Export bleibt lokal — eine
Veröffentlichung als Wandereintrag erfordert einen Commit der Dateien unter
<code>src/content/hikes/&lt;slug&gt;/</code>.
</p>
</section>
<!-- FAB lives outside .builder so its fixed-positioning doesn't add a
phantom flex item / gap to the page's vertical flow. -->
<ImageDropzone onGpxImport={importGpxFile} />
<style>
.builder {
max-width: 1400px;
margin-inline: auto;
padding: 1rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.header {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin: 0;
}
/* Primary row: name input is the dominant element; download is the
* single primary CTA; reset is an icon-only escape hatch. */
.header-primary {
display: flex;
align-items: center;
gap: 0.5rem;
}
.name-input {
flex: 1 1 auto;
min-width: 0;
font-size: 1.35rem;
font-weight: 600;
padding: 0.55rem 0.85rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
}
.name-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 20%, transparent);
}
.download-cta {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.45rem;
font: inherit;
font-size: 0.9rem;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: var(--radius-pill);
border: 1px solid var(--color-primary);
background: var(--color-primary);
color: var(--color-text-on-primary);
cursor: pointer;
transition: background var(--transition-fast), transform var(--transition-fast),
box-shadow var(--transition-fast);
flex-shrink: 0;
}
.download-cta:hover:not(:disabled) {
background: var(--color-primary-hover, var(--color-primary));
transform: translateY(-1px);
box-shadow: 0 0.4em 1em -0.4em color-mix(in oklab, var(--color-primary) 65%, transparent);
}
.download-cta:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.reset-btn {
appearance: none;
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-tertiary);
padding: 0.5rem;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), border-color var(--transition-fast),
background var(--transition-fast);
flex-shrink: 0;
}
.reset-btn:hover {
color: var(--red);
border-color: color-mix(in oklab, var(--red) 35%, var(--color-border));
background: color-mix(in oklab, var(--red) 8%, transparent);
}
/* Secondary row: routing settings grouped on the left, live snap
* status on the right. Quieter than the primary row by design. */
.header-settings {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
padding: 0.25rem 0.15rem 0;
}
.setting {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.setting-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
font-weight: 600;
}
.profile-select {
appearance: none;
font: inherit;
font-size: 0.85rem;
padding: 0.35rem 1.85rem 0.35rem 0.7rem;
background: var(--color-bg-tertiary)
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2381a1c1' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>")
no-repeat right 0.55rem center;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
cursor: pointer;
}
.profile-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.setting-toggle :global(.toggle-wrapper label) {
font-size: 0.85rem;
gap: 0.55rem;
}
.snap-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.snap-status :global(svg) {
color: var(--green);
flex-shrink: 0;
}
.snap-status.busy {
color: var(--color-text-secondary);
}
.snap-status.busy :global(svg) {
color: var(--color-primary);
}
.snap-status :global(.spin) {
animation: snap-spin 0.85s linear infinite;
}
@keyframes snap-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.snap-status :global(.spin) {
animation: none;
}
}
.map-row {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
gap: 1rem;
align-items: start;
}
@media (max-width: 1024px) {
.map-row {
grid-template-columns: 1fr;
}
}
.map-stage {
position: relative;
min-width: 0;
}
.side-col {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
/* Stand-alone Zurück/Weiter slider sitting above the detail-panel card,
* not inside it — gives the user the same step controls as the floating
* map-nav, but with full labels in the side column. */
.step-nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 0.3rem;
align-items: center;
padding: 0.3rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
}
.step-nav button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-primary);
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
border-radius: var(--radius-pill);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
}
.step-nav button:first-child {
justify-self: start;
}
.step-nav button:last-child {
justify-self: end;
}
.step-nav button:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.step-nav button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.step-pos {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
min-width: 3.2rem;
text-align: center;
}
.step-pos .sep {
color: var(--color-text-tertiary);
margin: 0 0.2rem;
font-weight: 400;
}
/* Floating prev/next navigator anchored to the top-right of the map.
* Sits above Leaflet's own zoom controls (z-index 1000); we use 600 to
* stay above markers/polylines (400) but below modal-ish UI. */
.map-nav {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 600;
display: inline-flex;
align-items: center;
gap: 0.1rem;
padding: 0.25rem;
background: color-mix(in oklab, var(--color-surface) 92%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-md);
font: inherit;
}
.map-nav button {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-primary);
width: 2rem;
height: 2rem;
border-radius: 50%;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
.map-nav button:hover {
background: var(--color-bg-elevated);
}
.map-nav button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.map-nav button.recenter {
color: var(--blue);
}
.map-nav-label {
padding: 0 0.4rem;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary);
min-width: 3.2rem;
text-align: center;
}
.map-nav-label .sep {
color: var(--color-text-tertiary);
margin: 0 0.15rem;
font-weight: 400;
}
.err {
margin: 0;
padding: 0.5rem 0.75rem;
background: color-mix(in oklab, var(--red) 12%, transparent);
color: var(--red);
border-radius: var(--radius-md);
}
.hint {
margin-top: 0.25rem;
color: var(--color-text-tertiary);
font-size: 0.85rem;
}
code {
background: var(--color-bg-tertiary);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
font-size: 0.85em;
}
</style>