diff --git a/.env.example b/.env.example
index 98d01ad..f24c17f 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,6 @@
# Copy this file to .env and configure your MQTT feed. No map key or build is needed.
# Keep .env and any optional .secrets files private.
-CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.8.5
+CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.9.0
CARTOLITE_BIND_ADDR=127.0.0.1
CARTOLITE_PORT=8080
# Optional CARTO override: see compose.carto.yml and docs/deployment.md.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70caf06..0f5b959 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -150,6 +150,21 @@ jobs:
bash scripts/publish-fixture.sh cartolite-server-mqtt testdata/synthetic-live.ndjson
bash scripts/integration-smoke.sh http://127.0.0.1:39476 artifacts/integration cartolite-server-mqtt
bash scripts/performance-smoke.sh http://127.0.0.1:39476 cartolite-server-mqtt
+ - name: Terminal presentation and isolation
+ if: matrix.arch == 'amd64'
+ working-directory: web
+ env:
+ PLAYWRIGHT_HTML_OUTPUT_DIR: terminal-playwright-report
+ run: npm run test:e2e -- terminal.spec.ts --output terminal-test-results
+ - name: Preserve Terminal review screenshots
+ if: always() && matrix.arch == 'amd64'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
+ with:
+ name: cartolite-server-terminal-review
+ retention-days: 30
+ path: |
+ web/terminal-test-results/
+ web/terminal-playwright-report/
- name: UI audit and recovery regressions
if: matrix.arch == 'amd64'
working-directory: web
@@ -165,12 +180,6 @@ jobs:
path: |
web/ux-audit-test-results/
web/ux-audit-playwright-report/
- - name: Terminal presentation and isolation
- if: matrix.arch == 'amd64'
- working-directory: web
- env:
- PLAYWRIGHT_HTML_OUTPUT_DIR: terminal-playwright-report
- run: npm run test:e2e -- terminal.spec.ts --output terminal-test-results
- name: Worldwide and terrain regressions
if: matrix.arch == 'amd64'
working-directory: web
diff --git a/README.md b/README.md
index 8f46898..2c842b7 100644
--- a/README.md
+++ b/README.md
@@ -192,3 +192,20 @@ Layout, region choices and route visibility are remembered in this browser.
Open /terminal/ or choose **Terminal** beside Map and Netgraph (in the View picker on phones). This BBS-style geographic view uses live Braille/ASCII graphics with ANSI, amber or green phosphor. Find, inspection, layer toggles, route windows, Follow, optional 3D and opt-in sounds use the same live data and controls as Map. Set characters and cell size under **Display → Terminal display**. The character renderer is downloaded only when you open Terminal; no extra map key or service is required.
See [implementation and validation plan](docs/terminal-view-plan-2026-10-17.md).
+
+### Terminal appearance
+
+Open **Terminal → Display → Terminal options** for six retro presets, per-element
+colours, HSL sliders, console/pixel fonts and independent map brightness/live glow.
+Choose Braille, ASCII, pixel-block or cel-shaded geography; confirmed routes stay
+sharp above it. Live packets can use characters or smooth strokes. Settings are
+local, migrate older Terminal choices and synchronize across tabs without changing
+Map/Netgraph preferences or sound. The colour editor always keeps a readable reset.
+Fonts and licences are bundled in `web/public/fonts/terminal/`.
+
+Terminal defaults match the owner-selected screenshots: routes, live packets,
+heatmap, node/place labels, roads and buildings on; clusters, regions, Topo and
+3D off; 15-minute routes and 15% Topo shading. Fine ANSI/BBS Braille, Spectacle,
+Complete detail, High graphics, system motion, standard text and Neon routes.
+Layer/display settings are independent from Map/Netgraph, and saved Terminal
+customizations remain. Reset all display settings restores these Terminal defaults.
diff --git a/VERSION b/VERSION
index 7ada0d3..ac39a10 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.8.5
+0.9.0
diff --git a/compose.yml b/compose.yml
index 6a0b5a8..9643e52 100644
--- a/compose.yml
+++ b/compose.yml
@@ -2,7 +2,7 @@ name: cartolite-server
services:
cartolite:
- image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.8.5}
+ image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.9.0}
restart: unless-stopped
ports:
- "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080"
diff --git a/docs/releases/0.9.0.md b/docs/releases/0.9.0.md
new file mode 100644
index 0000000..9e7b4d1
--- /dev/null
+++ b/docs/releases/0.9.0.md
@@ -0,0 +1,27 @@
+# 0.9.0 — Make Terminal your own
+
+- Add Terminal options inside Display, with BBS, Amber, Green, CGA, EGA and Cel-shaded presets.
+- Adjust geography, node, packet, route, label and interface colours with a picker, hex value and hue/saturation/lightness sliders.
+- Choose system mono, Typewriter, VT323 console or Silkscreen pixel fonts, with map size, weight and interface scale controls.
+- Draw Braille, ASCII, pixel-block or cel-shaded maps. Keep routes sharp above the map and choose glowing characters or smooth live packets.
+- Adjust background brightness, place labels and live glow independently; keep the existing six route styles and advanced controls.
+- Preserve existing preferences and synchronize Terminal profiles across tabs. The editor retains readable colours and reset controls.
+
+Fonts are bundled locally under SIL OFL 1.1; no remote font service is used. No
+backend endpoints, traffic fields, dependencies, packet/audio timing or native APK
+changes. Builds, tests and screenshots run only in Actions. Release publication
+and deployment evidence will be recorded in the published release.
+
+Terminal defaults match the owner-selected screenshots: routes, live packets,
+heatmap, node/place labels, roads and buildings on; clusters, regions, Topo and
+3D off; 15-minute routes and 15% Topo shading. Fine ANSI/BBS Braille, Spectacle,
+Complete detail, High graphics, system motion, standard text and Neon routes.
+Layer/display settings are independent from Map/Netgraph, and saved Terminal
+customizations remain. Reset all display settings restores these Terminal defaults.
+
+Route visibility now updates the map scene and finishes its compositor handoff,
+preventing stale lines after hiding routes in Safari. Terrain graphics recovery
+uses stylesheet readiness rather than waiting for a tile reload to emit a new
+style event. Terminal keeps its active time window visible and honours Large
+text for map labels. The supplemental visual audit is tracked separately in the
+roadmap; broader inspector/overview changes are not claimed in this release.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 59abd71..ff9eb21 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,12 +1,12 @@
# CartoLite delivery roadmap
-Updated 17 October 2026. Current audit: [end-to-end UI/UX review](ui-ux-audit-2026-10-17.md).
+Updated 18 October 2026. Current audit: [end-to-end UI/UX review](ui-ux-audit-2026-10-17.md).
The [older roadmap](roadmap-archive-2026-09.md) is preserved as a dated record;
its Planned labels and build-size caps are not current instructions.
## Previously verified release baseline
-Canada **0.17.3**, Worldwide **0.8.4**, Canada Android **1.1.2**. Already shipped:
+Canada **0.17.4**, Worldwide **0.8.5**, Canada Android **1.1.2**. Already shipped:
shared appearance and six route presets; cinematic effects and quality/motion
controls; optional Map and Netgraph 3D; buildings/topography; scoped ten-second
Follow; region layout/dragging/filtering and live-only routes; compact phone
@@ -23,8 +23,8 @@ It must be reviewed against current main before integration, preserving the new
| Phase | Status | Deliverable | Exit condition |
| --- | --- | --- | --- |
| 1. Everyday navigation and recovery | Delivered | UI01–UI06/UI15: live search refresh, dialog Back/Escape, quiet dismissible map notices, actionable empty Netgraph, reachable Follow actions, stable Labs feedback and this reconciled roadmap. Released in Canada 0.17.3 / Worldwide 0.8.4. | Baseline reproduction; focused tests pass without weakening assertions; full Actions gates; reviewed paired PRs; exact images, qualified Canada deployment and Worldwide installs. |
-| 2. Controls, discovery and explanations | Implemented; [delivery status](https://github.com/n30nex/CartoLite-Server/releases/tag/v0.8.5) | UI07/UI08: relevant per-view controls (including a true High-quality override in Labs), consistent visible help, clearer active filters and route/live/observer-only explanations. Silent voice browsing and reliable immediate mute. | Keyboard/Back/focus, 320px/short landscape, enlarged text, sound opt-in and no duplicate feed/layout reset. |
-| 3. Animation and visual clarity | Planned | UI09/UI10: semantic Terminal network ink, dense-label priority, purposeful live emphasis, consistent ground/perspective behavior and measured sustained quality. Review each Labs scene independently. | Fixed-time synthetic GPU/Canvas/audio comparisons; all themes/presets; terrain/buildings/date-line/context loss; unchanged 4k-node/7k-route and responsiveness gates. |
+| 2. Controls, discovery and explanations | Delivered; [delivery status](https://github.com/n30nex/CartoLite-Server/releases/tag/v0.8.5) | UI07/UI08: relevant per-view controls (including a true High-quality override in Labs), consistent visible help, clearer active filters and route/live/observer-only explanations. Silent voice browsing and reliable immediate mute. | Keyboard/Back/focus, 320px/short landscape, enlarged text, sound opt-in and no duplicate feed/layout reset. |
+| 3. Animation and visual clarity | Active: Terminal customization | UI09/UI10: semantic Terminal network ink, dense-label priority, purposeful live emphasis, consistent ground/perspective behavior and measured sustained quality. Review each Labs scene independently. | Fixed-time synthetic GPU/Canvas/audio comparisons; all themes/presets; terrain/buildings/date-line/context loss; unchanged 4k-node/7k-route and responsiveness gates. |
| 4. Recovery and release hardening | Planned integration | UI11/UI12: reconcile the separate retained/freshness/clock/region-loading work; clear development-tool advisories through existing dependency PRs; extend browser/accessibility coverage. | Conservative topology/privacy; real reconnect/failure scenarios; exact native images and checkpoint-preserving anonymous installs. No unrelated branch resets or local builds. |
| 5. Optional additions | Planned | UI13/UI14: named local views, public-only share links, bounded role/recent/in-view Find filters, EN/FR Canada chrome and worldwide locale/units. Approved extra tile providers remain a separate configuration decision. | Demonstrate a useful task, reversible saved settings and explicit data/coverage limitations before broadening the UI. |
@@ -63,7 +63,7 @@ passed 224 frontend and 321 browser cases; its
[161 deployed browser checks](https://github.com/n30nex/CartoLite/actions/runs/39066754097)
passed without retries. Three live observations over 369.4 seconds confirmed
advancing traffic/checkpoints and zero drops/restarts after an encrypted backup
-and isolated restore. The exact tested image remains deployed.
+and isolated restore. That exact tested image was deployed and qualified.
Worldwide's [exact-main CI](https://github.com/n30nex/CartoLite-Server/actions/runs/39064086634)
passed 198 frontend and 273 browser cases, including native anonymous AMD64/ARM64
@@ -86,3 +86,39 @@ The new Find filters remain an optional addition; existing Find recovery is reta
The linked release record carries final validation and delivery status. This
versioned roadmap records the source implementation; publication alone is not
proof of a healthy deployment. The release notes distinguish these stages.
+
+## Next package: Terminal customization
+
+Target Canada 0.18.0 / Worldwide 0.9.0. Add a compact Terminal options editor
+inside Display with BBS, Amber, Green, CGA, EGA and Cel-shaded presets. Provide
+separate semantic colours for geography, traffic, nodes, labels and interface,
+colour-picker/hex/HSL sliders, independent map brightness, live glow and typography.
+Keep a readable reset/editor surface even for low-contrast custom palettes.
+
+Convert geography before drawing the existing network layers. Preserve solid
+route cores, source topology, hit geometry and packet/audio timing. Offer Braille,
+ASCII, pixel blocks or flat cel-shaded geography, and character or smooth packets.
+Keep bounded sampling, quality/reduced-motion behavior, fallback and context recovery.
+Fonts are bundled locally with their licences and only loaded by Terminal.
+
+Migrate legacy phosphor/glyph/grid preferences; save the new profile separately
+and synchronize Terminal tabs. Existing Map/Netgraph styles, layers and sound remain.
+Actions must cover all presets, arbitrary colour separation, mobile bounds, font
+loading, keyboard input, migration/reset, cross-tab behavior, unchanged live timing,
+context recovery and the existing scale/privacy/installation gates. Exact-image
+release and Canada qualification follow passing paired reviews. The remaining
+phase 3 Labs/dense-label/terrain audit and phases 4-5 remain planned.
+
+Owner-selected Terminal defaults (18 October): routes/live/heatmap/labels/roads/
+buildings on; clusters/regions/Topo/3D off; 15-minute routes, 15% Topo shading;
+BBS/ANSI fine Braille, Spectacle, Complete, High, system motion, standard text
+and Neon routes. Terminal layer/display settings now use separate storage from
+Map/Netgraph, while existing Terminal custom profiles and sound choices persist.
+
+## Supplemental visual audit
+
+The owner-supplied 15-page visual audit adds A01–A15, separate from this audit's
+UI identifiers. [The follow-up](visual-audit-followup-2026-10-18.md) prioritizes a
+measured selected-node/inspector layout and complete duration labels after the
+Terminal package, then scope handoff, overview/selection clarity and control copy.
+These findings remain planned until reproduced and verified on current code.
diff --git a/docs/visual-audit-followup-2026-10-18.md b/docs/visual-audit-followup-2026-10-18.md
new file mode 100644
index 0000000..0b70149
--- /dev/null
+++ b/docs/visual-audit-followup-2026-10-18.md
@@ -0,0 +1,52 @@
+# Supplemental visual-audit follow-up
+
+The owner supplied **CartoLite UI/UX Audit, 17 October 2026** (15-page PDF) on
+18 October. Its A01–A15 findings come from rendered Canada browser observations;
+they are distinct from UI01–UI15 in the repository's source audit. They do not
+establish backend/RF defects, clean-session defaults, hardware performance or
+full accessibility conformance. Reproduce the relevant states against current
+code with synthetic fixtures before marking a finding fixed. Do not commit the
+live examples or captures as test data.
+
+## Current Terminal package
+
+Canada 0.18.0 / Worldwide 0.9.0 address the visual-hierarchy portion of A11:
+geography is drawn separately from exact network strokes; colours, map brightness,
+fonts and live treatment are adjustable; labels retain an opaque backing and
+Large text support. The selected marker remains `@`, and stale markers can be
+quieter without dimming their names. A visible Terminal status names the route
+window or live-only/hidden state. This is not a claim that all A11 inspection,
+naming and handoff work is finished.
+
+The owner's later screenshots explicitly choose independent Terminal defaults:
+fine ANSI/BBS Braille, Spectacle, Complete, High, system motion, Standard and Neon;
+15-minute routes with the requested layers. These take precedence over the PDF's
+suggestion to share every preference. Map and Netgraph keep their saved settings.
+
+## Next focused packages
+
+1. **Keep the subject visible — A01, A02, A07a.** Measure the actual mobile
+ inspector, desktop summary/inspector rail and dock. Frame the selected node
+ inside the remaining canvas on selection or explicit recovery; preserve manual
+ panning. Add readable sheet states and a clear return to the map. Show complete
+ duration values at 320px and with Large text. Validate Map and Netgraph in both
+ editions; reuse the same framing for Terminal.
+2. **Explain investigation scope — A03, A08, A15.** Keep selected identity across
+ views and announce the destination's time/filter scope. Offer an explicit
+ same-window action where appropriate, preserving Terminal's chosen defaults.
+ Verify timestamp and retained-count meanings before changing labels. Keep the
+ query during no-result recovery; distinguish quiet, filtered, paused and
+ disconnected states.
+3. **Readable overview and primary selection — A04, A05.** Collision-safe region
+ names, stronger primary selection, quieter neighbours and a reachable node-type
+ key. Retain every eligible connection and honest drill-down. Any optional
+ aggregated summary must be labelled and reversible; never present representative
+ region-centre lines as confirmed RF links.
+4. **Consistent controls and outreach — A06, A07b, A09–A14.** Align panel/trigger
+ names and reset scopes, make camera recovery and Follow scope easier to reach,
+ use consistent Terminal naming, and clarify Labs keys, pause, sound and scene
+ rotation. Labs remains Canada-only. Review each change with before/after scenes.
+
+Use the existing Actions suites and synthetic scenes at desktop, 390px, 320px,
+short landscape and Large text. Preserve keyboard/Back behavior, opt-in sound,
+reduced motion, frame/route budgets, privacy and exact-image release checks.
diff --git a/scripts/write-terminal-page.mjs b/scripts/write-terminal-page.mjs
index 65abc9c..b8158b3 100644
--- a/scripts/write-terminal-page.mjs
+++ b/scripts/write-terminal-page.mjs
@@ -1,12 +1,20 @@
-import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
+import { mkdir, readFile, writeFile, unlink, rmdir } from 'node:fs/promises';
// Reuse the Map document and its exact asset graph. A second bundled Map entry
// would move MapLibre into shared chunks also downloaded by Netgraph.
const html = await readFile('dist/index.html', 'utf8');
-const assets = (await readdir('dist/assets')).filter(name => /^terminal-.*\.(?:js|css)$/.test(name));
-if (assets.filter(name => name.endsWith('.js')).length !== 1 || assets.filter(name => name.endsWith('.css')).length !== 1) throw new Error('Terminal entry assets missing or ambiguous');
-const script = ``;
-const style = ``;
+const manifest = JSON.parse(await readFile('dist/.vite/manifest.json','utf8'));
+const entry = manifest['src/terminal.ts'];
+if(!entry?.isEntry || !/^assets\/[\w.-]+\.js$/.test(entry.file))throw new Error('Terminal entry missing from build manifest');
+const visited=new Set(), css=new Set();
+function collect(chunk) {
+ for(const id of chunk.imports??[]) { if(visited.has(id))continue;visited.add(id);if(!manifest[id])throw new Error('Missing Terminal import');collect(manifest[id]); }
+ for(const path of chunk.css??[]) {if(!/^assets\/[\w.-]+\.css$/.test(path))throw new Error('Invalid Terminal CSS');if(!html.includes(`href="/${path}"`))css.add(path);}
+}
+collect(entry);
+if(!css.size)throw new Error('Terminal stylesheet missing');
+const script = ``;
+const style = [...css].map(path=>``).join('\n');
const terminal = html.replace('', '')
.replace('
', `\n${script}`)
.replace('', `${style}\n`)
@@ -16,3 +24,6 @@ const terminal = html.replace('', '({...((element as HTMLElement).dataset)}))); throw error; }
await expect(map).toHaveAttribute('data-rendered-route-segments', '7000');
+ if(viewPath === '/terminal/') {
+ // First-view timing above exercises the new High/Neon/unclustered defaults.
+ // Stage the same off-to-on route and clustered-to-individual interactions
+ // below, preserving every existing count and performance assertion.
+ await openMapOptions(page);await page.locator('#routes-button').click();await page.locator('#clusters-button').click();
+ await page.keyboard.press('Escape');await expect(map).toHaveAttribute('data-render-state','idle');
+ }
await installLongTaskObserver(page);
const heatmapButton = page.locator('#heatmap-button');
diff --git a/web/e2e/terminal.spec.ts b/web/e2e/terminal.spec.ts
index c673bb1..5c57415 100644
--- a/web/e2e/terminal.spec.ts
+++ b/web/e2e/terminal.spec.ts
@@ -2,6 +2,7 @@ import { expect, test, type Page } from '@playwright/test';
import { visualFixture, visualPacket } from './visualFixtures';
import { openMapOptions } from './mapControls';
import { openViewMenu } from './dockControls';
+import type { LiveMap } from '../src/map';
test('Terminal gives live packets a bounded glow while keeping labels and quiet modes clear', async ({page},info) => {
test.slow();
@@ -184,7 +185,7 @@ test('unavailable Terminal graphics falls back without stopping the feed', async
test('Map and Netgraph never download the optional Terminal renderer', async ({page}) => {
const terminalAssets: string[] = [];
- page.on('request', request => { if (/\/terminal-[^/]+\.(?:js|css)(?:\?|$)/.test(request.url())) terminalAssets.push(request.url()); });
+ page.on('request', request => { if (/\/terminal-[^/]+\.(?:js|css)(?:\?|$)|\/fonts\/terminal\//.test(request.url())) terminalAssets.push(request.url()); });
await visualFixture(page);
for (const path of ['/', '/netgraph/']) {
await page.goto(path);
@@ -193,6 +194,232 @@ test('Map and Netgraph never download the optional Terminal renderer', async ({p
expect(terminalAssets).toEqual([]);
});
+test('Terminal presets keep real routes above every drawing mode and load the selected fonts',async({page},info)=>{
+ test.slow();await visualFixture(page,10.2,true);await page.goto('/terminal/');
+ await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle');
+ const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message));
+ let sequence=0;
+ for(const [preset,mode,font] of [['ansi','0','mono'],['amber','0','mono'],['green','0','mono'],['cga','2','pixel'],['ega','1','vt323'],['cell','3','mono']]) {
+ await openMapOptions(page);await page.getByLabel('Terminal palette').selectOption(preset!);
+ await page.keyboard.press('Escape');
+ await expect(page.locator('#map')).toHaveAttribute('data-terminal-drawing',mode!);
+ await expect(page.locator('html')).toHaveAttribute('data-terminal-font',font!);
+ await expect(page.locator('#map')).toHaveAttribute('data-eligible-routes','1');
+ await expect(page.locator('.terminal-notice')).toBeHidden();
+ await expect.poll(()=>page.evaluate(()=>document.fonts.status)).toBe('loaded');
+ if(font==='pixel')expect(await page.evaluate(()=>document.fonts.check('16px Silkscreen'))).toBe(true);
+ if(font==='vt323')expect(await page.evaluate(()=>document.fonts.check('16px VT323'))).toBe(true);
+ await visualPacket(page,++sequence, 'Text');
+ await page.screenshot({path:info.outputPath(`preset-${preset}.png`)});
+ }
+ expect(errors).toEqual([]);
+});
+
+test('Terminal starts with the requested layers and Neon appearance without overwriting Map defaults',async({page},info)=>{
+ test.slow();await visualFixture(page,10.2,true);
+ await page.addInitScript(()=>{
+ if(sessionStorage.getItem('terminal-defaults-seeded'))return;
+ sessionStorage.setItem('terminal-defaults-seeded','true');
+ for(const prefix of ['cartolite','cartolite-server']) {
+ localStorage.removeItem(`${prefix}:terminal-ui:v1`);
+ localStorage.setItem(`${prefix}:display:v1`,JSON.stringify({preset:'dotted',quality:'economy'}));
+ }
+ });
+ await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle');
+ await openMapOptions(page);
+ for(const id of ['routes','live-packets','heatmap','node-labels','map-labels','roads','buildings'])await expect(page.locator(`#${id}-button`)).toHaveAttribute('aria-pressed','true');
+ for(const id of ['clusters','regions','hillshade','terrain'])if(await page.locator(`#${id}-button`).count())await expect(page.locator(`#${id}-button`)).toHaveAttribute('aria-pressed','false');
+ await expect(page.locator('#route-window')).toHaveValue('15m');await expect(page.locator('#terrain-relief')).toHaveValue('15');
+ for(const [label,value] of [['Terminal palette','ansi'],['Terminal characters','braille'],['Terminal cell size','10'],['Effects intensity','spectacle'],['Detail level','complete'],['Graphics quality','high'],['Motion preference','system'],['Text size','standard'],['Route style','neon']])await expect(page.getByLabel(label!,{exact:true})).toHaveValue(value!);
+ await page.screenshot({path:info.outputPath('terminal-defaults.png')});
+ await page.locator('#route-window').selectOption('1h');await page.getByLabel('Route style',{exact:true}).selectOption('dashed');
+ await page.keyboard.press('Escape');await page.goto('/');await openMapOptions(page);
+ await expect(page.locator('#route-window')).toHaveValue('24h');await expect(page.getByLabel('Graphics quality')).toHaveValue('economy');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('dotted');
+ await page.goto('/terminal/');await openMapOptions(page);
+ await expect(page.locator('#route-window')).toHaveValue('1h');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('dashed');
+ await page.getByRole('button',{name:'Reset all display settings',exact:true}).click();
+ await expect(page.locator('#route-window')).toHaveValue('15m');await expect(page.getByLabel('Route style',{exact:true})).toHaveValue('neon');
+});
+
+test('Terminal colour and typography editor preserves other views, survives refresh and synchronizes tabs',async({page,context},info)=>{
+ test.slow();await visualFixture(page,10.2,true);
+ await page.addInitScript(()=>{
+ if(!localStorage.getItem('cartolite-terminal-v2'))localStorage.setItem('cartolite-terminal-v1',JSON.stringify({palette:'amber',glyphs:'ascii',size:16}));
+ });
+ await page.goto('/terminal/?node=visual-a');
+ await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id','visual-a');
+ await expect(page.locator('html')).toHaveAttribute('data-terminal-palette','amber');
+ await page.keyboard.press('Escape');
+ const saved=await page.evaluate(()=>Object.fromEntries(Object.entries(localStorage).filter(([key])=>!key.startsWith('cartolite-terminal')&&!key.includes(':view:'))));
+ await openMapOptions(page);
+ await page.getByText('Typography',{exact:true}).click();
+ await expect(page.getByLabel('Terminal map font size')).toHaveValue('16');
+ await page.getByLabel('Terminal map font',{exact:true}).selectOption('vt323');
+ await page.getByLabel('Terminal interface font').selectOption('pixel');
+ await page.getByLabel('Terminal map font size').fill('18');
+ await page.getByLabel('Terminal interface scale').fill('1.35');
+ await page.getByText('Element colours',{exact:true}).click();
+ await page.getByLabel('Terminal colour element').selectOption('water');
+ await page.getByLabel('Terminal hue',{exact:true}).focus();await page.keyboard.press('ArrowRight');
+ await page.getByLabel('Terminal colour hex').fill('#403a77');
+ await page.getByLabel('Terminal colour element').selectOption('ink');
+ await page.getByLabel('Terminal colour hex').fill('#050b10');
+ // Even a deliberately invisible custom UI must leave the recovery editor legible.
+ expect(await page.locator('.terminal-settings').evaluate(el=>getComputedStyle(el).color)).toBe('rgb(213, 230, 223)');
+ await page.screenshot({path:info.outputPath('terminal-editor.png')});
+ const panel=(await page.locator('#layers-panel').boundingBox())!;
+ expect(panel.x).toBeGreaterThanOrEqual(0);expect(panel.x+panel.width).toBeLessThanOrEqual(page.viewportSize()!.width+1);
+ expect(await page.locator('.terminal-settings').evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true);
+ await page.keyboard.press('Escape');await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-terminal-font','vt323');
+ await expect.poll(()=>page.evaluate(()=>getComputedStyle(document.documentElement).getPropertyValue('--terminal-water').trim())).toBe('#403a77');
+ expect(await page.evaluate(()=>Object.fromEntries(Object.entries(localStorage).filter(([key])=>!key.startsWith('cartolite-terminal')&&!key.includes(':view:'))))).toEqual(saved);
+ const other=await context.newPage();await visualFixture(other,10.2,true);await other.goto('/terminal/');
+ await expect(other.locator('html')).toHaveAttribute('data-terminal-font','vt323');
+ await openMapOptions(page);await page.getByRole('button',{name:'Reset Terminal display',exact:true}).click();
+ await expect(other.locator('html')).toHaveAttribute('data-terminal-palette','ansi');
+ await expect(other.locator('html')).toHaveAttribute('data-terminal-font','mono');
+ await other.close();
+});
+
+test('Terminal custom traffic ink stays visible over geography without restarting packets or camera',async({page},info)=>{
+ test.slow();await visualFixture(page,10.2,true);
+ await page.addInitScript(()=>window.addEventListener('cartolite-map-ready',event=>Object.assign(window,{terminalHarness:(event as CustomEvent).detail}),{once:true}));
+ await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle');
+ await openMapOptions(page);await page.getByText('Element colours',{exact:true}).click();
+ await page.getByLabel('Terminal colour element').selectOption('text');await page.getByLabel('Terminal colour hex').fill('#67ff23');
+ await page.getByLabel('Terminal colour element').selectOption('water');await page.getByLabel('Terminal colour hex').fill('#5b183a');
+ await page.getByLabel('Terminal colour element').selectOption('roads');await page.getByLabel('Terminal colour hex').fill('#ff3600');
+ await page.keyboard.press('Escape');
+ await expect.poll(()=>greenRoutePixels(page)).toBeGreaterThan(20);
+ await expect.poll(async()=>{
+ const screenshot=await page.screenshot();
+ return page.evaluate(async image=>{
+ const img=new Image();img.src='data:image/png;base64,'+image;await img.decode();
+ const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height;
+ const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0);
+ const pixels=ctx.getImageData(0,0,img.width,img.height).data;let roads=0;
+ for(let i=0;i110&&pixels[i+1]!<70&&pixels[i+2]!<40)roads++;
+ return roads;
+ },screenshot.toString('base64'));
+ }).toBeGreaterThan(30);
+ const order=await page.evaluate(()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ const ids=view.map.getLayersOrder();return [ids.indexOf('terminal-graphics'),ids.indexOf('route-exact-webgl')];
+ });expect(order[0]).toBeLessThan(order[1]!);
+ await page.screenshot({path:info.outputPath('terminal-custom-ink.png')});
+ await openMapOptions(page);await page.locator('#routes-button').click();await page.keyboard.press('Escape');
+ await expect(page.locator('#map')).toHaveAttribute('data-routes-visible','false');
+ await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle');
+ try { await expect.poll(()=>greenRoutePixels(page)).toBeLessThan(10); }
+ catch(error) {
+ const diagnostic=await page.evaluate(async()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ const map=view.map,gl=map.getCanvas().getContext('webgl2')!;
+ const before={...map.getContainer().dataset};
+ const layers=map.getLayersOrder().filter(id=>id.startsWith('route')).map(id=>({id,visibility:map.getLayoutProperty(id,'visibility')}));
+ const gpuError=gl.getError();
+ await new Promise(resolve=>{map.once('render',()=>{map.once('render',()=>resolve());map.triggerRepaint();});map.triggerRepaint();});
+ return {before,after:{...map.getContainer().dataset},layers,gpuError};
+ });
+ await info.attach('hidden-route-diagnostic',{body:JSON.stringify({...diagnostic,pixelsAfterTwoFrames:await greenRoutePixels(page)}),contentType:'application/json'});
+ await page.screenshot({path:info.outputPath('hidden-route-after-two-frames.png')});
+ throw error;
+ }
+ await visualPacket(page,1,'Text');
+ const continuity=await page.evaluate(()=>{
+ const {view,animator}=(window as unknown as {terminalHarness:{view:LiveMap;animator:{activeRoutes:{started:number;duration:number}[]}}}).terminalHarness;
+ const before=animator.activeRoutes.map(route=>[route.started,route.duration]);const camera=[view.map.getCenter().toArray(),view.map.getZoom(),view.map.getPitch()];
+ const input=document.querySelector('[data-terminal="mapBrightness"]')!;
+ input.value='.3';input.dispatchEvent(new Event('input',{bubbles:true}));
+ return {before,after:animator.activeRoutes.map(route=>[route.started,route.duration]),camera,afterCamera:[view.map.getCenter().toArray(),view.map.getZoom(),view.map.getPitch()]};
+ });
+ expect(continuity.before.length).toBe(1);expect(continuity.after).toEqual(continuity.before);expect(continuity.afterCamera).toEqual(continuity.camera);
+ await expect.poll(async()=>Number(await page.locator('#terminal-packets').getAttribute('data-cells'))).toBeGreaterThan(0);
+ await page.screenshot({path:info.outputPath('terminal-custom-live.png')});
+});
+
+test('Terminal cel-shaded 3D retains its profile and routes through graphics-context recovery',async({page},info)=>{
+ test.skip(info.project.name!=='desktop','Actual context-loss injection is exercised on desktop Chromium; fallback/retry runs on every browser.');
+ test.slow();await visualFixture(page,14,true);
+ const errors:string[]=[];page.on('pageerror',error=>errors.push(error.message));
+ const warnings:string[]=[];page.on('console',message=>{if(['warning','error'].includes(message.type()))warnings.push(message.text());});
+ await page.addInitScript(()=>window.addEventListener('cartolite-map-ready',event=>Object.assign(window,{terminalHarness:(event as CustomEvent).detail}),{once:true}));
+ await page.goto('/terminal/');await expect(page.locator('#map')).toHaveAttribute('data-render-state','idle');
+ await openMapOptions(page);await page.getByLabel('Terminal palette').selectOption('cell');await page.keyboard.press('Escape');
+ await page.locator('#terrain-shortcut').click();
+ await expect(page.locator('#map')).toHaveAttribute('data-terrain3d','true');
+ await expect(page.locator('#map')).toHaveAttribute('data-camera-moving','false');
+ await page.evaluate(()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ view.map.jumpTo({bearing:25});
+ });
+ await terminalMapIdle(page);
+ const before=await page.screenshot({path:info.outputPath('terminal-3d-before-recovery.png')});
+ await page.evaluate(()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ const gl=view.map.getCanvas().getContext('webgl2')!,extension=gl.getExtension('WEBGL_lose_context');
+ if(!extension)throw Error('Context-loss fixture unavailable');
+ extension.loseContext();setTimeout(()=>extension.restoreContext(),120);
+ });
+ await expect(page.locator('#map')).toHaveAttribute('data-route-context-restores','1');
+ await expect(page.locator('#map')).toHaveAttribute('data-terminal-drawing','3');
+ await expect(page.locator('html')).toHaveAttribute('data-terminal-palette','cell');
+ await expect(page.locator('.terminal-notice')).toBeHidden();
+ await expect.poll(()=>page.evaluate(()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ const ids=view.map.getLayersOrder();return ids.indexOf('terminal-graphics')message.includes('INVALID_OPERATION'))).toEqual([]);
+ const difference=await page.evaluate(async images=>{
+ const samples:Uint8ClampedArray[]=[];
+ for(const image of images){
+ const img=new Image();img.src='data:image/png;base64,'+image;await img.decode();
+ const canvas=document.createElement('canvas');canvas.width=img.width;canvas.height=img.height;
+ const ctx=canvas.getContext('2d')!;ctx.drawImage(img,0,0);
+ samples.push(ctx.getImageData(0,0,img.width,img.height).data);
+ }
+ let total=0,count=0;
+ // Same stationary scene: ignore alpha and sample sparsely, allowing small
+ // label/antialias differences while rejecting a broken terrain framebuffer.
+ for(let i=0;i{
+ await page.evaluate(async()=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ if(!view.map.loaded())await new Promise(resolve=>view.map.once('idle',()=>resolve()));
+ });
+}
+
+async function greenRoutePixels(page: Page):Promise {
+ const png=await page.screenshot();
+ return page.evaluate(async base64=>{
+ const {view}=(window as unknown as {terminalHarness:{view:LiveMap}}).terminalHarness;
+ const a=view.map.project([-80.35,43.45]),b=view.map.project([-80.23,43.51]);
+ const image=new Image();image.src='data:image/png;base64,'+base64;await image.decode();
+ const canvas=document.createElement('canvas');canvas.width=image.width;canvas.height=image.height;
+ const ctx=canvas.getContext('2d')!;ctx.drawImage(image,0,0);
+ const pixels=ctx.getImageData(0,0,image.width,image.height).data;
+ const dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy,sx=image.width/innerWidth,sy=image.height/innerHeight;
+ // Sample the middle of the actual route. Endpoint labels may contain green
+ // emoji and must not be counted as retained network ink.
+ let count=0;for(let y=0;y.75||(px-t*dx)**2+(py-t*dy)**2>36)continue;
+ const i=(y*image.width+x)*4;if(pixels[i]!<140&&pixels[i+1]!>140&&pixels[i+2]!<100)count++;
+ }return count;
+ },png.toString('base64'));
+}
+
// Inspect actual pixels, not a renderer's own readiness flag. Only the ANSI
// character water has this bright blue in the synthetic scene's centre.
async function blueGlyphPixels(page: Page): Promise {
diff --git a/web/e2e/terrain-scale.spec.ts b/web/e2e/terrain-scale.spec.ts
index 1dd5714..fcf383c 100644
--- a/web/e2e/terrain-scale.spec.ts
+++ b/web/e2e/terrain-scale.spec.ts
@@ -5,7 +5,7 @@ import type { NodeV2, RouteV2, StateV2 } from '../src/types';
test.use({ trace: 'off', screenshot: 'off' });
-test('keeps close 3D inspection responsive with 7000 retained routes', async ({ page }, info) => {
+test('@terrain keeps close 3D inspection responsive with 7000 retained routes', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'desktop terrain scale gate');
await visualFixture(page, 15);
const now = Date.now();
@@ -43,7 +43,7 @@ test('keeps close 3D inspection responsive with 7000 retained routes', async ({
await page.screenshot({ path: info.outputPath('terrain-scale-close.png') });
});
-test('keeps controls responsive while preparing a dense 7000-route terrain mesh', async ({ page }, info) => {
+test('@terrain keeps controls responsive while preparing a dense 7000-route terrain mesh', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'desktop dense terrain gate');
await visualFixture(page, 13);
const now = Date.now();
diff --git a/web/e2e/ui-audit.spec.ts b/web/e2e/ui-audit.spec.ts
index 9cd56cd..8fee779 100644
--- a/web/e2e/ui-audit.spec.ts
+++ b/web/e2e/ui-audit.spec.ts
@@ -195,9 +195,10 @@ test.describe('UI audit controls', () => {
await page.goto('/terminal/'); await expect(page.locator('#terminal-map')).toBeVisible();
await openDisplay(page);
await expect(page.locator('#basemap-style')).toBeHidden(); await expect(page.locator('#interface-theme')).toBeHidden();
- for (const [palette, colour] of [['ansi', 'rgb(121, 248, 196)'], ['green', 'rgb(141, 255, 176)'], ['amber', 'rgb(255, 209, 138)']]) {
+ for (const [palette, colour] of [['ansi', 'rgb(114, 255, 207)'], ['green', 'rgb(141, 255, 176)'], ['amber', 'rgb(255, 209, 138)']]) {
await page.getByLabel('Terminal palette').selectOption(palette!);
- await expect(page.locator('.terminal-settings legend')).toHaveCSS('color', colour!);
+ // The recovery editor keeps fixed readable colours; the route preview follows traffic ink.
+ await expect(page.locator('.terminal-settings legend')).toHaveCSS('color', 'rgb(121, 248, 196)');
await expect(page.locator('.route-preview')).toHaveCSS('color', colour!);
await expect(page.locator('.route-preview')).toHaveCSS('background-color', 'rgb(0, 0, 0)');
}
diff --git a/web/e2e/visualFixtures.ts b/web/e2e/visualFixtures.ts
index 94b5631..f6c17b6 100644
--- a/web/e2e/visualFixtures.ts
+++ b/web/e2e/visualFixtures.ts
@@ -24,7 +24,7 @@ export async function visualFixture(page: Page, zoom = 10.2, detail = false): Pr
await page.route('**/api/config', (route) => route.fulfill({ json: { schemaVersion: 1, basemap: { provider: 'openfreemap' } } }));
await page.addInitScript(({ center, zoom, detail }) => {
const ui = { routes: true, heatmap: false, clusters: false, hillshade: false, terrain3D: false, buildings: false, mapLabels: detail, nodeLabels: detail, livePackets: true, routeWindow: '24h' };
- for (const key of ['cartolite:ui:v1', 'cartolite-server:ui:v1']) if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(ui));
+ for (const key of ['cartolite:ui:v1', 'cartolite-server:ui:v1','cartolite:terminal-ui:v1','cartolite-server:terminal-ui:v1']) if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(ui));
for (const prefix of ['cartolite:view:v3', 'cartolite-server:view:v1']) for (const kind of ['desktop', 'mobile']) {
if (!localStorage.getItem(`${prefix}:${kind}`)) localStorage.setItem(`${prefix}:${kind}`, JSON.stringify({ center, zoom }));
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 61b408c..398f972 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "cartolite-server-web",
- "version": "0.8.5",
+ "version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cartolite-server-web",
- "version": "0.8.5",
+ "version": "0.9.0",
"dependencies": {
"maplibre-gl": "6.4.1"
},
diff --git a/web/package.json b/web/package.json
index cd1ed7f..9513c44 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,6 +1,6 @@
{
"name": "cartolite-server-web",
- "version": "0.8.5",
+ "version": "0.9.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/web/public/fonts/terminal/README.txt b/web/public/fonts/terminal/README.txt
new file mode 100644
index 0000000..6544e44
--- /dev/null
+++ b/web/public/fonts/terminal/README.txt
@@ -0,0 +1,10 @@
+CartoLite Terminal fonts
+
+VT323 - Copyright 2011 The VT323 Project Authors (peter.hull@oikoi.com).
+Silkscreen - Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen).
+
+Unmodified fonts under SIL Open Font License 1.1, bundled with their complete licences:
+vt323-OFL.txt and silkscreen-OFL.txt in this directory.
+Source: https://github.com/google/fonts/tree/47d54ca1ebb14fd864695f1d39dc734f39017c33/ofl
+Only Terminal requests these fonts. No remote font service or tracking is used.
+System mono and Typewriter use installed browser fonts with a monospace fallback.
diff --git a/web/public/fonts/terminal/Silkscreen-Bold.ttf b/web/public/fonts/terminal/Silkscreen-Bold.ttf
new file mode 100644
index 0000000..6771252
Binary files /dev/null and b/web/public/fonts/terminal/Silkscreen-Bold.ttf differ
diff --git a/web/public/fonts/terminal/Silkscreen-Regular.ttf b/web/public/fonts/terminal/Silkscreen-Regular.ttf
new file mode 100644
index 0000000..8caff81
Binary files /dev/null and b/web/public/fonts/terminal/Silkscreen-Regular.ttf differ
diff --git a/web/public/fonts/terminal/VT323-Regular.ttf b/web/public/fonts/terminal/VT323-Regular.ttf
new file mode 100644
index 0000000..afa6909
Binary files /dev/null and b/web/public/fonts/terminal/VT323-Regular.ttf differ
diff --git a/web/public/fonts/terminal/silkscreen-OFL.txt b/web/public/fonts/terminal/silkscreen-OFL.txt
new file mode 100644
index 0000000..d26b0ed
--- /dev/null
+++ b/web/public/fonts/terminal/silkscreen-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/public/fonts/terminal/vt323-OFL.txt b/web/public/fonts/terminal/vt323-OFL.txt
new file mode 100644
index 0000000..bcb092f
--- /dev/null
+++ b/web/public/fonts/terminal/vt323-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011, The VT323 Project Authors (peter.hull@oikoi.com)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/src/displayControls.ts b/web/src/displayControls.ts
index 466809c..62d43dd 100644
--- a/web/src/displayControls.ts
+++ b/web/src/displayControls.ts
@@ -1,4 +1,5 @@
import './display.css';
+import { TERMINAL_PAGE } from './preferences';
import './cinematic.css';
import { DEFAULT_DISPLAY, DISPLAY_EVENT, ROUTE_PRESETS, displayPreferences, initializeDisplay, updateDisplay, type DisplayPreferences } from './displayPreferences';
@@ -25,7 +26,7 @@ export function mountDisplayControls(parent: HTMLElement, surface: 'map' | 'netg
] as const).map(([key, label, min, max, step, unit]) => ``).join('')}
` : ''}
-
${surface === 'labs' ? 'Effects, motion, graphics, text and interface settings are shared. Each experiment keeps its own artistic setting; route styling stays in Map, Netgraph and Terminal.' : 'Shared across Map, Netgraph and Terminal in this browser. Route styling changes appearance, not connections or packet timing.'}
`;
+
${surface === 'labs' ? 'Effects, motion, graphics, text and interface settings are shared. Each experiment keeps its own artistic setting; route styling stays in Map, Netgraph and Terminal.' : (TERMINAL_PAGE ? 'Terminal saves its own display settings. Map and Netgraph keep their choices. Styling never changes connections or packet timing.' : 'Shared across Map and Netgraph in this browser. Terminal saves its own display settings. Styling never changes connections or packet timing.')}