Skip to content

Commit db51db0

Browse files
committed
feat(showcase+lint+audit): ADR-0085 #2548 follow-ups — console bump to PR4, detail-shapes e2e, shadowed-group lint, labeled feed summaries
Lands the follow-up plan from the #2548 real-backend browser pass: - examples/app-showcase: semantic-zoo gains one NON-highlighted member per group (code → basics, budget → money) so grouped detail layout — including Money's collapse:'collapsed' — is exercised in-browser instead of vanishing under the highlight strip; money group declares icon/description (served- pipeline dogfood for the §5 derivation passthrough); legacy-zoo options pin badge colors; budget/annual_revenue declare currencyConfig.defaultCurrency 'USD' (the spec channel — a bare `currency` key is stripped at parse) so currency rendering shows a real symbol. - e2e: new detail-shapes.spec.ts — permanent Playwright regression for the four ADR-0085 detail shapes against the vendored console (3/3 green locally against `serve --dev`). - @objectstack/lint: new `field-group-shadowed` warning — a declared group whose every visible member is hoisted into the detail highlight strip (or is the record title) renders on forms but never on detail pages. - @objectstack/plugin-audit: user-facing feed summaries name the object by its display label, not its API name. - docs: ADR-0085 flipped Proposed → Accepted with an as-built addendum; the detail-shapes audit note's out-of-scope section replaced with the triaged root causes (DEV_PROXY_TARGET harness misconfig; approvalsApi cookie-only auth — fixed objectui-side) and the runbook corrected. - dogfood: semantic-roles test updated for the fixture (money fields, icon/ description passthrough, currencyConfig round-trip). Verification: lint 209 ✓, plugin-audit 32 ✓, dogfood semantic-roles 5/5 ✓, showcase verify (validate+typecheck+55 tests) ✓, detail-shapes e2e 3/3 ✓. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wpaFMPowSn4nAWsjeGZib
1 parent 7eaa2cf commit db51db0

10 files changed

Lines changed: 396 additions & 23 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/lint': patch
3+
'@objectstack/plugin-audit': patch
4+
---
5+
6+
ADR-0085 #2548 follow-ups surfaced by the real-backend browser pass:
7+
8+
- **lint**: new `field-group-shadowed` warning in `validate-semantic-roles` — a
9+
declared fieldGroup whose every visible member is hoisted into the detail
10+
highlight strip (or is the record title) renders on forms but silently never
11+
on detail pages (detail bodies hide the first 4 highlightFields). Warning
12+
tier, same as the other semantic-role rules.
13+
- **plugin-audit**: feed/audit summaries ("Created … / Deleted … / Updated …")
14+
now name the object by its display label ("Semantic Zoo") instead of its API
15+
name ("showcase_semantic_zoo") — these strings render verbatim in the record
16+
Discussion feed and Setup dashboards. Falls back to the API name when the
17+
object definition isn't resolvable. Existing stored rows are unchanged.

docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0085: Presentation intent on an object is declared as cross-surface semantic roles, never as per-surface hint blocks (delete `detail`, retire `views.*`, type `stageField`)
22

3-
**Status**: Proposed (2026-07-02)
3+
**Status**: Accepted (2026-07-16) — proposed 2026-07-02; execution complete through PR4 + real-backend verification, see the as-built addendum
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove trichotomy), [ADR-0078](./0078-no-silently-inert-metadata.md) (completeness gate; "AI is the author, silent no-op manufactures false completion"), [ADR-0032](./0032-unified-expression-layer.md) (validate-by-default, no silent failure), ADR-0079 (`nameField` — the canonical object-level semantic-role precedent)
66
**Supersedes**: the `detail.*` passthrough-block approach adopted by [objectui#2065](https://github.com/objectstack-ai/objectui/issues/2065) and proposed in [objectui#2148](https://github.com/objectstack-ai/objectui/issues/2148) §方案-6 (that mechanism was the right escape hatch *given* the spec of the day; this ADR replaces the mechanism, not the goals — both issues' goals ship)
@@ -103,3 +103,15 @@ Warnings (fragile): `Field.group` references an undeclared group key; a declared
103103
- **Add `highlightFields` alongside `compactLayout`** — rejected. Two curated "important fields" lists drift; Salesforce solved this the same way: the compact layout drives the highlights panel. (Resolved instead as a rename: one list, the clearer name.)
104104
- **Keep the `compactLayout` name** — rejected. The name claims a structure the value doesn't have, misses its own biggest consumer (default list columns), and mismatches the renderer vocabulary that already says "highlight" everywhere. A protocol whose keys are named for what they actually do is itself an AI guardrail; the rename costs one alias (ADR-0079 machinery already exists) and a mechanical in-repo sweep.
105105
- **Deprecation window for `views.*`** — rejected as cargo cult: there is no author to deprecate for. Windows are for populations, not for ghosts.
106+
107+
## As-built addendum (2026-07-16)
108+
109+
Execution landed across both repos; differences from the proposal are noted inline.
110+
111+
- **Roles & rename**`highlightFields` shipped with the ADR-0079 alias machinery; the `compactLayout` alias was then fully retired by framework#2536 (served metadata now carries the canonical key only). `stageField` is spec-typed including the strict-`false` suppression contract (renderer side objectui#2168).
112+
- **§5 shared derivation**`deriveFieldGroupLayout` lives in `@objectstack/spec` (`packages/spec/src/data/field-group-layout.ts`) and passes group `icon`/`description`/`collapse` through; objectui form/detail synth consume it (the two pre-existing near-copies were deleted).
113+
- **§6 guardrails**`@objectstack/lint` `validate-semantic-roles`: undeclared `Field.group` reference, declared-but-unreferenced group, dangling `stageField`/`highlightFields` pointers; plus `field-group-shadowed` (added by the #2548 follow-up): a group whose every visible member is hoisted into the detail highlight strip (or is the record title) renders on forms but never on detail pages.
114+
- **PR4 (legacy-path deletion)**objectui#2546 removed the monolith `DetailView` branch in `RecordDetailView` together with the `detail.renderViaSchema` kill-switch and the `?renderViaSchema=0` debug param; schema-driven is the only path.
115+
- **Cross-surface consumption** — kanban default card fields ride `highlightFields` (objectui#2541), alongside grids/lists/detail strip.
116+
- **Verification** — parse: `@objectstack/spec` suite; served pipeline: `packages/dogfood/test/semantic-roles.dogfood.test.ts`; real-backend browser pass over the four detail shapes (grouped / ungrouped / `stageField: false` / related-heavy): framework#3019, runbook + results in `docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md`, and a permanent Playwright spec in `examples/app-showcase/e2e/detail-shapes.spec.ts`.
117+
- **Consequence surfaced by the browser pass** — because detail bodies hide strip fields, a fully-highlighted group silently disappears from detail pages. Judged working-as-intended (one curated list, every surface) but author-surprising — hence the `field-group-shadowed` warning and the semantic-zoo fixture keeping one non-highlighted member per group.

docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,12 @@ curl -s localhost:4600/api/v1/health # -> {"status":"ok"}
4444

4545
# 2. Console — objectui source (post-#2546) pointed at the backend. CORS is
4646
# enabled in dev, so the cross-origin :5180 -> :4600 calls are allowed.
47+
# DEV_PROXY_TARGET matters too: relative-path calls (/api/v1/runtime/config,
48+
# /api/v1/dev/metadata-events) go through the Vite /api proxy, which
49+
# defaults to the dead :3000 without it.
4750
cd ../objectui
48-
VITE_SERVER_URL=http://localhost:4600 pnpm --filter @object-ui/console dev # :5180
51+
VITE_SERVER_URL=http://localhost:4600 DEV_PROXY_TARGET=http://localhost:4600 \
52+
pnpm --filter @object-ui/console dev # :5180
4953

5054
# 3. Auth + fixtures (better-auth Bearer). Sign in, grab the set-auth-token
5155
# header, POST the two semantic-zoo records (not seeded), then drive the
@@ -82,16 +86,24 @@ highlighted `status/industry/revenue` do not repeat in its Details body. The
8286
untitled bucket) is separately proven over the served pipeline in
8387
`semantic-roles.dogfood.test.ts`.
8488

85-
## Out-of-scope observations (not defects in the detail path)
86-
87-
- `502 /api/v1/runtime/config` and `502 /api/v1/dev/metadata-events` are
88-
**same-origin** requests the Console makes to its own origin (`:5180`); in the
89-
split-origin verify harness the Vite server has nothing there. `initRuntimeConfig`
90-
absorbs the failure by design and the app boots + renders regardless. Not seen
91-
in the normal same-origin (`--ui` / vendored console) deployment.
92-
- `401 /api/v1/approvals/requests?…` (the header approvals badge) is a peripheral
93-
poll unrelated to record rendering; the detail pages loaded their data fine.
94-
Left for separate triage.
89+
## Out-of-scope observations (triaged in the #2548 follow-up)
90+
91+
- `502 /api/v1/runtime/config` / `502 /api/v1/dev/metadata-events`**harness
92+
misconfiguration, not a product defect**. The console dev server proxies
93+
`/api` to `DEV_PROXY_TARGET` (default `http://localhost:3000`); this pass set
94+
only `VITE_SERVER_URL`, so relative-path calls hit the proxy's dead default.
95+
Correct invocation (now also in the runbook above):
96+
`VITE_SERVER_URL=http://localhost:<port> DEV_PROXY_TARGET=http://localhost:<port> pnpm --filter @object-ui/console dev`.
97+
In production the SPA and runtime share an origin, so relative paths are by
98+
design. `initRuntimeConfig` absorbed the failure and the app rendered anyway.
99+
- `401 /api/v1/approvals/requests?…` (header approvals badge) — **real latent
100+
defect for split-origin deployments**: `approvalsApi` authenticated with
101+
cookies only (`credentials: 'include'`) while every other console API call
102+
sends the Bearer token, so any deployment where the SameSite cookie doesn't
103+
flow (custom-domain console ↔ API split, or this token-injected harness)
104+
loses the approvals surface. Fixed in the objectui #2548 follow-up —
105+
`approvalsApi` now attaches the stored Bearer token like the rest of the
106+
console.
95107

96108
## Conclusion
97109

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* ADR-0085 detail-shape regression (#2548) — permanent form of the one-off
5+
* real-backend browser verification that closed objectui#2546 (PR4: legacy
6+
* monolith detail renderer + `renderViaSchema` kill-switch removed).
7+
*
8+
* Four shapes, one invariant each, all through the single SchemaRenderer /
9+
* `buildDefaultPageSchema` path the console now has:
10+
* - grouped → fieldGroup sections render; `collapse: 'collapsed'`
11+
* actually collapses (Money hides `budget` until opened)
12+
* - ungrouped → flat details body, no section cards
13+
* - stageField:false → NO `record:path` stepper despite a `status` field
14+
* named to bait the heuristic
15+
* - related-heavy → related lists surface as tabs (prominence rule)
16+
*
17+
* Runs against the console the backend serves at /_console (baseURL from
18+
* playwright.config.ts; storageState carries the admin session). The
19+
* semantic-zoo fixtures are objects but not seeded records — each run
20+
* creates its own rows via the REST API.
21+
*
22+
* NOTE: assertions are scoped to what the *pinned* console build supports
23+
* (.objectui-sha). Group icon/description chrome ships with a later objectui
24+
* bump — extend the grouped case then.
25+
*/
26+
27+
const APP = process.env.SHOWCASE_APP || 'com.example.showcase';
28+
const API = process.env.SMOKE_API_URL || 'http://localhost:3000';
29+
const recordUrl = (object: string, id: string) =>
30+
`/_console/apps/${APP}/${object}/record/${encodeURIComponent(id)}`;
31+
32+
const PATH_STEPPER = '[aria-label="Record path"]';
33+
34+
let zooId = '';
35+
let zooLegacyId = '';
36+
let contosoId = '';
37+
38+
test.beforeAll(async ({ request }) => {
39+
const createRecord = async (object: string, data: Record<string, unknown>) => {
40+
const res = await request.post(`${API}/api/v1/data/${object}`, { data });
41+
expect(res.ok(), `create ${object} failed: ${res.status()} ${await res.text()}`).toBeTruthy();
42+
const body = (await res.json()) as any;
43+
const id = body.id ?? body.record?.id ?? body.data?.id;
44+
expect(id, `no id returned creating ${object}`).toBeTruthy();
45+
return String(id);
46+
};
47+
48+
zooId = await createRecord('showcase_semantic_zoo', {
49+
name: 'E2E Zoo Grouped',
50+
status: 'active',
51+
code: 'ZG-1',
52+
amount: 4200,
53+
budget: 100000,
54+
notes: 'detail-shapes e2e fixture',
55+
});
56+
zooLegacyId = await createRecord('showcase_semantic_zoo_legacy', {
57+
name: 'E2E Zoo Legacy',
58+
status: 'green',
59+
amount: 99,
60+
});
61+
62+
// Contoso is seeded with 2 contacts / 2 invoices / 2 projects — the
63+
// related-heavy shape. Resolve its id instead of assuming one.
64+
const res = await request.get(`${API}/api/v1/data/showcase_account?%24top=50`);
65+
expect(res.ok(), `account list failed: ${res.status()}`).toBeTruthy();
66+
const rows: any[] = ((await res.json()) as any).records ?? [];
67+
const contoso = rows.find((r) => r?.name === 'Contoso');
68+
expect(contoso, 'seeded account "Contoso" not found').toBeTruthy();
69+
contosoId = String(contoso.id);
70+
});
71+
72+
async function openRecord(page: import('@playwright/test').Page, url: string) {
73+
const pageErrors: string[] = [];
74+
page.on('pageerror', (e) => pageErrors.push(e.message));
75+
await page.goto(url, { waitUntil: 'domcontentloaded' });
76+
await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 });
77+
await page.waitForTimeout(1500);
78+
return pageErrors;
79+
}
80+
81+
test('grouped: fieldGroup sections render and Money starts collapsed', async ({ page }) => {
82+
const errors = await openRecord(page, recordUrl('showcase_semantic_zoo', zooId));
83+
84+
// Stepper from stageField: 'status'.
85+
// record:path renders a desktop + a mobile variant — assert the visible one.
86+
await expect(page.locator(PATH_STEPPER).first()).toBeVisible();
87+
88+
// Declared groups render as titled sections. Their highlighted members
89+
// (status/amount) are hoisted to the strip; the non-highlighted members
90+
// (code/budget) keep the groups visible in the body.
91+
await expect(page.getByText('Basics', { exact: true })).toBeVisible();
92+
await expect(page.getByText('Money', { exact: true })).toBeVisible();
93+
await expect(page.getByText('ZG-1')).toBeVisible();
94+
95+
// collapse: 'collapsed' — Budget stays hidden until the header is opened.
96+
await expect(page.getByText('Budget', { exact: true })).toHaveCount(0);
97+
await page.getByText('Money', { exact: true }).click();
98+
await expect(page.getByText('Budget', { exact: true })).toBeVisible();
99+
100+
expect(errors, 'uncaught page errors on grouped detail').toEqual([]);
101+
});
102+
103+
test('stageField:false: status renders as a plain field, no stepper', async ({ page }) => {
104+
const errors = await openRecord(
105+
page,
106+
recordUrl('showcase_semantic_zoo_legacy', zooLegacyId),
107+
);
108+
109+
// The whole point of `stageField: false`: the status-named select must NOT
110+
// become a chevron path. (objectui#2168 pinned strict-false handling.)
111+
await expect(page.locator(PATH_STEPPER)).toHaveCount(0);
112+
// …but the field itself still renders (as a value, not a lifecycle).
113+
await expect(page.getByText('Green').first()).toBeVisible();
114+
115+
expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]);
116+
});
117+
118+
test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => {
119+
const errors = await openRecord(page, recordUrl('showcase_account', contosoId));
120+
121+
// Heuristic stepper (account.status is a select named "status").
122+
// record:path renders a desktop + a mobile variant — assert the visible one.
123+
await expect(page.locator(PATH_STEPPER).first()).toBeVisible();
124+
125+
// Related lists surface as tabs (ADR-0085 prominence rule).
126+
const detailsTab = page.getByRole('tab', { name: /Details/i });
127+
await expect(detailsTab).toBeVisible();
128+
const invoicesTab = page.getByRole('tab', { name: /Invoices/i });
129+
const projectsTab = page.getByRole('tab', { name: /Projects/i });
130+
await expect(invoicesTab).toBeVisible();
131+
await expect(projectsTab).toBeVisible();
132+
133+
// Ungrouped shape: the Details body is flat — no fieldGroup section cards.
134+
// (Contoso declares no fieldGroups; spot-check a body field label.)
135+
await expect(page.getByText('Basics', { exact: true })).toHaveCount(0);
136+
137+
// Related lists self-fetch lazily when their tab is shown.
138+
await invoicesTab.click();
139+
const panel = page.getByRole('tabpanel');
140+
await expect(panel).toBeVisible();
141+
await page.waitForTimeout(1500);
142+
const panelText = (await panel.innerText().catch(() => '')) || '';
143+
expect(panelText.trim().length, 'invoices tab rendered no content').toBeGreaterThan(0);
144+
145+
expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]);
146+
});

examples/app-showcase/src/data/objects/account.object.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,16 @@ export const Account = ObjectSchema.create({
5252
{ label: 'Retail', value: 'retail' },
5353
],
5454
}),
55-
annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2, min: 0 }),
55+
// Explicit ISO code (spec channel: `currencyConfig.defaultCurrency`): a
56+
// currency field without a resolvable code renders as a bare grouped
57+
// number by design (no guessed symbol) — with it, the detail highlight
58+
// strip and grids show "$25,000,000" instead of "25,000,000".
59+
annual_revenue: Field.currency({
60+
label: 'Annual Revenue',
61+
scale: 2,
62+
min: 0,
63+
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
64+
}),
5665
website: Field.url({ label: 'Website' }),
5766
hq: Field.location({ label: 'Headquarters' }),
5867
status: Field.select({

examples/app-showcase/src/data/objects/semantic-zoo.object.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,40 @@ export const SemanticZoo = ObjectSchema.create({
4242
],
4343
group: 'basics',
4444
}),
45+
// `code`/`budget` are deliberately NOT in highlightFields: detail pages
46+
// hide highlighted fields from the body, so a group whose every member is
47+
// highlighted never renders there (the `field-group-shadowed` lint). Each
48+
// group keeps one non-highlighted member so the grouped detail layout —
49+
// including Money's `collapse: 'collapsed'` — is actually exercised
50+
// in-browser (#2548 follow-up), not just at parse time.
51+
code: Field.text({ label: 'Code', group: 'basics' }),
4552
amount: Field.number({ label: 'Amount', group: 'money' }),
53+
// Explicit ISO currency (spec channel: `currencyConfig.defaultCurrency`;
54+
// a bare `currency` key is NOT on the field schema and gets stripped at
55+
// parse). Without a resolvable code, renderers deliberately show a bare
56+
// grouped number — never a guessed symbol.
57+
budget: Field.currency({
58+
label: 'Budget',
59+
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
60+
group: 'money',
61+
}),
4662
notes: Field.textarea({ label: 'Notes' }),
4763
},
4864

4965
highlightFields: ['name', 'status', 'amount'],
5066
stageField: 'status',
5167
fieldGroups: [
5268
{ key: 'basics', label: 'Basics' },
53-
{ key: 'money', label: 'Money', collapse: 'collapsed' },
69+
// icon/description ride the served pipeline into the shared
70+
// deriveFieldGroupLayout output (ADR-0085 §5) — declared here so the
71+
// detail/form section chrome for them is dogfooded end-to-end.
72+
{
73+
key: 'money',
74+
label: 'Money',
75+
icon: 'banknote',
76+
description: 'Financial fields — collapsed by default.',
77+
collapse: 'collapsed',
78+
},
5479
],
5580
});
5681

@@ -68,12 +93,14 @@ export const SemanticZooLegacy = ObjectSchema.create({
6893
fields: {
6994
name: Field.text({ label: 'Name', required: true }),
7095
// Named `status` ON PURPOSE: the stepper heuristic would pick it up —
71-
// `stageField: false` below is what keeps it suppressed.
96+
// `stageField: false` below is what keeps it suppressed. Option `color`
97+
// pins the badge tint (spec-typed; without it renderers fall back to a
98+
// value-hash palette, which colored "Green" amber).
7299
status: Field.select({
73100
label: 'Status',
74101
options: [
75-
{ label: 'Red', value: 'red', default: true },
76-
{ label: 'Green', value: 'green' },
102+
{ label: 'Red', value: 'red', color: 'red', default: true },
103+
{ label: 'Green', value: 'green', color: 'green' },
77104
],
78105
}),
79106
amount: Field.number({ label: 'Amount' }),

0 commit comments

Comments
 (0)