diff --git a/apps/mapgen-studio/src/features/configOverrides/SchemaConfigForm.tsx b/apps/mapgen-studio/src/features/configOverrides/SchemaConfigForm.tsx index d2c5964436..757f765a6d 100644 --- a/apps/mapgen-studio/src/features/configOverrides/SchemaConfigForm.tsx +++ b/apps/mapgen-studio/src/features/configOverrides/SchemaConfigForm.tsx @@ -7,7 +7,7 @@ import { toRjsfSchema, tryGetSchemaAtPath, } from "./schemaPresentation"; -import type { BrowserConfigFormContext } from "./rjsfTemplates"; +import type { BrowserConfigFormContext, ConfigCollapseContext } from "./rjsfTemplates"; import { getAtPath, setAtPath } from "./pathUtils"; function isPlainObject(value: unknown): value is Record { @@ -34,10 +34,15 @@ export type SchemaConfigFormProps = Readonly<{ onChange(next: TConfig): void; disabled: boolean; focusPath?: readonly string[] | null; + /** + * Collapse state for config objects (Pass-4 config-collapse spec). + * Omitted ⇒ the form renders fully expanded with no disclosure chrome. + */ + collapse?: ConfigCollapseContext; }>; export function SchemaConfigForm(props: SchemaConfigFormProps) { - const { schema, value, onChange, disabled, focusPath } = props; + const { schema, value, onChange, disabled, focusPath, collapse } = props; const buildUiSchema = useMemo(() => { const hasEnum = (node: RJSFSchema): boolean => @@ -168,11 +173,18 @@ export function SchemaConfigForm(props: SchemaConfigFormProps) [active.resolved.schema, buildUiSchema] ); + // Pointers are mode-independent (focused mode wraps the stage under its own + // key), so one collapse context serves both views unchanged. + const formContext = useMemo( + () => ({ ...active.resolved.formContext, collapse }), + [active.resolved.formContext, collapse] + ); + return ( { diff --git a/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx b/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx index 701075874f..81e4a7e489 100644 --- a/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx +++ b/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx @@ -1,10 +1,30 @@ import type { ArrayFieldTemplateProps, FieldTemplateProps, ObjectFieldTemplateProps, RJSFSchema } from "@rjsf/utils"; import type { ReactNode } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { FieldRow } from "../../ui/components/fields"; import { pathToPointer } from "./schemaPresentation"; +/** + * Collapse state for the form's config objects (Pass-4 config-collapse + * spec), keyed by JSON pointer. Provided by `useConfigCollapse` via the + * RecipePanel; when ABSENT the templates render today's always-expanded + * markup with no chevrons (template unit mounts, bare `SchemaForm` reuse). + * + * The expanded set is exposed as DATA (not an `isExpanded` closure) on + * purpose: rjsf's `SchemaField.shouldComponentUpdate` uses `deepEquals`, + * which assumes all functions are equivalent — a context whose only change + * is a fresh closure identity would never re-render the form. lodash + * `isEqual` compares Set contents, so membership changes propagate. + */ +export type ConfigCollapseContext = Readonly<{ + /** Resolved set of expanded pointers (defaults already applied). */ + expandedPointers: ReadonlySet; + toggle(pointer: string): void; +}>; + export type BrowserConfigFormContext = { transparentPaths: ReadonlySet; + collapse?: ConfigCollapseContext; }; // Token-driven chrome for the rjsf config form — this is a high-traffic live @@ -79,6 +99,47 @@ function renderGsComments(args: { schema: unknown; className: string }): ReactNo ); } +function configContentId(pointer: string): string { + return `config-object-content${pointer.replace(/[^A-Za-z0-9_-]+/g, "-")}`; +} + +/** + * The per-object header row (Pass-4 config-collapse spec): chevron + title + * as ONE disclosure button, plus a trailing zone for object-local actions — + * the future home of per-object Reset/Show-JSON; the array template's Add + * button rides it already. The `data-config-*` attributes are the sticky + * engine's DOM contract (see `useConfigCollapse`). + */ +function CollapsibleHeader(args: { + pointer: string; + title: string; + titleClass: string; + expanded: boolean; + collapse: ConfigCollapseContext; + className?: string; + actions?: ReactNode; +}): ReactNode { + const { pointer, title, titleClass, expanded, collapse, className, actions } = args; + const Chevron = expanded ? ChevronDown : ChevronRight; + return ( +
+ + {actions ?
{actions}
: null} +
+ ); +} + export function BrowserConfigFieldTemplate( props: FieldTemplateProps ){ @@ -160,6 +221,11 @@ export function BrowserConfigObjectFieldTemplate( const prettyTitle = title ? humanizeSchemaLabel(title) : leafKey ? humanizeSchemaLabel(leafKey) : "Section"; const isStage = depth === 1; + // Collapse plumbing (Pass-4): no context ⇒ always expanded, no chevrons. + const collapse = props.registry.formContext?.collapse; + const pointer = pathToPointer(path); + const expanded = collapse ? collapse.expandedPointers.has(pointer) : true; + const content = (
{!isStage && description ? ( @@ -171,14 +237,37 @@ export function BrowserConfigObjectFieldTemplate( if (isStage) { return ( -
-
-
{prettyTitle}
- {renderGsComments({ schema, className: labelClass })} - {description ?
{description}
: null} -
-
- {content} +
+ {collapse ? ( + + ) : ( +
+
{prettyTitle}
+ {renderGsComments({ schema, className: labelClass })} + {description ?
{description}
: null} +
+ )} + {expanded ? ( +
+ {collapse ? ( +
+ {renderGsComments({ schema, className: labelClass })} + {description ?
{description}
: null} +
+ ) : null} +
+ {content} +
+ ) : null}
); } @@ -188,21 +277,48 @@ export function BrowserConfigObjectFieldTemplate( // (surface nesting is capped at card → well; see FORM.well). if (depth === 2) { return ( -
-
-
{prettyTitle}
-
- {content} +
+ {collapse ? ( + + ) : ( +
+
{prettyTitle}
+
+ )} + {expanded ?
{content}
: null}
); } return ( -
-
-
{prettyTitle}
-
- {content} +
+ {collapse ? ( + + ) : ( +
+
{prettyTitle}
+
+ )} + {expanded ?
{content}
: null}
); } @@ -210,43 +326,71 @@ export function BrowserConfigObjectFieldTemplate( export function BrowserConfigArrayFieldTemplate( props: ArrayFieldTemplateProps ){ - const { title, items, canAdd, onAddClick, disabled, readonly, schema } = props; + const { title, items, canAdd, onAddClick, disabled, readonly, schema, fieldPathId } = props; const prettyTitle = title ? humanizeSchemaLabel(title) : "Items"; const allowMutations = !disabled && !readonly; const labelClass = FORM.label; + // Collapse plumbing (Pass-4): arrays ride the same per-object header + // anatomy as object groups; the Add button is the first object-local + // action living in the header's trailing zone. + const collapse = props.registry.formContext?.collapse; + const pointer = pathToPointer(fieldPathId.path ?? []); + const expanded = collapse ? collapse.expandedPointers.has(pointer) : true; + + const addButton = + canAdd && allowMutations ? ( + + ) : null; + // Arrays ride the same well tier as object groups (one surface recess); // items separate by hairline borders only — a tinted item box would be a // third surface tier, which the elevation scheme caps out. return ( -
-
-
{prettyTitle}
-
- {canAdd && allowMutations ? ( - - ) : null} -
- {renderGsComments({ schema, className: labelClass })} -
-
- {items.map((item, index) => { - // RJSF v6 types this as ReactElement[], but some templates/versions - // pass an "item" object that wraps the actual element in `.children`. - const content = (item as any)?.children ?? (item as any)?.props?.children ?? item; - return ( -
- {content} -
- ); - })} -
+
+ {collapse ? ( + + ) : ( +
+
{prettyTitle}
+
+ {addButton} +
+ )} + {expanded ? ( +
+ {renderGsComments({ schema, className: labelClass })} +
+
+ {items.map((item, index) => { + // RJSF v6 types this as ReactElement[], but some templates/versions + // pass an "item" object that wraps the actual element in `.children`. + const content = (item as any)?.children ?? (item as any)?.props?.children ?? item; + return ( +
+ {content} +
+ ); + })} +
+
+ ) : null}
); } diff --git a/apps/mapgen-studio/src/features/configOverrides/useConfigCollapse.ts b/apps/mapgen-studio/src/features/configOverrides/useConfigCollapse.ts new file mode 100644 index 0000000000..a465cbbf5f --- /dev/null +++ b/apps/mapgen-studio/src/features/configOverrides/useConfigCollapse.ts @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { RefObject } from "react"; +import type { ConfigCollapseContext } from "./rjsfTemplates"; + +// ============================================================================ +// CONFIG COLLAPSE (Pass-4 config-collapse spec) +// ============================================================================ +// Owns the expansion state for the config form's collapsible objects and the +// optional sticky auto-expand-on-scroll engine. State is keyed by JSON +// pointer — pointers are identical in focused and unfocused modes (focused +// mode wraps the stage under its own key), so expansion survives mode +// switches. The engine is DOM-driven: templates stamp `data-config-header` + +// `data-config-pointer`, and the hook queries them on scroll — no ref +// registry to keep alive across rjsf re-renders. +// ============================================================================ + +/** + * Distance (px) from the top of the scroll viewport to the sticky "focus + * line": the header that most recently crossed it owns the expansion chain. + */ +const FOCUS_LINE_OFFSET = 56; + +/** "/a/b/c" → ["/a", "/a/b", "/a/b/c"] — a pointer plus all its ancestors. */ +export function pointerPrefixes(pointer: string): string[] { + const parts = pointer.split("/").filter(Boolean); + const out: string[] = []; + let acc = ""; + for (const part of parts) { + acc += `/${part}`; + out.push(acc); + } + return out; +} + +/** + * The active chain for sticky mode: the candidate is the LAST header sitting + * at/above the focus line (the section the reader is inside); with none + * above it yet, the first header. The chain is the candidate plus its + * ancestor pointers — ancestors are string prefixes, no tree bookkeeping. + */ +export function computeActiveChain( + headers: ReadonlyArray<{ pointer: string; top: number }>, + focusLine: number +): ReadonlySet { + if (headers.length === 0) return new Set(); + let candidate = headers[0]; + for (const header of headers) { + if (header.top <= focusLine) candidate = header; + } + return new Set(pointerPrefixes(candidate.pointer)); +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +export type UseConfigCollapseArgs = Readonly<{ + /** The form's scroll container (the config section of the recipe panel). */ + scrollRootRef: RefObject; + /** Sticky auto-expand-on-scroll mode (config toolbar toggle, default OFF). */ + sticky: boolean; + /** + * Pointer of the focused stage root, or null in unfocused mode. The + * focused root defaults expanded (focusing a stage means "show me this + * one"); everything else defaults collapsed. + */ + focusRootPointer: string | null; +}>; + +export function useConfigCollapse(args: UseConfigCollapseArgs): ConfigCollapseContext { + const { scrollRootRef, sticky, focusRootPointer } = args; + + // Explicit user choices only — absence means "default", so the focused-root + // default never fights a deliberate collapse. + const [choices, setChoices] = useState>(new Map()); + const [activeChain, setActiveChain] = useState>(new Set()); + // Scroll anchor: the deepest active header's viewport offset, recorded when + // the chain changes and restored after render so the reading position never + // visibly teleports when content above the focus line collapses. + const anchorRef = useRef<{ pointer: string; top: number } | null>(null); + + // The RESOLVED expanded set (defaults applied) — exposed as data because + // rjsf's deepEquals treats functions as always-equal; only content changes + // in this Set make the form re-render (see ConfigCollapseContext). + const expandedPointers = useMemo>(() => { + if (sticky) return activeChain; + const out = new Set(); + for (const [pointer, expanded] of choices) { + if (expanded) out.add(pointer); + } + if (focusRootPointer && choices.get(focusRootPointer) !== false) { + out.add(focusRootPointer); + } + return out; + }, [sticky, activeChain, choices, focusRootPointer]); + + const toggle = useCallback( + (pointer: string) => { + // Flip from the rendered state. In sticky mode the click is recorded as + // a manual choice (it applies when the mode turns off); the next scroll + // recomputation keeps owning the visible state. + const current = expandedPointers.has(pointer); + setChoices((prev) => { + const next = new Map(prev); + next.set(pointer, !current); + return next; + }); + }, + [expandedPointers] + ); + + useEffect(() => { + if (!sticky) return; + const root = scrollRootRef.current; + if (!root) return; + let raf = 0; + const recompute = () => { + raf = 0; + const rootTop = root.getBoundingClientRect().top; + const focusLine = rootTop + FOCUS_LINE_OFFSET; + const headerEls = Array.from( + root.querySelectorAll("[data-config-header][data-config-pointer]") + ); + const headers = headerEls + .map((el) => ({ + pointer: el.getAttribute("data-config-pointer") ?? "", + top: el.getBoundingClientRect().top, + el, + })) + .filter((h) => h.pointer.length > 0); + const chain = computeActiveChain(headers, focusLine); + setActiveChain((prev) => { + if (setsEqual(prev, chain)) return prev; + let deepest: string | null = null; + for (const pointer of chain) { + if (deepest === null || pointer.length > deepest.length) deepest = pointer; + } + const anchorEl = deepest ? headers.find((h) => h.pointer === deepest)?.el : undefined; + anchorRef.current = + deepest && anchorEl ? { pointer: deepest, top: anchorEl.getBoundingClientRect().top } : null; + return chain; + }); + }; + const onScroll = () => { + if (!raf) raf = requestAnimationFrame(recompute); + }; + root.addEventListener("scroll", onScroll, { passive: true }); + recompute(); + return () => { + root.removeEventListener("scroll", onScroll); + if (raf) cancelAnimationFrame(raf); + }; + }, [sticky, scrollRootRef]); + + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (!anchor) return; + anchorRef.current = null; + const root = scrollRootRef.current; + if (!root) return; + const el = root.querySelector( + `[data-config-header][data-config-pointer="${anchor.pointer}"]` + ); + if (!el) return; + const delta = el.getBoundingClientRect().top - anchor.top; + if (delta !== 0) root.scrollTop += delta; + }, [activeChain, scrollRootRef]); + + return useMemo(() => ({ expandedPointers, toggle }), [expandedPointers, toggle]); +} diff --git a/apps/mapgen-studio/src/ui/components/RecipePanel.tsx b/apps/mapgen-studio/src/ui/components/RecipePanel.tsx index ba1def8681..417518de43 100644 --- a/apps/mapgen-studio/src/ui/components/RecipePanel.tsx +++ b/apps/mapgen-studio/src/ui/components/RecipePanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; // ============================================================================ // RECIPE PANEL // ============================================================================ @@ -11,10 +11,12 @@ import { Braces, BookOpen, Focus, + ListCollapse, Settings, Save } from 'lucide-react'; import { SchemaConfigForm } from '../../features/configOverrides/SchemaConfigForm'; +import { useConfigCollapse } from '../../features/configOverrides/useConfigCollapse'; import { LAYOUT } from '../constants'; import { formatMapConfigSaveDeployPhaseLabel, @@ -175,6 +177,16 @@ export const RecipePanel: React.FC = ({ return config; }, [config, selectedStep, showAllSteps]); const focusPath = !showAllSteps && selectedStep ? [selectedStep] : null; + // Config-object collapse (Pass-4): collapsed by default with manual expand; + // the focused stage root defaults expanded; the sticky toggle (default OFF) + // hands expansion to the scroll engine. + const [stickyAutoExpand, setStickyAutoExpand] = useState(false); + const configScrollRef = useRef(null); + const collapse = useConfigCollapse({ + scrollRootRef: configScrollRef, + sticky: stickyAutoExpand, + focusRootPointer: focusPath ? `/${focusPath.join('/')}` : null + }); // ========================================================================== // Handlers // ========================================================================== @@ -351,13 +363,28 @@ export const RecipePanel: React.FC = ({ {/* Config Content */} {!configCollapsed && -
+
{/* Config Actions */}
+ + + + + {stickyAutoExpand ? 'Auto-Expand on Scroll: On' : 'Auto-Expand on Scroll: Off'} + +
}], + canAdd: true, + onAddClick: () => {}, + disabled: false, + readonly: false, + schema: { type: "array" } as RJSFSchema, + fieldPathId: { path: ["foundation", "rangeSeeds"], id: "foundation_rangeSeeds" }, + registry: { + formContext: { + transparentPaths: new Set(), + collapse: { expandedPointers: new Set(), toggle: () => {} }, + }, + }, + } as unknown as ArrayProps)} + /> + ); + // Collapsed: the Add action stays reachable on the header, items hidden. + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain(">Add<"); + expect(html).not.toContain("array-item-marker"); + }); + + it("renders no disclosure chrome without a collapse context (unit mounts, bare reuse)", () => { + const expandedDefault = renderToStaticMarkup( + field-content-marker
, name: "x" }], + fieldPathId: { path: ["foundation"], id: "foundation" }, + schema: { type: "object" } as RJSFSchema, + registry: { formContext: { transparentPaths: new Set() } }, + } as unknown as ObjectProps)} + /> + ); + expect(expandedDefault).toContain("field-content-marker"); + expect(expandedDefault).not.toContain("aria-expanded"); + }); +}); diff --git a/apps/mapgen-studio/test/config/useConfigCollapse.test.ts b/apps/mapgen-studio/test/config/useConfigCollapse.test.ts new file mode 100644 index 0000000000..e0b3cc1318 --- /dev/null +++ b/apps/mapgen-studio/test/config/useConfigCollapse.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { computeActiveChain, pointerPrefixes } from "../../src/features/configOverrides/useConfigCollapse"; + +// The sticky engine's pure core (Pass-4 config-collapse design): the +// candidate is the LAST header at/above the focus line; the active chain is +// the candidate plus its ancestor pointers (string prefixes). The DOM/scroll +// glue around it is verified live. + +describe("pointerPrefixes", () => { + it("yields the pointer and every ancestor", () => { + expect(pointerPrefixes("/a/b/c")).toEqual(["/a", "/a/b", "/a/b/c"]); + }); + + it("handles a top-level pointer", () => { + expect(pointerPrefixes("/foundation")).toEqual(["/foundation"]); + }); +}); + +describe("computeActiveChain", () => { + const headers = [ + { pointer: "/foundation", top: 0 }, + { pointer: "/foundation/knobs", top: 40 }, + { pointer: "/morphology", top: 400 }, + { pointer: "/morphology/coasts", top: 440 }, + ]; + + it("is empty with no headers", () => { + expect(computeActiveChain([], 100).size).toBe(0); + }); + + it("activates the first header before any crosses the focus line", () => { + const chain = computeActiveChain(headers.map((h) => ({ ...h, top: h.top + 200 })), 100); + expect([...chain]).toEqual(["/foundation"]); + }); + + it("activates the last header at or above the focus line, with ancestors", () => { + const chain = computeActiveChain(headers, 100); + expect(chain.has("/foundation")).toBe(true); + expect(chain.has("/foundation/knobs")).toBe(true); + expect(chain.has("/morphology")).toBe(false); + }); + + it("moves the chain to the next section once its header crosses the line", () => { + const chain = computeActiveChain(headers, 410); + expect([...chain]).toEqual(["/morphology"]); + }); + + it("cascades into nested headers as they cross the line", () => { + const chain = computeActiveChain(headers, 450); + expect(chain.has("/morphology")).toBe(true); + expect(chain.has("/morphology/coasts")).toBe(true); + expect(chain.has("/foundation")).toBe(false); + }); +}); diff --git a/docs/projects/mapgen-studio-redesign/00-GOAL.md b/docs/projects/mapgen-studio-redesign/00-GOAL.md index b15fb42207..01ba7ae012 100644 --- a/docs/projects/mapgen-studio-redesign/00-GOAL.md +++ b/docs/projects/mapgen-studio-redesign/00-GOAL.md @@ -282,3 +282,38 @@ px-3 ⇒ 12px; footer centering measured 860==860 @1720px; debug filter exercised live 2→3→2). Gates green at tip: tsc, build + worker-bundle, 146 tests, five strict OpenSpec validations. Stack remains UNSUBMITTED per the standing rule. + +## Pass-4 design fixes (2026-06-11, late) — COMPLETE + +User-grounded wave four (`pass-4-design-fixes.md`): game-console placement +deliberation, console icon set, and the config-collapse feature. New standing +meta-rule recorded (and saved to agent memory): user callouts are categorical +by default — sweep the class, not the instance. OpenSpec slices stacked on +`design/explore-toolbar-groups`: + +- [x] **E1** `mapgen-studio-game-console-dock` — `design/game-console-dock` + (deliberated merge-vs-colocate; chose colocation: AppHeader `gameConsole` + slot renders the console centered beneath the world bar — vertical zoning + top = game, bottom = studio; footer reduced to the centered Studio console; + shared operation gating preserved; game-console tests moved to the + component that owns the markup) +- [x] **E2** `mapgen-studio-game-console-icons` — `design/game-console-icons` + (autoplay `FastForward`/`Square`/spinner, Run in Game + `SquareArrowOutUpRight` — icon-only with the dynamic action labels leading + aria-label/title/Tooltip; new disabled `Binoculars` Explore placeholder so + the three-command set reads together) +- [x] **E3** `mapgen-studio-config-collapse` — `design/config-collapse` + (per-object header anatomy with trailing action zone — future home of + object-local Reset/Show-JSON; collapsed-by-default with manual expand, + focused stage root defaults expanded; pointer-keyed state survives mode + switches — verified live; sticky auto-expand-on-scroll toggle default OFF, + DOM-driven engine with scroll anchoring, cascade verified live through + nested groups. Found + documented: rjsf `deepEquals` treats functions as + always-equal, so collapse state must cross formContext as Set DATA, not + closures) + +**Pass-4 closure (2026-06-11):** all three slices implemented + visually +verified on :5173 (dark + light; focused/unfocused collapse, persistence, +and the sticky chain walk exercised live). Gates green at tip: tsc, build + +worker-bundle, 162 tests, three strict OpenSpec validations. Stack remains +UNSUBMITTED per the standing rule. diff --git a/openspec/changes/mapgen-studio-config-collapse/design.md b/openspec/changes/mapgen-studio-config-collapse/design.md new file mode 100644 index 0000000000..d5e80f5f3b --- /dev/null +++ b/openspec/changes/mapgen-studio-config-collapse/design.md @@ -0,0 +1,116 @@ +# Design — config-object collapse, sticky auto-expand, per-object header + +## Context + +The rjsf form renders three collapsible tiers (Pass-3 elevation scheme): +stage cards (depth 1, `card`), group/array wells (depth 2, `well`), and +subgroup headings (depth ≥3, eyebrow only). Transparent paths render no +chrome and are not collapsible. The form scrolls inside +`#recipe-panel-config-section` (the RecipePanel config section). Focused mode +wraps the selected stage as `{ [stage]: schema }`, so `fieldPathId.path` — +and therefore JSON pointers — are identical in both modes. + +## State model + +- `expansionChoices: Map` — explicit user choices only. +- `defaultExpanded(pointer)` = `pointer === focusRootPointer` (the focused + stage root; null in unfocused mode). Everything else defaults collapsed. +- Manual mode: `isExpanded(p) = choices.get(p) ?? defaultExpanded(p)`. +- Sticky mode ON: `isExpanded(p) = activeChain.has(p)`; chevron clicks still + write `choices` (they apply when the mode turns off or between scroll + recomputations). +- The map is keyed by pointer, not mode, so expansion survives focus + switches; switching the selected stage re-seeds only the default, never + clears choices. + +## Template plumbing (no registry) + +`BrowserConfigFormContext` gains an optional member: + +```ts +collapse?: { + expandedPointers: ReadonlySet; // resolved (defaults applied) + toggle(pointer: string): void; +} +``` + +The expanded set is DATA, not an `isExpanded` closure, by necessity: rjsf's +`SchemaField.shouldComponentUpdate` compares props with `deepEquals`, which +assumes all functions are equivalent (lodash `isEqualWith` + function +short-circuit) — a context whose only change is a fresh closure identity +never re-renders the form. lodash compares Set contents, so membership +changes propagate. (Found live: the first closure-shaped implementation +rendered correct initial state but ignored every toggle.) + +Absent ⇒ templates render today's expanded markup with no chevrons (template +unit tests, hypothetical bare `SchemaForm` consumers). Present ⇒ stage, +group, subgroup, and array templates render the header anatomy below and +gate their content on `isExpanded(pointer)`. + +Collapsible sections stamp DOM data attributes instead of registering refs: +`data-config-section`/`data-config-header` + `data-config-pointer`. The +sticky engine reads them with `querySelectorAll` — robust to rjsf re-renders, +zero plumbing beyond formContext. + +## Header anatomy (the per-object toolbar) + +``` +[chevron + title ......................... trailing action zone] +``` + +- Chevron (`ChevronRight`/`ChevronDown`) + title form ONE disclosure button + (`aria-expanded`, `aria-controls` on the content region) — same pattern as + the panel section headers. +- The trailing zone is a `flex items-center gap-1` slot reserved for + object-local actions. The array template's Add button moves into it now; + Reset-to-defaults / Show-JSON migrate in a later slice (they are global + toolbar actions today). +- Stage descriptions/gs-comments render only when expanded (the header row + stays one line when collapsed). + +## Sticky engine (auto-expand on scroll) + +Owned by `useConfigCollapse(scrollRootRef, { sticky, focusRootPointer })`: + +1. On scroll (rAF-throttled) query `[data-config-header]` in DOM order. +2. The **candidate** is the last header whose top sits at/above the focus + line (scroll-viewport top + 56px offset); with none above it, the first + header. +3. `activeChain` = the candidate pointer plus all its pointer prefixes + (ancestors are derivable from the pointer string — no tree bookkeeping). + Cascade follows naturally: expanding a parent reveals child headers; + scrolling onto a child header deepens the chain. +4. Recompute only when the chain actually changes (set equality) to avoid + re-render storms. +5. **Scroll anchoring:** before applying a chain change, record the + candidate header's viewport offset; in a layout effect after render, + restore it by adjusting `scrollTop` — collapsing content above the focus + line must not visually teleport the header the user is reading. + +`computeActiveChain(headerPointersInDomOrder, headerTops, focusLine)` is a +pure exported helper with its own unit test; the DOM/scroll glue is verified +live. + +## Decisions + +- **DOM-driven engine over a ref registry** — rjsf re-renders templates + freely; data attributes survive that without lifecycle bookkeeping. +- **Map-of-explicit-choices over a Set** — distinguishes "user collapsed the + focused root" from "no choice yet", so defaults never fight the user. +- **No persistence** of expansion or the sticky toggle (session state; + default OFF each load per the user's spec). Revisit only on user ask. +- **Validation visibility:** collapsed sections can hide field errors; rjsf + surfaces submit-time errors elsewhere, and the studio form validates + passively. Accepted for now; a header error badge is a natural follow-up + and slots into the trailing action zone. +- **Arrays collapse too** — they ride the same well tier; excluding them + would leave one expanded-only surface type and break the set. + +## Risks + +- Scroll-jump artifacts in sticky mode → mitigated by the anchoring step; + iterate live (the falsifier for the mechanic is "reading position visibly + teleports while scrolling"). +- rjsf content unmount on collapse is value-safe (values live in form state, + not mounted inputs); collapse therefore must NOT change submitted config — + covered by behavior-parity expectations. diff --git a/openspec/changes/mapgen-studio-config-collapse/proposal.md b/openspec/changes/mapgen-studio-config-collapse/proposal.md new file mode 100644 index 0000000000..466df1f6bd --- /dev/null +++ b/openspec/changes/mapgen-studio-config-collapse/proposal.md @@ -0,0 +1,48 @@ +# Config objects collapse/expand + sticky auto-expand + per-object header + +## Why + +The config form renders every stage, group, and subgroup fully expanded — a +wall of fields with no overview. The user wants config objects collapsed by +default with manual expand, an optional sticky auto-expand-on-scroll mode +(scroll reaches a title bar → it expands; scroll past → it collapses and the +next expands, cascading into nested objects), and a per-object header/action +row that will eventually host object-local actions (Reset to Defaults, Show +JSON) now sitting in the global config toolbar. We own all three rjsf +templates, so nothing blocks full customization. + +## Target Authority Refs + +- `docs/projects/mapgen-studio-redesign/pass-4-design-fixes.md` (E3) +- `apps/mapgen-studio/.interface-design/system.md` (§Pass-4 amendment: + config objects collapse) +- `openspec/changes/mapgen-studio-config-collapse/design.md` (mechanics) + +## What Changes + +- **Per-object header anatomy** in the object/array templates: chevron + + title as one disclosure button, plus a trailing action zone (the array + template's existing Add button moves into it — the first object-local + action on the new anatomy). +- **Collapse state** via an optional `collapse` member on the rjsf + formContext (absent ⇒ templates render expanded with no chevrons, so + template unit tests and any bare `SchemaForm` use are unaffected). State + lives in a `useConfigCollapse` hook owned by `RecipePanel`, keyed by JSON + pointer — pointers are identical in focused and unfocused modes, so + expansion survives mode switches. Default collapsed everywhere; the focused + stage root defaults expanded (focusing a stage means "show me this one"). +- **Sticky auto-expand toggle** (`ListCollapse` icon, `aria-pressed`, + default OFF) in the config actions row. When ON, scroll position drives the + expansion chain (design.md §sticky engine); manual chevrons still work + between scrolls. +- Tests: template-level collapse scenarios (header renders collapsed, content + on expand, no chevrons without context) + a pure unit for the active-chain + computation. + +## Impact + +- Affected specs: `mapgen-studio` +- Affected code: `apps/mapgen-studio/src/features/configOverrides/ + {rjsfTemplates.tsx,SchemaConfigForm.tsx,useConfigCollapse.ts}`, + `apps/mapgen-studio/src/ui/components/RecipePanel.tsx`, tests under + `apps/mapgen-studio/test/config/` diff --git a/openspec/changes/mapgen-studio-config-collapse/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-config-collapse/specs/mapgen-studio/spec.md new file mode 100644 index 0000000000..3b7a776899 --- /dev/null +++ b/openspec/changes/mapgen-studio-config-collapse/specs/mapgen-studio/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Config Objects Collapse With A Per-Object Header + +Every titled config object in the form SHALL render a per-object header row +(stage cards, group and array wells, subgroup headings) — a chevron+title +disclosure button (`aria-expanded`, `aria-controls`) plus a trailing +object-local action zone — and SHALL render collapsed by default with manual +expand, in focused and unfocused modes alike. The focused stage root defaults +expanded. Expansion state is keyed by the object's JSON pointer and survives +switching between modes. Templates rendered without a collapse context (unit +mounts, bare reuse) keep today's always-expanded markup. + +#### Scenario: Objects render collapsed by default +- **WHEN** the config form renders in unfocused mode with no prior expansion choices +- **THEN** every stage card renders as a single header row with its content hidden +- **AND** expanding a stage reveals its loose fields and its child group headers, themselves collapsed + +#### Scenario: Focused stage root is shown, children still collapse +- **WHEN** the form renders in focused mode for a selected stage +- **THEN** that stage renders expanded while its child groups render collapsed until expanded manually + +#### Scenario: Disclosure is accessible +- **WHEN** any config-object header renders +- **THEN** the chevron+title is one button with `aria-expanded` reflecting state and `aria-controls` referencing the content region + +#### Scenario: Array actions live in the header action zone +- **WHEN** a mutable array object renders its header +- **THEN** the Add affordance renders in the header's trailing action zone + +### Requirement: Sticky Auto-Expand Is An Opt-In Config Toolbar Toggle + +The config toolbar SHALL offer an auto-expand-on-scroll toggle +(`aria-pressed`, default OFF). When ON, the object whose header has most +recently passed the focus line expands — cascading along its ancestor chain — +and objects scrolled past collapse; the reading position never visibly jumps. +When OFF, scrolling never changes expansion. + +#### Scenario: Default is manual +- **WHEN** the config form loads fresh +- **THEN** the toggle is off and scrolling changes no expansion state + +#### Scenario: Scroll drives the expansion chain when enabled +- **WHEN** the toggle is on and the user scrolls a collapsed object's header past the focus line +- **THEN** that object (and its ancestors) expand while previously active objects outside the chain collapse diff --git a/openspec/changes/mapgen-studio-config-collapse/tasks.md b/openspec/changes/mapgen-studio-config-collapse/tasks.md new file mode 100644 index 0000000000..880c930c61 --- /dev/null +++ b/openspec/changes/mapgen-studio-config-collapse/tasks.md @@ -0,0 +1,25 @@ +## 1. Implementation + +- [x] 1.1 Extend `BrowserConfigFormContext` with the optional `collapse` + member; absent ⇒ templates keep today's expanded markup (no chevrons). +- [x] 1.2 Per-object header anatomy in object + array templates (chevron+ + title disclosure, trailing action zone, `data-config-*` attributes); + array Add button moves into the action zone. +- [x] 1.3 `useConfigCollapse` hook: explicit-choices map, focused-root + default, sticky engine (rAF scroll → `computeActiveChain` pure helper → + scroll anchoring). +- [x] 1.4 RecipePanel: sticky toggle (`ListCollapse`, `aria-pressed`, + default OFF) in the config actions row; scroll-root ref; wire the hook + through `SchemaConfigForm` into formContext. +- [x] 1.5 Tests: template collapse scenarios (collapsed header, expand + reveals content, no chevrons without context, array Add in action + zone) + `computeActiveChain` unit coverage. + +## 2. Verification + +- [x] 2.1 `bun run openspec -- validate mapgen-studio-config-collapse --strict` +- [x] 2.2 tsc + mapgen-studio vitest green +- [x] 2.3 Visual on :5173 (both modes): collapsed-by-default overview; + manual expand/collapse incl. nested; focused root expanded; sticky + toggle ON walks the chain on scroll without reading-position jumps; + config values unchanged by collapse (behavior parity). Screenshot.