diff --git a/.changeset/13103-heading-select-label.md b/.changeset/13103-heading-select-label.md
new file mode 100644
index 0000000000..a9914d74f8
--- /dev/null
+++ b/.changeset/13103-heading-select-label.md
@@ -0,0 +1,7 @@
+---
+'@object-ui/components': patch
+---
+
+The record page heading labels a select `nameField` (objectui#13103). When an object's declared `nameField` (or `displayNameField`) is a select field, the record page H1 now shows the value's option label, for example `Config change`, instead of its stored value `config_change`. The heading's declared-pointer rung reads the record through the same option-label mapping its `titleFormat` rung already uses, so one select field reads the same through either rung. A text `nameField`, the order of the heading's rungs and the field-level-security reads are unchanged.
+
+Nothing is added to the package entry: no export, prop, type member or language-pack key.
diff --git a/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx b/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx
new file mode 100644
index 0000000000..60efe83fdb
--- /dev/null
+++ b/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx
@@ -0,0 +1,142 @@
+/**
+ * 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.
+ */
+
+/**
+ * The record page H1's declared-pointer rung labels a select `nameField`
+ * (objectui#13103).
+ *
+ * ## The defect
+ *
+ * `page:header`'s first record rung reads the declared `nameField` off the
+ * record as served, while the `titleFormat` rung one step down reads the
+ * record through `withOptionLabels`. So one select field headed the page as
+ * its option label through the template and as its stored value through the
+ * pointer, and the pointer outranks the template. The card's case: an audit
+ * row whose object declares `nameField: 'action'` read `config_change`.
+ *
+ * ## What is pinned
+ *
+ * - THE CARD: a select `nameField` heads the page with its option label, with
+ * and without a `titleFormat` beside it (the pointer rung answers first).
+ * - CONTROLS: a text `nameField` heads the page with its value unchanged, even
+ * when another select field on the object declares that same string as an
+ * option value; a value no option declares renders raw; the `titleFormat`
+ * rung still labels a select token.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+import { ActionProvider, RecordContextProvider } from '@object-ui/react';
+// Registers `page:header` at module scope, NOT inside a `beforeAll` (see
+// page-header-title.oneInterpolator-10447.test.tsx).
+import '../renderers';
+
+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 ;
+}
+
+/** Render the real `page:header` for one record and return its H1 text. */
+function h1Of(objectSchema: any, record: any): string | null | undefined {
+ const { container } = render(
+
+
+
+
+ ,
+ );
+ return container.querySelector('h1')?.textContent;
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+const actionOptions = [
+ { value: 'config_change', label: 'Config change' },
+ { value: 'login', label: 'Sign in' },
+];
+
+const auditLog = (extra: Record = {}) => ({
+ name: 'audit_log',
+ label: 'Audit Log',
+ nameField: 'action',
+ fields: {
+ action: { type: 'select', label: 'Action', options: actionOptions },
+ actor: { type: 'text', label: 'Actor' },
+ },
+ ...extra,
+});
+
+describe('page:header H1 — the declared-pointer rung labels a select nameField (#13103)', () => {
+ it('THE CARD — a select nameField heads the page with its option label', () => {
+ const record = { id: 'rec-1', action: 'config_change', actor: 'ada' };
+
+ expect(h1Of(auditLog(), record)).toBe('Config change');
+ });
+
+ it('the pointer rung still outranks a titleFormat, and answers with the label', () => {
+ const record = { id: 'rec-2', action: 'login', actor: 'ada' };
+
+ expect(h1Of(auditLog({ titleFormat: '{actor}' }), record)).toBe('Sign in');
+ });
+
+ it('the array form of `fields` labels the same way', () => {
+ const record = { id: 'rec-3', action: 'config_change' };
+ const schema = auditLog();
+ const arrayFields = Object.entries(schema.fields).map(([name, def]) => ({ name, ...def }));
+
+ expect(h1Of({ ...schema, fields: arrayFields }, record)).toBe('Config change');
+ });
+
+ it('CONTROL — a value no option declares renders raw', () => {
+ const record = { id: 'rec-4', action: 'purge' };
+
+ expect(h1Of(auditLog(), record)).toBe('purge');
+ });
+
+ it('CONTROL — a text nameField heads the page with its value unchanged', () => {
+ // `status` declares `config_change` as an option value; the pointer names
+ // the text field `subject`, whose value is that same string, and it stays.
+ const schema = {
+ name: 'ticket',
+ label: 'Ticket',
+ nameField: 'subject',
+ fields: {
+ subject: { type: 'text', label: 'Subject' },
+ status: { type: 'select', label: 'Status', options: actionOptions },
+ },
+ };
+ const record = { id: 'rec-5', subject: 'config_change', status: 'login' };
+
+ expect(h1Of(schema, record)).toBe('config_change');
+ });
+
+ it('CONTROL — the titleFormat rung still labels a select token', () => {
+ const schema = {
+ name: 'ticket',
+ label: 'Ticket',
+ titleFormat: '{deal_no} · {status}',
+ fields: {
+ deal_no: { type: 'autonumber', label: 'Deal No' },
+ status: { type: 'select', label: 'Status', options: actionOptions },
+ },
+ };
+ const record = { id: 'rec-6', deal_no: 'Q3-042', status: 'login' };
+
+ expect(h1Of(schema, record)).toBe('Q3-042 · Sign in');
+ });
+});
diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx
index 0186071109..c4acc45de2 100644
--- a/packages/components/src/renderers/layout/containers.tsx
+++ b/packages/components/src/renderers/layout/containers.tsx
@@ -337,8 +337,8 @@ type FieldOptionLabel = (objectName: string, fieldName: string, value: string, f
*
* The ONE place this file maps an enum value to its label. Two readers call
* it: the `{token}` interpolation below (`page:header`'s `title` /
- * `subtitle`), and the record copy the `titleFormat` rung hands to core's
- * `formatTitleTemplate` ({@link withOptionLabels}, objectui#10447).
+ * `subtitle`), and the record copy the H1's `titleFormat` and declared-pointer
+ * rungs read ({@link withOptionLabels}, objectui#10447, objectui#13103).
*/
const optionLabelFor = (
objectSchema: any,
@@ -2593,10 +2593,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => {
// unresolved token. The one thing the H1 adds is the translated option
// label for a select value, applied to a copy of the record
// (`withOptionLabels`) so the formatter's signature does not move.
- const interpolatedTitleFormat = formatTitleTemplate(
- objSchema?.titleFormat,
- withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName),
- );
+ //
+ // The same labelled copy feeds the declared-pointer rung below
+ // (objectui#13103): a select `nameField` heads the page with its option
+ // label, exactly as the same field does through the template.
+ const labelledData = withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName);
+ const interpolatedTitleFormat = formatTitleTemplate(objSchema?.titleFormat, labelledData);
// Unified resolver (ADR-0079): honours the object's declared
// `nameField`/`displayNameField` and falls back to type-aware field
// derivation. `deriveFromRecordKeys: false` keeps bare record-key
@@ -2658,9 +2660,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => {
// WITHOUT a `titleFormat` this rung answers exactly what `unifiedTitle`
// answered before it; only the template's rank moved. A blank value here
// falls through to the template, just as `getRecordDisplayName` walks from
- // a blank step 1+2 to step 3.
+ // a blank step 1+2 to step 3. It reads the record through the same
+ // `withOptionLabels` copy the template rung reads (objectui#13103), so a
+ // select pointer shows its option label, not its stored value; a non-
+ // select pointer reads the same value it reads on the raw record.
const declaredTitle = (() => {
- const resolved = recordDisplayValueAt(data, declaredNameField(objSchema));
+ const resolved = recordDisplayValueAt(labelledData, declaredNameField(objSchema));
return resolved && !isResolverFloor(resolved) ? resolved : '';
})();
const titleCandidate =