Compare commits

...

18 commits

Author SHA1 Message Date
ab46d90d6a [WV2] Wearing item unwears incompatible items 2025-11-03 07:50:03 +00:00
e72a0ec72f [WV2] Fix outfit viewer scaling 2025-11-03 07:50:03 +00:00
c4290980ed [WV2] Item search first draft 2025-11-03 07:50:03 +00:00
80db7ad3bf [WV2] Add migration plan document
Claude made this, I'm not editing it hardly at all; it's mainly a context dump for itself.
2025-11-03 06:51:43 +00:00
481fbce6ce [WV2] Remove "Items (N)" header 2025-11-03 06:08:12 +00:00
88797bc165 [WV2] Refactor outfit state params helper 2025-11-03 06:07:35 +00:00
079bcc8d1d [WV2] Add item removal 2025-11-03 00:44:12 +00:00
f4417f7fb0 [WV2] Add tests for item sorting & grouping 2025-11-03 00:31:05 +00:00
e8d768961b [WV2] Group items by zone 2025-11-03 00:07:08 +00:00
dad185150c [WV2] Add badges to items 2025-11-02 23:48:39 +00:00
f96569b2bf [WV2] Persist state in URL 2025-11-02 08:55:17 +00:00
58fabad3c2 [WV2] Filter colors, using advanced fallbacks 2025-11-02 08:46:53 +00:00
ddb89dc2fa [WV2] Fix iframe border in outfit-viewer
I think our application reset CSS usually kills this, and maybe our wardrobe v2 CSS should reset too? But seems smart to have it here for consistency.
2025-11-02 08:19:53 +00:00
14298fafa9 [WV2] Extract species-color-picker component 2025-11-02 08:19:16 +00:00
2dc5505147 [WV2] Move species color picker into outfit area 2025-11-02 08:06:27 +00:00
0651a2871c Simplify error handling in wardrobe v2 2025-11-02 07:58:30 +00:00
a00d57bcbb Fix outfit sizing in wardrobe v2 2025-11-02 07:58:11 +00:00
276cc1b5ea Wardrobe V2 initial proof-of-concept 2025-11-02 07:43:54 +00:00
26 changed files with 2735 additions and 76 deletions

View file

@ -24,24 +24,6 @@ document.addEventListener("turbo:frame-missing", (e) => {
e.preventDefault(); e.preventDefault();
}); });
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement { class SpeciesFacePicker extends HTMLElement {
connectedCallback() { connectedCallback() {
@ -109,7 +91,6 @@ class MeasuredContainer extends HTMLElement {
} }
} }
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker); customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions); customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer); customElements.define("measured-container", MeasuredContainer);

View file

@ -126,7 +126,7 @@ class OutfitLayer extends HTMLElement {
} else { } else {
throw new Error( throw new Error(
`<outfit-layer> got unexpected status: ` + `<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status), JSON.stringify(data.status),
); );
} }
} else { } else {
@ -196,7 +196,7 @@ function morphWithOutfitLayers(currentElement, newElement) {
if ( if (
newNode.tagName === "OUTFIT-LAYER" && newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !== newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id") currentNode.getAttribute("data-asset-id")
) { ) {
currentNode.replaceWith(newNode); currentNode.replaceWith(newNode);
return false; return false;
@ -205,10 +205,19 @@ function morphWithOutfitLayers(currentElement, newElement) {
}, },
}); });
} }
addEventListener("turbo:before-frame-render", (event) => {
function onTurboRender(event) {
// Rather than enforce Idiomorph must be loaded, let's just be resilient // Rather than enforce Idiomorph must be loaded, let's just be resilient
// and only bother if we have it. (Replacing content is not *that* bad!) // and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") { if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b); event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
} }
}); }
// On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!)
addEventListener("turbo:before-frame-render", onTurboRender);
// But on pages that opt into it (namely the wardrobe), we do it for the full page too.
if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) {
addEventListener("turbo:before-render", onTurboRender);
}

View file

@ -0,0 +1,6 @@
// Wardrobe v2 - Simple Rails+Turbo outfit editor
//
// This page uses Turbo Frames for instant updates when changing species/color.
// The outfit_viewer Web Component handles the pet rendering.
console.log("Wardrobe v2 loaded!");

View file

@ -0,0 +1,28 @@
/**
* SpeciesColorPicker web component
*
* Progressive enhancement for species/color picker forms:
* - Auto-submits the form when species or color changes (if JS is enabled)
* - Shows a submit button as fallback (if JS is disabled or slow to load)
* - Uses Custom Element internals API to communicate state to CSS
*/
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
customElements.define("species-color-picker", SpeciesColorPicker);

View file

@ -0,0 +1,32 @@
/*
* Shared item badge styles for NC/NP/PB badges
* Used across item pages, wardrobe, search results, etc.
*
* These colors are from DTI 2020, based on Chakra UI's color palette.
*/
.item-badge {
padding: 0.25em 0.5em;
border-radius: 0.25em;
text-decoration: none;
font-size: .75rem;
font-weight: bold;
line-height: 1;
background: #E2E8F0;
color: #1A202C;
&[data-item-kind="nc"] {
background: #E9D8FD;
color: #44337A;
}
&[data-item-kind="pb"] {
background: #FEEBC8;
color: #7B341E;
}
.icon {
vertical-align: middle;
}
}

View file

@ -46,6 +46,7 @@ outfit-viewer
img, iframe img, iframe
width: 100% width: 100%
height: 100% height: 100%
border: 0
.loading-indicator .loading-indicator
position: absolute position: absolute

View file

@ -0,0 +1,545 @@
@import "../application/item-badges.css";
body.wardrobe-v2 {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
font-family: sans-serif;
}
.wardrobe-container {
display: flex;
height: 100vh;
background: #000;
@media (max-width: 800px) {
flex-direction: column;
}
}
.outfit-preview-section {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
min-height: 400px;
container-type: size;
/* The outfit viewer is a square filling the space, to at most 600px. */
outfit-viewer {
width: min(100cqw, 100cqh, 600px);
height: min(100cqw, 100cqh, 600px);
}
.no-preview-message {
color: white;
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
/* Species/color picker floats over the preview at the bottom */
species-color-picker {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
pointer-events: none;
/* Allow clicks through when hidden */
/* Start hidden, reveal on hover or focus */
opacity: 0;
transition: opacity 0.2s;
form {
pointer-events: auto;
/* Re-enable clicks on the form itself */
}
select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
option {
background: #2D3748;
color: white;
}
}
/* Submit button: progressive enhancement pattern */
/* If JS is disabled, the button is always visible */
/* If JS is enabled but slow to load, fade in after 0.75s */
/* Once the web component loads, hide the button completely */
input[type="submit"] {
padding: 0.5rem 1rem;
font-size: 1rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 0.375rem;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
&:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
}
/* If JS is enabled, hide the submit button initially with a delay */
@media (scripting: enabled) {
input[type="submit"] {
position: absolute;
margin-left: 0.5em;
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
/* Once auto-loading is ready, hide the submit button completely */
&:state(auto-loading) {
input[type="submit"] {
display: none;
}
}
}
/* Show picker on hover (real hover only, not simulated touch hover) */
@media (hover: hover) {
&:hover species-color-picker {
opacity: 1;
}
}
/* Show picker when it has focus */
&:has(species-color-picker:focus-within) species-color-picker {
opacity: 1;
}
}
.outfit-controls-section {
width: 400px;
background: #fff;
padding: 2rem;
overflow-y: auto;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
@media (max-width: 800px) {
width: 100%;
max-height: 40vh;
}
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #448844;
}
h2 {
font-size: 1.25rem;
color: #448844;
margin-top: 2rem;
}
}
.current-selection {
padding: 1rem;
background: #f0f0f0;
border-radius: 4px;
margin: 1rem 0;
p {
margin: 0;
color: #666;
}
strong {
color: #000;
}
}
.worn-items {
margin-top: 2rem;
.items-list {
list-style: none;
padding: 0;
margin: 0.5rem 0;
}
.item-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
margin-bottom: 0.5rem;
border-radius: 8px;
color: #333;
transition: background 0.2s, box-shadow 0.2s;
position: relative;
&:hover {
background: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:hover .item-remove-button {
opacity: 1;
}
}
.item-thumbnail {
flex-shrink: 0;
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.item-info {
flex: 1;
min-width: 0;
/* Allow text to truncate */
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-name {
font-weight: 500;
color: #2D3748;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badges {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.item-remove-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0;
margin: 0;
border: none;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s, background 0.2s, transform 0.1s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&:hover {
background: rgba(255, 255, 255, 1);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&:focus {
opacity: 1;
outline: 2px solid #448844;
outline-offset: 2px;
}
}
}
.item-search-form {
margin-bottom: 1.5rem;
display: flex;
gap: 0.5rem;
input[type="text"] {
flex: 1;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
&:focus {
outline: none;
border-color: #448844;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
}
}
input[type="submit"] {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 500;
border: none;
border-radius: 6px;
background: #448844;
color: white;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #357535;
}
&:active {
transform: scale(0.98);
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.3);
}
}
}
.search-results {
.search-results-header {
margin-bottom: 1.5rem;
.back-button {
padding: 0.5rem 1rem;
margin: 0 0 1rem 0;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
color: #448844;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: background 0.2s, border-color 0.2s;
&:hover {
background: #f9f9f9;
border-color: #448844;
}
&:focus {
outline: none;
border-color: #448844;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
}
}
}
.search-results-list {
list-style: none;
padding: 0;
margin: 1rem 0;
.item-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
margin-bottom: 0.5rem;
border-radius: 8px;
color: #333;
transition: background 0.2s, box-shadow 0.2s;
position: relative;
&:hover {
background: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:hover .item-add-button {
opacity: 1;
}
}
.item-thumbnail {
flex-shrink: 0;
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.item-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-name {
font-weight: 500;
color: #2D3748;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badges {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.item-add-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0;
margin: 0;
border: none;
background: rgba(68, 136, 68, 0.9);
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s, background 0.2s, transform 0.1s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&:hover {
background: rgba(68, 136, 68, 1);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
&:focus {
opacity: 1;
outline: 2px solid #448844;
outline-offset: 2px;
}
}
}
.empty-state {
padding: 2rem;
text-align: center;
color: #666;
}
.pagination {
margin: 1.5rem 0;
display: flex;
justify-content: center;
gap: 0.5rem;
font-size: 0.9rem;
a,
span,
em {
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
text-decoration: none;
color: #448844;
background: white;
&:hover {
background: #f9f9f9;
border-color: #448844;
}
}
.current,
em {
background: #448844;
color: white;
border-color: #448844;
font-style: normal;
}
.disabled {
color: #ccc;
cursor: not-allowed;
border-color: #eee;
&:hover {
background: white;
border-color: #eee;
}
}
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

@ -1,5 +1,6 @@
@import "../partials/clean/constants" @import "../partials/clean/constants"
@import "../partials/clean/mixins" @import "../partials/clean/mixins"
@import "../application/item-badges"
=item-header =item-header
border-bottom: 1px solid $module-border-color border-bottom: 1px solid $module-border-color
@ -27,7 +28,7 @@
text-align: left text-align: left
line-height: 100% line-height: 100%
margin-bottom: 0 margin-bottom: 0
.item-links .item-links
grid-area: links grid-area: links
@ -41,32 +42,6 @@
abbr abbr
cursor: help cursor: help
.item-kind, .first-seen-at
padding: .25em .5em
border-radius: .25em
text-decoration: none
font-weight: bold
line-height: 1
background: #E2E8F0
color: #1A202C
.icon
vertical-align: middle
.item-kind
// These colors are copied from DTI 2020, for initial consistency!
// They're based on the Chakra UI colors, which I think are in turn the
// Bootstrap colors? Or something?
// NOTE: For the data-type=np case, we use the default gray colors.
&[data-type=nc]
background: #E9D8FD
color: #44337A
&[data-type=pb]
background: #FEEBC8
color: #7B341E
.support-form .support-form
grid-area: support grid-area: support
font-size: 85% font-size: 85%
@ -100,21 +75,21 @@
font-size: 150% font-size: 150%
font-weight: bold font-weight: bold
margin-bottom: .75em margin-bottom: .75em
.closet-hangers-ownership-groups .closet-hangers-ownership-groups
+clearfix +clearfix
margin-bottom: .5em margin-bottom: .5em
div div
float: left float: left
margin: 0 5% margin: 0 5%
text-align: left text-align: left
width: 40% width: 40%
li li
list-style: none list-style: none
word-wrap: break-word word-wrap: break-word
label.unlisted label.unlisted
font-style: italic font-style: italic

View file

@ -68,7 +68,7 @@ class OutfitsController < ApplicationController
end end
@species_count = Species.count @species_count = Species.count
@latest_contribution = Contribution.recent.first @latest_contribution = Contribution.recent.first
Contribution.preload_contributeds_and_parents([@latest_contribution].compact) Contribution.preload_contributeds_and_parents([@latest_contribution].compact)
@ -77,6 +77,54 @@ class OutfitsController < ApplicationController
@campaign = Fundraising::Campaign.current rescue nil @campaign = Fundraising::Campaign.current rescue nil
end end
def new_v2
# Get selected species and color from params, or default to Blue Acara
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
# Load valid colors for the selected species (colors that have existing pet types)
@species = Species.alphabetical
@colors = @selected_species.compatible_colors
# Find the best pet type for this species+color combo
# If the exact combo doesn't exist, this will fall back to a simple color
@pet_type = PetType.for_species_and_color(
species_id: @selected_species.id,
color_id: @selected_color.id
)
# Use the pet type's actual color as the selected color
# (might differ from requested color if we fell back to a simple color)
@selected_color = @pet_type&.color
# Load items from the objects[] parameter
item_ids = params[:objects] || []
items = Item.where(id: item_ids)
# Build the outfit
@outfit = Outfit.new(
pet_state: @pet_type&.canonical_pet_state,
worn_items: items,
)
# Preload the manifests for all visible layers, so they load efficiently
# in parallel rather than sequentially when rendering
SwfAsset.preload_manifests(@outfit.visible_layers)
# Handle search mode
@search_mode = params[:q].present?
if @search_mode
search_filters = build_search_filters(params[:q], @outfit)
query_params = ActionController::Parameters.new(
search_filters.each_with_index.map { |filter, i| [i.to_s, filter] }.to_h
)
@query = Item::Search::Query.from_params(query_params, current_user)
@search_results = @query.results.paginate(page: params.dig(:q, :page), per_page: 30)
end
render layout: false
end
def show def show
@outfit = Outfit.find(params[:id]) @outfit = Outfit.find(params[:id])
@ -127,5 +175,52 @@ class OutfitsController < ApplicationController
:full_error_messages => @outfit.errors.full_messages}, :full_error_messages => @outfit.errors.full_messages},
:status => :bad_request :status => :bad_request
end end
def build_search_filters(query_params, outfit)
filters = []
# Add name filter if present
if query_params[:name].present?
filters << { key: "name", value: query_params[:name] }
end
# Add item kind filter if present
if query_params[:item_kind].present?
case query_params[:item_kind]
when "nc"
filters << { key: "is_nc", value: "true" }
when "np"
filters << { key: "is_np", value: "true" }
when "pb"
filters << { key: "is_pb", value: "true" }
end
end
# Add zone filter if present
if query_params[:zone].present?
filters << { key: "occupied_zone_set_name", value: query_params[:zone] }
end
# Always add auto-filter for items that fit the current pet
pet_type = outfit.pet_type
if pet_type
fit_filter = {
key: "fits",
value: {
species_id: pet_type.species_id.to_s,
color_id: pet_type.color_id.to_s
}
}
# Include alt_style_id if present
if outfit.alt_style_id.present?
fit_filter[:value][:alt_style_id] = outfit.alt_style_id.to_s
end
filters << fit_filter
end
filters
end
end end

View file

@ -65,6 +65,30 @@ module OutfitsHelper
text_field_tag 'name', nil, options text_field_tag 'name', nil, options
end end
# Generate hidden fields to preserve outfit state in URL params.
# Use the `except` parameter to skip certain fields, e.g. to override
# them with specific values, like in the species/color picker.
def outfit_state_params(outfit = @outfit, except: [])
fields = []
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
unless except.include?(:worn_items)
outfit.worn_items.each do |item|
fields << hidden_field_tag('objects[]', item.id)
end
end
unless except.include?(:q)
(params[:q] || {}).each do |key, value|
fields << hidden_field_tag("q[#{key}]", value) if value.present?
end
end
safe_join fields
end
def outfit_viewer(...) def outfit_viewer(...)
render partial: "outfit_viewer", render partial: "outfit_viewer",
locals: parse_outfit_viewer_options(...) locals: parse_outfit_viewer_options(...)
@ -75,8 +99,133 @@ module OutfitsHelper
locals: parse_outfit_viewer_options(...) locals: parse_outfit_viewer_options(...)
end end
# Group outfit items by zone, applying smart multi-zone simplification.
# Returns an array of hashes: {zone:, items:}
# This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil?
# Get item appearances for this outfit
item_appearances = Item.appearances_for(
outfit.worn_items,
outfit.pet_type,
swf_asset_includes: [:zone]
)
# Separate incompatible items (no layers for this pet)
compatible_items = []
incompatible_items = []
outfit.worn_items.each do |item|
appearance = item_appearances[item.id]
if appearance&.present?
compatible_items << {item: item, appearance: appearance}
else
incompatible_items << item
end
end
# Group items by zone - multi-zone items appear in each zone
items_by_zone = Hash.new { |h, k| h[k] = [] }
zones_by_id = {}
compatible_items.each do |item_with_appearance|
item = item_with_appearance[:item]
appearance = item_with_appearance[:appearance]
# Get unique zones for this item (an item may have multiple assets per zone)
appearance.swf_assets.map(&:zone).uniq.each do |zone|
zones_by_id[zone.id] = zone
items_by_zone[zone.id] << item
end
end
# Create zone groups with sorted items
zones_and_items = items_by_zone.map do |zone_id, items|
{
zone_id: zone_id,
zone_label: zones_by_id[zone_id].label,
items: items.sort_by { |item| item.name.downcase }
}
end
# Sort zone groups alphabetically by label, then by ID for tiebreaking
zones_and_items.sort_by! do |group|
[group[:zone_label].downcase, group[:zone_id]]
end
# Apply multi-zone simplification: remove redundant single-item groups
zones_and_items = simplify_multi_zone_groups(zones_and_items)
# Add zone ID disambiguation for duplicate labels
zones_and_items = disambiguate_zone_labels(zones_and_items)
# Add incompatible items section if any
if incompatible_items.any?
zones_and_items << {
zone_id: nil,
zone_label: "Incompatible",
items: incompatible_items.sort_by { |item| item.name.downcase }
}
end
zones_and_items
end
private private
# Simplify zone groups by removing redundant single-item groups.
# Keep groups with multiple items (conflicts). For single-item groups,
# only keep them if the item doesn't appear in a multi-item group.
def simplify_multi_zone_groups(zones_and_items)
# Find groups with conflicts (multiple items)
groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 }
# Track which items appear in conflict groups
items_with_conflicts = Set.new(
groups_with_conflicts.flat_map { |g| g[:items].map(&:id) }
)
# Track which items we've already shown
items_we_have_seen = Set.new
# Filter groups
zones_and_items.select do |group|
# Always keep groups with multiple items
if group[:items].length > 1
group[:items].each { |item| items_we_have_seen.add(item.id) }
true
else
# For single-item groups, only keep if:
# - Item hasn't been seen yet AND
# - Item won't appear in a conflict group
item = group[:items].first
item_id = item.id
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
false
else
items_we_have_seen.add(item_id)
true
end
end
end
end
# Add zone IDs to labels when there are duplicates
def disambiguate_zone_labels(zones_and_items)
label_counts = zones_and_items.group_by { |g| g[:zone_label] }
.transform_values(&:count)
zones_and_items.each do |group|
if label_counts[group[:zone_label]] > 1
group[:zone_label] = "#{group[:zone_label]} (##{group[:zone_id]})"
end
end
zones_and_items
end
def parse_outfit_viewer_options( def parse_outfit_viewer_options(
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
) )

View file

@ -549,6 +549,22 @@ class Item < ApplicationRecord
return [] if empty? return [] if empty?
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort ([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
end end
# Check if this appearance is compatible with another appearance.
# Two appearances are incompatible if:
# 1. They occupy the same zone (can't wear two items in same slot)
# 2. One restricts a zone the other occupies (e.g., hat restricts hair zone)
def compatible_with?(other)
occupied = occupied_zone_ids
other_occupied = other.occupied_zone_ids
restricted = restricted_zone_ids
other_restricted = other.restricted_zone_ids
# Check for zone conflicts
(occupied & other_occupied).empty? &&
(restricted & other_occupied).empty? &&
(other_restricted & occupied).empty?
end
end end
Appearance::Body = Struct.new(:id, :species) do Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON include ActiveModel::Serializers::JSON

View file

@ -170,6 +170,8 @@ class Outfit < ApplicationRecord
end end
def visible_layers def visible_layers
return [] if pet_state.nil?
# TODO: This method doesn't currently handle alt styles! If the outfit has # TODO: This method doesn't currently handle alt styles! If the outfit has
# an alt_style, we should use its layers instead of pet_state layers, and # an alt_style, we should use its layers instead of pet_state layers, and
# filter items to only those with body_id=0. This isn't needed yet because # filter items to only those with body_id=0. This isn't needed yet because
@ -279,4 +281,55 @@ class Outfit < ApplicationRecord
i += 1 i += 1
end end
end end
# When creating Outfit copies, include items. They're considered a basic
# property of the record, in the grand scheme of things, despite being
# associations.
def dup
super.tap do |outfit|
outfit.worn_items = self.worn_items
outfit.closeted_items = self.closeted_items
end
end
# Create a copy of this outfit, but *not* wearing the given item.
def without_item(item)
dup.tap { |o| o.worn_items.delete(item) }
end
# Create a copy of this outfit, additionally wearing the given item.
# Automatically moves any incompatible worn items to the closet.
def with_item(item)
dup.tap do |o|
# Skip if item is nil, already worn, or outfit has no pet_state
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
# Load appearances for the new item and all currently worn items
all_items = o.worn_items + [item]
appearances = Item.appearances_for(all_items, o.pet_type,
swf_asset_includes: [:zone])
new_item_appearance = appearances[item.id]
# If the new item has no appearance (doesn't fit this pet), skip it
next if new_item_appearance.empty?
# Find items that conflict with the new item
conflicting_items = o.worn_items.select do |worn_item|
worn_appearance = appearances[worn_item.id]
# Empty appearances are always compatible
!worn_appearance.empty? &&
!new_item_appearance.compatible_with?(worn_appearance)
end
# Move conflicting items to closet
conflicting_items.each do |conflicting_item|
o.worn_items.delete(conflicting_item)
o.closeted_items << conflicting_item unless o.closeted_item_ids.include?(conflicting_item.id)
end
# Add the new item
o.worn_items << item
end
end
end end

View file

@ -48,6 +48,26 @@ class PetType < ApplicationRecord
random_pet_types random_pet_types
end end
# Given a species ID and color ID, return the best matching PetType.
#
# If the exact species+color combo exists, return it.
# Otherwise, find the best fallback for that species:
# - Prefer the requested color if available
# - Otherwise prefer simple colors (basic > standard > alphabetical)
#
# This matches the wardrobe behavior where we automatically switch to a valid
# color when the user selects a species that doesn't support their current color.
#
# Returns the PetType, or nil if no pet types exist for this species.
def self.for_species_and_color(species_id:, color_id:)
return nil if species_id.nil?
where(species_id: species_id)
.preferring_color(color_id)
.preferring_simple
.first
end
def as_json(options={}) def as_json(options={})
super({ super({
only: [:id], only: [:id],

View file

@ -34,4 +34,13 @@ class Species < ApplicationRecord
def self.param_to_id(param) def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
end end
# Get all colors that are compatible with this species (have pet types)
# Returns an ActiveRecord::Relation of Color records
def compatible_colors
Color.alphabetical
.joins(:pet_types)
.where(pet_types: { species_id: id })
.distinct
end
end end

View file

@ -9,26 +9,8 @@
= image_tag item.thumbnail_url, class: 'item-thumbnail' = image_tag item.thumbnail_url, class: 'item-thumbnail'
%h2.item-name= item.name %h2.item-name= item.name
%nav.item-links %nav.item-links
- if item.currently_in_mall? = render "items/badges/kind", item: item
= link_to "https://ncmall.neopets.com/", class: "item-kind", data: {type: "nc"}, = render "items/badges/first_seen", item: item
title: "Currently in NC Mall!", target: "_blank" do
= cart_icon alt: "Buy"
#{item.current_nc_price} NC
- elsif item.nc?
%abbr.item-kind{'data-type' => 'nc', title: t('items.show.item_kinds.nc.description')}
= t('items.show.item_kinds.nc.label')
- elsif item.pb?
%abbr.item-kind{'data-type' => 'pb', title: t('items.show.item_kinds.pb.description')}
= t('items.show.item_kinds.pb.label')
- else
%abbr.item-kind{'data-type' => 'np', title: t('items.show.item_kinds.np.description')}
= t('items.show.item_kinds.np.label')
- if item.created_at?
%time.first-seen-at{
datetime: item.created_at.iso8601,
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
}= time_with_only_month_if_old item.created_at
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item) = link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item) = link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)

View file

@ -0,0 +1,13 @@
-# Renders a "first seen" timestamp badge for an item
-#
-# Usage:
-# = render "items/badges/first_seen", item: @item
-#
-# Shows when the item was first added to the database.
-# Only renders if the item has a created_at timestamp.
- if item.created_at?
%time.item-badge.first-seen-at{
datetime: item.created_at.iso8601,
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
}= time_with_only_month_if_old item.created_at

View file

@ -0,0 +1,25 @@
-# Renders an item kind badge (NC/NP/PB)
-#
-# Usage:
-# = render "items/item_kind_badge", item: @item
-#
-# The badge shows:
-# - For NC Mall items: clickable link with price
-# - For NC items: purple "NC" badge
-# - For PB items: orange "PB" badge
-# - For NP items: gray "NP" badge
- if item.currently_in_mall?
= link_to "https://ncmall.neopets.com/", class: "item-badge", data: {item_kind: "nc"},
title: "Currently in NC Mall!", target: "_blank" do
= cart_icon alt: "Buy"
#{item.current_nc_price} NC
- elsif item.nc?
%abbr.item-badge{data: {item_kind: "nc"}, title: t('items.show.item_kinds.nc.description')}
= t('items.show.item_kinds.nc.label')
- elsif item.pb?
%abbr.item-badge{data: {item_kind: "pb"}, title: t('items.show.item_kinds.pb.description')}
= t('items.show.item_kinds.pb.label')
- else
%abbr.item-badge{data: {item_kind: "np"}, title: t('items.show.item_kinds.np.description')}
= t('items.show.item_kinds.np.label')

View file

@ -131,4 +131,5 @@
- content_for :javascripts do - content_for :javascripts do
= javascript_include_tag "idiomorph", async: true = javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true = javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "items/show", async: true = javascript_include_tag "items/show", async: true

View file

@ -0,0 +1,28 @@
.search-results
.search-results-header
= button_to wardrobe_v2_path, method: :get, class: "back-button" do
← Back to outfit
= outfit_state_params except: [:q]
- if @search_results.any?
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
%ul.search-results-list
- @search_results.each do |item|
%li.item-card
.item-thumbnail
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
.item-info
.item-name= item.name
.item-badges
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
= outfit_state_params @outfit.with_item(item)
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
- else
.empty-state
%p No matching items found. Try a different search term, or browse items on the main site.

View file

@ -0,0 +1,71 @@
- title "Wardrobe v2"
!!! 5
%html
%head
%meta{charset: 'utf-8'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
%title= yield :title
%link{href: image_path('favicon.png'), rel: 'icon'}
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= page_stylesheet_link_tag "outfits/new_v2"
= javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "outfits/new_v2", async: true
= csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2
.wardrobe-container
.outfit-preview-section
- if @pet_type.nil?
.no-preview-message
%p
We haven't seen this kind of pet before! Try a different species/color
combination.
- else
= outfit_viewer @outfit
%species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f|
= outfit_state_params except: [:color, :species]
= select_tag :color,
options_from_collection_for_select(@colors, "id", "human_name",
@selected_color&.id),
"aria-label": "Pet color"
= select_tag :species,
options_from_collection_for_select(@species, "id", "human_name",
@selected_species&.id),
"aria-label": "Pet species"
= submit_tag "Go", name: nil
.outfit-controls-section
%h1 Customize your pet
= form_with url: wardrobe_v2_path, method: :get, class: "item-search-form" do |f|
= outfit_state_params
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
= f.submit "Search"
- if @search_mode
= render "search_results"
- elsif @outfit.worn_items.any?
.worn-items
- outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group
%h3.zone-label= zone_group[:zone_label]
%ul.items-list
- zone_group[:items].each do |item|
%li.item-card
.item-thumbnail
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
.item-info
.item-name= item.name
.item-badges
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item
= button_to wardrobe_v2_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
= outfit_state_params @outfit.without_item(item)

View file

@ -10,6 +10,7 @@ OpenneoImpressItems::Application.routes.draw do
# TODO: It's a bit silly that outfits/new points to outfits#edit. # TODO: It's a bit silly that outfits/new points to outfits#edit.
# Should we refactor the controller/view structure here? # Should we refactor the controller/view structure here?
get '/outfits/new', to: 'outfits#edit', as: :wardrobe get '/outfits/new', to: 'outfits#edit', as: :wardrobe
get '/outfits/new/v2', to: 'outfits#new_v2', as: :wardrobe_v2
get '/wardrobe' => redirect('/outfits/new') get '/wardrobe' => redirect('/outfits/new')
get '/start/:color_name/:species_name' => 'outfits#start' get '/start/:color_name/:species_name' => 'outfits#start'

View file

@ -0,0 +1,994 @@
# Wardrobe V2 Migration Status
This document tracks the status of Wardrobe V2, a ground-up rewrite of the outfit editor using Rails + Turbo, replacing the React + GraphQL system embedded from Impress 2020.
## Goal
Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a simpler Rails/Turbo implementation that:
- Eliminates dependency on Impress 2020's GraphQL API
- Uses progressive enhancement (works without JavaScript)
- Leverages Web Components for interactive features
- Reduces frontend complexity and maintenance burden
- Eventually enables full deprecation of the Impress 2020 service
## Current Status
**Wardrobe V2 is in early prototype/proof-of-concept stage.** It's accessible at `/outfits/new/v2` but is not yet linked from the main UI.
### What's Implemented
#### Core Infrastructure
**Route & Controller** ([outfits_controller.rb:80-115](app/controllers/outfits_controller.rb#L80-L115))
- `GET /outfits/new/v2` - Main wardrobe endpoint
- Takes URL params: `species`, `color`, `objects[]` (item IDs)
- Returns full HTML page (no layout, designed to work standalone)
- Defaults to Blue Acara if no pet specified
**View Layer** ([new_v2.html.haml](app/views/outfits/new_v2.html.haml))
- Full-page layout with preview (left) and controls (right)
- Responsive: stacks vertically on mobile (< 800px)
- Uses existing `outfit_viewer` partial for rendering
- Custom CSS in [outfits/new_v2.css](app/assets/stylesheets/outfits/new_v2.css)
- Minimal JavaScript in [outfits/new_v2.js](app/assets/javascripts/outfits/new_v2.js)
**Pet Selection** ([new_v2.html.haml:31-42](app/views/outfits/new_v2.html.haml#L31-L42))
- Species/color picker using `<species-color-picker>` web component
- Floats over preview area (bottom), reveals on hover/focus
- Progressive enhancement: submit button appears if JS slow/disabled
- Auto-submits form on change when JS loaded
- Filters colors to only those compatible with selected species
- Advanced fallback: if species+color combo doesn't exist, falls back to simple color ([outfits_controller.rb:89-98](app/controllers/outfits_controller.rb#L89-L98))
**Item Display** ([new_v2.html.haml:47-64](app/views/outfits/new_v2.html.haml#L47-L64))
- Groups worn items by zone (Hat, Jacket, Wings, etc.)
- Smart multi-zone simplification: hides redundant single-item zones
- Shows items that occupy multiple zones in conflict zones only
- Handles zone label disambiguation (adds IDs when duplicates exist)
- Separates incompatible items (wrong body_id) into "Incompatible" section
- Displays item thumbnails, names, and badges (NC/NP, first seen date)
- Alphabetical sorting within zones
**Item Search** ([new_v2.html.haml:47-50](app/views/outfits/new_v2.html.haml#L47-L50))
- Search form at top of controls section
- Auto-filters to items that fit current pet (species + color + alt_style)
- Uses `Item::Search::Query.from_params` for structured search
- Toggles between search results and worn items views (full page refresh)
- Pagination with 30 items per page using `q[page]` parameter
- All search state scoped under `q[...]` params (name, page, etc.)
- "Back to outfit" button to exit search
**Item Addition** ([_search_results.html.haml](app/views/outfits/_search_results.html.haml))
- Add button () on each search result item
- Adds item to outfit via GET request with updated `objects[]` params
- Button hidden by default, appears on hover/focus
- Preserves search state when adding items
- Uses `outfit.with_item(item)` helper to generate new state
**Item Removal** ([new_v2.html.haml:70-72](app/views/outfits/new_v2.html.haml#L70-L72))
- Remove button (❌) on each worn item
- Removes item from outfit via GET request with updated `objects[]` params
- Button hidden by default, appears on hover/focus
- Uses `outfit.without_item(item)` helper to generate new state
**State Management** ([outfits_helper.rb:68-90](app/helpers/outfits_helper.rb#L68-L90))
- All state lives in URL params (no client-side state)
- `outfit_state_params` helper generates hidden fields for outfit state
- Preserves: species, color, worn items (`objects[]`), search query (`q[...]`)
- Can exclude specific params via `except:` (e.g., to override species/color, or clear search)
- Every action generates new URL via GET request
#### Supporting Helpers
**`outfit_items_by_zone`** ([outfits_helper.rb:96-167](app/helpers/outfits_helper.rb#L96-L167))
- Core grouping logic for items by zone
- Matches wardrobe-2020's `getZonesAndItems` behavior
- Returns array of `{zone_id:, zone_label:, items:}` hashes
- Extensively tested in [outfits_helper_spec.rb](spec/helpers/outfits_helper_spec.rb)
**`outfit_viewer`** ([outfits_helper.rb:86-89](app/helpers/outfits_helper.rb#L86-L89))
- Renders `<outfit-viewer>` web component
- Displays pet + item layers using HTML5 Canvas/iframes
- Reused from existing codebase (also used on item pages, alt style pages, etc.)
**Web Components**
- `<species-color-picker>` - Auto-submit form on change ([species-color-picker.js](app/assets/javascripts/species-color-picker.js))
- `<outfit-viewer>` - Pet/item layer rendering ([outfit-viewer.js](app/assets/javascripts/outfit-viewer.js))
- `<outfit-layer>` - Individual layer loading/error states
#### Model Support
**`Outfit#without_item`** ([outfit.rb:296-298](app/models/outfit.rb#L296-L298))
- Creates a duplicate outfit without the specified item
- Used for remove button state generation
**`Outfit#with_item`** ([outfit.rb:300-303](app/models/outfit.rb#L300-L303))
- Creates a duplicate outfit with the specified item added
- Used for add button state generation in search results
- Prevents duplicate items (checks if item already worn)
**`Outfit#visible_layers`** ([outfit.rb:172-192](app/models/outfit.rb#L172-L192))
- Returns array of `SwfAsset` layers to render
- Combines pet biology layers + compatible item layers
- Filters by zone restrictions
- Note: Doesn't currently handle alt styles (TODO in code)
**`Item.appearances_for`**
- Batch-loads item appearances for multiple items on a pet type
- Returns hash of `{item_id => Appearance}` structs
- Used by `outfit_items_by_zone` to determine compatibility
**`Item::Search::Query.from_params`** ([item/search/query.rb](app/models/item/search/query.rb))
- Structured search query builder (vs. `from_text` which parses strings)
- Takes indexed hash of filters with `key`, `value`, `is_positive`
- Supported filters: `name`, `is_nc`, `is_np`, `is_pb`, `fits`, `occupied_zone_set_name`, etc.
- Used by Wardrobe V2 to auto-filter items by current pet compatibility
### What's NOT Implemented Yet
Below is a comprehensive comparison with the full feature set of Wardrobe 2020 (React version).
#### Critical Missing Features
**Item Search & Addition**
- ✅ Basic search UI implemented (text search only)
- ✅ Item results display with pagination (30 per page)
- ✅ Add items to outfit from search results
- ✅ Auto-filtering to items that fit current pet
- ✅ Empty state messages
- ❌ Still missing from Wardrobe 2020:
- Inline search syntax (`is:nc`, `fits:blue-acara`, etc.) - currently only supports plain text
- Advanced filter UI (NC/NP/PB toggles, zone selector, ownership filters)
- Filter chips display showing active filters
- Autosuggest/autocomplete
- Keyboard navigation in search results (Up/Down arrows, Escape, Enter)
- Preloading adjacent pages for faster pagination
- NC Styles intelligent hints
- Item restoration logic (restoring previous items when trying on conflicts)
**Outfit Saving/Loading**
- ❌ No save button
- ❌ No editable outfit name field
- ❌ No load existing outfit capability
- ❌ No user authentication integration
- ❌ Missing from Wardrobe 2020:
- Auto-save with debounce
- "Saving..." / "Saved" indicator
- Version tracking
- Navigation blocking for unsaved changes
- Owner-only editing restrictions
- Outfit menu (Edit a Copy, Rename, Delete)
- Shopping list link (Items Sources page)
**Pose/Emotion Selection**
- ✅ Has: Species and color pickers
- ❌ No pose picker UI
- ❌ Locked to canonical pose for pet type
- ❌ Missing from Wardrobe 2020:
- Tabbed interface (Expressions tab, Styles tab)
- Expression grid (3×2 matrix: Happy/Sad/Sick × Masc/Fem)
- Visual pose preview thumbnails
- Unconverted (UC) pose option with warning
- Pose availability indicators (grayed out with "?")
- Auto-recovery when pose becomes invalid
- Alt Styles/Pet Styles picker (Styles tab)
- "Default" option to return to normal appearance
- Visual thumbnails for each alt style
- Link to Rainbow Pool Styles info
**Pet Loading**
- ❌ No "load my pet" feature (pet name lookup)
- ❌ Can only use species/color pickers
- ❌ No modeling integration
**Preview Controls**
- ✅ Has: Basic outfit viewer rendering
- ❌ No overlay controls
- ❌ Missing from Wardrobe 2020:
- Back button (to homepage/Your Outfits)
- Play/Pause animation button (with localStorage persistence)
- Download outfit as PNG (with pre-generation on hover)
- Copy link to clipboard (with "Copied!" confirmation)
- Settings popover:
- Hi-res mode toggle (SVG vs PNG)
- Use DTI's image archive toggle
- HTML5 conversion badge (green checkmark / warnings)
- Known glitches badge (lists specific issues)
- Right-click context menu (Download, Layers info modal)
- Auto-hide controls on desktop, always visible on touch
- Focus lock on touch devices
**Advanced Item Features**
- ✅ Has: Item badges (NC/NP, first seen)
- ✅ Has: Remove item button
- ❌ Missing from Wardrobe 2020:
- Wear/unwear toggle (click to toggle, radio button behavior)
- Item info button (opens item page in new tab)
- "You own this" badge (for logged-in users)
- "You want this" badge (for logged-in users)
- Zone badges (shows occupied zones on item)
- Restricted zone badges
- Incompatible items tooltip (explains why item doesn't fit)
- Alt Style incompatibility indicators
- Smooth animations (fade out and collapse on removal)
**User Features**
- ❌ No user authentication
- ❌ No closet integration
- ❌ No ownership tracking
- ❌ No wishlist tracking
- ❌ Can't filter search by owned/wanted items
**URL State**
- ✅ Has: Basic state (species, color, items)
- ❌ Missing parameters:
- `name` - Outfit name
- `pose` - Pose string (e.g., HAPPY_FEM)
- `style` - Alt Style ID
- `state` - Appearance ID (pose version pinning)
- `closet[]` - Closeted items array
- ❌ No browser back/forward support (full page reloads)
- ❌ No legacy URL format support (#params)
#### UI/UX Gaps
**Visual Polish**
- Basic styling, needs design refinement
- No loading spinners with configurable delay
- No skeleton screens
- No smooth transitions (fade in/out as layers load)
- No error boundaries
- No toast notifications
- Species/color picker styling is functional but basic
- No dark mode support
**Accessibility**
- ❌ Missing from Wardrobe 2020:
- ARIA labels on all interactive elements
- VisuallyHidden screen reader helpers
- Semantic heading hierarchy
- Landmark regions
- Skip links between sections
- High color contrast
- Comprehensive keyboard shortcuts
**Performance**
- ❌ No optimistic updates (every change is full page navigation)
- ❌ Could benefit from Turbo Frames for partial updates
- ❌ No prefetching/preloading
- ❌ Missing from Wardrobe 2020:
- React.memo optimizations
- Object caching to prevent re-renders
- GraphQL query caching
- Image preloading
- LRU caches for expensive computations
- Low FPS detection and auto-pause
- Network error recovery
- Incremental loading
- Non-blocking loading overlays
**Mobile Experience**
- ✅ Has: Basic responsive layout (stacks vertically)
- ❌ Touch interactions not optimized
- ❌ Species/color picker hover state doesn't work well on touch
- ❌ Missing from Wardrobe 2020:
- Large touch targets
- Always-visible action buttons on touch devices
- Adapted control layouts for small screens
- No hover-only interactions
#### Advanced Features Not Implemented
**Conflict Detection & Resolution**
- ❌ No automatic zone conflict resolution
- ❌ No smart item restoration when unwearing
- ❌ No closet panel for conflicted items
- ❌ Current: Remove item just removes it (can't restore)
**Special Cases**
- ❌ No known glitch detection system
- ❌ No special handling for:
- Unconverted (UC) pets
- Invisible pets
- Dyeworks items
- Baby Body Paint warnings
- Faerie Uni dithering horn
- Body ID mismatches
- ❌ No glitch badges or warnings
**Appearance Customization**
- ❌ No pose locking (pinning to specific appearance version)
- ❌ No manual appearance ID selection
- ❌ No layer visibility controls
- ❌ No zone restriction customization
**Data Management**
- ❌ No modeling integration
- ❌ No contribution tracking
- ❌ No appearance version history
#### Support/Admin Features (Not Needed for MVP)
**Support Mode** (for staff users only):
- ❌ Item Support Drawer
- ❌ Appearance Layer Support Modal
- ❌ Pose Picker Support Mode
- ❌ All Item Layers Support Modal
- ❌ Debug features (appearance IDs, "Maybe Animated" badge)
- Note: These are staff-only and not required for general migration
## Technical Approach
### State Management Philosophy
**URL as Single Source of Truth**
- All outfit state encoded in URL params
- No JavaScript state management
- Every interaction generates a new URL via GET
- Browser back/forward work naturally
- Easy to bookmark/share
**Progressive Enhancement**
- Works without JavaScript (submit buttons, full page loads)
- Web Components enhance with auto-submit, hover effects
- Graceful degradation at every layer
### Rendering Strategy
**Server-Side Rendering**
- All HTML generated server-side
- No client-side templates
- Uses existing Rails helpers and partials
- Fast initial load, good for SEO
**Web Components for Interactivity**
- Lightweight custom elements for specific behaviors
- No framework overhead
- Easy to understand and maintain
- Examples: `<species-color-picker>`, `<outfit-viewer>`
### Data Flow
```
User Action (click/submit)
GET request with updated params
Controller builds Outfit model
Preloads all necessary data (SwfAssets, manifests)
Renders full HTML with outfit_viewer
Browser displays (instant if Turbo, full page otherwise)
```
### Testing Strategy
**Helper specs** cover core logic:
- `outfit_items_by_zone` - extensively tested for all edge cases
- Multi-zone simplification
- Zone label disambiguation
- Incompatible item handling
- Sorting behavior
**Missing test coverage:**
- Controller specs
- Integration/system specs
- JavaScript web component tests
## Migration Challenges
### Data Model Gaps
**Current Issues:**
- Alt style support not implemented in `visible_layers`
- Outfit model designed around saved outfits (has `user_id`, `name`)
- Need to handle unsaved/anonymous outfits better
### Performance Concerns
**Full Page Loads**
- Every species/color/item change triggers new page load
- Could use Turbo Frames to update only changed sections
- Need to measure actual performance impact
**Asset Preloading**
- Currently preloads all visible layer manifests ([outfits_controller.rb:112](app/controllers/outfits_controller.rb#L112))
- Good for parallelization, but loads data that might not be needed
- Could be more selective
### User Experience Parity
**Wardrobe 2020 Features to Match:**
- Real-time search with instant results
- Drag-and-drop item management
- Visual zone conflict indicators
- Item info popovers
- "Try on all these items" bulk add
- Sharing outfits with generated images
### Migration Path Unknowns
**Big Questions:**
1. Can we achieve acceptable UX without heavy JavaScript?
2. Is Turbo sufficient, or do we need more React-like patterns?
3. How to handle the transition period (two wardrobes)?
4. What to do with existing saved outfits (data model changes)?
5. How to migrate user workflows (muscle memory, bookmarks)?
## Next Steps
**Recently Completed:**
- ✅ Basic item search functionality (November 2025)
- Text search with auto-filtering by pet compatibility
- Pagination with 30 items per page
- Add/remove items with state preservation
- Clean URL-based state management with all search state scoped under `q[...]`
### Phase 1: Core Functionality (MVP)
**Goal:** Basic usable wardrobe that can compete with essential features.
1. **Item Search & Addition** 🟢 Complete (basic), 🟡 Enhancements pending
- [x] Search input field in sidebar
- [x] Basic text search (query Items API)
- [x] Paginated results display (30 per page)
- [x] Button to wear items (Add button on search results)
- [x] Add items to outfit via URL params
- [x] Empty state message
- [x] Auto-filter items to current pet compatibility
- [x] Back to outfit button to exit search
- [x] Search state scoped under `q[...]` params
- Optional enhancements:
- [ ] Inline search syntax (`is:nc`, `fits:blue-acara`)
- [ ] Autosuggest/autocomplete
- [ ] Filter chips display
- [ ] Advanced filter UI (NC/NP/PB toggles, zone selector)
- [ ] Keyboard navigation (arrows, escape, enter)
2. **Outfit Saving/Loading** 🔴 Critical
- [ ] Editable outfit name field
- [ ] Save button (for logged-in users)
- [ ] Auto-save on change (with debounce)
- [ ] "Saving..." / "Saved" indicator
- [ ] Route: `GET /outfits/:id/v2` to load saved outfit
- [ ] Owner-only editing enforcement
- [ ] Handle unsaved outfits gracefully
- Optional enhancements:
- [ ] Navigation blocking for unsaved changes
- [ ] Outfit menu (Delete, Edit a Copy)
3. **Pose Selection** 🟡 Important
- [ ] Pose picker UI (emotion grid)
- [ ] Visual pose thumbnails (tiny pet renders)
- [ ] Update pet_state via URL params
- [ ] Handle missing/invalid poses gracefully
- [ ] Add `pose` param to URL state
- Optional enhancements:
- [ ] Tabbed interface (Expressions / Styles)
- [ ] Pose availability indicators (gray out unavailable)
- [ ] Auto-recovery when pose becomes invalid
4. **Alt Styles Support** 🟡 Important
- [ ] Alt styles picker (Styles tab in pose picker?)
- [ ] Visual thumbnails for each style
- [ ] Add `style` param to URL state
- [ ] Update `Outfit#visible_layers` to handle alt styles
- [ ] "Default" option to return to normal
- Optional enhancements:
- [ ] Link to Rainbow Pool Styles info
### Phase 2: Polish & UX Improvements
**Goal:** Match quality and usability of Wardrobe 2020.
5. **Improved Item Display**
- [ ] Wear/unwear toggle (click item to toggle)
- [ ] Item info button (link to item page)
- [ ] Zone badges on items
- [ ] Smooth animations on removal (fade + collapse)
- [ ] Better incompatible items messaging
- [ ] Tooltips explaining incompatibility
6. **Preview Controls**
- [ ] Overlay controls (auto-hide on desktop, always visible on touch)
- [ ] Play/Pause animation button
- [ ] Download outfit as PNG
- [ ] Copy link to clipboard (with confirmation)
- [ ] Settings dropdown (hi-res mode, use archive)
- [ ] HTML5 conversion badge
- Optional:
- [ ] Known glitches badge
- [ ] Right-click context menu
7. **Loading & Error States**
- [ ] Loading spinners with delay
- [ ] Skeleton screens for items
- [ ] Smooth transitions (fade in/out)
- [ ] Toast notifications for errors
- [ ] Network error recovery
- [ ] Graceful degradation
8. **Mobile Optimization**
- [ ] Large touch targets
- [ ] Always-visible controls on touch devices
- [ ] Fix species/color picker on touch (no hover)
- [ ] Optimized layout for small screens
- [ ] Test swipe gestures
9. **Keyboard Navigation & Accessibility**
- [ ] Comprehensive keyboard shortcuts (match Wardrobe 2020)
- [ ] ARIA labels on all interactive elements
- [ ] Semantic heading hierarchy
- [ ] Skip links between sections
- [ ] High color contrast
- [ ] Screen reader testing
### Phase 3: Advanced Features
**Goal:** Feature parity with Wardrobe 2020 where valuable.
10. **User Features** (if keeping)
- [ ] User authentication integration
- [ ] Closet integration
- [ ] "You own this" badges
- [ ] "You want this" badges
- [ ] Filter search by owned/wanted items
- [ ] Shopping list link (Items Sources page)
11. **Conflict Detection & Management**
- [ ] Automatic zone conflict resolution
- [ ] Closet panel for conflicted items
- [ ] Smart item restoration when unwearing
- [ ] Visual conflict indicators
12. **Pet Loading**
- [ ] "Load my pet" input field
- [ ] Integration with modeling system
- [ ] Handle modeling errors gracefully
- [ ] Redirect to wardrobe with loaded pet
13. **Search Enhancements** (if needed)
- [ ] Advanced search dropdown
- [ ] Preload adjacent pages
- [ ] NC Styles intelligent hints
- [ ] Item restoration logic
14. **URL Enhancements**
- [ ] Add missing params (pose, style, state, closet[])
- [ ] Browser back/forward support (Turbo handles this?)
- [ ] Legacy URL format support (#params)
- [ ] Clean URL generation
### Phase 4: Performance & Optimization
**Goal:** Fast, smooth experience comparable to React version.
15. **Performance Improvements**
- [ ] Evaluate Turbo Frames for partial updates
- [ ] Measure page load times
- [ ] Image preloading strategies
- [ ] Optimize database queries (N+1, etc.)
- [ ] Consider caching strategies
- [ ] Monitor real-world performance
16. **Visual Polish**
- [ ] Design refinement (match DTI aesthetic)
- [ ] Dark mode support
- [ ] Smooth animations throughout
- [ ] Responsive typography
- [ ] Consistent spacing/layout
### Phase 5: Migration & Rollout
**Goal:** Transition users from Wardrobe 2020 to Wardrobe V2.
17. **Migration Preparation**
- [ ] Feature flag system (A/B test)
- [ ] User feedback collection mechanism
- [ ] Performance monitoring
- [ ] Bug tracking and triage
- [ ] Documentation for users
18. **Gradual Rollout**
- [ ] Internal testing (staff only)
- [ ] Beta testing (opt-in users)
- [ ] Gradual percentage rollout
- [ ] Monitor error rates and feedback
- [ ] Make default for new users
- [ ] Eventually deprecate old wardrobe
19. **Deprecation Cleanup**
- [ ] Remove wardrobe-2020 JavaScript code
- [ ] Update Impress 2020 dependencies doc
- [ ] Remove GraphQL queries (if no longer needed)
- [ ] Simplify codebase
- [ ] Update documentation
### Deferred / Maybe Never
**Features that may not be worth implementing:**
- ❓ Support mode features (can keep using old wardrobe for this)
- ❓ Unconverted pet support (being phased out in favor of Alt Styles)
- ❓ Known glitches system (complex, low value)
- ❓ Appearance version pinning (niche feature)
- ❓ Manual appearance ID selection (staff only)
- ❓ Layer visibility controls (complexity vs value)
- ❓ Low FPS detection and auto-pause (nice-to-have)
- ❓ All the React.memo micro-optimizations (Rails is different)
### Success Criteria for Each Phase
**Phase 1 (MVP):**
- ✅ Can search for and add items
- ✅ Can remove items
- ✅ Can change species/color/pose
- ✅ Can save and load outfits
- ✅ Can use alt styles
- ✅ Works without JavaScript (progressive enhancement)
**Phase 2 (Polish):**
- ✅ Feels responsive and smooth
- ✅ Works well on mobile
- ✅ Accessible to keyboard and screen reader users
- ✅ Handles errors gracefully
- ✅ Loading states don't feel jarring
**Phase 3 (Advanced):**
- ✅ Feature parity with Wardrobe 2020 for common workflows
- ✅ Closet integration works (if keeping)
- ✅ Conflict resolution feels natural
- ✅ Pet loading works reliably
**Phase 4 (Performance):**
- ✅ Page loads in < 1 second
- ✅ Interactions feel instant (< 100ms perceived)
- ✅ No janky animations
- ✅ Works well on slower connections
**Phase 5 (Migration):**
- ✅ User satisfaction meets or exceeds old wardrobe
- ✅ Error rates acceptable (< 1% of interactions)
- ✅ Can safely deprecate old wardrobe
- ✅ Impress 2020 dependencies reduced/eliminated
## Open Questions
**Architecture Decisions:**
- Should we commit fully to Rails/Turbo, or is some React necessary?
- Can we get away with just Web Components, or need a framework?
- Is the URL-as-state approach sustainable as complexity grows?
**Feature Parity:**
- Which wardrobe-2020 features are actually essential?
- What can we simplify or eliminate?
- Are there features users don't use that we can drop?
**Migration Logistics:**
- Do we maintain both wardrobes during transition?
- How to handle user preferences (which wardrobe to use)?
- What's the deprecation timeline for Impress 2020?
**Data Model:**
- Should Outfit model change to better support unsaved/anonymous outfits?
- How to handle alt styles in visible_layers?
- Any schema changes needed?
## Appendix: Wardrobe 2020 Complete Feature Reference
This section documents ALL features in the React-based Wardrobe 2020 for reference during migration.
### UI Layout
**Main Structure:**
- Split-screen: Preview (left/top) + Items/Search panel (right/bottom)
- Responsive: Different layouts for mobile vs desktop
- Search footer (desktop only, behind feature flag)
### Pet Customization
**Species & Color Picker:**
- Species dropdown
- Color dropdown
- Real-time validation (red border for invalid combos)
- Smart fallback to basic colors
- Loading states with placeholders
**Pose Picker:**
- Tabbed interface: "Expressions" and "Styles"
- Expression tab: 3×2 grid (Happy/Sad/Sick × Masc/Fem)
- Visual pose preview thumbnails
- Unconverted (UC) option with warning
- Pose availability indicators (grayed out + "?")
- Auto-recovery to valid pose
- Full keyboard/screen reader support
**Alt Styles (Styles Tab):**
- Grid of available alt styles for species
- "Default" option to return to normal
- Visual thumbnails
- Link to Rainbow Pool Styles page
### Item Management
**Items Panel:**
- Editable outfit name (click to rename)
- Items grouped by zone (Hat, Jacket, etc.)
- Zone conflict display (multiple items per zone)
- Zone label deduplication (adds IDs)
- Incompatible items section with tooltip
- Per-item actions:
- Remove (X icon)
- Info (opens item page)
- Support drawer (staff only)
- Wear/unwear toggle (click/space to toggle)
- Radio button behavior for zone conflicts
- Keyboard navigation (space, tab)
- Smooth animations (fade + collapse)
**Item Display:**
- Thumbnail image
- Item name
- Badge system:
- NC/NP/PB (item type)
- Zone badges (occupied zones)
- Restricted zone badges
- "You own this" (logged in)
- "You want this" (logged in)
- "Maybe animated" (support only)
### Search
**Search Toolbar:**
- Free text input
- Autosuggest dropdown
- Advanced search dropdown (chevron)
- Active filter chips
- Clear button (X)
**Search Filters:**
- Item type: NC, NP, PB
- Zone filter (by body zone)
- Ownership: items you own, items you want (logged in)
**Search Results:**
- Paginated (30 per page)
- Pagination toolbar
- Preloads adjacent pages
- Visual checkboxes (wear/unwear)
- Item restoration logic
- Keyboard navigation:
- Arrow Up/Down between results
- Arrow Up from first → search box
- Escape → search box
- Enter to toggle
- NC Styles intelligent hint
- Empty state message
### Outfit Preview
**Preview Display:**
- 600×600px canvas
- Layered rendering (proper z-index)
- Loading spinner (with delay)
- Cached thumbnail placeholder
- HTML5 Canvas animations
- SVG hi-res mode (optional)
- Performance monitoring (auto-pause on low FPS)
- Error handling (fallback to static)
- Smooth transitions
**Overlay Controls:**
- Auto-hide on desktop (hover/focus to show)
- Always visible on touch devices
- Focus lock on touch (tap to lock)
**Control Buttons:**
- Back (to homepage/Your Outfits)
- Play/Pause (remembers in localStorage)
- Download PNG (pre-generates on hover)
- Copy link (shows "Copied!" confirmation)
- Settings popover:
- Hi-res mode toggle
- Use DTI's archive toggle
**Info Badges:**
- HTML5 conversion status:
- Green checkmark (fully converted)
- Gray/warning (not converted)
- Lists unconverted items
- Known glitches badge:
- Lists specific issues
- Covers: UC compat, Dyeworks, Baby Body Paint, Invisible, etc.
**Context Menu (Right-click):**
- Download option
- Layers info modal (SWF/PNG/SVG links)
### Outfit Saving
**Save Functionality:**
- Auto-save (debounced)
- Save button for new outfits
- Save indicator (Saving... / Saved / error)
- Version tracking
- Navigation blocking (unsaved changes)
- Owner-only editing
**Outfit Menu:**
- Edit a copy (new tab)
- Rename (inline editing)
- Delete (with confirmation)
- Shopping list (Items Sources page)
### URL State
**Parameters:**
- `outfit` - Outfit ID
- `name` - Outfit name
- `species` - Species ID
- `color` - Color ID
- `pose` - Pose string (HAPPY_FEM, etc.)
- `style` - Alt Style ID
- `state` - Appearance ID (version pinning)
- `objects[]` - Worn item IDs
- `closet[]` - Closeted item IDs
**Behavior:**
- Real-time URL updates
- Browser back/forward support
- Deep linking
- Legacy format support (`#params`)
- Path routing (`/outfits/:id` or `/outfits/new`)
### Keyboard Shortcuts
**Search Panel:**
- Escape: Clear/close
- Enter: Accept suggestion
- Arrow Down (from search): Focus first result
- Arrow Up/Down: Navigate results
- Arrow Up (from first): Back to search
- Backspace (at start): Clear filters
- Space: Toggle item
**Items Panel:**
- Space: Toggle wear/unwear
- Tab: Navigate between items
**Pose Picker:**
- Tab: Navigate poses
- Enter/Space: Select
### Accessibility
**Screen Readers:**
- ARIA labels on all controls
- VisuallyHidden checkboxes/radios
- Semantic HTML (headings, landmarks)
- Landmark regions
**Keyboard:**
- Full keyboard control
- Logical focus order
- Visible focus indicators
- Skip links
**Visual:**
- High color contrast
- Dark mode support
- Clear focus states
- Icon + text labels
### Performance
**Optimizations:**
- React.memo on heavy components
- Object caching (prevent re-renders)
- GraphQL query caching
- Image preloading
- LRU caches (movie clips, etc.)
- Pagination preloading
**Error Handling:**
- Error boundaries
- Network error recovery
- Animation fallbacks
- Low FPS detection + auto-pause
- Toast notifications
**Loading:**
- Skeleton screens
- Delayed spinners
- Incremental loading
- Non-blocking overlays
### Mobile/Responsive
- Touch-friendly targets
- Vertical stacking on mobile
- Adapted controls for small screens
- Always-visible buttons on touch
- No hover-only interactions
### Special Cases
**Known Glitches System:**
- UC/Invisible compatibility
- OFFICIAL_SWF_IS_INCORRECT
- OFFICIAL_MOVIE_IS_INCORRECT
- OFFICIAL_SVG_IS_INCORRECT (hi-res)
- DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN
- OFFICIAL_BODY_ID_IS_INCORRECT
- Dyeworks unconverted warnings
- Baby Body Paint warnings
- Invisible pet blanket warning
- Faerie Uni dithering horn
**Special Pet Types:**
- Unconverted (UC) pets
- Invisible pets
- Alt Style pets
- Dithering pets
### Support/Admin Features
**Support Mode (Staff Only):**
- Item Support Drawer
- Appearance Layer Support Modal
- Pose Picker Support Mode
- All Item Layers Support Modal
- Debug info (appearance IDs, localhost)
- "Maybe Animated" badge
- Extra logging
### Data Features
**Conflict Detection:**
- Auto zone conflict resolution
- Smart item restoration on unwear
- Zone restriction handling
**Appearance Data:**
- Body ID compatibility system
- Alt Style support
- Pose locking (via appearanceId)
- Unconverted preservation
**User Data (Logged In):**
- Ownership tracking
- Wishlist tracking
- Closet filtering
## References
**Related Documentation:**
- [Impress 2020 Dependencies](./impress-2020-dependencies.md) - What still uses the GraphQL API
- [Customization Architecture](./customization-architecture.md) - Data model deep dive
**Wardrobe V2 Files:**
- Controller: [app/controllers/outfits_controller.rb](../app/controllers/outfits_controller.rb)
- View: [app/views/outfits/new_v2.html.haml](../app/views/outfits/new_v2.html.haml)
- Helpers: [app/helpers/outfits_helper.rb](../app/helpers/outfits_helper.rb)
- Tests: [spec/helpers/outfits_helper_spec.rb](../spec/helpers/outfits_helper_spec.rb)
- Styles: [app/assets/stylesheets/outfits/new_v2.css](../app/assets/stylesheets/outfits/new_v2.css)
- JavaScript: [app/assets/javascripts/outfits/new_v2.js](../app/assets/javascripts/outfits/new_v2.js)
- Web Components:
- [app/assets/javascripts/species-color-picker.js](../app/assets/javascripts/species-color-picker.js)
- [app/assets/javascripts/outfit-viewer.js](../app/assets/javascripts/outfit-viewer.js)
**Wardrobe 2020 Files (React):**
- Main directory: [app/javascript/wardrobe-2020/](../app/javascript/wardrobe-2020/)
- Main page: [WardrobePage/index.js](../app/javascript/wardrobe-2020/WardrobePage/index.js)
- State: [useOutfitState.js](../app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js)
- Search: [SearchToolbar.js](../app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js)
- Items: [ItemsPanel.js](../app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js)
- Preview: [OutfitPreview.js](../app/javascript/wardrobe-2020/components/OutfitPreview.js)
---
**Document Status:** Living document - update as migration progresses
**Last Updated:** 2025-11-03
**Current Branch:** `feature/wardrobe-v2`

View file

@ -0,0 +1,310 @@
require_relative '../rails_helper'
RSpec.describe OutfitsHelper, type: :helper do
fixtures :zones, :colors, :species, :pet_types
# Use the Blue Acara's body_id throughout tests
let(:body_id) { pet_types(:blue_acara).body_id }
# Helper to create a test outfit with a pet type
# Biology assets are just setup noise - we only care about pet_type.body_id
def create_test_outfit
pet_type = pet_types(:blue_acara)
# PetState requires at least one biology asset (validation requirement)
bio_asset = SwfAsset.create!(
type: "biology",
remote_id: (@bio_remote_id = (@bio_remote_id || 1000) + 1),
url: "https://images.neopets.example/bio_#{@bio_remote_id}.swf",
zone: zones(:body),
zones_restrict: "",
body_id: 0
)
pet_state = PetState.create!(
pet_type: pet_type,
pose: "HAPPY_MASC",
swf_assets: [bio_asset],
swf_asset_ids: [bio_asset.id]
)
Outfit.create!(pet_state: pet_state)
end
# Helper to create SwfAssets for items (matches pattern from item_spec.rb)
def build_item_asset(zone, body_id:)
@item_remote_id = (@item_remote_id || 0) + 1
SwfAsset.create!(
type: "object",
remote_id: @item_remote_id,
url: "https://images.neopets.example/item_#{@item_remote_id}.swf",
zone: zone,
zones_restrict: "",
body_id: body_id
)
end
# Helper to create an item with zones
def create_item(name, zones_and_bodies)
item = Item.create!(
name: name,
description: "",
thumbnail_url: "https://images.neopets.example/#{name.parameterize}.gif",
rarity: "Common",
price: 100,
zones_restrict: "0" * 52
)
zones_and_bodies.each do |zone, body_id|
item.swf_assets << build_item_asset(zone, body_id: body_id)
end
item
end
describe '#outfit_items_by_zone' do
context 'with nil pet_type' do
it 'returns empty array' do
# Create an outfit without a pet_state (pet_type will be nil)
outfit = Outfit.new
# Allow the delegation to fail gracefully
allow(outfit).to receive(:pet_type).and_return(nil)
result = helper.outfit_items_by_zone(outfit)
expect(result).to eq([])
end
end
context 'with empty outfit' do
it 'returns empty array' do
outfit = create_test_outfit
result = helper.outfit_items_by_zone(outfit)
expect(result).to eq([])
end
end
context 'with single-zone items' do
let(:outfit) { create_test_outfit }
let!(:hat_item) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
let!(:jacket_item) { create_item("Red Jacket", [[zones(:jacket), body_id]]) }
before do
outfit.worn_items << hat_item
outfit.worn_items << jacket_item
end
it 'groups items by zone' do
result = helper.outfit_items_by_zone(outfit)
expect(result.length).to eq(2)
zone_labels = result.map { |g| g[:zone_label] }
expect(zone_labels).to contain_exactly("Hat", "Jacket")
end
it 'sorts zones alphabetically' do
result = helper.outfit_items_by_zone(outfit)
zone_labels = result.map { |g| g[:zone_label] }
expect(zone_labels).to eq(["Hat", "Jacket"])
end
it 'includes items in their respective zones' do
result = helper.outfit_items_by_zone(outfit)
hat_group = result.find { |g| g[:zone_label] == "Hat" }
jacket_group = result.find { |g| g[:zone_label] == "Jacket" }
expect(hat_group[:items]).to eq([hat_item])
expect(jacket_group[:items]).to eq([jacket_item])
end
end
context 'with multiple items in same zone' do
let(:outfit) { create_test_outfit }
let!(:hat1) { create_item("Awesome Hat", [[zones(:hat), body_id]]) }
let!(:hat2) { create_item("Cool Hat", [[zones(:hat), body_id]]) }
let!(:hat3) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
before do
outfit.worn_items << hat1
outfit.worn_items << hat2
outfit.worn_items << hat3
end
it 'sorts items alphabetically within zone' do
result = helper.outfit_items_by_zone(outfit)
hat_group = result.find { |g| g[:zone_label] == "Hat" }
item_names = hat_group[:items].map(&:name)
expect(item_names).to eq(["Awesome Hat", "Blue Hat", "Cool Hat"])
end
end
context 'with multi-zone item (no conflicts)' do
let(:outfit) { create_test_outfit }
let!(:bow_tie) do
create_item("Bow Tie", [
[zones(:collar), body_id],
[zones(:necklace), body_id],
[zones(:earrings), body_id]
])
end
before do
outfit.worn_items << bow_tie
end
it 'shows item in only one zone (simplification)' do
result = helper.outfit_items_by_zone(outfit)
# Should show in Collar zone only (first alphabetically)
expect(result.length).to eq(1)
expect(result[0][:zone_label]).to eq("Collar")
expect(result[0][:items]).to eq([bow_tie])
end
end
context 'with multi-zone simplification (item appears in conflict zone)' do
let(:outfit) { create_test_outfit }
let!(:multi_zone_item) do
create_item("Fancy Outfit", [
[zones(:jacket), body_id],
[zones(:collar), body_id]
])
end
let!(:collar_item) { create_item("Simple Collar", [[zones(:collar), body_id]]) }
before do
outfit.worn_items << multi_zone_item
outfit.worn_items << collar_item
end
it 'keeps conflict zone and hides redundant single-item zone' do
result = helper.outfit_items_by_zone(outfit)
zone_labels = result.map { |g| g[:zone_label] }
# Should show Collar (has conflict with 2 items)
# Should NOT show Jacket (redundant - item already in Collar)
expect(zone_labels).to eq(["Collar"])
collar_group = result.find { |g| g[:zone_label] == "Collar" }
item_names = collar_group[:items].map(&:name).sort
expect(item_names).to eq(["Fancy Outfit", "Simple Collar"])
end
end
context 'with zone label disambiguation' do
let(:outfit) { create_test_outfit }
# Create additional zones with duplicate labels for this test
let!(:markings_zone_a) do
Zone.create!(label: "Markings", depth: 100, plain_label: "markings_a", type_id: 2)
end
let!(:markings_zone_b) do
Zone.create!(label: "Markings", depth: 101, plain_label: "markings_b", type_id: 2)
end
let!(:item_zone_a) { create_item("Tattoo A", [[markings_zone_a, body_id]]) }
let!(:item_zone_b) { create_item("Tattoo B", [[markings_zone_b, body_id]]) }
let!(:item_zone_a_b) { create_item("Tattoo C", [[markings_zone_a, body_id]]) }
before do
outfit.worn_items << item_zone_a
outfit.worn_items << item_zone_b
outfit.worn_items << item_zone_a_b
end
it 'adds zone IDs to duplicate labels' do
result = helper.outfit_items_by_zone(outfit)
zone_labels = result.map { |g| g[:zone_label] }
# Both should have IDs appended since they share the label "Markings"
expect(zone_labels).to contain_exactly(
"Markings (##{markings_zone_a.id})",
"Markings (##{markings_zone_b.id})"
)
end
it 'groups items correctly by zone despite same label' do
result = helper.outfit_items_by_zone(outfit)
zone_a_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_a.id})" }
zone_b_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_b.id})" }
expect(zone_a_group[:items].map(&:name).sort).to eq(["Tattoo A", "Tattoo C"])
expect(zone_b_group[:items].map(&:name)).to eq(["Tattoo B"])
end
end
context 'with incompatible items' do
let(:outfit) { create_test_outfit }
let!(:compatible_item) { create_item("Fits Pet", [[zones(:hat), body_id]]) }
let!(:incompatible_item) { create_item("Wrong Body", [[zones(:jacket), 999]]) }
before do
outfit.worn_items << compatible_item
outfit.worn_items << incompatible_item
end
it 'separates incompatible items into their own section' do
result = helper.outfit_items_by_zone(outfit)
zone_labels = result.map { |g| g[:zone_label] }
expect(zone_labels).to contain_exactly("Hat", "Incompatible")
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
expect(incompatible_group[:items]).to eq([incompatible_item])
end
it 'sorts incompatible items alphabetically' do
outfit.worn_items << create_item("Alpha Item", [[zones(:jacket), 999]])
outfit.worn_items << create_item("Zulu Item", [[zones(:jacket), 999]])
result = helper.outfit_items_by_zone(outfit)
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
item_names = incompatible_group[:items].map(&:name)
expect(item_names).to eq(["Alpha Item", "Wrong Body", "Zulu Item"])
end
end
context 'with complex multi-zone scenario' do
let(:outfit) { create_test_outfit }
let!(:bg1) { create_item("Forest Background", [[zones(:background), 0]]) }
let!(:bg2) { create_item("Beach Background", [[zones(:background), 0]]) }
let!(:multi_item) do
create_item("Wings and Hat", [
[zones(:wings), 0],
[zones(:hat), 0]
])
end
let!(:hat_item) { create_item("Simple Hat", [[zones(:hat), 0]]) }
before do
outfit.worn_items << bg1
outfit.worn_items << bg2
outfit.worn_items << multi_item
outfit.worn_items << hat_item
end
it 'correctly applies all sorting and grouping rules' do
result = helper.outfit_items_by_zone(outfit)
# Background: has conflict (2 items)
# Hat: has conflict (2 items, including multi-zone item)
# Wings: should be hidden (multi-zone item already in Hat conflict)
zone_labels = result.map { |g| g[:zone_label] }
expect(zone_labels).to eq(["Background", "Hat"])
bg_group = result.find { |g| g[:zone_label] == "Background" }
expect(bg_group[:items].map(&:name)).to eq(["Beach Background", "Forest Background"])
hat_group = result.find { |g| g[:zone_label] == "Hat" }
expect(hat_group[:items].map(&:name)).to eq(["Simple Hat", "Wings and Hat"])
end
end
end
end

288
spec/models/outfit_spec.rb Normal file
View file

@ -0,0 +1,288 @@
require_relative '../rails_helper'
RSpec.describe Outfit do
fixtures :colors, :species, :zones
let(:blue) { colors(:blue) }
let(:acara) { species(:acara) }
before do
PetType.destroy_all
@pet_type = PetType.create!(color: blue, species: acara, body_id: 1)
@pet_state = create_pet_state(@pet_type, "HAPPY_MASC")
@outfit = Outfit.new(pet_state: @pet_state)
end
def create_pet_state(pet_type, pose)
# Create a basic biology asset so pet state saves correctly
swf_asset = SwfAsset.create!(
type: "biology",
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
url: "https://images.neopets.example/biology.swf",
zone: zones(:body),
zones_restrict: "",
body_id: pet_type.body_id
)
PetState.create!(
pet_type: pet_type,
pose: pose,
swf_assets: [swf_asset],
swf_asset_ids: [swf_asset.id]
)
end
def create_item(name, zone, body_id: 1, zones_restrict: "")
item = Item.create!(
name: name,
description: "Test item",
thumbnail_url: "https://images.neopets.example/item.png",
zones_restrict: zones_restrict,
rarity: "Common",
price: 100
)
swf_asset = SwfAsset.create!(
type: "object",
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
url: "https://images.neopets.example/#{name}.swf",
zone: zone,
zones_restrict: zones_restrict,
body_id: body_id
)
item.swf_assets << swf_asset
item
end
describe "Item::Appearance#compatible_with?" do
it "returns true for items in different zones with no restrictions" do
hat = create_item("Hat", zones(:hat))
shirt = create_item("Shirt", zones(:shirtdress))
appearances = Item.appearances_for([hat, shirt], @pet_type)
hat_appearance = appearances[hat.id]
shirt_appearance = appearances[shirt.id]
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
expect(shirt_appearance.compatible_with?(hat_appearance)).to be true
end
it "returns false for items in the same zone" do
hat1 = create_item("Hat 1", zones(:hat))
hat2 = create_item("Hat 2", zones(:hat))
appearances = Item.appearances_for([hat1, hat2], @pet_type)
hat1_appearance = appearances[hat1.id]
hat2_appearance = appearances[hat2.id]
expect(hat1_appearance.compatible_with?(hat2_appearance)).to be false
expect(hat2_appearance.compatible_with?(hat1_appearance)).to be false
end
it "returns false when one item restricts a zone the other occupies" do
# Create a hat that restricts the ruff zone (zone 29)
# The zones_restrict format is a 52-character bitstring where bit N corresponds to zone N+1
# Zones are 1-indexed, so zone 29 needs the bit at position 28 (0-indexed from right)
# Build string from right to left: 28 zeros, then "1", then 23 zeros
zones_restrict = ("0" * 23 + "1" + "0" * 28).reverse.chars.reverse.join
# Simpler approach: create a 52-char string with bit 28 set to "1"
zones_restrict_array = Array.new(52, "0")
zones_restrict_array[28] = "1" # Set bit for zone 29
zones_restrict = zones_restrict_array.join
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
# Create an item in the ruff zone
ruff_item = create_item("Ruff Item", zones(:ruff))
appearances = Item.appearances_for([restricting_hat, ruff_item], @pet_type)
hat_appearance = appearances[restricting_hat.id]
ruff_appearance = appearances[ruff_item.id]
expect(hat_appearance.compatible_with?(ruff_appearance)).to be false
expect(ruff_appearance.compatible_with?(hat_appearance)).to be false
end
it "returns true for empty appearances" do
# Create items that don't fit the current pet (wrong body_id)
hat = create_item("Hat", zones(:hat), body_id: 999)
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
appearances = Item.appearances_for([hat, shirt], @pet_type)
hat_appearance = appearances[hat.id]
shirt_appearance = appearances[shirt.id]
# Both should be empty (no swf_assets for this pet)
expect(hat_appearance).to be_empty
expect(shirt_appearance).to be_empty
# Empty appearances should be compatible
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
end
end
describe "#without_item" do
it "returns a new outfit without the given item" do
hat = create_item("Hat", zones(:hat))
outfit_with_hat = @outfit.with_item(hat)
new_outfit = outfit_with_hat.without_item(hat)
expect(new_outfit.worn_items).not_to include(hat)
expect(outfit_with_hat.worn_items).to include(hat) # Original unchanged
end
it "returns a new outfit instance (immutable)" do
hat = create_item("Hat", zones(:hat))
outfit_with_hat = @outfit.with_item(hat)
new_outfit = outfit_with_hat.without_item(hat)
expect(new_outfit).not_to eq(outfit_with_hat)
expect(new_outfit.object_id).not_to eq(outfit_with_hat.object_id)
end
it "does nothing if the item is not worn" do
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.without_item(hat)
expect(new_outfit.worn_items).to be_empty
end
end
describe "#with_item" do
it "adds an item when there are no conflicts" do
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.with_item(hat)
expect(new_outfit.worn_items).to include(hat)
end
it "returns a new outfit instance (immutable)" do
hat = create_item("Hat", zones(:hat))
new_outfit = @outfit.with_item(hat)
expect(new_outfit).not_to eq(@outfit)
expect(new_outfit.object_id).not_to eq(@outfit.object_id)
expect(@outfit.worn_items).to be_empty # Original unchanged
end
it "is idempotent (adding same item twice has no effect)" do
hat = create_item("Hat", zones(:hat))
outfit1 = @outfit.with_item(hat)
outfit2 = outfit1.with_item(hat)
expect(outfit1.worn_items.size).to eq(1)
expect(outfit2.worn_items.size).to eq(1)
expect(outfit2.worn_items).to include(hat)
end
it "does not add items that don't fit this pet" do
# Create item with wrong body_id
hat = create_item("Hat", zones(:hat), body_id: 999)
new_outfit = @outfit.with_item(hat)
expect(new_outfit.worn_items).to be_empty
end
context "with conflicting items" do
it "moves conflicting item to closet when items occupy the same zone" do
hat1 = create_item("Hat 1", zones(:hat))
hat2 = create_item("Hat 2", zones(:hat))
outfit_with_hat1 = @outfit.with_item(hat1)
outfit_with_hat2 = outfit_with_hat1.with_item(hat2)
expect(outfit_with_hat2.worn_items).to include(hat2)
expect(outfit_with_hat2.worn_items).not_to include(hat1)
expect(outfit_with_hat2.closeted_items).to include(hat1)
end
it "moves conflicting item to closet when new item restricts zone" do
# Create item in ruff zone
ruff_item = create_item("Ruff Item", zones(:ruff))
# Create hat that restricts ruff zone (zone 29)
# zones_restrict is 0-indexed, so zone 29 needs bit 28 to be "1"
zones_restrict_array = Array.new(52, "0")
zones_restrict_array[28] = "1"
zones_restrict = zones_restrict_array.join
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
# First wear ruff item, then wear restricting hat
outfit_with_ruff = @outfit.with_item(ruff_item)
outfit_with_hat = outfit_with_ruff.with_item(restricting_hat)
expect(outfit_with_hat.worn_items).to include(restricting_hat)
expect(outfit_with_hat.worn_items).not_to include(ruff_item)
expect(outfit_with_hat.closeted_items).to include(ruff_item)
end
it "keeps compatible items when adding new item" do
hat = create_item("Hat", zones(:hat))
shirt = create_item("Shirt", zones(:shirtdress))
pants = create_item("Pants", zones(:trousers))
outfit1 = @outfit.with_item(hat).with_item(shirt)
outfit2 = outfit1.with_item(pants)
expect(outfit2.worn_items).to include(hat, shirt, pants)
expect(outfit2.closeted_items).to be_empty
end
it "can move multiple conflicting items to closet" do
hat1 = create_item("Hat 1", zones(:hat))
hat2 = create_item("Hat 2", zones(:hat))
hat3 = create_item("Hat 3", zones(:hat))
# Wear hat1 and hat2 by manually building the outfit
# (normally you can't, but we're testing the conflict resolution)
outfit = @outfit.dup
outfit.worn_items << hat1
outfit.worn_items << hat2
# Now add hat3, which should move both hat1 and hat2 to closet
outfit_with_hat3 = outfit.with_item(hat3)
expect(outfit_with_hat3.worn_items).to contain_exactly(hat3)
expect(outfit_with_hat3.closeted_items).to contain_exactly(hat1, hat2)
end
it "does not duplicate items in closet if already closeted" do
hat1 = create_item("Hat 1", zones(:hat))
hat2 = create_item("Hat 2", zones(:hat))
# Wear hat1
outfit1 = @outfit.with_item(hat1)
# Add hat2 (moves hat1 to closet)
outfit2 = outfit1.with_item(hat2)
# Add hat2 again (should be idempotent, not duplicate hat1 in closet)
outfit3 = outfit2.with_item(hat2)
expect(outfit3.closeted_items.size).to eq(1)
expect(outfit3.closeted_items).to include(hat1)
end
end
context "edge cases" do
it "handles nil item gracefully" do
expect { @outfit.with_item(nil) }.not_to raise_error
end
it "works with outfit that has no pet_state" do
# This shouldn't happen in practice, but let's be defensive
outfit_no_pet = Outfit.new
hat = create_item("Hat", zones(:hat))
# Should not crash, but also won't add the item
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
end
end
end
end

View file

@ -38,4 +38,23 @@ RSpec.describe PetType do
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies) expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
end end
end end
describe ".for_species_and_color" do
it('returns the exact match when it exists') do
result = PetType.for_species_and_color(species_id: species(:acara), color_id: colors(:blue))
expect(result).to eq pet_types(:blue_acara)
end
it('returns nil when species is nil') do
result = PetType.for_species_and_color(species_id: nil, color_id: colors(:blue))
expect(result).to be_nil
end
it('falls back to a simple color when exact match does not exist') do
# Request a species that exists but with a color that might not
# It should fall back to a basic/standard color for that species
result = PetType.for_species_and_color(species_id: species(:acara), color_id: 999)
expect(result).to eq pet_types(:blue_acara)
end
end
end end

View file

@ -1,7 +1,15 @@
require_relative '../rails_helper' require_relative '../rails_helper'
RSpec.describe Species do RSpec.describe Species do
fixtures :species fixtures :species, :colors
describe "#valid_colors_for_species" do
it('returns colors that have pet types for the species') do
# The Blue Acara exists in fixtures, as does a "Color #123 Acara", which we'll ignore.
compatible_colors = species(:acara).compatible_colors
expect(compatible_colors.map(&:id)).to eq [8]
end
end
describe '#to_param' do describe '#to_param' do
it("uses name when possible") do it("uses name when possible") do