/** * Drawing the board. * * Two renderers, chosen because they fail in opposite places (docs/design/board-layout-studies.html): * * - `officeSvg` — the district stays a MAP. Cards on a grid with the rails actually drawn, so a * join is rail meeting rail rather than two descriptions that happen to agree. * - `divisionSvg`— the Division is not a map, it is a queue of sections with hard capacities, so * it is drawn as a dispatcher's diagram: one line per track, count them to know * what fits. * * SELF-CONTAINED ON PURPOSE. Both functions reference nothing outside their own bodies — no imports, * no module-level helpers. The playable app imports them normally; the replay is a single HTML file * with an inline script and cannot import anything, so it embeds these via `Function.toString()`. * That keeps ONE implementation: a second copy would eventually draw a different board from the same * state, which is the exact failure a track diagram exists to prevent. * * Rails come from `CellView.links`, which is the engine's own `connectionsFor`. A drawn rail can * therefore never claim a connection the rules do not have. */ import type { CellView, DivisionView } from './view.ts'; /** Anything the renderers need to know about a train standing somewhere. */ export type BoardTrain = { label: string; consist: string[] }; /** * The Division as a dispatcher would see it: one continuous line per running track, sections * separated by thin seams, capacity legible because the lines can be counted. */ export function divisionSvg(nodes: DivisionView[]): string { /** * THE WHOLE DIVISION, west to east, as one continuous route. * * It used to draw one box per node, which collapsed every player's district into a single * "Office" tile — so the thing a train actually runs along, the Running Track between the Limits, * was invisible on the only map that shows where trains are. Now an Office expands into its * Running Track, Limits to Limits, and the route reads end to end: * * West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP * * SEATING. Players sit around a table, so the route is laid out the way they do: one row alone, * two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop * — trains enter at one Division Point and leave at the other — so the shape is deliberately left * open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it * into a ring would promise a connection the rules do not have. * * Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for * anything outside its own body. */ const CW = { dp: 118, ml: 152, run: 78 }; const CH = 58; const GAP = 6; /** * Room for the buffer stops AND their labels. "trains enter" hangs off the west end and "trains * leave" off the east, so a padding sized for the stop alone clipped both to their middle three * letters — the west end read "TER" and the east "tra". */ const PAD = 92; const SIDE_GAP = 34; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); // -- flatten the route into drawable cells, remembering which belong to whose district -------- type Cell = { kind: 'dp' | 'ml' | 'run'; label: string; sub: string; trains: { label: string; consist: string[]; region?: number; direction?: string }[]; cap: number | null; tip: string; owner: number | null; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; w: number; x: number; y: number; }; const cells: Cell[] = []; const sides: number[][] = []; let side: number[] = []; const push = (c: Omit): void => { side.push(cells.length); cells.push({ ...c, x: 0, y: 0 }); }; for (const n of nodes) { if (n.kind === 'office') { const cap = n.capacity; const ad = n.trains.flat(); for (const rc of n.running ?? []) { const isOffice = rc.kind === 'office'; push({ kind: 'run', label: rc.label, sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '', // A train standing at the Office occupies an A/D track, which is where it is. trains: isOffice ? [...rc.trains, ...ad] : rc.trains, cap: isOffice ? cap : null, tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, owner: n.owner ?? null, // No regions inside a district: a crew moves by Moves there, not by Stages, so it // occupies a card outright rather than a part of one. regions: 0, w: CW.run, }); } // A crew below the Running Track has no position ON it, so it is reported against the // district rather than drawn somewhere it is not. const below = n.switching ?? []; if (below.length > 0) { const last = cells[cells.length - 1]; if (last) last.sub = `${below.length} switching below`; } sides.push(side); side = []; continue; } const dp = n.kind === 'dp'; push({ kind: dp ? 'dp' : 'ml', label: n.label, sub: n.capacity === null ? 'no limit — trains queue' : `${Math.max(0, n.capacity - n.trains.flat().length)} of ${n.capacity} free`, trains: n.trains.flat(), cap: n.capacity, tip: dp ? 'A Division Point — trains enter and leave the Division here, and queue without limit' : `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`, owner: null, // A Division Point is one region — the queue trains enter and leave the Division through. regions: dp ? 1 : (n.regions ?? 0), w: dp ? CW.dp : CW.ml, }); } if (side.length > 0) sides.push(side); // Each player's side carries their district and the Mainline card leading into it; whatever is // left over (the last Mainline and the East DP) joins the final side. const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length)); const lanes: number[][] = []; for (let i = 0; i < seats; i++) lanes.push([]); sides.forEach((grp, i) => { const target = Math.min(i, seats - 1); for (const idx of grp) lanes[target]!.push(idx); }); // -- lay the sides out around the table ------------------------------------------------------- // top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a // horseshoe open to the west, and a square broken at the same place. const dir: ('top' | 'right' | 'bottom' | 'left')[] = seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left']; const runLen = (idxs: number[]): number => idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP); const widest = Math.max(...lanes.map((l) => runLen(l)), 200); const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH; const vertCount = dir.filter((d) => d === 'right' || d === 'left').length; const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0); const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30; lanes.forEach((idxs, i) => { const d = dir[i]!; if (d === 'top' || d === 'bottom') { const y = d === 'top' ? PAD : boardH - PAD - CH - 22; const order = d === 'bottom' ? [...idxs].reverse() : idxs; let x = PAD; for (const idx of order) { const c = cells[idx]!; c.x = x; c.y = y; x += c.w + GAP; } } else { const x = d === 'right' ? boardW - PAD - CW.run : PAD; const order = d === 'left' ? [...idxs].reverse() : idxs; let y = PAD + CH + SIDE_GAP; for (const idx of order) { const c = cells[idx]!; c.x = x; c.y = y; c.w = CW.run; y += CH + GAP; } } }); // -- draw ------------------------------------------------------------------------------------- const rail = (x1: number, y: number, x2: number): string => { let o = `` + ``; const n = Math.max(2, Math.floor(Math.abs(x2 - x1) / 9)); for (let i = 0; i <= n; i++) { const tx = x1 + ((x2 - x1) * i) / n; o += ``; } return o; }; // The same rail turned through ninety degrees, for the sides of the table. const railV = (x: number, y1: number, y2: number): string => { let o = `` + ``; const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9)); for (let i = 0; i <= n; i++) { const ty = y1 + ((y2 - y1) * i) / n; o += ``; } return o; }; let out = ``; // The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join // that crosses from one player's side to the next is drawn heavier and labelled: that boundary is // the Mainline card two districts share, and it is the thing a table needs to see. for (let i = 0; i + 1 < cells.length; i++) { const a = cells[i]!; const b = cells[i + 1]!; const sameRow = Math.abs(a.y - b.y) < 1; const sameCol = Math.abs(a.x - b.x) < 1; if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x); else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x); else if (sameCol) { // Stacked down one side of the table: still one straight run of track, not a turn. const top = Math.min(a.y + CH, b.y + CH); const bot = Math.max(a.y, b.y); out += railV(a.x + a.w / 2, top, bot); } else { // A turn between sides: an elbow, so the route is visibly continuous around the table. const ax = a.x + a.w / 2; const bx = b.x + b.w / 2; const ay = a.y + CH; const by = b.y; out += ``; } } cells.forEach((c) => { const full = c.cap !== null && c.trains.length >= c.cap; out += ``; out += ``; out += `${esc(c.label)}`; out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); if (c.sub) out += `${esc(c.sub)}`; // REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage. // The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them, // so a 60 card is crossed in one Stage and a slow train on a 30 takes three. const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0; for (let r = 0; r < c.regions; r++) { out += ``; } c.trains.forEach((t, k) => { const w = 30 + Math.min(t.consist.length, 4) * 8; // A train on a Mainline card sits in ITS region; anywhere else it just sits on the card. const inRegion = c.regions > 1 && typeof t.region === 'number'; const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2; const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4); const tx = slotX + spread; const dir = t.direction === 'west' ? ' ◀ west' : t.direction === 'east' ? ' east ▶' : ''; out += `` + `` + `${esc(t.label)}`; }); out += ''; }); // THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is // what stops a seated layout being read as a loop. const first = cells[0]; const last = cells[cells.length - 1]; if (first) { out += `` + ``; out += `trains enter`; } if (last && last !== first) { out += `` + ``; out += `trains leave`; } out += ''; return out; } /** * The Office Area as a map: one card per grid square, rails drawn edge to edge. * * THE PRINTED CARD, and nothing else (docs/tracks.png). The through rail runs east-west across the * DEAD CENTRE of every card, so abutting cards produce one unbroken line and a gap is visibly a * gap. Everything that leaves through the north or south edge does so at 45°, through the middle of * that edge — which is what lets two stacked cards read as one continuous diagonal. * * The card is 166 x 96, the printed sheet's 259:150 proportions. The aspect is not decoration: the * frog where a 45° leg meets the through track sits H/2 from the centre, so a squarer card pushes * it almost to the edge and a turnout's two routes stop being tellable apart. */ export function officeSvg( cells: CellView[], runningRow: number, ghosts: { row: number; col: number }[] = [], ): string { const W = 166; const H = 96; const PAD = 3; const RAIL = H / 2; const esc = (t: string): string => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c); /** * ONE COORDINATE SYSTEM, AND ONE CANVAS. * * Empty squares a card may be placed on used to be drawn by a separate `ghostSvg` and spliced in * afterwards. It computed its own origin over cells PLUS spots while this function sized itself * over cells alone, so the two disagreed the moment a legal square lay outside the played cards — * and every legal square that extends the district does. Measured on seed 555: three placements * offered, the one empty square drawn at y=99 on a canvas 103 tall, i.e. off it entirely. The * list said three, the board showed two, and the third could not be clicked. * * Sizing over cells AND ghosts together is what makes that class of bug impossible. */ const all = [...cells.map((c) => ({ row: c.row, col: c.col })), ...ghosts]; if (all.length === 0) return ''; const rows = all.map((c) => c.row); const cols = all.map((c) => c.col); const r0 = Math.min(...rows); const r1 = Math.max(...rows); const c0 = Math.min(...cols); const c1 = Math.max(...cols); const width = (c1 - c0 + 1) * (W + PAD); const height = (r1 - r0 + 1) * (H + PAD) + 4; // Screen position of a card. Rows count DOWN from the top row, so the Running Track sits highest // and the district hangs beneath it, as the rules describe it. const px = (col: number): number => (col - c0) * (W + PAD); const py = (row: number): number => (r1 - row) * (H + PAD); const rail = (x1: number, y1: number, x2: number, y2: number): string => { const dx = x2 - x1; const dy = y2 - y1; const len = Math.hypot(dx, dy); const nx = (-dy / len) * 2.5; const ny = (dx / len) * 2.5; let out = `` + ``; const n = Math.max(2, Math.floor(len / 9)); for (let i = 0; i <= n; i++) { const tx = x1 + (dx * i) / n; const ty = y1 + (dy * i) / n; out += ``; } return out; }; // Where each port meets the card edge. East and west sit at the rail height — the card's middle — // so a straight run stays straight across the whole row; north and south are centred on the edge. const port = (p: string): { x: number; y: number } => { if (p === 'e') return { x: W, y: RAIL }; if (p === 'w') return { x: 0, y: RAIL }; if (p === 'n') return { x: W / 2, y: 0 }; return { x: W / 2, y: H }; }; let out = ``; for (const cell of cells) { const x = px(cell.col); const y = py(cell.row); const kind = cell.kind === 'office' ? 'office' : cell.kind === 'limits' ? 'lim' : cell.kind === 'facility' ? 'fac' : cell.kind === 'modifier' ? 'mod' : 'trk'; // Addressable so a caller can outline a square as a legal destination without re-rendering. // The explanation rides on the group as a tooltip rather than being printed: it is reference // detail, read once, and printing it costs the space the board itself needs. out += ``; out += ``; // The rails. A Modifier is not track and deliberately gets none — that is why nothing can be // routed through it, and the picture should say so. for (const link of cell.links) { const from = link[0] ?? 'e'; const to = link[1] ?? 'w'; const vertical = from === 'n' || from === 's' ? from : to === 'n' || to === 's' ? to : ''; if (vertical === '') { // The through track: edge to edge along the middle of the card. out += rail(port(from).x, port(from).y, port(to).x, port(to).y); } else { /** * A 45° LEG, drawn as the card prints it: along the centre line from the east or west edge * to the FROG, then out at exactly 45° through the middle of the north or south edge. * * The frog is `H/2` from the centre because a 45° run climbing half the card's height * travels half its height sideways. This used to be a fixed elbow at (W/2, RAIL+(H-RAIL)/2) * — below the rail on the assumption everything diverged downward — which drew a leg * reaching north as a hook that dropped past the rail and came back up, and drew nothing at * any angle the matching rule cares about. */ const side = vertical === from ? to : from; const frog = { x: W / 2 + (side === 'e' ? H / 2 : -H / 2), y: RAIL }; const edge = port(vertical); out += rail(port(side).x, port(side).y, frog.x, frog.y) + rail(frog.x, frog.y, edge.x, edge.y); } } out += `${esc(cell.label)}`; out += `${cell.row},${cell.col}`; /** * THE LOAD PIPELINE, on the card itself. * * A load crosses green -> MEN | AT | WORK -> the spotted car, and that journey is the whole of * freight. It was drawn only in the side panel, so advancing a load changed nothing on the card * the player was looking at. The printed cards put these squares under the track for exactly * this reason. */ const f = cell.facility; if (f) { const cellW = 13; const box = (bx: number, by: number, filled: boolean, cls: string, label?: string): string => `` + (label ? `${esc(label)}` : ''); let bx = 7; const by = H - 42; // Green: what is waiting to be worked. for (let i = 0; i < Math.max(1, f.greenCap); i++) { out += box(bx, by, i < f.green.length, 'bs-green'); bx += cellW + 2; } out += `›`; bx += 8; // MEN | AT | WORK — a load sits in one of these while it is being worked. for (let i = 0; i < f.maw.length; i++) { out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]); bx += cellW + 2; } out += `›`; bx += 8; // Red: what has come off an arriving car and is waiting to be cleared. for (let i = 0; i < Math.max(1, f.redCap); i++) { out += box(bx, by, i < f.red.length, 'bs-red'); bx += cellW + 2; } } // Standing room, drawn as the printed squares: filled means occupied. const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length; for (let i = 0; i < Math.min(spots, 4); i++) { const filled = i < cell.cars.length; /** * WHICH CAR IS STANDING HERE, not just that one is. * * The switching game is entirely about getting the RIGHT car to the right industry, so "a car * is here" is not enough — a Grocer's Warehouse wants a boxcar or a reefer and nothing else * will do. This printed the first three characters of the label, which for "loaded hopper" and * "loaded boxcar" alike is `loa`: every car on the board looked the same. * * Type by colour and by three letters, loaded by a filled slot and empty by an outlined one — * the same distinction the printed game makes with coloured tokens. */ const car = cell.cars[i] ?? ''; const type = /caboose/.test(car) ? 'cab' : /hopper/.test(car) ? 'hop' : /boxcar/.test(car) ? 'box' : /reefer/.test(car) ? 'rfr' : /tank/.test(car) ? 'tnk' : /coach/.test(car) ? 'cch' : ''; const loaded = /^loaded/.test(car); const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : ''; out += ``; if (filled) { out += `${esc(type)}`; } } if (cell.enhancements.length > 0) { // ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the // load pipeline below and the through track — and with the rail now down the middle of the // card, the old y = H-46 baseline printed the label straight along the rail itself. out += `${esc(cell.enhancements.join(' · '))}`; } if (cell.tray) { out += `` + `${esc(cell.tray.split(' ')[0] ?? 'crew')}`; } out += ''; } // A legal EMPTY square has no card to outline, so it gets a target drawn in the same frame. for (const g of ghosts) { out += `` + `` + `place here` + `${g.row},${g.col}` + ``; } // Mark the Running Track so the spine of the district is unmistakable. out += `RUNNING TRACK`; out += ''; return out; } /** Styling for both renderers. Shared so the replay and the app cannot drift apart visually. */ export const BOARD_CSS = ` .bs{width:100%;height:auto;background:#0e1116;border-radius:6px;padding:6px;overflow:visible} .bs-rail{stroke:#cfd6e0;stroke-width:1.6} .bs-tie{stroke:#8d97a5;stroke-width:1.1} .bs-open .bs-rail{stroke:#6b7686;stroke-dasharray:6 4} .bs-open .bs-tie{stroke:#59626f} .bs-card{fill:#161b21;stroke:#39424e;stroke-width:1} .bs-card.bs-run{fill:#1d232c} .bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2} .bs-lim{fill:#241d1d;stroke:#8a5a5a} .bs-fac{fill:#201c26;stroke:#7a5f9a} .bs-mod{fill:#231d2b;stroke:#5c4a70;stroke-dasharray:4 3} .bs-sec{fill:#161b21;stroke:#39424e} .bs-sec.bs-dp{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} .bs-sec.bs-office{fill:#1b2534;stroke:#4d6fa8;stroke-width:2} /* THE DIVISION, drawn as the route rather than as a row of boxes. Three kinds of cell, and they must be told apart at a glance: a Division Point is where trains come from and go to, a Mainline card is shared ground between two districts, and a Running Track card belongs to one player's Office Area. */ .bs-dcell rect{fill:#161b21;stroke:#39424e;stroke-width:1} .bs-ddp rect{fill:#151f19;stroke:#3f7a52;stroke-width:2;stroke-dasharray:5 3} .bs-dml rect{fill:#1d222b;stroke:#6b7686;stroke-width:1.6} .bs-drun rect{fill:#1b2534;stroke:#4d6fa8} .bs-dcell.bs-full rect{stroke:#e0a060;stroke-width:2} /* The elbow where the route turns from one player's side of the table to the next. Drawn heavier than a plain join because that turn is the boundary two districts share. */ .bs-turn{fill:none;stroke:#7f8b9c;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round} /* Buffer stops at both ends. The Division is a line, not a loop: trains enter at one Division Point and leave at the other, and a seated layout must not be read as a ring. */ .bs-stop line{stroke:#e0a060;stroke-width:2.6;stroke-linecap:round} .bs-end{fill:#e0a060;font:10px ui-monospace,monospace;letter-spacing:.03em} /* 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} .bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2} .bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none} /* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the type has to be readable at a glance; and a loaded car and an empty one of the same type are opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */ .bs-slot.bs-car-box{stroke:#c08a4a} .bs-slot.bs-car-hop{stroke:#8d97a5} .bs-slot.bs-car-tnk{stroke:#7a6ab0} .bs-slot.bs-car-rfr{stroke:#6fb5a0} .bs-slot.bs-car-cch{stroke:#5aa9e6} .bs-slot.bs-car-cab{stroke:#c05a5a} .bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)} .bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)} .bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)} .bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)} .bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)} .bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)} .bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2} .bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2} .bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace} .bs-carlab{fill:#cfd6e0;font:9px ui-monospace,monospace} .bs-wb{fill:#20262e;stroke:#4a5361;stroke-width:1} .bs-wb.bs-green{stroke:#3f7a52} .bs-wb.bs-green.bs-occ{fill:rgba(63,122,82,.55)} .bs-wb.bs-maw{stroke:#8a6d1f} .bs-wb.bs-maw.bs-occ{fill:rgba(190,150,40,.65)} .bs-wb.bs-red{stroke:#8a5a5a} .bs-wb.bs-red.bs-occ{fill:rgba(170,70,70,.6)} .bs-wl{fill:#8d97a5;font:7px ui-monospace,monospace} .bs-arrow{fill:#5f6b7a;font:10px ui-monospace,monospace} .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace} .bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace} .bs-cap.bs-full{fill:#e0a060;font-weight:600} .bs-grade{fill:#e08060;font:10px ui-monospace,monospace} .bs-mod{font:10px ui-monospace,monospace} text.bs-mod{fill:#c8a04a} .bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace} .bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em} .bs-ghost rect{fill:rgba(90,169,230,.07);stroke:#5aa9e6;stroke-width:2;stroke-dasharray:5 4} .bs-ghost text{fill:#5aa9e6;font:11px ui-monospace,monospace} .bs-ghost,g[data-cell].bs-legal{cursor:pointer} g[data-cell].bs-legal .bs-card{stroke:#5aa9e6;stroke-width:2.5} g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246} g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5} g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3} `;