1import { createMemo, createSignal, For, onMount, onCleanup } from "solid-js";2import { StackCard } from "./StackCard";3import type { CardId, StackBodies, StackCardSpec } from "./types";45interface StackDeckProps {6 active: CardId;7 onActiveChange: (id: CardId) => void;8 onBack: () => void;9 cards: readonly StackCardSpec[];10 bodies: StackBodies;11 12 version?: string;13}1415const EXPANDED_H = 258;16const DEFAULT_FOCUS_H = 462;17const TAB_H = 46;18const TAB_OVERLAP = 14;1920export function StackDeck(props: StackDeckProps) {21 const [deckOpen, setDeckOpen] = createSignal(true);22 const [focusHeight, setFocusHeight] = createSignal(DEFAULT_FOCUS_H);23 let focusedAt = 0;24 let deckEl: HTMLDivElement | undefined;2526 function handleBack() {27 if (!deckOpen()) {28 setDeckOpen(true);29 } else {30 props.onBack();31 }32 }3334 onMount(() => {35 const listener = () => handleBack();36 window.addEventListener("peach:settings-back", listener);3738 const updateFocusHeight = () => {39 if (!deckEl) return;40 setFocusHeight(Math.round(deckEl.getBoundingClientRect().height));41 };42 updateFocusHeight();43 const observer =44 typeof ResizeObserver === "undefined"45 ? undefined46 : new ResizeObserver(updateFocusHeight);47 if (deckEl) observer?.observe(deckEl);48 if (!observer) window.addEventListener("resize", updateFocusHeight);4950 onCleanup(() => {51 window.removeEventListener("peach:settings-back", listener);52 observer?.disconnect();53 if (!observer) window.removeEventListener("resize", updateFocusHeight);54 });55 });5657 const activeIndex = createMemo(() => {58 const idx = props.cards.findIndex((card) => card.id === props.active);59 return idx >= 0 ? idx : 0;60 });6162 return (63 <section class="stk-shell" aria-label="Settings">64 <div class="stk-mast">65 <button66 class="stk-back"67 onClick={handleBack}68 aria-label="Back"69 type="button"70 >71 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">72 <path d="M6 1L1 6L6 11" />73 </svg>74 </button>75 <div class="stk-mast-title">Settings</div>76 <div class="stk-mast-meta">77 <span class="stk-mast-counter">{activeIndex() + 1} / {props.cards.length}</span>78 {props.version && <span class="stk-mast-version">v{props.version}</span>}79 </div>80 </div>8182 <div83 ref={(el) => { deckEl = el; }}84 class={`stk-deck ${deckOpen() ? "is-open" : "is-focused"}`}85 onWheel={(e) => {86 const cardEl = (e.target as HTMLElement).closest<HTMLElement>("[data-settings-card-id]");87 if (!cardEl || cardEl.dataset.active !== "true") return;88 if (deckOpen() && e.deltaY > 5) {89 90 setDeckOpen(false);91 focusedAt = Date.now();92 } else if (!deckOpen() && e.deltaY < -5 && Date.now() - focusedAt > 300) {93 94 95 const body = cardEl.querySelector<HTMLElement>(".stk-card-body");96 if (!body || body.scrollTop === 0) setDeckOpen(true);97 }98 }}99 >100 <For each={props.cards}>101 {(card, index) => {102 const active = () => card.id === props.active;103 const concealed = () => !deckOpen() && !active();104 const distance = createMemo(() => {105 const ai = activeIndex();106 const ci = index();107 if (ci === ai) return -1;108 if (ci > ai) return ci - ai - 1;109 return props.cards.length - ai + ci - 1;110 });111 const top = () => {112 if (active()) return 0;113 if (!deckOpen()) return focusHeight() + 44 + distance() * 8;114 return EXPANDED_H - TAB_OVERLAP + distance() * (TAB_H - TAB_OVERLAP);115 };116 const zIndex = () => (active() ? 99 : 50 - distance());117118 return (119 <StackCard120 spec={card}121 active={active()}122 focused={!deckOpen() && active()}123 concealed={concealed()}124 top={top()}125 zIndex={zIndex()}126 onClick={() => {127 if (active()) return;128 props.onActiveChange(card.id);129 setDeckOpen(false);130 }}131 height={active() ? (deckOpen() ? EXPANDED_H : focusHeight()) : TAB_H + 4}132 >133 {props.bodies[card.id]}134 </StackCard>135 );136 }}137 </For>138 </div>139 </section>140 );141}