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
70 changes: 52 additions & 18 deletions apps/mapgen-studio/src/features/configOverrides/rjsfTemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export type BrowserConfigFormContext = {
// tokens (`card`/`muted`/`border`/`accent`/…), so there is no `lightMode` read.
const FORM = {
card: "bg-card border-border",
nested: "bg-muted/40 border-border-subtle",
// 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",
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 @@ -25,6 +31,19 @@ const FORM = {
text: "text-foreground",
borderSubtle: "border-border-subtle",
button: "bg-muted text-foreground border-border hover:bg-accent",
// Group headings are eyebrows: the well's geometry carries the grouping, so
// its caption recedes below field labels (the brightest scan line in a card).
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, 12px between groups/stage sections. Group wells carry
// `my-1`, which composes with the sibling gap to the 12px group step.
rhythm: {
field: "gap-1",
siblings: "gap-2",
sections: "gap-3",
groupPull: "my-1",
},
} as const;

function humanizeSchemaLabel(label: string): string {
Expand Down Expand Up @@ -85,7 +104,7 @@ export function BrowserConfigFieldTemplate(

if (!showLabel) {
return (
<div className={["flex flex-col gap-1", classNames].filter(Boolean).join(" ")}>
<div className={[`flex flex-col ${FORM.rhythm.field}`, classNames].filter(Boolean).join(" ")}>
<div className={textClass}>{children}</div>
{description && !suppressDescription ? <div className={`text-data ${labelClass}`}>{description}</div> : null}
{renderGsComments({ schema: props.schema, className: labelClass })}
Expand All @@ -96,7 +115,7 @@ export function BrowserConfigFieldTemplate(
}

return (
<div className={["flex flex-col gap-1", classNames].filter(Boolean).join(" ")}>
<div className={[`flex flex-col ${FORM.rhythm.field}`, classNames].filter(Boolean).join(" ")}>
<FieldRow>
<label className={`text-data min-w-[96px] ${FORM.fieldLabel}`} htmlFor={id}>
<span className="font-medium">{prettyLabel}</span>
Expand Down Expand Up @@ -127,7 +146,11 @@ export function BrowserConfigObjectFieldTemplate(
const textClass = FORM.text;

if (isRoot) {
return <div className="flex flex-col gap-2">{properties.filter((p) => !p.hidden).map((p) => p.content)}</div>;
return (
<div className={`flex flex-col ${FORM.rhythm.sections}`}>
{properties.filter((p) => !p.hidden).map((p) => p.content)}
</div>
);
}

if (isTransparent) {
Expand All @@ -138,7 +161,7 @@ export function BrowserConfigObjectFieldTemplate(
const isStage = depth === 1;

const content = (
<div className="flex flex-col gap-1.5">
<div className={`flex flex-col ${FORM.rhythm.siblings}`}>
{!isStage && description ? (
<div className={`text-data ${labelClass}`}>{description}</div>
) : null}
Expand All @@ -160,17 +183,26 @@ export function BrowserConfigObjectFieldTemplate(
);
}

const headingClass = `${depth >= 3 ? "text-data" : "text-xs"} font-semibold ${textClass}`;
const inlineBorder = `${depth >= 3 ? "pl-2" : "pl-2.5"} border-l ${FORM.borderSubtle}`;
const groupWrapper = `flex flex-col gap-0.5`;
// 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}`}>
<header className="pb-1.5">
<div className={FORM.groupHeading}>{prettyTitle}</div>
</header>
{content}
</section>
);
}

return (
<section className={groupWrapper}>
<section className={`flex flex-col gap-1 ${FORM.rhythm.groupPull}`}>
<header>
<div className={headingClass}>{prettyTitle}</div>
<div className={FORM.subGroupHeading}>{prettyTitle}</div>
</header>
<div className={inlineBorder}>
{content}
</div>
{content}
</section>
);
}
Expand All @@ -181,13 +213,15 @@ export function BrowserConfigArrayFieldTemplate(
const { title, items, canAdd, onAddClick, disabled, readonly, schema } = props;
const prettyTitle = title ? humanizeSchemaLabel(title) : "Items";
const allowMutations = !disabled && !readonly;
const textClass = FORM.text;
const labelClass = FORM.label;

// 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 (
<section className={`rounded-md border p-2 ${FORM.nested}`}>
<section className={`rounded-md border p-2 ${FORM.rhythm.groupPull} ${FORM.well}`}>
<div className="flex items-center gap-2">
<div className={`text-xs font-semibold ${textClass}`}>{prettyTitle}</div>
<div className={FORM.groupHeading}>{prettyTitle}</div>
<div style={{ flex: 1 }} />
{canAdd && allowMutations ? (
<button
Expand All @@ -201,13 +235,13 @@ export function BrowserConfigArrayFieldTemplate(
</div>
{renderGsComments({ schema, className: labelClass })}
<div className={`my-2 border-t ${FORM.divider}`} />
<div className="flex flex-col gap-2">
<div className={`flex flex-col ${FORM.rhythm.siblings}`}>
{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-md border p-2 ${FORM.nested}`}>
<div key={item.key ?? index} className={`rounded border p-2 ${FORM.borderSubtle}`}>
{content}
</div>
);
Expand Down
34 changes: 34 additions & 0 deletions apps/mapgen-studio/test/config/rjsfFieldTemplateErrors.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { FieldTemplateProps, RJSFSchema } from "@rjsf/utils";

import type { ObjectFieldTemplateProps } from "@rjsf/utils";

import {
BrowserConfigFieldTemplate,
BrowserConfigObjectFieldTemplate,
type BrowserConfigFormContext,
} from "../../src/features/configOverrides/rjsfTemplates";

Expand Down Expand Up @@ -56,3 +59,34 @@ describe("BrowserConfigFieldTemplate error live regions", () => {
expect(html).toMatch(/text-muted-foreground/);
});
});

describe("BrowserConfigObjectFieldTemplate nesting surfaces", () => {
type ObjectProps = ObjectFieldTemplateProps<unknown, RJSFSchema, BrowserConfigFormContext>;

function renderGroup(path: (string | number)[]): string {
const props = {
title: "Mesh Resolution",
description: undefined,
properties: [{ hidden: false, content: <div>field</div>, name: "x" }],
fieldPathId: { path, id: path.join("_") },
schema: { type: "object" } as RJSFSchema,
registry: { formContext: { transparentPaths: new Set<string>() } },
};
return renderToStaticMarkup(
<BrowserConfigObjectFieldTemplate {...(props as unknown as ObjectProps)} />
);
}

it("renders a depth-2 group as a recessed well, not an indent rule", () => {
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");
});

it("adds no third surface tier at depth 3", () => {
const html = renderGroup(["foundation", "meshResolution", "advanced"]);
expect(html).not.toContain("bg-background/40");
expect(html).not.toContain("border-l");
});
});
16 changes: 8 additions & 8 deletions openspec/changes/mapgen-studio-config-surface/tasks.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
## 1. Implementation

- [ ] 1.1 Codify `FORM` rhythm + well surface classes in `rjsfTemplates.tsx`
- [x] 1.1 Codify `FORM` rhythm + well surface classes in `rjsfTemplates.tsx`
(fieldGap 4 / siblingGap 8 / groupGap 12; well = page-tint + subtle
border + rounded + p-2).
- [ ] 1.2 Object template: depth≥2 groups render as wells (drop `border-l` +
- [x] 1.2 Object template: depth≥2 groups render as wells (drop `border-l` +
`pl-*` ladder); depth≥3 adds heading + rhythm only; headings move to the
eyebrow tier.
- [ ] 1.3 Array template: unify onto the well treatment.
- [ ] 1.4 Apply rhythm to root/stage content gaps.
- [ ] 1.5 Update `rjsfFieldTemplateErrors.test.tsx` assertions if class names
- [x] 1.3 Array template: unify onto the well treatment.
- [x] 1.4 Apply rhythm to root/stage content gaps.
- [x] 1.5 Update `rjsfFieldTemplateErrors.test.tsx` assertions if class names
shifted; add a well/no-indent assertion for a nested group.

## 2. Verification

- [ ] 2.1 `bun run openspec -- validate mapgen-studio-config-surface --strict`
- [ ] 2.2 tsc + mapgen-studio vitest green
- [ ] 2.3 Visual on :5173, dark AND light: stage card → group well reads as two
- [x] 2.1 `bun run openspec -- validate mapgen-studio-config-surface --strict`
- [x] 2.2 tsc + mapgen-studio vitest green
- [x] 2.3 Visual on :5173, dark AND light: stage card → group well reads as two
recess steps (squint test shows chunking); no left-rule indents; rhythm
visibly tiered (field < sibling < group). Screenshot both themes.