Skip to content

fix(openworlds): portrait gallery empty-state fallback (Closes #379) - #390

Closed
100yenadmin wants to merge 1 commit into
mainfrom
fix/379-empty-gallery-fallback
Closed

fix(openworlds): portrait gallery empty-state fallback (Closes #379)#390
100yenadmin wants to merge 1 commit into
mainfrom
fix/379-empty-gallery-fallback

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 30, 2026

Copy link
Copy Markdown
Member

TL;DR

Closes the Critical regression Loop-10 caught: PR #369 added 5 races to RACES (dwarf / halfling / gnome / dragonborn / half-orc) without adding matching PORTRAIT_GALLERY entries. The race-filter at screen-create.jsx:548 returns [] for those races, so the portrait grid renders 0 buttons — the wizard dead-ends for any player who picks one of those 5 lineages.

This PR delivers #379 AC3 (empty-state fallback). The curated 24+ per race path (AC1) is tracked separately as #378.

Closes

What this lands

When the lineage filter yields [], the gallery render block:

  1. Surfaces an a11y-friendly banner (role="status", aria-live="polite") explaining the gap by lineage name. A player picking dwarf sees:

    "No canon portrait curated for Dwarf yet — pick any face below for now, or generate a unique one with 'A face of your own' further down. The full per-race catalogue lands as feat(openworlds): curated portrait gallery — 24+ per race from the 2,077-portrait local pool (#315 AC3 follow-up) #378 finishes ingest (tracked: fix(openworlds): portrait gallery empty for 5 of 11 races (dwarf, halfling, gnome, dragonborn, half-orc) #379)."

  2. Falls back to showing the full living gallery (livingAll = PORTRAIT_GALLERY.filter(p => p.alive !== false)) so the player can still pick a portrait, generate a unique face, or proceed. The wizard never dead-ends.

  3. Preserves the original gallery-index → scope mapping — the existing PORTRAIT_GALLERY.indexOf(p) remap continues to work for both filtered and fallback paths, so hero.portrait / portraitScope / bindHero spec all stay correct.

Why this approach

  • Zero new state: no useState, no toggle. Fallback is purely derived from the filter result.
  • Zero behavior change for races with portraits: when lineageMatches.length > 0, usingFallback === false and toRender === lineageMatches exactly as before. 6 of the 11 races (7 with PR fix(openworlds): add aasimar lineage to RACES (Closes #375) #389's aasimar) see the original render path unchanged.
  • Zero regression risk to selection state: a portrait the player picked while their race was "dwarf" stays pickable + remains the chosen portrait if they change race; the engine takes the slug, not a race-match assertion.
  • Honest signposting: the banner names tracking issues so the player (and anyone reading markup) sees WHY the gallery shows the full set.

Acceptance criteria (per #379)

Out of scope

Interaction with PR #386 (gallery_per_race gate)

The gallery_per_race gate measures PORTRAIT_GALLERY data drift. After this PR:

  • The data is unchanged (still 0 entries for the 5 races) → gate still FAILs on those races
  • The user-facing dead-end is closed → wizard usable for all 11 races

Both gates fire on the right shape of regression. The gate stays useful as the gate for #378's eventual completion.

Diff stat

  • viewer/openworlds/screen-create.jsx — 1 file, +46/-15 lines
  • Brace balance verified clean (558/558), paren balance clean (361/361)

Collision audit

DO NOT MERGE yet

Per owner direction. Ready for review.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Improved portrait gallery behavior in the Face step to display matching portraits when available and provide helpful guidance when the selected lineage lacks curated options.

Review Change Stack

Closes the Critical regression Loop-10 caught: PR #369 added 5 races
to RACES (dwarf, halfling, gnome, dragonborn, half-orc) without adding
matching PORTRAIT_GALLERY entries. The race-filter at screen-create.jsx:548
returns [] for those races, so the portrait grid renders 0 buttons —
the wizard dead-ends for any player who picks one of those 5 lineages.

Fix per #379 AC3 (empty-state fallback) — the curated 24+ per race
path (AC1) is the better long-term answer but requires portrait
curation work tracked separately as #378.

What this lands
---------------

When the lineage filter yields [], the gallery render block:

1. Surfaces an a11y-friendly banner (role="status", aria-live="polite")
   explaining the gap by lineage name + pointing at #378 / #379 as
   the tracking issues. So a player picking dwarf sees:

     "No canon portrait curated for Dwarf yet — pick any face below for
      now, or generate a unique one with 'A face of your own' further
      down. The full per-race catalogue lands as #378 finishes ingest
      (tracked: #379)."

2. Falls back to showing the FULL living gallery (livingAll =
   PORTRAIT_GALLERY.filter(p => p.alive !== false)) so the player can
   still pick a portrait, generate a unique face, or proceed to the
   next step. The wizard never dead-ends.

3. Preserves the original gallery-index → scope mapping (the existing
   PORTRAIT_GALLERY.indexOf(p) remap continues to work for both the
   filtered and fallback paths), so hero.portrait / portraitScope /
   bindHero spec all stay correct.

Why this approach
-----------------

- ZERO new state: no useState, no toggle, no controlled component.
  The fallback is purely derived from the filter result.
- ZERO behavior change for the 7 races with portraits (the 6
  pre-PR-#369 races + aasimar from PR #389): when lineageMatches
  has entries, usingFallback is false and toRender = lineageMatches
  exactly as before.
- ZERO regression risk to selection state: a portrait the player
  picked while their race was "dwarf" (e.g. Astarion, idx 2) stays
  pickable + remains the chosen portrait if they change race to a
  lineage that doesn't include him; the engine takes the slug, not
  a race-match assertion.
- Honest signposting: the banner names the tracking issues so the
  player (or anyone reading the markup) can see WHY the gallery is
  showing the full set instead of a curated lineage subset.

Acceptance criteria (per #379)
------------------------------

- [x] AC3 (fallback): empty-state affordance renders when filter
      yields []
- [x] Banner names the lineage that has no portraits ("No canon
      portrait curated for <RaceName> yet")
- [x] CTA paths exposed: "pick any face below" + "generate a unique
      one" (both already exist; the banner just signposts them)
- [x] References #378 (the curated catalogue work) so the
      relationship between this fallback and the long-term fix is
      visible to the player
- [x] a11y: role="status" + aria-live="polite" so screen readers
      announce the banner when the race change triggers the fallback

Out of scope
------------

- AC1 (curated 24+ per race) — that's #378, requires content
  curation from the 2,077-portrait local pool, separate PR
- BYO drag-drop PNG (#376) — separate PR
- Subrace handling (#377) — separate PR

Smoke test
----------

Manually verified the brace + paren balance of the file (558/558,
361/361). The change is purely a wrap-into-IIFE-with-conditional;
no other code paths are affected.

When the gate from PR #386 (gallery_per_race) sees this PR, the
underlying PORTRAIT_GALLERY data is unchanged (still 0 entries for
the 5 races) so the gate would still FAIL on those races — that's
correct, because #386 measures data drift (the symptom that needs
content curation in #378), while this PR closes the user-facing
deadend (the symptom that breaks the wizard today). Both gates
fire on the right shape of regression.

Refs
----

- Closes #379 (Critical: portrait gallery empty for 5 of 11 races)
- Pairs with PR #389 (#375 aasimar lineage)
- Tracks toward #378 (curated 24+ per race — the proper fix)
- Builds on Loop-9 + Loop-10 verification trail
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The Face step of character creation now handles lineages with no curated living portraits by filtering the gallery by hero race, detecting empty results, falling back to the full living gallery with an explanatory banner, and preserving selection consistency by mapping rendered entries to original PORTRAIT_GALLERY indices.

Changes

Portrait Gallery Filtering with Fallback

Layer / File(s) Summary
Lineage-filtered gallery with fallback and index mapping
viewer/openworlds/screen-create.jsx
Computes a filtered subset of PORTRAIT_GALLERY matching hero.race (living portraits only), detects when empty, falls back to the full living gallery with an inline accessibility-friendly banner explaining the gap, and maintains consistent selection by deriving each rendered tile's original index before wiring pickGallery(i) and portraitScope(i) callbacks.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

  • electricsheephq/WorldOS#378: Main change (lineage-filter + fallback rendering while preserving PORTRAIT_GALLERY indices) directly pertains to the same portrait gallery logic in viewer/openworlds/screen-create.jsx.
  • electricsheephq/WorldOS#315: Main change to StepPortrait's gallery filtering and index mapping directly addresses the same StepPortrait rendering/selection logic.
  • electricsheephq/WorldOS#375: Updates portrait gallery filtering so that when no lineage matches are found it falls back to the full living gallery with a banner, directly addressing the mismatched portrait race key scenario.
  • electricsheephq/WorldOS#379: Both implement filtered "lineage matches" plus fallback to the full living gallery with an inline banner while preserving PORTRAIT_GALLERY index mapping.

Possibly related PRs

  • electricsheephq/WorldOS#369: Both PRs update the Face/portrait gallery logic in viewer/openworlds/screen-create.jsx around filtering PORTRAIT_GALLERY entries by race/alive and handling empty/zero-match results.
  • electricsheephq/WorldOS#329: Both PRs modify viewer/openworlds/screen-create.jsx's portrait gallery filtering/rendering around "living" portrait availability and handling empty curated-living subsets with a fallback.

Poem

🐰 A rabbit hops through portrait halls,
Where curated faces fill the walls,
But when the lineage finds no match,
A clever fallback springs the latch—
"Pick any face, or craft your own!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing an empty-state fallback for the portrait gallery when no curated portraits exist for a lineage.
Description check ✅ Passed The description is comprehensive and well-structured, covering the problem, solution, acceptance criteria, and rationale, though it does not explicitly address the CLA checkbox requirements from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
viewer/openworlds/screen-create.jsx (1)

578-578: 💤 Low value

Index mapping via indexOf works correctly.

The PORTRAIT_GALLERY.indexOf(p) approach correctly maps filtered portraits back to their original indices because filter() preserves object references. This ensures that pickGallery(i) receives the correct index regardless of whether the fallback is active.

Note: This relies on reference equality, so it would break if PORTRAIT_GALLERY entries were ever cloned or reconstructed. For the current 12-portrait gallery this is fine, but worth keeping in mind if the gallery becomes dynamically loaded.

Alternative approach (optional)

If portraits were ever loaded dynamically or cloned, consider adding an explicit index field to each portrait object:

const PORTRAIT_GALLERY = [
  { slug: "aubree", name: "Aubree", race: "human", alive: true, index: 0 },
  { slug: "shadowheart", name: "Shadowheart", race: "half", alive: true, index: 1 },
  // ...
];

Then use p.index instead of PORTRAIT_GALLERY.indexOf(p). However, this adds maintenance burden and isn't needed for the current static gallery.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/openworlds/screen-create.jsx` at line 578, When mapping filtered
portraits back to their original index, guard against
PORTRAIT_GALLERY.indexOf(p) returning -1; in the code around the variable i and
the call to pickGallery, resolve the index defensively by first using
PORTRAIT_GALLERY.indexOf(p) and if it equals -1, fallback to
PORTRAIT_GALLERY.findIndex(x => x.slug === p.slug) or use p.index when portraits
are augmented with an explicit index field; then pass the resolved index to
pickGallery and optionally log a warning if resolution fails to aid debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@viewer/openworlds/screen-create.jsx`:
- Line 570: The style entry using color: "var(--ink-2)" is likely a typo; update
the color value in the banner/text style to use an existing design token (e.g.,
replace "var(--ink-2)" with a consistent token such as "var(--ink-700)" or the
token used elsewhere in this file) so it matches the pattern used by other color
variables; locate the color property in the style object where color:
"var(--ink-2)" appears and swap it for the correct --ink-* token (for example
--ink-700) to restore proper rendering.

---

Nitpick comments:
In `@viewer/openworlds/screen-create.jsx`:
- Line 578: When mapping filtered portraits back to their original index, guard
against PORTRAIT_GALLERY.indexOf(p) returning -1; in the code around the
variable i and the call to pickGallery, resolve the index defensively by first
using PORTRAIT_GALLERY.indexOf(p) and if it equals -1, fallback to
PORTRAIT_GALLERY.findIndex(x => x.slug === p.slug) or use p.index when portraits
are augmented with an explicit index field; then pass the resolved index to
pickGallery and optionally log a warning if resolution fails to aid debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d1b72cf-cd02-499f-b371-fd6a2663f244

📥 Commits

Reviewing files that changed from the base of the PR and between bb1870d and a6f9027.

📒 Files selected for processing (1)
  • viewer/openworlds/screen-create.jsx

border: "1px dashed rgba(140,100,60,0.35)",
borderRadius: 6,
fontSize: 13,
color: "var(--ink-2)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Likely typo in color variable.

The color value var(--ink-2) is inconsistent with the color variable pattern used elsewhere in this file (--ink-600, --ink-700, --ink-800, --ink-900). If --ink-2 is undefined, the banner text may not render with the intended color, degrading visibility and accessibility.

🎨 Proposed fix
-                color: "var(--ink-2)",
+                color: "var(--ink-800)",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
color: "var(--ink-2)",
color: "var(--ink-800)",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/openworlds/screen-create.jsx` at line 570, The style entry using
color: "var(--ink-2)" is likely a typo; update the color value in the
banner/text style to use an existing design token (e.g., replace "var(--ink-2)"
with a consistent token such as "var(--ink-700)" or the token used elsewhere in
this file) so it matches the pattern used by other color variables; locate the
color property in the style object where color: "var(--ink-2)" appears and swap
it for the correct --ink-* token (for example --ink-700) to restore proper
rendering.

@100yenadmin

Copy link
Copy Markdown
Member Author

Superseded: the empty-portrait-gallery fallback landed on main via #528 (ae27602) — screen-create.jsx already carries it. This PR conflicts on the same render block and adds nothing new. Closing the PR; issue #379 stays OPEN for the remaining curated-catalogue work.

@100yenadmin 100yenadmin closed this Jun 5, 2026
100yenadmin pushed a commit that referenced this pull request Jun 5, 2026
…, #315 AC5)

The character creator offered no subrace choice. This adds subrace as an
OPTIONAL second-tier lineage choice and threads it through the ability
preview, the review/summary surfaces, the portrait filter, and the
startProviderSession seam.

Additive, render-only (viewer JSX, no build step). Validated by transforming
screen-create.jsx through viewer/openworlds/vendor/babel-standalone-7.29.0.min.js.

Changes (viewer/openworlds/screen-create.jsx):
- RACES: optional `subraces` map on elf (high/wood), dwarf (mountain/hill),
  halfling (lightfoot/stout), gnome (forest/rock). Each subrace declares
  name/body and a `bonus` DELTA that stacks on the base race.
  Drow DECISION documented: drow stays a STANDALONE top-level race (its own
  RACES entry + Minthara gallery face), NOT folded into elf.subraces — matches
  the already-shipped data fork. (AC1)
- Helpers: subracesForRace / hasSubraces / mergeBonus (additive, non-mutating) /
  effectiveRaceBonus (base + chosen subrace) / subraceLineageName.
- StepRace: new SubracePicker renders brass/sketch radio chips, gated on the
  race having a non-empty subraces map; a synthesized "Standard" (subrace: null)
  option is first and is the default so base-race-only/homebrew heroes are never
  blocked. Switching race clears a stale subrace. (AC2)
- Ability stacking: StepAbilities preview, StepReview summary, and the live
  right-rail summary all read effectiveRaceBonus(hero) — Mountain Dwarf shows
  STR +2 / CON +2. No surface left reading the bare base-race bonus. (AC3)
- bindHero: spec.subrace serialized (empty string when none) across the
  startProviderSession seam. Engine-side wiring out of scope. (AC4)
- Initial hero state gains `subrace: null`.
- portraitChoicesForRace(race, subrace): ANDs in subrace when the gallery
  carries subrace-tagged faces, else degrades to the race-level pool (never an
  empty grid — the curated faces are race-only today). Preserves the existing
  #379/#390 race-level fallback. (#315 AC5)

Test guard (viewer/tests/test_openworlds_static.py):
- test_openworlds_create_subrace_handling_is_wired: parses RACES per-race,
  asserts the four subrace maps + bonus deltas, the drow-standalone decision,
  the SubracePicker/radiogroup/Standard-default UI, the single effectiveRaceBonus
  stacking path, the race-switch reset, the bindHero spec.subrace, and the
  subrace-aware portrait filter.

Findings skipped: none — all four AC of #377 implemented.
100yenadmin added a commit that referenced this pull request Jun 5, 2026
…, #315 AC5) (#669)

The character creator offered no subrace choice. This adds subrace as an
OPTIONAL second-tier lineage choice and threads it through the ability
preview, the review/summary surfaces, the portrait filter, and the
startProviderSession seam.

Additive, render-only (viewer JSX, no build step). Validated by transforming
screen-create.jsx through viewer/openworlds/vendor/babel-standalone-7.29.0.min.js.

Changes (viewer/openworlds/screen-create.jsx):
- RACES: optional `subraces` map on elf (high/wood), dwarf (mountain/hill),
  halfling (lightfoot/stout), gnome (forest/rock). Each subrace declares
  name/body and a `bonus` DELTA that stacks on the base race.
  Drow DECISION documented: drow stays a STANDALONE top-level race (its own
  RACES entry + Minthara gallery face), NOT folded into elf.subraces — matches
  the already-shipped data fork. (AC1)
- Helpers: subracesForRace / hasSubraces / mergeBonus (additive, non-mutating) /
  effectiveRaceBonus (base + chosen subrace) / subraceLineageName.
- StepRace: new SubracePicker renders brass/sketch radio chips, gated on the
  race having a non-empty subraces map; a synthesized "Standard" (subrace: null)
  option is first and is the default so base-race-only/homebrew heroes are never
  blocked. Switching race clears a stale subrace. (AC2)
- Ability stacking: StepAbilities preview, StepReview summary, and the live
  right-rail summary all read effectiveRaceBonus(hero) — Mountain Dwarf shows
  STR +2 / CON +2. No surface left reading the bare base-race bonus. (AC3)
- bindHero: spec.subrace serialized (empty string when none) across the
  startProviderSession seam. Engine-side wiring out of scope. (AC4)
- Initial hero state gains `subrace: null`.
- portraitChoicesForRace(race, subrace): ANDs in subrace when the gallery
  carries subrace-tagged faces, else degrades to the race-level pool (never an
  empty grid — the curated faces are race-only today). Preserves the existing
  #379/#390 race-level fallback. (#315 AC5)

Test guard (viewer/tests/test_openworlds_static.py):
- test_openworlds_create_subrace_handling_is_wired: parses RACES per-race,
  asserts the four subrace maps + bonus deltas, the drow-standalone decision,
  the SubracePicker/radiogroup/Standard-default UI, the single effectiveRaceBonus
  stacking path, the race-switch reset, the bindHero spec.subrace, and the
  subrace-aware portrait filter.

Findings skipped: none — all four AC of #377 implemented.

Co-authored-by: Eva <arncalso@gmail.com>
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.

1 participant