diff --git a/.changeset/28242-action-pressed-face.md b/.changeset/28242-action-pressed-face.md new file mode 100644 index 00000000000..759e5906334 --- /dev/null +++ b/.changeset/28242-action-pressed-face.md @@ -0,0 +1,10 @@ +--- +'@object-ui/core': patch +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +The record header and the declared actions bar now draw an action's `pressed` predicate. A button whose action declares `pressed` is a toggle button: `aria-pressed="true"` and Shadcn's toggle-on face while the predicate holds for this reader and record, `aria-pressed="false"` while it does not. A predicate that cannot be evaluated draws the unpressed button and is reported once. Display only: a pressed action stays enabled and still runs. `ActionDef` declares `pressed`, derived from the spec's `ActionSchema.pressed`. + +Clause-②: yes (widening) +Metadata-protocol: none diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 7a92822ea6b..1bfff70fff1 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -363,13 +363,10 @@ pressed: "has(record.viewer) && record.viewer.vote == 'helpful'" ``` - It is **display only**. A pressed action stays runnable, and the action's own body decides what a second press does (records the vote again, withdraws it, or answers "already"). +- The record header and the declared actions bar draw it on their buttons: `aria-pressed="true"` and the pressed face while it is true, `aria-pressed="false"` otherwise. A predicate that cannot be evaluated draws the unpressed button. A menu item (an action folded into the header's overflow menu) has no pressed state, so it is not drawn there. - A boolean literal is refused: `pressed: true` would draw the button pressed for every reader, which is a style rather than the reader's state. - A per-reader fact is usually not a column of the row (a vote is a child record per user). Declare it as a block under the object's `attachedOnRead`, have the serving side attach it to each row it returns for the caller (a service, or an `afterFind` hook that shapes the rows), and read it as `record..`, guarded with `has()`. `os build` and `os validate` check the leaf against the declared block. - -The spec, the build check and this contract are in place; the console's record header and declared actions bar do not draw the pressed face yet ([#28242](https://github.com/objectstack-ai/objectstack/issues/28242)), so today the button looks the same whatever the predicate answers. - - #### Evaluation context Action predicates evaluate against the **record the action is attached to**. Both `record.` and the bare field name resolve to the current record's value: diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index 1b1f6464ae8..25c9c9afa61 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -430,14 +430,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ note: 'Split from `cel-action-visible` rather than sharing one row with it, for the reason the collision assertion encodes: one classification covering both would have to pick one fail-policy, and the two faces are opposites — a faulting `visible` HIDES the action, a `disabled` the renderer cannot evaluate LEAVES IT RUNNABLE. A single row would have stated one of those and silently spoken for the other. ⛔ NOT MEASURED HERE: the renderer, and the `-log` half of `fail-soft-log` — the reading above establishes the soft DIRECTION from the objectui fix that removed the closed face, not that a line is logged; the same posture, and the same limit, as `cel-row-crud-disabled` on the built-in row buttons.', }, { - // The key arrived with #27946, its spec half; the console half is a follow-up card. + // The key arrived with #27946 (spec half); the console half that draws it is #28242. id: 'cel-action-pressed', summary: 'registered action pressed state (Action.pressed) — the per-reader toggle face of a button, drawn as aria-pressed', - dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated', + dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', enforcement: - 'PARSE ONLY — `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses a boolean literal, an `ast`-only envelope and a blank source; NO EVALUATOR FOUND: no action surface reads the key yet. The build-time gate is in place ahead of the reader: lint/validate-expressions.ts `checkAction` checks it against the `record` scope on every declared action, as a served-row site where a block the object declares under `attachedOnRead` resolves', + 'Same declaration site and same build-time gate as `cel-action-visible`: `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses a boolean literal, an `ast`-only envelope and a blank source, and lint/validate-expressions.ts `checkAction` checks it against the `record` scope on every declared action, as a served-row site where a block the object declares under `attachedOnRead` resolves. EVALUATOR: two console (objectui) surfaces, both on @objectstack/formula celEngine (interpret) against the record the client fetched — the record header (`PageHeaderActions` `pressedFor`, through `evalRowPredicate` with `fallback: false` and `warnOnError`) and the declared actions bar (`DeclaredActionButton` `useDeclaredActionPressed`, through `useCondition` with `throwOnError`, the binding its `visible` uses). TRUE draws `aria-pressed="true"` and the pressed face; a predicate that FAULTS answers `false` and is reported once, so the button is drawn unpressed and stays runnable', covers: ['ui/action.zod.ts:actionObject.pressed'], - note: 'EXPERIMENTAL until the console draws it: declared spec-first, with the liveness row `planned`. Display only by contract — it never gates, greys or routes the action — so when the reader lands its fault face is a plain, unpressed button, and this row is re-stated as `enforced` with that fail policy, naming the surfaces that evaluate it.', + note: 'Display only by contract — it never gates, greys or routes the action — so a fault refuses nothing: the predicate\'s effect (the pressed face) is dropped and the fault is logged, which is `fail-soft-log`, not `fail-closed`. The unpressed face is also the one that never claims the reader acted. Split from `cel-action-visible` / `cel-action-disabled` for the collision rule those two were split on: one row cannot state three fault faces. A menu item (the header overflow, the bar\'s override menu) draws no pressed state at all. Measured here: the ui half is in this checkout and its suites pin the TRUE / FALSE / fault faces on both surfaces.', }, { id: 'cel-record-alert-visible', diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index e187c6aaf42..618e4823650 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -159,13 +159,11 @@ "note": "LIVE since the console half of #26619 (ruling 6442737825, letter A: spec PR #26786 first, this console PR second). Each greyed-action surface shows the declared reason as the hover title and the accessible description (a menu item's second line), only while the DECLARED `disabled` predicate is TRUE, and shows `actions.notAvailableForRecord` (\"Not available for this record\") when the action declares none or a blank one. The record header, the section bar and the declared actions bar run actions through `useActionTextLocalizer`, so the bundle's sentence outranks the authored text; the four `action:*` renderers read the action as authored and honour its inline locale map. `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate, #21127). Display only by contract: never a server admission input, never read as a predicate." }, "pressed": { - "status": "planned", - "verifiedAt": "2026-10-30", + "status": "live", + "verifiedAt": "2026-10-31", "evidenceScope": "in-repo", - "authorWarn": true, - "authorHint": "Kept, but no console surface draws `pressed` yet: the button looks the same whatever the predicate answers. The predicate is still checked at build (record scope, declared fields and `attachedOnRead` blocks), so it is ready when the header buttons read it.", - "evidence": "packages/spec/src/ui/action.zod.ts#pressed (the parse-time door: `EvaluatedExpressionInputSchema`, a CEL string normalized to the envelope, a boolean literal and the blank shapes refused); packages/lint/src/validate-expressions.ts#checkAction (the authoring gate at `os build` / `os validate` and the object save door: the predicate is judged in the `record` scope, and as a SERVED_ROW_SITES member a block the object declares under `attachedOnRead` resolves)", - "note": "PLANNED — declared spec-first by #27946 (spec half; the console half is #28242, a domain:ui card). The reader to come is the record page header (`page:header` buttons) and `DeclaredActionsBar`, drawing the button with `aria-pressed` and a pressed face while the predicate is TRUE. Measured in this repo at origin/main 3a2f4c00: no source under ui/ reads `action.pressed` (the one hit is the inventory line in ui/packages/core/src/actions/actionKeys.ts SPEC_ACTION_KEYS, which restates the spec's declared keys and reads nothing), and no other package reads it. So an authored predicate draws nothing today; nothing is gated or stored differently, which is also the contract once it is live (display only). Flip to `live` on the #28242 console PR, citing its read points, and drop `authorWarn` / `authorHint` in the same edit (a warned `live` row reds this gate)." + "evidence": "ui/packages/components/src/renderers/layout/containers.tsx#pressedFor (the record header's inline buttons: a declared predicate makes the button a toggle, `aria-pressed` and the pressed face while it is TRUE, evaluated through the header's `evalHeaderPredicate` with `fallback: false`); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#useDeclaredActionPressed (the declared actions bar's buttons, through `useCondition` with `throwOnError`, the same record binding as `visible`); ui/packages/core/src/actions/ActionRunner.ts#ActionDef (the key derived from the spec); packages/spec/src/ui/action.zod.ts#pressed (the parse-time door); packages/lint/src/validate-expressions.ts#checkAction (the authoring gate, record scope, `attachedOnRead` blocks resolve)", + "note": "LIVE since #28242 (the console half of #27946). Each declared predicate is evaluated per record for this reader; TRUE draws `aria-pressed=\"true\"` and Shadcn's toggle-on face, FALSE `aria-pressed=\"false\"`, and an action with no predicate carries no `aria-pressed`. A predicate that cannot be evaluated draws the unpressed button and is reported once. Display only by contract: a pressed action stays enabled and still dispatches. Not drawn on a menu item (an action folded into the record header's overflow menu, or a lever in the declared actions bar's override menu): a menu item has no pressed state. `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate)." }, "ai": { "status": "live", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index c19eb3cb863..eea36eabf40 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 48 | 0 | 0 | 1 | 1 | 50 | +| `action` | 49 | 0 | 0 | 1 | 0 | 50 | diff --git a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx index 0645e9e63df..ed2ea610e8b 100644 --- a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -215,6 +215,28 @@ function useDeclaredActionShown(action: ActionDef, predicateRecord: ReturnType): boolean | undefined { + const declared = typeof action.pressed !== 'boolean' && hasDeclaredVisibilityGate(action.pressed); + const isPressed = useCondition(declared ? toPredicateInput(action.pressed) : undefined, predicateRecord, { + throwOnError: true, + label: `declared action "${action.name ?? action.label ?? 'action'}" (pressed)`, + }); + return declared ? isPressed : undefined; +} + /** * Report whether a declared action is drawn, and withdraw the report when the * reporter unmounts (objectstack#25877). `onDrawn` is the toolbar's stable @@ -327,6 +349,7 @@ const DeclaredActionButton: React.FC<{ // these two lines carried — which existed only because `ActionDef.disabled` // could not describe the envelope arm — have nothing left to reach around. const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord); + const pressed = useDeclaredActionPressed(action, predicateRecord); // Called up here with the other hooks: the `visible` gate below returns early. const reasonId = React.useId(); const labelId = React.useId(); @@ -651,13 +674,19 @@ const DeclaredActionButton: React.FC<{ variant={variant as any} disabled={disabledByPredicate || loading} aria-describedby={disabledReason ? reasonId : undefined} + aria-pressed={pressed} onClick={handleClick} // Amber warning treatment for an override (objectui#5178) — the same // palette the approvals surfaces already use for "waiting / attention", // via `cn()` over the outline variant rather than a new Button variant // (`packages/components/src/ui/**` is a no-touch Shadcn zone). - className={cn(isOverride - && 'border-amber-500/60 text-amber-700 hover:bg-amber-50 hover:text-amber-800 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-300')} + // The pressed face is Shadcn's toggle-on colours keyed off the attribute, + // the record header's spelling (`PageHeaderActions`). + className={cn( + isOverride + && 'border-amber-500/60 text-amber-700 hover:bg-amber-50 hover:text-amber-800 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-300', + pressed !== undefined && 'aria-pressed:bg-accent aria-pressed:text-accent-foreground hover:aria-pressed:bg-accent', + )} data-testid={`declared-action-${action.name}`} // Pinnable, and readable by an E2E/QA pass without matching on copy. data-override-decision={isOverride ? 'true' : undefined} diff --git a/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx b/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx new file mode 100644 index 00000000000..409c263d69f --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx @@ -0,0 +1,113 @@ +/** + * objectstack#28242 — a `DeclaredActionsBar` action that declares `pressed` + * (the reader's own state, `ActionSchema.pressed`) is a toggle button: drawn + * with `aria-pressed="true"` and the pressed face while the predicate holds + * for this record, `aria-pressed="false"` while it does not. An action that + * declares no predicate carries no `aria-pressed` at all. + * + * Display only: a pressed action stays enabled and still dispatches, and a + * predicate that cannot be evaluated draws the plain button (never a claim + * that the reader acted) and says so once. + * + * Doubles as the sibling suites (`DeclaredActionsBar.disabledReason-26619.test.tsx`) + * do: only the action dispatch and the console runtime shell. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import React from 'react'; + +const executeSpy = vi.fn().mockResolvedValue({ success: true }); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useAction: () => ({ execute: executeSpy }), + }; +}); +vi.mock('../../hooks/useConsoleActionRuntime', () => ({ + useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }), +})); +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) })); +vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null })); + +import { DeclaredActionsBar } from '../DeclaredActionsBar'; + +const HELPFUL = { + name: 'mark_article_helpful', + type: 'script', + label: 'Helpful', + locations: ['record_section'], + pressed: "has(record.viewer) && record.viewer.vote == 'helpful'", +}; +const NOT_HELPFUL = { + name: 'mark_article_not_helpful', + type: 'script', + label: 'Not helpful', + locations: ['record_section'], + pressed: { dialect: 'cel', source: "has(record.viewer) && record.viewer.vote == 'not_helpful'" }, +}; +const SHARE = { name: 'share_article', type: 'script', label: 'Share', locations: ['record_section'] }; + +function mount(record: Record, actions: Record[] = [HELPFUL, NOT_HELPFUL]) { + return render( + , + ); +} + +const lever = (name: string) => screen.getByTestId(`declared-action-${name}`); + +beforeEach(() => executeSpy.mockReset().mockResolvedValue({ success: true })); +afterEach(() => vi.restoreAllMocks()); + +describe('DeclaredActionsBar — `pressed` draws the reader\'s own state (objectstack#28242)', () => { + it('the predicate holds: aria-pressed="true" and the pressed face; the sibling vote is unpressed', () => { + mount({ viewer: { vote: 'helpful' } }); + expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'true'); + expect(lever('mark_article_helpful').className).toContain('aria-pressed:bg-accent'); + expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('the envelope arm reads the same: the other vote is the pressed one', () => { + mount({ viewer: { vote: 'not_helpful' } }); + expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false'); + expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('a row with no served block draws both unpressed', () => { + mount({}); + expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false'); + expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('display only: a pressed action stays enabled and still dispatches', async () => { + const user = userEvent.setup(); + mount({ viewer: { vote: 'helpful' } }, [HELPFUL]); + expect(lever('mark_article_helpful')).not.toBeDisabled(); + await user.click(lever('mark_article_helpful')); + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(executeSpy.mock.calls[0][0]).toMatchObject({ name: 'mark_article_helpful' }); + }); + + it('a predicate that cannot be evaluated draws the plain button, and says so', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount({}, [{ ...HELPFUL, pressed: "record.viewer.vote == 'helpful'" }]); + expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false'); + expect(warn.mock.calls.some((c) => String(c[0]).includes('(pressed)'))).toBe(true); + }); + + it('control — an action that declares no predicate is a plain button: no aria-pressed, no face', () => { + mount({ viewer: { vote: 'helpful' } }, [SHARE]); + expect(lever('share_article')).not.toHaveAttribute('aria-pressed'); + expect(lever('share_article').className).not.toContain('aria-pressed:'); + }); +}); diff --git a/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx b/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx new file mode 100644 index 00000000000..f060ed8a25a --- /dev/null +++ b/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx @@ -0,0 +1,108 @@ +/** + * objectstack#28242 — a `page:header` action that declares `pressed` (the + * reader's own state, `ActionSchema.pressed`) is a toggle button: drawn with + * `aria-pressed="true"` and the pressed face while the predicate holds for + * this record, `aria-pressed="false"` while it does not. An action that + * declares no predicate carries no `aria-pressed` at all. + * + * Display only: a pressed action stays enabled and still dispatches, and a + * predicate that cannot be evaluated draws the plain button (never a claim + * that the reader acted) and says so once. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +// Registers `page:header` at module scope (object-ui/no-dynamic-import-in-test-hook). +import '../renderers'; + +const HELPFUL = { + name: 'mark_article_helpful', + label: 'Helpful', + type: 'script', + locations: ['record_header'], + pressed: "has(record.viewer) && record.viewer.vote == 'helpful'", +}; +const NOT_HELPFUL = { + name: 'mark_article_not_helpful', + label: 'Not helpful', + type: 'script', + locations: ['record_header'], + pressed: { dialect: 'cel', source: "has(record.viewer) && record.viewer.vote == 'not_helpful'" }, +}; +const SHARE = { name: 'share_article', label: 'Share', type: 'script', locations: ['record_header'] }; + +function PageHeader({ schema }: { schema: any }) { + const Component = ComponentRegistry.get('page:header'); + if (!Component) throw new Error('page:header not registered'); + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render + return ; +} + +function mount(actions: Record[], record: Record, handler?: (a: any) => any) { + return render( + + + + + , + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('page:header — `pressed` draws the reader\'s own state (objectstack#28242)', () => { + it('the predicate holds: aria-pressed="true" and the pressed face; the sibling vote is unpressed', () => { + mount([HELPFUL, NOT_HELPFUL], { viewer: { vote: 'helpful' } }); + const helpful = screen.getByRole('button', { name: 'Helpful' }); + expect(helpful).toHaveAttribute('aria-pressed', 'true'); + expect(helpful.className).toContain('aria-pressed:bg-accent'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('the envelope arm reads the same: the other vote is the pressed one', () => { + mount([HELPFUL, NOT_HELPFUL], { viewer: { vote: 'not_helpful' } }); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('a row with no served block (fetched by another route) draws both unpressed', () => { + mount([HELPFUL, NOT_HELPFUL], {}); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('display only: a pressed action stays enabled and still dispatches', async () => { + const user = userEvent.setup(); + const handler = vi.fn(async () => ({ success: true })); + mount([{ ...HELPFUL, type: 'vote-run' }], { viewer: { vote: 'helpful' } }, handler); + const helpful = screen.getByRole('button', { name: 'Helpful' }); + expect(helpful).not.toBeDisabled(); + await user.click(helpful); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect((handler.mock.calls[0] as unknown[])[0]).toMatchObject({ name: 'mark_article_helpful' }); + }); + + it('a predicate that cannot be evaluated draws the plain button, and says so', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount([{ ...HELPFUL, pressed: "record.viewer.vote == 'helpful'" }], {}); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(warn.mock.calls.some((c) => String(c[0]).includes('pressed'))).toBe(true); + }); + + it('control — an action that declares no predicate is a plain button: no aria-pressed, no face', () => { + mount([SHARE], { viewer: { vote: 'helpful' } }); + const share = screen.getByRole('button', { name: 'Share' }); + expect(share).not.toHaveAttribute('aria-pressed'); + expect(share.className).not.toContain('aria-pressed:'); + }); +}); diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index ce97a34bfdc..59a991efed7 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -2404,6 +2404,22 @@ export const PageHeaderActions: React.FC = ({ if (!src) return false; return evalHeaderPredicate(d, `page:header action "${String(actionName)}" disabled`, true); }; + // The reader's own state (`ActionSchema.pressed`, objectstack#27946): a + // DECLARED predicate makes the button a toggle, drawn pressed while it is + // TRUE. Same entry and bindings as `visible` / `disabled`; its fail + // direction is "not pressed" (`fallback: false`), said once — a fault + // draws the plain button and never claims the reader acted. Display only: + // nothing here gates, greys or routes on it. `undefined` = no predicate + // (not a toggle); a boolean is refused by the spec at parse, so it gets no + // reading here. + const pressedFor = (action: any): boolean | undefined => { + const p = action?.pressed; + const src = typeof p === 'string' + ? p + : (p && typeof p === 'object' && typeof (p as any).source === 'string' ? (p as any).source : undefined); + if (!src || src.trim() === '') return undefined; + return evalHeaderPredicate(p, `page:header action "${String(action?.name ?? action?.id)}" pressed`, false); + }; // A live inline-edit session disables actions the host flagged with // `disableDuringInlineEdit` (objectui#2572 item 4) — see `inlineEditing` // at the top of the renderer. @@ -2442,6 +2458,7 @@ export const PageHeaderActions: React.FC = ({ const actionKey = action.name || action.id || `header-action-${idx}`; const disabledReason = disabledReasonFor(action); const reasonId = `${disabledReasonIdBase}-reason-${idx}`; + const pressed = pressedFor(action); const button = (