From 180845dd635a3acd24132f418fbbe6cc1e2d2fa7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:38:34 +0000 Subject: [PATCH 1/4] fix(record-page): one date format, appends aligned with the page cards, comment text in the foreground colour A record:details section row writes the year on a current-year date, as the highlights strip already does, so a record page draws every date one way. The approvals and attachments fallbacks and the provenance footer appended after an authored page take the page body's horizontal inset. A feed entry's body reads in the foreground colour; the meta line stays muted. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvWX6aMbbnnsKtDC5k --- ...ecordDetailView.appendInset-27520.test.tsx | 183 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 19 +- .../plugin-detail/src/DetailSection.tsx | 6 + .../src/RecordActivityTimeline.tsx | 5 +- .../DetailSection.dateYear-27520.test.tsx | 93 +++++++++ ...imeline.commentBodyContrast-27520.test.tsx | 52 +++++ 6 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.appendInset-27520.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/DetailSection.dateYear-27520.test.tsx create mode 100644 ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.commentBodyContrast-27520.test.tsx diff --git a/ui/packages/app-shell/src/views/RecordDetailView.appendInset-27520.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.appendInset-27520.test.tsx new file mode 100644 index 00000000000..2a73c4268dc --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.appendInset-27520.test.tsx @@ -0,0 +1,183 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27520 row 14 — what the record page appends after an authored + * page lines up with that page's cards. + * + * Measured on the showcase project page: the Attachments card the host appends + * (the object declares `enable.files`, the page places no `record:attachments`) + * drew 24 px wider on each side than the page's own cards, and the provenance + * footer with it. The page renderer insets its body (`p-3 md:p-4 lg:p-6`) and + * the appends sit beside the page, not inside it. They now take the same + * horizontal inset. + * + * Mounted through `RecordDetailView` itself on an ASSIGNED record page, so the + * inset is read off the page root the real page renderer draws, not copied. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); +// Orthogonal chrome, stubbed. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT = 'os_27520_project'; +const REC = 'prj-1'; + +const objectDef = { + name: OBJECT, + label: 'Project', + enable: { files: true }, + highlightFields: ['name'], + fields: { + id: { label: 'Id', type: 'text' }, + name: { label: 'Name', type: 'text' }, + owner_email: { label: 'Owner Email', type: 'text' }, + }, +}; + +const RECORD = { + id: REC, + name: 'Website Relaunch', + owner_email: 'auditor@example.com', + created_at: '2026-01-02T03:04:05.000Z', + updated_at: '2026-02-03T04:05:06.000Z', +}; + +/** An authored page that places no `record:attachments`, so the host appends the panel. */ +const ASSIGNED_PAGE = { + name: 'os_27520_project_detail', + label: 'Project', + type: 'record', + object: OBJECT, + regions: [ + { + name: 'main', + components: [ + { type: 'record:details', id: 'overview', properties: { sections: [{ label: 'Overview', fields: ['owner_email'] }] } }, + ], + }, + ], +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [], total: 0, hasMore: false, pageSize: 50 })), + findOne: vi.fn(async (name: string, id: string) => (name === OBJECT ? { ...RECORD, id } : null)), + create: vi.fn(async (_o: string, row: any) => row), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + getObjectSchema: async (name: string) => ({ name, fields: objectDef.fields }), + } as any; +} + +function makeMetadata(pages: any[]) { + return { + objects: [], pages, loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: async () => pages, getItem: async () => null, + getItemsByType: () => pages, + } as any; +} + +function renderRecordPage(pages: any[]): HTMLElement { + const { container } = render( + + + {}} + objectNameOverride={OBJECT} + recordIdOverride={REC} + embedded + /> + + , + ); + return container; +} + +/** The page root's padding utilities, each as the horizontal inset an append must carry. */ +function pageInsetOf(root: ParentNode): string[] { + const page = root.querySelector('[data-page-type="record"]'); + expect(page, 'the page renderer must draw the record page root').toBeTruthy(); + const padding = Array.from(page!.classList).filter((c) => /^(?:[a-z]+:)?p-\d+$/.test(c)); + // Control: the page does inset its body; an inset-free page would make + // every assertion below vacuous. + expect(padding.length).toBeGreaterThan(0); + return padding.map((c) => c.replace(/p-(\d+)$/, 'px-$1')); +} + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +beforeEach(() => { + cleanup(); + vi.stubGlobal('fetch', vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, headers: { 'content-type': 'application/json' }, + }))); +}); + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); + +describe('record page appends line up with the page cards (objectstack#27520 row 14)', () => { + it('the appended Attachments panel takes the page body inset', async () => { + const container = renderRecordPage([ASSIGNED_PAGE]); + await waitFor(() => expect(container.textContent).toContain('auditor@example.com')); + const fallback = await waitFor(() => { + const el = container.querySelector('[data-record-attachments-fallback]'); + expect(el, 'the host appends the Attachments panel for an authored page that omits it').toBeTruthy(); + return el as HTMLElement; + }); + const inset = pageInsetOf(container); + for (const cls of inset) expect(fallback.classList).toContain(cls); + // It is an append, not part of the page tree. + expect(container.querySelector('[data-page-type="record"]')!.contains(fallback)).toBe(false); + }); + + it('the provenance footer takes the same inset', async () => { + const container = renderRecordPage([ASSIGNED_PAGE]); + const footer = await waitFor(() => { + const el = container.querySelector('[data-testid="record-meta-footer"]'); + expect(el).toBeTruthy(); + return el as HTMLElement; + }); + for (const cls of pageInsetOf(container)) expect(footer.classList).toContain(cls); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 3b458d013b6..5d411f792f6 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -113,6 +113,16 @@ interface RecordDetailViewProps { const FALLBACK_USER = { id: 'current-user', name: 'Demo User' }; +/** + * The horizontal inset the `page` renderer gives a page's body + * (`p-3 md:p-4 lg:p-6` in `@object-ui/components` `renderers/layout/page.tsx`). + * The appends this host draws after a page it renders whole (the approvals and + * attachments fallbacks and the provenance footer) sit beside that page, not + * inside it, so they take the same inset; without it they drew wider than the + * page's own cards (objectstack#27520 row 14). + */ +const RECORD_PAGE_APPEND_INSET = 'px-3 md:px-4 lg:px-6'; + /** * `⌘⇧,` / `Ctrl+Shift+,` — show or hide the record page's right column, the * reader's ⊟ (objectstack#27052; objectstack#27524, Target 4). The record @@ -4776,7 +4786,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri nothing when the record has no requests. */} {pureRecordId && !hasApprovalsNode && ( @@ -4789,7 +4799,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri (objectstack#4358), so this bottom append fires only for authored pages that omit the panel. */} {filesEnabled && !hasAttachments && pureRecordId && ( -
+
= ({ value={value} field={enrichedField as unknown as FieldMetadata} deadlineSettled={isDeadlineSettled(enrichedField, data, { scope: predicateScope, fields: objectSchema?.fields })} + // A record page writes every date one way. The highlights strip + // above writes the year on a current-year date (objectui#13331), + // so a section row does too: "Dec 31, 2026" in the strip beside + // "Oct 2" in a section was two formats on one page. Grid cells + // keep the shared face that drops it. + alwaysShowYear /> ); } diff --git a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx index f9d687ef2a9..23df968713f 100644 --- a/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/ui/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -621,7 +621,10 @@ export const RecordActivityTimeline: React.FC = ({ item={item} onEditComment={onEditComment} onDeleteComment={onDeleteComment} - bodyClassName="text-sm whitespace-pre-wrap break-words text-muted-foreground" + // The body is what the author wrote, so it reads in the + // foreground colour, as a reply's body does; the muted grey + // belongs to the meta line above it (objectstack#27520 row 14). + bodyClassName="text-sm whitespace-pre-wrap break-words text-foreground" /> {/* Drill to source rich entity (ADR-0052 ActivityPointer) */} diff --git a/ui/packages/plugin-detail/src/__tests__/DetailSection.dateYear-27520.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailSection.dateYear-27520.test.tsx new file mode 100644 index 00000000000..1de81aa7929 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/DetailSection.dateYear-27520.test.tsx @@ -0,0 +1,93 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A record page writes every date one way (objectstack#27520 row 14). + * + * The sighting was a project record whose highlights strip read + * "End Date Dec 31, 2026" while a section below read "Start Date Oct 2": the + * strip writes the year on every date (objectui#13331) and the section row + * used the shared face that drops the current year. A section row now passes + * the same `alwaysShowYear`, so both read with the year. A grid cell keeps the + * face that drops it. + * + * The clock is pinned, so "the current year" is 2026 and every date is + * outside the relative window. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import * as React from 'react'; +import type { DateFieldMetadata, DetailViewSection, HighlightField } from '@object-ui/types'; +import { getCellRenderer } from '@object-ui/fields'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; + +const CLOCK = '2026-11-01T12:00:00.000Z'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(CLOCK)); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const objectSchema = { + fields: { + start_date: { name: 'start_date', type: 'date', label: 'Start Date' }, + end_date: { name: 'end_date', type: 'date', label: 'End Date' }, + kickoff_at: { name: 'kickoff_at', type: 'datetime', label: 'Kickoff', format: 'relative' }, + }, +}; + +const data = { start_date: '2026-10-02', end_date: '2026-12-31', kickoff_at: '2026-09-29T12:00:00.000Z' }; + +const section = { + title: 'Timeline', + fields: [ + { name: 'start_date', label: 'Start Date', type: 'date' }, + { name: 'kickoff_at', label: 'Kickoff', type: 'datetime', format: 'relative' }, + ], +} as unknown as DetailViewSection; + +describe('one date format on a record page (objectstack#27520 row 14)', () => { + it('a section row writes the year on a current-year date', () => { + render(); + expect(screen.getByText('Oct 2, 2026')).toBeTruthy(); + expect(screen.queryByText('Oct 2')).toBeNull(); + // A `relative` datetime past the relative window falls back to the same face. + expect(screen.getByText('Sep 29, 2026')).toBeTruthy(); + }); + + it('the section and the highlights strip draw a current-year date alike', () => { + render( + <> + + + , + ); + expect(screen.getAllByText('Dec 31, 2026')).toHaveLength(2); + }); + + it('CONTROL: a grid cell keeps the face that drops the current year', () => { + const DateCell = getCellRenderer('date'); + const field: DateFieldMetadata = { name: 'start_date', label: 'Start Date', type: 'date' }; + render(); + expect(screen.getByText('Oct 2')).toBeTruthy(); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.commentBodyContrast-27520.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.commentBodyContrast-27520.test.tsx new file mode 100644 index 00000000000..5b78e78bee1 --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RecordActivityTimeline.commentBodyContrast-27520.test.tsx @@ -0,0 +1,52 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A comment's text reads in the foreground colour (objectstack#27520 row 14). + * + * The sighting was a project record whose Discussion drew the comment text in + * the muted grey the meta line uses (`text-muted-foreground`), so what the + * author wrote read as fainter than the name above it. The body now takes + * `text-foreground`, as a reply's body already did; the actor and timestamp + * keep the muted grey. + */ +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordActivityTimeline } from '../RecordActivityTimeline'; + +afterEach(cleanup); + +const BODY = 'Kickoff moved to Monday, please review the plan.'; + +const COMMENT: FeedItem = { + id: 'c-1', + type: 'comment', + actor: 'Ada', + body: BODY, + createdAt: '2026-10-01T08:00:00.000Z', +}; + +describe('comment text contrast (objectstack#27520 row 14)', () => { + it('draws the comment body in the foreground colour, not the muted grey', () => { + render(); + const body = screen.getByText(BODY); + expect(body.className).toContain('text-foreground'); + expect(body.className).not.toContain('text-muted-foreground'); + }); + + it('CONTROL: the meta line beside it keeps the muted grey', () => { + render(); + // The timestamp is the meta line's muted span; the actor's name is not. + const actor = screen.getByText('Ada'); + const meta = actor.parentElement as HTMLElement; + const muted = meta.querySelectorAll('.text-muted-foreground'); + expect(muted.length).toBeGreaterThan(0); + }); +}); From 1a3259567a8b81b2b3c02b0465e8819a8ce060f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:40:28 +0000 Subject: [PATCH 2/4] fix(showcase): the greyed Archive action says why it is unavailable showcase_archive_task declares a disabledReason (en, plus the zh-CN copy key), so its greyed button reads "Mark the task done first." instead of the console's generic "Not available for this record". Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvWX6aMbbnnsKtDC5k --- .../src/system/translations/index.ts | 6 ++- examples/app-showcase/src/ui/actions/index.ts | 5 +++ .../test/archive-disabled-reason.test.ts | 44 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 examples/app-showcase/test/archive-disabled-reason.test.ts diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index d52702e54d2..a2ba8669092 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -543,7 +543,11 @@ export const ShowcaseTranslationBundle = { showcase_quick_view: { label: '快速查看' }, showcase_log_time: { label: '记录工时' }, showcase_new_task: { label: '新建任务' }, - showcase_archive_task: { label: '归档', successMessage: "{{ record.title | default:'任务' }}已归档(演示——未修改任何数据)。" }, + showcase_archive_task: { + label: '归档', + successMessage: "{{ record.title | default:'任务' }}已归档(演示——未修改任何数据)。", + disabledReason: '请先将任务标记为完成。', + }, }, // Section headings of the six form-view projections in // `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick). diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index fcb8a237d4f..9c52cba4713 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -496,6 +496,11 @@ export const ArchiveTaskAction = defineAction({ // Disabled while the task is not done — visible either way. #8990: `has()` // for the sparse face; `!=` against a literal cannot fault, so no `!= null`. disabled: 'has(record.done) && record.done != true', + // What the greyed button says while `disabled` holds: why it is + // unavailable and what unlocks it. Without it the console falls back to + // "Not available for this record", which names neither. Display only; it + // never gates the dispatch. + disabledReason: 'Mark the task done first.', locations: ['record_header', 'record_section'], refreshAfter: false, }); diff --git a/examples/app-showcase/test/archive-disabled-reason.test.ts b/examples/app-showcase/test/archive-disabled-reason.test.ts new file mode 100644 index 00000000000..d5035ff3969 --- /dev/null +++ b/examples/app-showcase/test/archive-disabled-reason.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The greyed Archive button on a task says why it is unavailable + * (objectstack#27520 row 9). + * + * `showcase_archive_task` is the `disabled`-predicate specimen: it stays on + * screen and is greyed until the task is done. The console draws the action's + * `disabledReason` (`ActionSchema.disabledReason`, #26619) as the button's + * hover title and accessible description, and falls back to "Not available for + * this record" only when the action declares none, which told the user neither + * why nor what to do. So the specimen declares the sentence, in English on the + * action and in zh-CN at `objects.showcase_task._actions..disabledReason`, + * the copy keys' translation route. + */ + +import { describe, expect, it } from 'vitest'; +import { ActionSchema } from '@objectstack/spec/ui'; +import { ArchiveTaskAction } from '../src/ui/actions/index.js'; +import { ShowcaseTranslationBundle } from '../src/system/translations/index.js'; + +const GENERIC = 'Not available for this record'; + +describe('showcase_archive_task says why it is greyed (objectstack#27520 row 9)', () => { + it('declares a disabledReason beside its disabled predicate, and the spec keeps it', () => { + const parsed = ActionSchema.parse(ArchiveTaskAction); + // Control: the action is still the `disabled` specimen. + expect(parsed.disabled).toBeTruthy(); + expect(typeof parsed.disabledReason).toBe('string'); + const reason = String(parsed.disabledReason); + expect(reason.trim()).not.toBe(''); + expect(reason).not.toBe(GENERIC); + }); + + it('carries the zh-CN reason on the copy keys\' translation route', () => { + const zhActions = (ShowcaseTranslationBundle['zh-CN'] as { + objects: Record }>; + }).objects.showcase_task._actions ?? {}; + const zh = zhActions.showcase_archive_task?.disabledReason; + expect(typeof zh).toBe('string'); + expect(String(zh).trim()).not.toBe(''); + expect(zh).not.toBe(ArchiveTaskAction.disabledReason); + }); +}); From 6b17724f0f517fce2dae35b284fd12614ddf9dc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:42:54 +0000 Subject: [PATCH 3/4] fix(showcase): the task record page draws each action once The Task Detail quick-actions bar carried Mark Done, Log Time and Archive, which its page header already draws, so the page showed the same buttons twice. The bar now carries the task's record_section set alone (Recalculate Estimate, which declares record_section only), and the three header actions drop record_section. Every record location keeps a stock host; the platform checklist's action-location matrix is re-pointed to match. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvWX6aMbbnnsKtDC5k --- .../areas/records-forms.json | 14 ++++-- examples/app-showcase/src/ui/actions/index.ts | 18 ++++---- .../src/ui/pages/task-detail.page.ts | 15 ++++--- .../test/record-action-location-hosts.test.ts | 43 ++++++++++++++++++- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 36d8daf45c2..bbcb415cc36 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -2056,7 +2056,7 @@ "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section — and each dispatches for real", "since": "v15", "status": "active", - "revision": 17, + "revision": 18, "priority": "P1", "surface": "browser", "personas": [ @@ -2065,7 +2065,7 @@ "fixtures": { "app": "showcase", "requires": [ - "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, a declarative write on the default script route — operation 'update', patch {done: true, progress: 100} — visible 'has(record.done) && record.done != true'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — declares record_more ONLY, which is what keeps it off the toolbar; objectui#3142), showcase_new_task (locations: [] — the fleet's headless specimen, read by the empty-locations probe), showcase_reopen_task (list_item only, operation 'update' patch {done: false}, visible 'has(record.done) && record.done == true' — Mark Done's mirror, so a done task's row menu offers Reopen where a not-done one offers Mark Done; a click on it is refused by the showcase_guard_task_reopen hook by design, which records-forms.script-action-hook-refusal-toast drives)", + "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header, a declarative write on the default script route — operation 'update', patch {done: true, progress: 100} — visible 'has(record.done) && record.done != true'), showcase_log_time (record_header+record_related, form), showcase_open_docs (record_more, url), showcase_recalc_estimate (record_section only, api — the Task Detail quick-actions bar's action), showcase_recalc_selection (record_more, api — declares record_more ONLY, which is what keeps it off the toolbar; objectui#3142), showcase_new_task (locations: [] — the fleet's headless specimen, read by the empty-locations probe), showcase_reopen_task (list_item only, operation 'update' patch {done: false}, visible 'has(record.done) && record.done == true' — Mark Done's mirror, so a done task's row menu offers Reopen where a not-done one offers Mark Done; a click on it is refused by the showcase_guard_task_reopen hook by design, which records-forms.script-action-hook-refusal-toast drives)", "seeded tasks in both done and not-done states for the CEL visibility both-sides check" ], "provisioning": { @@ -2074,7 +2074,7 @@ }, "knownGaps": [ "NO PIN COVERS A FULL CLAUSE — `automated` was dropped at revision 13, so this P1 item is hand-driven (Tier 2). The two objectui live specs it cited are adjacent: e2e/live/list-row-action-cel.spec.ts opens the first task row's menu and asserts only that at most one Mark Done renders (not both sides of acceptance[3], nor its fail-closed half), and e2e/live/action-modal.spec.ts only that the host's row Edit opens and closes a modal (one dispatch kind of acceptance[2], on no declared action). Tier 1 run #24598 scored every clause not-run on them. Re-add `automated` only with a pin that asserts a full clause.", - "[CLOSED by #22030 (PR #22036, 60ccda5a24), lifted by #22039 — the gap text is rewritten to say where each location now lives] a stock showcase boot hosts all six locations on showcase_task, including the three this gap used to block. record_header and record_more: Task Detail (showcase_task_detail, the record page the console opens for any task), whose first node is a page:header. That header renders the action ids it lists, each placed by its own locations, followed by the host's Edit / Share / Delete. Inline (record_header) on a desktop viewport, where the header's default inline budget is 3 (1 on mobile; a record_header action past the budget folds into ⋯): showcase_mark_done (script, shown only while the task is not done), showcase_log_time (form) and showcase_archive_task (script, disabled until the task is done). Under the ⋯ overflow (record_more without record_header): showcase_open_docs (url), showcase_recalc_estimate (api) and showcase_recalc_selection (api). The Task Detail quick-actions bar (record_section) names its actions explicitly (actionNames: mark_done, log_time and archive_task, in examples/app-showcase/src/ui/pages/task-detail.page.ts), so showcase_recalc_estimate, which declares record_section too, is absent there by page authoring, not a missing placement (run #25817). record_related: Project Detail (showcase_project_detail, the record page for any project) → the Related tab (?tab=related), which holds the Tasks related list, a record:related_list for showcase_task through its project FK; open a project that has tasks. It is an authored node, not the synthesizer's strip: the page's tabs override still replaces that strip, and this tab puts the list back with no actions key, so its rows carry showcase_log_time beside the task's list_item actions, and its header renders the list_toolbar action (Reassign…), as in 18.1 (run #25817): that list's own toolbar placement, not an extra one. The Tasks tab beside it is the record:line_items grid, which hosts no record_related action. An expected action missing from one of these hosts is a missing placement, never blocked(fixture). The hosts were read from objectui source at pin a58626c88dc8 and from the served metadata, not from a browser render, so the first run against them is their first browser evidence", + "[CLOSED by #22030 (PR #22036, 60ccda5a24), lifted by #22039 — the gap text is rewritten to say where each location now lives] a stock showcase boot hosts all six locations on showcase_task, including the three this gap used to block. record_header and record_more: Task Detail (showcase_task_detail, the record page the console opens for any task), whose first node is a page:header. That header renders the action ids it lists, each placed by its own locations, followed by the host's Edit / Share / Delete. Inline (record_header) on a desktop viewport, where the header's default inline budget is 3 (1 on mobile; a record_header action past the budget folds into ⋯): showcase_mark_done (script, shown only while the task is not done), showcase_log_time (form) and showcase_archive_task (script, disabled until the task is done). Under the ⋯ overflow (record_more without record_header): showcase_open_docs (url) and showcase_recalc_selection (api). The Task Detail quick-actions bar (record_section) names the task's record_section set, showcase_recalc_estimate (api), in examples/app-showcase/src/ui/pages/task-detail.page.ts; no action the header draws is also in the bar, so each action renders once on the page (#27520 row 9). record_related: Project Detail (showcase_project_detail, the record page for any project) → the Related tab (?tab=related), which holds the Tasks related list, a record:related_list for showcase_task through its project FK; open a project that has tasks. It is an authored node, not the synthesizer's strip: the page's tabs override still replaces that strip, and this tab puts the list back with no actions key, so its rows carry showcase_log_time beside the task's list_item actions, and its header renders the list_toolbar action (Reassign…), as in 18.1 (run #25817): that list's own toolbar placement, not an extra one. The Tasks tab beside it is the record:line_items grid, which hosts no record_related action. An expected action missing from one of these hosts is a missing placement, never blocked(fixture). The hosts were read from objectui source at pin a58626c88dc8 and from the served metadata, not from a browser render, so the first run against them is their first browser evidence", "CLOSED by the qaact-placement-specimen recipe (#26625) for acceptance[4] half (a) and acceptance[3]'s fail-closed half. A runtime-authored action is still not joined into the served object's actions[] (QA run #23163 A4: its positive control, the same probe with a location, rendered nowhere), and authoring refuses a throwing `visible` (QA run #26456), so ⛔ do not author either probe at runtime. The recipe installs both specimens through the package loader, whose actions the served object does carry. Score half (a) on qaact_no_locations and the fail-closed half on qaact_visible_throws, each only on a surface where the recipe's qaact_control renders: the list row menu and the Project Detail Related-tab row menus. Task Detail's header, ⋯ overflow and quick-actions bar list their action ids, so the control is absent there and they are no evidence for either half (the recipe's knownGaps[0]). Half (b) stays on showcase_new_task as before." ] }, @@ -2084,7 +2084,7 @@ "record_header — showcase_mark_done / showcase_log_time in the detail title bar", "record_more — showcase_open_docs / showcase_recalc_selection under the ⋯ overflow", "record_related — showcase_log_time on each row of the related-list section inside a record (the Tasks related list on a showcase_project record), and on no row of the showcase_task list view", - "record_section — showcase_mark_done / showcase_log_time in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)", + "record_section — showcase_recalc_estimate in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)", "empty-locations semantics probe — an action with NO locations key renders on NO surface, and `locations: []` likewise (objectui#3142 collapsed the renderers onto one membership predicate, `actionRendersAt`; placement is a declaration, which is why recalc_selection has to name record_more)" ], "steps": [ @@ -2247,6 +2247,12 @@ "date": "2026-10-31", "change": "fixtures.requires[0], steps[5] and acceptance[3]'s clause name Mark Done's real predicate and list showcase_reopen_task. From QA run #27441 (W3-C3b, at c4c515ad; wave anchor #27377, wave-3 accuracy slice S1). showcase_mark_done's `visible` has been 'has(record.done) && record.done != true' since #8990 (has() guards the sparse list_item face, and != true never faults on a projected null), not '!record.done', and it is a declarative operation 'update' write on the default script route. showcase_reopen_task, the hook-refusal specimen, is its list_item-only mirror ('has(record.done) && record.done == true'), so a done task's row menu shows Reopen where the not-done one shows Mark Done; steps[5] says not to count it. Read at base a0f865491d: examples/app-showcase/src/ui/actions/index.ts. The both-sides contract is unchanged", "ref": "#27441" + }, + { + "revision": 18, + "date": "2026-11-01", + "change": "The Task Detail page draws each task action once (#27520 row 9: the walkthrough saw Mark Done, Log Time and Archive both in the header and as a button row in the body). The quick-actions bar now carries the task's record_section set alone, showcase_recalc_estimate, which declares record_section only, and Mark Done, Log Time and Archive drop record_section. fixtures.requires[0], the record_section variant and knownGaps[1] re-pointed: record_section is still hosted on showcase_task, by Recalculate Estimate in the bar, and the ⋯ overflow keeps open_docs (url) and recalc_selection (api). Read from the examples source at this branch, not a browser run; no clause changed", + "ref": "#27520" } ], "enumSource": { diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 9c52cba4713..93616549da8 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -90,10 +90,9 @@ export const MarkDoneAction = defineAction({ // still reads an unset `done` as "not done". `has()` alone is therefore the // whole guard here. Rule: `packages/objectql/src/declared-fields.ts`. visible: 'has(record.done) && record.done != true', - // `record_section` so the Task Detail page's `record:quick_actions` bar - // (which names this action) resolves it — the engine location-filters even - // explicitly-named actions, mirroring the platform's own sys-user pages. - locations: ['list_item', 'record_header', 'record_section'], + // No `record_section`: the Task Detail header already draws it, and its + // quick-actions bar carries only what the header does not (one action row). + locations: ['list_item', 'record_header'], refreshAfter: true, }); @@ -190,7 +189,11 @@ export const RecalcEstimateAction = defineAction({ execution: 'perRecord', // Names the task (see MarkDoneAction's note on the hole and its fallback). successMessage: "Estimate recalculated for {{ record.title | default:'the task' }}.", - locations: ['record_more', 'record_section'], + // `record_section` only: the Task Detail page's `record:quick_actions` bar + // names it, and the engine location-filters even explicitly-named actions. + // Not `record_more` too, so the page draws it once, in that bar, and not + // again under the header's ⋯ overflow. + locations: ['record_section'], // The endpoint is record-scoped and rejects a body without an id. On a // record surface the page supplies it; invoked from a LIST ROW (see the // `legacy_row_actions` view) nothing else can, so name the body key the @@ -268,8 +271,7 @@ export const LogTimeAction = defineAction({ // implicitly claims the `default` key), so pointing a form action there opens // a list as a form — now a build error (#2554). target: 'showcase_task.form', - // `record_section` so it surfaces in the Task Detail quick-actions bar too. - locations: ['record_header', 'record_related', 'record_section'], + locations: ['record_header', 'record_related'], refreshAfter: true, }); @@ -501,7 +503,7 @@ export const ArchiveTaskAction = defineAction({ // "Not available for this record", which names neither. Display only; it // never gates the dispatch. disabledReason: 'Mark the task done first.', - locations: ['record_header', 'record_section'], + locations: ['record_header'], refreshAfter: false, }); diff --git a/examples/app-showcase/src/ui/pages/task-detail.page.ts b/examples/app-showcase/src/ui/pages/task-detail.page.ts index dbc1f29f8cb..b7ba5a98dbe 100644 --- a/examples/app-showcase/src/ui/pages/task-detail.page.ts +++ b/examples/app-showcase/src/ui/pages/task-detail.page.ts @@ -13,7 +13,9 @@ import { definePage } from '@objectstack/spec/ui'; * • `record:alert` — a conditional banner shown only while the task is * In Review (demonstrates the ADR-0089 component-node * `visibleWhen` predicate, `has()`-guarded). - * • `record:quick_actions` — object Actions surfaced as inline buttons. + * • `record:quick_actions` — object Actions surfaced as inline buttons: the + * task's `record_section` set, which the header + * does not draw, so each action shows once. * • `record:highlights` + `record:details` — the standard compact + section * layout. * `kind: 'full'` — this page fully owns the record layout, as the Project @@ -57,9 +59,8 @@ export const TaskDetailPage = definePage({ 'showcase_mark_done', 'showcase_log_time', 'showcase_archive_task', - // record_more only — the ⋯ overflow (url, api, api). + // record_more only — the ⋯ overflow (url, api). 'showcase_open_docs', - 'showcase_recalc_estimate', 'showcase_recalc_selection', ], }, @@ -110,10 +111,10 @@ export const TaskDetailPage = definePage({ properties: { location: 'record_section', align: 'start', - // showcase_archive_task is the `disabled`-predicate specimen: - // visible on every task, greyed until `record.done` (contrast with - // showcase_mark_done's `visible`, which HIDES once done). - actionNames: ['showcase_mark_done', 'showcase_log_time', 'showcase_archive_task'], + // The task's `record_section` set, and nothing the header above + // draws: one action row per action, never the same buttons twice. + // Mark Done, Log Time and Archive live in the header only. + actionNames: ['showcase_recalc_estimate'], }, }, { diff --git a/examples/app-showcase/test/record-action-location-hosts.test.ts b/examples/app-showcase/test/record-action-location-hosts.test.ts index bf10590092d..0afd9999938 100644 --- a/examples/app-showcase/test/record-action-location-hosts.test.ts +++ b/examples/app-showcase/test/record-action-location-hosts.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { PageHeaderProps, RecordRelatedListProps } from '@objectstack/spec/ui'; +import { PageHeaderProps, RecordQuickActionsProps, RecordRelatedListProps } from '@objectstack/spec/ui'; import stack from '../objectstack.config.js'; import { Task } from '../src/data/objects/task.object.js'; @@ -157,4 +157,45 @@ describe('showcase_task record action locations have a stock host (#22030)', () expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); }); }); + + describe('record_section — the Task Detail quick-actions bar (objectstack#27520 row 9)', () => { + const bars = taskPage ? allComponents(taskPage).filter((c) => c.type === 'record:quick_actions') : []; + const names = (bars[0]?.properties?.actionNames ?? []) as unknown[]; + const headerIds = (taskPage + ? (allComponents(taskPage).find((c) => c.type === 'page:header')?.properties?.actions ?? []) + : []) as unknown[]; + + it('composes one quick-actions bar at record_section that parses against the spec row', () => { + expect(bars).toHaveLength(1); + expect(bars[0]?.properties?.location).toBe('record_section'); + const result = RecordQuickActionsProps.safeParse(bars[0]?.properties); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('names the task\'s whole record_section set, each declaring record_section', () => { + expect(names.length).toBeGreaterThan(0); + const sectionSet = taskActions.filter(declares('record_section')).map((a) => a.name as string); + expect([...names].sort()).toEqual([...sectionSet].sort()); + }); + + it('draws each action once: nothing the bar names is also drawn by the header', () => { + // Control: both lists are populated, so an empty overlap is a reading. + expect(names.length).toBeGreaterThan(0); + expect(headerIds.length).toBeGreaterThan(0); + expect(names.filter((n) => headerIds.includes(n))).toEqual([]); + // And by declaration: no task action is placed both in the header's + // locations and in the bar's, so a synthesized page cannot double it either. + const doubled = taskActions + .filter(declares('record_section')) + .filter((a) => declares('record_header')(a) || declares('record_more')(a)) + .map((a) => a.name); + expect(doubled).toEqual([]); + }); + }); + + it('every record location keeps a stock host for a task action', () => { + for (const location of ['record_header', 'record_more', 'record_related', 'record_section']) { + expect(taskActions.filter(declares(location)).length, location).toBeGreaterThan(0); + } + }); }); From e042e207fd48d93b18d6e9108bdee42ada5696b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:56:54 +0000 Subject: [PATCH 4/4] chore(changeset): record page dates, card insets and comment contrast Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvWX6aMbbnnsKtDC5k --- .../27520-record-page-dates-cards-comments.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/27520-record-page-dates-cards-comments.md diff --git a/.changeset/27520-record-page-dates-cards-comments.md b/.changeset/27520-record-page-dates-cards-comments.md new file mode 100644 index 00000000000..38c9dd8dbbe --- /dev/null +++ b/.changeset/27520-record-page-dates-cards-comments.md @@ -0,0 +1,15 @@ +--- +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +fix(record-page): one date format per record page, appended cards line up with the page's own, and comment text reads in the foreground colour + +Clause-②: no +Metadata-protocol: none + +Three record-page readability fixes from the 18.3.0 RC walkthrough: + +- **One date format.** A `record:details` section row now writes the year on a current-year date, as the highlights strip above it already does. A project page read "End Date Dec 31, 2026" in the strip and "Start Date Oct 2" in a section; both now read with the year. Grid cells keep the face that drops the current year. +- **Aligned cards.** The panels the record page appends after an authored page (the Attachments and Approvals fallbacks, and the "Created … Updated …" footer) take the same horizontal inset as the page body, so they no longer draw wider than the page's own cards. +- **Comment contrast.** A feed entry's body text uses the foreground colour, as a reply's body already did. The actor, the timestamp and the other meta text stay muted.