[WV2] Pose picker first draft

This commit is contained in:
Emi Matchu 2025-11-11 17:41:57 -08:00
parent 78931ddb47
commit 76496f8a6d
6 changed files with 316 additions and 3 deletions

View file

@ -0,0 +1,31 @@
/**
* PosePicker web component
*
* Progressive enhancement for pose picker forms:
* - Auto-submits the form when a pose is selected (if JS is enabled)
* - Shows a submit button as fallback (if JS is disabled or slow to load)
* - Uses Custom Element internals API to communicate state to CSS
*/
class PosePicker 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) {
// Only auto-submit if a radio button was changed
if (e.target.type === "radio") {
this.querySelector("form").requestSubmit();
}
}
}
customElements.define("pose-picker", PosePicker);

View file

@ -41,6 +41,176 @@ body.wardrobe-v2 {
font-size: 1.2rem; font-size: 1.2rem;
} }
/* Pose picker floats over the preview above the species/color picker */
pose-picker {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
pointer-events: none;
/* Allow clicks through when hidden */
/* Start hidden, reveal on hover or focus */
opacity: 0;
transition: opacity 0.2s;
.pose-picker-form {
pointer-events: auto;
/* Re-enable clicks on the form itself */
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.pose-picker-table {
border-collapse: separate;
border-spacing: 0.5rem;
th {
text-align: center;
color: white;
padding: 0.25rem;
font-weight: normal;
}
td {
padding: 0;
}
}
.emoji-icon {
font-size: 1.25rem;
display: inline-block;
user-select: none;
}
.pose-option {
display: block;
cursor: pointer;
position: relative;
width: 60px;
height: 60px;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pose-thumbnail {
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
background: rgba(255, 255, 255, 0.1);
border: 2px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.pose-thumbnail-viewer {
width: 60px !important;
height: 60px !important;
transform: scale(0.8);
}
.pose-unavailable {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.4;
.question-mark {
font-size: 2rem;
}
}
/* Selected state */
input[type="radio"]:checked + .pose-thumbnail {
border-color: #48BB78;
box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.3);
transform: scale(1.05);
}
/* Hover state (only for available poses) */
&.available:hover .pose-thumbnail {
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.05);
}
/* Focus state */
input[type="radio"]:focus + .pose-thumbnail {
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
}
/* Unavailable state */
&.unavailable {
cursor: not-allowed;
.pose-thumbnail {
opacity: 0.5;
background: rgba(128, 128, 128, 0.2);
}
}
}
/* Submit button: progressive enhancement pattern */
.pose-submit-button {
margin-top: 1rem;
padding: 0.5rem 1rem;
font-size: 0.95rem;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
width: 100%;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
&:hover {
background-color: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
}
&:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
}
}
/* If JS is enabled, hide the submit button initially with a delay */
@media (scripting: enabled) {
.pose-submit-button {
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
/* Once auto-loading is ready, hide the submit button completely */
&:state(auto-loading) {
.pose-submit-button {
display: none;
}
}
}
/* Species/color picker floats over the preview at the bottom */ /* Species/color picker floats over the preview at the bottom */
species-color-picker { species-color-picker {
position: absolute; position: absolute;
@ -142,14 +312,22 @@ body.wardrobe-v2 {
} }
} }
/* Show picker on hover (real hover only, not simulated touch hover) */ /* Show pickers on hover (real hover only, not simulated touch hover) */
@media (hover: hover) { @media (hover: hover) {
&:hover pose-picker {
opacity: 1;
}
&:hover species-color-picker { &:hover species-color-picker {
opacity: 1; opacity: 1;
} }
} }
/* Show picker when it has focus */ /* Show pickers when they have focus */
&:has(pose-picker:focus-within) pose-picker {
opacity: 1;
}
&:has(species-color-picker:focus-within) species-color-picker { &:has(species-color-picker:focus-within) species-color-picker {
opacity: 1; opacity: 1;
} }

View file

@ -19,13 +19,35 @@ class WardrobeController < ApplicationController
# (might differ from requested color if we fell back to a simple color) # (might differ from requested color if we fell back to a simple color)
@selected_color = @pet_type&.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 items from the objects[] parameter # Load items from the objects[] parameter
item_ids = params[:objects] || [] item_ids = params[:objects] || []
items = Item.where(id: item_ids) items = Item.where(id: item_ids)
# Build the outfit # Build the outfit
@outfit = Outfit.new( @outfit = Outfit.new(
pet_state: @pet_type&.canonical_pet_state, pet_state: @pet_state,
worn_items: items, worn_items: items,
) )
@ -49,6 +71,30 @@ class WardrobeController < ApplicationController
private 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 build_search_filters(query_params, outfit) def build_search_filters(query_params, outfit)
filters = [] filters = []

View file

@ -7,6 +7,7 @@ module WardrobeHelper
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species) 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(:color, @outfit.color_id) unless except.include?(:color)
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
unless except.include?(:worn_items) unless except.include?(:worn_items)
outfit.worn_items.each do |item| outfit.worn_items.each do |item|

View file

@ -0,0 +1,21 @@
-# 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"
- else
.pose-unavailable
%span.question-mark{title: "Not available"} ❓

View file

@ -14,6 +14,7 @@
= javascript_include_tag "idiomorph", async: true = javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true = javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true = javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "pose-picker", async: true
= javascript_include_tag "wardrobe/show", async: true = javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags = csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
@ -28,6 +29,41 @@
- else - else
= outfit_viewer @outfit = outfit_viewer @outfit
- if @pet_type
%pose-picker
= 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"
%species-color-picker %species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f| = form_with url: wardrobe_v2_path, method: :get do |f|
= outfit_state_params except: [:color, :species] = outfit_state_params except: [:color, :species]