2026 10 10 shop slider design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-10. Screen: the hero's shop, src/app/Console/MyPortal/HomeTown/AccessoryShop/ (route shop, /shop/:merchant?/:userId?).
The shop shows a grid of four cards per page that cover the shop's own art (each backdrop already paints a counter and shelves), every item builds its own ion-modal, and icon-only items (every preset reward) show a broken image because stockShelf puts the icon name in image. The owner always meant the shop to show one item at a time on a slider driven by the back/next buttons. The game already draws buildings, temples and held items as voxel models, so the items for sale should be voxels too.
- Layout C: one item at a time, a slider.
- The item has no background of its own: the shop's backdrop shows around it.
- The search bar and the back/next buttons stay as they are and drive the slider; swiping the item moves it too.
- An empty shop gives a Game Master a button to start stocking it.
- Voxel source, option C: a hand-built shape when the icon clearly matches one, otherwise one generated from the item's icon, otherwise from its photo.
- Uploaded photos, option A: the slider shows a voxel built from the photo; Look closer shows the real photo.
Top to bottom, inside the existing ion-page (header with back button, sign and ATM button unchanged):
- Clerk card (kept): clerk name, wallet, charisma line, the Shelves filter. Its speech line is the greeting on arrival and the current item's line once the hero slides ("Ah, the ice cream cone! Cold and sweet. 25 GP."): the item's name, the first sentence of its description if any, and its price.
- Stage: the item's voxel model, slowly spinning, over the shop backdrop. No card, panel or fill behind it. A soft oval shadow (CSS, not 3D) sits under the model.
-
Item plate: a compact
rpg-boxholding the name, the price badge (dimmed with "need N more" when the hero can't afford it), the description, a position marker ("3 / 12"), a Buy button, and Look closer only when the item has an uploaded photo. The plate exists so text reads over the art; the item itself stays bare. - Footer (unchanged markup): search bar, back and next buttons.
- Back/next step one item (
ITEMS_PER_PAGEbecomes 1;pageis the 1-based position). They disable at the first and last item as they do now. No wrap-around. - A horizontal swipe on the stage (pointer events, over 50 px and more horizontal than vertical) steps the same way. Vertical drags scroll the page as usual.
- Each step plays a short slide-out / slide-in on the stage and plate and the
cursorsound. - Changing the search or the Shelves filter returns to item 1; a shrinking list never leaves the position past the end (existing watchers kept).
Buy opens an Ionic alert in the clerk's voice, the pattern buyWallet already uses: header is the item name, message is the clerk's line with the price, buttons "No thanks" and "Buy for 25 GP". Confirm calls the existing buyItem (PurchaseService, sounds and toasts unchanged). The per-item ion-modals and closeModal are removed.
Look closer opens a modal with the uploaded photo, the name and the description.
In shops that sell one (walletOfferAt), the wallet is the first slide, drawn with the hand-built wallet voxel. Its plate shows its blurb, price and note; Buy calls the existing buyWallet, so the locked, owned and short-of-GP rules are unchanged. It counts in the position marker. The Shelves filter never hides it; a search hides it unless its name matches.
-
Bare shelves (nothing stocked): the clerk's "shelves are bare" line. When
canAccessGameMaster()(fromsrc/router/_Guards/gm.guard.ts) is true, a Stock this shop button routes toxp-shop-itemswithshopId= the shop record's id (Compendium > Economy > Shops > this shop > Items). - No matches (stock exists but search/filter hides all of it): "Nothing on the shelves matches. Try another search or shelf." No GM button.
- Closed: unchanged; no stage, no plate, no footer.
The Wish List, Purchased and Favorites filter options stay as they are (they still do nothing). The nav bars, the header and the ATM modal are not touched.
All shapes are Box[] in the existing voxel space (src/components/organisms/HeroModel/voxel/mesh.ts: hero 16 units tall, +Y up) and become meshes through the existing buildMeshData. New code lives in src/components/organisms/ItemModel/.
- Reuses the nine held builders from
held.ts(sword, shield, wand, book, axe, hammer, bow, potion, torch), turned from hand space (+Z out of the fist) to stand upright on display.held.tsexports its builders for this; its own behaviour does not change. - Adds a wallet and a gift box.
-
itemKind(icon)matches the icon name against the held patterns but returnsnullwhen nothing matches (unlikeheldKind, which falls back to"hammer").
gridToBoxes(grid) takes an N x N grid of cells, each empty or { layer: "primary" | "secondary", color }, and returns boxes:
- primary cells are 2 units deep, secondary cells 1 unit deep, both centred on the same middle plane, so the primary shape stands out in relief;
- horizontal runs of cells with the same layer and colour merge into one box;
- the result is centred on x = 0, stands on y = 0, and is scaled so its largest side is 8 units (about a held item's length);
- an empty grid returns
[].
- Builds a hidden
<i class="fad fa-NAME">with the app's icon colour classes so the per-icon duotone colours apply, readscontentandcolorof its::before(primary) and::after(secondary), waits for the Font Awesome duotone font (document.fonts.load), draws each glyph onto a 24 x 24 canvas and keeps cells whose alpha is above half. - Where both layers cover a cell, primary wins.
- Returns
nullfor an unknown icon (no glyph content) so the next source is tried.
- Draws the photo at 24 x 24, removes the background by flood-filling from the four corners across near-identical colours, snaps colours to 4 bits per channel, and marks every remaining cell
secondary(one layer, 1 unit deep). - If the canvas is tainted (a cross-origin photo that blocks pixel reads), fails to load, or nothing is left after the background cut, returns
null.
itemVoxel(item) returns mesh data, trying in order and stopping at the first that yields boxes:
- hand-built shape when
itemKind(item.icon)matches; - generated from the icon;
- generated from the photo;
- the gift box.
Results are cached in memory by item id + icon + photo. The wallet slide asks for the wallet shape directly.
- Follows
useBuildingScene: oneWebGLRendererwithalpha: trueand a transparent clear, vertex-coloured material and lighting asuseBuildingScenehas them, 30 fps cap while idle. - Auto-spins; no drag-to-rotate (horizontal swipes belong to the slider).
- Pauses when off screen (IntersectionObserver) or when the document is hidden; disposes geometry and renderer on unmount.
- Prop
item(or ashapeoverride for the wallet); changing it swaps the mesh without recreating the renderer. Only one instance is on the shop page. - If WebGL is unavailable, falls back to the item's Font Awesome icon in its colours.
ShelfItem (src/lib/services/shop/PurchaseService.ts) gains icon: string (accessory icon) and photo: string (accessory imageUrl). image is removed and its readers in the shop are updated. The stockShelf spec covers the split: an icon-only accessory has photo: "", a photo-only one icon: "".
- Jest:
-
gridToBoxes: depths per layer, run merging, centring and scale, empty grid. -
itemKind: known icons match, unknown icons returnnull. -
itemVoxelorder with stubbed grid sources: hand-built beats generated, icon beats photo, allnullgives the gift box, and the cache. -
stockShelf: icon/photo split.
-
- Browser pane at 390 x 844 against the dev server, with seeded accessories in one shop: an icon item, a photo item, a hand-built match (a sword icon); back/next, swipe, search reset, Buy confirm and cancel, Look closer, the Bigger Wallet slide; an empty shop as a Game Master (button routes to the Items tab) and as a hero (no button); a closed shop.
- Gate:
chemx typecheck,chemx lint,chemx test tests/unit,chemx verify. - Cypress is out:
cy.seedWorldcannot restore with nobody signed in (known, tracked separately).