From fd881ee31dbb046be5a13ee41ee2f19feaf09570 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Fri, 6 Feb 2026 07:54:09 -0800 Subject: [PATCH] [WV2] Support closeted items as well as worn items --- app/assets/stylesheets/wardrobe/show.css | 43 +++++++++++++- app/controllers/wardrobe_controller.rb | 13 ++-- app/helpers/wardrobe_helper.rb | 59 +++++++++++-------- app/models/outfit.rb | 28 ++++++++- app/views/wardrobe/_item_card.html.haml | 13 +++- app/views/wardrobe/_save_button.html.haml | 2 - .../wardrobe/_save_outfit_fields.html.haml | 2 + app/views/wardrobe/_search_results.html.haml | 4 +- app/views/wardrobe/show.html.haml | 8 +-- docs/wardrobe-v2-migration.md | 7 ++- 10 files changed, 132 insertions(+), 47 deletions(-) diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index 92ef48eb..fcf158ba 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -79,7 +79,9 @@ select, /* Icon button pattern - small action buttons with hover reveals */ .item-remove-button, -.item-add-button { +.item-add-button, +.item-hide-button, +.item-show-button { position: absolute; top: 0.5rem; right: 0.5rem; @@ -129,6 +131,24 @@ select, } } +.item-hide-button { + right: 2.5rem; + background: rgba(255, 255, 255, 0.9); + + &:hover { + background: rgba(255, 255, 255, 1); + } +} + +.item-show-button { + right: 2.5rem; + background: rgba(68, 136, 68, 0.9); + + &:hover { + background: rgba(68, 136, 68, 1); + } +} + /* Item card - shared layout for worn items and search results */ .item-card { display: flex; @@ -147,7 +167,7 @@ select, box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } - &:hover :is(.item-add-button, .item-remove-button) { + &:hover :is(.item-add-button, .item-remove-button, .item-hide-button, .item-show-button) { opacity: 1; } @@ -191,6 +211,23 @@ select, } } +/* Worn item emphasis */ +.item-card[data-is-worn] { + background: #eef5ee; + box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2); + + .item-name { + font-weight: 600; + } +} + +/* Closeted item de-emphasis */ +.item-card[data-is-closeted] { + background: #f5f5f5; + border: 1px dashed #ccc; + opacity: 0.75; +} + /* Pagination links - treated as buttons for consistency */ .pagination { a, @@ -780,7 +817,7 @@ pose-picker-popover { } } -.worn-items { +.outfit-items { margin-top: 2rem; .items-list { diff --git a/app/controllers/wardrobe_controller.rb b/app/controllers/wardrobe_controller.rb index 600f8ca1..6f302aa5 100644 --- a/app/controllers/wardrobe_controller.rb +++ b/app/controllers/wardrobe_controller.rb @@ -64,16 +64,19 @@ class WardrobeController < ApplicationController @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) + # 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: items, + worn_items: worn_items, + closeted_items: closeted_items, ) # Preload the manifests for all visible layers, so they load efficiently @@ -132,7 +135,7 @@ class WardrobeController < ApplicationController end def outfit_state_params_present? - params[:species].present? || params[:color].present? || params[:objects].present? + params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present? end def build_search_filters(query_params, outfit) diff --git a/app/helpers/wardrobe_helper.rb b/app/helpers/wardrobe_helper.rb index ac89e229..ee3df9d2 100644 --- a/app/helpers/wardrobe_helper.rb +++ b/app/helpers/wardrobe_helper.rb @@ -17,6 +17,12 @@ module WardrobeHelper 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? @@ -46,28 +52,33 @@ module WardrobeHelper end # Group outfit items by zone, applying smart multi-zone simplification. - # Returns an array of hashes: {zone:, items:} + # Returns an array of hashes: {zone_id:, zone_label:, items:} + # Each item entry is {item:, state: :worn/:closeted}. # This matches the logic from wardrobe-2020's getZonesAndItems function. def outfit_items_by_zone(outfit) return [] if outfit.pet_type.nil? - # Get item appearances for this outfit + # Build a list of all items with their state (:worn or :closeted) + all_items = outfit.worn_items.map { |i| {item: i, state: :worn} } + + outfit.closeted_items.map { |i| {item: i, state: :closeted} } + + # Get item appearances for all items at once item_appearances = Item.appearances_for( - outfit.worn_items, + all_items.map { |e| e[:item] }, outfit.pet_type, swf_asset_includes: [:zone] ) - # Separate incompatible items (no layers for this pet) - compatible_items = [] - incompatible_items = [] + # Separate compatible and incompatible items + compatible_entries = [] + incompatible_entries = [] - outfit.worn_items.each do |item| - appearance = item_appearances[item.id] + all_items.each do |entry| + appearance = item_appearances[entry[:item].id] if appearance&.present? - compatible_items << {item: item, appearance: appearance} + compatible_entries << entry.merge(appearance: appearance) else - incompatible_items << item + incompatible_entries << entry end end @@ -75,23 +86,19 @@ module WardrobeHelper items_by_zone = Hash.new { |h, k| h[k] = [] } zones_by_id = {} - compatible_items.each do |item_with_appearance| - item = item_with_appearance[:item] - appearance = item_with_appearance[:appearance] - - # Get unique zones for this item (an item may have multiple assets per zone) - appearance.swf_assets.map(&:zone).uniq.each do |zone| + compatible_entries.each do |entry| + entry[:appearance].swf_assets.map(&:zone).uniq.each do |zone| zones_by_id[zone.id] = zone - items_by_zone[zone.id] << item + items_by_zone[zone.id] << {item: entry[:item], state: entry[:state]} end end # Create zone groups with sorted items - zones_and_items = items_by_zone.map do |zone_id, items| + zones_and_items = items_by_zone.map do |zone_id, entries| { zone_id: zone_id, zone_label: zones_by_id[zone_id].label, - items: items.sort_by { |item| item.name.downcase } + items: entries.sort_by { |e| e[:item].name.downcase } } end @@ -107,11 +114,13 @@ module WardrobeHelper zones_and_items = disambiguate_zone_labels(zones_and_items) # Add incompatible items section if any - if incompatible_items.any? + if incompatible_entries.any? zones_and_items << { zone_id: nil, zone_label: "Incompatible", - items: incompatible_items.sort_by { |item| item.name.downcase } + items: incompatible_entries + .map { |e| {item: e[:item], state: e[:state]} } + .sort_by { |e| e[:item].name.downcase } } end @@ -123,13 +132,14 @@ module WardrobeHelper # 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. + # Each item entry is {item:, state:}. 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) } + groups_with_conflicts.flat_map { |g| g[:items].map { |e| e[:item].id } } ) # Track which items we've already shown @@ -139,14 +149,13 @@ module WardrobeHelper 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) } + group[:items].each { |e| items_we_have_seen.add(e[:item].id) } true else # For single-item groups, only keep if: # - Item hasn't been seen yet AND # - Item won't appear in a conflict group - item = group[:items].first - item_id = item.id + item_id = group[:items].first[:item].id if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id) false diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 48270019..a1dddace 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -262,7 +262,11 @@ class Outfit < ApplicationRecord end def same_wardrobe_state_as?(other) - wardrobe_params == other.wardrobe_params + # 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 @@ -274,6 +278,7 @@ class Outfit < ApplicationRecord closet: closeted_item_ids.sort, } params[:style] = alt_style_id if alt_style_id.present? + params[:name] = name if !persisted? && name.present? params end @@ -313,9 +318,23 @@ class Outfit < ApplicationRecord end end - # Create a copy of this outfit, but *not* wearing the given item. + # Create a copy of this outfit without the given item at all + # (removed from both worn and closeted). def without_item(item) - dup.tap { |o| o.worn_items.delete(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. @@ -325,6 +344,9 @@ class Outfit < ApplicationRecord # 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, diff --git a/app/views/wardrobe/_item_card.html.haml b/app/views/wardrobe/_item_card.html.haml index 158040a3..4cc53d1f 100644 --- a/app/views/wardrobe/_item_card.html.haml +++ b/app/views/wardrobe/_item_card.html.haml @@ -1,5 +1,6 @@ - is_worn = @outfit.worn_items.include?(item) -%li.item-card +- is_closeted = @outfit.closeted_items.include?(item) +%li.item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}} .item-thumbnail = image_tag item.thumbnail_url, alt: item.name, loading: "lazy" .item-info @@ -8,6 +9,16 @@ = render "items/badges/kind", item: item = render "items/badges/first_seen", 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) diff --git a/app/views/wardrobe/_save_button.html.haml b/app/views/wardrobe/_save_button.html.haml index 4b821c21..d3f71d4d 100644 --- a/app/views/wardrobe/_save_button.html.haml +++ b/app/views/wardrobe/_save_button.html.haml @@ -3,8 +3,6 @@ - if @has_unsaved_changes = form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f| = render "save_outfit_fields" - - @saved_outfit.closeted_items.each do |item| - = hidden_field_tag "outfit[item_ids][closeted][]", item.id = f.submit "Save", class: "outfit-save-button" - else %button.outfit-save-button{disabled: true} Saved! diff --git a/app/views/wardrobe/_save_outfit_fields.html.haml b/app/views/wardrobe/_save_outfit_fields.html.haml index 11e4226f..9fa5dd28 100644 --- a/app/views/wardrobe/_save_outfit_fields.html.haml +++ b/app/views/wardrobe/_save_outfit_fields.html.haml @@ -6,3 +6,5 @@ = 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 diff --git a/app/views/wardrobe/_search_results.html.haml b/app/views/wardrobe/_search_results.html.haml index 695abab3..dc452b67 100644 --- a/app/views/wardrobe/_search_results.html.haml +++ b/app/views/wardrobe/_search_results.html.haml @@ -1,12 +1,12 @@ .search-results - if @search_results.any? - = will_paginate @search_results, page_links: false, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] } + = 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 "item_card", item: item - = will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] } + = will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q]) - else .empty-state diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index 0b3edc00..55e44343 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -69,11 +69,11 @@ - else = render "outfit_rename_field" = render "save_button" - - if @outfit.worn_items.any? - .worn-items + - 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 "item_card", item: item + - zone_group[:items].each do |entry| + = render "item_card", item: entry[:item] diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index 6c8bade4..b48d6f88 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -58,7 +58,7 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search, - Stale style params dropped gracefully when switching species - Search results auto-filtered by alt style compatibility -**Closeted Items** +**Closeted Items** (baseline done, progressive enhancement pending) - 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. @@ -66,7 +66,10 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search, - 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). -- Progressive enhancement: +- 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 (pending): - In the outfit view, items in the same zone group (mutually-incompatible) are a radio group. Whichever radio button is checked is the worn item. The others are merely closeted. - In the search view, each item has a worn checkbox (analogous to the worn radio button).