Skip to content

Claude/pixi react UI theme uj63lt - #31

Open
billsonnn wants to merge 149 commits into
mainfrom
claude/pixi-react-ui-theme-uj63lt
Open

Claude/pixi react UI theme uj63lt#31
billsonnn wants to merge 149 commits into
mainfrom
claude/pixi-react-ui-theme-uj63lt

Conversation

@billsonnn

Copy link
Copy Markdown
Contributor

No description provided.

claude and others added 30 commits August 21, 2026 05:48
Invert the render bootstrap so the room and the Pixi-rendered UI share
a single Application/renderer/GPU context (no duplicate texture
uploads): PixiApplicationRoot now owns the one renderer, adopted via a
new SetRenderer() in nitro-renderer, and mounts the room's existing
stage as its bottom layer. RoomCanvas and Nitro's bootstrap sequencing
are updated accordingly.

Add a theme-pixi package mirroring the DOM theme system's public shape
(Box as the flex/positioning primitive via @pixi/layout, Border/Button
with native Pixi tinting and nine-slice sprites, icon/text-style/
interaction-state hooks), reusing the existing variant-cascade system
verbatim. Port PurseView to PurseViewPixi as a working proof of concept.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
@pixi/layout only positions/sizes an object if `.layout` is explicitly
set on it (confirmed by reading Layout.getRoot()/LayoutSystem.mjs in
node_modules): an object with no `.layout` is invisible to the yoga
tree and stays at Pixi's raw default position. NitroIcon,
NitroCurrencyIcon, and Button's inline text never set `layout` at all,
so icons and text ignored gap/justifyContent/sizing entirely. Both
components now default `layout` to `{}` so leaf content always joins
the tree (still overridable via a new `layout` prop).

Also, app.stage - the root of the yoga tree for all Pixi UI - never
had its own `.layout`, so a top-level `position: 'absolute'` child
(e.g. PurseViewPixi's outer Border) had no sized, positioned ancestor
to resolve `top`/`right`/etc against and landed at (0, 0) regardless.
PixiApplicationRoot now gives app.stage a `.layout` matching the
renderer's screen size (position: 'relative', so it's a valid
containing block), kept in sync on the renderer's 'resize' event.

Verified with a headless-browser pixel readback of the actual canvas
(toDataURL, since preserveDrawingBuffer is normally false): the Purse
widget now renders in the correct screen position with icons and text
correctly placed by the flex layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Frame + its supporting pieces (Header, ContentArea, Scaler, CloseButton)
port theme/Frame.tsx and its siblings for variants '0' and '3' (the two
actually used across the DOM views: FriendList/dialogs use '0', Catalog/
Inventory use '3'). Reuses the DOM package's variant-cascade system
verbatim, same as Border/Button.

Dragging and resizing are Pixi ports of hooks/ui/useFrameDrag.ts and
useFrameResize.ts:
- Pixi's FederatedPointerEvent has no setPointerCapture (confirmed
  absent from pixi.js's own types), so both hooks drive their gesture
  off window-level pointermove/pointerup listeners - the same
  technique the DOM drag hook already uses, just applied to resize too.
- The drag offset is applied as the frame's own `.x`/`.y`: @pixi/layout
  ADDS the yoga-computed position on top of a container's existing
  `.position` (see updateLocalTransformWithLayout in
  node_modules/@pixi/layout), the same layering CSS `transform:
  translate()` gives the DOM version, so no fighting with the layout
  system for position during a drag.
- Frame's width/height precedence now matches the DOM version: an
  active resize (stored/dragged size) overrides the caller's own
  layout, the same way the DOM Frame's resize-driven inline style
  out-specifies its className defaults - caught by testing, the first
  pass had this backwards.

Also fixes a real gap found while building this: nine-slice
frame/header art and the closebutton/scaler sprites needed slicing a
sub-region out of a shared spritesheet texture (CSS background-position
in the DOM version) - added utils/useSpriteFrameTexture.ts for that,
and registered TilingSprite (the header's repeating background) in
pixiElements.ts.

Verified end-to-end with two live Frame instances (variants 0 and 3) in
a headless-browser pixel readback: correct position, size, drag-ready
z-order/header, close button, and content placement, with zero console
errors. Not wired into any real view yet (out of scope for this pass,
matching the PurseView-proof-of-concept precedent) - Frame is ready for
whichever windowed view migrates next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
…not just Box

Reported crash: "Text instances are not yet supported" when a Frame's raw
string children (e.g. `<Frame>some text</Frame>`) reached Pixi's reconciler,
since - unlike a DOM <div> - pixiContainer has no implicit text-node concept.

Box already special-cased its own JSX-authored children, but that only
covers `<Box>{string}</Box>` directly. ContentArea and Border both forward
an arbitrary caller-supplied `children` through VariantCascadeProvider (a
passthrough Fragment/Context.Provider with no host node of its own) before
it lands inside their own Box - so a raw string never appears as a
JSX-authored child of that Box; it only surfaces once React fully resolves
the passthrough wrapper at render time, past the point Box's own
Children.map can see it.

Extracted the wrapping helper to theme-pixi/utils/wrapTextChildren.tsx and
apply it directly in ContentArea and Border, at the point where each still
owns the caller's children, matching the exact Frame -> ContentArea ->
raw-string stack trace from the report.
…der parity

Batch 1+2a of the theme parity pass: cross-cutting rendering primitives that
every remaining component builds on, plus Border.tsx expanded from 2 to all
19 DOM variants as the reference implementation for the new patterns.

- utils/Layer.tsx: NineSliceLayer/SpriteLayer/TileLayer wrap the existing
  fill-absolute + eventMode="none" background-layer idiom already used ad
  hoc in Frame/Header; CompositeLayer renders the multi-piece DOM variants
  (Border 101/102/103/106/107, and later Button/ContainerButton/Frame) as
  positioned pixiSprites via the same absolute+inset stretch mechanism
  @pixi/layout already uses everywhere else in this package; BlendOverlay
  reproduces Border's `blend` white-wash tint as a live masked Graphics
  overlay (same masked-Sprite technique as theme/utils/pixiTint.ts's
  tintImage(), just without its canvas-bake round trip, which buys nothing
  in Pixi).
- utils/textStyles.ts: expanded from 2 to all 18 real text-style-* keys in
  theme/utilities.css (font family/size, plus the 3 utilities that bake in
  their own fill color). The two Frame-referenced classnames that don't
  actually exist in utilities.css (text-style-id-frame-title,
  text-style-il-frame-modal-title - dead Tailwind utilities in the DOM
  source) are intentionally not ported; those Frame variants get no special
  text style, matching what DOM actually (fails to) render.
- Border.tsx: all 19 variants. Plain nine-slice variants (0-10, 100, 104,
  105, 108, 200) use NineSliceLayer; the 5 composite variants (101, 102,
  103, 106, 107) use CompositeLayer with piece geometry transcribed from
  the DOM's background-position/background-size values, including two
  cross-piece asset-reuse quirks (101 and 107 both reuse their center-left
  texture for center-right) preserved as-is to match shipped art. 104/105
  get their overlay via NineSliceLayer/CompositeLayer; `blend` support
  added via BlendOverlay. Tint colors ('9', '108') match DOM exactly.
  Variant '100' is the one DOM variant whose border-image-slice omits
  `fill` (CSS leaves the center transparent); Pixi's NineSliceSprite has
  no equivalent, so this port always fills the center - noted in a comment
  rather than silently diverging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
…ded components

Batch 2 of the theme parity pass: every component previously ported with
only the 1-2 variants the PurseView/Frame proof-of-concept used now covers
its complete theme/ variant table, including tint colors, overlay/shine
layers, and (for Frame/Button/ContainerButton-style composites) the
multi-piece background composites, using the Layer.tsx/textStyles.ts
primitives from the previous commit.

- Border: 2 -> 19 variants (composite variants 101/102/103/106/107 via
  CompositeLayer, overlay variants 104/105, blend support, full tint table).
- Button: 1 -> 13 variants (per-state nine-slice geometry since 100/101
  change slice width between default and hover/active; 11-piece composite
  overlay for 100/101 with its pressed-state asset+spacer swap, 2-piece
  curve overlay for 102/103; per-variant text style/color; '1's pressed
  state correctly reuses `border-3-default-src`, a real DOM cross-asset
  quirk, not a bug).
- CloseButton: 2 -> 7 variants, including '1'/'2's static (no real hover,
  active===default) art and '100's separate fixed-size texture.
- Scaler: 2 -> 6 variants + the per-variant offset-positioning table (only
  '0'/'3' actually position bottom-right in DOM - '1'/'2'/'4' define no
  offset class at all, preserved as-is) + shine overlay for 0/1/2.
- Header: 2 -> 8 variants (tile/stretch/nine-slice backgrounds, shine
  overlay for 0/1/2, the caption/close-button tint-color background chip,
  per-variant text style/color/font-aa drop shadow, and the real margin-
  vs-padding inconsistency between variants 0/2 and 1 preserved via Yoga
  margin vs. padding rather than flattened to one behavior).
- Frame: 2 -> 9 variants, the universal drop-shadow (all DOM variants get
  `drop-shadow-[2.83px_2.83px_4px_rgba(0,0,0,0.349)]`) via a Pixi
  DropShadowFilter, shine overlays, and variant '100' reusing Border's own
  `border-101-*` composite asset set wholesale (a real DOM asset-reuse
  quirk).
- ContentArea: fixed a real bug (not just an unported variant) - variant
  was accepted as a prop and cascade-resolved but had zero effect on the
  render; now applies '0's bottom padding and '3's `position: relative`.
- NitroIcon/useIconTexture: 4 -> 264 icon keys (184 discrete PNGs from
  icons.css, 80 spritesheet frames from habbo-icons.css), full parity with
  both DOM icon registries.
- NitroCurrencyIcon: added the DOM `Image.tsx`-equivalent loading/error
  fallback (a `loading.icon.url` texture shown whenever the real currency
  texture hasn't resolved, matching DOM's "stays visible forever on error"
  behavior since Pixi has no distinct loading-vs-errored texture state).
- Added `pixi-filters` (already a transitive/hoisted dependency via
  nitro-renderer) as a direct nitro-react dependency for DropShadowFilter.

Found and fixed two real bugs during verification (headless Playwright,
multiple simultaneous Frame instances each containing Button+Border):
1. FRAME_3_SHINE's nine-slice args were passed in the wrong order
   (leftWidth/topHeight swapped vs. the base layer), corrupting variants
   3/4/7's shine overlay geometry.
2. The new DropShadowFilter defaulted to resolution 1 while the app
   renders at `GetPixelRatio()` - the mismatch caused a stale-texture
   ghosting artifact (a fragment of one Frame's content bleeding into
   another's) whenever 2+ filtered Frames rendered in the same tick, only
   reproducible with multiple simultaneous Frames. Fixed by passing the
   app's own resolution to the filter explicitly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
…eckBox, RadioButton, Bubble family

Batch 3 of the theme parity pass: the remaining "simple" theme/ primitives
that weren't part of the original PurseView/Frame proof-of-concept, each
following the variant-table + Layer.tsx recipe established in the
previous two commits.

- ButtonThick: 6 variants (0-2 stretch sprites, 3-6 nine-slice), per-
  variant text style/color, no overlay (DOM's overlay config exists but
  every entry is empty).
- ContainerButton: 14 variants. Extensive real cross-component asset reuse
  confirmed directly from the DOM source (not guessed): 0/1/2 reuse
  Button's own textures, 3/6 reuse ButtonThick's, 100-103 reuse Button's
  100-103 textures AND overlay composites verbatim - extracted the shared
  11-piece/2-piece composite-overlay data Button and ContainerButton both
  need into utils/buttonOverlayPieces.ts (Button.tsx now imports from
  there too, replacing its former local copy). Preserves DOM's missing
  `not-aria-disabled:` cursor guard (ContainerButton always shows a
  pointer cursor, unlike Button/ButtonThick) and variant '1's
  `border-3-default-src` pressed-state quirk (same as Button's own).
- ButtonGroupLeft/Center/Right: 4 variants each, sharing one
  `createButtonGroupComponent` factory (utils/buttonGroupFactory.tsx)
  since the three DOM components are otherwise byte-identical apart from
  asset-key prefix and slice geometry. `aria-selected`/`active:` collapse
  into one Pixi `selected` state since DOM swaps to the same art for both.
- CheckBox/RadioButton: sprite-sheet toggle components, `selected` fully
  caller-controlled (matching DOM's `aria-selected`-driven, non-
  self-toggling design). Preserves CheckBox's real DOM quirk where
  pressing a checked box reverts to the unchecked frame while held down,
  and both components' variant '2' reusing variant '0's exact sprite
  positions.
- Bubble/BubblePointer/FramePointerDown: chat-bubble family. Bubble's
  pointer-direction layout (up/down/left/right) ported via
  `flexDirection`/`flexDirection-reverse`; BubblePointer's 4 direction
  configs each get their own sprite-sheet frame + per-side Yoga margin
  matching DOM's small negative-margin nudge into the bubble body.
  FramePointerDown is DOM's simplest component (one variant, no states).

Verified via headless Playwright: all new components render correctly
inside Frames (selected states, nine-slice chrome, sprite frames, text)
with no console/page errors and no ghosting artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
…t/Tooltip

Batches 4-6 of the theme parity pass.

Tab family (TabContext, TabContent, TabButton, TabContainerButton): a
shared utils/tabButtonChrome.tsx holds the border-image variant table used
by both TabButton and TabContainerButton (identical chrome, TabButton adds
padding/sizing/text on top of it - mirrors the buttonGroupFactory.tsx
pattern from the previous commit). TabContent's variant '3' has an
asymmetric nine-slice (only the top edge sliced) plus a negative top
margin tucking it under the tab bar, reproduced via Yoga's negative-margin
support. `aria-selected:`/`active:` collapse into one `selected` prop
throughout, consistent with every other selection-driven component ported
so far. VARIANT_CASCADE_CONFIG's existing `tabContext` -> `tabButton`
cascade (reused verbatim from #base/theme) means a TabButton nested under
a shiny TabContext auto-renders as the shiny variant with no explicit prop.

Accordion family (Accordion, AccordionItem, AccordionContext,
AccordionTrigger, AccordionContent): AccordionContext is pure React
Context with zero DOM dependency, so it's re-exported verbatim from
#base/theme rather than forked - and Accordion.tsx reuses that same
Context *object* directly (not a re-implementation), so a Pixi
Accordion.Provider and the shared useAccordion/useAccordionItem hooks stay
correctly wired together. AccordionContent has zero open/close animation
in DOM (hard conditional mount/unmount, no transition, no Motion usage
anywhere in it or its call sites) - the instant show/hide here is exact
parity, not a cut corner. AccordionTrigger's `alwaysOpen` case maps to
`eventMode="none"` in Pixi, which is strictly more correct than DOM's
still-technically-wired-but-inert click handler (it also lets pointer
events reach whatever's beneath the trigger, which DOM's div can't do).

Dropmenu/DropmenuItem/Droplist/DroplistItem/Tooltip: static-skinning
parity only. Confirmed via full-codebase research earlier in this pass
that DOM ships zero open/close/positioning/portal logic for any of these
five - Dropmenu is only ever rendered as a bare closed box with no items,
Droplist/DroplistItem/Tooltip/DropmenuItem have no call sites at all - so
this port matches that reality (variant/tint/overlay-arrow art only) and
does not add new floating-menu behavior DOM never had. Tooltip's DOM class
string (`text-[#000000] text-style-u-tool-tip`) actually renders WHITE
text in the browser, since text-style-u-tool-tip's own baked-in
`text-white` is the later, winning declaration in Tailwind's cascade -
reproduced here via the text style's own color rather than the literal
(losing) black hex, matching real DOM output over the misleading literal.

Verified via headless Playwright: tab switching, accordion open/close,
dropdown arrow overlays, and selection states all render correctly inside
Frames with no console/page errors and no ghosting artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Ports theme/'s scroll system to theme-pixi/: useScrollController (Pixi
equivalent of useScrollbarController, since Pixi has no native scroll to
drive/be driven by), ScrollViewport (masked clipping window), useHoldToRepeat,
useRowVirtualizer (a small from-scratch row virtualizer replacing
@tanstack/react-virtual, which is inherently shaped around a DOM scroll
element Pixi doesn't have), the full ScrollbarVertical/Horizontal chrome
family, ScrollArea, and InfiniteGrid.

Also fixes a real, previously-shipped rendering bug found while building this:
a plain pixiSprite/pixiGraphics doesn't stretch to fill its parent via
`layout={{position:'absolute', top:0, left:0, right:0, bottom:0}}` alone in
this @pixi/layout version - it needs explicit width/height:'100%' too, since
NineSliceSprite/TilingSprite are the only leaf types that apply their computed
size directly. Fixed in Layer.tsx's shared FILL_LAYOUT (used by SpriteLayer/
BlendOverlay) and Header.tsx's TintChip.

This completes the theme-pixi package's full variant/color/overlay parity
with theme/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Every component repeated the same three blocks of logic verbatim:
- Variant resolution (useCascadedVariant + resolvedVariant + ownCascade lookup)
- Interaction-state -> art resolution (resolveLayerState/resolveTabChromeLayer,
  identical in Button.tsx, ContainerButton.tsx, buttonGroupFactory.tsx and
  tabButtonChrome.tsx)
- Background-layer kind switching (nineSlice/composite/tile/stretch),
  duplicated character-for-character between Border.tsx and Frame.tsx and
  reimplemented separately in Header.tsx

Extracted three shared primitives and adopted them everywhere they apply:
useResolvedVariant (utils/useResolvedVariant.ts), resolveByState +
NineSliceLayerState/nineSliceLayerState (utils/useInteractionState.ts), and
BackgroundLayer (utils/Layer.tsx). Applied to Border, Frame, Header, Button,
ContainerButton, buttonGroupFactory (used by ButtonGroupLeft/Center/Right),
tabButtonChrome (used by TabButton/TabContainerButton) - no behavior change,
verified via a visual regression pass showing identical rendered output
before/after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Mechanical follow-up to the shared theme-pixi primitives consolidation:
replaces the repeated cascadedVariant/resolvedVariant/ownCascade block with
useResolvedVariant() in the 12 scrollbar-family components (ScrollbarVertical/
Horizontal, all slider bar/track/button sub-parts, ScrollableItemList/GridVertical).
No behavior change - eslint/tsc verified clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Mechanical follow-up completing the shared-primitives consolidation: replaces
the repeated cascadedVariant/resolvedVariant/ownCascade block with
useResolvedVariant() in Bubble, BubblePointer, ButtonThick, CheckBox,
CloseButton, ContentArea, Droplist, DroplistItem, Dropmenu, DropmenuItem,
FramePointerDown, RadioButton, Scaler, TabContent, TabContext, Tooltip.

Also opportunistically replaced three local per-state art resolvers
(DroplistItem, ButtonThick, CloseButton) with the shared resolveByState
utility where their state tables were an exact shape match.

No behavior change - eslint/tsc verified clean, visual regression pass shows
identical rendered output before/after across all touched components.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
First batch of the views/ -> views-pixi/ migration (see
/root/.claude/plans/pixi-views-migration.md for the full plan): ports
ActivityPointsView, AvatarEditorView and MessengerView, following the
Wrapper/Component/ViewPixi pattern already established by the Purse/Catalog
migrations. AvatarEditorView and MessengerView are themselves DOM stubs
("test"/"chats" placeholder content) - ported at the same stub fidelity.

Adds a shared ColorLayer primitive to theme-pixi/utils/Layer.tsx (a flat
solid-color fill, matching a DOM `bg-[#hex]` div) and switches Header.tsx's
local TintChip over to it, since views will need the same solid-fill pattern
constantly. Exposes utils/Layer.tsx from the theme-pixi/ barrel now that
views (not just theme-pixi components) need to consume it directly.

Splits MessengerComponent out of FriendListWrapper so it can move into
Nitro.tsx's Pixi tree independently - FriendListComponent still renders DOM
views and can't move until FriendList's own migration.

NotificationCenterView (theme/'s own source always returns null) and
HotelView (empty div, wrapped in DOM-only motion.js transition machinery
outside the view itself) have nothing to migrate - dropped rather than
ported as no-ops, noted in MainView.tsx.

Verified via eslint/tsc (105-error pre-existing baseline, zero new) and a
Playwright visual check of all three views rendering correctly with no
console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Adds useAvatarImageTexture (wraps AvatarRenderManager's already-decoded
cropped avatar image in a Pixi Texture.from(), reusing the same engine
call components/AvatarImage.tsx makes rather than round-tripping through
a CSS background-image), a shared ToolbarMenu component behind
ToolbarMeMenuPixi/ToolbarProgressionMenuPixi (structurally identical DOM
sources), and ToolbarViewPixi itself (collapse toggles, me/progression
popups, circular avatar icon masked via Pixi). Wires ToolbarViewPixi into
Nitro.tsx's PixiApplicationRoot in place of the DOM ToolbarView.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
InventoryFurniViewPixi reuses theme-pixi's row-virtualizer/scroll-
controller primitives (the same ones InfiniteGrid is built on) directly
with a fixed 4-column layout, matching DOM's own hand-rolled
react-virtual usage rather than InfiniteGrid's auto-column behavior.
InventoryPetsView/BotsView/BadgesView (byte-identical DOM stubs) share
one InventoryStubTabPixi. Wires InventoryComponent into Nitro.tsx's
PixiApplicationRoot in place of the DOM InventoryView.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Adds two new shared theme-pixi primitives this migration needed for real
(not stub) DOM behavior: TextInput (a faked editable text field - Pixi has
no native editable text object - backing every real search/message box
hit so far) and useOutsideClick (geometric hit-test port of
hooks/ui/useOutsideClick.ts, used by the friend-row relationship
dropdown). Extends AccordionTrigger with optional onPointerOver/onPointerOut
passthrough for FriendListTab's hover-driven footer tooltip text.

Ports the full FriendList tree: root view, the shared Tab wrapper (DOM's
hard-edged two-color trigger gradient reproduced as two ColorLayer
halves), Friends/Requests/Search tabs, group/item leaves, footers, and
the two dialogs. Wires FriendListComponent to render the Pixi tree and
moves FriendListWrapper's mount point into Nitro.tsx's PixiApplicationRoot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Ports the Navigator window: root frame (collapsible left pane, dynamic
frame width, top-level context tabs), quick links pane, search bar,
category/room-entry results (rows and tiles modes, with the SWF's
alternating-row-color and user-count-color math reused verbatim from
NavigatorRoomEntryUtils.ts, which has no DOM dependency), and the
create/random/promote room action buttons. Drops the DOM version's
getBoundingClientRect-driven window-position telemetry sync (Frame
doesn't forward a ref; not worth widening its contract for a
background-only feature).

Extends Dropmenu/DropmenuItem with an optional onPress - unlike every
other confirmed-dead Dropmenu call site found so far, NavigatorSearchView
uses it as a real interactive filter-type picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Ports the full catalog tree: root frame with tabs, header, search,
navigation tree, item grid, product preview, purchase widgets, and the
purchase confirmation dialog.

Adds two new engine-integration hooks reusing existing DOM logic
verbatim (both are pure engine/state logic with the DOM-specific I/O
swapped out):
- useFurnitureImageTexturePixi mirrors useAvatarImageTexture's own
  approach for components/FurnitureImage.tsx's GetRoomEngine() call.
- useRoomPreviewerPixi mirrors hooks/room/useRoomPreviewer.tsx, but
  mounts the temp room's own `room.canvas.master` (confirmed to be a
  plain Pixi Container) directly into the UI's Pixi tree instead of
  DOM's extract-to-2D-canvas-every-tick round trip - a genuine
  simplification once room and UI share one renderer, not just a port.

Adds a shared Image component generalizing the loading-placeholder
pattern NitroCurrencyIcon.tsx already established for its own image src.
Extends the AccordionTrigger circular-import port pattern to
CatalogNavigationSetView/CatalogNavigationSetItemView's own recursion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Ports the object-infostand and object-menu view families to Pixi
equivalents (InfostandBot/Pet/Furni/User, InfoBubbleName/Avatar/
OwnAvatar), plus RoomObjectMenuBubblePixi - a Pixi port of the
imperative screen-position-tracking bubble container that mutates a
Container ref's x/y/alpha/visible on every RoomRenderedEvent tick
instead of DOM style properties - and RoomWidgetsPixi, which replaces
the DOM createPortal root with a normal Pixi child carrying its own
RoomEngineEvent-driven isReady state. The four logic-only wrapper
components (RoomObjectInfostandWidget, InfostandFurni,
RoomObjectMenuNameBubble, RoomObjectMenuWidget) are edited in place to
point at the new Pixi views, and RoomWidgetsPixi is wired into
Nitro.tsx in place of RoomContainer's DOM RoomWidgets portal.

This completes the "convert all views to Pixi" migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Every themed background/overlay layer (NineSliceLayer, SpriteLayer,
ColorLayer, CompositePieceSprite, BlendOverlay) sets eventMode="none"
so its art doesn't shadow its own container's press handlers, and
nothing ever gave the interactive container itself an explicit
hitArea. Per Pixi's own EventBoundary (hitPruneFn/hitTestFn), a
static/dynamic Container with no hitArea has no shape of its own to
hit-test against, so its clickable region silently collapsed to
whatever passive/interactive children happened to sit under the
pointer - in practice just a text label, even though the button's
rendered art fills the whole box.

Box now attaches a self-tracking fallback hitArea on mount (skipped
if the caller already set one) that re-reads the container's own
yoga-computed size on every hit test, so it stays correct as content/
padding resize the box without an effect keeping a baked Rectangle in
sync. Since every pressable primitive (Button, CloseButton,
ContainerButton, Header, Frame, tabs, accordion triggers, dropmenu
items, ...) is built on Box, this fixes the whole family in one
place. Verified interactively: a corner click away from a button's
centered text now fires onPress, same as clicking the text itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Replaces every <pixiText style={getPixiTextStyle(key, overrides)}>
call site across theme-pixi and views-pixi with <Text textStyle={key}
textOptions={overrides}>, matching the pattern Button.tsx already
established. Left untouched: Text.tsx itself (the implementation),
utils/wrapTextChildren.tsx (the unstyled raw-string safety net for
Box's string children - Text's textStyle prop is required, and this
is deliberately unstyled), and the one fallback branch each in
CheckBox.tsx/RadioButton.tsx that renders unstyled pixiText when a
variant carries no textStyleKey at all (0/1/2, which have no label in
DOM either).

Every file's getPixiTextStyle import is removed once its only call
site becomes redundant (verified 1:1 per file first). Verified via
tsc --build --force (stable, no new errors) and a Playwright
screenshot comparison against the pre-conversion room-widgets/Button
render, pixel-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
billsonnn and others added 30 commits August 25, 2026 23:45
Packs the ~250 individual border/button/scrollbar PNGs THEME_URLS points at
into one combined atlas.png + Spritesheet-format manifest.json, built by a
new build-theme-atlas.ts script (yarn bundle-theme-assets). Reduces the
combined asset payload from 1.1MB across ~250 requests to a 24KB image + 40KB
manifest across 2 requests.

preloadThemeAssets() fetches and decodes the atlas once at boot, before the
app renders: in Pixi it seeds AssetManager's texture cache via Pixi's own
Spritesheet class (same technique AssetManager.processNitroBundle already
uses for room/furniture .nitro bundles); in DOM it slices each asset's rect
into a data: URL and overwrites THEME_URLS' values in place, so every
existing DOM call site keeps working unchanged. Either path failing leaves
assets on their existing lazy-load fallback.

The bake script extrudes each packed sprite's edge pixels into its padding
gutter to prevent bilinear-filtering bleed between adjacent atlas tiles, and
the Pixi loader explicitly sets nearest-neighbor scaling on the atlas texture
(the app's own renderer sets this as a global default, but this preload runs
before that renderer exists).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
The atlas preload resolves themed textures synchronously at boot, so a
scrollbar thumb's NineSliceSprite now mounts on the very first render
instead of only once its texture finishes a lazy network fetch. That
exposed a real Pixi bug: useScrollController's thumbSize briefly reads 0
on the first measure tick after content becomes scrollable (the track's
own layout settles a tick after the viewport/content sizes that make it
scrollable), and a NineSliceSprite that mounts at zero size never
recovers once resized to a real one on the next tick - it used to render
correctly by accident, because the lazy texture fetch usually didn't
resolve until after that zero-size tick had already passed.

Skip mounting the thumb while thumbSize is still 0 in both
ScrollbarVertical and ScrollbarHorizontal, so it always mounts fresh at
a real size instead of getting stuck invisible after a resize.

Also drop themeAssetBundle.ts's Spritesheet-based shared atlas texture
for Pixi in favor of a standalone per-asset Texture sliced from the
decoded atlas image: TilingSprite (Header's tiled background/shine, any
repeat-mode nine-slice's stretched middle) silently collapses a
tile-kind texture cropped from a shared source into one flat sample
instead of repeating the pattern, since its "can batch" fast path
requires the texture's frame to equal its source's full size. A
standalone texture per asset always satisfies that, exactly like the
individually-loaded PNG it replaces.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
Pixi's EventSystem listens for pointermove on document and pointerup on
window rather than scoping them to its own canvas the way pointerdown is
(by design, so a drag started on the canvas keeps tracking once the
pointer leaves it). In DOM render mode the room's canvas sits underneath
real DOM windows, so hovering or releasing the pointer over a window's
own DOM elements still reached the room's hover/drag handling
underneath it - confirmed directly with a raw EventSystem + overlaid DOM
element reproduction.

RoomCanvas's pointer handler now bails out whenever the real topmost
element at the event's coordinates isn't the room's own canvas, in DOM
mode only (Pixi UI mode has nothing to check - the UI lives on the same
canvas already).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
…into useInteractionState

useInteractionState now accepts pointer-handler props and composes them with its own
hover/press state-transition handlers, so a caller's onPointerTap/onPointerDown etc. always
fires alongside whatever internal state logic a themed component needs - no more hand-written
merge wrappers per component. useThemeVariant threads the same handlers through to any
component built on it. Box and ThemeImage now derive eventMode/pointer-events automatically
from whichever pointer handlers are actually present, replacing ~99 hardcoded
eventMode="static"/"none" occurrences across the theme package and views with the one
consistent rule: a real handler implies interactivity, no handler stays passive.

Auditing every occurrence during the sweep surfaced two latent bugs, fixed here: Scaler's
resize-drag handle had no eventMode at all in Pixi mode (the window resize handler could never
fire), and FriendListItemPixi's outer Box set eventMode="none" whenever onPress was absent,
which silently blocked a nested child's own hover-tooltip handlers per Pixi's
subtree-blocking semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
ThemeProps<T> (the public prop type most theme components extend) now carries the same
PointerHandlerProps every useThemeVariant/useInteractionState caller already accepted
internally - previously only the internal ThemeOptions type declared them, so a caller
literally could not pass onPointerOver/onPointerUp/etc. to <Button>, <Frame>, <Dropmenu> and
friends despite the hooks already knowing how to compose them. Every themed component now
destructures the full set and forwards it through, composing with whatever internal handler
it already owns (Frame's activate-on-click, CloseButton's drag-stop, Button's onPress) via the
newly-exported compose() helper, so a caller's handler always fires alongside the component's
own - never replacing it.

useInteractionState no longer fabricates hover/press-tracking closures or forces eventMode
when a component has neither an internal need nor a caller-supplied handler for any of the
six events - it returns {} and the component stays passive, matching Box's own auto-derivation
instead of overriding it unconditionally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
BackgroundLayerConfig's 'sprite' kind now takes an optional frame, cropping a sub-region out of
the texture the same way ThemeImage/BubblePointer already do - a themed component's variant
table can now declare Stretch(textureKey, frame) directly and pass it through BackgroundLayer
like any other layer kind, instead of reaching for a standalone <ThemeImage> on the side.

A frame defaults the layer's own render size to its native crop dimensions rather than
Stretch's usual "fill whatever box the caller assigns" (which only makes sense for a whole,
uncropped texture) - a caller-supplied layout can still override positioning. Works on both
backends: Pixi crops via a Rectangle-offset Texture (same technique ImagePixi already uses),
DOM crops via background-position (same technique ImageDom already uses) - BackgroundLayer's
own DOM dispatch, which calls BackgroundLayerDom directly rather than through SpriteLayer,
shares the sizing default via the same spriteLayoutFromFrame helper so the two paths can't
drift out of sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvYpFc4uidm2Sa4hpY9FYa
- Memoize VariantCascadeProvider's context value so consumers only
  re-render when the cascaded variant map actually changes, instead of
  on every render of any ancestor provider.
- Skip usePixiTexture's async retry effect entirely when the texture is
  already resolvable synchronously (at mount, or after a url change to
  an already-cached key) - avoids a redundant promise/microtask and the
  one-frame blank flash on a url swap to a cached texture.
- Add a fast-path bailout to wrapTextChildren for the common case of a
  single non-text child, skipping Children.map's clone/key-assignment
  work when there's nothing that could need wrapping.
- Stabilize useOutsideClick's callback via a ref so the global pointer
  listener isn't torn down and re-added on every render of the caller.
- Wrap useFrameDrag/useFrameResize's stopDragging/stopResizing in
  useCallback so the cleanup effect's dependency is stable across
  renders, matching the pattern already used in useScrollController/
  useDomScrollController.

Verified via a temporary Playwright harness (deleted before commit):
dragging/resizing survive a re-render mid-gesture in both DOM and Pixi
modes, tsc holds at the pre-existing 89-error baseline, eslint clean.
event.stopImmediatePropagation() only exists on Pixi's FederatedPointerEvent -
Box.tsx forwards this same handler as a React PointerEventHandler in DOM mode,
where the event is a React SyntheticEvent with no stopImmediatePropagation
method, so this would throw at runtime whenever stopsPropagation is used in
DOM mode. Switched to stopPropagation(), which both event types implement and
which is all that's actually needed here (block bubbling to an ancestor's
pointerdown handler, e.g. Frame's drag-start) - matching the same method
already used for this purpose in useScrollController/useDomScrollController.
CloseButton's stopsPropagation was flipped on, but pressing and holding it
still started a window drag. Root cause: @pixi/react maps the onPointerDown
JSX prop straight onto Pixi's legacy on<type> property idiom, which
EventBoundary.notifyTarget invokes for every ancestor along the hit-tested
path unconditionally during the capturing sweep - before the actual target's
own handler ever runs. So Header's onPointerDown (the drag-start handler)
already fires before CloseButton's own handler gets a chance to call
stopPropagation() - by then it's too late, the drag has already started.

Fixed by having useFrameDrag's header handler check that it is itself the
real hit target (event.target === event.currentTarget) before starting a
drag, which correctly excludes a press that landed on a nested interactive
descendant like CloseButton. Only applies in Pixi mode - DOM's real
bubbling doesn't share this ordering quirk (stopPropagation already worked
there), and DOM's target is the deepest DOM node regardless of interactivity,
so the same check there would incorrectly reject legitimate header clicks.

Also flipped CloseButton's stopsPropagation to true (it was still passing
false, leaving the feature dormant) and removed a stray debug console.log
left in Box.tsx's DOM path.

Verified via a temporary Playwright harness (deleted before commit): normal
header dragging still works, a quick tap on the close button still fires
onClose with no drag, and pressing-and-holding the close button no longer
drags the window - in both DOM and Pixi render modes. tsc holds at the
pre-existing baseline, eslint clean.
`manualChunks` sent every `node_modules` module to a single `vendor`
chunk, including modules that are only reached through `import()`.
`truffle-text` top-level awaits `initFreeType()`, which `import()`s the
FreeType Emscripten module, so both sides of that dynamic import landed
in `vendor`: the chunk ended up dynamically importing itself and awaiting
the result at top level, a promise that can only settle once the chunk
has finished evaluating.

The result was a deadlock with no error anywhere - `vendor` never
finished evaluating, so the `theme` and entry chunks never evaluated
either, `createRoot().render()` never ran, and the page stayed the black
that `index.html` paints. Dev was unaffected because modules are served
unbundled there, so the dynamic import resolves to a real separate
module.

Keep any module that is the target of an `import()` out of the manual
chunks so the async boundary survives bundling. Same guard added to the
nitro-renderer config, which has the identical rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bg582tPieGXQHdaahHoefm
The Flash WindowParam port centred an element with `left: '50%'` plus a
negative pixel margin (and the same vertically). Replace that with flex
mechanics, in the generator's centre branches and across all 266
generated layout views (583 horizontal + 262 vertical sites):

- A centre-anchored child now carries no inset on that axis. Both Yoga
  and CSS resolve an absolutely-positioned child's inset-less axis to
  its static position - "as if it were the sole flex item" - so the
  parent's `justifyContent: 'center'` centres it horizontally (main
  axis of the shared default row direction), and the child's own
  `alignSelf: 'center'` centres it vertically (cross axis, needing
  nothing from the parent).
- A non-zero design offset rides along as an equal-and-opposite margin
  pair (`marginLeft: d, marginRight: -d`), which shifts the centred box
  without changing its margin-box size. For fixed-size elements the
  result is pixel-identical to the old form at every container size
  (verified against both Yoga 3.2 and Chromium): old
  `W/2 + M` == new `(W - w)/2 + (M + w/2)`.
- Auto-sized elements (translated labels, template rows) previously had
  their design-time left edge pinned near the centre, so a label wider
  or narrower than the Flash design drifted off-centre; they now stay
  truly centred at whatever width they render.

Parent `justifyContent` is safe for every generated sibling - all other
anchors always emit at least one inset per axis, and an inset always
wins over alignment. Composite chrome pieces were the one exception:
an axis with neither inset set relied on the static position being the
top-left corner, which only holds under default alignment. Pin their
start inset explicitly (CompositePieceSprite + CompositePieceDom) so
themed boxes that now declare `justifyContent: 'center'` don't
re-centre their own chrome.

Known small delta: 13 ContainerButtons whose bare caption duplicates a
centred label child - the caption (a flow text item) moves from the
button's top-left to top-centre, aligning with the label it duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bg582tPieGXQHdaahHoefm
One import - InventoryView's `import { InventoryLayout } from '../layouts'`
- went through the generated `export *` barrel over all ~800 layout views.
None of those modules are provably side-effect-free to the bundler, so
tree-shaking kept every one, and the entire lazily-registered catalogue
landed in the entry's static module graph: the built index.html carried
794 modulepreload links, so every page load fetched every layout chunk
up front, defeating layoutRegistry's per-entry dynamic `load()` entirely.

- InventoryView now imports InventoryLayout by its own path, like the
  other two views that legitimately need a layout at boot.
- The barrel index.ts is deleted and no longer generated, so a future
  convenience import fails loudly instead of silently re-eagerizing 800
  chunks. Layouts are imported by path, or through the registry.
- LayoutBrowserView (the dev tool) is now behind React.lazy: it
  statically holds layoutRegistry - ~6,400 lines of metadata plus the
  dynamic-import map for every layout - which otherwise sits in the
  boot-critical entry chunk for a window most sessions never open.

Built output, before -> after:
- index.html: 78,190 -> 2,258 bytes (modulepreload links 802 -> 11)
- entry chunk: 743 KB -> 217 KB
- dist/assets file count: 1,616 -> 826 (each dual static+dynamic layout
  had split into a real chunk plus a re-export stub; only the three
  intentionally-static layouts keep a facade now)
- boot fetches 11 JS files instead of 800+

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bg582tPieGXQHdaahHoefm
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.

2 participants