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.jsonpins 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'smainholds.MODULE_API_VERSIONgoes to 1.29.0 forsecondaryBonusBudgetAtLevelandsecondaryBonusLabel; 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=noandmaximum-scaleon 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 dropsmaximum-scaleanduser-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 inindex.cssfloors text-entry controls at 16px. Mantine sizes inputs from--input-fz, whosesmdefault is 14px, so effectively every field in the app was a trigger. The floor is written asmax(16px, …)over Mantine's own variable so larger inputs keep their size, and as three:not()s rather than a bareinputselector 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 settouch-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'sPageLayoutsized itselfcalc(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 becameposition: fixedin 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 subtractsGLOBAL_TOPNAV_HEIGHTandFOOTER_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 bysheetFrame.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 throughTraitListRow, 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 ownflex: 1filler, 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
IconGripVerticalnub 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.unassignAbilityhad 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, andwithUnassignmentis 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
secondaryBonusCapAtLevelhas 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 fromvalidate()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 addsAbilityScoreSchema.secondaryBonusBudgetAtLevelandsecondaryBonusLabel, andsystem80.12.0 declares both — the budget through the module's owngetTotalProficiencyPointsForLevel, 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 samesecondaryTotalthe 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 noautocompleteand no name it recognises — andColorInputrenders exactly that, because a hex string has to be typeable. Masking is a consequence oftype="password", not a cause of the offer, so a field showing#7048e8in 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. Thedata-*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 themedefaultPropson 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/spellCheckgo 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, andmayBuildadmitsownerandeditonly — 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_IDresolves asowneron 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 saidownerexactly 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/:idnow attaches_adminwhen the resolvedownercame 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_levelit 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/datadepends 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:
CharacterSheetrenders oneSectionLockButtonper section with nokindcheck anywhere, andMetadataSectionalready honouredsectionEditMode. What dropped it was theSectionprimitive. A section that passes notitle— Identity and Token Identity, which lead with the character's own name at heading size — took a branch that renderedheaderLeftand discardedheaderRightentirely. 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 samespace-betweenshapeSectionHeaderuses, so a control lands in the same place whether or not its section has a title. The empty left group is deliberate:space-betweenwith 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}onpointerdownand subtracted from it onpointermove, which is complete for a mouse and half the story for a touch screen: Zoom had no touch input at all. It wasonWheel, 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 secondpointerdownoverwrote 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/pinchGestureis that arithmetic, unit-tested;hooks/usePinchPanis 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 hadtouch-action: noneand 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.)FilterSlideOveris 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 isaria-hiddenandinert—aria-hiddenalone 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
system80.12.0 computes it in the pass its tokens endpoint already makes over every record and ships it asTokenField.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:groupSchoolsByClasswith 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 Elementalistis 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
userIdon 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_idis the missing fact, nullable in both dialects, added by a generated postgres migration and a boot-timeALTERfor 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 samecampaignsForUserthe 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 inusePartiesrather than at each of sixcreatePartycall sites, for the same reason.
API
- [x] An unauthenticated
/api/*request returns 401, not 500. The handler moves out ofindex.tsintomiddleware/authErrorHandler.ts, besideimpersonation.tsand for the same reason — it can be unit-tested without booting the server — and mounts last, immediately beforeserver.listen(), so it sits below the core routes, the module routers that mount insidestart(), and the local-storage static mount. It answers onlyUnauthenticated; every other error falls through untouched, so a genuine fault keeps its 500. The diagnostics the dead handler was written to log now log, atwarnrather thanerror, 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 assertsindex.tsstill mounts it below every/apiregistration and that nothing routes between it andlisten(). Local boot cannot catch this class of bug at all: withoutCLERK_SECRET_KEYthe dev fallback bypasses auth and injectsdev_user, so the unauthenticated path never executes outside a deployed environment.
Migrations
- [x] The
processedUrlcleanup migration is registered, so production finally runs it.migrations-pg/0024_strip_processed_url.sqlshipped in v0.70.0 with no entry inmeta/_journal.json, and drizzle builds its work list from the journal — it reads${tag}.sqlfor 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 everyartworksrow in production. It is journalled now, and renumbered to0025on the way: v0.75.0's generated0024_overjoyed_hydrahad 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 raisedcannot get array length of a non-arrayon the very first statement. Thejsonb_typeof("artworks") = 'array'that was supposed to protectjsonb_array_length("artworks") > 0sat beside it in the sameANDlist, and postgres orders qualifiers by cost and selectivity rather than by source order —EXPLAINshows it putting the length test first. One row whoseartworksis an object rather than an array aborts the migration,postgresRunMigrationsrethrows 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_lengthreturns 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 aprocessedUrlrow 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.tsnow asserts that both halves are journal entries, that no.sqlfile in either folder is unnamed by its journal, that no two migrations share a numeric prefix, and that journalidxandwhenboth increase — a stalewhenis journalled and still never runs, since drizzle applies an entry only when its timestamp exceeds the newestcreated_atalready 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.
MobileNavDrawerasks for<SideNav expanded />, andSideNavdestructures that prop to_expandedand never reads it — it resolvescollapsedfrom 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 passescollapsed={false}explicitly, andexpandedeither drivescollapsedor 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]
PageHeaderstacks when the row cannot hold all three. Title, search and CTAs are threeflexoccupants of oneGroup justify="space-between" wrap="nowrap"; the CTA group isflexShrink: 0by 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-fillwill 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 becomesmin(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 withCardSkeletonGrid, 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 = 6items and lays them out withGroup 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 capcolumns × 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 isnode, and a list that can only be advanced by an API jsdom does not implement is a list that cannot be tested). Thelistlayout keeps its pager: it is used inside modals and panels that do not own the page's scroll. The footer is alreadyposition: fixedand 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.tsbesidecolumnsForWidthand 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-testidand 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.
AppShellrendersGlobalTopNavonly when!isMobile, so a phone getsMobileTopNav: 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 ofGlobalTopNavis extracted soMobileTopNavcan render the same sections between its two controls, truncating rather than wrapping, with the same popup z-index it uses on the desktop.