Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/28242-action-pressed-face.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 1 addition & 4 deletions content/docs/protocol/objectui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<block>.<leaf>`, guarded with `has()`. `os build` and `os validate` check the leaf against the declared block.

<Callout type="info">
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.
</Callout>

#### Evaluation context

Action predicates evaluate against the **record the action is attached to**. Both `record.<field>` and the bare field name resolve to the current record's value:
Expand Down
8 changes: 4 additions & 4 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 4 additions & 6 deletions packages/spec/liveness/action.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/state-counts/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
33 changes: 31 additions & 2 deletions ui/packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,28 @@ function useDeclaredActionShown(action: ActionDef, predicateRecord: ReturnType<t
return !(hasDeclaredVisibilityGate(action.visible) && !isVisible);
}

/**
* The reader's own state (`ActionSchema.pressed`, objectstack#27946): does the
* action DECLARE a pressed predicate, and does it hold for this record?
*
* `undefined` = no predicate, so the lever is a plain button and carries no
* `aria-pressed`; otherwise the verdict, drawn as `aria-pressed` and the
* pressed face. Same entry and record binding as `visible` above, with
* `throwOnError` so a predicate that cannot be evaluated answers `false` — the
* plain button, never a claim that the reader acted — and is reported once. A
* boolean is refused by the spec at parse (a constant is a style, not a
* reader's state), so it gets no reading here. Display only: the verdict never
* gates, greys or routes the action.
*/
function useDeclaredActionPressed(action: ActionDef, predicateRecord: ReturnType<typeof usePredicateRecordContext>): 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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('@object-ui/react')>();
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<string, unknown>, actions: Record<string, unknown>[] = [HELPFUL, NOT_HELPFUL]) {
return render(
<DeclaredActionsBar
objectName="kb_article"
record={{ id: 'a-1', ...record }}
location="record_section"
actions={actions as any}
/>,
);
}

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:');
});
});
Loading
Loading