Compare commits

...

62 commits

Author SHA1 Message Date
f4b1309149 [WV2] Stabilize IDs for pose picker outfit viewers
This should help with the morphing for the main preview
2026-02-06 19:27:22 -08:00
f13481783d Merge branch 'main' into feature/wardrobe-v2 2026-02-06 19:24:30 -08:00
0a9c346fa6 [WV2] Minor update to WV2 migration doc 2026-02-06 18:11:11 -08:00
6f7b307e39 [WV2] Fix bug with play/pause visibility 2026-02-06 17:50:32 -08:00
b462272dc3 [WV2] Add search keyboard shortcuts 2026-02-06 17:20:10 -08:00
10e2140045 [WV2] Progressive enhancement for item search 2026-02-06 17:05:01 -08:00
36a28cff10 [WV2] Add progressive enhancement for outfit item list toggles
Rather than just buttons, upgrade to radio buttons when we have JS.
2026-02-06 16:42:42 -08:00
81b60eefad [WV2] Unify auto-submit behaviors into a shared web component 2026-02-06 11:29:04 -08:00
9baa64d39a [WV2] Unify common CSS patterns 2026-02-06 09:17:37 -08:00
3582b3674b [WV2] Remove unnecessary worn/closeted state tracking from helper fn 2026-02-06 09:10:37 -08:00
d0acb1c7e5 [WV2] Use variables for colors 2026-02-06 09:09:58 -08:00
0a82ed7b68 [WV2] Reorganize partials into subdirectories 2026-02-06 08:11:19 -08:00
fd881ee31d [WV2] Support closeted items as well as worn items 2026-02-06 07:54:09 -08:00
f5ad5d2b17 [WV2] Simplify canceling outfit renaming
Don't need a button anymore, with focusout and escape doing it
2026-02-05 22:01:45 -08:00
d7c561f91d [WV2] Use outfit name for page title 2026-02-05 21:58:27 -08:00
6fa4e57184 [WV2] Outfit renaming as an atomic operation 2026-02-05 21:56:23 -08:00
0d4b553162 [WV2] Outfit saving first draft 2026-02-05 20:47:05 -08:00
5e68d3809c [WV2] Fix syncing for play/pause state across page morphs
Reproduce:
1. Add an item with animations, and play them.
2. Remove the item.
3. Add it back.
4. Observe the button shows up in "Paused" state, even though it's playing.

This is because the server-side template wasn't doing anything to try to keep the play/pause button it renders in sync with the current saved state in the cookies, so it was always causing a morph to the pause state. Now we listen to the cookie instead!

I also updated the JS behavior to be a bit more consistent: treat the behavior as defaulting to true, unless it's explicitly set to the string "false".
2026-02-05 19:15:09 -08:00
ff3dd2249e [WV2] Fix Safari-specific bug for play/pause button state 2026-02-05 19:03:17 -08:00
97a035b3a3 [WV2] Fix bug where play/pause button shows even after anims removed 2026-02-05 18:59:08 -08:00
d7b1f0e067 [WV2] Minor tweaks to look more like the real wardrobe 2026-02-05 18:49:07 -08:00
4503c12a1f [WV2] Make show.css a bit more manageable 2026-02-05 18:43:02 -08:00
e694bc5d05 [WV2] Minor UI improvements to pose picker 2026-02-05 18:35:10 -08:00
fc93239482 [WV2] Scroll selected alt style into view 2026-02-05 18:17:34 -08:00
b7bbd1ace3 [WV2] Simplify pose vs style picking
Rather than surface the fact that pose and style are independent values, in this change we treat them as basically mutually exclusive appearance options.

If there's no alt style selected, a pose option is visibly selected instead. If there's an alt style selected, no pose option is visibly selected (even though the data model contains one), and selecting one removes the alt style.
2026-02-05 18:11:01 -08:00
3b471fcb05 [WV2] Add alt style picker 2026-02-05 18:04:49 -08:00
fd2940880f [WV2] Update migration status doc 2026-02-05 17:33:30 -08:00
b03b32c538 Merge branch 'main' into feature/wardrobe-v2
# Conflicts:
#	app/models/outfit.rb
#	spec/models/outfit_spec.rb
2026-01-20 18:53:21 -08:00
f545510edc [WV2] Custom play/pause animations button 2026-01-03 10:44:10 -08:00
cbf69e1189 [WV2] Split template into partials 2025-12-26 23:19:39 -08:00
9ea48f6e8c [WV2] Improve pose picker button styles
Make it more subtle, to create clearer hierarchy between species/color and pose
2025-12-26 23:11:22 -08:00
812e8226bb [WV2] Unify button styles 2025-12-26 23:01:40 -08:00
955aeb984e [WV2] Simplify item search layout 2025-12-26 22:43:17 -08:00
74386b45d7 [WV2] More advanced mobile layout 2025-12-26 22:32:20 -08:00
ba0612b694 [WV2] Fix controls area width on mobile 2025-12-26 22:28:24 -08:00
b36b1577b5 [WV2] Fix cursor for outfit viewer play/pause 2025-12-26 22:19:06 -08:00
fb8fb4b27e [WV2] Fix anchor positioning for pose picker popover 2025-12-26 22:15:57 -08:00
02a64ef639 Merge branch 'main' into feature/wardrobe-v2 2025-12-26 21:23:33 -08:00
d23f16c217 Merge branch 'main' into feature/wardrobe-v2 2025-12-26 20:44:57 -08:00
7459037c8a [WV2] Simplify item search pagination
I'll want to do it smarter than this, but for now, just getting rid of the page links altogether seems best
2025-11-26 16:58:38 -08:00
6eace54c34 [WV2] Pose picker popover 2025-11-11 18:07:06 -08:00
76496f8a6d [WV2] Pose picker first draft 2025-11-11 17:41:57 -08:00
78931ddb47 [WV2] Move to a new WardrobeController 2025-11-11 17:21:03 -08:00
811bb3e036 Merge branch 'main' into feature/wardrobe-v2 2025-11-11 12:57:25 -08:00
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
44 changed files with 3444 additions and 124 deletions

View file

@ -0,0 +1,27 @@
/**
* AutoSubmitForm web component
*
* Generic progressive enhancement for forms that should auto-submit on change:
* - Listens for `change` events on descendant form inputs
* - Calls `requestSubmit()` on the nearest `<form>`
* - Exposes `:state(auto-loading)` to hide fallback submit buttons via CSS
*/
class AutoSubmitForm extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
e.target.closest("form")?.requestSubmit();
}
}
customElements.define("auto-submit-form", AutoSubmitForm);

View file

@ -4,7 +4,7 @@ document.addEventListener("change", (e) => {
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form",
"#item-preview .species-color-picker form",
);
const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']",
@ -24,24 +24,6 @@ document.addEventListener("turbo:frame-missing", (e) => {
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 {
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-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

@ -0,0 +1,47 @@
/**
* OutfitRenameField web component
*
* Progressive enhancement for the outfit name field:
* - Shows a static text header with a pencil icon button
* - Pencil appears on hover/focus of the container
* - Clicking pencil switches to the editable form
* - Enter submits, Escape/blur reverts to static display
*
* State is managed via the `editing` attribute, which CSS uses to toggle
* visibility. Turbo morphs naturally reset this attribute (since it's not in
* the server HTML), so no morph-specific handling is needed.
*/
class OutfitRenameField extends HTMLElement {
connectedCallback() {
const pencil = this.querySelector(".outfit-rename-pencil");
const input = this.querySelector("input[type=text]");
if (!pencil || !input) return;
pencil.addEventListener("click", () => {
this.dataset.originalValue = input.value;
this.setAttribute("editing", "");
input.focus();
input.select();
});
this.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.hasAttribute("editing")) {
e.preventDefault();
this.#cancelEditing(input);
}
});
this.addEventListener("focusout", (e) => {
if (this.hasAttribute("editing") && !this.contains(e.relatedTarget)) {
this.#cancelEditing(input);
}
});
}
#cancelEditing(input) {
input.value = this.dataset.originalValue ?? input.value;
this.removeAttribute("editing");
}
}
customElements.define("outfit-rename-field", OutfitRenameField);

View file

@ -1,5 +1,7 @@
class OutfitViewer extends HTMLElement {
#internals;
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
constructor() {
super();
@ -7,26 +9,113 @@ class OutfitViewer extends HTMLElement {
}
connectedCallback() {
const observer = new MutationObserver((mutations) => {
// When a layer is added, update its playing state to match ours.
const addedLayers = mutations
.flatMap(m => [...m.addedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
for (const layer of addedLayers) {
if (this.#internals.states.has("playing")) {
layer.play();
} else {
layer.pause();
}
}
const removedLayers = mutations
.flatMap(m => [...m.removedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
// If any layers were added or removed, updated our hasAnimations state.
if (addedLayers.length > 0 || removedLayers.length > 0) {
this.#updateHasAnimations();
}
});
observer.observe(this, { childList: true });
// When a new layer finishes loading and determines it has animations, update.
this.addEventListener("hasanimationschange", (e) => {
// Only handle events from outfit-layer children, not from ourselves
if (e.target === this) return;
this.#updateHasAnimations();
});
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0);
}
#connectToChildren() {
const playPauseToggle = document.querySelector(".play-pause-toggle");
// Read initial playing state from cookie and initialize
const isPlayingFromCookie = this.#getIsPlayingCookie();
// Read our initial playing state from the toggle, and subscribe to changes.
this.#setIsPlaying(playPauseToggle.checked);
playPauseToggle.addEventListener("change", () => {
this.#setIsPlaying(playPauseToggle.checked);
this.#setIsPlayingCookie(playPauseToggle.checked);
});
// Initialize the boolean before calling #setIsPlaying
// (We set it to the opposite first so #setIsPlaying detects a change)
this.#isPlaying = !isPlayingFromCookie;
this.#setIsPlaying(isPlayingFromCookie);
// Check initial animation state
this.#updateHasAnimations();
}
#updateHasAnimations() {
// Check if any layer has animations
const hasAnimations =
this.querySelector("outfit-layer:state(has-animations)") !== null;
// Check if state actually changed
if (hasAnimations === this.#hasAnimations) {
return; // No change, skip
}
// Update internal state
this.#hasAnimations = hasAnimations;
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
// Dispatch event so external components can react
this.dispatchEvent(
new CustomEvent("hasanimationschange", {
detail: { hasAnimations },
bubbles: true,
}),
);
}
/**
* Public API: Start playing animations
*/
play() {
this.#setIsPlaying(true);
}
/**
* Public API: Pause animations
*/
pause() {
this.#setIsPlaying(false);
}
/**
* Public API: Check if currently playing
*/
get isPlaying() {
return this.#isPlaying;
}
#setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new
// nodes arrive, if playing.
const thirtyDays = 60 * 60 * 24 * 30;
// Skip if already in this state
if (this.#isPlaying === isPlaying) {
return;
}
// Update internal state (both boolean and CustomStateSet for CSS)
this.#isPlaying = isPlaying;
if (isPlaying) {
this.#internals.states.add("playing");
for (const layer of this.querySelectorAll("outfit-layer")) {
@ -38,6 +127,27 @@ class OutfitViewer extends HTMLElement {
layer.pause();
}
}
// Persist to cookie
this.#setIsPlayingCookie(isPlaying);
// Dispatch event so external components can react
this.dispatchEvent(
new CustomEvent("playingchange", {
detail: { isPlaying },
bubbles: true,
}),
);
}
#getIsPlayingCookie() {
const cookie = document.cookie
.split("; ")
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
if (cookie) {
return cookie.split("=")[1] !== "false";
}
return true; // Default to playing
}
#setIsPlayingCookie(isPlaying) {
@ -49,6 +159,7 @@ class OutfitViewer extends HTMLElement {
class OutfitLayer extends HTMLElement {
#internals;
#pendingMessage = null; // Queue one message to send when iframe loads
constructor() {
super();
@ -105,6 +216,12 @@ class OutfitLayer extends HTMLElement {
this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error"));
// If there was a pending play/pause message, send it now
if (this.#pendingMessage) {
this.#sendMessageToIframe(this.#pendingMessage);
this.#pendingMessage = null;
}
} else {
console.warn(`<outfit-layer> contained no image or iframe: `, this);
}
@ -126,7 +243,7 @@ class OutfitLayer extends HTMLElement {
} else {
throw new Error(
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
JSON.stringify(data.status),
);
}
} else {
@ -151,24 +268,45 @@ class OutfitLayer extends HTMLElement {
* Set whether CSS selector `:state(has-animations)` matches this element.
*/
#setHasAnimations(hasAnimations) {
// Check if state actually changed
const hadAnimations = this.#internals.states.has("has-animations");
if (hasAnimations === hadAnimations) {
return; // No change, skip
}
// Update internal state
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
// Dispatch event so parent OutfitViewer can react
this.dispatchEvent(
new CustomEvent("hasanimationschange", {
detail: { hasAnimations },
bubbles: true,
}),
);
}
#sendMessageToIframe(message) {
// If we have no frame or it hasn't loaded, ignore this message.
// If we have no frame, queue play/pause messages for later
if (this.iframe == null) {
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return;
}
if (this.iframe.contentWindow == null) {
console.debug(
`Ignoring message, frame not loaded yet: `,
`Queueing message, frame not loaded yet: `,
this.iframe,
message,
);
if (message.type === "play" || message.type === "pause") {
this.#pendingMessage = message;
}
return;
}
@ -177,8 +315,119 @@ class OutfitLayer extends HTMLElement {
}
}
/**
* OutfitViewerPlayPauseToggle is a web component that creates a play/pause
* toggle button for an outfit-viewer, referenced by the `for` attribute.
*
* Usage:
* <outfit-viewer id="my-viewer">...</outfit-viewer>
* <outfit-viewer-play-pause-toggle for="my-viewer">
* <input type="checkbox" class="toggle-input">
* <span class="playing-label">Playing</span>
* <span class="paused-label">Paused</span>
* </outfit-viewer-play-pause-toggle>
*
* The toggle will:
* - Read initial state from the outfit-viewer
* - Update the checkbox when the outfit-viewer state changes
* - Call play()/pause() on the outfit-viewer when the checkbox changes
* - Show/hide based on whether the outfit has animations
*/
class OutfitViewerPlayPauseToggle extends HTMLElement {
#outfitViewer = null;
#checkbox = null;
connectedCallback() {
setTimeout(() => this.#connect(), 0);
}
#connect() {
// Find the outfit-viewer referenced by the `for` attribute
const forId = this.getAttribute("for");
if (!forId) {
console.warn(
"<outfit-viewer-play-pause-toggle> requires a 'for' attribute",
);
return;
}
this.#outfitViewer = document.getElementById(forId);
if (!this.#outfitViewer) {
console.warn(
`<outfit-viewer-play-pause-toggle> could not find outfit-viewer with id="${forId}"`,
);
return;
}
// Find the checkbox input
this.#checkbox = this.querySelector('input[type="checkbox"]');
if (!this.#checkbox) {
console.warn(
"<outfit-viewer-play-pause-toggle> requires a checkbox input child",
);
return;
}
// Sync initial state from outfit-viewer to checkbox
this.#syncFromOutfitViewer();
// Listen for checkbox changes and update outfit-viewer
this.#checkbox.addEventListener("change", () => {
if (this.#checkbox.checked) {
this.#outfitViewer.play();
} else {
this.#outfitViewer.pause();
}
});
// Listen for outfit-viewer state changes and update checkbox
this.#outfitViewer.addEventListener("playingchange", (e) => {
this.#checkbox.checked = e.detail.isPlaying;
});
// Listen for animation availability changes and update visibility
this.#outfitViewer.addEventListener("hasanimationschange", (e) => {
this.#updateVisibility(e.detail.hasAnimations);
});
// Set initial visibility based on current state
this.#updateVisibility(
this.#outfitViewer.querySelector(
"outfit-layer:state(has-animations)",
) !== null,
);
// After a Turbo morph, Idiomorph may remove our `hidden` attribute
// (since the server HTML never includes it). Re-apply visibility
// based on the current animation state.
document.addEventListener("turbo:render", () => {
this.#syncFromOutfitViewer();
});
}
#syncFromOutfitViewer() {
if (this.#outfitViewer && this.#checkbox) {
this.#checkbox.checked = this.#outfitViewer.isPlaying;
}
}
#updateVisibility(hasAnimations) {
// Show/hide the toggle based on whether there are animations
// Use a custom state so CSS can control the display
if (hasAnimations) {
this.removeAttribute("hidden");
} else {
this.setAttribute("hidden", "");
}
}
}
customElements.define("outfit-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer);
customElements.define(
"outfit-viewer-play-pause-toggle",
OutfitViewerPlayPauseToggle,
);
// Morph turbo-frames on this page, to reuse asset nodes when we want to—very
// important for movies!—but ensure that it *doesn't* do its usual behavior of
@ -196,7 +445,7 @@ function morphWithOutfitLayers(currentElement, newElement) {
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
@ -205,10 +454,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
// and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") {
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,32 @@
/**
* PosePickerPopover web component
*
* Scrolls the selected style into view when the style picker list becomes
* visible (e.g. tab switch or popover open).
*/
class PosePickerPopover extends HTMLElement {
#styleListObserver;
connectedCallback() {
// When the style picker list becomes visible (e.g. tab switch or
// popover open), scroll the selected style into view.
const styleList = this.querySelector(".style-picker-list");
if (styleList) {
this.#styleListObserver = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
const checked = styleList.querySelector("input:checked");
checked
?.closest("label")
?.scrollIntoView({ block: "nearest" });
}
});
this.#styleListObserver.observe(styleList);
}
}
disconnectedCallback() {
this.#styleListObserver?.disconnect();
}
}
customElements.define("pose-picker-popover", PosePickerPopover);

View file

@ -0,0 +1,37 @@
/**
* TabPanel web component
*
* A simple tab switcher. Reads the `active` attribute to determine which tab
* is visible. Without JS, both panels are visible (tab buttons hidden via CSS).
*/
class TabPanel extends HTMLElement {
connectedCallback() {
this.querySelectorAll(".tab-button").forEach((button) => {
button.addEventListener("click", () => {
this.setAttribute("active", button.dataset.tab);
});
});
}
static get observedAttributes() {
return ["active"];
}
attributeChangedCallback(name) {
if (name === "active") this.#updateVisibility();
}
#updateVisibility() {
const active = this.getAttribute("active");
this.querySelectorAll(".tab-button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === active);
});
this.querySelectorAll(".tab-content").forEach((content) => {
content.hidden = content.dataset.tab !== active;
});
}
}
customElements.define("tab-panel", TabPanel);

View file

@ -0,0 +1,70 @@
/**
* ItemCard web component
*
* Progressive enhancement for item cards in both outfit and search views.
* Replaces baseline Show/Hide/Add buttons with click-to-toggle behavior:
*
* Outfit view (radio inputs):
* - Clicking a closeted item's label selects its radio and submits the Show form
* - Clicking a worn item's label submits the Hide form (un-wears it)
* - Arrow keys navigate between items via native radio behavior
* - Space on a checked radio submits the Hide form (radios don't toggle natively)
*
* Search view (checkbox inputs):
* - Clicking an unworn item's label checks the checkbox and submits the Add form
* - Clicking a worn item's label prevents default and submits the Hide form
*/
class ItemCard extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
this.addEventListener("keydown", this.#handleKeydown);
this.addEventListener("change", this.#handleChange);
}
#handleClick = (e) => {
const label = e.target.closest(".item-card-label");
if (!label) return;
const input = label.querySelector("input[type=radio], input[type=checkbox]");
if (!input) return;
// If this item is worn, un-wear it by submitting the Hide form
if (this.dataset.isWorn != null) {
e.preventDefault();
const hideButton = this.querySelector(".item-hide-button");
if (hideButton) hideButton.closest("form").requestSubmit();
}
// Otherwise, let the default label click proceed—it checks the input,
// which fires the `change` event handled below
};
#handleKeydown = (e) => {
// Spacebar on an already-checked radio: un-wear the item
if (e.key !== " ") return;
const input = e.target;
if (input.type !== "radio" || !input.checked) return;
if (this.dataset.isWorn == null) return;
e.preventDefault();
const hideButton = this.querySelector(".item-hide-button");
if (hideButton) hideButton.closest("form").requestSubmit();
};
#handleChange = (e) => {
const input = e.target;
if (!input.checked) return;
if (input.type !== "radio" && input.type !== "checkbox") return;
// Submit the Show form to wear this item, or the Add form if not closeted
const showButton = this.querySelector(".item-show-button");
if (showButton) {
showButton.closest("form").requestSubmit();
} else {
const addButton = this.querySelector(".item-add-button");
if (addButton) addButton.closest("form").requestSubmit();
}
};
}
customElements.define("item-card", ItemCard);

View file

@ -0,0 +1,61 @@
/**
* Keyboard shortcuts for item search.
*
* - Up/Down arrows move focus between the search field and the item list,
* so keyboard users can quickly browse results without tabbing.
* - Escape exits search mode (clicks the back button).
*/
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
const backButton = document.querySelector(
".item-search-form .back-button",
);
if (!backButton) return;
// Only act when focus is on the search input or a search result.
const section = document.querySelector(".outfit-controls-section");
const searchInput = section?.querySelector(
'.search-form input[type="text"]',
);
const isSearchFocused =
document.activeElement === searchInput ||
document.activeElement?.closest(".search-results-list") != null;
if (!isSearchFocused) return;
e.preventDefault();
backButton.click();
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const section = document.querySelector(".outfit-controls-section");
if (!section) return;
const searchInput = section.querySelector('.search-form input[type="text"]');
if (!searchInput) return;
// Collect all focusable item inputs in the results list.
const itemInputs = [
...section.querySelectorAll(
'.search-results-list item-card input[type="checkbox"]',
),
];
if (itemInputs.length === 0) return;
const allTargets = [searchInput, ...itemInputs];
const currentIndex = allTargets.indexOf(document.activeElement);
if (currentIndex === -1) return;
let nextIndex;
if (e.key === "ArrowDown") {
nextIndex = Math.min(currentIndex + 1, allTargets.length - 1);
} else {
nextIndex = Math.max(currentIndex - 1, 0);
}
if (nextIndex !== currentIndex) {
e.preventDefault();
allTargets[nextIndex].focus();
}
});

View file

@ -0,0 +1,36 @@
// Wardrobe v2 - Simple Rails+Turbo outfit editor
//
// This page uses Turbo for instant updates when changing species/color.
// The outfit_viewer Web Component handles the pet rendering.
// Unsaved changes warning: use a MutationObserver to watch the
// data-has-unsaved-changes attribute on the wardrobe container. This is more
// robust than event listeners because it works regardless of how the DOM is
// updated (Turbo morph, direct manipulation, etc.).
function setupUnsavedChangesObserver() {
const container = document.querySelector("[data-has-unsaved-changes]");
if (!container) return;
function update() {
if (container.dataset.hasUnsavedChanges === "true") {
window.onbeforeunload = (e) => {
e.preventDefault();
return "";
};
} else {
window.onbeforeunload = null;
}
}
// Set initial state
update();
// Watch for attribute changes
const observer = new MutationObserver(update);
observer.observe(container, {
attributes: true,
attributeFilter: ["data-has-unsaved-changes"],
});
}
setupUnsavedChangesObserver();

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
width: 100%
height: 100%
border: 0
.loading-indicator
position: absolute
@ -72,6 +73,7 @@ outfit-viewer
border-radius: 100%
border: 2px solid transparent
transition: all .25s
cursor: pointer
.playing-label, .paused-label
display: none
@ -102,7 +104,12 @@ outfit-viewer
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
// Hide the play-pause toggle when there are no animations
outfit-viewer-play-pause-toggle[hidden]
display: none
// Show the play-pause button when visible
outfit-viewer-play-pause-toggle:not([hidden])
.play-pause-button
display: flex

View file

@ -45,6 +45,8 @@
.preview-area
margin: 0 auto
position: relative
width: 300px
height: 300px
.customize-more
position: absolute
@ -107,7 +109,7 @@ outfit-viewer
.error-indicator
display: block
species-color-picker
.species-color-picker
.error-icon
cursor: help
margin-right: .25em
@ -128,7 +130,7 @@ species-color-picker
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
auto-submit-form:state(auto-loading)
input[type=submit]
display: none
@ -287,11 +289,14 @@ species-face-picker
.preview-area
grid-area: viewer
width: 380px
height: 380px
outfit-viewer
width: 380px
height: 380px
species-color-picker
.species-color-picker
grid-area: picker
species-face-picker
@ -302,6 +307,52 @@ species-face-picker
.item-preview-meta-info
grid-area: meta
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: flex
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
cursor: pointer
.playing-label, .paused-label
display: none
width: 1em
height: 1em
input[type=checkbox]
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
@keyframes fade-in
from
opacity: 0

View file

@ -1,5 +1,6 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../application/item-badges"
=item-header
border-bottom: 1px solid $module-border-color
@ -27,7 +28,7 @@
text-align: left
line-height: 100%
margin-bottom: 0
.item-links
grid-area: links
@ -41,32 +42,6 @@
abbr
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
grid-area: support
font-size: 85%
@ -100,21 +75,21 @@
font-size: 150%
font-weight: bold
margin-bottom: .75em
.closet-hangers-ownership-groups
+clearfix
margin-bottom: .5em
div
float: left
margin: 0 5%
text-align: left
width: 40%
li
list-style: none
word-wrap: break-word
label.unlisted
font-style: italic

File diff suppressed because it is too large Load diff

View file

@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
@outfit.user = current_user
if @outfit.save
render :json => @outfit
respond_to do |format|
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
format.json { render json: @outfit }
end
else
render_outfit_errors
respond_to do |format|
format.html do
redirect_back fallback_location: wardrobe_v2_path,
alert: @outfit.errors.full_messages.join(", ")
end
format.json { render_outfit_errors }
end
end
end
@ -87,7 +96,7 @@ class OutfitsController < ApplicationController
end
@species_count = Species.count
@latest_contribution = Contribution.recent.first
Contribution.preload_contributeds_and_parents([@latest_contribution].compact)
@ -96,6 +105,7 @@ class OutfitsController < ApplicationController
@campaign = Fundraising::Campaign.current rescue nil
end
def show
@outfit = Outfit.find(params[:id])
@ -122,9 +132,25 @@ class OutfitsController < ApplicationController
def update
if @outfit.update(outfit_params)
render :json => @outfit
respond_to do |format|
format.html do
return_to = params[:return_to]
if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//")
redirect_to return_to
else
redirect_to wardrobe_v2_outfit_path(@outfit)
end
end
format.json { render json: @outfit }
end
else
render_outfit_errors
respond_to do |format|
format.html do
redirect_back fallback_location: wardrobe_v2_outfit_path(@outfit),
alert: @outfit.errors.full_messages.join(", ")
end
format.json { render_outfit_errors }
end
end
end
@ -180,5 +206,6 @@ class OutfitsController < ApplicationController
:full_error_messages => @outfit.errors.full_messages},
:status => :bad_request
end
end

View file

@ -0,0 +1,189 @@
class WardrobeController < ApplicationController
prepend_view_path Rails.root.join("app/views/wardrobe")
def show
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
# If visiting a saved outfit with no state params, redirect with the
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
# the rest of the action always reads from params.
if @saved_outfit && !outfit_state_params_present?
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
return
end
# Set the form target path for all wardrobe forms
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
# 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
# Get the selected pose from params, or default to nil (will use canonical)
@selected_pose = params[:pose]
# Find the pet state for the selected pose, or use canonical
@pet_state = if @pet_type && @selected_pose.present?
@pet_type.pet_states.with_pose(@selected_pose).first || @pet_type.canonical_pet_state
else
@pet_type&.canonical_pet_state
end
# If we found a pet_state, use its actual pose as the selected pose
@selected_pose = @pet_state&.pose
# Load all available poses for this pet type (for the pose picker)
@available_poses = @pet_type ? available_poses_for(@pet_type) : {}
# Preload the layers for all available poses so the thumbnails render efficiently
if @pet_type
pose_pet_states = @available_poses.values.compact
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
end
# Load alt style from params, scoped to the current species
@alt_style = if params[:style].present? && @selected_species
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
end
# Load all available alt styles for this species (for the style picker)
@available_alt_styles = @selected_species ?
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
# Load items from the objects[] and closet[] parameters
worn_item_ids = params[:objects] || []
closeted_item_ids = params[:closet] || []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Build the outfit
@outfit = Outfit.new(
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
pet_state: @pet_state,
alt_style: @alt_style,
worn_items: worn_items,
closeted_items: closeted_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)
# Also preload alt style layer manifests for the style picker thumbnails
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
# Compute saved outfit state for the view
if @saved_outfit
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
else
@has_unsaved_changes = false
end
# 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
private
# Returns a hash of pose => pet_state for all the main poses,
# indicating which poses are available for this pet type.
# Uses the same logic as the Rainbow Pool to pick the "canonical" pet state
# for each pose when multiple states exist.
def available_poses_for(pet_type)
poses_hash = {}
# Group all pet states by pose, then pick the best one for each pose
# using emotion_order (same logic as Rainbow Pool)
pet_type.pet_states.emotion_order.group_by(&:pose).each do |pose, states|
# Only include the main poses (skip UNKNOWN, UNCONVERTED, etc.)
if PetState::MAIN_POSES.include?(pose)
poses_hash[pose] = states.first
end
end
# Ensure all main poses are in the hash, even if nil
PetState::MAIN_POSES.each do |pose|
poses_hash[pose] ||= nil
end
poses_hash
end
def outfit_state_params_present?
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
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

View file

@ -0,0 +1,177 @@
module WardrobeHelper
# 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(:name, @outfit.name) if !@saved_outfit && @outfit.name.present? && !except.include?(:name)
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
unless except.include?(:worn_items)
outfit.worn_items.each do |item|
fields << hidden_field_tag('objects[]', item.id)
end
end
unless except.include?(:closeted_items)
outfit.closeted_items.each do |item|
fields << hidden_field_tag('closet[]', 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
# Get the emoji and label for the pose picker button.
# Shows the alt style name when one is active, otherwise the pose name.
def pose_emoji_and_label(pose, alt_style: nil)
if alt_style
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
else
case pose
when "HAPPY_MASC", "HAPPY_FEM"
{ emoji: "😀", label: "Happy" }
when "SAD_MASC", "SAD_FEM"
{ emoji: "😢", label: "Sad" }
when "SICK_MASC", "SICK_FEM"
{ emoji: "🤢", label: "Sick" }
else
{ emoji: "😀", label: "Default" }
end
end
end
# Group outfit items by zone, applying smart multi-zone simplification.
# Returns an array of hashes: {zone_id:, zone_label:, items: [Item, ...]}
# This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil?
all_items = outfit.worn_items + outfit.closeted_items
# Get item appearances for all items at once
item_appearances = Item.appearances_for(
all_items,
outfit.pet_type,
swf_asset_includes: [:zone]
)
# Separate compatible and incompatible items
compatible = {}
incompatible_items = []
all_items.each do |item|
appearance = item_appearances[item.id]
if appearance&.present?
compatible[item] = 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.each do |item, appearance|
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
# 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_id = group[:items].first.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
end

View file

@ -572,6 +572,22 @@ class Item < ApplicationRecord
return [] if empty?
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
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
Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON

View file

@ -170,6 +170,8 @@ class Outfit < ApplicationRecord
end
def visible_layers
return [] if pet_state.nil?
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
if alt_style
biology_layers = alt_style.swf_assets.includes(:zone).to_a
@ -259,17 +261,24 @@ class Outfit < ApplicationRecord
(biology_layers + item_layers).sort_by(&:depth)
end
def same_wardrobe_state_as?(other)
# Exclude :name because it's managed separately via atomic rename, not URL
# state. This also works around the @outfit (new) vs @saved_outfit
# (persisted) split in WardrobeController, where only the unpersisted
# outfit includes :name. We should consider keeping their names in sync.
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
end
def wardrobe_params
params = {
name: name,
color: color_id,
species: species_id,
pose: pose,
state: pet_state_id,
objects: worn_item_ids,
closet: closeted_item_ids,
objects: worn_item_ids.sort,
closet: closeted_item_ids.sort,
}
params[:style] = alt_style_id if alt_style_id.present?
params[:name] = name if !persisted? && name.present?
params
end
@ -298,4 +307,72 @@ class Outfit < ApplicationRecord
i += 1
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 without the given item at all
# (removed from both worn and closeted).
def without_item(item)
dup.tap do |o|
o.worn_items.delete(item)
o.closeted_items.delete(item)
end
end
# Create a copy of this outfit with the given item moved from worn to
# closeted. If it's not currently worn, returns the outfit unchanged.
def hide_item(item)
dup.tap do |o|
next unless o.worn_item_ids.include?(item.id)
o.worn_items.delete(item)
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
end
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?
# If the item was closeted, remove it from closet (it's moving to worn)
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
# 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

View file

@ -48,6 +48,26 @@ class PetType < ApplicationRecord
random_pet_types
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={})
super({
only: [:id],

View file

@ -34,4 +34,13 @@ class Species < ApplicationRecord
def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
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

View file

@ -3,16 +3,6 @@
= content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner"
%label.play-pause-button{title: "Pause/play animations"}
%input.play-pause-toggle{
type: "checkbox",
checked: outfit_viewer_is_playing,
}
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
- outfit.visible_layers.each do |swf_asset|
%outfit-layer{
id: "#{viewer_id}-layer-#{swf_asset.id}",

View file

@ -9,26 +9,8 @@
= image_tag item.thumbnail_url, class: 'item-thumbnail'
%h2.item-name= item.name
%nav.item-links
- if item.currently_in_mall?
= link_to "https://ncmall.neopets.com/", class: "item-kind", data: {type: "nc"},
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
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: 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)

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

@ -17,6 +17,13 @@
= turbo_frame_tag "item-preview" do
.preview-area
= outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer"
%outfit-viewer-play-pause-toggle{for: "item-preview-outfit-viewer"}
%label.play-pause-button{title: "Pause/play animations"}
%input{type: "checkbox"}
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
.error-indicator
💥 We couldn't load all of this outfit. Try again?
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
@ -26,20 +33,21 @@
Customize more
= edit_icon
%species-color-picker
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
.species-color-picker
%auto-submit-form
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
= select_tag "preview[color_id]",
options_from_collection_for_select(Color.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil
= select_tag "preview[color_id]",
options_from_collection_for_select(Color.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil
%species-face-picker
%noscript
@ -131,4 +139,5 @@
- content_for :javascripts do
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "auto-submit-form", async: true
= javascript_include_tag "items/show", async: true

View file

@ -0,0 +1,22 @@
-# Renders a single pose option in the pose picker grid
-# @param pose [String] The pose name (e.g., "HAPPY_MASC")
-# @param pet_state [PetState, nil] The pet state for this pose, or nil if unavailable
-# @param selected [Boolean] Whether this pose is currently selected
- is_available = pet_state.present?
- pose_label = pose.split('_').map(&:capitalize).join(' ')
%label.pose-option{class: [is_available ? 'available' : 'unavailable', selected ? 'selected' : nil]}
= radio_button_tag :pose, pose, selected,
disabled: !is_available,
"aria-label": pose_label + (is_available ? "" : " (not available)")
.pose-thumbnail
- if is_available
-# Create a minimal outfit with just this pet state for the thumbnail
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer",
id: "pose-thumbnail-viewer-#{pet_state.id}"
- else
.pose-unavailable
%span.question-mark{title: "Not available"} ❓

View file

@ -0,0 +1,22 @@
- pose_info = pose_emoji_and_label(@selected_pose, alt_style: @alt_style)
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
%span.pose-emoji= pose_info[:emoji]
%span.pose-label= pose_info[:label]
%span.chevron ▾
%pose-picker-popover#pose-picker-popover{popover: "auto"}
- active_tab = @alt_style ? "styles" : "expressions"
%tab-panel{active: active_tab}
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
%auto-submit-form
= render "appearance/pose_picker_form"
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
%auto-submit-form
= render "appearance/style_picker_form"
.tab-list
%button.tab-button{"data-tab": "expressions", type: "button",
class: ("active" if active_tab == "expressions")}
Expressions
%button.tab-button{"data-tab": "styles", type: "button",
class: ("active" if active_tab == "styles")}
Styles

View file

@ -0,0 +1,32 @@
= form_with url: @wardrobe_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose, :style]
%table.pose-picker-table
%thead
%tr
%th
%th
%span.emoji-icon{title: "Happy"} 😀
%th
%span.emoji-icon{title: "Sad"} 😢
%th
%span.emoji-icon{title: "Sick"} 🤢
%tbody
%tr
%th
%span.emoji-icon{title: "Masculine"} 💁‍♂️
%td
= render "appearance/pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: !@alt_style && @selected_pose == "HAPPY_MASC"
%td
= render "appearance/pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: !@alt_style && @selected_pose == "SAD_MASC"
%td
= render "appearance/pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: !@alt_style && @selected_pose == "SICK_MASC"
%tr
%th
%span.emoji-icon{title: "Feminine"} 💁‍♀️
%td
= render "appearance/pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: !@alt_style && @selected_pose == "HAPPY_FEM"
%td
= render "appearance/pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: !@alt_style && @selected_pose == "SAD_FEM"
%td
= render "appearance/pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: !@alt_style && @selected_pose == "SICK_FEM"
= submit_tag "Change pose", name: nil, class: "pose-submit-button progressive-submit"

View file

@ -0,0 +1,13 @@
.species-color-picker
%auto-submit-form
= form_with url: @wardrobe_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, class: "progressive-submit"

View file

@ -0,0 +1,11 @@
= form_with url: @wardrobe_path, method: :get, class: "style-picker-form" do |f|
= outfit_state_params except: [:style]
.style-picker-list
- @available_alt_styles.each do |alt_style|
%label.style-option
= radio_button_tag :style, alt_style.id, @alt_style&.id == alt_style.id
.style-option-content
.style-option-thumbnail
%img{src: alt_style.thumbnail_url, alt: "", width: 40, height: 40, loading: "lazy"}
%span.style-option-name= alt_style.adjective_name
= submit_tag "Change style", name: nil, class: "style-submit-button progressive-submit"

View file

@ -0,0 +1,22 @@
- if @saved_outfit
- form_url = outfit_path(@saved_outfit)
- form_method = :patch
- field_name = "outfit[name]"
- else
- form_url = @wardrobe_path
- form_method = :get
- field_name = :name
%outfit-rename-field
.outfit-rename-static-display
%span.outfit-rename-name= @outfit.name.presence || "Untitled outfit"
%button.outfit-rename-pencil{type: "button", "aria-label": "Rename outfit"} ✏️
= form_with url: form_url, method: form_method, class: "outfit-name-form" do |f|
= hidden_field_tag :return_to, request.fullpath
- unless @saved_outfit
= outfit_state_params except: [:name]
= f.text_field field_name, value: @outfit.name,
class: "outfit-name-input", placeholder: "Untitled outfit",
"aria-label": "Outfit name"
= f.submit "Rename", name: nil, class: "outfit-name-submit"

View file

@ -0,0 +1,20 @@
- if @saved_outfit
- if @is_owner
- if @has_unsaved_changes
= form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f|
= render "header/save_outfit_fields"
= f.submit "Save", class: "outfit-save-button"
- else
%button.outfit-save-button{disabled: true} Saved!
- elsif user_signed_in?
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
= render "header/save_outfit_fields"
= f.submit "Save a copy", class: "outfit-save-button"
- else
= link_to "Log in to save a copy",
new_auth_user_session_path(return_to: request.fullpath),
class: "outfit-save-button"
- elsif user_signed_in?
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
= render "header/save_outfit_fields"
= f.submit "Save", class: "outfit-save-button"

View file

@ -0,0 +1,10 @@
= hidden_field_tag "outfit[name]", @outfit.name
= hidden_field_tag "outfit[biology][species_id]", @outfit.species_id
= hidden_field_tag "outfit[biology][color_id]", @outfit.color_id
= hidden_field_tag "outfit[biology][pose]", @outfit.pet_state.pose
- if @alt_style
= hidden_field_tag "outfit[alt_style_id]", @alt_style.id
- @outfit.worn_items.each do |item|
= hidden_field_tag "outfit[item_ids][worn][]", item.id
- @outfit.closeted_items.each do |item|
= hidden_field_tag "outfit[item_ids][closeted][]", item.id

View file

@ -0,0 +1,29 @@
- is_worn = @outfit.worn_items.include?(item)
- is_closeted = @outfit.closeted_items.include?(item)
%item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}}
- if defined?(zone_id) && zone_id
%label.item-card-label
%input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name}
= render "wardrobe/items/item_card_content", item: item
- else
%label.item-card-label
%input.visually-hidden{type: "checkbox", checked: is_worn || nil, "aria-label": item.name}
= render "wardrobe/items/item_card_content", item: item
- if is_worn
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
👁️‍🗨️
= outfit_state_params @outfit.hide_item(item)
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
= outfit_state_params @outfit.without_item(item)
- elsif is_closeted
= button_to @wardrobe_path, method: :get, class: "item-show-button", title: "Show #{item.name}", "aria-label": "Show #{item.name}" do
👁️
= outfit_state_params @outfit.with_item(item)
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
= outfit_state_params @outfit.without_item(item)
- else
= button_to @wardrobe_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
= outfit_state_params @outfit.with_item(item)

View file

@ -0,0 +1,7 @@
.item-thumbnail
= image_tag item.thumbnail_url, alt: "", loading: "lazy"
.item-info
.item-name= item.name
.item-badges
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item

View file

@ -0,0 +1,13 @@
.search-results
- if @search_results.any?
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
%ul.search-results-list
- @search_results.each do |item|
= render "items/item_card", item: item
= will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
- 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,81 @@
- title @outfit.name
!!! 5
%html
%head
%meta{charset: 'utf-8'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
%title #{yield :title} | #{t "app_name"}
%link{href: image_path('favicon.png'), rel: 'icon'}
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
= page_stylesheet_link_tag "wardrobe/show"
= javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "auto-submit-form", async: true
= javascript_include_tag "pose-picker", async: true
= javascript_include_tag "tab-panel", async: true
= javascript_include_tag "outfit-rename-field", async: true
= javascript_include_tag "wardrobe/item-card", async: true
= javascript_include_tag "wardrobe/item-search-keys", async: true
= javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2
- if flash[:alert]
.flash-messages
.flash-alert= flash[:alert]
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
.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, id: "wardrobe-outfit-viewer",
preferred_image_format: :svg # TODO: Make this a selectable option
.preview-controls
.preview-controls-top
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
%label.play-pause-control-button.button
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
%span.paused-label Paused
%span.playing-label Playing
.preview-controls-bottom
= render "appearance/species_color_picker"
- if @pet_type
= render "appearance/pose_picker"
.outfit-controls-section
.item-search-form
- if @search_mode
= button_to @wardrobe_path, method: :get, class: "back-button" do
= outfit_state_params except: [:q]
= form_with url: @wardrobe_path, method: :get, class: "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 "items/search_results"
- else
.outfit-header
- if @saved_outfit && !@is_owner
.outfit-name-static= @outfit.name
- else
= render "header/outfit_rename_field"
= render "header/save_button"
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
.outfit-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|
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]

View file

@ -11,6 +11,8 @@ OpenneoImpressItems::Application.routes.draw do
# Should we refactor the controller/view structure here?
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
get '/wardrobe' => redirect('/outfits/new')
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
get '/start/:color_name/:species_name' => 'outfits#start'
# The outfits users have created!

View file

@ -0,0 +1,126 @@
# 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 active development** on the `feature/wardrobe-v2` branch. It's accessible at `/wardrobe/v2` but is not yet linked from the main UI.
### What Works
- **Species/color/pose/style selection**: Full picker UI with visual pose thumbnails, availability indicators, canonical fallback, and alt style picker with tabbed UI
- **Item search**: Text search with auto-filtering by pet compatibility, pagination, add/remove items
- **Item display**: Grouped by zone with multi-zone simplification, incompatible items section, NC/NP badges
- **Outfit rendering**: Uses the shared `<outfit-viewer>` web component
- **Progressive enhancement**: Everything works without JS; web components add auto-submit and smoother interactions
- **Smooth navigation**: Idiomorph DOM morphing reuses `<outfit-viewer>` layers across full-page navigations
- **Outfit saving/loading**: Load saved outfits at `/outfits/:id/v2`, save changes (owner) or save copies (non-owner), editable outfit name, unsaved changes warning
### Key implementation files
Code lives in `app/controllers/wardrobe_controller.rb`, `app/views/wardrobe/`, `app/helpers/wardrobe_helper.rb`, and `app/assets/{stylesheets,javascripts}/wardrobe/`.
## Technical Approach
**Simplicity over polish**: Unlike many webapps, we value the simplicity of the codebase very highly, even at the expense of an ideal user experience. Start with simple solutions, even if the UX is clunky; then we'll iterate up as needed.
**URL as single source of truth**: All outfit state lives in URL params (`species`, `color`, `pose`, `style`, `objects[]`, `q[...]`). Every interaction is a GET request that generates a new URL. No client-side state management. Browser back/forward work naturally.
**Server-side rendering + Web Components**: All HTML is generated server-side. Lightweight web components (`<auto-submit-form>`, `<pose-picker-popover>`, `<tab-panel>`, `<outfit-viewer>`) add interactivity without framework overhead.
**Progressive enhancement**: Submit buttons appear when JS is slow/disabled. Web components enhance forms with auto-submit on change.
## Roadmap
### Phase 1: Core Functionality (MVP)
The goal is a basic usable wardrobe. Species/color/pose selection, item search, and add/remove are already done.
**Outfit Saving/Loading** (basic implementation done)
- Save button near editable outfit name, disabled/"Saved!" when state matches saved
- Route to load saved outfits (`GET /outfits/:id/v2`) with redirect-based state initialization
- "Save a copy" for non-owners, login prompt for unauthenticated users
- `beforeunload` warning for unsaved changes via MutationObserver
- Outfit name: For saved outfits, rename is a standalone PATCH operation (not a URL param). For unsaved outfits, name is tracked as a URL param. Progressive enhancement shows static text + pencil icon for renaming.
**Alt Styles Support** (done)
- `Outfit#visible_layers` handles alt styles
- Picker UI with tabbed Expressions/Styles panels, `style` URL param, visual thumbnails, "Default" option
- Stale style params dropped gracefully when switching species
- Search results auto-filtered by alt style compatibility
**Closeted Items** (done)
- Instead of just wearing/unwearing items, also support a "closeted" state: the user is *considering* this item,
but it is not displayed on the pet itself right now.
- Wearing an item will stop wearing, but keep in closet, items that are mutually incompatible with it.
- Baseline behavior: separate toggle buttons for worn state and closeted state.
- Unworn items have "Add" (wear).
- Worn items have "Hide" (stop wearing, keep in closet) and "Remove" (remove from worn and closet).
- Closeted items have "Show" (wear) and "Remove" (remove from closet).
- Visual distinction: worn items have green emphasis, closeted items have dashed border and reduced opacity.
- Closeted items appear in zone groups alongside worn items.
- `closet[]` URL params, saving/loading, and search pagination all work.
- Progressive enhancement (done):
- Each item card is an `<item-card>` custom element with a visually-hidden input inside a `<label>`.
- In the outfit view, items use radio inputs (mutual exclusivity within zone via `name` attribute). Arrow keys
navigate between items via native radio behavior.
- In the search view, items use checkbox inputs (independent toggle).
- In both views, the `<item-card>` web component delegates clicks to the baseline forms: clicking a closeted or
absent item submits its Show/Add form (wears it), clicking a worn item submits its Hide form (un-wears it).
- When the item is closeted or worn, there is a "Remove" button.
- The radio button and checkbox are visually hidden, and are reflected in the item card worn/closeted styles instead.
### Phase 2: Polish & Parity
Match the quality and usability of Wardrobe 2020 where it matters.
- **Item UX**: Wear/unwear toggle, item info links, zone badges, removal animations, incompatibility tooltips
- **Preview controls**: Download as PNG, copy link, settings (hi-res mode, archive toggle), HTML5 conversion badge
- **Loading & errors**: Spinners, skeleton screens, smooth transitions, error recovery
- **Mobile**: Touch-friendly targets, fix hover-dependent interactions, always-visible controls on touch
- **Accessibility**: ARIA labels, keyboard navigation, semantic headings, skip links, color contrast
### Phase 3: Advanced Features
Feature parity with Wardrobe 2020 where valuable.
- **User features**: Auth integration, closet/ownership/wishlist badges, filter search by owned/wanted
- **Conflict management**: Auto zone conflict resolution, smart item restoration on unwear
- **Pet loading**: "Load my pet" by name, modeling integration
- **Search enhancements**: Inline syntax (`is:nc`, `fits:blue-acara`), advanced filter UI, autocomplete
- **Outfit auto-saving**: Save outfit changes automatically over time, rather than requiring clicking Save
### Phase 4: Migration & Rollout
- Gradual rollout (staff → beta → default)
- Performance measurement and optimization (Turbo Frames for partial updates?)
- Visual polish and design refinement
- Remove wardrobe-2020 code and update Impress 2020 dependencies doc
### Deferred / Maybe Never
- Support mode features (can keep using old wardrobe)
- Known glitches system (complex, low value)
- Appearance version pinning (niche)
- UC pet support (being phased out)
## Open Questions
- Is the URL-as-state approach sustainable as complexity grows (saving, closet, conflicts)?
- Which Wardrobe 2020 features are actually essential vs. nice-to-have? (Need user feedback)
- How to handle the transition period (maintain both? redirect? feature flag?)
## References
- [Impress 2020 Dependencies](./impress-2020-dependencies.md) - What still depends on the Impress 2020 service
- [Customization Architecture](./customization-architecture.md) - Data model deep dive
- Wardrobe 2020 source: `app/javascript/wardrobe-2020/` (the authoritative reference for feature parity comparisons)

View file

@ -0,0 +1,310 @@
require_relative '../rails_helper'
RSpec.describe WardrobeHelper, 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

View file

@ -3,6 +3,289 @@ require_relative '../rails_helper'
RSpec.describe Outfit do
fixtures :zones, :colors, :species
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(:hat1))
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(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
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(:hat1), 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(:hat1), 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(:hat1))
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(:hat1))
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(:hat1))
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(:hat1))
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(:hat1))
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(:hat1))
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(:hat1), 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(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
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(:hat1), 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(:hat1))
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(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
hat3 = create_item("Hat 3", zones(:hat1))
# 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(:hat1))
hat2 = create_item("Hat 2", zones(:hat1))
# 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(:hat1))
# Should not crash, but also won't add the item
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
end
end
end
# Helper to create a pet state with specific swf_assets
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
pet_state = PetState.create!(
@ -60,6 +343,72 @@ RSpec.describe Outfit do
item
end
describe "#same_wardrobe_state_as?" do
it "returns true for outfits with identical state" do
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns true even when names differ (name is not part of wardrobe state)" do
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when poses differ" do
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when worn items differ" do
hat = create_item("Hat", zones(:hat1))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns true regardless of worn item order" do
hat = create_item("Hat", zones(:hat1))
shirt = create_item("Shirt", zones(:shirtdress))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when species differ" do
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when alt styles differ" do
alt_style = AltStyle.create!(
species: acara,
color: blue,
body_id: 999,
series_name: "Nostalgic",
thumbnail_url: "https://images.neopets.example/alt.png"
)
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
end
describe "#visible_layers" do
before do
# Clean up any existing pet types to avoid conflicts

View file

@ -38,4 +38,23 @@ RSpec.describe PetType do
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
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

View file

@ -1,7 +1,15 @@
require_relative '../rails_helper'
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
it("uses name when possible") do