You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/29 14:24:20 UTC

(causeway) 02/06: feat(webcomponents): enhance collection headings

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit da560ae67411ca10224b484643f9aa0c2b1554bf
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 15:53:26 2026 +0200

    feat(webcomponents): enhance collection headings
---
 .../changes/enhance-collection-headings/tasks.md   | 26 +++----
 viewers/webcomponents/foundation/USAGE.adoc        | 12 +++-
 .../foundation/src/collection-element.mjs          | 54 ++++++++++-----
 .../foundation/src/object-context-controller.mjs   |  8 ++-
 viewers/webcomponents/foundation/src/theme.css     | 10 ++-
 .../foundation/test/collection.test.mjs            | 81 ++++++++++++++++++++++
 .../foundation/test/component-styles.test.mjs      |  6 ++
 .../test/fixtures/rich-schema-fixture.mjs          | 14 +++-
 .../foundation/test/member-composition.test.mjs    |  4 +-
 .../foundation/test/object-context.test.mjs        | 50 +++++++++++++
 .../htmx/petclinic/domain/PetClinicHomePage.java   |  4 +-
 .../sample/htmx/petclinic/domain/PetOwner.java     |  4 +-
 .../webcomponents/pages/petclinic.HomePage.html    |  2 +-
 .../webcomponents/pages/petclinic.PetOwner.html    |  6 +-
 .../petclinic/PetClinicHtmxPlaywrightTest.java     | 23 ++++++
 .../PetClinicHtmxApplication_IntegTest.java        | 23 ++++--
 16 files changed, 280 insertions(+), 47 deletions(-)

diff --git a/openspec/changes/enhance-collection-headings/tasks.md b/openspec/changes/enhance-collection-headings/tasks.md
index 3f0bb0b01d8..cdc15b13075 100644
--- a/openspec/changes/enhance-collection-headings/tasks.md
+++ b/openspec/changes/enhance-collection-headings/tasks.md
@@ -1,24 +1,24 @@
 ## 1. Collection metadata and heading behavior
 
-- [ ] 1.1 Extend targeted collection requirements to select supported canonical friendly-name and description metadata.
-- [ ] 1.2 Implement `named` and `described-as` collection attributes with documented precedence, compatibility, duplicate suppression, and reactive rerendering.
-- [ ] 1.3 Remove visible collection-level disabled or unmodifiable reason markup while preserving read-only and member/action semantics.
-- [ ] 1.4 Refine collection description styling below the label with accessible subdued typography.
+- [x] 1.1 Extend targeted collection requirements to select supported canonical friendly-name and description metadata.
+- [x] 1.2 Implement `named` and `described-as` collection attributes with documented precedence, compatibility, duplicate suppression, and reactive rerendering.
+- [x] 1.3 Remove visible collection-level disabled or unmodifiable reason markup while preserving read-only and member/action semantics.
+- [x] 1.4 Refine collection description styling below the label with accessible subdued typography.
 
 ## 2. Foundation regression coverage
 
-- [ ] 2.1 Add object-context tests for targeted collection metadata selection, partial support, and request reuse.
-- [ ] 2.2 Add collection component tests for canonical and HTML heading precedence, reactive attributes, accessibility, duplicate suppression, and quiet unmodifiable state.
-- [ ] 2.3 Update stylesheet and component-contract coverage for the new public attributes and description presentation.
+- [x] 2.1 Add object-context tests for targeted collection metadata selection, partial support, and request reuse.
+- [x] 2.2 Add collection component tests for canonical and HTML heading precedence, reactive attributes, accessibility, duplicate suppression, and quiet unmodifiable state.
+- [x] 2.3 Update stylesheet and component-contract coverage for the new public attributes and description presentation.
 
 ## 3. Petclinic demonstrations
 
-- [ ] 3.1 Add selective `@CollectionLayout(describedAs)` descriptions to Petclinic collections.
-- [ ] 3.2 Add selective `named` and `described-as` overrides to Petclinic HTML pages while leaving other collections metadata-driven or unchanged.
-- [ ] 3.3 Extend Petclinic integration and Playwright assertions for names, descriptions, omitted unmodifiable noise, and unaffected interactions.
+- [x] 3.1 Add selective `@CollectionLayout(describedAs)` descriptions to Petclinic collections.
+- [x] 3.2 Add selective `named` and `described-as` overrides to Petclinic HTML pages while leaving other collections metadata-driven or unchanged.
+- [x] 3.3 Extend Petclinic integration and Playwright assertions for names, descriptions, omitted unmodifiable noise, and unaffected interactions.
 
 ## 4. Validation and archive readiness
 
-- [ ] 4.1 Run foundation tests and package verification.
-- [ ] 4.2 Run Petclinic integration and Playwright acceptance.
-- [ ] 4.3 Run strict OpenSpec validation, compilation, license, diff, and working-tree checks.
+- [x] 4.1 Run foundation tests and package verification.
+- [x] 4.2 Run Petclinic integration and Playwright acceptance.
+- [x] 4.3 Run strict OpenSpec validation, compilation, license, diff, and working-tree checks.
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 5c1d7c08a25..6f218abfd08 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -57,12 +57,16 @@ The `cw-` element namespace replaces the former `causeway-` element namespace wi
 Applications upgrading across this breaking change must replace each former opening and closing custom-element tag and each element-name selector with its one-to-one `cw-` equivalent.
 Semantic `causeway-*` events, CSS classes, `data-causeway-*` attributes, `--causeway-*` variables, and `/causeway-*` asset paths keep their existing spelling.
 
-Properties, actions, collections, and collection columns use the standard `id` attribute and native reflected `id` property to identify their Causeway member, and accept an optional `label` override.
+Properties, actions, collections, and collection columns use the standard `id` attribute and native reflected `id` property to identify their Causeway member.
+Properties, actions, and collection columns accept an optional `label` override.
+Collections accept `named` and `described-as` heading overrides; the existing `label` name override remains compatible, with `named` taking precedence.
 The former `member` attribute and custom `member` property are not compatibility aliases.
 Applications must keep authored IDs unique according to ordinary HTML identifier rules.
 A property exposes interaction only when its `editable` attribute or property is enabled.
 Object links use `logical-type`, `object-id`, `title`, and `disabled`.
 Collections use `active` and declarative collection-column children.
+Without HTML heading overrides, collections use canonical `metadata.friendlyName` and `metadata.description`, followed by a humanized member-id name and no-description fallback.
+Collection-level disabled or unmodifiable reasons are not presented as explanatory text or tooltips; individual cells and associated actions retain their own usability semantics.
 
 The corresponding JavaScript properties support programmatic composition.
 A property or collection accepts a `rendererRegistry`.
@@ -479,7 +483,7 @@ An authored property or collection can place object actions directly beneath the
   <cw-action id="updateName"></cw-action>
 </cw-property>
 
-<cw-collection id="pets" active>
+<cw-collection id="pets" named="Companion animals" described-as="Pets registered to this owner." active>
   <cw-collection-column id="name" label="Name"></cw-collection-column>
   <cw-collection-column id="species" label="Species"></cw-collection-column>
   <cw-action id="addPet"></cw-action>
@@ -505,7 +509,9 @@ Generated `<cw-object>` layouts retain semantically equivalent associated-member
 
 == Lazy collections and row hydration
 
-A collection's primary object requirement contains only advertised `hidden` and `disabled` state.
+A collection's primary object requirement contains advertised `hidden` and `disabled` state plus supported canonical `metadata.friendlyName` and `metadata.description` heading fields.
+The heading resolves `named`, legacy `label`, canonical friendly name, and humanized member id in that order; its description resolves `described-as`, canonical description, and no text in that order.
+A distinct description is associated with the collection and displayed below the heading in subdued smaller text, while a description equal to the resolved name is omitted.
 The `get` field is loaded through a context-owned secondary operation after activation.
 Secondary operations are cached by member and structural row selection, support forced refresh, and are canceled when their component or object context is disconnected.
 
diff --git a/viewers/webcomponents/foundation/src/collection-element.mjs b/viewers/webcomponents/foundation/src/collection-element.mjs
index b6f4f9f650b..416f59bba1f 100644
--- a/viewers/webcomponents/foundation/src/collection-element.mjs
+++ b/viewers/webcomponents/foundation/src/collection-element.mjs
@@ -69,7 +69,7 @@ export function captureDeclarativeCollectionColumns(root = globalThis.document)
 
 export class CausewayCollectionElement extends CausewayContextConsumerElement {
   static get observedAttributes() {
-    return ['id', 'label', 'active'];
+    return ['id', 'named', 'described-as', 'label', 'active'];
   }
 
   constructor() {
@@ -156,6 +156,22 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
     });
   }
 
+  get named() {
+    return this.getAttribute('named') || '';
+  }
+
+  set named(value) {
+    this.setAttribute('named', value);
+  }
+
+  get describedAs() {
+    return this.getAttribute('described-as') || '';
+  }
+
+  set describedAs(value) {
+    this.setAttribute('described-as', value);
+  }
+
   get label() {
     return this.getAttribute('label') || '';
   }
@@ -382,9 +398,9 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
       return;
     }
     this.#qualifyGrid(state);
-    const label = this.label || humanize(this.id);
-    const candidateDescription = state.descriptor?.description || '';
-    const description = candidateDescription.trim().toLocaleLowerCase() === label.trim().toLocaleLowerCase()
+    const label = firstNonBlank(this.named, this.label, state.data?.metadata?.friendlyName, humanize(this.id));
+    const candidateDescription = firstNonBlank(this.describedAs, state.data?.metadata?.description);
+    const description = normalizedText(candidateDescription) === normalizedText(label)
       ? ''
       : candidateDescription;
     const shell = (content, attributes = '') => collectionShell(
@@ -399,7 +415,11 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
       renderMemberPrimary(this, shell('<span role="status">Loading collection metadata…</span>', ' aria-busy="true"'));
       return;
     }
-    if (['terminal-error', 'unsupported', 'partial-error'].includes(state.status)) {
+    const recoverablePartialState = state.status === 'partial-error'
+      && state.data
+      && typeof state.data.hidden === 'boolean';
+    if (['terminal-error', 'unsupported'].includes(state.status)
+        || (state.status === 'partial-error' && !recoverablePartialState)) {
       renderMemberPrimary(this, shell(`<span class="causeway-error" role="alert">${escapeHtml(errorMessage(state))}</span>`));
       return;
     }
@@ -407,14 +427,8 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
       renderMemberPrimary(this, '', {hidden: true});
       return;
     }
-    const disabledReason = typeof state.data?.disabled === 'string'
-      ? state.data.disabled
-      : state.data?.disabled === true ? 'Disabled' : '';
-    const disabledMarkup = disabledReason
-      ? `<p class="causeway-collection-disabled-reason">${escapeHtml(disabledReason)}</p>`
-      : '';
     if (!this.active) {
-      renderMemberPrimary(this, shell(`${disabledMarkup}<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`));
+      renderMemberPrimary(this, shell(`<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`));
       return;
     }
     if (this.collectionState.status === 'idle' || this.collectionState.status === 'loading') {
@@ -434,22 +448,22 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
       ? `<p class="causeway-error" role="alert">${escapeHtml(errorMessage(this.collectionState))}</p>`
       : '';
     if (this._gridQualification.qualified) {
-      this.#renderGrid(shell, disabledMarkup, errors, description);
+      this.#renderGrid(shell, errors, description);
       return;
     }
     const content = this._columns.length > 0
       ? this.#renderTable(rows)
       : this.#renderDefaultRows(rows);
-    renderMemberPrimary(this, shell(`${disabledMarkup}${content}${errors}`));
+    renderMemberPrimary(this, shell(`${content}${errors}`));
     this.#restoreNativeFocus();
   }
 
-  #renderGrid(shell, disabledMarkup, errors, description) {
+  #renderGrid(shell, errors, description) {
     const revision = this.gridHostRevision;
     const bounded = this._gridQualification.presentation === 'grid-bounded';
     const window = this.collectionState.window;
     const pager = bounded ? this.#renderBoundedPager(window) : '';
-    renderMemberPrimary(this, shell(`${disabledMarkup}<${CAUSEWAY_COLLECTION_GRID} data-causeway-collection-grid></${CAUSEWAY_COLLECTION_GRID}>${pager}${errors}`));
+    renderMemberPrimary(this, shell(`<${CAUSEWAY_COLLECTION_GRID} data-causeway-collection-grid></${CAUSEWAY_COLLECTION_GRID}>${pager}${errors}`));
     const adapter = this.querySelector?.(`${CAUSEWAY_COLLECTION_GRID}[data-causeway-collection-grid]`);
     if (!adapter || revision !== this.gridHostRevision || !this.isConnected) return;
     adapter.presentation = {
@@ -750,6 +764,14 @@ function humanize(value) {
   return String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, character => character.toUpperCase());
 }
 
+function firstNonBlank(...values) {
+  return values.find(value => String(value ?? '').trim())?.trim() ?? '';
+}
+
+function normalizedText(value) {
+  return String(value ?? '').trim().toLocaleLowerCase();
+}
+
 function collectionBreakpointPixels() {
   const candidate = globalThis.getComputedStyle?.(globalThis.document?.documentElement)?.fontSize;
   const rootFontSize = Number.parseFloat(candidate);
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index ba8c6579761..10e7ae474ce 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -1301,9 +1301,15 @@ function translateRequirement(requirement, description) {
     throw new Error(`Collection '${requirement.member}' does not expose readable contents.`);
   }
   const supportedFields = ['hidden', 'disabled'].filter(field => member.fields.has(field));
+  const memberSelection = Object.fromEntries(supportedFields.map(field => [field, true]));
+  const metadataFields = ['friendlyName', 'description']
+    .filter(field => member.metadata?.fields.has(field));
+  if (metadataFields.length > 0) {
+    memberSelection.metadata = Object.fromEntries(metadataFields.map(field => [field, true]));
+  }
   return {
     descriptor: member,
-    selection: {[member.id]: Object.fromEntries(supportedFields.map(field => [field, true]))}
+    selection: {[member.id]: memberSelection}
   };
 }
 
diff --git a/viewers/webcomponents/foundation/src/theme.css b/viewers/webcomponents/foundation/src/theme.css
index 23a59da2914..2ea8f618b06 100644
--- a/viewers/webcomponents/foundation/src/theme.css
+++ b/viewers/webcomponents/foundation/src/theme.css
@@ -355,13 +355,19 @@ cw-property:last-child .causeway-property {
   text-align: start;
 }
 
-.causeway-property-description,
-.causeway-collection-description {
+.causeway-property-description {
   grid-column: 2 / -1;
   color: var(--causeway-muted);
   font-size: var(--causeway-font-size-sm);
 }
 
+.causeway-collection-description {
+  margin: 0;
+  padding: var(--causeway-space-2) var(--causeway-space-4);
+  color: var(--causeway-muted);
+  font-size: var(--causeway-font-size-sm);
+}
+
 :where(cw-property[multiline]) .causeway-property-label {
   grid-column: 1;
   grid-row: 1;
diff --git a/viewers/webcomponents/foundation/test/collection.test.mjs b/viewers/webcomponents/foundation/test/collection.test.mjs
index 972b2479023..e27e440322c 100644
--- a/viewers/webcomponents/foundation/test/collection.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection.test.mjs
@@ -558,6 +558,87 @@ test('collection component does not read until activated and renders declared co
   assert.equal(collection.hidden, true);
 });
 
+test('collection component resolves canonical and HTML headings without unmodifiable noise or rereads', async () => {
+  let registrations = 0;
+  let publish;
+  const context = {
+    registerRequirement(_requirement, listener) {
+      registrations += 1;
+      publish = listener;
+      listener({
+        status: 'ready',
+        descriptor: {id: 'staffMembers'},
+        data: {
+          hidden: false,
+          disabled: 'Cannot edit a mixed-in collection.',
+          metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+        },
+        errors: [],
+        generation: 1
+      });
+      return () => {};
+    }
+  };
+  const collection = new CausewayCollectionElement();
+  collection.id = 'staffMembers';
+  collection.context = context;
+  document.body.appendChild(collection);
+  await waitFor(() => collection.innerHTML.includes('Department staff'));
+
+  assert.deepEqual(CausewayCollectionElement.observedAttributes, ['id', 'named', 'described-as', 'label', 'active']);
+  assert.match(collection.innerHTML, /class="causeway-collection-label">Department staff<\/h2>/);
+  assert.match(collection.innerHTML, /class="causeway-collection-description">Current staff members\.<\/p>/);
+  assert.match(collection.innerHTML, /aria-labelledby="causeway-collection-label-/);
+  assert.match(collection.innerHTML, /aria-describedby="causeway-collection-description-/);
+  assert.doesNotMatch(collection.innerHTML, /Cannot edit a mixed-in collection/);
+  assert.doesNotMatch(collection.innerHTML, /causeway-collection-disabled-reason/);
+
+  publish({
+    status: 'partial-error',
+    descriptor: {id: 'staffMembers'},
+    data: {
+      hidden: false,
+      disabled: null,
+      metadata: {friendlyName: 'Department staff', description: null}
+    },
+    errors: [{message: 'Collection description is unavailable.'}],
+    generation: 2
+  });
+  assert.match(collection.innerHTML, /Department staff/);
+  assert.doesNotMatch(collection.innerHTML, /causeway-collection-description/);
+  assert.doesNotMatch(collection.innerHTML, /causeway-error/);
+  publish({
+    status: 'ready',
+    descriptor: {id: 'staffMembers'},
+    data: {
+      hidden: false,
+      disabled: null,
+      metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+    },
+    errors: [],
+    generation: 3
+  });
+
+  collection.label = 'Legacy staff label';
+  assert.match(collection.innerHTML, /Legacy staff label/);
+  collection.named = 'Priority team';
+  collection.describedAs = 'People assigned to priority cases.';
+  assert.match(collection.innerHTML, /class="causeway-collection-label">Priority team<\/h2>/);
+  assert.match(collection.innerHTML, /class="causeway-collection-description">People assigned to priority cases\.<\/p>/);
+
+  collection.describedAs = ' priority TEAM ';
+  assert.doesNotMatch(collection.innerHTML, /causeway-collection-description/);
+  assert.doesNotMatch(collection.innerHTML, /aria-describedby/);
+
+  collection.removeAttribute('named');
+  collection.removeAttribute('described-as');
+  collection.removeAttribute('label');
+  assert.match(collection.innerHTML, /Department staff/);
+  assert.match(collection.innerHTML, /Current staff members/);
+  assert.equal(registrations, 1);
+  document.body.removeChild(collection);
+});
+
 test('collection component forwards window requests and publishes semantic window state', async () => {
   let request;
   const context = {
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
index a4906d8218f..1bbe5cef39f 100644
--- a/viewers/webcomponents/foundation/test/component-styles.test.mjs
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -56,6 +56,12 @@ test('disabled reasons use label-owned tooltips and string values align to logic
   assert.match(theme, /\.causeway-property-value-string \{\s+text-align: start;/);
 });
 
+test('collection descriptions remain quiet text directly below their labels', async () => {
+  const theme = await readFile(new URL('../src/theme.css', import.meta.url), 'utf8');
+  assert.match(theme, /\.causeway-collection-description \{\s+margin: 0;\s+padding: var\(--causeway-space-2\) var\(--causeway-space-4\);\s+color: var\(--causeway-muted\);\s+font-size: var\(--causeway-font-size-sm\);/);
+  assert.doesNotMatch(theme, /\.causeway-property-description,\s+\.causeway-collection-description/);
+});
+
 test('application native-control chrome does not cross into toolkit-owned slotted field inputs', async () => {
   const theme = await readFile(new URL('../src/theme.css', import.meta.url), 'utf8');
   const nativeInput = 'input:not([slot="input"])';
diff --git a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
index 05b6645f0bc..f16a1d748c8 100644
--- a/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/rich-schema-fixture.mjs
@@ -113,12 +113,14 @@ export function createRichSchemaTypes() {
     [COLLECTION_TYPE, objectType(COLLECTION_TYPE, null, [
       field('hidden', null, scalar('Boolean')),
       field('disabled', null, scalar('String')),
+      field('metadata', null, named(MEMBER_METADATA_TYPE)),
       field('get', null, list(named(STAFF_OBJECT_TYPE))),
       field('datatype', null, scalar('String'))
     ])],
     [EMPTY_COLLECTION_TYPE, objectType(EMPTY_COLLECTION_TYPE, null, [
       field('hidden', null, scalar('Boolean')),
       field('disabled', null, scalar('String')),
+      field('metadata', null, named(MEMBER_METADATA_TYPE)),
       field('get', null, list(named(STAFF_OBJECT_TYPE))),
       field('datatype', null, scalar('String'))
     ])],
@@ -301,8 +303,16 @@ export function departmentObjectData({
       get: {name: 'history.txt', mimeType: 'text/plain', chars: '/graphql/object/history/clobChars'}
     },
     unsupportedValue: {hidden: false, disabled: null, get: {nested: {value: 'unknown'}}},
-    staffMembers: {hidden: false, disabled: null},
-    formerStaff: {hidden: false, disabled: null},
+    staffMembers: {
+      hidden: false,
+      disabled: null,
+      metadata: {friendlyName: 'Department staff', description: 'Current staff members.'}
+    },
+    formerStaff: {
+      hidden: false,
+      disabled: null,
+      metadata: {friendlyName: 'Former staff', description: null}
+    },
     changeName: {hidden: false, disabled: null}
   };
 }
diff --git a/viewers/webcomponents/foundation/test/member-composition.test.mjs b/viewers/webcomponents/foundation/test/member-composition.test.mjs
index 165cfe6948f..e79cb13c804 100644
--- a/viewers/webcomponents/foundation/test/member-composition.test.mjs
+++ b/viewers/webcomponents/foundation/test/member-composition.test.mjs
@@ -191,7 +191,9 @@ test('collection keeps interleaved columns and actions in separate vocabularies'
   assert.equal(collection.childNodes[0].hidden, true);
   assert.equal(addPet.isConnected, true);
   context.publish('collection:pets', collectionState({disabled: 'Collection is locked'}));
-  assert.match(collection.innerHTML, /Collection is locked/);
+  assert.doesNotMatch(collection.innerHTML, /Collection is locked/);
+  assert.equal(addPet.isConnected, true);
+  assert.equal(removePet.isConnected, true);
   assert.deepEqual(collection.columns.map(candidate => candidate.member), ['name', 'species']);
   assert.equal(context.count('action:addPet'), 1);
   assert.equal(context.count('action:removePet'), 1);
diff --git a/viewers/webcomponents/foundation/test/object-context.test.mjs b/viewers/webcomponents/foundation/test/object-context.test.mjs
index 953f4fffa56..67569c9397f 100644
--- a/viewers/webcomponents/foundation/test/object-context.test.mjs
+++ b/viewers/webcomponents/foundation/test/object-context.test.mjs
@@ -133,6 +133,56 @@ test('property reads remain compatible when member metadata is unavailable', asy
   assert.equal(propertyState.data.get, 'Classics');
 });
 
+test('collection reads include only supported canonical heading metadata', async () => {
+  const executor = createRichSchemaFixtureExecutor();
+  const context = createContext(executor);
+  let collectionState;
+  context.registerRequirement({kind: 'collection', member: 'staffMembers'}, state => { collectionState = state; });
+  await waitFor(() => context.state.status === 'ready');
+
+  assert.equal(executor.readCalls.length, 1);
+  const collectionDocument = executor.readCalls[0].document;
+  assert.match(collectionDocument, /staffMembers\s*\{[\s\S]*metadata\s*\{/);
+  assert.match(collectionDocument, /\bfriendlyName\b/);
+  assert.match(collectionDocument, /\bdescription\b/);
+  assert.deepEqual(collectionState.data.metadata, {
+    friendlyName: 'Department staff',
+    description: 'Current staff members.'
+  });
+
+  const metadataErrorResponse = graphQLObjectResponse();
+  metadataErrorResponse.data.rich[DEPARTMENT_OBJECT_FIELD].staffMembers.metadata.description = null;
+  metadataErrorResponse.errors = [{
+    message: 'Collection description is unavailable.',
+    path: ['rich', DEPARTMENT_OBJECT_FIELD, 'staffMembers', 'metadata', 'description'],
+    extensions: {classification: 'DataFetchingException'}
+  }];
+  const metadataErrorExecutor = createRichSchemaFixtureExecutor({readResponses: [metadataErrorResponse]});
+  const metadataErrorContext = createContext(metadataErrorExecutor);
+  let metadataErrorState;
+  metadataErrorContext.registerRequirement({kind: 'collection', member: 'staffMembers'}, state => { metadataErrorState = state; });
+  await waitFor(() => metadataErrorContext.state.status === 'partial-error');
+  assert.equal(metadataErrorState.status, 'partial-error');
+  assert.equal(metadataErrorState.data.metadata.friendlyName, 'Department staff');
+  assert.equal(metadataErrorState.data.metadata.description, null);
+
+  const types = createRichSchemaTypes();
+  for (const [name, type] of types) {
+    if (type.fields?.some(field => field.name === 'friendlyName')) {
+      types.set(name, {...type, fields: type.fields.filter(field => field.name !== 'description')});
+    }
+  }
+  const partialExecutor = createRichSchemaFixtureExecutor({types});
+  const partialContext = createContext(partialExecutor);
+  partialContext.registerRequirement({kind: 'collection', member: 'staffMembers'});
+  await waitFor(() => partialContext.state.status === 'ready');
+  const collectionSelection = partialExecutor.readCalls[0].document.slice(
+    partialExecutor.readCalls[0].document.indexOf('staffMembers {')
+  );
+  assert.match(collectionSelection, /metadata\s*\{[\s\S]*friendlyName/);
+  assert.doesNotMatch(collectionSelection, /\bdescription\b/);
+});
+
 test('coalesces layout metadata with semantic member requirements', async () => {
   const executor = createRichSchemaFixtureExecutor();
   const context = createContext(executor);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
index 0851b3203da..01083d9e3fb 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
@@ -60,7 +60,9 @@ public List<PetOwner> getPetOwners() {
     }
 
     @Collection
-    @CollectionLayout(paged = 10)
+    @CollectionLayout(
+            paged = 10,
+            describedAs = "Scheduled visits that have not yet taken place.")
     public List<Visit> getFutureVisits() {
         final LocalDateTime now = clockService.getClock().nowAsLocalDateTime();
         return visitRepository.findByVisitAtAfter(now);
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
index 792497b5f48..3329f504227 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
@@ -110,7 +110,9 @@ public class PetOwner implements Comparable<PetOwner> {
     private LocalDate lastVisit;
 
     @Collection
-    @CollectionLayout(sortedBy = Pet.ByName.class)
+    @CollectionLayout(
+            sortedBy = Pet.ByName.class,
+            describedAs = "Pets currently registered to this owner.")
     @OneToMany(mappedBy = "petOwner", cascade = CascadeType.ALL, orphanRemoval = true)
     private Set<Pet> pets = new TreeSet<>();
 
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
index c065e60886d..4549c8529e0 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.HomePage.html
@@ -26,7 +26,7 @@ <h2 id="petclinic-owner-heading">Pet owners</h2>
     </section>
     <section aria-labelledby="petclinic-visit-heading">
       <h2 id="petclinic-visit-heading">Upcoming visits</h2>
-      <cw-collection id="futureVisits" label="Upcoming visits" active offset="0" size="10">
+      <cw-collection id="futureVisits" named="Next appointments" active offset="0" size="10">
         <cw-collection-column id="visitAt" label="When"></cw-collection-column>
         <cw-collection-column id="reason" label="Reason"></cw-collection-column>
         <cw-collection-column id="notes" label="Notes"></cw-collection-column>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
index cc516415d6c..3c8a91e595b 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html
@@ -55,7 +55,7 @@ <h2 id="petclinic-owner-details-heading">Details</h2>
     <div class="petclinic-object-collections">
       <section class="petclinic-card" aria-labelledby="petclinic-owner-pets-heading">
         <h2 id="petclinic-owner-pets-heading">Pets</h2>
-        <cw-collection id="pets" label="Pets" active offset="0" size="10">
+        <cw-collection id="pets" named="Companion animals" active offset="0" size="10">
           <cw-collection-column id="name" label="Name"></cw-collection-column>
           <cw-collection-column id="species" label="Species"></cw-collection-column>
           <cw-collection-column id="notes" label="Notes"></cw-collection-column>
@@ -66,7 +66,9 @@ <h2 id="petclinic-owner-pets-heading">Pets</h2>
 
       <section class="petclinic-card" aria-labelledby="petclinic-owner-visits-heading">
         <h2 id="petclinic-owner-visits-heading">Visits</h2>
-        <cw-collection id="visits" label="Visits" active offset="0" size="10">
+        <cw-collection id="visits" named="Visit history"
+                       described-as="All visits recorded for this owner's pets."
+                       active offset="0" size="10">
           <cw-collection-column id="visitAt" label="When"></cw-collection-column>
           <cw-collection-column id="reason" label="Reason"></cw-collection-column>
           <cw-collection-column id="notes" label="Notes"></cw-collection-column>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
index 896fabcb33e..b8fee8c5d7c 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/playwright/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxPlaywrightTest.java
@@ -199,6 +199,12 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
         assertFocused(ROUTE_PAGE);
         waitForCollectionRows("petOwners", 4);
         waitForCollectionRows("futureVisits", 3);
+        assertThat(page.locator("cw-collection[id='futureVisits'] .causeway-collection-label").innerText())
+                .isEqualTo("Next appointments");
+        assertThat(page.locator("cw-collection[id='futureVisits'] .causeway-collection-description").innerText())
+                .isEqualTo("Scheduled visits that have not yet taken place.");
+        assertThat(page.locator("cw-collection[id='petOwners'] .causeway-collection-description").count())
+                .isZero();
         assertCollectionPresentation("petOwners", "narrow");
         assertCollectionPresentation("futureVisits", "narrow");
         assertThat(toolkitRequests.stream().filter(url -> url.contains("/vaadin-menubar/vaadin-menubar.js")).count())
@@ -223,6 +229,12 @@ void routesHomeObjectsHistoryCollectionsAndResponsiveLayout() {
         assertFocused(ROUTE_PAGE);
         waitForCollectionRows("pets", 2);
         waitForCollectionRows("visits", 2);
+        assertCollectionHeading("pets", "Companion animals", "Pets currently registered to this owner.");
+        assertCollectionHeading("visits", "Visit history", "All visits recorded for this owner's pets.");
+        assertThat(page.locator("cw-collection .causeway-collection-disabled-reason").count()).isZero();
+        assertThat(page.locator("cw-collection [title*='Cannot edit']").count()).isZero();
+        assertThat(page.locator("cw-collection").allTextContents())
+                .noneMatch(text -> text.contains("Cannot edit a mixed-in collection"));
         assertCollectionPresentation("pets", "grid");
         assertCollectionPresentation("visits", "ordering-not-deterministic");
         assertThat(page.locator(".petclinic-page-toolbar cw-action[id='delete']").count()).isEqualTo(1);
@@ -1035,6 +1047,17 @@ private void waitForCollectionRows(final String member, final int count) {
         }
     }
 
+    private void assertCollectionHeading(
+            final String member,
+            final String expectedName,
+            final String expectedDescription) {
+        final var collection = page.locator("cw-collection[id='" + member + "']");
+        assertThat(collection.locator(".causeway-collection-label").innerText()).isEqualTo(expectedName);
+        assertThat(collection.locator(".causeway-collection-description").innerText()).isEqualTo(expectedDescription);
+        assertThat(collection.locator(".causeway-collection").getAttribute("aria-labelledby")).isNotBlank();
+        assertThat(collection.locator(".causeway-collection").getAttribute("aria-describedby")).isNotBlank();
+    }
+
     private void assertCollectionPresentation(final String member, final String expected) {
         final var collection = page.locator("cw-collection[id='" + member + "']");
         if (nativeToolkit() || !"grid".equals(expected)) {
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
index bc83c37d7ef..04a2291ccf8 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
@@ -216,12 +216,22 @@ void packagesHtmlPagesAndRetainsLayoutFallbackResources() throws Exception {
                 .contains("id=\"knownAs\" editable\n                       described-as=\"The familiar or preferred name used by this owner.\"")
                 .contains("id=\"notes\" editable multi-line=\"5\"")
                 .contains("id=\"lastVisit\" editable label-position=\"TOP\"")
-                .contains("<cw-collection id=\"pets\"")
+                .contains("<cw-collection id=\"pets\" named=\"Companion animals\"")
                 .contains("<cw-action id=\"addPet\"")
                 .contains("<cw-action id=\"removePet\"")
-                .contains("<cw-collection id=\"visits\"")
+                .contains("<cw-collection id=\"visits\" named=\"Visit history\"")
+                .contains("described-as=\"All visits recorded for this owner's pets.\"")
                 .contains("<cw-action id=\"bookVisit\"")
                 .doesNotContain("petclinic-associated-actions", "petclinic-member-composition");
+        final String homeHtml;
+        try (var input = loader.getResource(
+                "META-INF/causeway/webcomponents/pages/petclinic.HomePage.html").openStream()) {
+            homeHtml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        assertThat(homeHtml)
+                .contains("<cw-collection id=\"futureVisits\" named=\"Next appointments\"")
+                .contains("<cw-collection id=\"petOwners\" label=\"Pet owners\"")
+                .doesNotContain("id=\"petOwners\" named=", "id=\"petOwners\" described-as=");
         assertThat(ownerHtml.indexOf("id=\"addPet\""))
                 .isLessThan(ownerHtml.indexOf("id=\"removePet\""));
         assertThat(get("/META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html").statusCode())
@@ -319,8 +329,8 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
                     petclinic_PetOwner(object: {id: "s_owner-mary"}) {
                       _meta { id logicalTypeName title grid }
                       name { get }
-                      pets { get { _meta { id logicalTypeName title } } }
-                      visits { get { _meta { id logicalTypeName title } } }
+                      pets { metadata { friendlyName description } get { _meta { id logicalTypeName title } } }
+                      visits { metadata { friendlyName description } get { _meta { id logicalTypeName title } } }
                       addPet { hidden disabled params { name { validity datatype } } }
                       bookVisit { hidden disabled }
                     }
@@ -329,6 +339,11 @@ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exc
                 """);
         assertNoGraphQLErrors(owner);
         assertThat(owner.at("/data/rich/petclinic_PetOwner/name/get").asText()).isEqualTo("Mary Smith");
+        assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/metadata/friendlyName").asText()).isEqualTo("Pets");
+        assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/metadata/description").asText())
+                .isEqualTo("Pets currently registered to this owner.");
+        assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/metadata/friendlyName").asText()).isEqualTo("Visits");
+        assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/metadata/description").isNull()).isTrue();
         assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/get").size()).isEqualTo(2);
         assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/get").size()).isEqualTo(2);
     }