Skip to content

Feature/cwn vehicle seating - #83

Merged
over2take merged 49 commits into
mainfrom
feature/cwn-vehicle-seating
Aug 16, 2026
Merged

Feature/cwn vehicle seating#83
over2take merged 49 commits into
mainfrom
feature/cwn-vehicle-seating

Conversation

@over2take

@over2take over2take commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Test plan

  • Tested locally
  • Tests pass

Pre-merge checklist

Code quality:

  • Code follows project style
  • No breaking changes (or clearly documented)
  • No console errors or warnings

Version & Release:

  • Version bumped? If releasing to users, update:
    • frontend/package.json version
    • docker-compose.yml APP_VERSION
    • CHANGELOG.md with release notes
  • GitHub Actions will auto-tag Docker images with the new version

Before merging to main:

  • All tests passing
  • PR reviewed and approved
  • Branch is up to date with main
  • No merge conflicts

Related issues

Developer added 30 commits August 13, 2026 21:31
Phase one of CWN vehicles: the sheet block and firing a mounted weapon.
No token, no map presence — a vehicle is somewhere to keep stats and roll
from, which is what CWN needs and what SR6 will later need more of.

Mounts nest under their vehicle — vehicle1_weapon1_dmg — so a mount
belongs to a vehicle rather than to a shared pool. That naming is what
lets one resolver read both: getWeapon takes a prefix and a row count, and
`vehicle1_weapon` is as valid a prefix as `weapon`. Every existing caller
passes two arguments and is unaffected, which a test pins.

A mount is fired as its own action by a gunner, not as a variant of your
personal attack, so the client names the vehicle and the socket handler
routes to getVehicleWeapon. Merging mounts into the personal weapon list
would have implied you can fire the turret while driving.

The section reuses layout 'weapons', which is the row-chunking layout
rather than a weapons-only one — the name is historical. That avoids a new
SectionLayout member and a renderer branch doing exactly the same thing.
It does mean the row shape must divide evenly into `columns`, so each
vehicle is exactly three rows of six and notes live in their own section,
as they already do for weapons.

Armour here is Armour Rating, subtracted from damage, where personal
armour is AC and avoids the hit. They are not interchangeable and the
field hint says so — applying it is phase two, along with the
moving/stationary AC and destruction at zero HP.

Driving needed nothing: rolls.js already has drive mapped to dex_mod, so
the existing skill roll is the check.
Phase two, and stopping short of wiring on purpose.

vehicleAc, applyArmorRating and vehicleDestroyed encode the arithmetic
from the CWN vehicle combat rules. The AC one is worth having in code
rather than at the table because the two cases are not symmetric: moving
adds the driver's Drive skill, stationary applies a flat -4. An
implementation that read "+/- driveSkill" would look right and be wrong,
so there is a test for exactly that.

Armour Rating subtracts from damage. Personal armour is AC and decides
whether the hit lands at all. Confusing the two is the likeliest mistake
in this subsystem, so they are named differently, documented against each
other, and tested against each other.

What is deliberately not done: applying any of it to a live attack.
getAttackTarget resolves only token rows, and makeEmitResult needs the
target's x/z for the attack line — so a vehicle held on a sheet cannot be
the target of an attack at all. Wiring these needs a way to target a
vehicle, which is the token question that has been open since the spec was
written. Building a fake wiring around that would have been worse than
leaving the seam visible.

Crash damage on destruction is also absent: the rules give "damage from
the last attack, Luck save for half" but no dice for the speed-based
component, and inventing numbers for someone's ruleset is not mine to do.
Six vehicles declared and all six rendered gave eighteen rows of empty
fields on a blank sheet — which is what the screenshot showed, and not
what was asked for.

Sections can now declare `groupSize`, the number of fields in one repeated
entry. A section that declares it shows only the entries that have data,
plus one blank to type into, plus a button revealing the next. Weapons and
spells declare nothing and render exactly as before.

Visibility is derived from the data rather than remembered, which answers
the second half of the request for free: the vehicles you filled in are
the ones that come back after a reload or a login, because nothing has to
remember anything. Whitespace counts as empty, so a stray space cannot pin
a row open, and any field counts as data — someone may fill in a mount
before naming the vehicle.

Also fixes a header the screenshot exposed: every row sat under the
vehicle line's labels, so a mount's name appeared under VEHICLE and its
damage under HP. Each row of an entry now carries its own headings, shown
once on the first entry since the shape repeats.

Persistence needed nothing. updateSheetField writes data[fieldId] with no
whitelist, so vehicle fields are stored like any other and read back
whole.

One mismatch caught in passing: the sheet was raised to six vehicles while
the resolver still accepted three, which would have rendered vehicles
whose mounts silently refused to fire. Both are six now and a test reads
the template to pin the pair.
Three requested changes to the Cyberpunk RED stats block:

BODY gains a roll. That needed both sides, not just the button: the sheet
template renders the control, but the roll itself is resolved server-side
by field id through getRoll, and CPR_STATS did not list body. Adding the
template's roll alone would have drawn a button that failed when pressed.
It now rolls 1d10 + @Body with explode10, like every other stat — a stat
roll that did not explode would be quietly wrong rather than visibly
broken.

BODY takes MOVE's place and EMP takes BODY's, which leaves the grid
reading INT REF DEX TECH COOL / WILL BODY EMP LUCK MOVE. The two without
buttons end up last, and there is a reason they have none: MOVE is a
movement allowance rather than a check, and LUCK is a pool you spend. Both
now say so in a comment on each side.

The third test is the general form of the bug rather than the instance:
every field the template gives a button to must have a server-side roll.
The two live in different files and nothing else made them agree. Checked
it fails with body removed again, rather than assuming it would.
Version bump, changelog for the CWN vehicles and CP:R stats work, and the README structure caught up with the sheet files and two new test files it did not list.
The server has taken a vehicleIndex beside the weaponIndex since the mounts
landed, resolving a mount through the same path as a carried weapon. The panel
never sent one, so a mount on the sheet was unreachable.

The picker now lists mounts after the carried weapons, labelled with their
vehicle. Rows are keyed by the (vehicle, mount) pair rather than by index,
since mount 1 on two different vehicles are both weaponIndex 1 and one would
otherwise shadow the other. Counts come from the template constants, because
listing fewer than the sheet declares silently strands the last vehicles'
mounts.

Vehicles hang off a config entry, so the other two systems sharing this panel
are unaffected even if their sheet data happens to hold the same field names.
One block rather than one per vehicle, because you can only be inside one at
a time. Riding along points at another player's sheet by name, so a car with
four people in it has one set of HP and armour instead of four copies that
each take the damage separately.

Moving is declared rather than inferred from the token being dragged: it is an
eight point AC swing between moving and stationary, and inferring it from a
drag would make it accidental.

Fields only - nothing reads them yet.
readOccupancy reads the declaration off a sheet; getVehicle reads the vehicle
off whichever sheet holds it. Every unreadable state - an index past the rows
the sheet declares, a ride naming nobody, a row with no HP maximum - comes
back as on foot, which is how a character was attacked before vehicles
existed. A broken reference should cost someone their cover, never make them
impossible to hit.

Nothing calls either yet.
Armour Rating cuts the damage and the rest comes off the vehicle's HP on its
owner's sheet. Nobody inside is touched until it is destroyed, at which point
it stops being cover and the next shot resolves against them normally.

The defender's sheet is now read once before the to-hit rather than lazily at
damage time, because whether they are in a vehicle decides the AC. It was
already being fetched twice further down, so this is one fetch where there
were two.

A rider's damage lands on the owner's sheet, so four people in one car share
one HP pool instead of each taking the hit separately. Firing out of a moving
vehicle takes -4, whether the gun is a mount or one you are leaning out of the
window with, and the roll history names the vehicle and shows the armour
subtraction so an absorbed hit does not read as a bug.
Occupancy is mirrored from the sheet onto the token, alongside the name and
description that were already mirrored there - only the derived combat numbers,
never the sheet. Riders are re-mirrored when the owner saves, because a
passenger's badge is read off someone else's sheet and would otherwise go
quietly stale.

Attackers see TARGET IN VEHICLE with the AC, Armour Rating and HP before they
commit, and the token carries a badge that is always drawn rather than gated
behind hover: cover you have to hover to discover is cover nobody accounts for.

The attack prompt also quotes the vehicle's AC rather than the occupant's,
since the vehicle is what the shot has to beat.

resolveOccupiedVehicle now delegates to the same resolver the mirror uses, so
what the badge claims and what the attack applies cannot drift apart.
Mounts resolved off the attacker's own sheet, so the only person who could use
a turret was whoever owned the vehicle - while the sheet's own tooltip says
firing a mount costs the gunner their main action. A dedicated gunner is a
role the rules describe and the code could not do.

The resolution needed nothing: rollToHit already builds from the attacker's
sheet, so a gunner fires someone else's cannon with their own skill and
attribute mod. Only the weapon row moves.

The client cannot label what it cannot see, so the server sends the ride's
mounts down beside the sheet - only the mount rows, and only to someone who
has declared they are sitting in that vehicle. Firing one carries no vehicle
index, since the car is whichever one they have declared they are in: nothing
for the client to name and nothing for it to get wrong.

No seat gating and no action-economy checks. Nothing else in the combat system
polices how often you fire, and mounts should not be the exception.
The 1.8.2 entry described the vehicle rules as encoded but not wired, which
stopped being true. Rewritten to describe what actually landed: mounts that
fire, a gunner who can use a car they do not own, attacks that hit the vehicle,
and the badge that makes the cover visible before someone shoots at it.

Also notes the new locations column and the reordered defender-sheet read.
The dropdown listed MY VEHICLE 1 through 6 whether or not any of them existed,
so picking your car meant knowing which row it sits in - and six placeholder
rows are six ways to pick the wrong one. Options can now be derived from the
sheet's own data, so the list holds the vehicles you have named, by name.

The vehicle you are currently in stays listed even if its row is later
emptied. A select whose value is not among its options silently displays the
first one instead, which would quietly move you into a different car.

Also drops the stray blank: the renderer always prepended an empty option, so
a template naming its own blank state got two.
The field holds a login name and was free text, so a typo dropped you back to
being on foot with no cover and nothing on screen to say why. It is now a
dropdown of the other players holding a sheet in this system - the right list
rather than whoever happens to be online, since the car is read off their
sheet and that exists whether or not they are connected.

WHOSE never said whose. It is OWNER, and the tooltip now says it means the
other player rather than you.

Choices a template cannot derive from its own data arrive through a renderer
context rather than a prop threaded through five section layouts for the sake
of one field. An owner who is no longer listed still shows, tagged, instead of
the field appearing blank while it holds their name.
OWNER claimed something about the vehicle when the field describes an
arrangement, and it read as a seat role next to SEAT. RIDING WITH says it from
the passenger's side and pairs with RIDING IN.

The badge and the attack panel now name everyone in the car, which is the
thing you actually ask at the table - three people behind one Armour Rating is
a different decision from one. Shown only when the car is shared; your own
name back at you is noise.

No seat count. Recording a capacity would only matter if something enforced
it, and the software does not overrule the table on how many fit in a car any
more than it counts actions.

The mirror recomputes every player rather than the one who saved: boarding
someone's car changes what their badge says as much as yours, and working out
which subset to refresh means working out the whole thing anyway. That also
retires the rider-refresh special case for less code than it replaced.
A vehicle declares a layout and the layout decides what seats exist, so a
motorcycle does not show four empty doors. BIKE, CAR and VAN to start; adding
one later is an image and a seat list. Anything without a layout is a car,
which is what every vehicle that already exists is.

Movement moves off the occupant onto the vehicle. Each person declaring it
separately meant a driver and a passenger could disagree about an eight point
swing in the car's AC, and with a shared window there is one obvious place for
it to live. The attack path resolves the attacker's vehicle to find the firing
penalty rather than reading their own sheet, since the car may be someone
else's.

Layout and movement are stored but not rendered on the sheet: the vehicle row
is exactly eighteen fields and the renderer chunks by that, so adding two would
mean inventing four more to fill the row. The window is where both belong.

Seat ids exist on both sides - validated here, drawn there - and a test reads
the frontend source and fails if they drift, the same way the roll buttons are
pinned to their server rolls.
seatIn, seatOut and setMoving, with the checks the window should never be able
to trip: a seat the vehicle does not have, a vehicle that is not there, a
player with no sheet.

A seat holds one person and a person is in one seat, so seating someone empties
whatever seat they were in and turns out whoever was in this one. That is what
choosing a name in a dropdown means - a statement about where everyone is, not
an addition.

Anyone may seat anyone, because piling into a car is a decision the table makes
out loud and the window only records it. Getting out is the occupant's alone,
or the GM's, and that is checked on the server rather than by hiding a button:
a client sends whatever it likes.
Ten presets from the CWN table. Picking one fills the stat block; armour is
left unset on the vehicles the book marks * or ** because those are immunities
rather than numbers, and inventing a rating would be worse than the GM reading
the note the preset writes.

Hardpoints bound the mounts. A Motorcycle carries none and must not be offered
a gun; a Tank carries three and was short one. Mount rows go from two to three.

Crew is the seat count, so seats are derived from it rather than declared per
vehicle type: ids are positional, driver then seat2..seatN, and the server
needs only the number to validate. The names a player sees live with the
presets, where they are display text that cannot disagree with anything.

Guns are deliberately not seats. A Tank is crew 3 with three hardpoints, so it
can never man every gun and drive at once; making each hardpoint a seat would
hand it a fourth body and quietly resolve a tension the rules intend.

Speed becomes the number the book prints rather than free text, and the vehicle
carries its own Trauma Target - hits on a car should not roll against the
trauma target of whoever happens to be sitting in it.

Retires the IN A VEHICLE block and the data-driven select options added for it:
seating is shared state now, and an abstraction with no users is worse than
re-adding it when something needs it.
Seven shapes for ten vehicles. Sharing is fine where the silhouette is honest -
a Truck and an APC are both boxes on wheels from above - but a Helicopter and a
Dropcraft are not the same object and should not borrow one outline, so the
helicopter gets a rotor disc and tail rotor and the GEV a hover skirt rather
than the tank's tracks.
The vehicle descriptions contradict three of the shapes. A Micro Flyer is a
microlight - fabric over spars, light enough to break down into a pickup - not
the jet fighter it was drawn as. A CASRA is a Close Air Support Rotorwing
Aircraft, so it is rotors rather than wings. A Dropcraft is a VTOL transport
built to loiter and unload under fire.

All three had shared one fixed-wing outline, which was not a simplification but
a wrong drawing. The APC gets its own hull too: the book leads on its armour and
its mounted weapon, and borrowing the delivery van said neither.

One shape per vehicle now.
Picking a TYPE fills the stat block from the book table, and the row draws only
the mounts the vehicle has hardpoints for. A motorcycle showed three empty
mount rows and an empty stat block, which stated something false about it and
left the numbers to be copied in by hand.

Two general mechanisms rather than a CWN special case: a select can declare
presetFill, writing sibling fields when it changes, and a section can declare
rowHidden to drop a row of a repeated entry. Both are template data, so any
system can use them.

Picking a type will not rename a vehicle someone has already named, and the
immunity rule for the * and ** vehicles is appended to the notes once rather
than on every re-pick.

A mount past the hardpoints is hidden only when empty. Hiding one with data in
it reads as loss, and a GM may have overloaded a vehicle deliberately - which a
pre-existing test caught before it shipped.
Helicopter, APC, GEV and CASRA all have Trauma Target 10, not 12, and the
Dropcraft has Armour 12, not 10. All five came from transcribing the table out
of a low-resolution image, and none of them looks wrong once entered - a
vehicle with the wrong TT just takes traumatic hits slightly too rarely.

Verified against the PDF this time. A test now holds the ten rows verbatim in
the book's own column order, so the next transcription error fails there rather
than at someone's table.
Picking a type filled the row and then reverted it. Each field change is a
separate read-modify-write of the whole sheet blob on the server, so the eleven
a preset sends raced, all but one were overwritten, and the losing values came
straight back on the echo. They go as one importSheetFields now - a handler that
already existed for exactly this, one read and one write.

Vehicles had no way out. Each filled entry gets a REMOVE that blanks its thirty
fields in one write, behind a confirm click because a stray click on that would
be expensive. Entries blank in place rather than shifting up: occupancy refers
to vehicles by position, so closing the gap would silently move a passenger into
a different car.

Editing a vehicle now refreshes the badges too. Only name and description were
mirrored, so changing a car's armour left every passenger showing the old number
- their sheets were never touched, and nothing else was recomputing them.
The ten weapons a hardpoint can carry, as a TYPE picker on each mount that fills
damage, trauma and skill. Five are purpose-built vehicle weapons; the rest are
Heavy weapons from the personal tables that a mount can hold, and two of those
have no fixed damage at all - a grenade launcher fires whatever you loaded.

TYPE takes the column SHOCK had. No vehicle weapon in the book has shock, so a
picker earns the space more than a field that is always blank.

The book's damage notation carries markers the dice roller cannot read, so the
damage stored is clean dice and the ! rides on the trauma value instead:
'd20/x4!'. That marker turns out to be a rule I was getting wrong - only weapons
carrying it can inflict Traumatic Hits on a vehicle, and every trauma die was
working on cars. A pistol's trauma die is devastating to a person and does
nothing at all to a car.

A vehicle also rolls trauma against its own Trauma Target rather than against
that of whoever is sitting in it.
A section always showed one entry past the last filled one. That predates the
+ ADD button and duplicated it, so a single vehicle rendered as two - the second
full of ghost placeholder text that reads like real data at a glance. Only
entries holding something show now, plus one on an empty sheet to start from,
and + ADD is how you get another. Removing one clears the blank it revealed.

Notes move onto the vehicle. One box at the foot of the page for six vehicles
cannot say which one it is describing, and the immunity rule a preset writes had
nowhere sensible to land. Each vehicle carries its own, spanning the grid on a
row of its own, and the preset appends to that one - never over what a player
already wrote there.

Repeated entries are now grouped by field count rather than row count: rows vary
in width once one of them spans the grid, so counting rows sliced the groups in
the wrong places.

Existing vehicles_notes content is orphaned by this - it was one shared field
and there is no way to know which vehicle any line of it referred to.
The 24 fittings from the book as a per-vehicle list: add, remove, and a running
POWER and MASS line against the hull's totals that turns red when overloaded.
Mounted weapons count toward the same budget, because the book is explicit that
a hardpoint costs Power and Mass just as a fitting does - leaving them out would
flatter every armed vehicle.

A list rather than a dropdown filling fields, because a fitting comes off again.
Several rewrite the stat block - Extra Durability is +25% HP, Hardpoint Support
adds a mount - and a control that wrote those numbers in would have no way to
take them back. The effects are printed on the chips; the numbers stay yours.

Power Systems carry a negative drain since they supply Power rather than
spending it, which keeps the budget a plain sum instead of two special cases.
Only fittings the hull is big enough for are offered.

POW and MASS join the vehicle stat block, and the book-parity test now pins them
too. That made the stat block fourteen fields, which does not divide into rows
of six - so a mount began midway along a row and stopped being recognisable as
one, hiding no mounts on a motorcycle. Fields can declare startsRow now.
Fourteen stat fields do not divide into rows of six, and CSS grid flows a short
row's spare columns full of whatever comes next - so POW and MASS shared a line
with MOUNT 1, and every label after that sat above the wrong field. Each row
starts at column one now, which forces the break.

The stat block reorders while fixing it, so the short row is the one that
matters least: identity and defence, then capacity and budget, then cost and
size on their own. A sparse row of reference data reads better than a sparse row
of numbers the fittings budget depends on.

The full-width labels were also inheriting the section's centring.
Two Power Systems and a tool rack showed POWER -6/8. Netting a supply off the
drain makes the spend go negative, which reads as nonsense - you have not used
minus six Power.

The book calls them 'adds Power at a cost in Mass', so they raise the total:
the same vehicle now reads POWER 0/14 (+6). The number left of the slash is
what is being used and never goes below zero, and the supply is visible as the
reason the total grew.
The piece the redesign was missing. Retiring the IN A VEHICLE sheet block left
the seating actions with nothing calling them, so the branch could not ship.

A car picker across every player's sheet, the vehicle's wireframe with a leader
line and a dropdown per seat, a MOVING toggle on the car, and its AC, AR, HP and
crew count so the table can read a vehicle without opening anyone's sheet.

The server grows a roster: occupants are spread across their own sheets - the
car does not know who is in it, the passengers know which car they are in - so
one pass turns that inside out. Only the numbers a table needs travel.

Seat anchors are generated rather than hand-placed. Crews run from one to
sixteen, and sixteen tuned positions per vehicle would be a lot of numbers to
get subtly wrong; the driver takes the nose and the rest fill alternating sides.

Turning someone else out is refused in the window as well as on the server -
the client check saves a round trip, the server one is what makes it a rule.
Says which vehicle a character is in, and clicking it gets them out. The icon is
inline rather than an imported .svg so it takes the theme through currentColor,
the same reason the person and eye icons are inline.

Shown on anyone's token but inert unless it is yours, or you are the GM. Hidden
would be wrong: seeing that someone is in a car is the reason it is on their
token at all. The server refuses the same thing independently - this only saves
a round trip.

The sheet had no way to know where you are sitting since occupancy stopped being
sheet data, so the server sends the vehicle alongside it.
Developer added 19 commits August 15, 2026 14:38
Version bump, changelog, and the project structure caught up with the eight new
modules and four new test files.

Filed as 1.9.0 rather than 1.8.2: this adds a subsystem and a database column,
and 1.8.2 is claimed by the CP:R stat work on its own branch, which should go
out first and separately.
CUSTOM was a hole rather than a feature. With no type a vehicle had no crew, no
hardpoints and no Trauma Target - so it seated one person, refused to fire the
mounts you could still fill in and see in the weapon picker, and took traumatic
hits twice as often as any real vehicle. None of that said anything on screen.

The section now holds nothing until you add something, and adding gives you a
Motorcycle to change into whatever you meant. That also retires the blank entry
of placeholder text that read like real data at a glance.

Changing the type renames the vehicle unless you have named it yourself. The old
rule kept any non-empty name, so a MOTORCYCLE changed to a Tank stayed called
MOTORCYCLE - a name that is only a type label is not a name.

Sections can seed an entry on ADD rather than revealing a blank row; weapons and
spells still reveal, as they did.
WHO IS ABOARD in the VEHICLES section header, beside the collapse toggle rather
than inside the section, so folding the section away does not take it with them.
Sections declare the label; what the button does is the surface's business, and
the standalone sheet tab passes no handler so it shows nothing - it has no
windows to open.

WHO_IS_ABOARD on the token menu too, but only when the table owns a vehicle at
all. An empty roster means the button says nothing worth saying.

Knowing whether there are any vehicles moves the roster into a hook the App
holds, and the window takes it as props. One subscription, so a button that
opens the window and the window itself cannot disagree about whether there is
anything in it.
The window said NO VEHICLES on a sheet with a helicopter plainly on it. The hook
was handed socketRef.current rather than the ref: a ref's current is not
reactive, so mutating it does not re-render, and a hook that reads it once
before the socket exists binds to nothing and never binds again.

Takes the ref and waits for it, the way useInitiative already did - which is why
that pattern is there. A test holds it: hand the hook an empty ref, fill it in
afterwards, and the request still goes out.
Half the car sat below the fold. The window's content pane caps at 300px and
scrolls, and the diagram was a square the full width of the window - so it
overran every time, hiding the seats that matter most on a five-seater.

The diagram is sized from the vehicle instead. Seats stack in two columns down
the hull, so what sets the height is rows a side: two for a car, eight for an
APC. A car now needs a fraction of the room sixteen people do, and the window
widens to match rather than leaving the art floating in it.

Square and centred, so the art, its leader lines and the controls at either edge
scale as one piece and cannot drift apart. Bounded by the viewport at the top
end, so a Dropcraft cannot run off the screen either.
A Dropcraft's right-hand seats were cut off. The window class sets max-width
400px, and a max beats a width outright - so the inline width the diagram asked
for was ignored and the window never grew past 400 whatever it was given. Raised
inline, bounded by the viewport instead.

The dropdowns listed login names. Nobody at the table thinks of each other by
account name, so the roster sends the character name alongside the username: the
username stays the key everything is written against, the name is only the
label. The occupant list on the badge and in the attack panel reads the same way,
falling back to the login where a sheet has no name yet.

identity is required lazily inside the one function that needs it - it requires
this module for the vehicle mirror, and a cycle at load time left one of the two
half-built, which failed four unrelated combat tests.
The window sat empty until a vehicle was added or removed. A client asks for the
roster the moment its socket connects, which is before it has been identified -
the handler needs a username and drops the request silently, so nothing ever
came back. The next sheet save was what finally got one through.

Pushed on identify instead, once the system is known to be CWN. The explicit
request stays for everything after that.
Seat markers, leader lines and badges were a hardcoded amber. In monochrome that
was the one coloured thing on an otherwise white interface; under Cyberpunk it
sat on a yellow wireframe it barely separated from.

Each theme sets a --vehicle accent instead: amber where the wireframe is green
or red, cool blue where it is already yellow or orange, white in monochrome.

The badge on the sheet's title bar also read as coloured text on a bar painted
that same colour - invisible in monochrome. It sits on black there, the way the
buttons beside it already do.
Amber on pink, on red, on cyan. The accent was a hue no theme owned, so it read
as something borrowed from another interface wherever it landed.

Two changes. The accent is now each theme's own secondary rather than one colour
imported across all of them. And the seat furniture stops using it at all: the
leader lines, the labels and an empty seat are part of the diagram, so they are
drawn in the diagram's colour. The accent is left to mark the one thing worth
calling out — that somebody is sitting there.

Same on the token badge, which is framed like every other token label now, with
the accent only on the text.
Three from a look at it running.

The badge was drawn in the seat accent, which read as a stray teal among green
buttons. The accent means 'somebody is sitting here' inside the diagram; a button
sitting among buttons should look like them, so it takes the theme green.

WHO_IS_ABOARD said what the window contains rather than what it is. The token
menu says VEHICLES, matching the window and the sidebar entry; the sheet's
section header says SEATING, since a button labelled VEHICLES inside a section
called VEHICLES says nothing.

The badge moves to the token window's title bar, where the sheet already puts
it. Full width in a column of full-width buttons was more room than a short
vehicle name needs.
Clicking it emptied the seat but the badge stayed. selectedLocation is a
snapshot taken when the token was clicked, so it still said 'in a vehicle' after
the server had recomputed otherwise - the seating window, reading the live
roster, updated correctly at the same moment. It reads the live row now, the way
the hit points menu already did for the same reason.

It also read as a label rather than a control. Yours says LEAVE MOTORCYCLE,
because a button should say what it does; someone else's stays a statement of
fact, since it does nothing when clicked.
The anchor put seat one alone at the nose and started pairing from seat two, so a
car read as driver at the front, shotgun behind them on the left, and B.LEFT
across the aisle on the right. Shotgun is the seat beside the driver.

Seats pair off from the front instead: the first two are the front bench, the
next two the row behind, and an odd seat left at the end takes the centre line at
the back — which is where a rear gunner sits, and where a lone rider belongs on a
motorcycle. A truck reads as a two-seat bench, a tank as driver, gunner and a
commander behind them.
The roster hook and six test files were missing, and the vehicle modules had been
listed inside templates/ rather than beside it - so the tree said they lived
somewhere they do not. The CWN template line still described an occupancy block
that was retired and a field count from three revisions ago.
The window bullet described what it does without saying how anyone reaches it,
and left out that players are listed by character name. Also adds the theming
line: the vehicle accent follows each theme and marks occupancy rather than
decorating the diagram.
The whole subsystem is new in 1.9.0, so a bug found and fixed inside it never
reached anyone. Listing those under Fixed tells a reader upgrading from 1.8.1
that something was broken in the version they were running, which is not true —
that is development history, not release notes.

The three ways into the seating window are a list now rather than a sentence
buried in a paragraph, since finding it is the first thing anyone needs.
…eating

# Conflicts:
#	CHANGELOG.md
#	README.md
#	frontend/package.json
#	package.json
Combat already wrote a vehicle's HP when someone shot the car. What it never
covered was the repair afterwards, or a crash, or a ram - all of which meant
opening the owner's sheet and editing a number by hand.

The bar grades green to amber to red at the same thresholds the character
health windows use, and reads WRECKED at zero.

Both directions clamp to the hull server-side. Destroyed is derived from HP
rather than stored, so an unclamped write could put a vehicle in a state the
rules have no name for - above its maximum, or below zero and wrecked twice.

Everyone sees the bar; only the owner and the GM get the buttons. Taking
someone else's car apart is what shooting it is for.
The box was stretching to the buttons' height while its own 2px padding kept
the text pinned near the top, which read as a taller, misaligned control.
Zero vertical padding lets the flex row set the height and the browser centre
the value in it, so the three controls are one band.

Digits only, stripped rather than rejected: a fumbled 1e2 or a pasted 12hp
leaves you with the number instead of an empty box. type=number would not have
done it - it still accepts e, +, - and a decimal point. Four digits is the cap,
which is more hull than any vehicle in the book has.
The box was never too tall - it was the only control at full height.
.win95-window .upload-btn carries margin-top: 15px, which in a flex row
pushes both buttons down inside the stretched line while the box fills it,
so the box read as oversized and the row as misaligned.

Cleared on these two buttons rather than in the stylesheet, where every
other window still wants that gap.
@over2take
over2take merged commit 792f317 into main Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant