diff --git a/.changeset/27521-studio-authoring-suggestions.md b/.changeset/27521-studio-authoring-suggestions.md
new file mode 100644
index 00000000000..f41fcd24745
--- /dev/null
+++ b/.changeset/27521-studio-authoring-suggestions.md
@@ -0,0 +1,19 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+'@object-ui/console': patch
+---
+
+fix(studio): plainer authoring in Studio — option labels first, named validation rules, labelled condition fields, object labels on flow cards, honest field counts, a Create that says why it is off, and a publish toast that points to Access
+
+Clause-②: no
+Metadata-protocol: none
+
+- **Picklist options** read Label first, and a new option's stored value is filled in from its label until the author types one, so three labelled options are three saved options. An authored option's value never moves when it is relabelled. The lifecycle kind (Open / Won / Lost) is offered only on the field the record path follows, or where an option already declares one.
+- **Validation rules**: a new rule's API name follows its message (`Hours over 40` → `hours_over_40`) until the name is edited; an authored rule keeps its name. The condition builder lists fields by label, with the CEL spelling beside it, under "Pick a field". The rule type, severity and name labels are in plain words.
+- **Flow canvas**: a node card names the object it works on by the object's label.
+- **Object header**: counts the fields the canvas shows, and the platform's own columns apart (`2 fields (+8 system)`); "1 field" is singular.
+- **Add field**: a Create that cannot run yet stays focusable and says what is missing.
+- **Read-only inspectors**: disabled text areas take the muted disabled look.
+- **Publish**: the "one atomic release" wording is plain ("together: all of them go live, or none do"), and a publish that put an app or object live says members need a permission set, with an "Open Access" action.
+- **Studio access refusal** names who grants access (an administrator, in Setup → Permission Sets), in every language pack and in the Console's own fallback sentence. The publish confirm note's all-or-nothing wording is translated in every pack too.
diff --git a/ui/apps/console/src/components/StudioRoute.test.tsx b/ui/apps/console/src/components/StudioRoute.test.tsx
index 94319967173..dd5cfbcf304 100644
--- a/ui/apps/console/src/components/StudioRoute.test.tsx
+++ b/ui/apps/console/src/components/StudioRoute.test.tsx
@@ -152,7 +152,7 @@ const pathname = () => screen.getByTestId('pathname').textContent;
* answers. That the packs answer in the caller's language is the sibling
* file's pin.
*/
-const NOTICE = 'You need Studio access to open Studio.';
+const NOTICE = 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.';
const WAY_HOME = 'Back to home';
/**
diff --git a/ui/apps/console/src/components/StudioRoute.tsx b/ui/apps/console/src/components/StudioRoute.tsx
index 594ca538176..ce27969accc 100644
--- a/ui/apps/console/src/components/StudioRoute.tsx
+++ b/ui/apps/console/src/components/StudioRoute.tsx
@@ -98,7 +98,7 @@ function StudioAccessRequired({ home }: { home: string }) {
{t('console.studio.accessRequired', {
- defaultValue: 'You need Studio access to open Studio.',
+ defaultValue: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.',
})}
diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
index a2f96ea9e4e..abf7401edba 100644
--- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
+++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
@@ -1669,7 +1669,7 @@ export function DraftChangesPanel({
? t('preview.changes.confirmNote', {
count: entries!.length,
defaultValue:
- 'Publishing releases all {{count}} pending drafts of this package atomically.',
+ 'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.',
})
: t('preview.changes.confirmNoteSeparate', {
count: entries!.length,
diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx
index 18790c087bf..0d9f5701c47 100644
--- a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx
+++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx
@@ -100,7 +100,7 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
renderSheet('com.example.showcase');
await waitFor(async () =>
- expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package atomically.'),
+ expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package: it goes live, or nothing changes.'),
);
});
@@ -111,6 +111,6 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
]);
renderSheet('com.example.showcase');
- expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package atomically.');
+ expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package together: all of them go live, or none do.');
});
});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx
new file mode 100644
index 00000000000..dcd70d1539d
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx
@@ -0,0 +1,93 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * ConditionBuilder — a field subject reads by its label (objectstack#27521,
+ * Studio row 26).
+ *
+ * The Validations pillar's "Reject the save when…" listed `record.estimated_hours`
+ * under the placeholder "field / context", while the list-view filter in the
+ * same product showed labels. A field subject now reads by its label, with the
+ * spelling the CEL uses beside it; the compiled CEL is unchanged. A field with
+ * no label keeps its spelling, and the context subjects are as before. The
+ * label is the mount's choice (`labelSubjects`): a mount that lists spellings
+ * keeps them.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { render, cleanup, screen, fireEvent, within } from '@testing-library/react';
+
+const state = vi.hoisted(() => ({
+ metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
+}));
+vi.mock('../useMetadata', () => ({
+ useMetadataClient: () => state.metadataClient,
+}));
+
+import { ConditionBuilder } from './ConditionBuilder';
+import { t } from '../i18n';
+
+afterEach(cleanup);
+
+const FIELDS = [
+ { name: 'estimated_hours', label: 'Estimated Hours', type: 'number' },
+ { name: 'code', type: 'text' },
+];
+
+function mount(includePrevious = false, labels = true) {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.condition.add', 'en-US') }));
+ return { onChange };
+}
+
+async function openSubjects() {
+ const trigger = screen.getAllByRole('combobox')[0];
+ expect(trigger).toHaveTextContent(t('engine.inspector.condition.subjectPlaceholder', 'en-US'));
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' });
+ return within(await screen.findByRole('listbox')).getAllByRole('option');
+}
+
+describe('ConditionBuilder · field subjects read by label (objectstack#27521)', () => {
+ it('a labelled field reads by its label with its spelling beside it; an unlabelled one by its spelling', async () => {
+ mount();
+ const options = await openSubjects();
+ const texts = options.map((o) => o.textContent);
+ expect(texts).toContain('Estimated Hoursrecord.estimated_hours');
+ expect(texts).toContain('record.code');
+ // The context subjects are unchanged.
+ expect(texts).toContain('user.id');
+ });
+
+ it('picking the labelled field still compiles to its CEL spelling', async () => {
+ const { onChange } = mount();
+ const options = await openSubjects();
+ fireEvent.click(options.find((o) => o.textContent?.startsWith('Estimated Hours'))!);
+ fireEvent.change(screen.getByPlaceholderText(t('engine.inspector.condition.valuePlaceholder', 'en-US')), {
+ target: { value: '40' },
+ });
+ expect(JSON.stringify(onChange.mock.calls.at(-1)?.[0])).toContain('record.estimated_hours');
+ });
+
+ it('a previous-value subject names the field and that it is the value before the change', async () => {
+ mount(true);
+ const texts = (await openSubjects()).map((o) => o.textContent);
+ expect(texts).toContain('Estimated Hours (before the change)previous.estimated_hours');
+ });
+
+ it('CONTROL — a mount that does not ask for labels lists the spellings', async () => {
+ mount(false, false);
+ const texts = (await openSubjects()).map((o) => o.textContent);
+ expect(texts).toContain('record.estimated_hours');
+ expect(texts.some((x) => x?.includes('Estimated Hours'))).toBe(false);
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx
index f2683be26e0..963183b4ba5 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx
@@ -31,7 +31,7 @@ import { BOOLEAN_VALUE_TYPES, expandEmptyOperator, type EmptyOperatorArm } from
import { useObjectFields } from '../previews/useObjectFields.js';
import { CelPredicateField } from '../CelPredicateField.js';
import type { CelLintIssue } from '../celAuthoring.js';
-import { t, useMetadataLocale } from '../i18n.js';
+import { t, tFormat, useMetadataLocale } from '../i18n.js';
type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy';
@@ -820,7 +820,7 @@ function initFrom(value: string, valueOf: SubjectValueOf): { rows: Row[]; join:
return { rows: [], join: '&&', raw: !!value };
}
-export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue }: {
+export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue, labelSubjects = false }: {
label?: string;
value: string;
onCommit: (cel: string) => void;
@@ -906,6 +906,11 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
* builder renders no verdict of its own, so it does not read this.
*/
scopeIssue?: boolean;
+ /**
+ * The subject dropdown names a field by its label, with the CEL spelling
+ * beside it. Off by default, so a mount that lists spellings keeps them.
+ */
+ labelSubjects?: boolean;
}) {
const { fields: hookFields } = useObjectFields(objectName);
const fields = fieldsProp ?? hookFields;
@@ -921,27 +926,32 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
* advertisement and the placeholder teaches its whole example, both unchanged.
*/
const offeredRoots = roots ?? (scope === 'record' ? RECORD_CONDITION_ROOTS : undefined);
+ const locale = useMetadataLocale();
const subjectOptions = React.useMemo(() => {
const visible = fields.filter((f) => !f.hidden);
- const fieldOpts = visible.map((f) => ({
- value: `${fieldPrefix}${f.name}`,
- label: `${fieldPrefix}${f.name}`,
- }));
+ // With `labelSubjects`, a field reads by its label, its spelling beside it.
+ const named = (spelling: string, label: string | undefined) =>
+ labelSubjects && label ? { value: spelling, label, hint: spelling } : { value: spelling, label: spelling, hint: undefined };
+ const fieldOpts = visible.map((f) => named(`${fieldPrefix}${f.name}`, f.label));
// The whole-record `previous` comes first: `previous == null` is how an
// author branches on the create leg of a create-or-update trigger, and it
// reads as the head of the group rather than as one more field.
const previousOpts = includePrevious
? [
- { value: 'previous', label: 'previous' },
- ...visible.map((f) => ({ value: `previous.${f.name}`, label: `previous.${f.name}` })),
+ { value: 'previous', label: 'previous', hint: undefined },
+ ...visible.map((f) =>
+ named(
+ `previous.${f.name}`,
+ f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : undefined,
+ ),
+ ),
]
: [];
- const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value }));
+ const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: undefined }));
return [...fieldOpts, ...previousOpts, ...contextOpts];
- }, [fields, fieldPrefix, includePrevious, contextSubjects]);
+ }, [fields, fieldPrefix, includePrevious, contextSubjects, locale, labelSubjects]);
// The raw-expression editor's CEL assists (#1582): field-existence lint +
// autocomplete need the bare field-name catalog and a locale-bound `t`.
- const locale = useMetadataLocale();
const tLocal = React.useCallback((k: string) => t(k, locale), [locale]);
const fieldNames = React.useMemo(
() => fields.filter((f) => !f.hidden).map((f) => f.name),
@@ -1145,7 +1155,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f