From 59974d1b79d6c2ab52096ea59679565fb62682b0 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 19:23:10 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v0.20.0=20=E2=80=94=20ISS-021=20mov?= =?UTF-8?q?e=20On=20board=20to=20left=20pane,=20widen=20left=20360/280?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 ++ app/globals.css | 10 +-- app/page.tsx | 4 +- components/LeftPane.tsx | 109 ++++++++++++++++++++++++++++++- components/RightPane.tsx | 138 ++------------------------------------- e2e/actions.spec.ts | 3 +- e2e/panes.spec.ts | 6 ++ e2e/right-pane.spec.ts | 5 +- lib/useGameAction.ts | 53 +++++++++++++++ package.json | 2 +- 10 files changed, 194 insertions(+), 143 deletions(-) create mode 100644 lib/useGameAction.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e83f9d1..b93e074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.20.0 — 2026-09-19 19:21 +- **On board moved to the left pane (ISS-021).** The "On board" section (per-plane blocks with loaded/expired cargo rows, Refuel-for-leg / Fly-to / Abandon + penalty preview) now renders in `components/LeftPane.tsx` between Fleet and Time — wallet stays on top. The JSX moved verbatim (same testids: `onboard-{planeId}`, `onboard-package-{pkg.id}`, `refuel-for-*`, `fly-to-*`, `abandon-*` + penalty preview), along with the `onboard` computation (`planes.filter(flying || slotsUsedFor > 0)`) and per-plane cargo filter. All onboard e2e locators are global testids, so existing specs work unchanged +- **Left pane widened: grid swapped to 360/1fr/280.** `.app-shell` in `app/globals.css` goes from `280px minmax(0, 1fr) 360px` to `360px minmax(0, 1fr) 280px` — total fixed width stays 640 px, so the map column is pixel-identical (map.spec.ts clicks via dynamic `map.project()`). Stale comments updated (on-board blocks now in the left pane; Relocate row comment reflects the 280 px right pane) +- **Shared action runner hook.** RightPane's local `runAction` (busy gate, inline pane error + shell toast on failure, full post-action snapshot pushed via `applySnapshot`) extracted to new `lib/useGameAction.ts`: `useGameAction(state, applySnapshot, onError) → { runAction, busy, actionError }`, exact semantics kept incl. the non-2xx throw/catch and inline-error auto-clear on the next snapshot. Used in BOTH panes; LeftPane receives `applySnapshot` from the shell (`app/page.tsx`). RightPane keeps `[data-testid="right-error"]`; LeftPane gains a matching `[data-testid="left-action-error"]`. Pure helpers `slotsUsedFor` / `loadEligiblePlanes` stay exported from RightPane (unit tests import them there; LeftPane imports `slotsUsedFor` from it); the Market Relocate row + `/api/airports` fetch stay in RightPane +- Tests: e2e — `panes.spec.ts` first test now also asserts the "On board" heading is visible inside `[data-testid="left-pane"]` and absent from the right pane; stale header comments updated in `actions.spec.ts` / `right-pane.spec.ts`. No unit-test changes (baseline 283) +- Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack + ## v0.19.2 — 2026-09-19 20:06 - **AGENTS.md "Change process" item 1** — connected issues/tasks that reference the same part of code may now be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel) - **AGENTS.md "E2E tests" section rewritten to match current reality** — e2e runs ONLY in CI, against an isolated docker stack inside dind (`docker-compose.ci.yml`); there are NO local e2e runs at all (local verification = `tsc --noEmit` + `npx vitest run` + `npx playwright test --list`; the full suite is verified by CI on every PR). Dropped the stale local-run instructions (deploying to the Traefik-routed instance before testing, driving browsers through the remote Playwright server at 192.168.1.4:3000); kept and reworded the CI env vars that actually exist in `playwright.config.ts` (`E2E_BASE_URL`, `E2E_DEPLOY_CMD`, `PW_REMOTE_BROWSER=0`, `E2E_COMPOSE_ARGS`) diff --git a/app/globals.css b/app/globals.css index 657085f..d0a6d0d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -102,10 +102,12 @@ body { color: #334155; } -/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market */ +/* v0.9.0 — single-screen 3-pane shell: left Assets / center map / right Jobs & Market. + ISS-021 (v0.20.0): left widened to 360px (it now carries the On board section), right + narrowed to 280px — total fixed width stays 640px, so the map column is unchanged. */ .app-shell { display: grid; - grid-template-columns: 280px minmax(0, 1fr) 360px; + grid-template-columns: 360px minmax(0, 1fr) 280px; height: 100%; } @@ -362,7 +364,7 @@ body { } /* ISS-010 (v0.18.0) — the Relocate row carries TWO selects, so let it wrap instead of - squeezing both below their readable width in the 360 px pane. */ + squeezing both below their readable width in the 280 px right pane (ISS-021 v0.20.0). */ .market-row-relocate { flex-wrap: wrap; } @@ -374,7 +376,7 @@ body { font-variant-numeric: tabular-nums; } -/* v0.12.0 — on-board blocks (per plane) in the right pane */ +/* v0.12.0 — on-board blocks (per plane); moved to the left pane in ISS-021 (v0.20.0) */ .onboard-block { background: rgba(30, 41, 59, 0.6); border-radius: 6px; diff --git a/app/page.tsx b/app/page.tsx index 5777fb8..5aa721f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -43,7 +43,9 @@ export default function Home() { return (
- + {/* ISS-021 (v0.20.0) — applySnapshot also reaches the left pane: its On board section's + refuel/fly/abandon actions push their response snapshot into the shared state */} + {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its action response into the shared state, exactly like RightPane does */} diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 6d73475..858089a 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -3,9 +3,12 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; import type { PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; -import { formatCash, formatFuel, formatSimClock } from '@/lib/format'; +import { abandonPenalty } from '@/lib/game/penalty'; +import { formatCash, formatDeadlineRelative, formatFuel, formatSimClock } from '@/lib/format'; import { TIME_SCALES, scaleToSlider, sliderToScale } from '@/lib/timeScale'; +import { useGameAction } from '@/lib/useGameAction'; import { useSimClock } from '@/lib/useSimClock'; +import { slotsUsedFor } from './RightPane'; import type { HoverTarget } from './hoverTypes'; interface LeftPaneProps { @@ -14,6 +17,9 @@ interface LeftPaneProps { error: string | null; // The shell's single-poll refresh — called after a sim-control POST so the pane updates immediately. onRefresh: () => void; + // ISS-021 (v0.20.0): push the full snapshot returned by an action endpoint into the shared shell + // state — needed now that the On board section's refuel/fly/abandon actions live in this pane. + applySnapshot: (snapshot: StateSnapshot) => void; // ISS-002 (v0.15.0): report fleet-row hovers to the shell so the map can highlight the plane. onHover: (target: HoverTarget | null) => void; // ISS-008 (v0.17.0): report a failed sim-control POST to the shell as an error toast. @@ -83,9 +89,12 @@ export async function retrySimPost( return { ok: false, reason: 'exhausted', error: lastError }; } -export default function LeftPane({ state, loading, error, onRefresh, onHover, onError }: LeftPaneProps) { +export default function LeftPane({ state, loading, error, onRefresh, applySnapshot, onHover, onError }: LeftPaneProps) { const [busy, setBusy] = useState(false); + // ISS-021 (v0.20.0): shared action runner for the On board section (moved here from RightPane). + const { runAction, busy: actionsBusy, actionError } = useGameAction(state, applySnapshot, onError); + // POST /api/sim (retried up to SIM_POST_MAX_ATTEMPTS× on failure — CI runs #44/#47), then // refetch the full snapshot immediately so the pane reflects the new controls without waiting // for the next poll tick. After the final attempt fails keep the stale UI and surface an error @@ -153,6 +162,12 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on const paused = state?.simState.paused ?? false; const timeScale = state?.simState.timeScale ?? 1; + // On board (v0.12.0, moved from the right pane in ISS-021 v0.20.0): every plane that carries + // cargo or is mid-flight gets a block — its loaded/expired packages with fly-to-destination + + // abandon controls, and flying planes show their current leg. + const clockMs = state?.simState.clockMs ?? 0; + const onboard = state ? state.planes.filter((p) => p.status === 'flying' || slotsUsedFor(state, p.id) > 0) : []; + // Slider commit (v0.9.1): the displayed value follows the drag optimistically via // pendingScale; POST /api/sim fires after a 250 ms debounce, or immediately when the // user releases the slider (browsers fire a native 'change' on release). The server's @@ -294,6 +309,94 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on )} + {/* ISS-021 (v0.20.0): moved here from the right pane — between Fleet and Time. */} +
+

On board

+ {onboard.length === 0 ? ( +

Nothing on board.

+ ) : ( + onboard.map((plane) => { + const cargo = state.packages.filter( + (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'), + ); + return ( + // ISS-002: hovering the on-board block (including its cargo rows) highlights the plane. + // Handlers live only on the outer div — React's enter/leave don't fire when moving to a + // child, so this covers the whole block without flicker between sub-elements. +
onHover({ kind: 'plane', id: plane.id })} + onMouseLeave={() => onHover(null)} + > +
+ {PLANE_TYPES[plane.model]?.name ?? plane.model} + {plane.status === 'flying' ? ( + <> + in flight + {plane.destName && ( + + {plane.locationName} → {plane.destName} + + )} + + ) : ( + {plane.locationIcao} + )} +
+ {cargo.map((pkg) => { + const penalty = abandonPenalty(pkg, clockMs); + return ( +
+
+ + + +
+
+ {pkg.originName} → {pkg.destName} + {formatCash(pkg.reward)} + {pkg.status === 'expired' ? ( + expired + ) : ( + {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} + )} +
+
+ ); + })} +
+ ); + }) + )} +
+

Time

@@ -347,6 +450,8 @@ export default function LeftPane({ state, loading, error, onRefresh, onHover, on {error}

)} + {/* ISS-021 (v0.20.0): inline error from a failed On board action — mirrors right-error. */} + {actionError &&

{actionError}

} ) : null} diff --git a/components/RightPane.tsx b/components/RightPane.tsx index 91c9aeb..fbdbca4 100644 --- a/components/RightPane.tsx +++ b/components/RightPane.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { GeoJsonFeatureCollection } from '@/lib/airports'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import { PLANE_TYPES } from '@/lib/game/catalog'; -import { abandonPenalty } from '@/lib/game/penalty'; import { formatCash, formatDeadlineRelative } from '@/lib/format'; +import { useGameAction } from '@/lib/useGameAction'; import type { HoverTarget } from './hoverTypes'; interface RightPaneProps { @@ -109,13 +109,9 @@ function LoadControls({ } export default function RightPane({ state, loading, applySnapshot, onHover, onError }: RightPaneProps) { - const [busy, setBusy] = useState(false); - // Inline error from a failed action — auto-clears on the next successful poll (the shell's - // snapshot prop changes every tick) or the next successful action. - const [actionError, setActionError] = useState(null); - useEffect(() => { - if (state !== null) setActionError(null); - }, [state]); + // ISS-021 (v0.20.0): shared action runner — busy gate, inline pane error + shell toast on + // failure, full post-action snapshot pushed into the shared state on success. + const { runAction, busy, actionError } = useGameAction(state, applySnapshot, onError); // Market picker selections — same "effective selection" pattern as LoadControls: the // user's pick while it stays valid, else a sensible default (first catalog entry / first plane). @@ -149,36 +145,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr void airportsPromiseRef.current.then(setAirportOptions).catch(() => {}); }, []); - // POST an action endpoint. Every success returns the FULL post-action snapshot (v0.8.0 - // convention) — pushed into the shared state immediately; a 4xx {error} body becomes the - // inline pane error and the stale UI stays until the next poll. Buttons are disabled while - // a request is in flight, so at most one action runs at a time. - const runAction = useCallback( - async (path: string, body?: unknown) => { - if (busy) return; - setBusy(true); - try { - const res = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - cache: 'no-store', - }); - const data = (await res.json()) as StateSnapshot & { error?: string }; - if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); - setActionError(null); - applySnapshot(data); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setActionError(message); // inline pane error stays… - onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008) - } finally { - setBusy(false); - } - }, - [applySnapshot, busy, onError], - ); - if (loading && !state) { return (
-
-

On board

- {onboard.length === 0 ? ( -

Nothing on board.

- ) : ( - onboard.map((plane) => { - const cargo = state.packages.filter( - (pkg) => pkg.planeId === plane.id && (pkg.status === 'loaded' || pkg.status === 'expired'), - ); - return ( - // ISS-002: hovering the on-board block (including its cargo rows) highlights the plane. - // Handlers live only on the outer div — React's enter/leave don't fire when moving to a - // child, so this covers the whole block without flicker between sub-elements. -
onHover({ kind: 'plane', id: plane.id })} - onMouseLeave={() => onHover(null)} - > -
- {planeName(plane)} - {plane.status === 'flying' ? ( - <> - in flight - {plane.destName && ( - - {plane.locationName} → {plane.destName} - - )} - - ) : ( - {plane.locationIcao} - )} -
- {cargo.map((pkg) => { - const penalty = abandonPenalty(pkg, clockMs); - return ( -
-
- - - -
-
- {pkg.originName} → {pkg.destName} - {formatCash(pkg.reward)} - {pkg.status === 'expired' ? ( - expired - ) : ( - {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} - )} -
-
- ); - })} -
- ); - }) - )} -
+ {/* ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time). */}

Market

diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 6faad17..77df59f 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -3,7 +3,8 @@ import { expect, test } from '@playwright/test'; // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // the full core loop through the UI — hook creates a short package at EDDF → Load -// via the right pane → click its destination in the on-board list → fly at 1000× → +// via the right pane → click its destination in the on-board list (left pane since +// v0.20.0, ISS-021) → fly at 1000× → // arrival delivers EXACTLY the reward + rep; abandon deducts exactly the formula // penalty while grounded; expired packages deliver/abandon with no reward and no // penalty. Mirrors lib/game/penalty.ts for exact-value assertions. diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index c4dfd49..34413c7 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -69,6 +69,12 @@ test('left pane renders wallet, reputation and the starter fleet at Frankfurt', // Sim clock renders (UTC wall-clock of the game time). await expect(page.getByTestId('sim-clock')).toContainText(/UTC/); + + // ISS-021 (v0.20.0): the On board section moved to the left pane (between Fleet and Time) — + // its heading renders here… + await expect(page.getByTestId('left-pane').getByRole('heading', { name: 'On board', exact: true })).toBeVisible(); + // …and no longer in the right pane. + await expect(page.getByTestId('right-pane').getByRole('heading', { name: 'On board', exact: true })).toHaveCount(0); }); test('time controls: pause freezes the sim and the ladder slider sets timeScale', async ({ page }) => { diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 8fd8a89..9014339 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -2,8 +2,9 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with -// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list shows -// loaded packages with fly-to-destination + abandon buttons (penalty preview from the same +// names/reward/deadline and Load (UI click) binds one to the starter; the on-board list +// (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination + +// abandon buttons (penalty preview from the same // formula as the server); flying planes show their current leg; market buy/sell/refuel previews // match the catalog + economy constants exposed in /api/state. Packages are created via // POST /api/test/create-package (FLIGHT_TEST_HOOKS=1), same pattern as e2e/actions.spec.ts. diff --git a/lib/useGameAction.ts b/lib/useGameAction.ts new file mode 100644 index 0000000..9af3091 --- /dev/null +++ b/lib/useGameAction.ts @@ -0,0 +1,53 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { StateSnapshot } from '@/lib/game/state'; + +// ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from +// RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint; +// every success returns the FULL post-action snapshot (v0.8.0 convention), pushed into the +// shared shell state immediately via applySnapshot so the UI updates without waiting for the +// next poll tick. A non-2xx {error} body becomes the pane's inline error AND is mirrored to +// the shell as a bottom-right toast via onError (ISS-008 — both surfaces by design). Buttons +// are disabled while a request is in flight, so at most one action runs per pane at a time. +export function useGameAction( + state: StateSnapshot | null, + applySnapshot: (snapshot: StateSnapshot) => void, + onError?: (message: string) => void, +): { runAction: (path: string, body?: unknown) => Promise; busy: boolean; actionError: string | null } { + const [busy, setBusy] = useState(false); + // Inline error from a failed action — auto-clears on the next successful poll (the shell's + // snapshot prop changes every tick) or the next successful action. + const [actionError, setActionError] = useState(null); + useEffect(() => { + if (state !== null) setActionError(null); + }, [state]); + + const runAction = useCallback( + async (path: string, body?: unknown) => { + if (busy) return; + setBusy(true); + try { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + cache: 'no-store', + }); + const data = (await res.json()) as StateSnapshot & { error?: string }; + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + setActionError(null); + applySnapshot(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setActionError(message); // inline pane error stays… + onError?.(message); // …and the shell mirrors it as a bottom-right toast (ISS-008) + } finally { + setBusy(false); + } + }, + [applySnapshot, busy, onError], + ); + + return { runAction, busy, actionError }; +} diff --git a/package.json b/package.json index 8423d54..94a51b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.19.2", + "version": "0.20.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From 570ad52d473bcbeae02db670c99992c3de72e652 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 19 Sep 2026 20:09:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20v0.20.0=20part=202=20=E2=80=94=20IS?= =?UTF-8?q?S-028=20on-board=20rows=20two-line=20layout=20+=20airport=20nam?= =?UTF-8?q?e=20ellipsis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + app/globals.css | 26 ++++++++++++++++++++++++++ components/LeftPane.tsx | 22 ++++++++++++---------- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b93e074..90e1612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **Shared action runner hook.** RightPane's local `runAction` (busy gate, inline pane error + shell toast on failure, full post-action snapshot pushed via `applySnapshot`) extracted to new `lib/useGameAction.ts`: `useGameAction(state, applySnapshot, onError) → { runAction, busy, actionError }`, exact semantics kept incl. the non-2xx throw/catch and inline-error auto-clear on the next snapshot. Used in BOTH panes; LeftPane receives `applySnapshot` from the shell (`app/page.tsx`). RightPane keeps `[data-testid="right-error"]`; LeftPane gains a matching `[data-testid="left-action-error"]`. Pure helpers `slotsUsedFor` / `loadEligiblePlanes` stay exported from RightPane (unit tests import them there; LeftPane imports `slotsUsedFor` from it); the Market Relocate row + `/api/airports` fetch stay in RightPane - Tests: e2e — `panes.spec.ts` first test now also asserts the "On board" heading is visible inside `[data-testid="left-pane"]` and absent from the right pane; stale header comments updated in `actions.spec.ts` / `right-pane.spec.ts`. No unit-test changes (baseline 283) - Verified: `tsc --noEmit` clean; unit suite green locally; e2e not run locally by design — CI verifies against an isolated stack +- **On-board cargo rows are two lines (ISS-028).** Each `onboard-package-*` row now stacks a full-width "Fly to " button on its own line above Refuel + Abandon side by side: `components/LeftPane.tsx` moves the fly-to button out of `.contract-main` into the row root and wraps refuel-for + abandon in a new `.onboard-actions` div — all testids, label text, penalty preview and disabled states unchanged. Long destination names in the fly-to label truncate with an ellipsis instead of overflowing: the label's inner span gets `min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap`, full name preserved via a `title` attribute (CSS in `app/globals.css`: `.onboard-block .contract-row` becomes flex-column, new `.onboard-fly-btn` / `.onboard-fly-label` / `.onboard-actions`). No e2e changes — specs locate everything by testid and assert no layout or button order; no version bump (part 2 of v0.20.0) ## v0.19.2 — 2026-09-19 20:06 - **AGENTS.md "Change process" item 1** — connected issues/tasks that reference the same part of code may now be done together on one branch/PR with a single version bump (e.g. two UI tweaks to the same panel) diff --git a/app/globals.css b/app/globals.css index d0a6d0d..25540a3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -384,10 +384,36 @@ body { margin-bottom: 8px; } +/* ISS-028 (v0.20.0 part 2) — on-board cargo rows are two lines: a full-width fly-to button, + then refuel + abandon side by side below it; long destination names truncate with an ellipsis. */ .onboard-block .contract-row { background: transparent; padding: 6px 0 0; border-top: 1px solid rgba(148, 163, 184, 0.2); + display: flex; + flex-direction: column; + gap: 5px; +} + +.onboard-block .contract-row .contract-sub { + margin-top: 0; /* row spacing comes from the container's gap */ +} + +.onboard-fly-btn { + width: 100%; +} + +.onboard-fly-label { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.onboard-actions { + display: flex; + gap: 6px; } /* v0.17.0 — ISS-008: bottom-right toast stack (action errors + game events) */ diff --git a/components/LeftPane.tsx b/components/LeftPane.tsx index 858089a..23a80d5 100644 --- a/components/LeftPane.tsx +++ b/components/LeftPane.tsx @@ -349,7 +349,18 @@ export default function LeftPane({ state, loading, error, onRefresh, applySnapsh const penalty = abandonPenalty(pkg, clockMs); return (
-
+ +
-