Skip to content

ux: clearer menus — X conflict fixed, confirms, refusal params, resume card, give counts, garage names, money format - #90

Merged
dop42 merged 12 commits into
mainfrom
ux/menus-2
Oct 4, 2026
Merged

dop42 merged 12 commits into
mainfrom
ux/menus-2

Conversation

@dop42

@dop42 dop42 commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #88, implementing the coordinator's decisions on its proposals. Item 1 (menu titles and breadcrumbs) is skipped on purpose: the header-less menu is the design the owner approved. core/*/exports.lua and the contracts are untouched. origin/main, including #89, is merged in.

Before → after

3. The X key (high priority)

  • Before: the progress bar's cancel and a call's decline/hang-up were both bound to X. The host fires every mapping on a key for a single press, in no promised order, so cancelling an eat or repair during a call also ended the call.
  • After: while a cancelable bar is up, it owns the press. The calls key checks the progress contract first:
    • If a cancelable bar is up and bound to the same key, the press belongs to the bar.
    • If the bar's handler already ran and the bar is gone, a cancel within the last 300 ms still counts as that press.
    • With no bar, a bar that cannot be cancelled, or the two bound to different keys by the player, X declines or hangs up as before.
  • Why this resolution: moving one of the two defaults to another key would only work until a player rebinds; this rule holds for any binding. To support it, progress.State now also reports cancelable, cancelKey and cancelledAtMs.
  • Tests: the two handlers are fired in both orders.

4. OPX.Refuse takes params

  • Before: a refusal could not carry params, so any module whose sentence names something (a garage, an amount) followed it with NotifyLocale. The player got two toasts, and one of them still showed the {placeholder}.
  • After: OPX.Refuse takes a fifth argument, params, so existing calls are unchanged. Params are limited to plain strings and numbers under plain keys, capped at 8 entries and 64 bytes per value. The client re-checks them before rendering.
  • Duplicates removed: the last helper that sent both a refusal and a NotifyLocale was in the inventory; it now sends only the refusal.

2. A confirm option for menu and eye rows

  • Menus: a row can carry confirm = true, or { title, yes, no }. The menu turns it into a two-row level:
    • The first row keeps things as they are, so Enter pressed without looking does nothing.
    • The second row performs the action under the same id and data, so the caller receives exactly the select it always did.
    • The shops' outfit delete, which used to build this by hand, now uses it.
  • Eye: every danger row now asks for a second click, unless it sets confirm = false. Any row can opt in with confirm = true.
    • The first click relabels the row Confirm: <label> in place, inside its folder. Only a second click on the same row within 4 s runs it.
    • The staff Remove (staff vehicle) used to run on one stray click; it now asks.
    • Kick and Ban opt out, because each opens a form that confirms on its own.

5. "Where I left off" on the spawn screen

  • Before: a returning character offered the spawn menu could only keep its position by picking nothing and waiting out an invisible 45-second timer.
  • After:
    • When the character's row holds a position, the server says so in the offer, and the client shows a first card, Where I left off / Là où j'étais.
    • Choosing it settles the choice with no point, so the row decides where the character lands.
    • A character that has never stood anywhere does not get the card, and asking for it anyway is refused.
    • A configured place may not use the card's id.

6. Inventory give and drop

  • Before: Give and Drop moved the whole stack in one click.
  • After: a stack of more than one opens the existing count dialog, titled Give how many? or Drop how many? and starting at 1. Its confirm button says Give or Drop, and the dialog doubles as the confirm step. A single item still goes in one click.
  • Give list: it showed #12 for each person; it now shows their character's name. The old code said "never a name"; this follows the owner's decision. The list still contains only people within arm's reach, and only the giver sees it.

7. Garage list

  • Before: each row showed only a plate, e.g. 8XK2L0P.
  • After: rows read Villefort Cortes · 8XK2L0P, the same label as that vehicle's key. The name derivation moved to OPX.Vehicle.DisplayName so the key and the garage list cannot drift apart.

8. Money formatting

  • Before: prices showed as bare numbers or with the raw currency code: Paid 4500 at Jinguji., 2:00 1500, 500 BANK.
  • After: OPX.Locale.Money(amount, type) formats every amount:
    • The thousands separator follows the language: 4,500 in English, and in French 4 500 with a narrow no-break space.
    • The currency is a catalogue word: €$, or €$ (bank) / €$ (banque).
  • Where it applies: character FormatMoney, and through it every dealership price, plus shops, crafting, hauling and the paycheck.
  • Bug avoided: GroupDigits reverses the string, so grouping directly with the multi-byte French separator would have reversed its bytes. Grouping happens on a one-byte placeholder that is swapped afterwards.

9. Appearance panel

  • Before: Body opened a level holding one disabled row, and Outfits opened a level holding one row.
  • After: both are top-level rows: the body type as a fact, and Open fitting room as the action.

10. Unused locale keys

  • Removed: 26 keys, each from both languages. Each was searched for as a literal and checked for a dynamic prefix before removal:
    • calls: the incoming card and live chip, holo.tab.nearby, and the duplicated noNear / noneNear
    • target: target.hint, target.back
    • inventory: inventory.title, inventory.ui.slots
    • shops outfits.code, vehiclekeys given, garages notYours, and appearance title / command.wardrobe
  • Kept: menu.key.*, because the menu contract's public Keys() still returns them.

11. No raw keys before the translations arrive

  • Before: the catalogue reaches the page in parts of 250 keys per frame. On a cold join, every label not yet delivered showed as its raw key, e.g. hud.voice.state.idle.
  • After:
    • The page's store marks the catalogue ready only when its last part lands. Until then, a missing key draws nothing; after that, a genuinely missing key is still shown as the key.
    • Parts are collected separately and swapped in whole, so a language change no longer blanks the live labels between parts.

Checks

  • OPX_LIB_PATH=… lua tests/run.lua: 4646 checks, 0 failed (after merging main).
  • New test sections cover:
    • the X key, with handlers fired in both orders
    • refusal params on the server and their rendering on the client
    • the menu confirm, end to end through the page
    • eye confirm flags
    • the appearance panel's levels
    • OPX.Locale.Money in English and French, multi-group
    • the garage model name
    • the give list's names
    • the spawn resume card, offered and refused
    • the catalogue-ready rule, read off the source because the suite has no browser
  • luac -p on every Lua file, tools/check-sandbox-globals.py, and npm ci && npm run typecheck && npm run build all pass. web/index.html is rebuilt and committed.
  • No new files, so the manifest and path lengths are unchanged.

Notes for review

🤖 Generated with Claude Code

dop42 and others added 12 commits October 5, 2026 00:51
…ding the call

The progress bar's cancel and a call's decline/hang-up are both X out of the
box, and the host fires every mapping on a key from one press, in an order
nobody promises. Cancelling an eat or a repair while on a call therefore
also hung up the call (or declined the one ringing).

The rule now: a cancelable bar that is up takes the press. The calls key asks
the progress contract first. If a cancelable bar is up on the same key, the
press is the bar's. If the bar's handler already ran and the bar is gone, a
cancel in the last 300 ms counts as this press too. With no bar, or a bar
that cannot be cancelled, or the two bound to different keys by the player,
X declines or hangs up exactly as before. `progress.State` carries the two
facts this needs (`cancelable`/`cancelKey` of the bar that is up, and when
the key last cancelled one).

Tested with the two handlers fired in both orders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…usal toast goes

`OPX.Refuse` could not carry params, so a module whose sentence names
something followed it with `OPX.NotifyLocale`: two toasts in two lanes, and
the refusal's copy read `{garage}` or `{total}` with the placeholder left in.
It takes `params` now (fifth argument, so every existing call is unchanged),
bounded to plain strings and numbers under plain keys -- a refusal still
carries no internals by accident -- and the client renders the sentence
with them, checking them again.

The inventory's `refuse` was the last helper sending both; it sends the
refusal alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd on the eye

Menus: a row may carry `confirm` (true, or `{ title, yes, no }`). The menu
contract turns it into a two-row level whose FIRST row keeps things as they
are, so Enter pressed without looking does nothing, and whose second does
what the row said under the same id and data -- the caller hears exactly
the select it always heard. The shops' outfit delete, which built this by
hand, uses it.

The eye: a `danger` row now asks for a second click unless it says
`confirm = false` (any row may opt in with `confirm = true`). The first
click relabels the row `Confirm: <label>` in place, inside whatever folder it
sits in; only a second click on the same row within four seconds runs it.
The staff `Remove (staff vehicle)` ran on one stray click and now asks. Kick
and Ban opt out: they open a form, which confirms on its own.

web/index.html rebuilt for the page half of the eye's confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Body` opened a level holding a single disabled row, and `Outfits` a level
holding a single row whose label was a sentence: two presses to read one
fact, two to open the fitting room. The body type is now a disabled row on
the top level with its note as the description, and `Open fitting room` is
a top-level action. Their old labels go from both catalogues.

A test opens the panel and requires that no level of it holds a single row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Twenty-six keys, in both languages, that no Lua, Vue or TypeScript file
reaches -- each grepped for as a literal and checked for a dynamic prefix
before it went:

- calls: the incoming card and live chip (`calls.incoming.*`,
  `calls.live.*`), removed with the eye rows they named -- one of them still
  told players to "Hold ALT on yourself to answer" -- plus `holo.tab.nearby`
  and the duplicated `holo.noNear` / `holo.noneNear`;
- target: `target.hint` and `target.back`, sent to the page and drawn by
  nobody; the page's label type loses them too;
- inventory: `inventory.title`, `inventory.ui.slots` (and its slot in the
  labels payload);
- shops `outfits.code`, vehiclekeys `given`, garages `notYours`,
  appearance `title` and `command.wardrobe`.

`menu.key.*` stays: the menu contract's public `Keys()` answers them.

web/index.html rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncy's word

Prices reached players as bare numbers -- `Paid 4500 at Jinguji.`, `You
cannot afford that: 4500 needed.`, a crafting row's `2:00  1500`, a hauling
payout, a uniform's price -- or with the configured code (`500 BANK`), and
the one formatter that existed hard-coded `€$` and a space separator.

`OPX.Locale.Money(amount, moneyType)` is the one door now: the thousands
separator is the language's (`4,500` in English, `4 500` in French with a
narrow no-break space), the currency is a catalogue word (`€$`, `€$ (bank)` /
`€$ (banque)`), and a type with no word is named rather than dropped. The
character module's `FormatMoney` (and so every dealership price) uses it, as
do shops, crafting, hauling and the paycheck.

The grouping is done on a one-byte comma and swapped afterwards:
`GroupDigits` reverses the string, which would have reversed the bytes of the
French separator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The garage list was a column of plates -- `8XK2L0P`, `4KD9X1A` -- and nobody
recognises their car by its plate in two seconds. The server now sends each
vehicle's model as a player reads it and the row says `Villefort Cortes ·
8XK2L0P`, exactly as that vehicle's key is labelled in the bag.

The record-to-name reading the vehicle keys already had moves to
`OPX.Vehicle.DisplayName` in lib/shared, so the key and the garage cannot
drift into two names for one car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give and Drop moved the WHOLE stack in one click: handing over one of twenty
bandages meant splitting first, then giving -- three steps -- and a misclick
gave away all twenty. A stack of more than one now opens the count dialog
the split already had, titled `Give how many?` / `Drop how many?`, starting
at one; its confirm says `Give` or `Drop`. A single item still goes in one
click, where there is nothing to choose. The dialog is the confirm step too.

The give list read `#12` -- a server slot -- for each person in reach. It
now reads their character's name, falling back to the slot only when no
character is loaded. The nearby answer used to be "never a name"; this is
the owner's call, and the list still holds only the people within arm's
reach, shown only to the one about to hand them something.

web/index.html rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd wait

A returning character offered the spawn menu (OFFER_POLICY 'always') could
keep its position only by picking nothing and waiting the timeout out: the
page never said so, and Escape did nothing. The server now tells the client
whether the character's row holds a position, and the client puts a first
card, `Where I left off` / `Là où j'étais`, before the configured places.
Choosing it settles the choice with no point -- the row decides, which is
the resume -- and the toast says `Back where you left off.`

A character that has never stood anywhere is not offered the card, and
asking for it anyway is refused with the menu left up. A configured place
may not take the card's id. The page needs no change: the card is a location
like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page answers a missing key with the key itself, which is right once the
catalogue is there -- a key that does not exist should be loud -- and wrong
before it: the catalogue arrives in parts of 250 a frame, and on a cold join
every label not yet delivered painted as its raw key (`hud.voice.state.idle`
on the HUD, `calls.holo.title` in the sphere).

- The store marks the catalogue ready only when its last part has landed,
  and until then `useLocale.t()` draws nothing for a miss: a blank label for
  a frame or two, never a key. After that, a genuinely missing key is still
  drawn as the key.
- Parts are gathered aside and swapped in whole, so a language change no
  longer empties the live labels for the frames between its parts.

A source-level check holds both, since the suite has no browser.
web/index.html rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	modules/progress/client/main.lua
@dop42
dop42 merged commit 11b1ccf into main Oct 4, 2026
1 check passed
@dop42
dop42 deleted the ux/menus-2 branch October 4, 2026 23:31
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