Skip to content

Commit 55bc68d

Browse files
authored
feat(showcase+lint+audit): ADR-0085 #2548 follow-ups — console bump past PR4, detail-shapes e2e, shadowed-group lint, labeled feed summaries (#3029)
Follow-up plan from the #2548 real-backend browser pass (companion objectui#2577): .objectui-sha bumped to 092bd85 so the vendored console includes ADR-0085 PR4; permanent detail-shapes Playwright e2e (four shapes, 3/3 locally); semantic-zoo fixture keeps one non-highlighted member per group + money icon/description + option colors + currencyConfig USD; new field-group-shadowed lint warning; plugin-audit feed summaries use the object display label; ADR-0085 flipped to Accepted with as-built addendum; audit-note out-of-scope section replaced with triaged root causes (DEV_PROXY_TARGET harness misconfig; approvals cookie-only auth fixed in objectui#2577).
1 parent 10a570a commit 55bc68d

12 files changed

Lines changed: 445 additions & 24 deletions
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.

.changeset/console-092bd859934f.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/console": patch
3+
---
4+
5+
Console (objectui) refreshed to `092bd859934f`. Frontend changes in this range:
6+
7+
- fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) (#2573)
8+
- feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582) (#2567)
9+
- fix(detail): gate related lists on the current user's child-object read permission (#2359) (#2565)
10+
- feat(flow-designer): connector picker lists dispatchable connectors + marks declarative instances (#2563)
11+
- feat(app-shell): Studio CEL editor for list-view conditional formatting (#1584 / #1582) (#2558)
12+
- fix(fields): resolve lookup chip display name via referenced object schema, not the autonumber-prone key heuristic (#2357) (#2551)
13+
- feat(kanban): accept CEL { condition, style } conditional-formatting rules (#1584 follow-up) (#2550)
14+
- fix(plugin-grid): sniff CSV encoding in import wizard (GB18030 fallback) (#2557)
15+
- feat(detail): editable record highlights on the shared inline-edit draft (#2549)
16+
- fix(spec-bridge/form): #2545 stop dropping spec FormViewSchema keys; normalize legacy groups → sections (#2552)
17+
- feat(flow-designer): localize palette headings + cloud-sync recents + guide (#2553)
18+
- fix(studio): refresh builder top-bar name after a package rename (#2554)
19+
- feat(core): B3 cascading-option guardrail, role-gated demo, ADR + browser e2e (#1583) (#2547)
20+
- feat(list): unify conditional formatting + row-action visibility onto the CEL engine (#1584) (#2544)
21+
- feat(detail): record-level inline edit — shared InlineEditContext + one atomic Save (#2542)
22+
- feat(flow-designer): search box + keyboard nav + recents in the add-node palette (#2543)
23+
- feat(kanban): default card fields to object highlightFields (ADR-0085, #2162) (#2541)
24+
- fix(types/plugin-grid): #1763 declare spec-canonical bulkActions on ObjectGridSchema (#2539)
25+
- fix(attachments): download attachments via authenticated signed URL (framework #2970)
26+
- feat(studio): spec-driven package create/edit/view form in a modal (#2535)
27+
- fix(permissions/fields): #2926 ④⑧ — FLS fail-open + lookup display_field (#2537)
28+
- feat(app-shell): CEL authoring safety for RLS policies — lint, field autocomplete, test-run (#2533)
29+
- fix(auth): gate DeviceAuthPage on features.deviceAuthorization (framework#2874 / #2513) (#2536)
30+
- fix(app-shell): close view config panel on discard in edit mode (#2320)
31+
- feat(metadata-admin): create form-family views through the View create UI (#2531)
32+
- fix(app-shell): render action's objectName as an object selector (#2325)
33+
- fix(components): exit inline edit mode for injected cell editors (#2534)
34+
- fix(attachments): authenticated uploads + friendly denial copy in RecordAttachmentsPanel (#2755) (#2532)
35+
- feat(components): page:tabs honors item-level visibleWhen — conditional tabs (framework#2606) (#2516)
36+
- feat(metadata-admin): page variable source is a component picker (#2328) (#2523)
37+
- fix(studio-design): make object canvas overridable via studio-canvas-preview registry (#2337) (#2527)
38+
- fix(metadata-admin): seed flow createDefaults with required `type` (#2525)
39+
- fix(metadata-admin): give hook create form a createSchema so object renders as a ref:object picker (#2521)
40+
- feat(studio): enforce package namespace prefix at authoring time (framework#2694) (#2524)
41+
- fix(app-shell): render View create-form Object field as ref:object picker (#2526)
42+
- feat(components): add `variant === 'primary'` tie-break to action:bar ordering (#2339) (#2519)
43+
- fix(build): stop TS6059 rootDir errors in dts build across 21 packages (#2520)
44+
- fix(app-nav): exclude record-detail pages from the 'page' nav picker (#2333) (#2517)
45+
- fix(plugin-report): stop TS6059 rootDir errors in dts build (#2334) (#2518)
46+
- feat(flow-designer): add 'position' xRef picker kind for approval approvers / escalateTo (#2778) (#2515)
47+
48+
objectui range: `cc2156841787...092bd859934f`

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
cc2156841787fa6b6e5fc63613782c2a5bb8dbb9
1+
092bd859934f7bbc2654510aef912062c4c4f3a8

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({

0 commit comments

Comments
 (0)