feat: the boards stop being three products
The owner asked twice why there are still three separate boards. The honest answer, and what this round executes: **it feels like three boards, but not because the scale jumps 20x — because the three packs draw three different Californias, and the loudest difference is that the mountains are four times taller on one of them.** **THE 20x HORIZONTAL SCALE JUMP IS INVISIBLE**, and measuring that collapsed the cost of this whole round. `World.project` is a uniform scale in x/z with no vertical term, and a uniform scale leaves a perspective image identical — so a camera carried across the seam on matched true-metre offsets draws a pixel-identical horizontal frame. 1,919 -> 94 m/unit costs nothing to look at. Rescaling was never the problem. A boot card, a tab strip and a 4.17x vertical deflation were. **THE PAUSE WAS MOSTLY FAKE.** A switch covered the screen for 1,715 ms but only 608 ms blocked the main thread; the page drew 46 of 69 frames with nothing to show, because the outgoing board had already been disposed. `mountCity` now retains it: the incoming board builds BEHIND a live, interactive picture, and `stage.setScene` fires only on completion. Measured across all six directions, three runs each — boot card yes -> **no**, opaque cover 726-1,415 ms -> **0**, blank frames 21-46 -> **exactly 1**, wall clock down 12-29%, blocked main thread down 15-47%. A return to a board already seen links **zero** shader programs and blocks **zero** milliseconds: 298-312 ms of camera flight where it was ~1,600 ms behind a card. Disposal had been throwing away the shader cache too — linkProgram ran 38, 59, 78, 109, 127 across five mounts and never reused one. **The transition is a fog dip, not a crossfade**, through the `setAerialFog` seam built last round. Every both-boards-live crossfade breaks a budget — ca+sf is 2,640,307 triangles against bay-area's 2,600,000 cap — and a fade never lands inside the harness's sample window, which is the "a cap you do not measure is a cap you do not have" failure this repo already argues against. The dip costs zero triangles and zero draw calls, and it hides the 4.17x deflation, the 4,025 m projection disagreement and the vanishing 2 km freeway symbols at once, because all three happen at maximum obscuration. It is also diegetic: a descent through haze. The first dip was wrong and the photograph caught it: collapsing to 6% of board SPAN turned the whole night frame into one flat field — the exact "turns the map off" failure the risk list named. Re-anchored to 70% of camera STAND-OFF, so the coastline survives and only the relief melts. **One ladder, one places list.** 26 authored chapters become 24 rungs sorted descending by STAND-OFF, not altitude — by altitude they interleave badly and altitude cannot tell a low oblique from a high plan. The three-board tab strip is off by default; the left column is now one scrolling list of all 24 rungs under three region headings that does not change when the board does. Only which row is lit changes. Label collisions are resolved in the ladder and never in a pack, so the 29 index-aimed capture guards are untouched. The minimap stops turning through 90 degrees between boards: every board is pinned to a rectangle with California's proportions. **SF and SoCal are not regressed**, and that was the acceptance that mattered: 95.9-98.8% of board pixels are delta-0 against a baseline hash-verified identical to what the live site serves, and every one of the 34-70 surviving pixels per frame is an aircraft or a hull. **A real defect found only by photograph:** `minimap.setMarkers()` had zero call sites. Every marker on every board was gone — the LA studio's door dot, the Bay Area's eight company markers — dropped when the minimap went per-board. Typecheck, tests, budgets and the console were all green with that bug in. Also fixed: two capture presets that lied. `look.mjs`'s `glyph-la` and `glyph-sf` claimed California chapter closeups and returned SoCal and Bay Area frames, because they aimed by chapter index and the indices had moved. Aiming is now by identity, with a guard test. NOT SHIPPED, DELIBERATELY: the pack merge. At Bay density it is 34.04M triangles, 13x the highest budget — dead, not a trade. At SoCal density it is 1.99M and fits today, and the price is San Francisco rendering at 164 m lots instead of 40 m, i.e. SF looking the way SoCal looks now. SF and SoCal carry every marketing still on the site. That is the owner's decision and it is worthless as an argument and decisive as a photograph, so it ships as a measurement artifact with a side-by-side still and is wired into nothing. The four data reconciliations that would make one world honest — one exaggeration rule, roads in metres, one projection centre, one coastline convention — are behind TERA_RECONCILE, default OFF. Tests 1,570 -> 1,651, server 295. All ten budget cells pass, no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+135
-4
@@ -43,7 +43,7 @@ import type { DeviceDeclaration } from "../devices/types.ts";
|
||||
import type { ControlMode } from "../play/controlMode.ts";
|
||||
import { formatAircraftDetail, formatPlayHud } from "./hud.ts";
|
||||
import type { AircraftDetailInput, AircraftDetailView, PlayHudView, PlayTelemetry } from "./hud.ts";
|
||||
import { railHints } from "./shortcuts.ts";
|
||||
import { railHints, railLabel } from "./shortcuts.ts";
|
||||
import { BREAKPOINT } from "./tokens.ts";
|
||||
|
||||
// ---- Inputs ---------------------------------------------------------------
|
||||
@@ -122,6 +122,32 @@ export interface ChromeView {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One rung of the places list: a chapter on some board, named so it can be told
|
||||
* apart from the chapters on the other two.
|
||||
*
|
||||
* A separate shape from `ChromeView` on purpose. A view is a row in the *current*
|
||||
* board's tour and is addressed by its position in that list — which is the one
|
||||
* thing about a chapter this round may not move, because the capture guards aim
|
||||
* that way. A place is addressed by `key`, carries the board it is on, and is
|
||||
* ordered by how far the camera stands back. Two different identities, two
|
||||
* types, and no chance of one silently standing in for the other.
|
||||
*/
|
||||
export interface ChromePlace {
|
||||
/** `board:chapter`. Unique across the whole ladder. */
|
||||
key: string;
|
||||
/** The chapter id on its own board. */
|
||||
id: string;
|
||||
board: string;
|
||||
/** The heading this rung sits under: a region, never a board. */
|
||||
region: string;
|
||||
/** Unique across the ladder — the two "Whole Board" rungs are named here. */
|
||||
label: string;
|
||||
/** True metres from the camera to the target at this rung's authored pose. */
|
||||
standoffM: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the detail card is showing.
|
||||
*
|
||||
@@ -196,6 +222,20 @@ export interface ChromeInputs {
|
||||
presenceVisible?: boolean;
|
||||
/** The board strip's entries: cities outside a building, offices inside one. */
|
||||
boards?: readonly ChromeBoardTab[];
|
||||
/**
|
||||
* Whether the board strip is *drawn*. The list above is built either way.
|
||||
*
|
||||
* The two are separate because the strip has a second reader: the harnesses
|
||||
* reach a board with `document.querySelector('[data-board=…]').click()`, and a
|
||||
* DOM click does not care whether an element is displayed. Emptying the list
|
||||
* would take the control away from them as well as from the visitor; hiding it
|
||||
* takes it away from the visitor only, which is what this round is for.
|
||||
*/
|
||||
boardsVisible?: boolean;
|
||||
/** The ladder, in the order the column prints it. Empty hides the section. */
|
||||
places?: readonly ChromePlace[];
|
||||
/** The rung the camera is standing on, by `key`. */
|
||||
activePlaceKey?: string | null;
|
||||
/** The chapter list: city chapters outside, authored viewpoints inside. */
|
||||
views?: readonly ChromeView[];
|
||||
activeViewId?: string | null;
|
||||
@@ -255,6 +295,28 @@ export interface ChromeViewRow {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ChromePlaceRow {
|
||||
key: string;
|
||||
id: string;
|
||||
board: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
/** The region heading to print **above** this row, or `null` to print none. */
|
||||
region: string | null;
|
||||
/**
|
||||
* The digit key that reaches this rung, or `null` past the ninth.
|
||||
*
|
||||
* The number **is** the key, which is why it stops at nine rather than
|
||||
* counting to twenty-four: a printed ordinal that no keystroke matches is a
|
||||
* label pretending to be an affordance. The chapter list gets away with
|
||||
* numbering everything because a pack's numbers are authored captions; the
|
||||
* ladder's are not.
|
||||
*/
|
||||
key9: string | null;
|
||||
/** "1,551 km" / "3.9 km". Formatted here so the applier has no decisions left. */
|
||||
standoff: string;
|
||||
}
|
||||
|
||||
/** The detail card, resolved. `null` when nothing is picked. */
|
||||
export type ChromeDetailView =
|
||||
| { kind: "text"; text: string }
|
||||
@@ -307,8 +369,12 @@ export interface ChromeState {
|
||||
* consistent frame of chrome.
|
||||
*/
|
||||
deviceDeclarations: readonly DeviceDeclaration[];
|
||||
/** Whether the board strip is worth drawing at all. See `ChromeInputs.boards`. */
|
||||
boardsVisible: boolean;
|
||||
chaptersVisible: boolean;
|
||||
views: readonly ChromeViewRow[];
|
||||
placesVisible: boolean;
|
||||
places: readonly ChromePlaceRow[];
|
||||
blurbVisible: boolean;
|
||||
blurbText: string;
|
||||
|
||||
@@ -478,6 +544,21 @@ export function seedPlanOpen(_width: number): boolean {
|
||||
|
||||
// ---- The function ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A stand-off as a person reads it, from 3.9 km to 1,551 km.
|
||||
*
|
||||
* Kilometres everywhere, because the ladder spans four orders of magnitude and
|
||||
* switching units partway down a column of numbers turns a scale into a puzzle.
|
||||
* One decimal below 10 km, where the difference between 3.9 and 4.3 is two
|
||||
* different neighbourhoods; none above it, where it is noise.
|
||||
*/
|
||||
function formatStandoff(metres: number): string {
|
||||
if (!Number.isFinite(metres) || metres <= 0) return "";
|
||||
const km = metres / 1000;
|
||||
if (km < 10) return `${km.toFixed(1)} km`;
|
||||
return `${Math.round(km).toLocaleString("en-US")} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the whole interface for one instant. Pure, total, and cheap enough to
|
||||
* call on every frame if the caller wants to.
|
||||
@@ -578,8 +659,10 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
|
||||
keys: entry.keys,
|
||||
// `rail`, not `meaning`: the sheet gets the sentence, the rail gets the
|
||||
// glance. See the field's note in `shortcuts.ts` for what reusing the
|
||||
// sentence here actually looked like on screen.
|
||||
label: entry.rail ?? entry.meaning,
|
||||
// sentence here actually looked like on screen. `railLabel` is what makes
|
||||
// the digits read "Places" on a board and "Viewpoints" in a room without
|
||||
// binding the same nine keys twice.
|
||||
label: railLabel(entry, inside),
|
||||
}));
|
||||
|
||||
// ---- The honesty line ---------------------------------------------------
|
||||
@@ -654,7 +737,45 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
|
||||
label: view.shortLabel,
|
||||
active: view.id === inputs.activeViewId,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The ladder, with a heading printed only where the region actually changes.
|
||||
*
|
||||
* The caller hands these over already ordered — grouping is a fact about the
|
||||
* ladder and lives in `engine/ladder.ts`, next to the measurement that says a
|
||||
* globally interleaved list would print nine headings for three regions. All
|
||||
* this does is decide which rows carry one.
|
||||
*/
|
||||
const placesInput = inputs.places ?? [];
|
||||
let lastRegion: string | null = null;
|
||||
const places: readonly ChromePlaceRow[] = placesInput.map((place, i) => {
|
||||
const region = place.region === lastRegion ? null : place.region;
|
||||
lastRegion = place.region;
|
||||
return {
|
||||
key: place.key,
|
||||
id: place.id,
|
||||
board: place.board,
|
||||
label: place.label,
|
||||
active: place.key === inputs.activePlaceKey,
|
||||
region,
|
||||
key9: i < 9 ? String(i + 1) : null,
|
||||
standoff: formatStandoff(place.standoffM),
|
||||
};
|
||||
});
|
||||
|
||||
const activeView = (inputs.views ?? []).find((view) => view.id === inputs.activeViewId);
|
||||
/**
|
||||
* The sentence under the list, and it stays pinned to the **chapter** rather
|
||||
* than to the lit rung.
|
||||
*
|
||||
* They are usually the same sentence and deliberately not always. The lit rung
|
||||
* is an altimeter — it follows the camera, so dragging away from a chapter
|
||||
* moves it — and the blurb is a caption for the thing somebody *chose*.
|
||||
* `scripts/brand-assets/shots.mjs` depends on exactly that: several shots pick
|
||||
* a chapter so that "the blurb left in the panel is a caption inside the
|
||||
* frame", and then aim the camera somewhere else through the plan view. A
|
||||
* blurb that followed the camera would silently recaption those stills.
|
||||
*/
|
||||
const blurbText = activeView?.description ?? "";
|
||||
|
||||
const detailInput = inputs.detail ?? null;
|
||||
@@ -744,8 +865,18 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
|
||||
inputs.devices.length === 1 ? "Studio hardware →" : `Studio hardware · ${inputs.devices.length} →`,
|
||||
deviceDeclarations: inputs.devices,
|
||||
|
||||
chaptersVisible: views.length > 0,
|
||||
boardsVisible: boards.length > 0 && (inputs.boardsVisible ?? true),
|
||||
/**
|
||||
* Outside a building the chapter list stays in the document and out of
|
||||
* sight: the places list is the same journey said better, and the guards
|
||||
* that reach `#chapters .chapter` do it with a DOM `.click()`, which works
|
||||
* on a `hidden` element. Inside one it is the viewpoint list and is the
|
||||
* control.
|
||||
*/
|
||||
chaptersVisible: views.length > 0 && (inside || places.length === 0),
|
||||
views,
|
||||
placesVisible: !inside && places.length > 0,
|
||||
places,
|
||||
// On a phone the panel is a bottom sheet with a thumb-sized list in it; a
|
||||
// paragraph of description under that list pushes the door off the screen.
|
||||
blurbVisible: !phone && blurbText !== "",
|
||||
|
||||
+103
@@ -62,6 +62,15 @@ import type { ShortcutSheetHandles } from "./shortcuts.ts";
|
||||
export interface ChromeHandlers {
|
||||
onSelectBoard?(id: string): void;
|
||||
onSelectView?(id: string, index: number): void;
|
||||
/**
|
||||
* A rung of the places list, by its ladder key.
|
||||
*
|
||||
* By key and not by index, unlike `onSelectView` — a place is addressed by
|
||||
* identity because it may be on a board that is not the one on screen, and
|
||||
* because the list it lives in is ordered by stand-off rather than by the
|
||||
* order a pack happened to author its chapters in.
|
||||
*/
|
||||
onSelectPlace?(key: string): void;
|
||||
onEnter?(): void;
|
||||
onWalk?(): void;
|
||||
onFly?(): void;
|
||||
@@ -183,7 +192,12 @@ export function mountChrome(
|
||||
const officeInvite = find("office-invite");
|
||||
const officeNote = find("office-note");
|
||||
const chapterNav = find("chapters");
|
||||
const chapterSection = find("chapters-section");
|
||||
const boardSection = find("boards-section");
|
||||
const placeNav = find("places");
|
||||
const placeSection = find("places-section");
|
||||
const blurb = find("blurb");
|
||||
const blurbSection = find("blurb-section");
|
||||
|
||||
const tier = find("tier");
|
||||
const tierLabel = find("tier-label");
|
||||
@@ -225,6 +239,9 @@ export function mountChrome(
|
||||
let latest: ChromeState | null = null;
|
||||
let boardSignature = "";
|
||||
let viewSignature = "";
|
||||
let placeSignature = "";
|
||||
/** The rung that was last scrolled into view, so a steady camera does not scroll. */
|
||||
let scrolledPlaceKey = "";
|
||||
let deviceSignature = "";
|
||||
/**
|
||||
* Four more signatures, for the four rebuilds that are not lists of buttons.
|
||||
@@ -307,6 +324,13 @@ export function mountChrome(
|
||||
if (id !== null && id !== undefined && index >= 0) handlers.onSelectView?.(id, index);
|
||||
});
|
||||
|
||||
on(placeNav, "click", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target !== null && typeof target === "object" && "closest" in target)) return;
|
||||
const key = (target as Element).closest("[data-place]")?.getAttribute("data-place");
|
||||
if (key !== null && key !== undefined) handlers.onSelectPlace?.(key);
|
||||
});
|
||||
|
||||
// ---- The touch action pad ----------------------------------------------
|
||||
//
|
||||
// Held buttons are bound on the pointer rather than on click, because a click
|
||||
@@ -517,6 +541,84 @@ export function mountChrome(
|
||||
setHidden(officeNote, !state.officeNoteVisible);
|
||||
setText(officeNote, state.officeNoteText);
|
||||
|
||||
setHidden(boardSection, !state.boardsVisible);
|
||||
|
||||
/**
|
||||
* The places list, rebuilt only when a row's identity or its highlight moves.
|
||||
*
|
||||
* The highlight is in the signature deliberately: it is what makes the list
|
||||
* an altimeter, and it changes as the camera descends rather than only when
|
||||
* somebody clicks. Everything else about a row is a pure function of the
|
||||
* ladder, which is built once at module load, so in the steady state this
|
||||
* comparison is a string equality and nothing else.
|
||||
*/
|
||||
setHidden(placeSection, !state.placesVisible);
|
||||
const nextPlaceSignature = state.places
|
||||
.map((place) => `${place.key}:${place.region ?? ""}:${place.key9 ?? ""}:${place.active}`)
|
||||
.join("|");
|
||||
if (placeNav !== null && nextPlaceSignature !== placeSignature) {
|
||||
placeSignature = nextPlaceSignature;
|
||||
const rows: Node[] = [];
|
||||
for (const place of state.places) {
|
||||
if (place.region !== null) {
|
||||
const heading = doc.createElement("p");
|
||||
heading.className = "places__region";
|
||||
heading.textContent = place.region;
|
||||
rows.push(heading);
|
||||
}
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "place";
|
||||
// Two attributes, because the two questions are different: which rung
|
||||
// this is, and which board it would take you to. The second is what lets
|
||||
// a stylesheet or a test tell a cross-board rung from a local one
|
||||
// without parsing the first.
|
||||
button.setAttribute("data-place", place.key);
|
||||
button.setAttribute("data-place-board", place.board);
|
||||
button.setAttribute("aria-pressed", String(place.active));
|
||||
const name = doc.createElement("span");
|
||||
name.className = "place__name";
|
||||
name.textContent = place.label;
|
||||
const standoff = doc.createElement("span");
|
||||
standoff.className = "place__standoff";
|
||||
standoff.textContent = place.standoff;
|
||||
if (place.key9 !== null) {
|
||||
const key = doc.createElement("span");
|
||||
key.className = "num";
|
||||
key.textContent = place.key9;
|
||||
button.append(key);
|
||||
}
|
||||
button.append(name, standoff);
|
||||
rows.push(button);
|
||||
}
|
||||
placeNav.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the lit rung in view as the camera descends.
|
||||
*
|
||||
* Guarded on the key rather than run every apply, because `scrollIntoView`
|
||||
* on an element that is already in view still costs a layout — and this
|
||||
* runs from a once-a-second poll. `block: "nearest"` scrolls the list and
|
||||
* never the page: the panel is a column on a desktop and a bottom sheet on
|
||||
* a phone, and a rung dragging the whole document sideways is the failure
|
||||
* that arrangement is most prone to.
|
||||
*/
|
||||
const litPlace = state.places.find((place) => place.active) ?? null;
|
||||
if (placeNav !== null && litPlace !== null && litPlace.key !== scrolledPlaceKey) {
|
||||
scrolledPlaceKey = litPlace.key;
|
||||
// Walked rather than selected. A `[data-place="…"]` selector would need
|
||||
// `CSS.escape` for a key that is caller-supplied text, and `CSS` is a
|
||||
// browser global this module's tests do not have — the whole point of the
|
||||
// `FakeElement` harness in `ui/mount.test.ts` is that this file runs
|
||||
// without a DOM. Twenty-four children is a walk nobody can measure.
|
||||
const row = [...placeNav.children].find(
|
||||
(child) => child.getAttribute?.("data-place") === litPlace.key,
|
||||
);
|
||||
(row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" });
|
||||
}
|
||||
|
||||
setHidden(chapterSection, !state.chaptersVisible);
|
||||
setHidden(chapterNav, !state.chaptersVisible);
|
||||
const nextViewSignature = state.views
|
||||
.map((view) => `${view.id}:${view.number}:${view.label}:${view.active}`)
|
||||
@@ -541,6 +643,7 @@ export function mountChrome(
|
||||
}),
|
||||
);
|
||||
}
|
||||
setHidden(blurbSection, !state.blurbVisible);
|
||||
setHidden(blurb, !state.blurbVisible);
|
||||
setText(blurb, state.blurbText);
|
||||
|
||||
|
||||
+41
-5
@@ -110,6 +110,17 @@ export interface Shortcut {
|
||||
* `railHints` only ever picks from a fixed set of ids.
|
||||
*/
|
||||
rail?: string;
|
||||
/**
|
||||
* The rail word to use **inside a studio**, when the same binding means
|
||||
* something else there. Falls back to `rail`.
|
||||
*
|
||||
* One key, one row in the `?` sheet, two true glances. `ui/shortcuts.test.ts`
|
||||
* asserts that no key is bound to two different meanings — it is the check
|
||||
* that caught `G` being both the crow's glide and godmode's drawer — so a
|
||||
* second entry for the digits is not available and would be wrong anyway:
|
||||
* `1`-`9` is one binding whose list changes with where you are standing.
|
||||
*/
|
||||
railOffice?: string;
|
||||
section: ShortcutSection;
|
||||
scope: ShortcutScope;
|
||||
/**
|
||||
@@ -251,19 +262,38 @@ export const KEYMAP: readonly Shortcut[] = [
|
||||
{
|
||||
id: "board",
|
||||
keys: ["[", "]"],
|
||||
meaning: "Previous / next board — California, the Bay Area, Southern California",
|
||||
/*
|
||||
* It used to read "Previous / next board — California, the Bay Area,
|
||||
* Southern California", which stopped being a true sentence the moment
|
||||
* boards stopped being modes. There is one list now and it descends: the
|
||||
* state at 1,551 km of stand-off down to a street in Mission Bay at 3.9,
|
||||
* with the Southland and the Bay in between. Stepping it crosses from one
|
||||
* board to the next wherever the list does, which is the whole point.
|
||||
*/
|
||||
meaning: "Up / down the list of places — the state, the Southland, the Bay",
|
||||
section: "world",
|
||||
scope: "city",
|
||||
onScreen: "the board tabs in the left column",
|
||||
onScreen: "the Places list in the left column",
|
||||
},
|
||||
{
|
||||
id: "chapters",
|
||||
keys: ["1", "…", "9"],
|
||||
meaning: "Fly to a chapter of the tour, or to a viewpoint inside a studio",
|
||||
rail: "Chapters",
|
||||
meaning: "Fly to one of the first nine places, or to a viewpoint inside a studio",
|
||||
rail: "Places",
|
||||
/*
|
||||
* One binding, two true words.
|
||||
*
|
||||
* A second KEYMAP entry was the obvious way to say "Viewpoints" in a room
|
||||
* and `ui/shortcuts.test.ts` refused it, correctly: it asserts that no key
|
||||
* is bound to two different meanings, which is the check that caught `G`
|
||||
* being both the crow's glide and godmode's drawer. The keys here are the
|
||||
* *same* binding said in two places, so the row stays one row and only the
|
||||
* rail's glance changes.
|
||||
*/
|
||||
railOffice: "Viewpoints",
|
||||
section: "world",
|
||||
scope: "always",
|
||||
onScreen: "the numbered list in the left column",
|
||||
onScreen: "the numbered rows at the top of the Places list",
|
||||
},
|
||||
|
||||
// ---- The view -----------------------------------------------------------
|
||||
@@ -543,6 +573,12 @@ export function shortcutRows(inputs: ShortcutSheetInputs): readonly ShortcutRow[
|
||||
* at most two hints and they are about the mode you are actually in; everything
|
||||
* else is one press of `?` away.
|
||||
*/
|
||||
/** The word the rail prints for a shortcut, given where the viewer is standing. */
|
||||
export function railLabel(entry: Shortcut, inside: boolean): string {
|
||||
if (inside && entry.railOffice !== undefined) return entry.railOffice;
|
||||
return entry.rail ?? entry.meaning;
|
||||
}
|
||||
|
||||
export function railHints(mode: ControlMode, coarsePointer: boolean): readonly Shortcut[] {
|
||||
if (coarsePointer) return [];
|
||||
const wanted: readonly string[] =
|
||||
|
||||
Reference in New Issue
Block a user