diff --git a/apps/mapgen-studio/.interface-design/system.md b/apps/mapgen-studio/.interface-design/system.md index 8af921962c..b07c498fae 100644 --- a/apps/mapgen-studio/.interface-design/system.md +++ b/apps/mapgen-studio/.interface-design/system.md @@ -351,3 +351,31 @@ the trailing-disclosure placement; keeps the icon-only contract + zoning): authors the map, Game "Play" launches it) with a **`Rocket`** glyph — launch-the-external-app semantics, replacing `SquareArrowOutUpRight`. `SlidersHorizontal` retired from the header (gear owns setup). + +## P7 amendment (2026-06-12, user-grounded): config explorer v2 — flat nested accordion + responsive chip + Water proof + Custom precedence + +- **Config explorer v2 (supersedes the Pass-3 "group well" tier).** Nested + config objects are a FLAT collapsible object explorer: every object/array + at depth ≥2 is a flush, full-bleed disclosure row — no well cards, no + rounded borders, no side margins, no inter-section gaps. Hairlines come + from the parent container's `divide-y divide-border-subtle`; depth reads + through a compounding `pl-3` indent on each expanded body plus the heading + tier (group eyebrow at depth 2, the dimmer sub-group eyebrow below). Runs + of consecutive scalar fields are the only padded blocks (`px-2.5` + the + sibling gap); array items are hairline rows, never bordered boxes. The + stage slab (`surface-sunken`) remains the single recess tier — surface + count goes DOWN with this amendment, not up. +- **Responsive status chip.** The Game bar's center column is an + `@container`; the signal chip's seed suffix rides a `@max-3xl:hidden` span + so the chip collapses to `Turn N` before the bar wraps. Full + `Turn N · Seed S` stays in the tooltip/accessible name. +- **Water proof section (ExplorePanel).** The river/lake/floodplain + inspector renders as a collapsible section between Step and Layers: lane + eyebrow per truth class, claim status as status-dot + word (`ready` / + `inspect` / `fail` / `open` / `skip`; projection evidence is steel + "inspect", NEVER success-green — projection masks don't masquerade as + engine truth), module-owned data-color dots on the layer-jump chips. +- **Saved-config precedence shows "Custom".** Selection applies the saved + config file EXACTLY (full replace); ANY divergence flips the selector + value itself to a warning-tinted `Custom` entry with a `Re-apply` + affordance beside it. The Modified pill wording is retired. diff --git a/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx b/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx index dfe641e268..9555973d94 100644 --- a/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx +++ b/apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx @@ -33,13 +33,12 @@ export type BrowserConfigFormContext = { // is gone. The theme now follows the single `.dark` class via design-system // tokens (`card`/`muted`/`border`/`accent`/…), so there is no `lightMode` read. const FORM = { - // Group well: nesting is a surface, not an indent (Pass-3 config-surface - // spec). One recess below the stage card — tinted toward the page token so - // groups read as machined slots in the slate. `background` sits below `card` - // in BOTH themes (5%<9% dark, 96%<100% light), so the tint recesses in both. - // Two surface tiers maximum: card → well; deeper nesting adds headings and - // rhythm only, never a third surface. - well: "bg-background/40 border-border-subtle", + // Config explorer v2 (P7 flatten): the old depth-2 "well" cards are + // retired. Nesting is now a FLAT collapsible object explorer — full-bleed + // disclosure rows separated by hairline dividers, depth carried by a + // compounding left indent (`nestIndent`), never by a third surface. The + // stage slab (`surface-sunken`) stays the single recess tier. + nestIndent: "pl-3", divider: "border-border", // Field labels sit a full tier above prose (Pass-2 form hierarchy): labels are // foreground anchors the eye scans; descriptions/help/gs-comments recede on the @@ -55,13 +54,12 @@ const FORM = { groupHeading: "text-label font-semibold uppercase tracking-wider text-muted-foreground", subGroupHeading: "text-label font-semibold uppercase tracking-wider text-muted-foreground/70", // Rhythm on the 4px base (Pass-3): 4px inside a field block, 8px between - // sibling fields. Stage sections carry NO inter-item rhythm (Y4 flatten): - // the accordion is tight — hairline dividers separate rows, not margins. - // Group wells carry `my-1`, composing with the sibling gap inside a slab. + // sibling fields. Object/array sections carry NO inter-item rhythm (Y4 + + // P7 flatten): hairline dividers separate rows, not margins — only runs of + // scalar fields keep the sibling gap inside their padded block. rhythm: { field: "gap-1", siblings: "gap-2", - groupPull: "my-1", }, } as const; @@ -191,6 +189,72 @@ export function BrowserConfigFieldTemplate( ); } +type ObjectProperty = ObjectFieldTemplateProps["properties"][number]; + +type PropertyRun = + | { kind: "fields"; items: ObjectProperty[] } + | { kind: "section"; item: ObjectProperty }; + +/** + * True when a child property renders as its own disclosure section (object or + * array) rather than a scalar field row. Drives the flat-explorer layout: + * sections stack flush (hairline-divided), scalar runs keep the padded + * field rhythm. Unresolvable schemas (refs/unions) default to the field run — + * the safe choice is padding, never a phantom section. + */ +function isSectionProperty(parentSchema: RJSFSchema | undefined, name: string): boolean { + const sub = parentSchema?.properties?.[name]; + if (!sub || typeof sub !== "object") return false; + const type = (sub as RJSFSchema).type; + return type === "object" || type === "array"; +} + +function groupPropertyRuns( + properties: readonly ObjectProperty[], + parentSchema: RJSFSchema | undefined, +): PropertyRun[] { + const runs: PropertyRun[] = []; + for (const property of properties) { + if (property.hidden) continue; + if (isSectionProperty(parentSchema, property.name)) { + runs.push({ kind: "section", item: property }); + continue; + } + const last = runs.at(-1); + if (last?.kind === "fields") last.items.push(property); + else runs.push({ kind: "fields", items: [property] }); + } + return runs; +} + +/** + * Flat-explorer child layout (config explorer v2): consecutive scalar fields + * render as one padded block with the sibling gap; object/array children + * render flush as their own disclosure rows. The container's `divide-y` + * draws the hairline between every neighbor, so sections need no borders of + * their own. + */ +function FlatObjectChildren(args: { + properties: readonly ObjectProperty[]; + schema: RJSFSchema | undefined; + fieldsClass: string; +}): ReactNode { + const runs = groupPropertyRuns(args.properties, args.schema); + return ( +
+ {runs.map((run, index) => + run.kind === "section" ? ( +
{run.item.content}
+ ) : ( +
+ {run.items.map((p) => p.content)} +
+ ), + )} +
+ ); +} + export function BrowserConfigObjectFieldTemplate( props: ObjectFieldTemplateProps ){ @@ -228,22 +292,14 @@ export function BrowserConfigObjectFieldTemplate( const pointer = pathToPointer(path); const expanded = collapse ? collapse.expandedPointers.has(pointer) : true; - const content = ( -
- {!isStage && description ? ( -
{description}
- ) : null} - {properties.filter((p) => !p.hidden).map((p) => p.content)} -
- ); - if (isStage) { // Y4 flatten: stage objects lay FLAT on the panel — no card chrome, no // raised surface. The header is a full-bleed disclosure row; expanding it // opens a RECESSED slab (`surface-sunken` — below the panel, toward the // page) so the interaction reads as a door opening into the graphite, - // not a card inflating off it. Group wells keep their one machined-slot - // tier inside the slab. + // not a card inflating off it. Inside the slab the config explorer v2 + // layout takes over: full-bleed nested disclosure rows, hairline-divided, + // with only scalar-field runs carrying horizontal padding. return (
{collapse ? ( @@ -265,67 +321,50 @@ export function BrowserConfigObjectFieldTemplate( {expanded ? (
- {collapse ? ( -
+ className={`border-t bg-surface-sunken/60 ${FORM.borderSubtle}`}> + {collapse && (description || normalizeGsComments((schema as GsSchemaMeta | null)?.gs?.comments)) ? ( +
{renderGsComments({ schema, className: labelClass })} {description ?
{description}
: null}
) : null} - {content} +
) : null}
); } - // Depth 2: the one well tier inside a stage card. Depth ≥3: no further - // surface — an eyebrow heading and the sibling rhythm carry the structure - // (surface nesting is capped at card → well; see FORM.well). - if (depth === 2) { - return ( -
- {collapse ? ( - - ) : ( -
-
{prettyTitle}
-
- )} - {expanded ?
{content}
: null} -
- ); - } - + // Depth ≥2 (config explorer v2): every nested object is the SAME flat + // disclosure row — no well cards, no side margins, no inter-section gaps. + // The hairlines come from the parent's `divide-y`; depth reads through the + // compounding `nestIndent` on each expanded body plus the heading tier + // (group eyebrow at depth 2, the dimmer sub-group eyebrow below). + const headingClass = depth === 2 ? FORM.groupHeading : FORM.subGroupHeading; return ( -
+
{collapse ? ( ) : ( -
-
{prettyTitle}
+
+
{prettyTitle}
)} - {expanded ?
{content}
: null} + {expanded ? ( +
+ {description ? ( +
{description}
+ ) : null} + +
+ ) : null}
); } @@ -356,14 +395,11 @@ export function BrowserConfigArrayFieldTemplate( ) : 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. + // Arrays ride the same flat disclosure-row anatomy as object sections + // (config explorer v2): no well card, hairline-divided item rows instead + // of bordered item boxes — a box would be a phantom surface tier. return ( -
+
{collapse ? ( ) : ( -
+
{prettyTitle}
{addButton}
)} {expanded ? ( -
- {renderGsComments({ schema, className: labelClass })} -
-
+
+ {renderGsComments({ schema, className: `px-2.5 pb-1.5 ${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}
); diff --git a/apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx b/apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx index 11785c3bc3..497b91f115 100644 --- a/apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx +++ b/apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx @@ -83,17 +83,22 @@ describe("BrowserConfigObjectFieldTemplate nesting surfaces", () => { ); } - it("renders a depth-2 group as a recessed well, not an indent rule", () => { + it("renders a depth-2 group as a flat disclosure row — no well card, no side margins", () => { const html = renderGroup(["foundation", "meshResolution"]); - // Pass-3 config-surface spec: surface tier (page-tint well), no border-l ladder. - expect(html).toContain("bg-background/40"); - expect(html).not.toContain("border-l"); + // Config explorer v2 (P7 flatten): the well surface is retired. Nested + // objects are flush rows; depth reads through the compounding indent. + expect(html).not.toContain("bg-background/40"); + expect(html).not.toContain("rounded-md"); + expect(html).not.toContain("my-1"); + expect(html).toContain("pl-3"); }); - it("adds no third surface tier at depth 3", () => { + it("renders depth 3 with the same flat anatomy on the dimmer heading tier", () => { const html = renderGroup(["foundation", "meshResolution", "advanced"]); expect(html).not.toContain("bg-background/40"); - expect(html).not.toContain("border-l"); + expect(html).not.toContain("rounded-md"); + expect(html).toContain("text-muted-foreground/70"); + expect(html).toContain("pl-3"); }); });