2026 10 10 shop slider design - xopherdeep/do-it-for-the-xp GitHub Wiki

Shop slider with voxel items: design

Date: 2026-10-10. Screen: the hero's shop, src/app/Console/MyPortal/HomeTown/AccessoryShop/ (route shop, /shop/:merchant?/:userId?).

Why

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.

What the owner decided

  • 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.

The screen

Top to bottom, inside the existing ion-page (header with back button, sign and ATM button unchanged):

  1. 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.
  2. 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.
  3. Item plate: a compact rpg-box holding 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.
  4. Footer (unchanged markup): search bar, back and next buttons.

Moving through items

  • Back/next step one item (ITEMS_PER_PAGE becomes 1; page is 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 cursor sound.
  • Changing the search or the Shelves filter returns to item 1; a shrinking list never leaves the position past the end (existing watchers kept).

Buying

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.

The Bigger Wallet

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.

Empty and closed

  • Bare shelves (nothing stocked): the clerk's "shelves are bare" line. When canAccessGameMaster() (from src/router/_Guards/gm.guard.ts) is true, a Stock this shop button routes to xp-shop-items with shopId = 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.

Not in this pass

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.

Item voxels

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/.

Hand-built shapes: voxel/itemShapes.ts (pure)

  • 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.ts exports 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 returns null when nothing matches (unlike heldKind, which falls back to "hammer").

Generator: voxel/voxelize.ts (pure)

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 [].

Icon to grid: voxel/iconGrid.ts (browser)

  • Builds a hidden <i class="fad fa-NAME"> with the app's icon colour classes so the per-icon duotone colours apply, reads content and color of 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 null for an unknown icon (no glyph content) so the next source is tried.

Photo to grid: voxel/imageGrid.ts (browser)

  • 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.

Choosing a source: voxel/itemVoxel.ts

itemVoxel(item) returns mesh data, trying in order and stopping at the first that yields boxes:

  1. hand-built shape when itemKind(item.icon) matches;
  2. generated from the icon;
  3. generated from the photo;
  4. the gift box.

Results are cached in memory by item id + icon + photo. The wallet slide asks for the wallet shape directly.

3D view: XpItemModel.vue + useItemScene.ts

  • Follows useBuildingScene: one WebGLRenderer with alpha: true and a transparent clear, vertex-coloured material and lighting as useBuildingScene has 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 a shape override 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.

Data change

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: "".

Testing

  • Jest:
    • gridToBoxes: depths per layer, run merging, centring and scale, empty grid.
    • itemKind: known icons match, unknown icons return null.
    • itemVoxel order with stubbed grid sources: hand-built beats generated, icon beats photo, all null gives 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.seedWorld cannot restore with nobody signed in (known, tracked separately).
⚠️ **GitHub.com Fallback** ⚠️