diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3decac..7cb62d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -89,6 +89,41 @@ extension bug above, which passed only `days` — which is exactly how the next
it. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own floor is this same
formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivation.
+### A Heavy Grade shows which way it climbs
+
+Jesse, 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card doesn't show it."
+The Frame has carried `gradeUp` since the Division map was rebuilt and the tip has read "climbs
+east" all along — but the one Mainline card whose orientation is set per game, and the one where
+Helpers and Brakeman mean opposite things at opposite ends, drew nothing to tell the two apart.
+
+A brown wedge in the card's lower right, rising toward the climb, with a bone arrow lying along its
+slope. **East is right on this map** (Gitea#18), which is what lets a wedge be read without a
+compass — and is the layout decision paying for itself again.
+
+**Four passes, and the first three are worth recording because each failed differently.** An arrow
+up the hypotenuse had to start at the wedge's thin corner, where there is no height to draw in, so
+its head sat over the edge and read as clipped. Level, it was contained but did not read as
+climbing. At 30° — steeper than the wedge's own 22.5° — it had to be tucked into the fat half to
+survive. Lying **along** the slope is the shape that fits: the perpendicular gap to the hypotenuse
+is then constant down the whole arrow instead of closing at one end, so it can sit on the triangle's
+centroid. The wedge grew 44×19 → 58×24 to pay for that, because a centred arrow has *less* room than
+an off-centre one — the centroid is about 7px from the hypotenuse.
+
+The final sizes are a search result rather than an eyeballed nudge: the roomiest arrow keeping all
+seven vertices clear of both edges, at 2.88px.
+
+**A test that passed a visibly broken glyph is the reason this is pinned properly.** The first
+containment check bounded the arrow against the CARD, which it never left — while the wedge clipped
+it. The check is against the *triangle* now, with a 2px floor, plus the arrow being parallel to the
+wedge (derived from the wedge, so resizing it cannot leave a stale angle) and centred on the
+centroid.
+
+**Orientation is always set — measured, not assumed**, since a wedge that invented a direction would
+be worse than no wedge. Across 400 seeds × 4 player counts: **535 Heavy Grades placed, 0 without an
+orientation**, 276 east / 259 west. `setup.ts` is the only place a Mainline node is created and it
+rolls the direction from the seed for every grade, so the `?? 'east'` fallbacks in `view.ts` and
+`advance.ts` are unreachable.
+
### Three wording and layout fixes
- **The collision entries** on all three screens now read "The game ends immediately and results in
@@ -103,7 +138,7 @@ formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivatio
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
-873 tests pass, six of them new; one existing test asserted the opposite of the collision ruling
+874 tests pass, seven of them new; one existing test asserted the opposite of the collision ruling
above and says so where it was reversed.
---
diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts
index c5c733c..2d04de4 100644
--- a/src/sim/board-svg.ts
+++ b/src/sim/board-svg.ts
@@ -146,6 +146,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
below?: Cell['trains'];
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number;
+ /**
+ * Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the
+ * tooltip said "climbs east" and the card itself showed nothing — so the one card whose
+ * orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30).
+ */
+ gradeUp?: string | null;
w: number;
x: number;
y: number;
@@ -259,6 +265,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
seat: null,
// A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0),
+ gradeUp: dp ? null : (n.gradeUp ?? null),
w: dp ? CW.dp : CW.ml,
});
}
@@ -357,6 +364,73 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
out += ``;
}
+ /**
+ * WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
+ *
+ * The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed
+ * nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a
+ * Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to
+ * hover to read (Jesse, 2026-08-30).
+ *
+ * A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the
+ * wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a
+ * comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and
+ * below the region bars, so it never lands under a train chip.
+ *
+ * `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge
+ * readable without a compass, and it is why the row layout is worth its width.
+ */
+ if (c.gradeUp === 'east' || c.gradeUp === 'west') {
+ const s = c.gradeUp === 'east' ? 1 : -1;
+ const GW = 58;
+ const RISE = 24;
+ const gx1 = c.x + c.w - 9 - GW;
+ const gx2 = c.x + c.w - 9;
+ const yb = c.y + CH - 6;
+ const peakX = s === 1 ? gx2 : gx1;
+ out += ``;
+
+ /**
+ * THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30).
+ *
+ * Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is
+ * then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line
+ * does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is
+ * steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on
+ * it.
+ *
+ * Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point
+ * of the form rather than of its bounding box — centring on the box would push the arrow into
+ * the thin corner where there is no height for it.
+ *
+ * The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the
+ * hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form
+ * to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px;
+ * `web.test.ts` re-derives it and fails under 2px.
+ *
+ * Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it —
+ * so a westward climb is one sign on `u` rather than a second set of coordinates.
+ */
+ const L = 20;
+ const HL = 6;
+ const HW = 3.5;
+ const T = 1.4;
+ const A = Math.atan2(RISE, GW);
+ const cos = Math.cos(A);
+ const sin = Math.sin(A);
+ const cu = (2 * GW) / 3;
+ const ch = RISE / 3;
+ const pt = (lx: number, ly: number): string => {
+ const u = cu + lx * cos - ly * sin;
+ const h = ch + lx * sin + ly * cos;
+ return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`;
+ };
+ const H = L / 2;
+ out +=
+ ``;
+ }
+
/**
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
*
@@ -1123,6 +1197,14 @@ export const BOARD_CSS = `
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
train is measured against, not something to look at instead of the train. */
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
+/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning.
+ SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated
+ fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is
+ happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes
+ into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part
+ that carries. */
+.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1}
+.bs-gradeup{fill:#f2e8d5}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
diff --git a/test/web.test.ts b/test/web.test.ts
index 4b4c294..90f768a 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -21,6 +21,7 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
+import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts';
@@ -2834,6 +2835,107 @@ describe('the Division map shows the whole route', () => {
return divisionSvg(snapshot(s, [], null).division);
};
+ it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
+ /**
+ * REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
+ * doesn't show it." The Frame has carried `gradeUp` since the Division map was rebuilt, and the
+ * tip has read "climbs east" all along — but the one Mainline card whose orientation the PLAYER
+ * sets, and the one where Helpers and Brakeman mean opposite things at opposite ends, drew
+ * nothing to distinguish the two.
+ *
+ * Asserted on the geometry rather than on a screenshot: what can actually go wrong here is the
+ * wedge pointing the wrong way or hanging off the card, and both are numbers.
+ */
+ const card = (gradeUp: string | null): DivisionView =>
+ ({
+ kind: 'ml', label: 'Heavy Grade', trains: [], capacity: 1,
+ modifiers: [], gradeUp, regions: 3, what: 'three regions',
+ } as unknown as DivisionView);
+
+ const peakOf = (svg: string): [number, number][] | null => {
+ const m = / p.split(',').map(Number) as [number, number])) : null;
+ };
+
+ // EAST IS RIGHT on this map (Gitea#18), which is the whole reason a wedge can be read without a
+ // compass — so the apex belongs at the greater x when the grade climbs east, and the lesser when
+ // it climbs west.
+ const east = peakOf(divisionSvg([card('east')]));
+ assert.ok(east, 'a Heavy Grade climbing east draws no wedge at all');
+ const [ea, eb, epeak] = east;
+ assert.equal(epeak![0], Math.max(ea![0], eb![0]), 'the wedge climbs the wrong way for east');
+
+ const west = peakOf(divisionSvg([card('west')]));
+ assert.ok(west, 'a Heavy Grade climbing west draws no wedge at all');
+ const [wa, wb, wpeak] = west;
+ assert.equal(wpeak![0], Math.min(wa![0], wb![0]), 'the wedge climbs the wrong way for west');
+
+ // Every other Mainline card is flat and must stay unmarked, or the wedge stops meaning anything.
+ assert.equal(peakOf(divisionSvg([card(null)])), null, 'a card with no grade drew one anyway');
+
+ /**
+ * THE ARROW LIVES INSIDE THE WEDGE, with room to spare.
+ *
+ * The first pass ran it up the hypotenuse, which starts at the wedge's thin corner where there
+ * is no height to draw in, so the head sat over the edge and read as clipped — reported exactly
+ * that way. A bounding-box check would have passed it: every point was on the card. What matters
+ * is containment in the TRIANGLE, and a margin big enough to see, so both are asserted.
+ */
+ for (const dir of ['east', 'west'] as const) {
+ const svg = divisionSvg([card(dir)]);
+ const wedge = peakOf(svg)!;
+ const [w1, w2, apex] = wedge;
+ const yBase = w1![1];
+ const xMin = Math.min(w1![0], w2![0]);
+ const xMax = Math.max(w1![0], w2![0]);
+ const arrow = / p.split(',').map(Number) as [number, number]);
+ for (const [ax, ay] of av) {
+ // 0 at the thin corner, 1 at the apex — the wedge's height scales with it.
+ const t = apex![0] === xMax ? (ax - xMin) / (xMax - xMin) : (xMax - ax) / (xMax - xMin);
+ const ceiling = yBase - (yBase - apex![1]) * t;
+ assert.ok(ax >= xMin && ax <= xMax, `the ${dir} arrow leaves the wedge sideways at ${ax}`);
+ assert.ok(
+ yBase - ay >= 2 && ay - ceiling >= 2,
+ `the ${dir} arrow comes within 2px of the wedge edge at ${ax},${ay} — it will read as clipped`,
+ );
+ }
+
+ /**
+ * IT LIES ALONG THE WEDGE'S OWN SLOPE, AND IS CENTRED IN THE FORM.
+ *
+ * Derived from the wedge rather than hardcoded, so resizing the wedge cannot leave the arrow
+ * at a stale angle — the assertion is "parallel", which is the property that makes the gap to
+ * the hypotenuse constant along the arrow instead of closing at one end.
+ *
+ * Measured on the AXIS: the two tail vertices straddle it by the shaft's half-thickness, so
+ * measuring from a corner under-reads by a few degrees. That mistake cost a wrong reading once
+ * already.
+ */
+ const tail: [number, number] = [(av[0]![0] + av[6]![0]) / 2, (av[0]![1] + av[6]![1]) / 2];
+ const tip = av[3]!;
+ const dx = tip[0] - tail[0];
+ const climb = (Math.atan2(-(tip[1] - tail[1]), Math.abs(dx)) * 180) / Math.PI;
+ const slope = (Math.atan2(yBase - apex![1], xMax - xMin) * 180) / Math.PI;
+ assert.ok(
+ Math.abs(climb - slope) < 0.5,
+ `the ${dir} arrow climbs at ${climb.toFixed(1)}° but the wedge rises at ${slope.toFixed(1)}°`,
+ );
+ assert.equal(dx > 0, dir === 'east', `the ${dir} arrow points away from the summit`);
+
+ // Centred on the TRIANGLE's centroid — 2/3 along the base toward the apex, 1/3 up — not on
+ // the bounding box, which would put it in the thin corner where there is no height for it.
+ const midX = (tail[0] + tip[0]) / 2;
+ const midY = (tail[1] + tip[1]) / 2;
+ const cX = apex![0] === xMax ? xMin + ((xMax - xMin) * 2) / 3 : xMax - ((xMax - xMin) * 2) / 3;
+ const cY = yBase - (yBase - apex![1]) / 3;
+ assert.ok(Math.abs(midX - cX) < 1, `the ${dir} arrow is off the centroid horizontally`);
+ assert.ok(Math.abs(midY - cY) < 1, `the ${dir} arrow is off the centroid vertically`);
+ }
+ });
+
it('expands each Office into its Running Track, Limits to Limits', () => {
// The map used to collapse a whole district into one "Office" box, so the track a train
// actually runs along was invisible on the only view that shows where trains are.