[WV2] Add alt style picker

This commit is contained in:
Emi Matchu 2026-02-05 18:04:49 -08:00
parent fd2940880f
commit 3b471fcb05
10 changed files with 312 additions and 1020 deletions

View file

@ -23,7 +23,7 @@ class PosePickerPopover extends HTMLElement {
#handleChange(e) {
// Only auto-submit if a radio button was changed
if (e.target.type === "radio") {
this.querySelector("form").requestSubmit();
e.target.closest("form").requestSubmit();
}
}
}

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

@ -456,9 +456,140 @@ body.wardrobe-v2 {
}
/* Once auto-submit is enabled, hide the submit button completely */
&:state(auto-loading) .pose-submit-button {
&:state(auto-loading) .pose-submit-button,
&:state(auto-loading) .style-submit-button {
display: none;
}
/* Tab panel layout */
.tab-list {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
}
.tab-button {
flex: 1;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.3);
}
&.active {
background: rgba(255, 255, 255, 0.2);
color: white;
border-color: rgba(255, 255, 255, 0.4);
}
}
/* Without JS, hide tab buttons and show both panels stacked */
tab-panel:not(:defined) .tab-list {
display: none;
}
tab-panel:not(:defined) .tab-content[hidden] {
display: block !important;
}
/* Style picker form */
.style-picker-form {
display: flex;
flex-direction: column;
}
.style-picker-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 200px;
overflow-y: auto;
}
.style-option {
display: block;
cursor: pointer;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.style-option-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.2s;
}
.style-option-thumbnail {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.style-option-name {
color: white;
font-size: 0.9rem;
}
/* Hover */
&:hover .style-option-content {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
}
/* Selected state */
input[type="radio"]:checked + .style-option-content {
border-color: #48BB78;
box-shadow: 0 0 0 2px rgba(72, 187, 120, 0.3);
background: rgba(72, 187, 120, 0.1);
}
/* Focus state */
input[type="radio"]:focus + .style-option-content {
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
}
}
/* Style submit button: same progressive enhancement as pose */
.style-submit-button {
margin-top: 1rem;
width: 100%;
}
@media (scripting: enabled) {
.style-submit-button {
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
}
/* Species/color picker */

View file

@ -41,6 +41,15 @@ class WardrobeController < ApplicationController
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[] parameter
item_ids = params[:objects] || []
items = Item.where(id: item_ids)
@ -48,6 +57,7 @@ class WardrobeController < ApplicationController
# Build the outfit
@outfit = Outfit.new(
pet_state: @pet_state,
alt_style: @alt_style,
worn_items: items,
)
@ -55,6 +65,9 @@ class WardrobeController < ApplicationController
# 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
# Handle search mode
@search_mode = params[:q].present?
if @search_mode

View file

@ -8,6 +8,7 @@ module WardrobeHelper
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|
@ -24,17 +25,22 @@ module WardrobeHelper
safe_join fields
end
# Get the emoji and label for a pose, for display in the pose picker button
def pose_emoji_and_label(pose)
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" }
# 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
{ emoji: "😀", label: "Default" }
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

View file

@ -1,39 +1,20 @@
- pose_info = pose_emoji_and_label(@selected_pose)
- 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"}
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose]
%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 "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
%td
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
%td
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
%tr
%th
%span.emoji-icon{title: "Feminine"} 💁‍♀️
%td
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
%td
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
%td
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
- active_tab = @alt_style ? "styles" : "expressions"
%tab-panel{active: active_tab}
.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
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
= render "pose_picker_form"
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
= render "style_picker_form"

View file

@ -0,0 +1,32 @@
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose]
%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 "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
%td
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
%td
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
%tr
%th
%span.emoji-icon{title: "Feminine"} 💁‍♀️
%td
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
%td
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
%td
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
= submit_tag "Change pose", name: nil, class: "pose-submit-button"

View file

@ -0,0 +1,15 @@
= form_with url: wardrobe_v2_path, method: :get, class: "style-picker-form" do |f|
= outfit_state_params except: [:style]
.style-picker-list
%label.style-option
= radio_button_tag :style, "", @alt_style.nil?
.style-option-content
%span.style-option-name Default
- @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"

View file

@ -15,6 +15,7 @@
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "pose-picker", async: true
= javascript_include_tag "tab-panel", async: true
= javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}

File diff suppressed because it is too large Load diff