Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/mapgen-studio/.interface-design/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
186 changes: 111 additions & 75 deletions apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -191,6 +189,72 @@ export function BrowserConfigFieldTemplate(
);
}

type ObjectProperty = ObjectFieldTemplateProps<unknown, RJSFSchema, BrowserConfigFormContext>["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 (
<div className="flex flex-col divide-y divide-border-subtle">
{runs.map((run, index) =>
run.kind === "section" ? (
<div key={run.item.name ?? index}>{run.item.content}</div>
) : (
<div key={index} className={`flex flex-col ${FORM.rhythm.siblings} ${args.fieldsClass}`}>
{run.items.map((p) => p.content)}
</div>
),
)}
</div>
);
}

export function BrowserConfigObjectFieldTemplate(
props: ObjectFieldTemplateProps<unknown, RJSFSchema, BrowserConfigFormContext>
){
Expand Down Expand Up @@ -228,22 +292,14 @@ export function BrowserConfigObjectFieldTemplate(
const pointer = pathToPointer(path);
const expanded = collapse ? collapse.expandedPointers.has(pointer) : true;

const content = (
<div className={`flex flex-col ${FORM.rhythm.siblings}`}>
{!isStage && description ? (
<div className={`text-data ${labelClass}`}>{description}</div>
) : null}
{properties.filter((p) => !p.hidden).map((p) => p.content)}
</div>
);

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 (
<section data-config-section="" data-config-pointer={pointer}>
{collapse ? (
Expand All @@ -265,67 +321,50 @@ export function BrowserConfigObjectFieldTemplate(
{expanded ? (
<div
id={collapse ? configContentId(pointer) : undefined}
className={`border-t bg-surface-sunken/60 px-2.5 pb-2.5 pt-2 ${FORM.borderSubtle}`}>
{collapse ? (
<div className="flex flex-col gap-1 pb-1.5">
className={`border-t bg-surface-sunken/60 ${FORM.borderSubtle}`}>
{collapse && (description || normalizeGsComments((schema as GsSchemaMeta | null)?.gs?.comments)) ? (
<div className="flex flex-col gap-1 px-2.5 pt-2 pb-1.5">
{renderGsComments({ schema, className: labelClass })}
{description ? <div className={`text-data ${labelClass}`}>{description}</div> : null}
</div>
) : null}
{content}
<FlatObjectChildren properties={properties} schema={schema} fieldsClass="px-2.5 py-2" />
</div>
) : null}
</section>
);
}

// 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 (
<section
className={`rounded-md border p-2 ${FORM.rhythm.groupPull} ${FORM.well}`}
data-config-section=""
data-config-pointer={pointer}>
{collapse ? (
<CollapsibleHeader
pointer={pointer}
title={prettyTitle}
titleClass={FORM.groupHeading}
expanded={expanded}
collapse={collapse}
className={expanded ? "pb-1.5" : undefined}
/>
) : (
<header className="pb-1.5">
<div className={FORM.groupHeading}>{prettyTitle}</div>
</header>
)}
{expanded ? <div id={collapse ? configContentId(pointer) : undefined}>{content}</div> : null}
</section>
);
}

// 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 (
<section
className={`flex flex-col gap-1 ${FORM.rhythm.groupPull}`}
data-config-section=""
data-config-pointer={pointer}>
<section data-config-section="" data-config-pointer={pointer}>
{collapse ? (
<CollapsibleHeader
pointer={pointer}
title={prettyTitle}
titleClass={FORM.subGroupHeading}
titleClass={headingClass}
expanded={expanded}
collapse={collapse}
className="px-2.5 py-1.5 hover:bg-muted/20 transition-colors"
/>
) : (
<header>
<div className={FORM.subGroupHeading}>{prettyTitle}</div>
<header className="px-2.5 py-1.5">
<div className={headingClass}>{prettyTitle}</div>
</header>
)}
{expanded ? <div id={collapse ? configContentId(pointer) : undefined}>{content}</div> : null}
{expanded ? (
<div id={collapse ? configContentId(pointer) : undefined} className={FORM.nestIndent}>
{description ? (
<div className={`text-data px-2.5 pb-1.5 ${labelClass}`}>{description}</div>
) : null}
<FlatObjectChildren properties={properties} schema={schema} fieldsClass="px-2.5 pb-2 pt-1" />
</div>
) : null}
</section>
);
}
Expand Down Expand Up @@ -356,41 +395,38 @@ export function BrowserConfigArrayFieldTemplate(
</button>
) : 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 (
<section
className={`rounded-md border p-2 ${FORM.rhythm.groupPull} ${FORM.well}`}
data-config-section=""
data-config-pointer={pointer}>
<section data-config-section="" data-config-pointer={pointer}>
{collapse ? (
<CollapsibleHeader
pointer={pointer}
title={prettyTitle}
titleClass={FORM.groupHeading}
expanded={expanded}
collapse={collapse}
className="px-2.5 py-1.5 hover:bg-muted/20 transition-colors"
actions={addButton}
/>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-2.5 py-1.5">
<div className={FORM.groupHeading}>{prettyTitle}</div>
<div style={{ flex: 1 }} />
{addButton}
</div>
)}
{expanded ? (
<div id={collapse ? configContentId(pointer) : undefined}>
{renderGsComments({ schema, className: labelClass })}
<div className={`my-2 border-t ${FORM.divider}`} />
<div className={`flex flex-col ${FORM.rhythm.siblings}`}>
<div id={collapse ? configContentId(pointer) : undefined} className={FORM.nestIndent}>
{renderGsComments({ schema, className: `px-2.5 pb-1.5 ${labelClass}` })}
<div className="flex flex-col divide-y divide-border-subtle">
{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 (
<div key={item.key ?? index} className={`rounded border p-2 ${FORM.borderSubtle}`}>
<div key={item.key ?? index} className="px-2.5 py-2">
{content}
</div>
);
Expand Down
17 changes: 11 additions & 6 deletions apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down