Skip to content

v0.75.0 — the app on a phone

Twenty-six tasks. Most were found by watching someone use the app on a phone: the viewport zoom trap, eight list-page failures that all fall out of handing a 375px screen the desktop layout, and thirteen character-sheet fixes from the same playtest. Three came from elsewhere and are unrelated to each other — a party only its creator could see, an API that answered every logged-out request with a 500, and a cleanup migration that had never once run. The second of those opens these notes, because of who it misled.

It also carries a module release: system8 0.12.0, pinned in modules.hosted.json, without which two of the character-sheet tasks below have nothing to read.

Every logged-out request to /api/* in production came back 500 Internal Server Error, as a generic HTML page, including requests to routes that do not exist — the /api mount of requireAuthWithImpersonation runs before route matching, so a bad path failed auth before anything discovered it was a 404. Nothing was actually wrong with the server. Clerk's ClerkExpressRequireAuth reports a failed authentication by calling next(new Error("Unauthenticated")) rather than by writing a response, and nothing downstream turned that into a status code, so Express's default error handler answered it.

There was a handler for this, and it had never run once. It sat above the route table, and Express only offers an error to error-handling layers registered after the layer that raised it — so it neither logged its diagnostics nor changed the status. The fix is one mount point and a guard that keeps it there.

This is worth a release note rather than a silent patch because of who the 500 misled. A client cannot tell "your session expired" from "the backend is down", so the SPA had no basis for sending you to sign-in; and anyone reading a production trace saw server faults where there was only ordinary logged-out traffic. The bug was found while investigating a report that map.purplecat.games was down. It was not down.

Modules

  • [x] modules.hosted.json pins system8 0.12.0. Two of the tasks below need it — the Ability Scores pool and the School filter's class grouping — and prod and alpha load the versions this file pins, not whatever the module repo's main holds. MODULE_API_VERSION goes to 1.29.0 for secondaryBonusBudgetAtLevel and secondaryBonusLabel; both are additive, so a 1.28.0 module keeps working and simply shows no budget, which is what every sheet did before.

Mobile

  • [x] The gesture that zooms the UI in is tapping a text field, and the page can now be pinched back out. There was no mystery gesture. iOS zooms the whole page when a focused form control computes below 16px, and it does that whatever the viewport meta tag says — Safari has ignored user-scalable=no and maximum-scale on this point since iOS 10. So the tag we had never prevented the zoom-in; all it did was remove the way out. Once zoomed, the fixed header, footer and side rails are anchored to a visual viewport wider than the screen, which is how elements end up off-screen or under one another with no way to recover them. Both halves are fixed, at both ends. The viewport tag drops maximum-scale and user-scalable, so pinch works cleanly in both directions — that is the preferred option in the ask, and blocking zoom is a WCAG 1.4.4 failure besides. And the zoom-in stops happening at its source: a (pointer: coarse) rule in index.css floors text-entry controls at 16px. Mantine sizes inputs from --input-fz, whose sm default is 14px, so effectively every field in the app was a trigger. The floor is written as max(16px, …) over Mantine's own variable so larger inputs keep their size, and as three :not()s rather than a bare input selector because Mantine's rule is a class and would otherwise win on specificity. Checkbox, radio and range are excluded: they hold no text, so a floor would only inflate the control. Allowing page zoom does not cost the map or the artwork panes their own pinch handling — both already set touch-action: none, so the browser never sees those gestures.

Character sheet

  • [x] The Contents/Issues toggle is no longer under the footer, because the page frame no longer runs past it. The toggle sits at the bottom of the docked aside — the only place it can, since it belongs to whichever aside is showing and has to follow it — so anything covering the bottom of that column covers the one control that changes the layout. What covered it was the page's own frame: CharacterPage's PageLayout sized itself calc(100vh - 50px), a single approximate term for the top nav (which is 48, not 50) and no term at all for the footer. The footer became position: fixed in v0.58.0 and reserves no space of its own, which is why it was not in the sum when this was written and why it needed to be afterwards. The frame now subtracts GLOBAL_TOPNAV_HEIGHT and FOOTER_HEIGHT — the shared constants the rest of the shell already offsets by, rather than a number typed in by eye, which is what let the first term go stale unnoticed. Guarded by sheetFrame.test.ts, which rejects any pixel literal in the frame's arithmetic.
  • [x] Both ways to add a feat/power say what they do, and the second one has somewhere to put the trait. The two add actions have existed since v0.42.0, but as bare icons — a green tick and a blue skip-track glyph — so the second read as decoration and the keep-open path went unused. They are labelled buttons now: Add & Close and Add & Keep Open, with the first falling back to plain "Add" outside the scaffolded views, where nothing closes the modal and the longer label would be a lie. Behind "keep open", the question of which slot the next one goes in moves to shared/services/slotSuggestion.ts, which prefers an empty slot the trait is legal in, falls back to an empty slot it is not (over-rank — a rank-3 feat in a level-1 slot, the same shape of mistake as a 3rd-level spell in a 1st-level slot), and failing that returns an over-quota slot the scaffolded views already render under "Overloaded". It never returns nothing: previously, a full sheet closed the modal on a player who had just asked for another feat, so a fourth feat on a three-slot character was unaskable. Errors will now mount up silently as a player stacks traits; making that legible is a deliberate follow-up, not part of this. The suggester is resolved per system through a registry keyed by (systemId, kind) and shaped like the Module API hook that will replace it, so when a system owns this rule the call sites do not change. Two bugs fell out of sharing one slot model: the continue handler walked System 8's feat levels even on a 5e character, whose sheet renders ASI levels, so it advanced to slots that were not on screen; and it ignored user-added extra feat slots entirely.
  • [x] The Options section reports every setting in Play mode, including the ones nobody has set. It was not showing nothing by accident: each row was gated on being "worth reading" — a chat colour only if one was set, validation only if it differed from the default, a background only if a corner was pinned — with "No options set." behind all three. On an ordinary player character none of the three held, so the section was blank on essentially every sheet, and a section that is empty in the mode most people read it in reads as broken rather than as quiet. The judgement was also being made in the wrong place. "Validation: Flag" is only noise to a reader who already knows what the default is, and the reader who does not is exactly who the row is for. Play mode is now the same list of settings Build mode offers — character type, validation, chat colour, sheet background — with values in place of controls, each saying where its value came from: the character, or the default behind it. There is no "No options set." any more because there is no longer a case where the list is empty. The background row changes with it. v0.73.0 showed pinned corners only, on the grounds that a derivation is not a decision; it now shows all four painted corners in the same 2x2 the editor uses, marking which are pinned, with the caption carrying the chosen-versus-inherited distinction. A grid whose cells silently renumber answers a different question than the one the reader asked.
  • [x] A power with actions is a power row plus a caret. The multi-action row had stopped being a list row at all: it was a <Button variant="default"> carrying the trait's name, sized to match the action buttons it revealed. The reasoning behind that was about rhythm — keep the height of a one-action neighbour — and it cost the row all three things the report names. A name that is a button does not read as openable, which is why the description had to be reached through an (i); and a shrink-to-fit button with the controls packed against its right edge left those controls wherever the name happened to end, so no two rows agreed on where their buttons were. It goes back through TraitListRow, the same primitive the action-less row uses: the name is a dotted-underline link that opens the trait, the controls right-align against the primitive's own flex: 1 filler, and the caret — an icon button beside them — is now the only difference between a power with actions and one without. That is the ask stated structurally rather than as a set of matching styles, which is also what stops the two shapes drifting apart again. The (i) goes with it, and the rule behind it narrows rather than disappears: it exists for a row whose name is not the way into the description, which since v0.58.0 has meant the one-action row, where the label is the action's name on the button that casts it. That row keeps its (i), because nothing else on it opens the trait.
  • [x] The value chips wear a grip, and the copy leads with dragging. It is the same base for both systems — one modal, configured by whichever module declares the method — so this is one fix rather than two. Each unassigned value now carries the IconGripVertical nub the feat and power lists use for reordering, so the two affordances read as one vocabulary. The grip is decorative and the whole chip stays the drag source: making the nub the only draggable part would shrink a 44px target to 10px in order to advertise itself, and would break the tap path that touch depends on. The copy led with clicking and mentioned dropping only in the sentence about swapping, so a player who had not already guessed the chips were draggable had no reason to try. It now names both, drag first — that is the one the grip advertises — with tap after, since that is the one that works on a phone. Making the copy honest turned up a real dead end behind it. "Every value is assigned. Click one to move it." was untrue: a filled ability did nothing when clicked with nothing held, and no chip was left to pick up, so a mis-assignment could only be undone by re-rolling — which discards the throw — or by cancelling. unassignAbility had been written and unit-tested since 2f.2 with nothing ever calling it. Clicking a filled ability now takes its value back, with the label and the cursor saying so, and withUnassignment is the draft-level helper that was missing.
  • [x] The Ability Scores section shows the pool being spent from, not only the per-stat ceiling. Two limits govern System 8 proficiency and only one was expressible. CS-1.1's secondaryBonusCapAtLevel has always limited ONE ability — level + 3 — which the sheet showed as a + that stops responding; how many points exist in total, 2 * level + 6, had no declaration at all, so a player allocating them had nothing on screen to spend against and learned about an overspend from validate() afterwards. That is the wrong end of the decision, so this is a Module API addition rather than a label. Module API 1.29.0 adds AbilityScoreSchema.secondaryBonusBudgetAtLevel and secondaryBonusLabel, and system8 0.12.0 declares both — the budget through the module's own getTotalProficiencyPointsForLevel, so the number the sheet reports is the number validation checks against. Additive: a module that declares neither shows no line, which is every module that predates it, 5e included. What the platform does with them is arithmetic it could already do. It sums the secondary components it stores — the same secondaryTotal the tiles' steppers move by, so "secondary" means one thing on the section — and compares. It never learns what a point is; the noun comes from the module, into a sentence the platform composes: "5 proficiency points spent of 10 available". Overspend is coloured, never prevented, which is the never-block rule the validation panel already obeys — a GM handing out a bonus point should not have to fight the sheet.
  • [x] Colour fields say they hold no credential, so nothing offers to fill them. How it was happening: nothing looks at the value. A password manager decides from the element — a type="text" input with no autocomplete and no name it recognises — and ColorInput renders exactly that, because a hex string has to be typeable. Masking is a consequence of type="password", not a cause of the offer, so a field showing #7048e8 in plain text is as eligible as one showing dots. It takes a set of attributes rather than one. autocomplete="off" is the standard signal and the browsers do not all honour it — Safari and iOS Chrome have both, at various versions, ignored it for their own Keychain suggestions, precisely to defeat sites that switched autofill off on password fields. The data-* opt-outs are what the third-party managers publish for themselves, and 1Password, LastPass, Bitwarden and Dashlane each read a different one while ignoring the others. There is no single switch. Applied as theme defaultProps on both skins, not at the 35 call sites: a colour field added tomorrow inherits it, where 35 edits would have been 35 chances to forget and the 36th would have been this bug again. Riding along, autoCorrect / autoCapitalize / spellCheck go off too — a mobile keyboard capitalising a hex string is the same root cause, which is that nothing had ever told it what the field holds.
  • [x] It was the admin, and the sheet now says so. The parenthetical was right. The GM path is correct and was never the cause: a campaign GM's floor is control, a row can only raise it, and mayBuild admits owner and edit only — so a GM holding a "can play" row gets no Build toggle and no unlock buttons at all. Both facts are pinned by tests now, because the report is only explained if the first one is true. ADMIN_USER_ID resolves as owner on every character, matching every other permission check in the codebase. That is deliberate and stays. What it lacked was any trace of itself: the response said owner exactly as ownership does, so an admin opening someone else's sheet saw a sheet indistinguishable from one they own. That is what a permission bug looks like from the outside, which is why it was filed as one — and it is also a real hazard, since an admin can edit a stranger's character believing it is theirs. GET /api/characters/:id now attaches _admin when the resolved owner came from the override rather than from ownership, and the sheet header carries an "Admin access" badge explaining it. Ownership is checked first, so an admin on their own character is not flagged — that would be the same confusion inverted. The flag grants nothing and gates nothing; it explains access that already exists, and like _level it is registered as never writable, because a client able to send it would be claiming to be the admin. Not changed: the override itself. Withdrawing the admin's access to characters is a decision about how the site is operated, not a bug fix, and /admin/data depends on it.
  • [x] A title-less section renders its right-hand slot, so the meta section gets its unlock button — and everything else it was missing. The button was always being built for it: CharacterSheet renders one SectionLockButton per section with no kind check anywhere, and MetadataSection already honoured sectionEditMode. What dropped it was the Section primitive. A section that passes no title — Identity and Token Identity, which lead with the character's own name at heading size — took a branch that rendered headerLeft and discarded headerRight entirely. So the unlock button was one of several casualties: those two sections also had no validation indicator and no section control of any kind. v0.58.0 had already found half of this — it gave a title-less section a row so its reorder grip would exist — and stopped at the left slot. The row now carries both, in the same space-between shape SectionHeader uses, so a control lands in the same place whether or not its section has a title. The empty left group is deliberate: space-between with a single child pushes it to the start, so a lone unlock button would have appeared on the wrong side.

Edit Artwork Modal

  • [x] One finger pans and two fingers pinch, on every pan/zoom surface in the modal. Two distinct faults, both from the same shape of code. Every surface snapped a single {x, y} on pointerdown and subtracted from it on pointermove, which is complete for a mouse and half the story for a touch screen: Zoom had no touch input at all. It was onWheel, and a touch screen fires no wheel event, so a phone could reframe an image and could not scale it by any gesture whatsoever. And a second finger corrupted the pan: the second pointerdown overwrote the first pointer's origin while the first finger was still down, so the picture jumped to wherever the new finger landed and then followed whichever pointer moved last. Attempting to pinch produced an erratic pan — the "not working well" in the report. The replacement treats the two as one measurement rather than two modes. A gesture is its pointers' centroid and their spread, both defined for any number of fingers: one finger moves the centroid and leaves the spread at zero, two move both. utils/pinchGesture is that arithmetic, unit-tested; hooks/usePinchPan is the pointer bookkeeping, and the part that could not be pure — it re-takes the origin whenever the pointer count changes, so a second finger landing does not divide by an origin spread of zero and a finger lifting does not snap the image by the gap between the two-finger centroid and the survivor. Scale is a ratio against the gesture's own origin, not a pixel delta, so the same pinch means the same thing on a phone and a tablet. All four surfaces go through it — portrait edit, both slot previews, and the token editor, which additionally never had touch-action: none and so had been losing its drags to the modal's scroll. The wheel handlers stay, for the mouse.

System 8 Feat/Power Picker Modal

  • [x] The filters are a drawer inside the modal. They were a 300px <Popover> anchored to the Filters button, which on a 375px viewport covered the list it was filtering and the active-filter tags that sit under the search row — so nothing about the current selection was visible while adding to it. (The playtest note "current filters is taking up most of the filter modal on mobile" is the same surface from the other side.) FilterSlideOver is one track two panes wide, translated by half its own width — which is one modal width — with the Filters button carrying the right-facing caret that says the content moves sideways rather than a panel appearing over it, and a Back button in the same position on the way out. Both panes stay mounted, so neither loses scroll position or an open dropdown on the trip; the track stretches to its taller pane so the modal does not resize mid-slide, which would make the animation about itself rather than about where the content went. The off-screen pane is aria-hidden and inert — aria-hidden alone leaves the back button tabbable from the list view, which is a focus trap for anyone not using a pointer. Shared by both pickers rather than written twice, which also retires a z-index workaround: a portalled popover lands at Mantine's default 300, below a modal the host stacks at 2000, and v0.53.0 had to raise it. A drawer inside the modal has no second layer to raise.
  • [x] Schools are grouped under the classes that own them, with a toggle. The section was one flat alphabetical run of a dozen-plus checkboxes with nothing saying which class any of them came from, so picking "Plants" required already knowing that Plants is an Elementalist school. The playtest note "used multiple classes for power filter, multiple schools at the same time" is what happens when the two lists look independent. The relationship is not declared anywhere in the system data — it exists only as co-occurrence across the powers — so system8 0.12.0 computes it in the pass its tokens endpoint already makes over every record and ships it as TokenField.parents. It reports every owning class rather than one: nothing guarantees exclusivity, and a server picking a winner would be inventing a rule. Grouped by default, with the section's own options menu (upper right) turning it off for a player who knows the schools and wants the shorter list. The menu appears only when grouping would look different — a module with no mapping, or one whose schools all share a class, would get a control that changes nothing. And the ungrouped view is the same code path, not a second branch: groupSchoolsByClass with no mapping returns one catch-all group holding everything, which is the flat list.
  • [x] Ticking a school ticks the class that owns it. Filters within a group OR and filters across groups AND, so "Fighter + Plants" asks for a power that is both — and Plants is an Elementalist school, so the answer is correctly empty and looks exactly like a broken filter. The default class filters make that the common case rather than the odd one: the modal opens with the character's own classes ticked, so the first school a player picks from another class empties the list. The report's own fix, and the right one, because the class is not an extra restriction the player is choosing — it is implied by the choice they already made. With it, the two ticks mean what they look like they mean: "Fighter powers, or Elementalist powers of the Plants school". A school owned by two classes implies both. Deliberately one-way. Unticking a school leaves the class ticked: it may have been chosen on purpose, and removing a filter the player set because of one they set later is the surprising half of a rule whose whole point is to stop surprising them. A class already present is not duplicated, and a negated one is left alone rather than silently reversed — NOT Elementalist is an instruction, and quietly flipping it would be worse than the empty list this fixes. Nothing about how the server combines filters changes; the fix is in what the client sends, which is what made it the easy one.

Parties

  • [x] A party made in a campaign belongs to that campaign, and everyone in it can see and join. They were scoped to userId on every route, so a GM who made a party for their table was the only person who could see it — and there was nothing to widen the scope to: "every player in this campaign" was not a question the data could answer, because a party had no campaign. parties.campaign_id is the missing fact, nullable in both dialects, added by a generated postgres migration and a boot-time ALTER for databases already running. No backfill, deliberately. Every party that predates this has no campaign, and one made from the character list has none either; both keep exactly the owner-only visibility they had. Inferring a campaign from a party's characters would silently widen the visibility of every party on the server, which is a worse bug than the one being fixed. The rule lives in one helper. A party with a campaign is visible to everyone in that campaign — where "in" means what it means everywhere else in the app, via the same campaignsForUser the library uses: campaigns you own plus ones whose invitation you accepted. Members may read it, add a character and remove one; renaming and deleting stay with the owner, which was not asked for and is a different feature with a different failure mode. Two holes closed on the way. Unlink checked only "the party is yours", which was sufficient only while a party was only ever yours — a campaign member could otherwise have removed anyone else's character from a shared party. And creating into a campaign you have no access to is refused rather than silently dropped: dropping it would produce a party its creator believes is shared and nobody else can see, which is this bug exactly. The client attaches the campaign in useParties rather than at each of six createParty call sites, for the same reason.

API

  • [x] An unauthenticated /api/* request returns 401, not 500. The handler moves out of index.ts into middleware/authErrorHandler.ts, beside impersonation.ts and for the same reason — it can be unit-tested without booting the server — and mounts last, immediately before server.listen(), so it sits below the core routes, the module routers that mount inside start(), and the local-storage static mount. It answers only Unauthenticated; every other error falls through untouched, so a genuine fault keeps its 500. The diagnostics the dead handler was written to log now log, at warn rather than error, since a logged-out request is routine traffic and not a fault. Guarded from both sides: an integration test drives a real Express app over real HTTP and asserts that mounting after the routes yields 401 while mounting before still yields 500 — pinning the Express semantics the fix depends on — and a source guard asserts index.ts still mounts it below every /api registration and that nothing routes between it and listen(). Local boot cannot catch this class of bug at all: without CLERK_SECRET_KEY the dev fallback bypasses auth and injects dev_user, so the unauthenticated path never executes outside a deployed environment.

Migrations

  • [x] The processedUrl cleanup migration is registered, so production finally runs it. migrations-pg/0024_strip_processed_url.sql shipped in v0.70.0 with no entry in meta/_journal.json, and drizzle builds its work list from the journal — it reads ${tag}.sql for each entry and never lists the folder — so the file had sat there for five releases without executing anywhere. Nothing else applied it either: no runbook step, no script, no boot-time fallback. The deprecated field it was written to remove was therefore still in every artworks row in production. It is journalled now, and renumbered to 0025 on the way: v0.75.0's generated 0024_overjoyed_hydra had meanwhile taken the same numeric prefix, and two files sharing a number is how the un-journalled one stayed invisible — the folder listing looked complete. It would have failed boot had it ever been reached. Run for the first time against real postgres, it raised cannot get array length of a non-array on the very first statement. The jsonb_typeof("artworks") = 'array' that was supposed to protect jsonb_array_length("artworks") > 0 sat beside it in the same AND list, and postgres orders qualifiers by cost and selectivity rather than by source order — EXPLAIN shows it putting the length test first. One row whose artworks is an object rather than an array aborts the migration, postgresRunMigrations rethrows by design, and the deployment does not come up. The guard is now "artworks" <> '[]'::jsonb, which compares instead of destructuring and so cannot throw in any evaluation order. The sqlite half never had the problem — json_array_length returns 0 for a non-array instead of raising — but its journal entry was missing too, and is added. Verified end to end rather than by inspection: a database migrated to 0024, seeded with a processedUrl row alongside empty-array, NULL and non-array rows, then migrated again picks up exactly one new migration and comes out clean. The guard is the part that generalises. The existing tests executed the sqlite SQL against real SQLite and checked that the pg file was present on disk — both pass perfectly while the migration is inert, because neither asks the only question that decides whether it runs. stripProcessedUrl.test.ts now asserts that both halves are journal entries, that no .sql file in either folder is unnamed by its journal, that no two migrations share a numeric prefix, and that journal idx and when both increase — a stale when is journalled and still never runs, since drizzle applies an entry only when its timestamp exceeds the newest created_at already recorded.

Mobile list pages

A phone in portrait gets the desktop layout at a quarter of the width, and every one of the following falls out of that single fact. The page header is a space-between row of three unshrinkable things, so the Create button hangs off the right edge. The card grid's track floor is 220px, wider than the room a 375px viewport has left after the container's padding, so auto-fill lays one track and the cards spill instead of fitting. The landing page's strips spend two of their few hundred pixels on pan chevrons, and the only route to the rest of the list sits below the strip where a horizontal scroll can never reach it. And the hamburger — the one control that is mobile-only — opens a menu of unlabelled icons.

These are eight tasks and not one "make it responsive" sweep because they fail independently: the strips are wrong on a desktop too (the chevrons), the hamburger is wrong at every width, and the header and the grid are wrong only below a threshold each has to name for itself.

  • [x] The hamburger opens a nav with words in it. MobileNavDrawer asks for <SideNav expanded />, and SideNav destructures that prop to _expanded and never reads it — it resolves collapsed from the layout store instead, which is the desktop rail's hover state. So the full-screen drawer renders the 44px-wide icon rail's markup stretched across the viewport: centred glyphs, no labels, and a tooltip affordance that a touch device cannot trigger. The drawer passes collapsed={false} explicitly, and expanded either drives collapsed or stops being part of the signature — a prop that is accepted, ignored, and named with a leading underscore is how this survived since v0.24.0. Guard: a test that asserts the drawer cannot render the collapsed variant.

  • [x] PageHeader stacks when the row cannot hold all three. Title, search and CTAs are three flex occupants of one Group justify="space-between" wrap="nowrap"; the CTA group is flexShrink: 0 by design (a squeezed button is worse than a wrapped one), so on a narrow viewport the row is simply wider than the page and the primary action is clipped. Below a breakpoint the header becomes a column: title (with breadcrumbs) on its own line, then the search input at full width, then the CTA row at full width with its buttons free to wrap. "Unless there is ample room" is the whole rule — this is a media query on the header itself and not a viewport-size hook, so a wide phone in landscape keeps the one-line layout.

  • [x] Card grids fit the viewport instead of overflowing it. The grid is repeat(auto-fill, minmax(220px, 1fr)), and 220px is a floor that a narrow column cannot honour — auto-fill will lay a track wider than the container rather than none at all, which is the horizontal scrollbar on every list page in portrait. The floor becomes min(220px, 100%) so the track collapses to the available width, giving one full-bleed column on a phone and two on a large phone or a small tablet, cards filling the width after gaps in both cases. The template is exported and shared with CardSkeletonGrid, so both move together and the loading state keeps matching the loaded one.

  • [x] Frequently Accessed caps at two rows, whatever the column count is. Today it caps at a fixed collapsedCount = 6 items and lays them out with Group wrap="wrap", so "one row" is true only at the width the 6 was chosen for: on a phone six 140px tiles are three rows, and on a wide monitor they are half of one. It becomes the same measured arithmetic the strips use — a real grid, its column count derived from the observed width, and the cap columns × 2. Two rows, not one, because the section's value is recognition at a glance and a single row on a phone is two campaigns.

  • [x] A list page shows every card it has, without a pager. EntityList's grid paginates at 20 with a <Pagination> control at the bottom, which on a phone is a row of small tap targets under a column of large ones, and it hides items behind a control most users on a touch device will not find. The grid renders an initial window and appends the next as the sentinel at the end of the list scrolls into view (IntersectionObserver, with a "Show more" button as the fallback when the observer is unavailable — the test environment is node, and a list that can only be advanced by an API jsdom does not implement is a list that cannot be tested). The list layout keeps its pager: it is used inside modals and panels that do not own the page's scroll. The footer is already position: fixed and stays where it is; the growing list must not be allowed to push it.

  • [x] Strips indicate scrollability by peeking, not by chevrons. The pan buttons cost ~34px each off a 375px viewport — a fifth of the room a card has — to do something a touch device does natively, and they were the strip's only scroll affordance, which is why the cards were sized to fit exactly: a strip whose last card ends flush at the right edge looks like the end of the list. The buttons go (on every width, not just mobile), and the track width is instead computed so that a whole number of cards fits with a deliberate remainder — enough of the next card visible to read as "there is more this way". The arithmetic lands in rowStrip.ts beside columnsForWidth and is unit-tested there.

  • [x] "Show all" is the last card in the strip, not a button under it. It is the only route past the strip's cap, and it currently sits in a right-aligned row below the strip — which is reachable by a vertical scroll on a desktop and, on a phone where the strip is the width of the screen, is not what the user is doing when they run out of cards. It moves inside the scrolling track as a final tile, so scrolling right to the end lands on it. It keeps its data-testid and its (n) count, and it appears on the same condition as before — never when the strip already holds everything.

  • [x] The breadcrumb sections render on mobile. AppShell renders GlobalTopNav only when !isMobile, so a phone gets MobileTopNav: a hamburger, a profile button, and 300px of nothing between them. The campaign / map / character section menus are the app's only in-page way to switch campaign or map, so their absence is why a phone can leave a campaign but not move sideways within one. The left half of GlobalTopNav is extracted so MobileTopNav can render the same sections between its two controls, truncating rather than wrapping, with the same popup z-index it uses on the desktop.