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/19 05:18:30 UTC
(causeway) branch htmx updated (155c5800a17 -> fd1ce43f7cc)
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a change to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
from 155c5800a17 archives composite object web component
new 098ea793bd4 promotes and reconciles menubar web components
new 86e424d1a25 adds menubar web components
new 0e4e33f9a60 archives menubar web components
new d03c243a4e0 promotes and reconciles generic HTMX viewer
new fd1ce43f7cc adds generic HTMX web component viewer
The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
bom/pom.xml | 7 +
.../.openspec.yaml | 0
.../design.md | 190 ++++++
.../proposal.md | 47 ++
.../generic-htmx-web-component-viewer/spec.md | 227 +++++++
.../add-generic-htmx-web-component-viewer/tasks.md | 53 ++
.../.openspec.yaml | 0
.../design.md | 142 +++++
.../proposal.md | 37 ++
.../specs/domain-web-components/spec.md | 152 +++++
.../2026-08-18-add-menubar-web-components/tasks.md | 38 ++
.../planned-changes/web-component-viewer/README.md | 10 +-
.../design.md | 97 ---
.../proposal.md | 36 --
.../generic-htmx-web-component-viewer/spec.md | 90 ---
.../add-generic-htmx-web-component-viewer/tasks.md | 35 --
.../add-menubar-web-components/design.md | 96 ---
.../add-menubar-web-components/proposal.md | 32 -
.../specs/domain-web-components/spec.md | 96 ---
.../add-menubar-web-components/tasks.md | 35 --
openspec/specs/domain-web-components/spec.md | 151 +++++
.../ExecutionGraphQlServiceForCauseway.java | 25 +-
.../integration/GraphQlSourceForCauseway.java | 24 +-
viewers/webcomponents/README.adoc | 57 +-
.../webcomponents/canonical-route-fixtures.yaml | 30 +
viewers/webcomponents/foundation/README.adoc | 7 +-
viewers/webcomponents/foundation/USAGE.adoc | 86 ++-
.../foundation/src/application-operations.mjs | 96 +++
.../foundation/src/collection-element.mjs | 83 ++-
.../foundation/src/component-contracts.mjs | 18 +-
.../{component-styles.mjs => component-styles.css} | 153 ++++-
.../foundation/src/component-styles.mjs | 142 +++++
.../foundation/src/context-events.mjs | 13 +
.../foundation/src/graphql-client.mjs | 162 ++++-
viewers/webcomponents/foundation/src/index.mjs | 7 +
.../src/interaction-controller-element.mjs | 107 +++-
.../foundation/src/interaction-operations.mjs | 36 +-
.../webcomponents/foundation/src/introspection.mjs | 8 +
.../foundation/src/menu-context-controller.mjs | 343 +++++++++++
.../webcomponents/foundation/src/menu-layout.mjs | 311 ++++++++++
.../foundation/src/menubar-element.mjs | 381 ++++++++++++
.../foundation/src/menubars-element.mjs | 208 +++++++
.../foundation/src/object-context-controller.mjs | 66 +--
.../webcomponents/foundation/src/object-layout.mjs | 165 +-----
viewers/webcomponents/foundation/src/register.mjs | 10 +
.../foundation/src/service-action-context.mjs | 454 ++++++++++++++
.../foundation/src/structural-resource.mjs | 86 +++
.../foundation/src/structural-xml.mjs | 215 +++++++
viewers/webcomponents/foundation/src/theme.css | 654 +++++++++++++++++++++
.../test/application-menu-client.test.mjs | 205 +++++++
.../foundation/test/collection.test.mjs | 6 +-
.../component-styles.test.mjs} | 29 +-
.../test/fixtures/menu-graphql-fixture.mjs | 269 +++++++++
.../test/fixtures/menu-layout-fixtures.mjs | 78 +++
.../foundation/test/interaction.test.mjs | 3 +
.../foundation/test/menu-context.test.mjs | 161 +++++
.../foundation/test/menu-layout.test.mjs | 116 ++++
.../foundation/test/menubar-component.test.mjs | 175 ++++++
viewers/webcomponents/htmx/README.adoc | 96 +++
.../webcomponents/{sample-html => htmx}/pom.xml | 71 ++-
.../CausewayModuleViewerWebcomponentsHtmx.java | 50 ++
.../viewer/webcomponents/htmx/HtmxObjectRoute.java | 19 +-
.../htmx/HtmxPageFragmentFactory.java | 21 +-
.../htmx/HtmxPageFragmentRegistry.java | 51 ++
.../webcomponents/htmx/HtmxPageRenderer.java | 203 +++++++
.../viewer/webcomponents/htmx/HtmxRouteCodec.java | 191 ++++++
.../webcomponents/htmx/HtmxViewerController.java | 105 ++++
.../webcomponents/htmx/HtmxViewerProperties.java | 80 +++
.../htmx/InvalidHtmxRouteException.java | 10 +-
.../resources/causeway-htmx/causeway-htmx.css | 252 ++++++++
.../resources/causeway-htmx/causeway-htmx.mjs | 304 ++++++++++
.../resources/causeway-htmx/route-policy.mjs | 68 +++
.../htmx/HtmxPageFragmentRegistryTest.java | 64 ++
.../webcomponents/htmx/HtmxRouteCodecTest.java | 87 +++
.../htmx/HtmxViewerControllerTest.java | 126 ++++
.../webcomponents/htmx/test/route-policy.test.mjs | 64 ++
viewers/webcomponents/pom.xml | 16 +
viewers/webcomponents/sample-html/README.adoc | 89 ++-
viewers/webcomponents/sample-html/pom.xml | 4 +-
.../sample/html/domain/SampleMenu.java | 118 ++++
.../src/main/resources/menubars.layout.xml | 56 ++
.../main/resources/static/sample-html/index.html | 562 +++++-------------
.../html/SampleHtmlApplication_IntegTest.java | 153 ++++-
.../sample-htmx-petclinic/PROVENANCE.adoc | 20 +
.../sample-htmx-petclinic/README.adoc | 66 +++
.../{sample-html => sample-htmx-petclinic}/pom.xml | 23 +-
.../petclinic/PetClinicHomeFragmentFactory.java | 60 ++
.../htmx/petclinic/PetClinicHtmxApplication.java} | 16 +-
.../htmx/petclinic/PetClinicLandingController.java | 21 +-
.../sample/htmx/petclinic/domain/Pet.java | 143 +++++
.../domain/PetClinicDataConfiguration.java | 101 ++++
.../petclinic/domain/PetClinicDomainModule.java} | 10 +-
.../htmx/petclinic/domain/PetClinicHomePage.java | 68 +++
.../sample/htmx/petclinic/domain/PetOwner.java | 267 +++++++++
.../htmx/petclinic/domain/PetOwnerRepository.java | 68 +++
.../htmx/petclinic/domain/PetOwner_bookVisit.java | 88 +++
.../htmx/petclinic/domain/PetOwner_visits.java} | 41 +-
.../sample/htmx/petclinic/domain/PetOwners.java | 79 +++
.../sample/htmx/petclinic/domain/PetSpecies.java | 14 +-
.../sample/htmx/petclinic/domain/Visit.java | 135 +++++
.../htmx/petclinic/domain/VisitRepository.java | 58 ++
.../sample/htmx/petclinic/domain/Visits.java | 51 ++
.../src/main/resources/application.properties | 10 +-
.../src/main}/resources/menubars.layout.xml | 37 +-
.../sample/htmx/petclinic/domain/Pet.layout.xml | 32 +
.../PetClinicHomePage#futureVisits.columnOrder.txt | 4 +
.../PetClinicHomePage#petOwners.columnOrder.txt | 5 +
.../petclinic/domain/PetClinicHomePage.layout.xml | 31 +
.../petclinic/domain/PetOwner#pets.columnOrder.txt | 3 +
.../domain/PetOwner#visits.columnOrder.txt | 4 +
.../htmx/petclinic/domain/PetOwner.layout.xml | 69 +++
.../sample/htmx/petclinic/domain/Visit.layout.xml | 32 +
.../src/main/resources/static/css/application.css | 66 +++
.../PetClinicHtmxApplication_IntegTest.java | 235 ++++++++
114 files changed, 9884 insertions(+), 1393 deletions(-)
rename openspec/{planned-changes/web-component-viewer => changes}/add-generic-htmx-web-component-viewer/.openspec.yaml (100%)
create mode 100644 openspec/changes/add-generic-htmx-web-component-viewer/design.md
create mode 100644 openspec/changes/add-generic-htmx-web-component-viewer/proposal.md
create mode 100644 openspec/changes/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
create mode 100644 openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
rename openspec/{planned-changes/web-component-viewer/add-menubar-web-components => changes/archive/2026-08-18-add-menubar-web-components}/.openspec.yaml (100%)
create mode 100644 openspec/changes/archive/2026-08-18-add-menubar-web-components/design.md
create mode 100644 openspec/changes/archive/2026-08-18-add-menubar-web-components/proposal.md
create mode 100644 openspec/changes/archive/2026-08-18-add-menubar-web-components/specs/domain-web-components/spec.md
create mode 100644 openspec/changes/archive/2026-08-18-add-menubar-web-components/tasks.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/design.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/proposal.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/tasks.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-menubar-web-components/design.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-menubar-web-components/proposal.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-menubar-web-components/specs/domain-web-components/spec.md
delete mode 100644 openspec/planned-changes/web-component-viewer/add-menubar-web-components/tasks.md
create mode 100644 viewers/webcomponents/canonical-route-fixtures.yaml
create mode 100644 viewers/webcomponents/foundation/src/application-operations.mjs
copy viewers/webcomponents/foundation/src/{component-styles.mjs => component-styles.css} (51%)
create mode 100644 viewers/webcomponents/foundation/src/menu-context-controller.mjs
create mode 100644 viewers/webcomponents/foundation/src/menu-layout.mjs
create mode 100644 viewers/webcomponents/foundation/src/menubar-element.mjs
create mode 100644 viewers/webcomponents/foundation/src/menubars-element.mjs
create mode 100644 viewers/webcomponents/foundation/src/service-action-context.mjs
create mode 100644 viewers/webcomponents/foundation/src/structural-resource.mjs
create mode 100644 viewers/webcomponents/foundation/src/structural-xml.mjs
create mode 100644 viewers/webcomponents/foundation/src/theme.css
create mode 100644 viewers/webcomponents/foundation/test/application-menu-client.test.mjs
copy viewers/webcomponents/foundation/{src/rendering.mjs => test/component-styles.test.mjs} (59%)
create mode 100644 viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
create mode 100644 viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
create mode 100644 viewers/webcomponents/foundation/test/menu-context.test.mjs
create mode 100644 viewers/webcomponents/foundation/test/menu-layout.test.mjs
create mode 100644 viewers/webcomponents/foundation/test/menubar-component.test.mjs
create mode 100644 viewers/webcomponents/htmx/README.adoc
copy viewers/webcomponents/{sample-html => htmx}/pom.xml (58%)
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
copy extensions/core/executionoutbox/persistence-jpa/src/test/java/org/apache/causeway/extensions/executionoutbox/jpa/integtests/model/CounterRepository.java => viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxObjectRoute.java (62%)
copy extensions/core/executionlog/persistence-jpa/src/test/java/org/apache/causeway/extensions/executionlog/jpa/integtests/model/CounterRepository.java => viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentFactory.java (64%)
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistry.java
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodec.java
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerController.java
create mode 100644 viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
copy extensions/core/commandlog/applib/src/test/java/org/apache/causeway/extensions/commandlog/applib/integtest/model/CommandLogTestDomainModel.java => viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/InvalidHtmxRouteException.java (73%)
create mode 100644 viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.css
create mode 100644 viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
create mode 100644 viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/route-policy.mjs
create mode 100644 viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
create mode 100644 viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodecTest.java
create mode 100644 viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
create mode 100644 viewers/webcomponents/htmx/test/route-policy.test.mjs
create mode 100644 viewers/webcomponents/sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/domain/SampleMenu.java
create mode 100644 viewers/webcomponents/sample-html/src/main/resources/menubars.layout.xml
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/PROVENANCE.adoc
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/README.adoc
copy viewers/webcomponents/{sample-html => sample-htmx-petclinic}/pom.xml (85%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHomeFragmentFactory.java
copy viewers/webcomponents/{sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication.java => sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication.java} (75%)
copy extensions/core/executionoutbox/restclient/src/main/java/org/apache/causeway/extensions/executionoutbox/restclient/api/delete/StringValue.java => viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicLandingController.java (67%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDataConfiguration.java
copy viewers/webcomponents/{sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/domain/SampleDomainModule.java => sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDomainModule.java} (77%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerRepository.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_bookVisit.java
copy viewers/webcomponents/{foundation/src/rendering.mjs => sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_visits.java} (57%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
copy extensions/core/executionlog/applib/src/test/java/org/apache/causeway/extensions/executionlog/applib/integtest/model/ExecutionLogTestDomainModel.java => viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetSpecies.java (77%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/VisitRepository.java
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visits.java
copy viewers/webcomponents/{sample-html => sample-htmx-petclinic}/src/main/resources/application.properties (68%)
copy viewers/{graphql/test/src/test => webcomponents/sample-htmx-petclinic/src/main}/resources/menubars.layout.xml (55%)
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.layout.xml
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#futureVisits.columnOrder.txt
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#petOwners.columnOrder.txt
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.layout.xml
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#pets.columnOrder.txt
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#visits.columnOrder.txt
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.layout.xml
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.layout.xml
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/main/resources/static/css/application.css
create mode 100644 viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
(causeway) 04/05: promotes and reconciles generic HTMX viewer
Posted by da...@apache.org.
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 d03c243a4e0107908137ac9250eb63e2eaf7acdd
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 22:48:01 2026 +0100
promotes and reconciles generic HTMX viewer
---
.../.openspec.yaml | 0
.../design.md | 190 +++++++++++++++++
.../proposal.md | 47 +++++
.../generic-htmx-web-component-viewer/spec.md | 227 +++++++++++++++++++++
.../add-generic-htmx-web-component-viewer/tasks.md | 53 +++++
.../planned-changes/web-component-viewer/README.md | 8 +-
.../design.md | 97 ---------
.../proposal.md | 36 ----
.../generic-htmx-web-component-viewer/spec.md | 90 --------
.../add-generic-htmx-web-component-viewer/tasks.md | 35 ----
10 files changed, 521 insertions(+), 262 deletions(-)
diff --git a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/.openspec.yaml b/openspec/changes/add-generic-htmx-web-component-viewer/.openspec.yaml
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/.openspec.yaml
rename to openspec/changes/add-generic-htmx-web-component-viewer/.openspec.yaml
diff --git a/openspec/changes/add-generic-htmx-web-component-viewer/design.md b/openspec/changes/add-generic-htmx-web-component-viewer/design.md
new file mode 100644
index 00000000000..b182cace978
--- /dev/null
+++ b/openspec/changes/add-generic-htmx-web-component-viewer/design.md
@@ -0,0 +1,190 @@
+## Context
+
+The framework-neutral components own GraphQL projections, domain behavior, effective object layout, menu layout, interaction state, and semantic results.
+A generic viewer still needs a stable shell and a router that maps bookmarks and application entries to pages.
+
+The customization review considered a page provider inside `<causeway-object>` but rejected it.
+Every host framework already has a routing boundary that can select a custom page for a logical type or route to a generic page containing `<causeway-object>`.
+HTMX expresses that boundary through server routes and replaceable HTML fragments.
+
+The existing `sample-html` application is intentionally a low-level component acceptance fixture.
+It must retain its stable path and automation contracts, but its visual defects should not become the default presentation of the new viewer.
+A separate Petclinic sample supplies a cohesive application while `sample-html` adopts the same corrected design tokens and component styling.
+
+The Petclinic source baseline is Apache-licensed `apache/causeway-app-petclinic` commit `16a10608129ca9ce8ae04d21df1462f4d69ac018`.
+That application targets Causeway 2.0.0, so the copy is a provenance-preserving port of the Pet Owner, Pet, Visit, home-page, fixture, menu, and grid material to current APIs rather than an unmodified nested checkout.
+The unrelated starter `SimpleObject` module, historical security and operational extensions, and obsolete infrastructure are not copied.
+
+The current Causeway Wicket viewer uses a sticky primary-color navbar, left-aligned application menus, right-aligned secondary and user menus, contained page content, icon-and-title object headers, grouped action buttons, cards, tabs, aligned labels and fields, compact collection tables, modal prompts, visible disabled state, and responsive navbar collapse.
+Those characteristics are the visual reference, but the HTMX viewer uses ordinary CSS and public custom-element hooks rather than Wicket markup or Bootstrap classes.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Provide canonical bookmark routes, deep linking, refresh, and browser history.
+- Resolve custom logical-type pages before the generic object page.
+- Keep route fragments free of metamodel and layout implementation knowledge.
+- Preserve one route-level object context for custom and generic pages.
+- Compose menu bars and configurable object-home behavior in a stable shell.
+- Provide replaceable result, navigation, page, and theme policy.
+- Ship an executable Petclinic sample with deterministic fixture data and both HTMX and Wicket routes over the same model.
+- Deliver a cohesive responsive visual system that closely resembles the information hierarchy and interaction affordances of the Wicket viewer.
+- Correct the existing `sample-html` styling without breaking its vanilla-HTML or automation contracts.
+
+**Non-Goals:**
+
+- Teaching `<causeway-object>` about custom pages or routers.
+- Parsing grid or menu resources in the HTMX viewer.
+- Translating GraphQL JSON or constructing domain operations in HTMX server handlers.
+- Inventing a public home-action descriptor when the rich application-entry contract exposes only object homes.
+- Pixel-identical reproduction of one Wicket theme or reuse of Wicket-generated HTML.
+- Adding Bootstrap, Wicket, or Petclinic dependencies to the component foundation or generic HTMX viewer.
+- Porting Petclinic's unrelated SimpleObject starter module, SecMan administration, audit, command, execution, Quartz, Flyway, PDF, calendar, or historical deployment examples.
+- Reproducing authentication pages or every extension of existing viewers initially.
+- Defining framework APIs for Vue or Svelte.
+
+## Decisions
+
+### Make routing the customization boundary
+
+The server route resolver accepts a canonical bookmark route and determines the public logical type from the route value.
+It first checks an application registry keyed by exact logical type.
+A match renders the registered server fragment factory beneath the route object context.
+Absence renders the generic `<causeway-object>` fragment.
+
+`<causeway-object>` is never asked whether a custom page exists.
+It remains a pure semantic object renderer that uses the effective or fallback grid.
+
+### Use one canonical route grammar
+
+The default viewer base path is `/htmx`, configurable through `causeway.viewer.webcomponents.htmx.base-path`.
+The canonical object route is `<base-path>/object/<logical-type>/<identifier>`, where each route value is independently UTF-8 percent-encoded and decoding rejects malformed escapes, empty values, encoded path separators, control characters, dot segments, overlong values, and non-canonical round trips.
+Logical route identity is the decoded public logical type and opaque identifier pair, not the encoded spelling.
+
+Semantic object-navigation events are converted to the same canonical route.
+Direct loads, refresh, back, forward, home navigation, menu-originated object results, and object links therefore converge on one resolver.
+
+### Keep HTMX above the component data plane
+
+HTMX owns shell navigation, fragment replacement, history, loading indicators, route announcements, and route errors.
+The implementation uses HTMX `2.0.6` from `org.webjars.npm:htmx.org` and the documented `htmx.ajax()` target, swap, and push options for semantic-event navigation.
+Normal anchors remain present so routes work without scripted click handlers and preserve open-in-new-tab behavior.
+
+Semantic components continue to obtain all domain state through GraphQL contexts.
+HTMX does not construct GraphQL documents, inspect member wrappers, parse layouts, or convert domain values.
+
+### Preserve one route-level object context
+
+Every custom or generic object page is rendered beneath one route-level `<causeway-object-context>`.
+Custom pages can compose standard member components, `<causeway-object>`, ordinary HTML, and application custom elements without reimplementing GraphQL execution.
+Changing routes replaces the complete route context, creates a new generation, and disconnects obsolete page requirements.
+
+The generic server fragment contains only safely escaped route values and semantic custom elements.
+It does not resolve or authorize a domain object server-side.
+The object context performs established GraphQL resolution and presents bounded not-found, access-denied, partial, or terminal state without disclosing object or authorization details.
+
+### Register custom pages as server fragment factories
+
+The initial registry accepts ordered Spring beans implementing a small `HtmxPageFragmentFactory` SPI.
+Each factory declares one exact public logical type and renders trusted application-owned HTML for a validated route descriptor.
+Duplicate logical-type registrations fail at startup rather than depending on bean order.
+
+Factories receive only route identity and documented rendering helpers.
+They do not receive Causeway metamodel objects, persistence sessions, GraphQL responses, or authorization internals.
+Templates and client-only registries are deferred until a concrete need appears.
+
+### Keep menus in the stable shell
+
+The full-page shell owns one `<causeway-graphql-client>`, `<causeway-menubars>`, application branding, route announcement region, loading indicator, result region, and route-content region.
+Object-fragment replacement does not recreate menu state.
+Authorization, locale, or application-entry generation changes invalidate menus through their own contract rather than incidental page navigation.
+
+The shell returns a complete document for ordinary requests and the route fragment only when `HX-Request: true` is present.
+History restoration can request the complete page, and the server never returns a fragment where a complete document is required.
+
+### Keep home behavior within the accepted object-home contract
+
+The browser shell uses the existing targeted rich application-entry discovery to obtain an optional object home.
+An object home is translated to the canonical route under replaceable home policy.
+Absent, hidden, invalid, unsupported, or partially failing home entries produce a bounded local landing state.
+
+No home service-action descriptor or invocation contract is invented.
+If Causeway later exposes such a public contract, it will be added through a separate change.
+
+### Keep result behavior replaceable
+
+Default result policy routes object results, presents scalar and collection results in an accessible shell region, and announces void completion before refreshing the current object context when one exists.
+Applications can replace behavior per scalar, object, collection, or void kind without changing component interaction semantics.
+The policy never assumes that an object result must navigate when an application override is present.
+
+### Ship the viewer as an optional server module
+
+`viewers/webcomponents/htmx` contains the Spring configuration, route codec, fragment registry, controller, shell renderer, HTMX WebJar dependency, browser bridge, and default theme.
+The viewer is enabled only when an application imports its module configuration.
+Applications can override base path, brand, fragments, route policy, home policy, result policy, and CSS variables without forking semantic components.
+
+The browser package exposes versioned assets beneath a module-owned path and uses external scripts and styles compatible with a restrictive same-origin content-security policy.
+No inline script is required.
+
+### Port Petclinic as the cohesive acceptance application
+
+`viewers/webcomponents/sample-htmx-petclinic` copies and ports the pinned Petclinic domain under Causeway's current Jakarta, persistence, programming-model, GraphQL, and layout contracts.
+The sample retains recognizable Pet Owners, Pets, Visits, home-page collections, service actions, object actions, choices, defaults, validation, effective menus, effective grids, and deterministic fixture data.
+A provenance document lists the source repository, pinned commit, copied concepts, intentional omissions, and porting changes.
+
+The sample includes GraphQL and HTMX viewers by default.
+It includes the Wicket viewer as the live visual comparison at its documented `/wicket/` base path, while `/htmx` remains the generic viewer route.
+Both viewers use the same model, fixture data, authorization, menu definitions, and grid definitions.
+
+### Use one Wicket-inspired visual system without Bootstrap coupling
+
+The default theme defines public CSS custom properties for primary navigation, page background, surfaces, borders, text, muted text, links, focus, success, warning, danger, spacing, radii, shadows, label width, content width, and narrow breakpoint.
+It styles stable shell landmarks and documented component light-DOM hooks for menus, object headers, action groups, layout cards, tabs, properties, collections, tables, editors, prompts, results, loading, disabled state, diagnostics, and errors.
+
+The wide layout follows Wicket's information hierarchy: sticky primary-color navbar, contained content, title and actions on one header line where space permits, card-backed groups, aligned property labels, compact tables, and bounded modal prompts.
+The narrow layout collapses navigation accessibly, stacks columns and property labels, keeps tables scrollable within their region rather than the page, and preserves document and focus order.
+
+Light and dark palettes meet contrast requirements, visible focus is never removed, reduced-motion preferences disable decorative transitions, and forced-colors mode retains boundaries and focus.
+The theme is ordinary CSS and does not require Bootstrap classes or JavaScript.
+
+### Repair the vanilla sample without changing its role
+
+`sample-html` keeps `/sample-html/index.html`, `/graphql`, bookmark `s_sample-1`, existing test selectors, custom-element registration, ESM loading, readiness state, and low-level examples.
+Its page structure and styles are reorganized to use the shared design tokens, contained shell, consistent headings, cards, grids, spacing, forms, tables, prompts, responsive behavior, and accessible disclosures.
+Domain/member state remains rendered by components.
+No HTMX route or Petclinic domain behavior is introduced into this vanilla sample.
+
+### Verify visual similarity by behavior and hierarchy
+
+Acceptance compares the Wicket and HTMX Petclinic routes at desktop and mobile widths rather than asserting pixel identity.
+The comparison records screenshots and checks equivalent presence and ordering of brand, primary menus, secondary/tertiary menus, object title, action affordances, groups, tabs, properties, collections, tables, prompts, loading, disabled state, and error presentation.
+
+Automated browser checks also assert no horizontal page overflow, non-overlapping controls, usable target sizes, stable focus, bounded modal dimensions, readable line lengths, responsive table containment, light/dark contrast, and zero browser console errors.
+Lighthouse accessibility, best-practices, and SEO targets remain 100 for the executable HTMX Petclinic routes and repaired vanilla sample.
+
+## Risks / Trade-offs
+
+- [The pinned Petclinic application targets Causeway 2.0.0] → Port only the cohesive domain slice, record provenance and deviations, and compile it against the current reactor rather than preserving obsolete infrastructure.
+- [Wicket similarity could become pixel-copy coupling] → Compare information hierarchy, affordances, responsive behavior, and visual tokens while keeping independent semantic markup.
+- [A shared theme could leak host assumptions into components] → Keep behavior unstyled by host frameworks, publish CSS variables and selectors, and make the theme optional.
+- [Server fragments can duplicate shell state] → Keep menus and global state outside the replaceable object region.
+- [Custom fragments can bypass semantic contracts] → Require route context composition and document that domain state remains component-owned.
+- [Identifiers contain reserved characters] → Independently encode route segments and enforce canonical round-trip parsing.
+- [HTMX lifecycle can leave obsolete responses] → Couple fragment generation to route identity, abort superseded requests, and dispose replaced object contexts.
+- [History cache can restore stale authorization-sensitive markup] → Configure history restoration to request the server and retain no durable domain values in shell snapshots.
+- [Viewer routes may diverge from Vue and Svelte] → Maintain shared canonical route and fallback fixtures across all generic viewers.
+- [Including Wicket makes the sample heavier] → Isolate Wicket to the demonstration module and keep the reusable HTMX module free of Wicket dependencies.
+
+## Migration Plan
+
+The viewer is additive and opt-in.
+Applications include and import the HTMX module, configure the base path if required, and optionally register custom fragment factories or policy beans.
+Existing component-only applications can adopt selected routes incrementally.
+Rollback removes the optional module or route mapping without changing GraphQL or component contracts.
+The repaired `sample-html` retains all established external paths and selectors.
+
+## Open Questions
+
+No blocking design questions remain for the initial implementation.
+Client-only template registries, home service actions, authenticated shell chrome, and broader Petclinic extensions remain explicit follow-on work if evidence requires them.
diff --git a/openspec/changes/add-generic-htmx-web-component-viewer/proposal.md b/openspec/changes/add-generic-htmx-web-component-viewer/proposal.md
new file mode 100644
index 00000000000..7ef7cadae9a
--- /dev/null
+++ b/openspec/changes/add-generic-htmx-web-component-viewer/proposal.md
@@ -0,0 +1,47 @@
+## Why
+
+The semantic web-component library now provides complete object and application-menu components, but users also need an opt-in generic application viewer with canonical routes, deep links, history, custom pages, and generic fallback pages.
+The architectural review in `Causeway web components.pdf` concluded that routing, rather than `<causeway-object>` or a framework-neutral page provider, must choose between a logical-type-specific page and the generic object page.
+HTMX provides the lightest server-oriented reference implementation of that router boundary.
+
+The existing vanilla sample proves individual contracts but no longer provides a cohesive application experience, and its accumulated styling defects obscure the capabilities already implemented.
+A current-Causeway port of the Apache Causeway Petclinic application provides a recognizable domain, effective layouts, menus, home page, actions, collections, and fixture data against which the generic viewer can be judged.
+The Wicket viewer over the same Petclinic model provides a concrete visual and interaction reference without requiring the HTMX viewer to depend on Wicket or Bootstrap.
+
+The evidence and ownership boundary are recorded in `coverage-matrix.yaml` entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01`.
+
+## What Changes
+
+- Add an opt-in generic HTMX viewer whose primary responsibility is canonical routing, a stable application shell, and page-fragment lifecycle.
+- Define independently encoded canonical bookmark routes beneath a configurable base path, defaulting to `/htmx`.
+- Resolve an application page registered for the exact public logical type before falling back to a generic fragment containing `<causeway-object>`.
+- Keep `<causeway-object>` unaware of custom-page routing, page registration, and browser history.
+- Render `<causeway-menubars>` in the stable shell and translate semantic component navigation and result events into HTMX route requests under replaceable policy.
+- Support object home entries exposed by the established rich application-entry contract, without inventing a home-action descriptor.
+- Provide application extension points for custom server fragment factories, navigation, themes, home behavior, and result presentation.
+- Add deep-link, refresh, back, forward, loading, not-found, partial-error, access-denied, and terminal-error behavior.
+- Package HTMX `2.0.6` through its WebJar and keep HTMX above the component GraphQL data plane.
+- Add a current-Causeway Petclinic demonstration application copied and ported from `apache/causeway-app-petclinic` commit `16a10608129ca9ce8ae04d21df1462f4d69ac018`, retaining source provenance and the Pet Owner, Pet, Visit, home, fixture, menu, and grid concepts.
+- Run the Wicket and HTMX viewers over the same demonstration model so their shell, object, action, property, tab, card, collection, table, and prompt presentation can be compared directly.
+- Introduce one cohesive Wicket-inspired theme for the generic viewer and semantic components without adding Bootstrap or Wicket dependencies to the framework-neutral component layer.
+- Eradicate the known spacing, alignment, overflow, hierarchy, disclosure, form, table, prompt, focus, responsive, and light/dark styling defects in `sample-html` while preserving its established URL, selectors, bookmark, readiness, and vanilla-HTML contracts.
+
+## Capabilities
+
+### New Capabilities
+
+- `generic-htmx-web-component-viewer`: Provides a router-led HTMX Causeway application viewer over the framework-neutral semantic web-component library, together with the Petclinic reference application and cohesive default presentation.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- Adds optional `viewers/webcomponents/htmx` and `viewers/webcomponents/sample-htmx-petclinic` Maven modules, server routes, browser assets, fragment handling, a default theme, and executable acceptance coverage.
+- Updates the framework-neutral theme hooks and the existing `sample-html` presentation without changing its semantic component ownership or stable automation contract.
+- Depends on accepted P0 and P1 rich GraphQL coverage plus completed application-entry, composite-object, and menu-bar capabilities.
+- Uses the public rich GraphQL endpoint and semantic component events; it does not access Causeway metamodel internals or parse layout resources itself.
+- Establishes route and fallback semantics that the generic Vue and Svelte viewers also preserve.
+- Includes Wicket only in the Petclinic sample as a comparison viewer; the generic HTMX module and component foundation do not depend on it.
+- Does not require applications using the component library to adopt HTMX, Petclinic, Wicket, or the default theme.
diff --git a/openspec/changes/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md b/openspec/changes/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
new file mode 100644
index 00000000000..7570452ce43
--- /dev/null
+++ b/openspec/changes/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
@@ -0,0 +1,227 @@
+## ADDED Requirements
+
+### Requirement: Optional router-led HTMX viewer
+The project SHALL provide an explicitly enabled generic HTMX viewer whose primary responsibility is application routing, shell composition, and page-fragment lifecycle over the semantic web-component library.
+
+#### Scenario: Viewer is enabled
+- **WHEN** an application includes and imports the HTMX viewer module
+- **THEN** it serves the documented shell and canonical object routes beneath the configured base path
+- **AND** uses semantic components for domain, interaction, menu, and layout behavior
+
+#### Scenario: Viewer is absent
+- **WHEN** an application does not include and import the module
+- **THEN** no HTMX viewer routes or assets are required
+- **AND** the framework-neutral component library remains independently usable
+
+### Requirement: Canonical bookmark routing
+The viewer SHALL map a public logical type and opaque object identifier to one documented round-trippable route grammar beneath a configurable base path.
+
+#### Scenario: Direct object route is requested
+- **WHEN** `<base-path>/object/<logical-type>/<identifier>` contains independently canonical percent-encoded route segments
+- **THEN** the router renders the object page for that logical route identity
+- **AND** browser history represents the same canonical route
+
+#### Scenario: Semantic navigation is requested
+- **WHEN** a component publishes an object navigation or object-result event
+- **THEN** default route policy constructs the same canonical route
+- **AND** HTMX replaces only the route region while pushing that route into browser history
+
+#### Scenario: Route encoding is invalid
+- **WHEN** a route contains malformed escapes, empty values, encoded separators, control characters, dot segments, overlong values, or a non-canonical encoding
+- **THEN** the viewer presents a bounded invalid-route state
+- **AND** does not disclose object state, authorization rules, or raw decoder exceptions
+
+#### Scenario: Viewer is mounted elsewhere
+- **WHEN** an application configures a valid non-root base path
+- **THEN** shell, object, asset, home, and history URLs consistently use that path
+- **AND** no route assumes deployment at the origin root
+
+### Requirement: Router-owned custom-page resolution
+The HTMX route resolver SHALL select one custom server fragment factory registered for the exact public logical type before using the generic object page.
+
+#### Scenario: Custom page is registered
+- **WHEN** the route contains a logical type with one application fragment factory
+- **THEN** the viewer renders that trusted application definition beneath the route object context
+- **AND** the factory receives validated route identity rather than metamodel, persistence, authorization, or GraphQL internals
+
+#### Scenario: Duplicate pages are registered
+- **WHEN** two factories claim the same exact logical type
+- **THEN** viewer startup fails with a bounded configuration error
+- **AND** bean ordering does not select an arbitrary page
+
+#### Scenario: No custom page is registered
+- **WHEN** the route contains a logical type without an exact registration
+- **THEN** the viewer renders `<causeway-object>` beneath the same route object context
+
+#### Scenario: Generic component renders
+- **WHEN** `<causeway-object>` connects
+- **THEN** it renders the effective or fallback object layout
+- **AND** does not discover custom pages or inspect router state
+
+### Requirement: One disposable route object context
+Every custom or generic object fragment SHALL contain exactly one route-level `<causeway-object-context>` for the canonical logical route identity.
+
+#### Scenario: Route fragment is replaced
+- **WHEN** HTMX installs a newer object fragment
+- **THEN** the prior route context disconnects and releases obsolete requirements
+- **AND** stale GraphQL or structural-resource responses cannot render into the new route
+
+#### Scenario: Custom page composes semantic members
+- **WHEN** a custom page uses properties, actions, collections, ordinary HTML, or `<causeway-object>`
+- **THEN** those elements consume the same nearest route context
+- **AND** the custom page does not create a parallel domain-state channel
+
+### Requirement: Stable semantic application shell
+The viewer SHALL keep application branding, menu bars, announcements, loading state, result presentation, and other global shell state outside replaceable route fragments.
+
+#### Scenario: Full page is requested
+- **WHEN** an ordinary browser request loads the viewer root or an object route
+- **THEN** the server returns one complete document containing the GraphQL client, `<causeway-menubars>`, and route region
+- **AND** the requested landing or object fragment appears inside that region
+
+#### Scenario: HTMX fragment is requested
+- **WHEN** a valid request carries `HX-Request: true`
+- **THEN** the server returns only the route fragment and canonical history instruction
+- **AND** does not duplicate the stable shell
+
+#### Scenario: Object route changes
+- **WHEN** HTMX replaces the route-content fragment
+- **THEN** `<causeway-menubars>` remains coordinated in the stable shell
+- **AND** menu state is invalidated only by its documented application-entry context
+
+### Requirement: HTMX-independent component data plane
+The HTMX viewer SHALL NOT construct GraphQL domain operations, translate GraphQL response data, parse Causeway grid or menu resources, or directly access Causeway metamodel and persistence internals.
+
+#### Scenario: Page requires domain state
+- **WHEN** a custom or generic page connects beneath its route context
+- **THEN** semantic components obtain domain state through GraphQL context contracts
+- **AND** HTMX handles only routing, shell, history, announcements, and fragment lifecycle
+
+#### Scenario: HTMX JavaScript is unavailable
+- **WHEN** a user follows a canonical route link without HTMX enhancement
+- **THEN** the server returns a complete navigable shell for that route
+- **AND** no domain link depends solely on a scripted click handler
+
+### Requirement: Object-home and semantic result policy
+The viewer SHALL translate the established object-home entry and semantic result events through replaceable viewer policy rather than changing component contracts.
+
+#### Scenario: Object home is available
+- **WHEN** targeted application-entry discovery returns a valid public object home
+- **THEN** default home policy routes to its canonical object route
+- **AND** no home service-action descriptor is inferred
+
+#### Scenario: Home is unavailable
+- **WHEN** the object home is absent, hidden, invalid, unsupported, or partially failing
+- **THEN** the shell renders a bounded accessible landing state
+- **AND** menus and explicit canonical routes remain usable
+
+#### Scenario: Object result is published
+- **WHEN** an interaction publishes a semantic object result without an application override
+- **THEN** default policy requests its canonical route
+
+#### Scenario: Non-object result is published
+- **WHEN** an interaction publishes scalar, collection, or void semantics without an application override
+- **THEN** default policy presents or announces that result in the documented shell region
+- **AND** void completion refreshes the current object context only when one exists
+
+#### Scenario: Application overrides result handling
+- **WHEN** an application registers a scoped handler for a semantic result kind
+- **THEN** that handler receives the semantic result and public target detail
+- **AND** component interaction, validation, and mutation behavior remain unchanged
+
+### Requirement: Accessible route lifecycle
+The viewer SHALL provide accessible loading, ready, not-found, access-denied, partial-error, invalid-route, unsupported, and terminal-error states.
+
+#### Scenario: Navigation starts
+- **WHEN** a canonical HTMX request begins
+- **THEN** the shell exposes a non-blocking busy state and polite loading announcement
+- **AND** existing content is not made keyboard-inert before replacement succeeds
+
+#### Scenario: Navigation completes
+- **WHEN** a new route fragment replaces the active page
+- **THEN** the route heading or main landmark receives documented focus
+- **AND** a concise route announcement is published without trapping focus
+
+#### Scenario: Domain object cannot be presented
+- **WHEN** the route context reports absent, unauthorized, partial, or terminal GraphQL state
+- **THEN** the shell maps it to a bounded non-disclosing route presentation
+- **AND** stable menus and browser navigation remain operable
+
+### Requirement: Cohesive Wicket-inspired default theme
+The viewer SHALL provide an optional responsive theme that closely follows the current Wicket viewer's information hierarchy and affordances using documented semantic-component hooks and CSS variables rather than Wicket or Bootstrap markup.
+
+#### Scenario: Wide application shell is rendered
+- **WHEN** the viewport has sufficient width
+- **THEN** branding, primary menus, secondary and tertiary menus, contained page content, object title, actions, cards, tabs, aligned properties, and compact tables follow the documented Wicket-inspired hierarchy
+- **AND** the page has consistent spacing, alignment, boundaries, line length, and focus treatment
+
+#### Scenario: Narrow application shell is rendered
+- **WHEN** the viewport crosses the documented narrow threshold
+- **THEN** navigation collapses accessibly, columns and property labels stack in meaningful order, and tables scroll within their own regions
+- **AND** the page has no horizontal overflow or overlapping controls
+
+#### Scenario: User selects a color preference
+- **WHEN** light, dark, reduced-motion, or forced-colors preferences apply
+- **THEN** the theme retains readable contrast, visible focus, semantic boundaries, and operable controls
+- **AND** decorative motion is removed when requested
+
+#### Scenario: Application supplies a theme
+- **WHEN** an application overrides documented variables or selectors
+- **THEN** it can change brand, color, spacing, typography, surfaces, borders, radii, shadows, label width, content width, and breakpoint presentation
+- **AND** semantic behavior and DOM ownership remain unchanged
+
+### Requirement: Executable Petclinic reference application
+The project SHALL include a deterministic current-Causeway Petclinic application ported from the documented pinned Apache source and exposing HTMX and Wicket viewers over the same domain model.
+
+#### Scenario: Petclinic sample starts
+- **WHEN** the documented Maven profile launches the sample
+- **THEN** Pet Owners, Pets, Visits, object home, service actions, object actions, choices, defaults, validation, effective menus, effective grids, and fixture data are available through GraphQL and the HTMX viewer
+- **AND** the Wicket viewer is available at its documented comparison path over the same state
+
+#### Scenario: Copied source is reviewed
+- **WHEN** a maintainer inspects the Petclinic sample
+- **THEN** provenance identifies repository commit `16a10608129ca9ce8ae04d21df1462f4d69ac018`, copied concepts, license, omissions, and current-API porting changes
+- **AND** obsolete starter, security, operational, and deployment infrastructure is not represented as current viewer behavior
+
+#### Scenario: Petclinic object has no custom page
+- **WHEN** its exact logical type has no custom fragment factory
+- **THEN** the HTMX router uses the generic `<causeway-object>` page
+
+#### Scenario: Petclinic object has a custom page
+- **WHEN** one exact logical type is registered for the sample's custom fragment factory
+- **THEN** the custom page composes ordinary HTML and semantic components beneath one route context
+- **AND** demonstrates precedence without adding custom-page knowledge to `<causeway-object>`
+
+### Requirement: Wicket-relative visual acceptance
+The Petclinic sample SHALL compare HTMX and current Wicket presentation by shared content hierarchy and interaction affordances rather than pixel identity.
+
+#### Scenario: Equivalent Petclinic object is opened
+- **WHEN** browser acceptance opens the same fixture object in Wicket and HTMX
+- **THEN** both expose recognizable branding, menus, title, actions, groups, tabs, properties, collections, tables, prompts, disabled state, and errors in equivalent semantic order
+- **AND** framework-specific markup and lifecycle remain internal to each viewer
+
+#### Scenario: Visual acceptance runs
+- **WHEN** desktop and mobile browser checks execute
+- **THEN** screenshots, responsive checks, focus checks, overflow checks, console checks, and accessibility audits cover both representative object and home routes
+- **AND** the HTMX routes meet the documented Lighthouse targets
+
+### Requirement: Repaired vanilla sample presentation
+The existing vanilla HTML sample SHALL use the cohesive design tokens and semantic-component styling while retaining its established executable contracts.
+
+#### Scenario: Existing sample route is loaded
+- **WHEN** `/sample-html/index.html` renders bookmark `s_sample-1`
+- **THEN** existing selectors, readiness states, GraphQL endpoint, ESM registration, vanilla custom-element composition, and interaction assertions continue to pass
+- **AND** domain and member state remain component-rendered
+
+#### Scenario: Existing sample is visually verified
+- **WHEN** desktop and mobile light and dark checks run
+- **THEN** headings, cards, columns, menus, properties, collections, forms, prompts, results, disclosures, focus, spacing, alignment, and overflow satisfy the same documented visual quality gates
+- **AND** no HTMX or Petclinic dependency is required by the sample
+
+### Requirement: Cross-viewer route compatibility
+Canonical route meaning, custom-page precedence, and generic `<causeway-object>` fallback SHALL remain semantically compatible with the generic Vue and Svelte viewers.
+
+#### Scenario: Viewer implementation changes
+- **WHEN** the same authorized bookmark is opened in another generic viewer
+- **THEN** logical route identity and custom-versus-generic resolution have the same semantic outcome
+- **AND** framework-specific lifecycle mechanics remain internal to that viewer
diff --git a/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md b/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
new file mode 100644
index 00000000000..3acb5581994
--- /dev/null
+++ b/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
@@ -0,0 +1,53 @@
+## 1. Evidence, Module, and Route Foundation
+
+- [ ] 1.1 Verify matrix entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01`, accepted P0/P1 prerequisites, current Wicket presentation hooks, and pinned Petclinic source `16a10608129ca9ce8ae04d21df1462f4d69ac018`.
+- [ ] 1.2 Establish optional `htmx` and `sample-htmx-petclinic` Maven modules, explicit module enablement, HTMX `2.0.6` WebJar packaging, browser assets, run profile, and integration-test wiring.
+- [ ] 1.3 Implement configurable `/htmx` base path and canonical object-route encoding and strict round-trip parsing with bounded invalid-route errors.
+- [ ] 1.4 Add codec and route tests for Unicode logical types, opaque identifiers, deployment paths, direct links, refresh, malformed escapes, separators, controls, dot segments, overlong values, absent objects, and access-denied outcomes.
+
+## 2. Server Fragments and Stable Shell
+
+- [ ] 2.1 Implement complete-document and `HX-Request` fragment responses with canonical history instructions and no duplicate shell.
+- [ ] 2.2 Implement the exact-logical-type `HtmxPageFragmentFactory` registry with duplicate-registration failure and no metamodel or persistence exposure.
+- [ ] 2.3 Implement generic fallback using `<causeway-object>` beneath exactly one route-level object context.
+- [ ] 2.4 Implement the stable shell with one GraphQL client, `<causeway-menubars>`, branding, main route region, loading indicator, announcement region, result region, and CSP-compatible external assets.
+- [ ] 2.5 Test custom selection, generic fallback, safe escaping, requirement release, shared context, explicit low-level composition, full-page requests, HTMX requests, and duplicate registrations.
+
+## 3. HTMX Navigation, History, Home, and Results
+
+- [ ] 3.1 Implement normal canonical anchors and an HTMX browser bridge using documented target, swap, push, replace, and lifecycle APIs.
+- [ ] 3.2 Translate object-link, object-action, service-action, and explicit navigation events into canonical route requests under replaceable policy.
+- [ ] 3.3 Implement direct-load, refresh, back, forward, history-cache-miss, loading, response-error, superseded-page, and focus behavior.
+- [ ] 3.4 Extend targeted application-entry discovery for the established optional object-home identity and implement bounded landing behavior for absent, hidden, invalid, unsupported, and partial home states.
+- [ ] 3.5 Implement replaceable scalar, object, collection, and void result handlers with object routing, accessible result presentation, and bounded current-context refresh.
+- [ ] 3.6 Verify HTMX owns only shell and fragment lifecycle while semantic components own GraphQL, layouts, domain interactions, menus, validation, mutations, and typed results.
+
+## 4. Petclinic Copy and Current-Causeway Port
+
+- [ ] 4.1 Record Apache Petclinic provenance, pinned commit, copied source inventory, license, intentional omissions, and current-API porting notes.
+- [ ] 4.2 Port Pet Owner, Pet, Visit, repositories, services, contributions, home page, fixtures, and deterministic sample data to current Jakarta/JPA and Causeway APIs.
+- [ ] 4.3 Port effective menu bars, object grids, collection columns, labels, icons, choices, defaults, validation, object actions, and service actions needed for cohesive viewer coverage.
+- [ ] 4.4 Configure GraphQL, structural resources, H2 persistence, security bypass, the HTMX module, and the current Wicket viewer over the same application state.
+- [ ] 4.5 Add one exact-logical-type custom Petclinic fragment that demonstrates router precedence and semantic low-level composition while all other types exercise generic fallback.
+- [ ] 4.6 Add integration tests for deterministic fixture identities, application entry, object home, effective menu/grid resources, Petclinic service and object interactions, canonical HTMX routes, and Wicket comparison availability.
+
+## 5. Cohesive Wicket-Inspired Theme
+
+- [ ] 5.1 Define shared design tokens and optional theme assets for navigation, content width, typography, surfaces, borders, links, focus, status colors, spacing, radii, shadows, labels, tables, prompts, and responsive behavior.
+- [ ] 5.2 Style the HTMX shell and documented menubar, object header, action, grid, card, tab, property, collection, table, editor, prompt, result, loading, disabled, diagnostic, and error hooks without Bootstrap coupling.
+- [ ] 5.3 Implement accessible narrow navigation, stacked object regions, contained table scrolling, touch targets, visible focus, reduced motion, forced colors, and light/dark palettes without horizontal page overflow.
+- [ ] 5.4 Compare live Wicket and HTMX Petclinic home and object pages and correct hierarchy, spacing, alignment, action placement, labels, cards, tabs, collections, prompts, and responsive discrepancies.
+
+## 6. Vanilla Sample Styling Repair
+
+- [ ] 6.1 Replace the ad hoc `sample-html` page styling with the cohesive tokens, contained shell, consistent hierarchy, cards, spacing, grids, and responsive rules while retaining vanilla HTML and ESM.
+- [ ] 6.2 Correct component-level spacing, alignment, disclosure, overflow, form, table, prompt, result, focus, light/dark, and narrow-layout defects exposed by the vanilla fixture.
+- [ ] 6.3 Preserve `/sample-html/index.html`, `/graphql`, bookmark `s_sample-1`, test selectors, readiness semantics, custom-element ownership, and all existing browser interactions.
+- [ ] 6.4 Extend automated and browser regression coverage for visual structure, control overlap, target sizes, table containment, modal bounds, responsive reflow, focus visibility, contrast, and zero console errors.
+
+## 7. Documentation and Final Verification
+
+- [ ] 7.1 Document enablement, HTMX version, canonical routes, base-path deployment, router customization, generic fallback, custom factories, object-home policy, result policy, theming, CSP, history, accessibility, and non-goals.
+- [ ] 7.2 Document Petclinic provenance, launch workflow, stable fixture bookmarks, HTMX routes, Wicket comparison route, and intentional differences from the pinned source.
+- [ ] 7.3 Maintain cross-viewer canonical-route and fallback fixtures shared with planned Vue and Svelte viewers.
+- [ ] 7.4 Run Node, Maven, GraphQL, Petclinic integration, vanilla integration, real-browser pointer and keyboard, direct-link and history, responsive light/dark, Wicket-relative screenshot, console, accessibility, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
diff --git a/openspec/planned-changes/web-component-viewer/README.md b/openspec/planned-changes/web-component-viewer/README.md
index 36972784995..d70a4fa7898 100644
--- a/openspec/planned-changes/web-component-viewer/README.md
+++ b/openspec/planned-changes/web-component-viewer/README.md
@@ -3,11 +3,11 @@
These are fully drafted follow-on changes for the Causeway web-component viewer programme.
They are held outside `openspec/changes/` because the repository permits only one active OpenSpec change at a time.
The foundation change `establish-graphql-web-component-foundation`, read-only change `add-read-only-domain-web-components`, interaction change `add-domain-web-component-interactions`, analysis change `analyze-rich-graphql-referenceapp-coverage`, both P0 correctness changes, value-semantics change, collection-windowing change, application-entry-points change, composite-object change, and menubar change are archived.
-No change is active.
-The remaining drafts are queued for serial promotion, beginning with `add-generic-htmx-web-component-viewer`.
+The generic HTMX viewer change is active.
+The remaining drafts are queued for serial promotion, beginning with `add-generic-vue-web-component-viewer`.
Each child directory is a complete planned change containing `.openspec.yaml`, `proposal.md`, `design.md`, `tasks.md`, and delta specifications.
-Promote the next draft verbatim:
+After the active change is archived, promote the next draft verbatim:
```shell
git mv openspec/planned-changes/web-component-viewer/<name> openspec/changes/<name>
@@ -32,7 +32,7 @@ Matrix entry references point to `viewers/graphql/adoc/modules/ROOT/examples/ref
| 8 | `add-rich-graphql-application-entry-points` *(archived)* | Complete | Object-interaction correctness and resource-link safety | NEW `rich-graphql-application-entry-points` |
| 9 | `add-composite-object-web-component` *(archived)* | Complete | Accepted value semantics, collection windows, object correctness, and structural resource safety | MODIFIED `domain-web-components` with `<causeway-object>` |
| 10 | `add-menubar-web-components` *(archived)* | Complete | Application entry points, service-action correctness, accepted value semantics, and structural resource safety | MODIFIED `domain-web-components` with menu bars |
-| 11 | `add-generic-htmx-web-component-viewer` | P1 | Accepted P0 and P1 GraphQL work, composite object, and menu bars | NEW `generic-htmx-web-component-viewer` |
+| 11 | `add-generic-htmx-web-component-viewer` *(active)* | P1 | Accepted P0 and P1 GraphQL work, composite object, and menu bars | NEW `generic-htmx-web-component-viewer` |
| 12 | `add-generic-vue-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-vue-web-component-viewer` |
| 13 | `add-generic-svelte-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-svelte-web-component-viewer` |
| 14 | `add-rich-graphql-member-metadata` | P2 | Completed analysis and proven standalone-component requirements | NEW narrow `rich-graphql-member-metadata` |
diff --git a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/design.md b/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/design.md
deleted file mode 100644
index 4d7c9c9efd5..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/design.md
+++ /dev/null
@@ -1,97 +0,0 @@
-## Context
-
-The framework-neutral components own GraphQL projections, domain behavior, effective object layout, menu layout, interaction state, and semantic results.
-A generic viewer still needs a stable shell and a router that maps bookmarks and application entries to pages.
-
-The customization review considered a page provider inside `<causeway-object>` but rejected it.
-Every host framework already has a routing boundary that can select a custom page for a logical type or route to a generic page containing `<causeway-object>`.
-HTMX expresses that boundary through server routes and replaceable HTML fragments.
-
-## Goals / Non-Goals
-
-**Goals:**
-
-- Provide canonical bookmark routes, deep linking, refresh, and browser history.
-- Resolve custom logical-type pages before the generic object page.
-- Keep route fragments free of metamodel and layout implementation knowledge.
-- Preserve one route-level object context for custom and generic pages.
-- Compose menu bars and configurable home-page behavior in a stable shell.
-- Provide replaceable result, navigation, page, and theme policy.
-
-**Non-Goals:**
-
-- Teaching `<causeway-object>` about custom pages or routers.
-- Parsing grid or menu resources in the HTMX viewer.
-- Translating GraphQL JSON or constructing domain operations in HTMX handlers.
-- Reproducing authentication pages or every extension of existing viewers initially.
-- Defining routing APIs for Vue or Svelte.
-
-## Decisions
-
-### Make routing the customization boundary
-
-The server route resolver accepts a canonical bookmark route and determines the public logical type.
-It first checks an application registry keyed by exact logical type.
-A match renders the registered HTML fragment or factory beneath the route object context.
-Absence renders the generic `<causeway-object>` fragment.
-
-`<causeway-object>` is never asked whether a custom page exists.
-It remains a pure semantic object renderer that uses the effective or fallback grid.
-
-### Use canonical bookmark routes
-
-The initial route grammar represents public logical type and identifier as independently encoded path values.
-Parsing rejects malformed, ambiguous, unsupported, absent, or unauthorized bookmarks with bounded route states.
-
-Semantic object-navigation events are converted to the same canonical route.
-Direct loads, refresh, back, forward, and menu-originated navigation therefore converge on one resolver.
-
-### Keep HTMX above the component data plane
-
-HTMX owns shell navigation, fragment replacement, history, loading indicators, and route errors.
-Semantic components continue to obtain all domain state through GraphQL contexts.
-HTMX does not construct GraphQL documents, inspect member wrappers, parse layouts, or convert domain values.
-
-### Preserve one route-level object context
-
-Every custom or generic object page is rendered beneath one route-level `<causeway-object-context>`.
-Custom pages can compose standard member components, `<causeway-object>`, ordinary HTML, and application custom elements without reimplementing GraphQL execution.
-Changing routes creates a new generation and disconnects obsolete page requirements.
-
-### Keep menus in the stable shell
-
-The full-page shell owns `<causeway-menubars>` and the route-content region.
-Object-fragment replacement does not recreate menu state.
-Authorization, locale, or application-entry generation changes invalidate menus through their own contract rather than incidental page navigation.
-
-### Keep home and result behavior replaceable
-
-The configured home-page object or service action can supply the initial route or result under viewer policy.
-Semantic scalar, object, collection, and void outcomes are passed to replaceable result handlers.
-Object navigation is a viewer decision rather than automatic component behavior.
-
-### Ship an optional server viewer module
-
-The viewer is disabled unless its module is included and configured.
-Its browser package includes HTMX and the Causeway web-component ESM assets under documented versions and content-security policy.
-Applications can override fragments, route policy, theme, and result behavior without forking semantic components.
-
-## Risks / Trade-offs
-
-- [Server fragments can duplicate shell state] → Keep menus and global state outside the replaceable object region.
-- [Custom fragments can bypass semantic contracts] → Require route context composition and document that domain state remains component-owned.
-- [Identifiers contain reserved characters] → Define independent canonical encoding and round-trip route tests.
-- [HTMX lifecycle can leave obsolete responses] → Couple fragment generation to route identity and object-context disposal.
-- [Viewer routes may diverge from Vue and Svelte] → Maintain shared canonical route and fallback acceptance fixtures across all generic viewers.
-
-## Migration Plan
-
-The viewer is additive and opt-in.
-Applications can enable it alongside existing viewers, adopt selected routes, and incrementally register custom logical-type pages.
-Rollback removes the optional module or route mapping without changing GraphQL or component contracts.
-
-## Open Questions
-
-- The final canonical route prefix and whether an application can mount it beneath another base path.
-- Whether custom fragment registration accepts templates, factories, or both in the first version.
-- Whether non-object semantic results use a shell region or application-provided result routes by default.
diff --git a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/proposal.md b/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/proposal.md
deleted file mode 100644
index 47a6d699d63..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/proposal.md
+++ /dev/null
@@ -1,36 +0,0 @@
-## Why
-
-The semantic web-component library will provide complete object and application-menu components, but users also need an opt-in generic application viewer with canonical routes, deep links, history, custom pages, and generic fallback pages.
-The architectural review in `Causeway web components.pdf` concluded that routing, rather than `<causeway-object>` or a framework-neutral page provider, must choose between a logical-type-specific page and the generic object page.
-HTMX provides the lightest server-oriented reference implementation of that router boundary.
-
-The evidence and ownership boundary are recorded in `coverage-matrix.yaml` entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01`.
-
-## What Changes
-
-- Add an opt-in generic HTMX viewer whose primary responsibility is canonical routing and page-fragment lifecycle.
-- Define canonical bookmark routes based on public logical type name and identifier.
-- Resolve an application page registered for the logical type before falling back to a generic fragment containing `<causeway-object>`.
-- Keep `<causeway-object>` unaware of custom-page routing and page registration.
-- Render `<causeway-menubars>` in a stable shell and translate semantic component navigation events into HTMX route requests.
-- Resolve configured home-page objects or service actions through replaceable viewer policy.
-- Provide application extension points for custom HTML fragments or factories, navigation, themes, home behavior, and result presentation.
-- Add deep-link, refresh, back, forward, loading, not-found, partial-error, access-denied, and terminal-error behavior.
-
-## Capabilities
-
-### New Capabilities
-
-- `generic-htmx-web-component-viewer`: Provides a router-led HTMX Causeway application viewer over the framework-neutral semantic web-component library.
-
-### Modified Capabilities
-
-None.
-
-## Impact
-
-- Adds an optional HTMX viewer module, server routes, browser assets, fragment handling, default theme, and demonstration application.
-- Depends on accepted P0 and P1 rich GraphQL coverage plus completed application-entry, composite-object, and menu-bar capabilities.
-- Uses the public rich GraphQL endpoint and semantic component events; it does not access Causeway metamodel internals or parse layout resources itself.
-- Establishes route and fallback semantics that the generic Vue and Svelte viewers also preserve.
-- Does not require applications using the component library to adopt HTMX.
diff --git a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md b/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
deleted file mode 100644
index af46e391a02..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/specs/generic-htmx-web-component-viewer/spec.md
+++ /dev/null
@@ -1,90 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Optional router-led HTMX viewer
-The project SHALL provide an explicitly enabled generic HTMX viewer whose primary responsibility is application routing, shell composition, and page-fragment lifecycle over the semantic web-component library.
-
-#### Scenario: Viewer is enabled
-- **WHEN** an application includes and configures the HTMX viewer module
-- **THEN** it serves the documented shell and canonical object routes
-- **AND** uses semantic components for domain and layout behavior
-
-#### Scenario: Viewer is absent
-- **WHEN** an application does not include the module
-- **THEN** no HTMX viewer routes or assets are required
-- **AND** the framework-neutral component library remains independently usable
-
-### Requirement: Canonical bookmark routing
-The viewer SHALL map public logical type and object identifier to one documented round-trippable route grammar.
-
-#### Scenario: Direct object route is requested
-- **WHEN** a valid authorized bookmark route is loaded or refreshed
-- **THEN** the router resolves the object page for that bookmark
-- **AND** browser history represents the same canonical route
-
-#### Scenario: Route is invalid or unavailable
-- **WHEN** route values are malformed, absent, stale, or unauthorized
-- **THEN** the viewer presents the documented bounded route state
-- **AND** does not disclose object state or authorization rules
-
-### Requirement: Router-owned custom-page resolution
-The HTMX route resolver SHALL select a custom page registered for the exact logical type before using the generic object page.
-
-#### Scenario: Custom page is registered
-- **WHEN** the route resolves an object whose logical type has an application fragment or factory
-- **THEN** the viewer renders that custom definition beneath the route object context
-
-#### Scenario: No custom page is registered
-- **WHEN** the route resolves an object without an exact registration
-- **THEN** the viewer renders `<causeway-object>` beneath the same route object context
-
-#### Scenario: Generic component renders
-- **WHEN** `<causeway-object>` connects
-- **THEN** it renders the effective or fallback object layout
-- **AND** does not discover custom pages or inspect router state
-
-### Requirement: Stable semantic application shell
-The viewer SHALL keep application menu bars and global shell state outside replaceable object fragments.
-
-#### Scenario: Object route changes
-- **WHEN** HTMX replaces the route-content fragment
-- **THEN** `<causeway-menubars>` remains coordinated in the stable shell
-- **AND** menu state is invalidated only by its documented application-entry context
-
-### Requirement: HTMX-independent component data plane
-The HTMX viewer SHALL NOT construct GraphQL domain operations, translate GraphQL response data, or parse Causeway grid or menu resources.
-
-#### Scenario: Page requires domain state
-- **WHEN** a custom or generic page connects beneath its route context
-- **THEN** semantic components obtain domain state through GraphQL context contracts
-- **AND** HTMX handles only routing, shell, history, and fragment lifecycle
-
-### Requirement: Semantic navigation and result policy
-The viewer SHALL translate semantic navigation and result events through replaceable viewer policy rather than changing component contracts.
-
-#### Scenario: Object navigation is requested
-- **WHEN** a component publishes a semantic object navigation event
-- **THEN** default policy requests the canonical HTMX object route
-
-#### Scenario: Application overrides result handling
-- **WHEN** an application registers a scoped handler for scalar, object, collection, or void results
-- **THEN** that handler receives the semantic result without replacing component interaction behavior
-
-#### Scenario: Home entry is available
-- **WHEN** application-entry metadata resolves a home-page object or service action
-- **THEN** viewer policy decides whether and when to route, invoke, or present it
-
-### Requirement: HTMX route lifecycle accessibility
-The viewer SHALL provide accessible route-level loading, ready, not-found, access-denied, partial-error, and terminal-error states.
-
-#### Scenario: HTMX navigation completes
-- **WHEN** a new route fragment replaces the active page
-- **THEN** focus and announcements follow documented accessible navigation behavior
-- **AND** obsolete object-context responses cannot render into the new route
-
-### Requirement: Cross-viewer route compatibility
-Canonical route meaning, custom-page precedence, and generic `<causeway-object>` fallback SHALL remain semantically compatible with the generic Vue and Svelte viewers.
-
-#### Scenario: Viewer implementation changes
-- **WHEN** the same authorized bookmark is opened in another generic viewer
-- **THEN** logical route identity and custom-versus-generic resolution have the same semantic outcome
-- **AND** framework-specific lifecycle mechanics remain internal to that viewer
diff --git a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/tasks.md b/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/tasks.md
deleted file mode 100644
index 66383ac95d9..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-generic-htmx-web-component-viewer/tasks.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## 1. Router and Viewer Module
-
-- [ ] 1.1 Verify matrix entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01` and all accepted P0 or P1 prerequisites are complete.
-- [ ] 1.2 Establish the optional viewer module, browser asset packaging, HTMX dependency, default theme, demonstration application, and explicit enablement path.
-- [ ] 1.3 Define canonical bookmark route encoding, parsing, base-path configuration, and semantic navigation-event handling.
-- [ ] 1.4 Add route tests for logical types, identifiers, direct links, refresh, malformed routes, absent objects, and access-denied outcomes.
-
-## 2. Custom and Generic Page Resolution
-
-- [ ] 2.1 Implement the exact-logical-type custom fragment or factory registry at the route boundary.
-- [ ] 2.2 Implement generic fallback using `<causeway-object>` beneath one route-level object context.
-- [ ] 2.3 Verify `<causeway-object>` never performs custom-page discovery or routing.
-- [ ] 2.4 Test custom selection, generic fallback, requirement release, shared context, and explicit low-level composition.
-
-## 3. HTMX Shell and History
-
-- [ ] 3.1 Implement the stable application shell, `<causeway-menubars>` placement, route-content region, loading indicators, and fragment transitions.
-- [ ] 3.2 Translate object-link, object-action, service-action, and home navigation outcomes into canonical route requests under replaceable policy.
-- [ ] 3.3 Implement back, forward, direct-load, refresh, and superseded-page behavior.
-- [ ] 3.4 Verify HTMX owns only shell and fragment lifecycle while semantic components own layouts and domain interactions.
-
-## 4. Home, Results, and Lifecycle
-
-- [ ] 4.1 Define initial-shell policy for present, absent, hidden, invalid, object, and action home entries.
-- [ ] 4.2 Implement replaceable scalar, object, collection, and void result handlers.
-- [ ] 4.3 Implement accessible loading, ready, not-found, access-denied, partial-error, and terminal-error route states.
-- [ ] 4.4 Test menu-originated results, object navigation, void refresh, non-object regions, partial errors, and terminal route failures.
-
-## 5. Theme, Documentation, and Verification
-
-- [ ] 5.1 Implement a responsive accessible default shell using documented component light-DOM hooks.
-- [ ] 5.2 Verify landmarks, headings, focus after navigation, loading announcements, route errors, menus, keyboard operation, and visible focus.
-- [ ] 5.3 Document enablement, canonical routes, router customization, generic fallback, custom fragments, home and result policy, theming, and non-goals.
-- [ ] 5.4 Maintain cross-viewer canonical-route and fallback fixtures shared with generic Vue and Svelte viewers.
-- [ ] 5.5 Run browser, Maven, GraphQL, accessibility, formatting, and strict OpenSpec validation checks.
(causeway) 01/05: promotes and reconciles menubar web components
Posted by da...@apache.org.
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 098ea793bd4f7f9adc2204e3afaf685234c92d72
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 20:02:20 2026 +0100
promotes and reconciles menubar web components
---
.../add-menubar-web-components/.openspec.yaml | 0
.../changes/add-menubar-web-components/design.md | 139 +++++++++++++++++++
.../changes/add-menubar-web-components/proposal.md | 36 +++++
.../specs/domain-web-components/spec.md | 147 +++++++++++++++++++++
.../changes/add-menubar-web-components/tasks.md | 38 ++++++
.../planned-changes/web-component-viewer/README.md | 6 +-
.../add-menubar-web-components/design.md | 96 --------------
.../add-menubar-web-components/proposal.md | 32 -----
.../specs/domain-web-components/spec.md | 96 --------------
.../add-menubar-web-components/tasks.md | 35 -----
10 files changed, 363 insertions(+), 262 deletions(-)
diff --git a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/.openspec.yaml b/openspec/changes/add-menubar-web-components/.openspec.yaml
similarity index 100%
rename from openspec/planned-changes/web-component-viewer/add-menubar-web-components/.openspec.yaml
rename to openspec/changes/add-menubar-web-components/.openspec.yaml
diff --git a/openspec/changes/add-menubar-web-components/design.md b/openspec/changes/add-menubar-web-components/design.md
new file mode 100644
index 00000000000..141dd8a3a8a
--- /dev/null
+++ b/openspec/changes/add-menubar-web-components/design.md
@@ -0,0 +1,139 @@
+## Context
+
+Causeway's effective menu-bars model has three semantic bars: primary, secondary, and tertiary.
+Each bar contains ordered menus, menu sections, and references to contributing service actions by public logical service type and semantic action ID.
+The rich GraphQL application entry point exposes a request-scoped descriptor for the authorized effective menu resource, and that XML preserves explicit or generated Causeway ordering and presentation metadata.
+Hidden and invalid action references are already filtered from that resource, while visible disabled actions remain so clients can obtain their current state from the established rich service-action wrappers.
+
+The current web-component interaction controller handles parameter editors, defaults, choices, autocomplete, validation, modal prompts, cancellation, stale results, and semantic outcomes for object actions.
+Menu actions are top-level service actions, so this change needs application-menu coordination and a service-action adapter that reuse those primitives without pretending that a service is a bookmark-addressable object.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Render all present primary, secondary, and tertiary bars through one high-level component.
+- Allow each semantic bar component to be used independently.
+- Discover, fetch, securely parse, and share one effective application-menu generation within a composite.
+- Preserve effective menus, sections, ordering, text labels, descriptions, icon hints, visibility, availability, and service-action semantics.
+- Coordinate current action state by logical service type rather than issuing one GraphQL request per menu entry.
+- Reuse standard editors, prompts, validation, invocation, semantic results, cancellation, and stale-response handling.
+- Provide accessible pointer, keyboard, desktop, and narrow-screen behavior using native controls and navigation landmarks.
+- Keep generated light DOM, styling hooks, diagnostics, and host integration framework neutral.
+
+**Non-Goals:**
+
+- Adding HTMX, routes, browser-history policy, or automatic action-result navigation.
+- Adding a public application-context custom element or a generic framework-neutral page-provider abstraction.
+- Rendering authentication, impersonation, notifications, or profile chrome beyond service actions present in the effective menu model.
+- Duplicating the GraphQL application-entry or service-action invocation APIs.
+- Exposing authorization rules, hidden menu entries, raw response bodies, or arbitrary XML markup.
+- Reproducing Wicket markup, ARIA application-menu mode, or Bootstrap JavaScript.
+
+## Decisions
+
+### Use consistent public element names
+
+The public names are `<causeway-menubars>`, `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>`.
+The compound `menubar` spelling matches Causeway MenuBars terminology and remains consistent across all three semantic bars.
+
+### Coordinate application state internally
+
+`<causeway-menubars>` requests the nearest configured GraphQL client and owns an internal `MenuBarsContextController` for one application-menu generation.
+The controller uses targeted introspection to discover the configured rich root, `application` field, and optional `menuBars` descriptor instead of assuming that structural resources are enabled.
+It reads application-entry metadata once per generation, validates the opaque origin-relative resource descriptor, fetches it with same-origin credentials and no-store semantics, and provides one immutable menu plan to child bars.
+An explicit `refresh()` starts a new generation, aborts superseded metadata, resource, and service-state requests, and re-evaluates authorization-sensitive and locale-sensitive content.
+The controller does not retain menu documents or action state across users, GraphQL-client instances, page loads, or explicit generations.
+
+No public application-context element is introduced in this slice.
+A bar beneath `<causeway-menubars>` consumes the composite's internal context, while a standalone bar requests the nearest GraphQL client and owns an equivalent private controller.
+
+### Preserve declarative children and generate only present roles
+
+The composite captures declaratively supplied semantic bar children before registration or upgrade and never replaces them merely because it generates layout.
+After a usable plan is available, it generates only missing primary, secondary, or tertiary children that contain effective entries.
+A declarative child for an absent or empty role receives empty state and exposes no empty interactive landmark.
+Generated bars appear in primary, secondary, tertiary document order without duplicating declarative roles.
+The composite generates one standard interaction controller for its service actions unless an authored direct child already provides one; a standalone bar does the equivalent only when it is not consuming composite state.
+
+### Reuse bounded structural-resource primitives
+
+The effective menu resource uses the same opaque path, same-origin credentials, no-store cache, redirect rejection, bounded body, abort, and non-disclosing error rules as effective object grids.
+Shared structural-resource loading and safe XML tokenization are factored so grid and menu parsing do not drift on security boundaries.
+Menu parsing retains the established one-mebibyte input limit, depth limit of 64, node limit of 4,096, and diagnostic limit of 20.
+Document type declarations, entity declarations or references, processing instructions beyond the XML declaration, executable elements, malformed XML, wrong namespaces, and oversized content are rejected without rendering response markup.
+
+### Interpret only the canonical menu subset
+
+The parser accepts the Causeway bootstrap menu-bars namespace and the structural sequence `menuBars`, semantic bar, `menu`, `named`, `section`, and `serviceAction`.
+A service action must have bounded valid `objectType` and `id` values, and optional `named`, `describedAs`, `cssClass`, and `cssClassFa` content is treated only as text or documented data attributes.
+Arbitrary XML is never inserted into `innerHTML`, and untrusted presentation hints are not applied as executable markup, URLs, or unsanitized CSS classes.
+Unknown local content produces a bounded diagnostic and is skipped locally where the surrounding bar remains usable.
+The server's effective explicit or generated model remains canonical, so the component does not invent a second client-side fallback menu hierarchy when the resource is absent.
+
+### Coordinate action state by logical service type
+
+Menu entries identify an existing rich service-action wrapper by public service logical type and semantic action ID.
+The GraphQL client describes each referenced service type through targeted introspection and the application controller batches hidden and disabled state reads by logical service type for one generation.
+Schema descriptions reuse the client cache, and a failed service or action remains a bounded local error without discarding unrelated services, menus, or bars.
+No action label or sensitive metadata is rendered when the canonical wrapper reports hidden.
+Visible disabled actions remain represented, count as visible entries, cannot be invoked, and expose their established disabled reason accessibly.
+Empty sections, menus, and bars collapse after current action state is applied.
+
+### Adapt established interaction behavior to service actions
+
+A cached service adapter bound to one logical service type implements the action-context methods consumed by the existing interaction controller.
+It reuses editor registry selection, parameter negotiation, defaults, choices, autocomplete, validation, modal prompting, cancellation, stale-response handling, and semantic result publication.
+Safe invocation uses the advertised rich service-action query field, while mutating invocation uses the existing top-level mutation field when required.
+Mutating service actions for one application-menu controller are serialized in submission order, while superseded transient negotiation requests are aborted or ignored.
+The adapter never supplies an object bookmark or `_target` argument for a service action.
+
+Service action request and result events retain their established names and typed results.
+Their additive target detail identifies `{kind: "service", logicalTypeName}` while object-action event identity remains unchanged, so a host can distinguish service and object interactions without inferring from generated GraphQL names.
+Routing, navigation, shell closing, and result presentation remain host decisions.
+
+### Use disclosure navigation rather than ARIA application-menu mode
+
+Each rendered semantic bar is a labelled navigation landmark.
+Menus use native buttons with `aria-expanded` and `aria-controls` to disclose ordered groups of native service-action buttons.
+The components do not assign `role="menubar"` or `role="menuitem"`, because these application-mode roles would replace ordinary document navigation and require a different interaction model.
+Tab and Shift+Tab follow native document order.
+Enter and Space toggle a focused disclosure, Escape closes the active disclosure and restores its originating button, and Home, End, and documented arrow keys move among peer menu buttons without trapping focus.
+Opening one menu closes its siblings in the same bar.
+
+### Preserve semantics across responsive presentation
+
+Wide presentation arranges bars and menu disclosures horizontally where space permits.
+At the documented narrow threshold, each non-empty bar exposes a native bar disclosure and menus stack in unchanged document order.
+Responsive presentation never changes semantic bar order, service-action order, event details, action state, or focus visibility.
+Primary, secondary, and tertiary roles are exposed through stable data attributes and CSS variables rather than hard-coded visual importance.
+
+### Keep diagnostics bounded and host policy external
+
+Layout, capability, resource, reference, and service-state failures publish bounded diagnostic codes with service type and action identifiers only when those identifiers are already public structural inputs.
+Diagnostics never include response bodies, authorization decisions, credentials, submitted parameter values, or raw exception text from remote content.
+Partial failures preserve unrelated successful bars and actions.
+The components never resolve or invoke a home page, select routes, mutate browser history, render authentication chrome, or navigate action results.
+
+## Risks / Trade-offs
+
+- [Application-entry resources vary by user and locale] → Scope state to one GraphQL client and explicit generation, use no-store requests, and expose `refresh()`.
+- [Many menu actions can cause excessive network work] → Batch current state by logical service type and reuse targeted schema descriptions.
+- [Menus can become keyboard traps] → Use native controls and navigation landmarks rather than ARIA application-menu mode, and verify focus restoration and Escape behavior.
+- [Service actions lack object identity] → Publish an explicit service interaction target instead of manufacturing a bookmark.
+- [Generated bars can conflict with declarative children] → Capture direct semantic children and generate only missing effective roles.
+- [Unsafe XML or presentation hints can affect the host page] → Reuse bounded structural parsing and expose hints only as inert text or documented data.
+- [Large menu models can overwhelm narrow screens] → Preserve hierarchy with bar and menu disclosures and collapse empty content after authorization state is known.
+
+## Migration Plan
+
+The menu components are additive.
+Applications can first use one standalone bar, then adopt the composite without changing service-action or host-event contracts.
+The generic HTMX viewer can later place `<causeway-menubars>` in its stable shell while keeping route pages and `<causeway-object>` independent.
+
+## Resolved Planning Questions
+
+- This slice uses an internal coordinator and does not add a public application-context element.
+- Visible disabled actions remain in the menu and in visible-entry counts.
+- Navigation landmarks and native disclosure buttons are used instead of ARIA `menubar` application mode.
+- The composite generates only effective non-empty bar roles and preserves declarative children for all roles.
diff --git a/openspec/changes/add-menubar-web-components/proposal.md b/openspec/changes/add-menubar-web-components/proposal.md
new file mode 100644
index 00000000000..4794c723fe0
--- /dev/null
+++ b/openspec/changes/add-menubar-web-components/proposal.md
@@ -0,0 +1,36 @@
+## Why
+
+Causeway applications organize service actions into primary, secondary, and tertiary menu bars, but the framework-neutral component vocabulary currently stops at bookmark-addressable domain objects.
+Applications need to render a complete application menu shell or one semantic bar without adopting HTMX, rebuilding effective-menu parsing, or inventing a second service-action protocol.
+The rich GraphQL application entry point and authorized effective menu resource now provide the canonical structural source required by this component slice.
+The reference evidence is recorded in `coverage-matrix.yaml` entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04`.
+
+## What Changes
+
+- Add public `<causeway-menubars>` as the high-level application-menu coordinator.
+- Add public `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` components that can be coordinated by the composite or used independently.
+- Discover the rich GraphQL application capability through targeted introspection, read its menu resource descriptor, and securely fetch and parse the authorized effective Causeway menu-bars XML.
+- Share one bounded application-menu generation across composite child bars while allowing standalone bars to own an equivalent private generation and explicit refresh lifecycle.
+- Render ordered menus, sections, text-safe presentation hints, and visible service actions while coordinating current hidden and disabled action state by logical service type.
+- Adapt the existing editor, prompt, validation, invocation, cancellation, stale-response, and semantic-result primitives to service actions without requiring an object bookmark or adding another invocation grammar.
+- Provide accessible wide and narrow disclosure behavior, keyboard operation, focus restoration, landmarks, empty-state handling, light-DOM styling hooks, and bounded redacted diagnostics.
+- Keep routing, browser history, automatic home-page behavior, authentication chrome, and action-result navigation under host policy.
+- Add vanilla-HTML, Node, Maven, real-browser, responsive, theme, and accessibility acceptance coverage.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `domain-web-components`: Adds coordinated and standalone semantic components for Causeway application menu bars and service actions.
+
+## Impact
+
+- Affects the web-component foundation module, GraphQL client operations, shared structural-resource safety primitives, application-menu coordination, service-action interaction adaptation, styling, tests, sample HTML, and documentation.
+- Depends on archived object-interaction correctness, resource-link safety, value semantics, application entry points, and component interaction work.
+- Uses the consistent public spelling `causeway-menubar-*`; no `causeway-menu-bar-*` aliases are introduced.
+- Adds no public framework-neutral application-context element because coordination remains internal to the composite or standalone bar.
+- Requires no HTMX, frontend framework, Bootstrap JavaScript, or host-router dependency.
diff --git a/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md b/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
new file mode 100644
index 00000000000..0bca273358e
--- /dev/null
+++ b/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
@@ -0,0 +1,147 @@
+## ADDED Requirements
+
+### Requirement: Causeway menu-bar component vocabulary
+The component library SHALL provide `<causeway-menubars>`, `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` as framework-neutral semantic application-menu components.
+
+#### Scenario: Composite menu bars connect
+- **WHEN** `<causeway-menubars>` connects beneath a configured GraphQL client
+- **THEN** it discovers the optional application menu capability through targeted introspection
+- **AND** coordinates present primary, secondary, and tertiary bar components in semantic order
+
+#### Scenario: One bar is used independently
+- **WHEN** an application uses a primary, secondary, or tertiary bar without the composite
+- **THEN** that component can obtain the same authorized effective resource and render only its semantic bar
+- **AND** does not require a public application-context element
+
+### Requirement: Generation-scoped application-menu coordination
+The composite SHALL own one bounded application-menu generation shared by its bars without caching effective content across users, client instances, or explicit refresh generations.
+
+#### Scenario: Composite loads its current generation
+- **WHEN** application metadata exposes a safe effective menu resource descriptor
+- **THEN** the coordinator reads that metadata once, fetches the opaque resource once with same-origin no-store semantics, and shares one immutable plan with child bars
+
+#### Scenario: Application menu capability is unavailable
+- **WHEN** targeted introspection shows that `application` or `menuBars` is absent
+- **THEN** the component reports a local bounded unsupported state
+- **AND** does not issue an invalid GraphQL operation or invent a client-side menu hierarchy
+
+#### Scenario: Application refreshes menu state
+- **WHEN** the application calls `refresh()`
+- **THEN** a new generation re-evaluates application metadata, resource content, and current service-action state
+- **AND** superseded metadata, resource, and action-state responses cannot replace the newer generation
+
+### Requirement: Declarative and generated bar composition
+The composite SHALL preserve declaratively supplied semantic bar children and generate only missing effective non-empty bar roles.
+
+#### Scenario: Children exist before custom-element upgrade
+- **WHEN** declarative primary or tertiary children are parsed before registration
+- **THEN** the composite captures and reuses them after upgrade
+- **AND** does not generate duplicate bars for those roles
+
+#### Scenario: Effective bar is absent
+- **WHEN** the effective menu resource contains no visible entries for a bar
+- **THEN** the composite does not generate a missing child for that role
+- **AND** a declarative child for that role exposes no empty interactive landmark
+
+### Requirement: Secure effective menu structure rendering
+Each bar SHALL use shared bounded structural-resource and XML safety rules to preserve the effective ordered menus, sections, service-action references, labels, descriptions, icon hints, and supported presentation data.
+
+#### Scenario: Menu resource is parsed
+- **WHEN** a bar receives an authorized menu resource in the documented Causeway namespace
+- **THEN** parsing rejects document types, entities, executable markup, cross-origin expansion, malformed nesting, and configured size or complexity limit violations
+- **AND** arbitrary response markup is never inserted into the component DOM
+
+#### Scenario: Bar contains multiple menus and sections
+- **WHEN** a bar is rendered
+- **THEN** menus, sections, and visible service actions follow effective Causeway document order
+- **AND** optional presentation metadata is exposed only as text-safe documented light-DOM hooks
+
+#### Scenario: Menu content is partially unsupported
+- **WHEN** one local node or action reference is unknown, malformed, stale, or wrong-kind
+- **THEN** the bar records a bounded redacted local diagnostic
+- **AND** retains unrelated recognized menus, sections, and actions
+
+### Requirement: Coordinated service-action state
+Menu components SHALL resolve current hidden and disabled state through established rich service-action wrappers while coordinating reads by logical service type.
+
+#### Scenario: Current menu action state is loaded
+- **WHEN** one generation contains actions from one or more logical service types
+- **THEN** targeted schema descriptions are cached by the GraphQL client
+- **AND** hidden and disabled state reads are grouped by logical service type rather than issued once per action
+
+#### Scenario: Action is hidden
+- **WHEN** a canonical service-action wrapper reports hidden for the current interaction
+- **THEN** no visible menu entry, label, description, icon hint, or authorization metadata for that action is rendered
+
+#### Scenario: Action is disabled
+- **WHEN** a service action is visible but disabled
+- **THEN** its entry remains represented and counted as visible
+- **AND** it is non-invokable and exposes its established disabled reason accessibly where available
+
+#### Scenario: Group becomes empty
+- **WHEN** current state removes every visible action from a section, menu, or bar
+- **THEN** empty sections, menus, and bars collapse in that order without an empty interactive landmark
+
+### Requirement: Service-action interaction reuse
+Menu entries SHALL reuse established semantic parameter, editor, choices, autocomplete, validation, invocation, cancellation, stale-response, mutation-serialization, and typed-result behavior for rich service actions.
+
+#### Scenario: Service action requires parameters
+- **WHEN** a user activates a parameterized menu action
+- **THEN** the standard accessible interaction presentation negotiates and validates parameters through a service-bound adapter
+- **AND** invokes the advertised rich service-action operation with typed values and no manufactured object target
+
+#### Scenario: Service action is mutating
+- **WHEN** a mutating service action is submitted through one application-menu coordinator
+- **THEN** it uses the existing top-level mutation field
+- **AND** mutating submissions are serialized in submission order
+
+#### Scenario: Service action returns a result
+- **WHEN** invocation returns scalar, object, collection, or void semantics
+- **THEN** the menu component publishes the established semantic result event and typed result
+- **AND** additive target detail identifies the public service logical type without pretending it is an object bookmark
+
+### Requirement: Accessible menu disclosure behavior
+Menu bars SHALL use labelled navigation landmarks and keyboard-operable native disclosure and action controls with documented traversal, closing, sibling coordination, and focus restoration.
+
+#### Scenario: User opens and closes a menu by keyboard
+- **WHEN** the user operates a menu disclosure with Enter or Space and later presses Escape
+- **THEN** the menu opens and closes without hover dependency
+- **AND** focus returns to the originating disclosure button
+
+#### Scenario: User traverses controls
+- **WHEN** the user uses Tab, Shift+Tab, Home, End, or documented arrow keys
+- **THEN** focus follows native document order or documented peer-disclosure order without becoming trapped
+- **AND** opening one menu closes sibling menus in the same bar
+
+#### Scenario: Assistive technology encounters a bar
+- **WHEN** a non-empty bar is rendered
+- **THEN** it exposes a labelled navigation landmark, native buttons, `aria-expanded`, and `aria-controls`
+- **AND** it does not misuse ARIA application-menu roles for ordinary page navigation
+
+### Requirement: Responsive semantic menu bars
+Menu bars SHALL adapt to wide and narrow layouts without changing semantic bar, menu, section, action order, interaction state, or event contracts.
+
+#### Scenario: Narrow layout is active
+- **WHEN** available width crosses the documented narrow threshold
+- **THEN** non-empty bars and menus use accessible disclosures in unchanged document order
+- **AND** every visible action remains keyboard operable with visible focus and no horizontal page overflow
+
+### Requirement: Observable and customizable menu composition
+Generated light DOM SHALL expose stable bar, menu, section, action, state, lifecycle, diagnostic, data-attribute, CSS-variable, and styling hooks without exposing sensitive remote content.
+
+#### Scenario: Application themes menu bars
+- **WHEN** an application supplies documented CSS variables and selectors
+- **THEN** it can style primary, secondary, tertiary, menu, section, and action regions without replacing semantic behavior
+
+#### Scenario: Partial menu error occurs
+- **WHEN** a capability, resource, reference, or service-state operation fails locally
+- **THEN** bounded lifecycle and diagnostic events identify the safe failure scope
+- **AND** diagnostics omit response bodies, credentials, authorization rules, submitted values, and remote exception text
+
+### Requirement: Host-controlled menu policy
+Menu components SHALL leave routes, browser history, authentication chrome, automatic home-page behavior, shell-closing behavior, and action-result navigation or presentation to the host.
+
+#### Scenario: Host receives a service object result
+- **WHEN** a service action publishes a semantic object result
+- **THEN** the host may navigate, render it, close a shell menu, or do nothing according to policy
+- **AND** the menu component does not assume HTMX, a canonical route, or an automatic home action
diff --git a/openspec/changes/add-menubar-web-components/tasks.md b/openspec/changes/add-menubar-web-components/tasks.md
new file mode 100644
index 00000000000..af0fdd407e6
--- /dev/null
+++ b/openspec/changes/add-menubar-web-components/tasks.md
@@ -0,0 +1,38 @@
+## 1. Public Vocabulary and Coordination
+
+- [ ] 1.1 Translate matrix entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04` into menu-resource and service-action fixtures.
+- [ ] 1.2 Verify archived object-interaction, structural-resource, application-entry, value-semantics, and composite-object prerequisites and their synced specifications.
+- [ ] 1.3 Factor shared opaque structural-resource loading and bounded safe XML parsing so object-grid and menu parsing retain identical security limits.
+- [ ] 1.4 Add targeted GraphQL application-entry and logical-service description and execution operations without full-schema introspection.
+- [ ] 1.5 Define the internal generation-scoped application-menu coordinator, service adapters, explicit refresh, cancellation, standalone-bar, and declarative-child contracts.
+- [ ] 1.6 Register and export `<causeway-menubars>` and primary, secondary, and tertiary semantic bar elements while preserving pre-upgrade declarative children.
+
+## 2. Menu Layout and Current Action State
+
+- [ ] 2.1 Parse the documented effective menu-bars namespace, semantic bars, menus, sections, service-action references, and text-safe presentation hints with bounded redacted diagnostics.
+- [ ] 2.2 Preserve effective document order and produce one immutable primary, secondary, and tertiary menu plan per generation.
+- [ ] 2.3 Implement composite sharing, generated missing roles, declarative-role reuse, standalone private coordination, and absent-role collapse.
+- [ ] 2.4 Batch current hidden and disabled service-action reads by logical service type while isolating missing services, stale actions, and partial GraphQL errors.
+- [ ] 2.5 Omit hidden entries, present disabled entries and reasons accessibly, and collapse empty sections, menus, and bars.
+
+## 3. Service-Action Interaction Reuse
+
+- [ ] 3.1 Implement service-bound action-context adapters over the existing editor, prompt, choices, autocomplete, validation, and typed argument primitives.
+- [ ] 3.2 Reuse or generate one standard interaction controller per composite or standalone scope and publish additive public service-target event detail.
+- [ ] 3.3 Invoke advertised safe query or mutating top-level fields without an object target, serialize mutations, and reject superseded transient responses.
+- [ ] 3.4 Preserve scalar, object, collection, and void result events while leaving navigation, result presentation, and shell-closing policy to the host.
+
+## 4. Accessible Responsive Presentation and Diagnostics
+
+- [ ] 4.1 Implement labelled navigation landmarks and native bar, menu-disclosure, and service-action controls without ARIA application-menu mode.
+- [ ] 4.2 Implement Tab, Shift+Tab, Enter, Space, Escape, Home, End, arrow navigation, sibling-menu closing, and focus restoration.
+- [ ] 4.3 Implement responsive wide and narrow disclosures without changing semantic order, action state, focus visibility, or event contracts.
+- [ ] 4.4 Add stable light-DOM role, bar, menu, section, action, state, lifecycle, diagnostic, CSS-variable, data-attribute, and styling hooks.
+
+## 5. Acceptance and Documentation
+
+- [ ] 5.1 Add deterministic parser, coordination, service-state, interaction, security, stale-response, declarative-child, empty-group, localization, and malformed-reference tests.
+- [ ] 5.2 Extend the vanilla-HTML sample with composite and standalone bar examples backed by deterministic primary, secondary, tertiary, hidden, disabled, parameterized, safe, mutating, and typed-result service actions.
+- [ ] 5.3 Execute real-browser pointer, keyboard-only, focus, responsive, light/dark, action-interaction, partial-error, fallback-absence, and console verification.
+- [ ] 5.4 Document component names, nesting, independent use, GraphQL and resource dependencies, refresh behavior, styling, events, interaction targets, diagnostics, security limits, and host-policy boundaries.
+- [ ] 5.5 Run Node, Maven, browser, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
diff --git a/openspec/planned-changes/web-component-viewer/README.md b/openspec/planned-changes/web-component-viewer/README.md
index de575a404c6..6267584bd5c 100644
--- a/openspec/planned-changes/web-component-viewer/README.md
+++ b/openspec/planned-changes/web-component-viewer/README.md
@@ -3,8 +3,8 @@
These are fully drafted follow-on changes for the Causeway web-component viewer programme.
They are held outside `openspec/changes/` because the repository permits only one active OpenSpec change at a time.
The foundation change `establish-graphql-web-component-foundation`, read-only change `add-read-only-domain-web-components`, interaction change `add-domain-web-component-interactions`, analysis change `analyze-rich-graphql-referenceapp-coverage`, both P0 correctness changes, value-semantics change, collection-windowing change, application-entry-points change, and composite-object change are archived.
-There is no active change.
-The remaining drafts are queued for serial promotion, beginning with `add-menubar-web-components`.
+The menubar change is active.
+The remaining drafts are queued for serial promotion, beginning with `add-generic-htmx-web-component-viewer`.
Each child directory is a complete planned change containing `.openspec.yaml`, `proposal.md`, `design.md`, `tasks.md`, and delta specifications.
After the active change is archived, promote the next draft verbatim:
@@ -31,7 +31,7 @@ Matrix entry references point to `viewers/graphql/adoc/modules/ROOT/examples/ref
| 7 | `add-rich-graphql-collection-windowing` *(archived)* | Complete | Object-interaction correctness | NEW `rich-graphql-collection-windowing` |
| 8 | `add-rich-graphql-application-entry-points` *(archived)* | Complete | Object-interaction correctness and resource-link safety | NEW `rich-graphql-application-entry-points` |
| 9 | `add-composite-object-web-component` *(archived)* | Complete | Accepted value semantics, collection windows, object correctness, and structural resource safety | MODIFIED `domain-web-components` with `<causeway-object>` |
-| 10 | `add-menubar-web-components` | P1 | Application entry points, service-action correctness, accepted value semantics, and structural resource safety | MODIFIED `domain-web-components` with menu bars |
+| 10 | `add-menubar-web-components` *(active)* | P1 | Application entry points, service-action correctness, accepted value semantics, and structural resource safety | MODIFIED `domain-web-components` with menu bars |
| 11 | `add-generic-htmx-web-component-viewer` | P1 | Accepted P0 and P1 GraphQL work, composite object, and menu bars | NEW `generic-htmx-web-component-viewer` |
| 12 | `add-generic-vue-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-vue-web-component-viewer` |
| 13 | `add-generic-svelte-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-svelte-web-component-viewer` |
diff --git a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/design.md b/openspec/planned-changes/web-component-viewer/add-menubar-web-components/design.md
deleted file mode 100644
index e6c5bee5c72..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/design.md
+++ /dev/null
@@ -1,96 +0,0 @@
-## Context
-
-Causeway's effective menu-bars model has three semantic bars: primary, secondary, and tertiary.
-Each contains ordered menus, menu sections, and references to contributing service actions by logical service type and action ID.
-The reference application's `menubars.layout.xml` demonstrates extensive explicit grouping and ordering.
-
-The current web-component interaction controller handles parameter editors, defaults, choices, autocomplete, validation, modal prompts, cancellation, and semantic results for object actions.
-Menu actions are top-level service actions, so menu components need an application-entry coordinator and a service-action adapter that reuse those interaction primitives without requiring an object bookmark context.
-
-## Goals / Non-Goals
-
-**Goals:**
-
-- Render all three Causeway bars through one high-level component.
-- Allow each bar component to be used independently.
-- Load, securely parse, and share the effective application-entry menu resource once where possible.
-- Preserve menus, sections, order, presentation metadata, visibility, and service-action semantics.
-- Reuse standard editors, prompts, validation, result events, and cancellation.
-- Provide accessible pointer, keyboard, desktop, and narrow-screen behavior.
-- Keep light-DOM styling and host integration straightforward.
-
-**Non-Goals:**
-
-- Adding HTMX, routes, browser-history policy, or automatic action-result navigation.
-- Rendering authentication, impersonation, notifications, or profile chrome beyond service actions present in the effective menu model.
-- Duplicating the GraphQL service-action invocation API.
-- Exposing authorization rules or hidden menu entries.
-- Reproducing Wicket markup or Bootstrap JavaScript.
-
-## Decisions
-
-### Use consistent public element names
-
-The public names are `<causeway-menubars>`, `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>`.
-The compound `menubar` spelling matches the Causeway MenuBars terminology and avoids one inconsistent `menu-bar` name.
-
-### Coordinate once and permit independent bars
-
-`<causeway-menubars>` obtains application-entry metadata from the nearest GraphQL client, fetches the authorized effective menu resource, and owns one shared generation and cache scope.
-It parses only the documented menu-bars XML subset with external entities, executable markup, and cross-origin resource expansion disabled.
-Declaratively supplied bar children are reused; missing bar children are generated in primary, secondary, tertiary order and receive the shared immutable menu plan.
-A standalone bar can obtain and parse its own application-entry resource under the same contract.
-
-### Adapt established interaction behavior to service actions
-
-Menu entries address the existing rich service-action wrapper by logical service type and semantic action ID.
-A service-action adapter reuses editor registry selection, parameter negotiation, modal prompting, validation, stale-response handling, cancellation, and semantic result publication.
-It does not need an object bookmark, and it does not create a second GraphQL invocation grammar.
-
-### Render menus as accessible disclosure navigation
-
-Each semantic bar is a labelled navigation landmark when rendered independently, while the composite provides coordinated labels to avoid redundant unnamed landmarks.
-Menus use native buttons to open ordered groups of service-action buttons.
-Keyboard behavior includes Tab, Shift+Tab, Enter, Space, Escape, Home, End, and documented arrow navigation without requiring hover.
-Opening one menu closes its sibling menu within the same bar, and focus returns predictably when a menu closes.
-
-### Preserve semantics across responsive presentation
-
-Wide presentation may arrange bars and menus horizontally.
-Narrow presentation may collapse bars or menus into disclosure panels, but DOM order, labels, action order, focus behavior, and semantic events remain unchanged.
-Primary, secondary, and tertiary are exposed as data and styling hooks rather than hard-coded visual importance.
-
-### Keep result and navigation policy external
-
-Service action results publish the same semantic scalar, object, collection, and void result events used by object actions.
-The host may navigate, render a result region, close a shell menu, or do nothing according to policy.
-The components never resolve a home-page object or invoke a home service action automatically.
-
-### Handle dynamic and partial states locally
-
-Entries already omitted by the authorized effective resource are never synthesized.
-Any entry whose canonical service-action wrapper reports hidden is omitted without rendering its label or state.
-Disabled entries remain represented according to established action semantics and explain their disabled reason accessibly.
-An invalid menu reference or one failed service action does not discard unrelated menus or bars.
-Empty sections, menus, and bars collapse in that order.
-
-## Risks / Trade-offs
-
-- [Application-entry resources can vary by user and locale] → Scope shared state and invalidation to the GraphQL execution context and application-entry generation.
-- [Menus can become keyboard traps] → Follow disclosure navigation patterns, rely on native controls, and test focus restoration and Escape extensively.
-- [Service actions lack object context] → Reuse interaction primitives through a dedicated service adapter rather than faking an object bookmark.
-- [Generated bars can conflict with declarative children] → Capture declarative bar children before custom-element upgrade and generate only missing roles.
-- [Large menu models can overwhelm narrow screens] → Preserve hierarchy with collapsible bars and menus and avoid rendering hidden entries.
-
-## Migration Plan
-
-The menu components are additive.
-Applications can first use one standalone bar, then adopt the composite.
-The generic HTMX viewer can later place `<causeway-menubars>` in its shell without changing component contracts.
-
-## Open Questions
-
-- Should standalone bars share data through an explicit public application context element in a later proposal?
-- Should disabled actions remain visible in closed-menu counts?
-- Which ARIA disclosure or menubar pattern best matches responsive service-action menus without overloading arrow-key behavior?
-- Should the composite generate all three bars or only bars present in the effective menu resource?
diff --git a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/proposal.md b/openspec/planned-changes/web-component-viewer/add-menubar-web-components/proposal.md
deleted file mode 100644
index 0819c0d7ea7..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/proposal.md
+++ /dev/null
@@ -1,32 +0,0 @@
-## Why
-
-Causeway applications organize service actions into primary, secondary, and tertiary menu bars, but the framework-neutral component vocabulary currently begins at bookmark-addressable domain objects.
-Applications should be able to render complete application menus or only one semantic bar without adopting HTMX or rebuilding menu-layout parsing and service-action interaction.
-The evidence is recorded in `coverage-matrix.yaml` entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04`.
-
-## What Changes
-
-- Add public `<causeway-menubars>` as the high-level application menu coordinator.
-- Add public `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` components that can be composed by the coordinator or used independently.
-- Consume the rich GraphQL application-entry contract and effective Causeway menu-bars layout.
-- Render ordered menus, sections, labels, descriptions, icons, and visible service actions while reusing established action parameter, editor, validation, invocation, result, and cancellation semantics.
-- Provide accessible desktop and narrow-screen disclosure behavior, keyboard operation, focus management, landmarks, empty-state handling, light-DOM styling hooks, and semantic events.
-- Keep routing, automatic home-page invocation, authentication chrome, and action-result navigation under host policy.
-- Add vanilla-HTML and real-browser acceptance coverage.
-
-## Capabilities
-
-### New Capabilities
-
-None.
-
-### Modified Capabilities
-
-- `domain-web-components`: Adds high-level and per-bar semantic components for Causeway application menu bars and service actions.
-
-## Impact
-
-- Affects the web-component foundation module, application-entry coordination, service-action interaction adaptation, styling, tests, sample HTML, and documentation.
-- Depends on corrected service-action argument interaction, safe structural resource links, `rich-graphql-application-entry-points`, and the established interaction components; narrow rich member metadata is optional.
-- Uses the consistent element spelling `causeway-menubar-*`; no `causeway-menu-bar-secondary` alias is proposed initially.
-- Does not require HTMX and can be consumed by any host framework.
diff --git a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/specs/domain-web-components/spec.md b/openspec/planned-changes/web-component-viewer/add-menubar-web-components/specs/domain-web-components/spec.md
deleted file mode 100644
index c85c8ea732b..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/specs/domain-web-components/spec.md
+++ /dev/null
@@ -1,96 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Causeway menu-bar component vocabulary
-The component library SHALL provide `<causeway-menubars>`, `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` as framework-neutral semantic application-menu components.
-
-#### Scenario: Composite menu bars connect
-- **WHEN** `<causeway-menubars>` connects beneath a configured GraphQL client
-- **THEN** it obtains and securely parses the effective application-entry menu resource once
-- **AND** composes or coordinates present primary, secondary, and tertiary bar components in semantic order
-
-#### Scenario: One bar is used independently
-- **WHEN** an application uses a primary, secondary, or tertiary bar without the composite
-- **THEN** that component can obtain the same authorized resource and render only its semantic bar
-
-### Requirement: Declarative and generated bar composition
-The composite SHALL preserve declaratively supplied semantic bar children and generate only required missing bar roles.
-
-#### Scenario: Children exist before custom-element upgrade
-- **WHEN** declarative primary or tertiary children are parsed before registration
-- **THEN** the composite captures and reuses them after upgrade
-- **AND** does not generate duplicate bars for those roles
-
-#### Scenario: Effective bar is absent
-- **WHEN** the effective menu resource contains no visible entries for a bar
-- **THEN** the composite does not expose an empty interactive landmark for that bar
-
-### Requirement: Secure effective menu structure rendering
-Each bar SHALL securely parse the documented menu-resource subset and preserve its effective ordered menus, sections, service-action references, labels, descriptions, icons, and supported hints.
-
-#### Scenario: Menu resource is parsed
-- **WHEN** a bar receives an authorized menu resource
-- **THEN** parsing disables external entities, executable markup, and cross-origin expansion
-- **AND** unsupported content produces a bounded diagnostic rather than executable DOM
-
-#### Scenario: Bar contains multiple menus and sections
-- **WHEN** a bar is rendered
-- **THEN** menus, sections, and visible service actions follow effective Causeway order
-- **AND** optional presentation metadata is available through documented light-DOM hooks
-
-#### Scenario: Menu reference is invalid
-- **WHEN** one entry references an unavailable service action
-- **THEN** the bar records a bounded local diagnostic
-- **AND** retains unrelated valid entries
-
-### Requirement: Service-action interaction reuse
-Menu entries SHALL reuse established semantic parameter, editor, choices, autocomplete, validation, invocation, cancellation, stale-response, and result behavior for rich service actions.
-
-#### Scenario: Service action requires parameters
-- **WHEN** a user activates a parameterized menu action
-- **THEN** the standard accessible interaction presentation negotiates and validates parameters
-- **AND** invokes the existing rich service-action operation with typed values
-
-#### Scenario: Service action returns a result
-- **WHEN** invocation returns scalar, object, collection, or void semantics
-- **THEN** the menu component publishes the established semantic result event
-- **AND** does not impose navigation or result presentation
-
-### Requirement: Dynamic menu visibility and availability
-Menu components SHALL honor current hidden and disabled service-action state without exposing authorization rules.
-
-#### Scenario: Action is hidden
-- **WHEN** a service action is hidden for the current context
-- **THEN** no visible menu entry or sensitive metadata for that action is rendered
-
-#### Scenario: Action is disabled
-- **WHEN** a service action is visible but disabled
-- **THEN** its entry is non-invokable
-- **AND** its established disabled reason is presented accessibly where available
-
-### Requirement: Accessible menu disclosure behavior
-Menu bars SHALL use labelled landmarks and keyboard-operable native controls with documented disclosure, traversal, closing, and focus-restoration behavior.
-
-#### Scenario: User opens and closes a menu by keyboard
-- **WHEN** the user operates a menu button with Enter or Space and later presses Escape
-- **THEN** the menu opens and closes without hover dependency
-- **AND** focus returns to the originating menu button
-
-#### Scenario: User traverses controls
-- **WHEN** the user uses Tab, Shift+Tab, Home, End, or documented arrow keys
-- **THEN** focus follows the documented menu order without becoming trapped
-
-### Requirement: Responsive semantic menu bars
-Menu bars SHALL adapt to wide and narrow layouts without changing semantic bar, menu, section, action order, or event contracts.
-
-#### Scenario: Narrow layout is active
-- **WHEN** available width requires collapsed presentation
-- **THEN** bars and menus use accessible disclosure regions
-- **AND** every visible action remains keyboard operable with visible focus
-
-### Requirement: Host-controlled menu policy
-Menu components SHALL leave routes, browser history, authentication chrome, automatic home-page invocation, and action-result navigation to the host.
-
-#### Scenario: Host receives an object result
-- **WHEN** a service action publishes a semantic object result
-- **THEN** the host may navigate or render it according to policy
-- **AND** the menu component does not assume HTMX or a canonical route
diff --git a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/tasks.md b/openspec/planned-changes/web-component-viewer/add-menubar-web-components/tasks.md
deleted file mode 100644
index a806cc7cb1d..00000000000
--- a/openspec/planned-changes/web-component-viewer/add-menubar-web-components/tasks.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## 1. Public Vocabulary and Coordination
-
-- [ ] 1.1 Translate matrix entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04` into menu-resource and service-action fixtures.
-- [ ] 1.2 Verify object-interaction, structural-resource, and application-entry prerequisites are complete.
-- [ ] 1.3 Define contracts for `<causeway-menubars>` and primary, secondary, and tertiary bar elements.
-- [ ] 1.4 Define shared application-entry state, generation, caching, invalidation, standalone-bar, and declarative-child behavior.
-- [ ] 1.5 Register all elements and preserve pre-upgrade declarative bar children.
-
-## 2. Menu Layout and Service Actions
-
-- [ ] 2.1 Fetch and securely parse the effective primary, secondary, tertiary, menu, section, and service-action resource from rich GraphQL application entry points.
-- [ ] 2.2 Preserve ordering, labels, descriptions, icons, supported hints, and partial-layout diagnostics.
-- [ ] 2.3 Implement the service-action adapter over existing editor, prompt, choices, autocomplete, validation, invocation, cancellation, stale-response, and result primitives.
-- [ ] 2.4 Omit hidden entries, present disabled entries accessibly, and collapse empty sections, menus, and bars.
-
-## 3. Accessible Responsive Presentation
-
-- [ ] 3.1 Implement labelled navigation landmarks and native disclosure and action controls.
-- [ ] 3.2 Implement Tab, Shift+Tab, Enter, Space, Escape, Home, End, arrow navigation, sibling-menu closing, and focus restoration.
-- [ ] 3.3 Implement responsive wide and narrow layouts without changing semantic order or events.
-- [ ] 3.4 Add light-DOM role, bar, menu, section, action, state, CSS-variable, and styling hooks.
-
-## 4. Events, Errors, and Host Policy
-
-- [ ] 4.1 Publish standard semantic interaction states and scalar, object, collection, and void results.
-- [ ] 4.2 Keep routing, navigation, home-page invocation, and result presentation under host policy.
-- [ ] 4.3 Isolate malformed references, service-action errors, and stale responses without discarding unrelated menus.
-
-## 5. Acceptance and Documentation
-
-- [ ] 5.1 Add deterministic menu fixtures for all bars, sections, hidden and disabled actions, parameters, results, empty groups, localization, and malformed references.
-- [ ] 5.2 Add vanilla-HTML examples for the composite and each standalone bar.
-- [ ] 5.3 Add real-browser pointer, keyboard-only, focus, responsive, light/dark, action-interaction, and accessibility verification.
-- [ ] 5.4 Document component names, nesting, independent use, GraphQL dependencies, styling, events, interaction behavior, and host-policy boundaries.
-- [ ] 5.5 Run Node, Maven, browser, Lighthouse, formatting, and strict OpenSpec validation checks.
(causeway) 03/05: archives menubar web components
Posted by da...@apache.org.
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 0e4e33f9a60592989115452fb08c95997d03c23d
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 22:31:30 2026 +0100
archives menubar web components
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../specs/domain-web-components/spec.md | 0
.../tasks.md | 0
.../planned-changes/web-component-viewer/README.md | 8 +-
openspec/specs/domain-web-components/spec.md | 151 +++++++++++++++++++++
7 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/openspec/changes/add-menubar-web-components/.openspec.yaml b/openspec/changes/archive/2026-08-18-add-menubar-web-components/.openspec.yaml
similarity index 100%
rename from openspec/changes/add-menubar-web-components/.openspec.yaml
rename to openspec/changes/archive/2026-08-18-add-menubar-web-components/.openspec.yaml
diff --git a/openspec/changes/add-menubar-web-components/design.md b/openspec/changes/archive/2026-08-18-add-menubar-web-components/design.md
similarity index 100%
rename from openspec/changes/add-menubar-web-components/design.md
rename to openspec/changes/archive/2026-08-18-add-menubar-web-components/design.md
diff --git a/openspec/changes/add-menubar-web-components/proposal.md b/openspec/changes/archive/2026-08-18-add-menubar-web-components/proposal.md
similarity index 100%
rename from openspec/changes/add-menubar-web-components/proposal.md
rename to openspec/changes/archive/2026-08-18-add-menubar-web-components/proposal.md
diff --git a/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md b/openspec/changes/archive/2026-08-18-add-menubar-web-components/specs/domain-web-components/spec.md
similarity index 100%
rename from openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
rename to openspec/changes/archive/2026-08-18-add-menubar-web-components/specs/domain-web-components/spec.md
diff --git a/openspec/changes/add-menubar-web-components/tasks.md b/openspec/changes/archive/2026-08-18-add-menubar-web-components/tasks.md
similarity index 100%
rename from openspec/changes/add-menubar-web-components/tasks.md
rename to openspec/changes/archive/2026-08-18-add-menubar-web-components/tasks.md
diff --git a/openspec/planned-changes/web-component-viewer/README.md b/openspec/planned-changes/web-component-viewer/README.md
index 6267584bd5c..36972784995 100644
--- a/openspec/planned-changes/web-component-viewer/README.md
+++ b/openspec/planned-changes/web-component-viewer/README.md
@@ -2,12 +2,12 @@
These are fully drafted follow-on changes for the Causeway web-component viewer programme.
They are held outside `openspec/changes/` because the repository permits only one active OpenSpec change at a time.
-The foundation change `establish-graphql-web-component-foundation`, read-only change `add-read-only-domain-web-components`, interaction change `add-domain-web-component-interactions`, analysis change `analyze-rich-graphql-referenceapp-coverage`, both P0 correctness changes, value-semantics change, collection-windowing change, application-entry-points change, and composite-object change are archived.
-The menubar change is active.
+The foundation change `establish-graphql-web-component-foundation`, read-only change `add-read-only-domain-web-components`, interaction change `add-domain-web-component-interactions`, analysis change `analyze-rich-graphql-referenceapp-coverage`, both P0 correctness changes, value-semantics change, collection-windowing change, application-entry-points change, composite-object change, and menubar change are archived.
+No change is active.
The remaining drafts are queued for serial promotion, beginning with `add-generic-htmx-web-component-viewer`.
Each child directory is a complete planned change containing `.openspec.yaml`, `proposal.md`, `design.md`, `tasks.md`, and delta specifications.
-After the active change is archived, promote the next draft verbatim:
+Promote the next draft verbatim:
```shell
git mv openspec/planned-changes/web-component-viewer/<name> openspec/changes/<name>
@@ -31,7 +31,7 @@ Matrix entry references point to `viewers/graphql/adoc/modules/ROOT/examples/ref
| 7 | `add-rich-graphql-collection-windowing` *(archived)* | Complete | Object-interaction correctness | NEW `rich-graphql-collection-windowing` |
| 8 | `add-rich-graphql-application-entry-points` *(archived)* | Complete | Object-interaction correctness and resource-link safety | NEW `rich-graphql-application-entry-points` |
| 9 | `add-composite-object-web-component` *(archived)* | Complete | Accepted value semantics, collection windows, object correctness, and structural resource safety | MODIFIED `domain-web-components` with `<causeway-object>` |
-| 10 | `add-menubar-web-components` *(active)* | P1 | Application entry points, service-action correctness, accepted value semantics, and structural resource safety | MODIFIED `domain-web-components` with menu bars |
+| 10 | `add-menubar-web-components` *(archived)* | Complete | Application entry points, service-action correctness, accepted value semantics, and structural resource safety | MODIFIED `domain-web-components` with menu bars |
| 11 | `add-generic-htmx-web-component-viewer` | P1 | Accepted P0 and P1 GraphQL work, composite object, and menu bars | NEW `generic-htmx-web-component-viewer` |
| 12 | `add-generic-vue-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-vue-web-component-viewer` |
| 13 | `add-generic-svelte-web-component-viewer` | P1 | Same semantic prerequisites and shared canonical routing contract | NEW `generic-svelte-web-component-viewer` |
diff --git a/openspec/specs/domain-web-components/spec.md b/openspec/specs/domain-web-components/spec.md
index 8446cc38f07..5018933dbcd 100644
--- a/openspec/specs/domain-web-components/spec.md
+++ b/openspec/specs/domain-web-components/spec.md
@@ -560,3 +560,154 @@ Generated light DOM SHALL expose documented stable styling, region, lifecycle, a
#### Scenario: Application needs custom structure
- **WHEN** automatic layout is not suitable
- **THEN** the application can continue composing established lower-level components directly beneath the same object context
+
+### Requirement: Causeway menu-bar component vocabulary
+The component library SHALL provide `<causeway-menubars>`, `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` as framework-neutral semantic application-menu components.
+
+#### Scenario: Composite menu bars connect
+- **WHEN** `<causeway-menubars>` connects beneath a configured GraphQL client
+- **THEN** it discovers the optional application menu capability through targeted introspection
+- **AND** coordinates present primary, secondary, and tertiary bar components in semantic order
+
+#### Scenario: One bar is used independently
+- **WHEN** an application uses a primary, secondary, or tertiary bar without the composite
+- **THEN** that component can obtain the same authorized effective resource and render only its semantic bar
+- **AND** does not require a public application-context element
+
+### Requirement: Generation-scoped application-menu coordination
+The composite SHALL own one bounded application-menu generation shared by its bars without caching effective content across users, client instances, or explicit refresh generations.
+
+#### Scenario: Composite loads its current generation
+- **WHEN** application metadata exposes a safe effective menu resource descriptor
+- **THEN** the coordinator reads that metadata once, fetches the opaque resource once with same-origin no-store semantics, and shares one immutable plan with child bars
+
+#### Scenario: Application menu capability is unavailable
+- **WHEN** targeted introspection shows that `application` or `menuBars` is absent
+- **THEN** the component reports a local bounded unsupported state
+- **AND** does not issue an invalid GraphQL operation or invent a client-side menu hierarchy
+
+#### Scenario: Application refreshes menu state
+- **WHEN** the application calls `refresh()`
+- **THEN** a new generation re-evaluates application metadata, resource content, and current service-action state
+- **AND** superseded metadata, resource, and action-state responses cannot replace the newer generation
+
+#### Scenario: Multiple contexts start against a cold GraphQL viewer
+- **WHEN** object and menu contexts issue concurrent initial requests before the GraphQL execution source exists
+- **THEN** the viewer initializes one complete execution source without corrupting shared schema registries
+- **AND** this initialization guard does not introduce parallel data-fetcher or mutation execution
+
+### Requirement: Declarative and generated bar composition
+The composite SHALL preserve declaratively supplied semantic bar children and generate only missing effective non-empty bar roles.
+
+#### Scenario: Children exist before custom-element upgrade
+- **WHEN** declarative primary or tertiary children are parsed before registration
+- **THEN** the composite captures and reuses them after upgrade
+- **AND** does not generate duplicate bars for those roles
+
+#### Scenario: Effective bar is absent
+- **WHEN** the effective menu resource contains no visible entries for a bar
+- **THEN** the composite does not generate a missing child for that role
+- **AND** a declarative child for that role exposes no empty interactive landmark
+
+### Requirement: Secure effective menu structure rendering
+Each bar SHALL use shared bounded structural-resource and XML safety rules to preserve the effective ordered menus, sections, service-action references, labels, descriptions, icon hints, and supported presentation data.
+
+#### Scenario: Menu resource is parsed
+- **WHEN** a bar receives an authorized menu resource in the documented Causeway namespace
+- **THEN** parsing rejects document types, entities, executable markup, cross-origin expansion, malformed nesting, and configured size or complexity limit violations
+- **AND** arbitrary response markup is never inserted into the component DOM
+
+#### Scenario: Bar contains multiple menus and sections
+- **WHEN** a bar is rendered
+- **THEN** menus, sections, and visible service actions follow effective Causeway document order
+- **AND** optional presentation metadata is exposed only as text-safe documented light-DOM hooks
+
+#### Scenario: Menu content is partially unsupported
+- **WHEN** one local node or action reference is unknown, malformed, stale, or wrong-kind
+- **THEN** the bar records a bounded redacted local diagnostic
+- **AND** retains unrelated recognized menus, sections, and actions
+
+### Requirement: Coordinated service-action state
+Menu components SHALL resolve current hidden and disabled state through established rich service-action wrappers while coordinating reads by logical service type.
+
+#### Scenario: Current menu action state is loaded
+- **WHEN** one generation contains actions from one or more logical service types
+- **THEN** targeted schema descriptions are cached by the GraphQL client
+- **AND** hidden and disabled state reads are grouped by logical service type rather than issued once per action
+
+#### Scenario: Action is hidden
+- **WHEN** a canonical service-action wrapper reports hidden for the current interaction
+- **THEN** no visible menu entry, label, description, icon hint, or authorization metadata for that action is rendered
+
+#### Scenario: Action is disabled
+- **WHEN** a service action is visible but disabled
+- **THEN** its entry remains represented and counted as visible
+- **AND** it is non-invokable and exposes its established disabled reason accessibly where available
+
+#### Scenario: Group becomes empty
+- **WHEN** current state removes every visible action from a section, menu, or bar
+- **THEN** empty sections, menus, and bars collapse in that order without an empty interactive landmark
+
+### Requirement: Service-action interaction reuse
+Menu entries SHALL reuse established semantic parameter, editor, choices, autocomplete, validation, invocation, cancellation, stale-response, mutation-serialization, and typed-result behavior for rich service actions.
+
+#### Scenario: Service action requires parameters
+- **WHEN** a user activates a parameterized menu action
+- **THEN** the standard accessible interaction presentation negotiates and validates parameters through a service-bound adapter
+- **AND** invokes the advertised rich service-action operation with typed values and no manufactured object target
+
+#### Scenario: Service action is mutating
+- **WHEN** a mutating service action is submitted through one application-menu coordinator
+- **THEN** it uses the existing top-level mutation field
+- **AND** mutating submissions are serialized in submission order
+
+#### Scenario: Service action returns a result
+- **WHEN** invocation returns scalar, object, collection, or void semantics
+- **THEN** the menu component publishes the established semantic result event and typed result
+- **AND** additive target detail identifies the public service logical type without pretending it is an object bookmark
+
+### Requirement: Accessible menu disclosure behavior
+Menu bars SHALL use labelled navigation landmarks and keyboard-operable native disclosure and action controls with documented traversal, closing, sibling coordination, and focus restoration.
+
+#### Scenario: User opens and closes a menu by keyboard
+- **WHEN** the user operates a menu disclosure with Enter or Space and later presses Escape
+- **THEN** the menu opens and closes without hover dependency
+- **AND** focus returns to the originating disclosure button
+
+#### Scenario: User traverses controls
+- **WHEN** the user uses Tab, Shift+Tab, Home, End, or documented arrow keys
+- **THEN** focus follows native document order or documented peer-disclosure order without becoming trapped
+- **AND** opening one menu closes sibling menus in the same bar
+
+#### Scenario: Assistive technology encounters a bar
+- **WHEN** a non-empty bar is rendered
+- **THEN** it exposes a labelled navigation landmark, native buttons, `aria-expanded`, and `aria-controls`
+- **AND** it does not misuse ARIA application-menu roles for ordinary page navigation
+
+### Requirement: Responsive semantic menu bars
+Menu bars SHALL adapt to wide and narrow layouts without changing semantic bar, menu, section, action order, interaction state, or event contracts.
+
+#### Scenario: Narrow layout is active
+- **WHEN** available width crosses the documented narrow threshold
+- **THEN** non-empty bars and menus use accessible disclosures in unchanged document order
+- **AND** every visible action remains keyboard operable with visible focus and no horizontal page overflow
+
+### Requirement: Observable and customizable menu composition
+Generated light DOM SHALL expose stable bar, menu, section, action, state, lifecycle, diagnostic, data-attribute, CSS-variable, and styling hooks without exposing sensitive remote content.
+
+#### Scenario: Application themes menu bars
+- **WHEN** an application supplies documented CSS variables and selectors
+- **THEN** it can style primary, secondary, tertiary, menu, section, and action regions without replacing semantic behavior
+
+#### Scenario: Partial menu error occurs
+- **WHEN** a capability, resource, reference, or service-state operation fails locally
+- **THEN** bounded lifecycle and diagnostic events identify the safe failure scope
+- **AND** diagnostics omit response bodies, credentials, authorization rules, submitted values, and remote exception text
+
+### Requirement: Host-controlled menu policy
+Menu components SHALL leave routes, browser history, authentication chrome, automatic home-page behavior, shell-closing behavior, and action-result navigation or presentation to the host.
+
+#### Scenario: Host receives a service object result
+- **WHEN** a service action publishes a semantic object result
+- **THEN** the host may navigate, render it, close a shell menu, or do nothing according to policy
+- **AND** the menu component does not assume HTMX, a canonical route, or an automatic home action
(causeway) 02/05: adds menubar web components
Posted by da...@apache.org.
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 86e424d1a251990b18f8f08077eb9a10c1fab20a
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Tue Aug 18 22:29:43 2026 +0100
adds menubar web components
---
.../changes/add-menubar-web-components/design.md | 3 +
.../changes/add-menubar-web-components/proposal.md | 3 +-
.../specs/domain-web-components/spec.md | 5 +
.../changes/add-menubar-web-components/tasks.md | 48 +--
.../ExecutionGraphQlServiceForCauseway.java | 25 +-
.../integration/GraphQlSourceForCauseway.java | 24 +-
viewers/webcomponents/README.adoc | 25 +-
viewers/webcomponents/foundation/README.adoc | 4 +-
viewers/webcomponents/foundation/USAGE.adoc | 74 +++-
.../foundation/src/application-operations.mjs | 64 +++
.../foundation/src/component-contracts.mjs | 18 +-
.../foundation/src/component-styles.mjs | 142 +++++++
.../foundation/src/context-events.mjs | 13 +
.../foundation/src/graphql-client.mjs | 150 ++++++-
viewers/webcomponents/foundation/src/index.mjs | 7 +
.../src/interaction-controller-element.mjs | 60 ++-
.../foundation/src/interaction-operations.mjs | 36 +-
.../foundation/src/menu-context-controller.mjs | 343 ++++++++++++++++
.../webcomponents/foundation/src/menu-layout.mjs | 311 ++++++++++++++
.../foundation/src/menubar-element.mjs | 381 +++++++++++++++++
.../foundation/src/menubars-element.mjs | 208 ++++++++++
.../foundation/src/object-context-controller.mjs | 61 +--
.../webcomponents/foundation/src/object-layout.mjs | 165 +-------
viewers/webcomponents/foundation/src/register.mjs | 10 +
.../foundation/src/service-action-context.mjs | 454 +++++++++++++++++++++
.../foundation/src/structural-resource.mjs | 86 ++++
.../foundation/src/structural-xml.mjs | 215 ++++++++++
.../test/application-menu-client.test.mjs | 202 +++++++++
.../test/fixtures/menu-graphql-fixture.mjs | 247 +++++++++++
.../test/fixtures/menu-layout-fixtures.mjs | 78 ++++
.../foundation/test/menu-context.test.mjs | 161 ++++++++
.../foundation/test/menu-layout.test.mjs | 116 ++++++
.../foundation/test/menubar-component.test.mjs | 175 ++++++++
viewers/webcomponents/sample-html/README.adoc | 83 +++-
.../sample/html/domain/SampleMenu.java | 118 ++++++
.../src/main/resources/menubars.layout.xml | 56 +++
.../main/resources/static/sample-html/index.html | 34 +-
.../html/SampleHtmlApplication_IntegTest.java | 139 +++++++
38 files changed, 4064 insertions(+), 280 deletions(-)
diff --git a/openspec/changes/add-menubar-web-components/design.md b/openspec/changes/add-menubar-web-components/design.md
index 141dd8a3a8a..3d2859b6ad5 100644
--- a/openspec/changes/add-menubar-web-components/design.md
+++ b/openspec/changes/add-menubar-web-components/design.md
@@ -44,6 +44,8 @@ The controller uses targeted introspection to discover the configured rich root,
It reads application-entry metadata once per generation, validates the opaque origin-relative resource descriptor, fetches it with same-origin credentials and no-store semantics, and provides one immutable menu plan to child bars.
An explicit `refresh()` starts a new generation, aborts superseded metadata, resource, and service-state requests, and re-evaluates authorization-sensitive and locale-sensitive content.
The controller does not retain menu documents or action state across users, GraphQL-client instances, page loads, or explicit generations.
+One GraphQL client serializes its initial targeted schema-discovery operations, and the GraphQL viewer initializes its lazy execution delegate and `GraphQL` source exactly once under concurrent first requests.
+This cold-start correctness guard does not parallelize data fetchers or alter serial top-level mutation semantics.
No public application-context element is introduced in this slice.
A bar beneath `<causeway-menubars>` consumes the composite's internal context, while a standalone bar requests the nearest GraphQL client and owns an equivalent private controller.
@@ -118,6 +120,7 @@ The components never resolve or invoke a home page, select routes, mutate browse
## Risks / Trade-offs
- [Application-entry resources vary by user and locale] → Scope state to one GraphQL client and explicit generation, use no-store requests, and expose `refresh()`.
+- [Object and menu contexts can race lazy schema startup] → Serialize one client's schema discovery and make viewer execution-source initialization thread safe without introducing fetcher parallelism.
- [Many menu actions can cause excessive network work] → Batch current state by logical service type and reuse targeted schema descriptions.
- [Menus can become keyboard traps] → Use native controls and navigation landmarks rather than ARIA application-menu mode, and verify focus restoration and Escape behavior.
- [Service actions lack object identity] → Publish an explicit service interaction target instead of manufacturing a bookmark.
diff --git a/openspec/changes/add-menubar-web-components/proposal.md b/openspec/changes/add-menubar-web-components/proposal.md
index 4794c723fe0..ca9c0489883 100644
--- a/openspec/changes/add-menubar-web-components/proposal.md
+++ b/openspec/changes/add-menubar-web-components/proposal.md
@@ -11,6 +11,7 @@ The reference evidence is recorded in `coverage-matrix.yaml` entries `REF-MENU-0
- Add public `<causeway-menubar-primary>`, `<causeway-menubar-secondary>`, and `<causeway-menubar-tertiary>` components that can be coordinated by the composite or used independently.
- Discover the rich GraphQL application capability through targeted introspection, read its menu resource descriptor, and securely fetch and parse the authorized effective Causeway menu-bars XML.
- Share one bounded application-menu generation across composite child bars while allowing standalone bars to own an equivalent private generation and explicit refresh lifecycle.
+- Serialize initial targeted discovery within one browser client and make the GraphQL viewer's lazy execution-source initialization safe for concurrent cold-start requests without enabling fetcher parallelism.
- Render ordered menus, sections, text-safe presentation hints, and visible service actions while coordinating current hidden and disabled action state by logical service type.
- Adapt the existing editor, prompt, validation, invocation, cancellation, stale-response, and semantic-result primitives to service actions without requiring an object bookmark or adding another invocation grammar.
- Provide accessible wide and narrow disclosure behavior, keyboard operation, focus restoration, landmarks, empty-state handling, light-DOM styling hooks, and bounded redacted diagnostics.
@@ -29,7 +30,7 @@ None.
## Impact
-- Affects the web-component foundation module, GraphQL client operations, shared structural-resource safety primitives, application-menu coordination, service-action interaction adaptation, styling, tests, sample HTML, and documentation.
+- Affects the web-component foundation module, GraphQL client operations, GraphQL viewer cold-start initialization, shared structural-resource safety primitives, application-menu coordination, service-action interaction adaptation, styling, tests, sample HTML, and documentation.
- Depends on archived object-interaction correctness, resource-link safety, value semantics, application entry points, and component interaction work.
- Uses the consistent public spelling `causeway-menubar-*`; no `causeway-menu-bar-*` aliases are introduced.
- Adds no public framework-neutral application-context element because coordination remains internal to the composite or standalone bar.
diff --git a/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md b/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
index 0bca273358e..3f8f558e934 100644
--- a/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
+++ b/openspec/changes/add-menubar-web-components/specs/domain-web-components/spec.md
@@ -30,6 +30,11 @@ The composite SHALL own one bounded application-menu generation shared by its ba
- **THEN** a new generation re-evaluates application metadata, resource content, and current service-action state
- **AND** superseded metadata, resource, and action-state responses cannot replace the newer generation
+#### Scenario: Multiple contexts start against a cold GraphQL viewer
+- **WHEN** object and menu contexts issue concurrent initial requests before the GraphQL execution source exists
+- **THEN** the viewer initializes one complete execution source without corrupting shared schema registries
+- **AND** this initialization guard does not introduce parallel data-fetcher or mutation execution
+
### Requirement: Declarative and generated bar composition
The composite SHALL preserve declaratively supplied semantic bar children and generate only missing effective non-empty bar roles.
diff --git a/openspec/changes/add-menubar-web-components/tasks.md b/openspec/changes/add-menubar-web-components/tasks.md
index af0fdd407e6..09754844954 100644
--- a/openspec/changes/add-menubar-web-components/tasks.md
+++ b/openspec/changes/add-menubar-web-components/tasks.md
@@ -1,38 +1,38 @@
## 1. Public Vocabulary and Coordination
-- [ ] 1.1 Translate matrix entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04` into menu-resource and service-action fixtures.
-- [ ] 1.2 Verify archived object-interaction, structural-resource, application-entry, value-semantics, and composite-object prerequisites and their synced specifications.
-- [ ] 1.3 Factor shared opaque structural-resource loading and bounded safe XML parsing so object-grid and menu parsing retain identical security limits.
-- [ ] 1.4 Add targeted GraphQL application-entry and logical-service description and execution operations without full-schema introspection.
-- [ ] 1.5 Define the internal generation-scoped application-menu coordinator, service adapters, explicit refresh, cancellation, standalone-bar, and declarative-child contracts.
-- [ ] 1.6 Register and export `<causeway-menubars>` and primary, secondary, and tertiary semantic bar elements while preserving pre-upgrade declarative children.
+- [x] 1.1 Translate matrix entries `REF-MENU-01`, `REF-COMPONENT-02`, `REF-ACTION-03`, and `REF-ACTION-04` into menu-resource and service-action fixtures.
+- [x] 1.2 Verify archived object-interaction, structural-resource, application-entry, value-semantics, and composite-object prerequisites and their synced specifications.
+- [x] 1.3 Factor shared opaque structural-resource loading and bounded safe XML parsing so object-grid and menu parsing retain identical security limits.
+- [x] 1.4 Add targeted GraphQL application-entry and logical-service description and execution operations without full-schema introspection, and make concurrent lazy GraphQL source initialization deterministic.
+- [x] 1.5 Define the internal generation-scoped application-menu coordinator, service adapters, explicit refresh, cancellation, standalone-bar, and declarative-child contracts.
+- [x] 1.6 Register and export `<causeway-menubars>` and primary, secondary, and tertiary semantic bar elements while preserving pre-upgrade declarative children.
## 2. Menu Layout and Current Action State
-- [ ] 2.1 Parse the documented effective menu-bars namespace, semantic bars, menus, sections, service-action references, and text-safe presentation hints with bounded redacted diagnostics.
-- [ ] 2.2 Preserve effective document order and produce one immutable primary, secondary, and tertiary menu plan per generation.
-- [ ] 2.3 Implement composite sharing, generated missing roles, declarative-role reuse, standalone private coordination, and absent-role collapse.
-- [ ] 2.4 Batch current hidden and disabled service-action reads by logical service type while isolating missing services, stale actions, and partial GraphQL errors.
-- [ ] 2.5 Omit hidden entries, present disabled entries and reasons accessibly, and collapse empty sections, menus, and bars.
+- [x] 2.1 Parse the documented effective menu-bars namespace, semantic bars, menus, sections, service-action references, and text-safe presentation hints with bounded redacted diagnostics.
+- [x] 2.2 Preserve effective document order and produce one immutable primary, secondary, and tertiary menu plan per generation.
+- [x] 2.3 Implement composite sharing, generated missing roles, declarative-role reuse, standalone private coordination, and absent-role collapse.
+- [x] 2.4 Batch current hidden and disabled service-action reads by logical service type while isolating missing services, stale actions, and partial GraphQL errors.
+- [x] 2.5 Omit hidden entries, present disabled entries and reasons accessibly, and collapse empty sections, menus, and bars.
## 3. Service-Action Interaction Reuse
-- [ ] 3.1 Implement service-bound action-context adapters over the existing editor, prompt, choices, autocomplete, validation, and typed argument primitives.
-- [ ] 3.2 Reuse or generate one standard interaction controller per composite or standalone scope and publish additive public service-target event detail.
-- [ ] 3.3 Invoke advertised safe query or mutating top-level fields without an object target, serialize mutations, and reject superseded transient responses.
-- [ ] 3.4 Preserve scalar, object, collection, and void result events while leaving navigation, result presentation, and shell-closing policy to the host.
+- [x] 3.1 Implement service-bound action-context adapters over the existing editor, prompt, choices, autocomplete, validation, and typed argument primitives.
+- [x] 3.2 Reuse or generate one standard interaction controller per composite or standalone scope and publish additive public service-target event detail.
+- [x] 3.3 Invoke advertised safe query or mutating top-level fields without an object target, serialize mutations, and reject superseded transient responses.
+- [x] 3.4 Preserve scalar, object, collection, and void result events while leaving navigation, result presentation, and shell-closing policy to the host.
## 4. Accessible Responsive Presentation and Diagnostics
-- [ ] 4.1 Implement labelled navigation landmarks and native bar, menu-disclosure, and service-action controls without ARIA application-menu mode.
-- [ ] 4.2 Implement Tab, Shift+Tab, Enter, Space, Escape, Home, End, arrow navigation, sibling-menu closing, and focus restoration.
-- [ ] 4.3 Implement responsive wide and narrow disclosures without changing semantic order, action state, focus visibility, or event contracts.
-- [ ] 4.4 Add stable light-DOM role, bar, menu, section, action, state, lifecycle, diagnostic, CSS-variable, data-attribute, and styling hooks.
+- [x] 4.1 Implement labelled navigation landmarks and native bar, menu-disclosure, and service-action controls without ARIA application-menu mode.
+- [x] 4.2 Implement Tab, Shift+Tab, Enter, Space, Escape, Home, End, arrow navigation, sibling-menu closing, and focus restoration.
+- [x] 4.3 Implement responsive wide and narrow disclosures without changing semantic order, action state, focus visibility, or event contracts.
+- [x] 4.4 Add stable light-DOM role, bar, menu, section, action, state, lifecycle, diagnostic, CSS-variable, data-attribute, and styling hooks.
## 5. Acceptance and Documentation
-- [ ] 5.1 Add deterministic parser, coordination, service-state, interaction, security, stale-response, declarative-child, empty-group, localization, and malformed-reference tests.
-- [ ] 5.2 Extend the vanilla-HTML sample with composite and standalone bar examples backed by deterministic primary, secondary, tertiary, hidden, disabled, parameterized, safe, mutating, and typed-result service actions.
-- [ ] 5.3 Execute real-browser pointer, keyboard-only, focus, responsive, light/dark, action-interaction, partial-error, fallback-absence, and console verification.
-- [ ] 5.4 Document component names, nesting, independent use, GraphQL and resource dependencies, refresh behavior, styling, events, interaction targets, diagnostics, security limits, and host-policy boundaries.
-- [ ] 5.5 Run Node, Maven, browser, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
+- [x] 5.1 Add deterministic parser, coordination, service-state, interaction, security, stale-response, declarative-child, empty-group, localization, and malformed-reference tests.
+- [x] 5.2 Extend the vanilla-HTML sample with composite and standalone bar examples backed by deterministic primary, secondary, tertiary, hidden, disabled, parameterized, safe, mutating, and typed-result service actions.
+- [x] 5.3 Execute real-browser pointer, keyboard-only, focus, responsive, light/dark, action-interaction, partial-error, fallback-absence, and console verification.
+- [x] 5.4 Document component names, nesting, independent use, GraphQL and resource dependencies, refresh behavior, styling, events, interaction targets, diagnostics, security limits, and host-policy boundaries.
+- [x] 5.5 Run Node, Maven, browser, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
diff --git a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/ExecutionGraphQlServiceForCauseway.java b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/ExecutionGraphQlServiceForCauseway.java
index c75c7d97dab..94280d2c6ae 100644
--- a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/ExecutionGraphQlServiceForCauseway.java
+++ b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/ExecutionGraphQlServiceForCauseway.java
@@ -41,19 +41,26 @@ public class ExecutionGraphQlServiceForCauseway implements ExecutionGraphQlServi
private final BatchLoaderRegistry batchLoaderRegistry;
private final GraphQlSource graphQlSource;
- DefaultExecutionGraphQlService delegate;
+ private volatile DefaultExecutionGraphQlService delegate;
@Override
public Mono<ExecutionGraphQlResponse> execute(final ExecutionGraphQlRequest request) {
- if(delegate == null) {
- try {
- delegate = new DefaultExecutionGraphQlService(graphQlSource);
- delegate.addDataLoaderRegistrar(batchLoaderRegistry);
- } catch (Exception e) {
- e.printStackTrace();
- throw e;
+ return delegate().execute(request);
+ }
+
+ private DefaultExecutionGraphQlService delegate() {
+ var current = delegate;
+ if (current != null) {
+ return current;
+ }
+ synchronized (this) {
+ current = delegate;
+ if (current == null) {
+ current = new DefaultExecutionGraphQlService(graphQlSource);
+ current.addDataLoaderRegistrar(batchLoaderRegistry);
+ delegate = current;
}
}
- return delegate.execute(request);
+ return current;
}
}
diff --git a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/GraphQlSourceForCauseway.java b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/GraphQlSourceForCauseway.java
index aff125cb27d..834054c6d6a 100644
--- a/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/GraphQlSourceForCauseway.java
+++ b/viewers/graphql/viewer/src/main/java/org/apache/causeway/viewer/graphql/viewer/integration/GraphQlSourceForCauseway.java
@@ -77,18 +77,26 @@ public void init() {
}
}
- GraphQL graphQL;
+ private volatile GraphQL graphQL;
@Override
public GraphQL graphQl() {
- if (graphQL == null) {
- graphQL = GraphQL.newGraphQL(schema())
- .defaultDataFetcherExceptionHandler(new SimpleDataFetcherExceptionHandler())
- .queryExecutionStrategy(executionStrategy)
- .mutationExecutionStrategy(executionStrategy)
- .build();
+ var current = graphQL;
+ if (current != null) {
+ return current;
}
- return graphQL;
+ synchronized (this) {
+ current = graphQL;
+ if (current == null) {
+ current = GraphQL.newGraphQL(schema())
+ .defaultDataFetcherExceptionHandler(new SimpleDataFetcherExceptionHandler())
+ .queryExecutionStrategy(executionStrategy)
+ .mutationExecutionStrategy(executionStrategy)
+ .build();
+ graphQL = current;
+ }
+ }
+ return current;
}
@Override
diff --git a/viewers/webcomponents/README.adoc b/viewers/webcomponents/README.adoc
index 1bfcc9d2341..f7c4a498394 100644
--- a/viewers/webcomponents/README.adoc
+++ b/viewers/webcomponents/README.adoc
@@ -3,7 +3,7 @@
:Notice: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at. http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or ag [...]
The Causeway web-component modules provide framework-neutral browser components backed by the Causeway rich GraphQL schema.
-The foundation module includes a GraphQL client provider, coordinated object contexts, safe effective-grid and fallback composite objects, semantic read-only rendering, explicitly enabled property editing, action prompts and invocation, normalized interaction results, and lazy collections.
+The foundation module includes a GraphQL client provider, coordinated application menu bars, service-action prompts and invocation, coordinated object contexts, safe effective-grid and fallback composite objects, semantic read-only rendering, explicitly enabled property editing, normalized interaction results, and lazy collections.
It deliberately has no dependency on HTMX, React, Vue, Svelte, or a browser component-authoring framework.
This page explains how to run the automated verification and how to exercise the components manually against a Causeway application.
@@ -34,8 +34,8 @@ A successful run reports the Node test totals and completes both Maven modules:
[source,text]
----
-tests 78
-pass 78
+tests 102
+pass 102
fail 0
BUILD SUCCESS
----
@@ -53,7 +53,7 @@ cd viewers/webcomponents/foundation
node --test --test-concurrency=1
----
-The tests cover safe grid parsing, deterministic and fallback layout planning, composite-object lifecycle and diagnostics, context-owned structural resources, generated rich-schema naming, targeted introspection, schema diagnostics, executor behavior, selection coordination, property and action semantic commands, editor selection, validation, cancellation, mutation serialization, prompts, typed outcomes, partial errors, component lifecycle, context propagation, and integrated state distr [...]
+The tests cover targeted application and service discovery, bounded menu and grid parsing, coordinated menu generations, standalone and declarative bars, grouped service-action state, service prompts and results, deterministic and fallback object layout planning, context-owned structural resources, generated rich-schema naming, schema diagnostics, executor behavior, selection coordination, property and object-action semantic commands, editor selection, validation, cancellation, mutation [...]
=== Related GraphQL modules
@@ -74,14 +74,14 @@ mvn -f bom/pom.xml validate
git diff --check
-openspec validate add-composite-object-web-component --strict
+openspec validate add-menubar-web-components --strict
----
The OpenSpec command should report:
[source,text]
----
-Change 'add-composite-object-web-component' is valid
+Change 'add-menubar-web-components' is valid
----
=== Packaged browser resources
@@ -186,7 +186,8 @@ Serve a temporary page from the Causeway application with markup similar to the
</head>
<body>
<causeway-graphql-client endpoint="/graphql">
- <causeway-object-context
+ <causeway-menubars></causeway-menubars>
+ <causeway-object-context
logical-type="university.dept.Department"
object-id="1">
<causeway-object editable></causeway-object>
@@ -223,6 +224,7 @@ For realistic authentication behavior, same-origin serving is usually simpler.
=== Rendered content
+Verify that `<causeway-menubars>` reaches `data-menu-state="ready"` or bounded `partial-error`, renders labelled effective primary, secondary, and tertiary navigation without hidden entries, and preserves visible disabled actions with reasons.
Verify that `<causeway-object>` reaches `data-layout-state="ready"` for an authorized effective grid or `fallback` when the resource is unavailable.
Verify that its generated `<causeway-object-header>` renders object title and identity and each discovered member is placed at most once.
Verify that each visible property has a label and an `<output>` value.
@@ -237,7 +239,9 @@ Open the browser developer tools and inspect the GraphQL POST requests.
The initial load should perform a small series of operations named `CausewayDescribeTypes` using targeted `__type` introspection.
It should not download and inspect the complete GraphQL schema.
-After schema discovery, one operation named `CausewayReadObject` should contain the combined active selection for the object header and all properties mounted during the initial rendering turn.
+Application menus first perform targeted operation-root, application-entry, and referenced service-type introspection, one `CausewayReadApplicationEntry`, one same-origin no-store effective-menu GET, and grouped `CausewayReadServiceActionStates` operations by logical service type.
+A composite shares those operations across its bars, while each standalone bar intentionally owns a separate generation.
+After object schema discovery, one operation named `CausewayReadObject` should contain the combined active selection for the object header and all properties mounted during the initial rendering turn.
For the composite example, the first object operation includes `_meta.grid`; generated children then contribute member requirements to a coordinated delta operation.
Auto layout also performs one same-origin no-store GET for the opaque grid reference, while forced fallback performs no structural-resource request.
There should not be one object operation per component.
@@ -338,6 +342,11 @@ Adjust the `<causeway-graphql-client>` attributes to match those settings.
Prefer a page served from the same application origin as `/graphql`.
For a cross-origin page, configure CORS explicitly and inject an executor if credentials, CSRF handling, or application-specific headers are required.
+=== Application menus are unavailable
+
+Confirm that the rich GraphQL root exposes `application.menuBars` and that structural metadata response type is `DIRECT` or `ATTACHMENT` rather than `FORBIDDEN`.
+Inspect `causeway-menubars-state-change` and bounded `causeway-menubars-diagnostic` events without logging the effective XML response body.
+
=== Effective grid is unavailable
Confirm the structural metadata response policy and inspect the grid GET status without logging its response body.
diff --git a/viewers/webcomponents/foundation/README.adoc b/viewers/webcomponents/foundation/README.adoc
index bfb8bc92bd6..988a127c19a 100644
--- a/viewers/webcomponents/foundation/README.adoc
+++ b/viewers/webcomponents/foundation/README.adoc
@@ -12,6 +12,6 @@
This module packages framework-neutral ECMAScript modules that consume the Apache Causeway rich GraphQL schema.
The Maven artifact exposes the modules below `META-INF/resources/causeway-webcomponents/` and runs its dependency-free test suite with the Node.js test runner.
-The package provides layout-aware composite objects, semantic object headers, read-only and explicitly editable properties, values, object links, action affordances, the standard accessible action interaction controller, semantic command execution, editor and renderer extension, normalized action outcomes, lazy collections, declarative collection columns, hydrated row contexts, semantic events, and optional minimal styles.
+The package provides coordinated and standalone application menu bars, service-action interaction adapters, layout-aware composite objects, semantic object headers, read-only and explicitly editable properties, values, object links, action affordances, the standard accessible action interaction controller, semantic command execution, editor and renderer extension, normalized action outcomes, lazy collections, declarative collection columns, hydrated row contexts, semantic events, and opt [...]
The foundation deliberately has no HTMX, React, Vue, Svelte, or browser component-authoring runtime dependency.
-See link:USAGE.adoc[USAGE.adoc] for the public component contracts, effective-grid and fallback object composition, plain-HTML composition, renderer and editor registration, property validation and mutation, action prompts and results, application overrides, collection activation and row hydration, accessibility and styling classes, semantic events, and current collection-size limitations.
+See link:USAGE.adoc[USAGE.adoc] for the public component contracts, application-entry menu coordination, safe effective-menu parsing, service-action prompts and results, standalone bars, effective-grid and fallback object composition, plain-HTML composition, renderer and editor registration, property validation and mutation, application overrides, collection activation and row hydration, accessibility and styling classes, semantic events, and current collection-size limitations.
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index ebffe2637fe..748b989be16 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -49,7 +49,7 @@ The collection remains inactive until its load button is selected or its `active
== Public component contract
-The public vocabulary consists of `causeway-graphql-client`, `causeway-object-context`, `causeway-object-header`, `causeway-property`, `causeway-value`, `causeway-object-link`, `causeway-action`, `causeway-interaction-controller`, `causeway-collection`, and `causeway-collection-column`.
+The public vocabulary consists of `causeway-graphql-client`, `causeway-menubars`, `causeway-menubar-primary`, `causeway-menubar-secondary`, `causeway-menubar-tertiary`, `causeway-object-context`, `causeway-object`, `causeway-object-header`, `causeway-property`, `causeway-value`, `causeway-object-link`, `causeway-action`, `causeway-interaction-controller`, `causeway-collection`, and `causeway-collection-column`.
`CausewayElementName`, `CausewayAttribute`, `CausewayHostClass`, and `CausewaySemanticEvent` export the stable names used by the implementation.
Member components use the `member` attribute and accept an optional `label` override.
@@ -95,6 +95,8 @@ The client derives generated rich-schema names from the Causeway logical type an
It describes the generated object type and then issues independent cached one-type introspection operations for reachable property, collection, action, parameter, metadata, and value wrapper types.
This pattern is compatible with GraphQL Java's good-faith introspection limits because one operation does not repeat `__Type.fields` across many aliases.
The client does not request an application-specific member-list field and does not access Causeway Java metamodel services.
+Application-menu discovery separately introspects the operation roots, configured rich root, `application` result, menu descriptor, and referenced public service types through the same cached one-type operations.
+One browser client serializes these initial targeted discovery requests so object and menu contexts do not race a cold GraphQL schema, while the viewer initializes its lazy execution source once without changing data-fetcher or mutation execution policy.
Descriptions include member kind, advertised wrapper fields, output type reference, named type, innermost type kind, collection element type, and reachable value-shape fields.
Descriptions are cached by generated type name within one `<causeway-graphql-client>` instance.
@@ -150,6 +152,76 @@ Stable hooks include `causeway-object`, `causeway-object-row`, `causeway-object-
`causeway-object-layout-diagnostic` reports one redacted bounded diagnostic without response content or authorization rules.
Applications that need different structure continue to author the established lower-level elements directly beneath the same context.
+== Application menu bars and service actions
+
+`<causeway-menubars>` is the framework-neutral high-level coordinator for Causeway's effective primary, secondary, and tertiary application menu bars.
+It connects directly beneath a configured `<causeway-graphql-client>` and does not require an object context:
+
+[source,html]
+----
+<causeway-graphql-client endpoint="/graphql">
+ <causeway-menubars></causeway-menubars>
+</causeway-graphql-client>
+----
+
+The composite uses targeted introspection to discover the rich root's optional `application.menuBars` capability.
+It reads the application descriptor once per generation, fetches the opaque origin-relative resource once with same-origin credentials and no-store semantics, and shares one immutable plan across generated or declarative bar children.
+It generates only effective non-empty roles in primary, secondary, tertiary order and preserves direct declarative bar children that existed before registration or custom-element upgrade.
+A direct authored `<causeway-interaction-controller>` is reused; otherwise the composite generates one standard controller for all service actions.
+
+Each semantic bar is independently usable and owns an equivalent private generation when no composite provides one:
+
+[source,html]
+----
+<causeway-graphql-client endpoint="/graphql">
+ <causeway-menubar-secondary></causeway-menubar-secondary>
+</causeway-graphql-client>
+----
+
+A standalone bar fetches and parses the same effective application resource but renders only its own semantic role.
+It generates a private standard interaction controller unless it consumes composite state.
+No public application-context element is required.
+
+The server must enable structural metadata resources for `application.menuBars` to appear:
+
+[source,properties]
+----
+causeway.viewer.graphql.resources.structural-metadata-response-type=DIRECT
+----
+
+When the capability is omitted, the components report bounded `unsupported` state and do not issue an invalid operation or invent a client-side menu hierarchy.
+The server's explicit or generated effective menu remains the canonical structural source.
+
+The menu parser accepts the Causeway bootstrap menu-bars namespace and the documented `menuBars`, primary/secondary/tertiary, `menu`, `named`, `section`, and `serviceAction` subset.
+It shares the object-grid parser's one-mebibyte input limit, depth limit of 64, element limit of 4,096, and diagnostic limit of 20.
+Document types, entity declarations and references, CDATA, non-XML processing instructions, scripts, styles, executable attributes, malformed nesting, wrong namespaces, and oversized content are rejected.
+Resource markup is never inserted into the page.
+Labels and descriptions are escaped text, while CSS and icon hints are exposed only as inert documented `data-*` attributes.
+
+Entries identify rich service-action wrappers by public logical service type and semantic action ID.
+Current hidden and disabled state is grouped into one read per logical service type for a generation rather than one operation per menu entry.
+Hidden actions expose no entry or presentation metadata.
+Disabled actions remain visible and counted, cannot be invoked, and associate their established reason accessibly.
+Empty sections, menus, and bars collapse after current state has been applied.
+
+Service-bound adapters reuse the standard parameter editors, defaults, choices, autocomplete, validation, prompt, cancellation, stale-response, and typed-result behavior.
+Safe actions use their advertised rich query invocation, while mutating actions use the existing top-level mutation and are serialized within the application-menu coordinator.
+No object bookmark or `_target` is manufactured for a service action.
+`causeway-action-request`, `causeway-action-prompt-state-change`, and `causeway-action-result` retain their established names.
+Service interaction events add `target: {kind: "service", logicalTypeName}`, `serviceLogicalTypeName`, and `identity: null`; object interaction identities remain unchanged.
+Routing, shell closing, result rendering, browser history, home behavior, and navigation remain host policy.
+
+Each non-empty bar renders a labelled `nav` landmark.
+Native buttons expose `aria-expanded` and `aria-controls` for menu and narrow bar disclosures.
+The components deliberately avoid ARIA `menubar` and `menuitem` application-mode roles.
+Tab and Shift+Tab retain native order; Enter and Space toggle disclosures; Escape closes and restores focus; Home, End, and arrow keys traverse documented peer controls; and opening a menu closes siblings within the same bar.
+At the `48rem` narrow threshold, bars and menus stack without changing semantic order or events.
+
+The composite and standalone bars expose `refresh()` to start a new authorization-sensitive and locale-sensitive generation and reject superseded metadata, resource, and service-state responses.
+Stable hooks include `data-menu-state`, `data-causeway-bar`, `data-causeway-bar-role`, `data-causeway-menu`, `data-causeway-menu-section`, `data-causeway-service-action`, `data-service-logical-type`, `data-action-id`, inert presentation-hint data, and the `causeway-menubar*`, `causeway-menu*`, and `causeway-service-action*` classes.
+`causeway-menubars-state-change` publishes lifecycle state.
+`causeway-menubars-diagnostic` publishes bounded redacted failures without response bodies, credentials, authorization rules, submitted values, or remote exception text.
+
== Values and renderer extension
The standard renderer registry presents GraphQL scalars, enums, nulls, object references, and the rich-schema Blob and Clob resource shapes.
diff --git a/viewers/webcomponents/foundation/src/application-operations.mjs b/viewers/webcomponents/foundation/src/application-operations.mjs
new file mode 100644
index 00000000000..5a5e1ed74b9
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/application-operations.mjs
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {assertGraphQLName} from './schema-names.mjs';
+
+const MENU_DESCRIPTOR_FIELDS = Object.freeze(['href', 'mediaType', 'formatVersion', 'generation', 'cacheControl']);
+const ISSUE_FIELDS = Object.freeze(['code', 'message']);
+
+export function buildApplicationEntryReadOperation({description, schemaNames}) {
+ if (!description?.supported || !description.applicationField || !description.menuBarsField) {
+ throw new Error('The rich GraphQL application menu capability is unavailable.');
+ }
+ const applicationField = assertGraphQLName(description.applicationField.name, 'application field');
+ const menuBarsField = assertGraphQLName(description.menuBarsField.name, 'menu bars field');
+ const availableMenuFields = new Set(description.menuBarsType?.fields?.map(field => field.name) ?? []);
+ const menuSelection = MENU_DESCRIPTOR_FIELDS
+ .filter(field => availableMenuFields.has(field))
+ .map(field => ` ${assertGraphQLName(field, 'menu descriptor field')}`);
+ if (!availableMenuFields.has('href')) {
+ throw new Error('The rich GraphQL application menu descriptor does not expose href.');
+ }
+ const applicationSelection = [
+ ` ${menuBarsField} {`,
+ ...menuSelection,
+ ' }'
+ ];
+ if (description.issuesField && description.issueType) {
+ const availableIssueFields = new Set(description.issueType.fields?.map(field => field.name) ?? []);
+ const issueSelection = ISSUE_FIELDS
+ .filter(field => availableIssueFields.has(field))
+ .map(field => ` ${assertGraphQLName(field, 'application issue field')}`);
+ if (issueSelection.length > 0) {
+ applicationSelection.push(` ${assertGraphQLName(description.issuesField.name, 'application issues field')} {`);
+ applicationSelection.push(...issueSelection);
+ applicationSelection.push(' }');
+ }
+ }
+ const richRoot = schemaNames.richRootField;
+ const applicationRead = richRoot
+ ? ` ${assertGraphQLName(richRoot, 'rich root field')} {\n ${applicationField} {\n${applicationSelection.join('\n')}\n }\n }`
+ : ` ${applicationField} {\n${applicationSelection.map(line => line.slice(2)).join('\n')}\n }`;
+ return Object.freeze({
+ document: `query CausewayReadApplicationEntry {\n${applicationRead}\n}`,
+ variables: Object.freeze({}),
+ operationName: 'CausewayReadApplicationEntry',
+ applicationPath: richRoot ? [richRoot, applicationField] : [applicationField]
+ });
+}
diff --git a/viewers/webcomponents/foundation/src/component-contracts.mjs b/viewers/webcomponents/foundation/src/component-contracts.mjs
index 2754a405961..461526409bf 100644
--- a/viewers/webcomponents/foundation/src/component-contracts.mjs
+++ b/viewers/webcomponents/foundation/src/component-contracts.mjs
@@ -31,7 +31,11 @@ export const CausewayElementName = Object.freeze({
ACTION: 'causeway-action',
INTERACTION_CONTROLLER: 'causeway-interaction-controller',
COLLECTION: 'causeway-collection',
- COLLECTION_COLUMN: 'causeway-collection-column'
+ COLLECTION_COLUMN: 'causeway-collection-column',
+ MENUBARS: 'causeway-menubars',
+ MENUBAR_PRIMARY: 'causeway-menubar-primary',
+ MENUBAR_SECONDARY: 'causeway-menubar-secondary',
+ MENUBAR_TERTIARY: 'causeway-menubar-tertiary'
});
/**
@@ -50,6 +54,11 @@ export const CausewayHostClass = Object.freeze({
PROPERTY_EDITOR: 'causeway-property-editor',
COLLECTION: 'causeway-collection',
COLLECTION_COLUMN: 'causeway-collection-column',
+ MENUBARS: 'causeway-menubars',
+ MENUBAR: 'causeway-menubar',
+ MENU: 'causeway-menu',
+ MENU_SECTION: 'causeway-menu-section',
+ SERVICE_ACTION: 'causeway-service-action',
LOADING: 'causeway-loading',
DISABLED: 'causeway-disabled',
EMPTY: 'causeway-empty',
@@ -68,7 +77,8 @@ export const CausewayAttribute = Object.freeze({
OBJECT_ID: 'object-id',
TITLE: 'title',
DISABLED: 'disabled',
- EDITABLE: 'editable'
+ EDITABLE: 'editable',
+ BAR: 'bar'
});
/**
@@ -87,5 +97,7 @@ export const CausewaySemanticEvent = Object.freeze({
COLLECTION_STATE: 'causeway-collection-state-change',
COLLECTION_CONFIGURATION: 'causeway-collection-configuration-change',
OBJECT_LAYOUT_STATE: 'causeway-object-layout-state-change',
- OBJECT_LAYOUT_DIAGNOSTIC: 'causeway-object-layout-diagnostic'
+ OBJECT_LAYOUT_DIAGNOSTIC: 'causeway-object-layout-diagnostic',
+ MENU_BARS_STATE: 'causeway-menubars-state-change',
+ MENU_BARS_DIAGNOSTIC: 'causeway-menubars-diagnostic'
});
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index 1504a9216fa..5619bb3943b 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -176,6 +176,148 @@ causeway-object {
padding: 0.35rem 0.5rem;
text-align: start;
}
+causeway-menubars {
+ display: flex;
+ flex-direction: column;
+ gap: var(--causeway-menubars-gap, 0.25rem);
+ position: relative;
+ z-index: var(--causeway-menubars-z-index, 10);
+}
+causeway-menubar-primary,
+causeway-menubar-secondary,
+causeway-menubar-tertiary {
+ display: block;
+ min-inline-size: 0;
+}
+causeway-menubars > [data-causeway-bar-order="0"] {
+ order: 0;
+}
+causeway-menubars > [data-causeway-bar-order="1"] {
+ order: 1;
+}
+causeway-menubars > [data-causeway-bar-order="2"] {
+ order: 2;
+}
+causeway-menubars > [data-causeway-menubars-status],
+causeway-menubars > causeway-interaction-controller {
+ order: 3;
+}
+.causeway-menubar-shell {
+ background: var(--causeway-menubar-background, Canvas);
+ border-block-end: var(--causeway-menubar-border, 1px solid color-mix(in srgb, currentColor 25%, transparent));
+ color: var(--causeway-menubar-color, CanvasText);
+ min-inline-size: 0;
+}
+.causeway-menubar {
+ align-items: stretch;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--causeway-menubar-menu-gap, 0.25rem);
+ padding: var(--causeway-menubar-padding, 0.25rem 0.5rem);
+}
+.causeway-menubar-bar-disclosure {
+ display: none;
+}
+.causeway-menu {
+ min-inline-size: 0;
+ position: relative;
+}
+.causeway-menubar button {
+ background: var(--causeway-menubar-control-background, transparent);
+ border: var(--causeway-menubar-control-border, 1px solid transparent);
+ border-radius: var(--causeway-menubar-control-radius, 0.25rem);
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ padding: var(--causeway-menubar-control-padding, 0.5rem 0.75rem);
+ text-align: start;
+}
+.causeway-menubar button:hover:not(:disabled),
+.causeway-menubar button[aria-expanded="true"] {
+ background: var(--causeway-menubar-control-active-background, color-mix(in srgb, LinkText 14%, transparent));
+}
+.causeway-menubar button:focus-visible,
+.causeway-menubar-bar-disclosure:focus-visible {
+ outline: var(--causeway-menubar-focus, 0.2rem solid LinkText);
+ outline-offset: 0.1rem;
+}
+.causeway-menubar button:disabled {
+ cursor: not-allowed;
+ opacity: 0.65;
+}
+.causeway-menu-panel {
+ background: var(--causeway-menu-panel-background, Canvas);
+ border: var(--causeway-menubar-border, 1px solid color-mix(in srgb, currentColor 25%, transparent));
+ border-radius: var(--causeway-menubar-control-radius, 0.25rem);
+ box-shadow: var(--causeway-menu-panel-shadow, 0 0.5rem 1.5rem color-mix(in srgb, #000 22%, transparent));
+ color: var(--causeway-menubar-color, CanvasText);
+ inline-size: max-content;
+ inset-block-start: 100%;
+ inset-inline-start: 0;
+ max-inline-size: min(28rem, calc(100vw - 2rem));
+ min-inline-size: min(16rem, calc(100vw - 2rem));
+ padding: 0.5rem;
+ position: absolute;
+}
+.causeway-menu-panel[hidden],
+.causeway-menubar[hidden] {
+ display: none;
+}
+.causeway-menu-section + .causeway-menu-section {
+ border-block-start: var(--causeway-menubar-border, 1px solid color-mix(in srgb, currentColor 25%, transparent));
+ margin-block-start: 0.5rem;
+ padding-block-start: 0.5rem;
+}
+.causeway-menu-section-label {
+ font-size: 0.85rem;
+ margin: 0.25rem 0.75rem;
+}
+.causeway-service-action-control {
+ inline-size: 100%;
+}
+.causeway-menu-icon {
+ display: inline-block;
+ inline-size: 0.75rem;
+ margin-inline-end: 0.35rem;
+}
+.causeway-menubars-status,
+.causeway-menubar-status {
+ margin-block: 0.5rem;
+}
+@media (min-width: 48.001rem) {
+ .causeway-menubar[data-causeway-bar-content][hidden] {
+ display: flex;
+ }
+}
+@media (max-width: 48rem) {
+ .causeway-menubar-shell {
+ padding: 0.25rem;
+ }
+ .causeway-menubar-bar-disclosure {
+ background: var(--causeway-menubar-control-background, transparent);
+ border: var(--causeway-menubar-control-border, 1px solid transparent);
+ border-radius: var(--causeway-menubar-control-radius, 0.25rem);
+ color: inherit;
+ display: block;
+ font: inherit;
+ inline-size: 100%;
+ padding: 0.6rem 0.75rem;
+ text-align: start;
+ }
+ .causeway-menubar {
+ align-items: stretch;
+ display: grid;
+ padding: 0.25rem;
+ }
+ .causeway-menu-panel {
+ box-shadow: none;
+ inline-size: auto;
+ margin-block-start: 0.25rem;
+ max-inline-size: none;
+ min-inline-size: 0;
+ position: static;
+ }
+}
`;
export function installCausewayComponentStyles(documentRoot = globalThis.document) {
diff --git a/viewers/webcomponents/foundation/src/context-events.mjs b/viewers/webcomponents/foundation/src/context-events.mjs
index 3367985e69a..f7cf2fe7115 100644
--- a/viewers/webcomponents/foundation/src/context-events.mjs
+++ b/viewers/webcomponents/foundation/src/context-events.mjs
@@ -31,6 +31,9 @@ export const COLLECTION_STATE_EVENT = 'causeway-collection-state-change';
export const COLLECTION_CONFIGURATION_EVENT = 'causeway-collection-configuration-change';
export const OBJECT_LAYOUT_STATE_EVENT = 'causeway-object-layout-state-change';
export const OBJECT_LAYOUT_DIAGNOSTIC_EVENT = 'causeway-object-layout-diagnostic';
+export const MENU_BARS_CONTEXT_REQUEST_EVENT = 'causeway-menubars-context-request';
+export const MENU_BARS_STATE_EVENT = 'causeway-menubars-state-change';
+export const MENU_BARS_DIAGNOSTIC_EVENT = 'causeway-menubars-diagnostic';
export function requestGraphQLClient(requester) {
let client = null;
@@ -42,6 +45,16 @@ export function requestGraphQLClient(requester) {
return client;
}
+export function requestMenuBarsContext(requester) {
+ let context = null;
+ requester.dispatchEvent(createSemanticEvent(MENU_BARS_CONTEXT_REQUEST_EVENT, {
+ provide(candidate) {
+ context ??= candidate;
+ }
+ }));
+ return context;
+}
+
export function requestObjectContext(requester) {
let context = null;
requester.dispatchEvent(createSemanticEvent(OBJECT_CONTEXT_REQUEST_EVENT, {
diff --git a/viewers/webcomponents/foundation/src/graphql-client.mjs b/viewers/webcomponents/foundation/src/graphql-client.mjs
index 5e6a16ecae9..9fc10e1c98c 100644
--- a/viewers/webcomponents/foundation/src/graphql-client.mjs
+++ b/viewers/webcomponents/foundation/src/graphql-client.mjs
@@ -17,6 +17,7 @@
* under the License.
*/
+import {buildApplicationEntryReadOperation} from './application-operations.mjs';
import {normalizeExecutor} from './graphql-executor.mjs';
import {buildDescribeTypesOperation, fieldsByName, namedType, normalizeTypeDescription} from './introspection.mjs';
import {RichSchemaNameError, RichSchemaNames} from './schema-names.mjs';
@@ -24,7 +25,8 @@ import {buildCollectionWindowReadOperation, buildObjectReadOperation} from './se
import {
buildDescribeOperationRootsOperation,
buildMutationInteractionOperation,
- buildObjectInteractionOperation
+ buildObjectInteractionOperation,
+ buildServiceInteractionOperation
} from './interaction-operations.mjs';
export class CausewaySchemaError extends Error {
@@ -42,8 +44,11 @@ export class CausewayGraphQLClient {
this.schemaNames = schemaNames;
this.typeCache = new Map();
this.objectDescriptionCache = new Map();
+ this.serviceDescriptionCache = new Map();
this.operationRootsPromise = null;
this.mutationDescriptionPromise = null;
+ this.applicationDescriptionPromise = null;
+ this.schemaDiscoveryTail = Promise.resolve();
}
describeObject(logicalTypeName) {
@@ -79,7 +84,7 @@ export class CausewayGraphQLClient {
async describeOperationRoots({signal} = {}) {
if (!this.operationRootsPromise) {
const operation = buildDescribeOperationRootsOperation();
- this.operationRootsPromise = this.executor({
+ this.operationRootsPromise = this.#executeSchemaDiscovery({
document: operation.document,
variables: operation.variables,
operationName: operation.operationName,
@@ -120,6 +125,44 @@ export class CausewayGraphQLClient {
return this.mutationDescriptionPromise;
}
+ describeService(logicalTypeName) {
+ if (!this.serviceDescriptionCache.has(logicalTypeName)) {
+ const promise = this.#describeObject(logicalTypeName)
+ .then(description => Object.freeze({...description, kind: 'service'}))
+ .catch(error => {
+ this.serviceDescriptionCache.delete(logicalTypeName);
+ throw error;
+ });
+ this.serviceDescriptionCache.set(logicalTypeName, promise);
+ }
+ return this.serviceDescriptionCache.get(logicalTypeName);
+ }
+
+ describeApplicationEntry() {
+ if (!this.applicationDescriptionPromise) {
+ this.applicationDescriptionPromise = this.#describeApplicationEntry().catch(error => {
+ this.applicationDescriptionPromise = null;
+ throw error;
+ });
+ }
+ return this.applicationDescriptionPromise;
+ }
+
+ async readApplicationEntry({description, signal} = {}) {
+ const operation = buildApplicationEntryReadOperation({description, schemaNames: this.schemaNames});
+ const response = await this.executor({
+ document: operation.document,
+ variables: operation.variables,
+ operationName: operation.operationName,
+ signal
+ });
+ return Object.freeze({
+ data: valueAtPath(response.data, operation.applicationPath) ?? null,
+ errors: normalizeErrors(response.errors, operation.applicationPath),
+ operation
+ });
+ }
+
async readObject({description, identity, selection, signal}) {
const operation = buildObjectReadOperation({
description,
@@ -187,6 +230,26 @@ export class CausewayGraphQLClient {
});
}
+ async executeServiceInteraction({description, selection, operationName, signal}) {
+ const operation = buildServiceInteractionOperation({
+ description,
+ selection,
+ schemaNames: this.schemaNames,
+ operationName
+ });
+ const response = await this.executor({
+ document: operation.document,
+ variables: operation.variables,
+ operationName: operation.operationName,
+ signal
+ });
+ return Object.freeze({
+ data: valueAtPath(response.data, operation.servicePath) ?? null,
+ errors: normalizeErrors(response.errors, operation.servicePath),
+ operation
+ });
+ }
+
async executeMutationInteraction({
description,
mutationType,
@@ -222,14 +285,75 @@ export class CausewayGraphQLClient {
clearSchemaCache() {
this.typeCache.clear();
this.objectDescriptionCache.clear();
+ this.serviceDescriptionCache.clear();
this.operationRootsPromise = null;
this.mutationDescriptionPromise = null;
+ this.applicationDescriptionPromise = null;
+ }
+
+ async #describeApplicationEntry(signal) {
+ const roots = await this.describeOperationRoots({signal});
+ if (!roots.queryTypeName) {
+ return unsupportedApplicationDescription('QUERY_ROOT_UNAVAILABLE');
+ }
+ const queryTypes = await this.describeTypes([roots.queryTypeName], {signal});
+ const queryType = queryTypes.get(roots.queryTypeName) ?? null;
+ if (!queryType) {
+ return unsupportedApplicationDescription('QUERY_ROOT_UNAVAILABLE');
+ }
+ let richType = queryType;
+ if (this.schemaNames.richRootField) {
+ const richField = fieldsByName(queryType).get(this.schemaNames.richRootField) ?? null;
+ const richTypeName = richField ? namedType(richField.type) : null;
+ if (!richTypeName) {
+ return unsupportedApplicationDescription('RICH_ROOT_UNAVAILABLE');
+ }
+ const richTypes = await this.describeTypes([richTypeName], {signal});
+ richType = richTypes.get(richTypeName) ?? null;
+ if (!richType) {
+ return unsupportedApplicationDescription('RICH_ROOT_UNAVAILABLE');
+ }
+ }
+ const applicationField = fieldsByName(richType).get('application') ?? null;
+ const applicationTypeName = applicationField ? namedType(applicationField.type) : null;
+ if (!applicationTypeName) {
+ return unsupportedApplicationDescription('APPLICATION_UNAVAILABLE');
+ }
+ const applicationTypes = await this.describeTypes([applicationTypeName], {signal});
+ const applicationType = applicationTypes.get(applicationTypeName) ?? null;
+ if (!applicationType) {
+ return unsupportedApplicationDescription('APPLICATION_UNAVAILABLE');
+ }
+ const applicationFields = fieldsByName(applicationType);
+ const menuBarsField = applicationFields.get('menuBars') ?? null;
+ const menuBarsTypeName = menuBarsField ? namedType(menuBarsField.type) : null;
+ if (!menuBarsTypeName) {
+ return unsupportedApplicationDescription('MENU_BARS_UNAVAILABLE', {applicationField, applicationType});
+ }
+ const issuesField = applicationFields.get('issues') ?? null;
+ const issueTypeName = issuesField ? namedType(issuesField.type) : null;
+ const requestedTypes = [menuBarsTypeName, issueTypeName].filter(Boolean);
+ const supportTypes = await this.describeTypes(requestedTypes, {signal});
+ const menuBarsType = supportTypes.get(menuBarsTypeName) ?? null;
+ if (!menuBarsType) {
+ return unsupportedApplicationDescription('MENU_BARS_UNAVAILABLE', {applicationField, applicationType});
+ }
+ return Object.freeze({
+ supported: true,
+ reason: null,
+ applicationField,
+ applicationType,
+ menuBarsField,
+ menuBarsType,
+ issuesField,
+ issueType: issueTypeName ? supportTypes.get(issueTypeName) ?? null : null
+ });
}
async #fetchTypes(typeNames, signal) {
const entries = await Promise.all(typeNames.map(async typeName => {
const operation = buildDescribeTypesOperation([typeName]);
- const response = await this.executor({
+ const response = await this.#executeSchemaDiscovery({
document: operation.document,
variables: operation.variables,
operationName: operation.operationName,
@@ -248,6 +372,13 @@ export class CausewayGraphQLClient {
return new Map(entries);
}
+ #executeSchemaDiscovery(request) {
+ const execute = () => this.executor(request);
+ const result = this.schemaDiscoveryTail.then(execute, execute);
+ this.schemaDiscoveryTail = result.catch(() => undefined);
+ return result;
+ }
+
async #describeObject(logicalTypeName) {
const generatedTypeName = this.schemaNames.objectType(logicalTypeName);
const initialTypes = await this.describeTypes([generatedTypeName]);
@@ -447,6 +578,19 @@ function semanticMemberKind(generatedKind) {
}[generatedKind] ?? null;
}
+function unsupportedApplicationDescription(reason, details = {}) {
+ return Object.freeze({
+ supported: false,
+ reason,
+ applicationField: details.applicationField ?? null,
+ applicationType: details.applicationType ?? null,
+ menuBarsField: null,
+ menuBarsType: null,
+ issuesField: null,
+ issueType: null
+ });
+}
+
function valueAtPath(root, path) {
return path.reduce((value, segment) => value?.[segment], root);
}
diff --git a/viewers/webcomponents/foundation/src/index.mjs b/viewers/webcomponents/foundation/src/index.mjs
index 3537e520af9..da3a4876bb2 100644
--- a/viewers/webcomponents/foundation/src/index.mjs
+++ b/viewers/webcomponents/foundation/src/index.mjs
@@ -31,6 +31,10 @@ export * from './graphql-executor.mjs';
export * from './interaction-controller-element.mjs';
export * from './interaction-operations.mjs';
export * from './introspection.mjs';
+export * from './menu-context-controller.mjs';
+export * from './menu-layout.mjs';
+export * from './menubar-element.mjs';
+export * from './menubars-element.mjs';
export * from './object-context-controller.mjs';
export * from './object-context-element.mjs';
export * from './object-element.mjs';
@@ -41,6 +45,9 @@ export * from './property-element.mjs';
export * from './register.mjs';
export * from './schema-names.mjs';
export * from './selection.mjs';
+export * from './service-action-context.mjs';
+export * from './structural-resource.mjs';
+export * from './structural-xml.mjs';
export * from './types.mjs';
export * from './value-element.mjs';
export * from './value-renderers.mjs';
diff --git a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
index 749b95bc2cc..67f2ce006e7 100644
--- a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
@@ -122,7 +122,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
this.render();
return false;
}
- const parameters = prepared.data.parameters ?? [];
+ let parameters = prepared.data.parameters ?? [];
if (parameters.length === 0) {
return this.#invoke(actionId, context, {}, source, generation);
}
@@ -132,6 +132,15 @@ export class CausewayInteractionControllerElement extends HTMLElement {
values[parameter.id] = parameter.state.default;
}
}
+ if (Object.keys(values).length > 0) {
+ const preparedWithDefaults = await context.prepareAction(actionId, values);
+ if (generation !== this.generation) {
+ return false;
+ }
+ if (preparedWithDefaults.status === InteractionStatus.SUCCESS) {
+ parameters = preparedWithDefaults.data.parameters ?? parameters;
+ }
+ }
this.promptState = Object.freeze({
...this.promptState,
status: InteractionStatus.EDITING,
@@ -235,11 +244,13 @@ export class CausewayInteractionControllerElement extends HTMLElement {
clearTimeout(this.parameterTimer);
const source = this.promptState.source;
const actionId = this.promptState.actionId;
+ const interactionContext = this.promptState.context;
this.generation += 1;
this.promptState = null;
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.ACTION_PROMPT_STATE, Object.freeze({
actionId,
- status: InteractionStatus.CANCELLED
+ status: InteractionStatus.CANCELLED,
+ ...interactionTargetDetail(interactionContext)
})));
this.render();
this.#restoreFocus(source);
@@ -261,7 +272,21 @@ export class CausewayInteractionControllerElement extends HTMLElement {
});
this.#publishPromptState();
this.render();
- const result = await context.invokeAction(actionId, values);
+ let result;
+ try {
+ result = await context.invokeAction(actionId, values);
+ } catch {
+ if (generation === this.generation) {
+ this.promptState = Object.freeze({
+ ...this.promptState,
+ status: InteractionStatus.FAILED,
+ error: 'Action invocation failed.'
+ });
+ this.#publishPromptState();
+ this.render();
+ }
+ return false;
+ }
if (generation !== this.generation) {
return false;
}
@@ -276,10 +301,10 @@ export class CausewayInteractionControllerElement extends HTMLElement {
return false;
}
this.promptState = null;
- this.resultState = Object.freeze({actionId, result: result.data});
+ this.resultState = Object.freeze({actionId, result: result.data, ...interactionTargetDetail(context)});
this.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.ACTION_RESULT, Object.freeze({
actionId,
- identity: context.identity,
+ ...interactionTargetDetail(context),
result: result.data,
context
})));
@@ -410,7 +435,8 @@ export class CausewayInteractionControllerElement extends HTMLElement {
actionId: this.promptState.actionId,
status: this.promptState.status,
values: this.promptState.values,
- error: this.promptState.error
+ error: this.promptState.error,
+ ...interactionTargetDetail(this.promptState.context)
})));
}
@@ -436,8 +462,28 @@ export class CausewayInteractionControllerElement extends HTMLElement {
}
#restoreFocus(source) {
- queueMicrotask(() => source?.querySelector?.('button')?.focus?.());
+ queueMicrotask(() => {
+ const focusTarget = source?.matches?.('button, [href], input, select, textarea, [tabindex]')
+ ? source
+ : source?.querySelector?.('button, [href], input, select, textarea, [tabindex]');
+ focusTarget?.focus?.();
+ });
+ }
+}
+
+function interactionTargetDetail(context) {
+ if (context?.interactionTarget?.kind === 'service') {
+ return Object.freeze({
+ identity: null,
+ target: context.interactionTarget,
+ serviceLogicalTypeName: context.interactionTarget.logicalTypeName
+ });
}
+ return Object.freeze({
+ identity: context?.identity ?? null,
+ target: context?.identity ? Object.freeze({kind: 'object', ...context.identity}) : null,
+ serviceLogicalTypeName: null
+ });
}
function promptStatusLabel(status) {
diff --git a/viewers/webcomponents/foundation/src/interaction-operations.mjs b/viewers/webcomponents/foundation/src/interaction-operations.mjs
index 33baf67ca0e..e1eeb11b20b 100644
--- a/viewers/webcomponents/foundation/src/interaction-operations.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-operations.mjs
@@ -62,6 +62,39 @@ export function buildObjectInteractionOperation({
});
}
+export function buildServiceInteractionOperation({
+ description,
+ selection,
+ schemaNames,
+ operationName = 'CausewayServiceInteraction'
+}) {
+ const variables = {};
+ const declarations = [];
+ const serviceType = description.types.get(description.generatedTypeName);
+ const renderedSelection = renderExecutableSelection({
+ selection,
+ typeDescription: serviceType,
+ types: description.types,
+ variables,
+ declarations,
+ variablePrefix: 'input',
+ indentation: schemaNames.richRootField ? ' ' : ' '
+ });
+ const serviceField = assertGraphQLName(description.generatedFieldName, 'service field');
+ const serviceRead = schemaNames.richRootField
+ ? ` ${assertGraphQLName(schemaNames.richRootField, 'rich root field')} {\n ${serviceField} {\n${renderedSelection}\n }\n }`
+ : ` ${serviceField} {\n${renderedSelection}\n }`;
+ const declarationsText = declarations.length > 0 ? `(${declarations.join(', ')})` : '';
+ return Object.freeze({
+ document: `query ${assertGraphQLName(operationName, 'operation name')}${declarationsText} {\n${serviceRead}\n}`,
+ variables,
+ operationName,
+ servicePath: schemaNames.richRootField
+ ? [schemaNames.richRootField, serviceField]
+ : [serviceField]
+ });
+}
+
export function buildMutationInteractionOperation({
mutationType,
fieldName,
@@ -95,8 +128,9 @@ export function buildMutationInteractionOperation({
indentation: ' '
})}\n }`
: '';
+ const declarationsText = declarations.length > 0 ? `(${declarations.join(', ')})` : '';
return Object.freeze({
- document: `mutation ${assertGraphQLName(operationName, 'operation name')}(${declarations.join(', ')}) {\n ${assertGraphQLName(fieldName, 'mutation field')}${renderedArgs}${renderedResult}\n}`,
+ document: `mutation ${assertGraphQLName(operationName, 'operation name')}${declarationsText} {\n ${assertGraphQLName(fieldName, 'mutation field')}${renderedArgs}${renderedResult}\n}`,
variables,
operationName,
resultPath: [fieldName],
diff --git a/viewers/webcomponents/foundation/src/menu-context-controller.mjs b/viewers/webcomponents/foundation/src/menu-context-controller.mjs
new file mode 100644
index 00000000000..a7e157ec36b
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/menu-context-controller.mjs
@@ -0,0 +1,343 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {createSemanticEvent, MENU_BARS_STATE_EVENT} from './context-events.mjs';
+import {
+ applyServiceActionStates,
+ CAUSEWAY_MENU_BARS_NAMESPACE,
+ menuPlanActionReferences,
+ parseCausewayMenuBarsXml,
+ serviceActionKey
+} from './menu-layout.mjs';
+import {ServiceActionContextController} from './service-action-context.mjs';
+import {fetchStructuralResource} from './structural-resource.mjs';
+
+export const MenuBarsStatus = Object.freeze({
+ IDLE: 'idle',
+ APPLICATION_LOADING: 'application-loading',
+ RESOURCE_LOADING: 'resource-loading',
+ SERVICE_LOADING: 'service-loading',
+ READY: 'ready',
+ PARTIAL_ERROR: 'partial-error',
+ UNSUPPORTED: 'unsupported',
+ TERMINAL_ERROR: 'terminal-error'
+});
+
+export class MenuBarsContextController extends EventTarget {
+ constructor({
+ client,
+ fetchImpl = globalThis.fetch,
+ schedule = callback => globalThis.queueMicrotask(callback)
+ } = {}) {
+ super();
+ this.client = client ?? null;
+ this.fetchImpl = fetchImpl;
+ this.schedule = schedule;
+ this.stateListeners = new Set();
+ this.serviceContexts = new Map();
+ this.generation = 0;
+ this.abortController = null;
+ this.mutationTail = Promise.resolve();
+ this.closed = false;
+ this.state = freezeState({
+ status: MenuBarsStatus.IDLE,
+ generation: 0,
+ plan: null,
+ diagnostics: [],
+ error: null,
+ resource: null
+ });
+ }
+
+ subscribe(listener) {
+ this.stateListeners.add(listener);
+ listener(this.state);
+ return () => this.stateListeners.delete(listener);
+ }
+
+ refresh() {
+ if (this.closed) {
+ return Promise.resolve(this.state);
+ }
+ const generation = ++this.generation;
+ this.abortController?.abort();
+ const abortController = new AbortController();
+ this.abortController = abortController;
+ if (!this.client) {
+ this.#setState({
+ status: MenuBarsStatus.TERMINAL_ERROR,
+ generation,
+ plan: null,
+ diagnostics: [diagnostic('GRAPHQL_CLIENT_REQUIRED', 'A Causeway GraphQL client is required.')],
+ error: new Error('A Causeway GraphQL client is required.'),
+ resource: null
+ });
+ return Promise.resolve(this.state);
+ }
+ this.#setState({
+ status: MenuBarsStatus.APPLICATION_LOADING,
+ generation,
+ plan: null,
+ diagnostics: [],
+ error: null,
+ resource: null
+ });
+ return this.#load(generation, abortController.signal);
+ }
+
+ serviceContext(logicalTypeName) {
+ if (!this.serviceContexts.has(logicalTypeName)) {
+ this.serviceContexts.set(logicalTypeName, new ServiceActionContextController({
+ client: this.client,
+ logicalTypeName,
+ serializeMutation: execute => this.#serializeMutation(execute),
+ onChanged: () => this.schedule(() => this.refresh())
+ }));
+ }
+ return this.serviceContexts.get(logicalTypeName);
+ }
+
+ disconnect() {
+ this.closed = true;
+ this.abortController?.abort();
+ this.abortController = null;
+ for (const context of this.serviceContexts.values()) {
+ context.disconnect();
+ }
+ this.serviceContexts.clear();
+ this.stateListeners.clear();
+ }
+
+ async #load(generation, signal) {
+ try {
+ const capability = await this.client.describeApplicationEntry({signal});
+ if (!this.#isCurrent(generation)) {
+ return this.state;
+ }
+ if (!capability.supported) {
+ this.#setState({
+ status: MenuBarsStatus.UNSUPPORTED,
+ generation,
+ plan: null,
+ diagnostics: [diagnostic(capability.reason ?? 'MENU_BARS_UNAVAILABLE', 'The rich GraphQL application menu capability is unavailable.')],
+ error: null,
+ resource: null
+ });
+ return this.state;
+ }
+ const application = await this.client.readApplicationEntry({description: capability, signal});
+ if (!this.#isCurrent(generation)) {
+ return this.state;
+ }
+ if (application.errors?.length && !application.data) {
+ throw new Error('Application menu metadata could not be read.');
+ }
+ const diagnostics = applicationDiagnostics(application.data?.issues);
+ for (const ignored of (application.errors ?? []).slice(0, 20 - diagnostics.length)) {
+ diagnostics.push(diagnostic('APPLICATION_ENTRY_PARTIAL_ERROR', 'Part of the application menu metadata could not be read.'));
+ }
+ if (!application.data?.menuBars) {
+ this.#setState({
+ status: MenuBarsStatus.UNSUPPORTED,
+ generation,
+ plan: null,
+ diagnostics: [...diagnostics, diagnostic('MENU_BARS_UNAVAILABLE', 'The current application menu resource is unavailable.')],
+ error: null,
+ resource: null
+ });
+ return this.state;
+ }
+ const descriptor = application.data.menuBars;
+ if (descriptor.mediaType && !String(descriptor.mediaType).toLowerCase().startsWith('application/xml')) {
+ throw menuError('MENU_MEDIA_TYPE_UNSUPPORTED', 'The application menu resource has an unsupported media type.');
+ }
+ if (descriptor.formatVersion && descriptor.formatVersion !== CAUSEWAY_MENU_BARS_NAMESPACE) {
+ throw menuError('MENU_FORMAT_UNSUPPORTED', 'The application menu resource uses an unsupported format.');
+ }
+ if (descriptor.cacheControl && !String(descriptor.cacheControl).toLowerCase().includes('no-store')) {
+ diagnostics.push(diagnostic('MENU_CACHE_POLICY_UNEXPECTED', 'The application menu descriptor did not advertise no-store delivery.'));
+ }
+ this.#setState({
+ status: MenuBarsStatus.RESOURCE_LOADING,
+ generation,
+ plan: null,
+ diagnostics,
+ error: null,
+ resource: safeResourceDescriptor(descriptor)
+ });
+ const resource = await fetchStructuralResource(descriptor.href, {
+ fetchImpl: this.fetchImpl,
+ accept: 'application/xml',
+ signal
+ });
+ if (!this.#isCurrent(generation)) {
+ return this.state;
+ }
+ const parsed = parseCausewayMenuBarsXml(resource.text);
+ diagnostics.push(...parsed.diagnostics);
+ this.#setState({
+ status: MenuBarsStatus.SERVICE_LOADING,
+ generation,
+ plan: parsed.plan,
+ diagnostics,
+ error: null,
+ resource: safeResourceDescriptor(descriptor)
+ });
+ const actionStates = new Map();
+ const references = menuPlanActionReferences(parsed.plan);
+ const byService = new Map();
+ for (const reference of references) {
+ const actions = byService.get(reference.serviceLogicalTypeName) ?? [];
+ actions.push(reference);
+ byService.set(reference.serviceLogicalTypeName, actions);
+ }
+ await Promise.all([...byService].map(async ([logicalTypeName, actions]) => {
+ try {
+ const result = await this.serviceContext(logicalTypeName).loadActionStates(
+ actions.map(action => action.actionId),
+ {signal}
+ );
+ for (const [actionId, state] of result.states) {
+ actionStates.set(serviceActionKey(logicalTypeName, actionId), state);
+ }
+ for (const error of result.errors ?? []) {
+ const actionId = error.path?.find(segment => typeof segment === 'string' && actions.some(action => action.actionId === segment));
+ diagnostics.push(diagnostic(
+ 'SERVICE_ACTION_STATE_FAILED',
+ 'Current state for a menu service action could not be read.',
+ actionId ? `${logicalTypeName}#${actionId}` : logicalTypeName
+ ));
+ }
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ throw error;
+ }
+ diagnostics.push(diagnostic('SERVICE_TYPE_UNAVAILABLE', 'A menu service type is unavailable.', logicalTypeName));
+ for (const action of actions) {
+ actionStates.set(serviceActionKey(logicalTypeName, action.actionId), Object.freeze({
+ hidden: false,
+ disabled: null,
+ error: 'SERVICE_TYPE_UNAVAILABLE'
+ }));
+ }
+ }
+ }));
+ if (!this.#isCurrent(generation)) {
+ return this.state;
+ }
+ const plan = applyServiceActionStates(parsed.plan, actionStates, diagnostics);
+ this.#setState({
+ status: plan.diagnostics.length > 0 ? MenuBarsStatus.PARTIAL_ERROR : MenuBarsStatus.READY,
+ generation,
+ plan,
+ diagnostics: plan.diagnostics,
+ error: null,
+ resource: safeResourceDescriptor(descriptor)
+ });
+ } catch (error) {
+ if (error?.name === 'AbortError' || !this.#isCurrent(generation)) {
+ return this.state;
+ }
+ const safe = safeError(error);
+ this.#setState({
+ status: MenuBarsStatus.TERMINAL_ERROR,
+ generation,
+ plan: null,
+ diagnostics: [diagnostic(safe.code, safe.message)],
+ error: safe,
+ resource: null
+ });
+ }
+ return this.state;
+ }
+
+ #serializeMutation(execute) {
+ const result = this.mutationTail.then(execute, execute);
+ this.mutationTail = result.catch(() => undefined);
+ return result;
+ }
+
+ #isCurrent(generation) {
+ return !this.closed && generation === this.generation;
+ }
+
+ #setState(changes) {
+ this.state = freezeState({...this.state, ...changes});
+ for (const listener of this.stateListeners) {
+ listener(this.state);
+ }
+ this.dispatchEvent(createSemanticEvent(MENU_BARS_STATE_EVENT, this.state));
+ }
+}
+
+function applicationDiagnostics(issues = []) {
+ return issues.slice(0, 20).map(issue => diagnostic(
+ /^[_A-Z][_A-Z0-9]{0,63}$/.test(issue?.code ?? '') ? issue.code : 'APPLICATION_MENU_ISSUE',
+ 'The application entry reported a menu issue.'
+ ));
+}
+
+function safeResourceDescriptor(descriptor) {
+ return Object.freeze({
+ href: typeof descriptor?.href === 'string' ? descriptor.href : null,
+ mediaType: typeof descriptor?.mediaType === 'string' ? descriptor.mediaType : null,
+ formatVersion: typeof descriptor?.formatVersion === 'string' ? descriptor.formatVersion : null,
+ generation: typeof descriptor?.generation === 'string' ? descriptor.generation.slice(0, 128) : null
+ });
+}
+
+function diagnostic(code, message, path = null) {
+ return Object.freeze({code, message, path});
+}
+
+function menuError(code, message) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function safeError(error) {
+ const code = /^[_A-Z][_A-Z0-9]{0,63}$/.test(error?.code ?? '') ? error.code : 'MENU_BARS_LOAD_FAILED';
+ const messages = {
+ INVALID_RESOURCE_PATH: 'The application menu resource path is invalid.',
+ RESOURCE_TOO_LARGE: 'The application menu resource exceeds the supported size.',
+ RESOURCE_REQUEST_FAILED: 'The application menu resource request failed.',
+ FETCH_UNAVAILABLE: 'The application menu resource cannot be fetched in this environment.',
+ MENU_MEDIA_TYPE_UNSUPPORTED: 'The application menu resource has an unsupported media type.',
+ MENU_FORMAT_UNSUPPORTED: 'The application menu resource uses an unsupported format.'
+ };
+ const message = messages[code] ?? (code.startsWith('MENU_XML_') || code === 'MENU_ROOT_REQUIRED'
+ ? 'The application menu resource is malformed or unsupported.'
+ : 'The application menu could not be loaded.');
+ const safe = new Error(message);
+ safe.name = 'MenuBarsError';
+ safe.code = code;
+ return safe;
+}
+
+function freezeState(state) {
+ return Object.freeze({
+ status: state.status,
+ generation: state.generation,
+ plan: state.plan ?? null,
+ diagnostics: Object.freeze([...(state.diagnostics ?? [])].slice(0, 20)),
+ error: state.error ?? null,
+ resource: state.resource ?? null
+ });
+}
diff --git a/viewers/webcomponents/foundation/src/menu-layout.mjs b/viewers/webcomponents/foundation/src/menu-layout.mjs
new file mode 100644
index 00000000000..4cc8f66f5ce
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/menu-layout.mjs
@@ -0,0 +1,311 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ MAX_STRUCTURAL_DIAGNOSTICS,
+ parseStructuralXml,
+ structuralTextContent
+} from './structural-xml.mjs';
+
+export const CAUSEWAY_MENU_BARS_NAMESPACE = 'https://causeway.apache.org/applib/layout/menubars/bootstrap3';
+export const CAUSEWAY_MENU_COMPONENT_NAMESPACE = 'https://causeway.apache.org/applib/layout/component';
+export const MENU_BAR_ROLES = Object.freeze(['primary', 'secondary', 'tertiary']);
+export const MAX_MENU_DIAGNOSTICS = MAX_STRUCTURAL_DIAGNOSTICS;
+
+const MENU_ATTRIBUTES = new Set(['cssClassFa', 'unreferencedActions']);
+const ACTION_ATTRIBUTES = new Set(['objectType', 'id', 'bookmarking', 'cssClass', 'cssClassFa', 'namedEscaped']);
+const DOCUMENT_ATTRIBUTES = new Set(['schemaLocation']);
+
+export class CausewayMenuError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'CausewayMenuError';
+ this.code = code;
+ }
+}
+
+export function parseCausewayMenuBarsXml(xml, {maxDiagnostics = MAX_MENU_DIAGNOSTICS} = {}) {
+ const diagnostics = createDiagnosticCollector(maxDiagnostics);
+ const root = parseStructuralXml(xml, {
+ codePrefix: 'MENU',
+ resourceLabel: 'effective menu resource',
+ ErrorType: CausewayMenuError
+ });
+ if (!isMenuNode(root, 'menuBars')) {
+ throw new CausewayMenuError('MENU_ROOT_REQUIRED', 'The effective menu resource does not contain a supported menuBars root.');
+ }
+ diagnoseAttributes(root, DOCUMENT_ATTRIBUTES, diagnostics, 'menuBars');
+ const bars = {};
+ for (const role of MENU_BAR_ROLES) {
+ const barNode = root.children.find(child => isMenuNode(child, role)) ?? null;
+ bars[role] = barNode ? parseBar(barNode, role, diagnostics) : barPlan(role, []);
+ }
+ for (const child of root.children) {
+ if (!MENU_BAR_ROLES.some(role => isMenuNode(child, role)) && !isMenuNode(child, 'metadataError')) {
+ diagnostics.add('UNSUPPORTED_MENU_NODE', `Unsupported menu node '${child.localName}' was ignored.`, 'menuBars');
+ }
+ }
+ return deepFreeze({
+ usable: true,
+ plan: {
+ source: 'application-menu',
+ bars
+ },
+ diagnostics: diagnostics.values()
+ });
+}
+
+export function applyServiceActionStates(plan, actionStates, diagnostics = []) {
+ const collector = createDiagnosticCollector(MAX_MENU_DIAGNOSTICS);
+ for (const diagnostic of [...(plan?.diagnostics ?? []), ...diagnostics]) {
+ collector.add(diagnostic.code, diagnostic.message, diagnostic.path);
+ }
+ const bars = {};
+ for (const role of MENU_BAR_ROLES) {
+ const sourceBar = plan?.bars?.[role] ?? barPlan(role, []);
+ const menus = [];
+ for (const menu of sourceBar.menus ?? []) {
+ const sections = [];
+ for (const section of menu.sections ?? []) {
+ const actions = [];
+ for (const action of section.actions ?? []) {
+ const state = actionStates.get(serviceActionKey(action.serviceLogicalTypeName, action.actionId));
+ if (!state || state.error) {
+ collector.add('SERVICE_ACTION_UNAVAILABLE', 'A menu service action is unavailable.', action.path);
+ continue;
+ }
+ if (state.hidden === true) {
+ continue;
+ }
+ actions.push({...action, disabled: state.disabled || null});
+ }
+ if (actions.length > 0) {
+ sections.push({...section, actions});
+ }
+ }
+ if (sections.length > 0) {
+ menus.push({...menu, sections});
+ }
+ }
+ bars[role] = barPlan(role, menus);
+ }
+ return deepFreeze({source: plan?.source ?? 'application-menu', bars, diagnostics: collector.values()});
+}
+
+export function serviceActionKey(serviceLogicalTypeName, actionId) {
+ return `${serviceLogicalTypeName}#${actionId}`;
+}
+
+export function menuPlanActionReferences(plan) {
+ const references = [];
+ for (const role of MENU_BAR_ROLES) {
+ for (const menu of plan?.bars?.[role]?.menus ?? []) {
+ for (const section of menu.sections ?? []) {
+ references.push(...(section.actions ?? []));
+ }
+ }
+ }
+ return Object.freeze(references);
+}
+
+function parseBar(node, role, diagnostics) {
+ diagnoseAttributes(node, new Set(), diagnostics, role);
+ const menus = [];
+ for (const child of node.children) {
+ if (!isMenuNode(child, 'menu')) {
+ diagnostics.add('UNSUPPORTED_BAR_NODE', `Unsupported ${role} bar node '${child.localName}' was ignored.`, role);
+ continue;
+ }
+ const menu = parseMenu(child, role, menus.length, diagnostics);
+ if (menu) {
+ menus.push(menu);
+ }
+ }
+ return barPlan(role, menus);
+}
+
+function parseMenu(node, role, index, diagnostics) {
+ const path = `${role}.menu[${index}]`;
+ diagnoseAttributes(node, MENU_ATTRIBUTES, diagnostics, path);
+ const namedNode = node.children.find(child => isMenuNode(child, 'named')) ?? null;
+ const label = boundedText(namedNode, 512, diagnostics, path, 'MENU_LABEL_TOO_LONG') || `Menu ${index + 1}`;
+ const sections = [];
+ for (const child of node.children) {
+ if (isMenuNode(child, 'named')) {
+ continue;
+ }
+ if (!isMenuNode(child, 'section')) {
+ diagnostics.add('UNSUPPORTED_MENU_CONTENT', `Unsupported menu content '${child.localName}' was ignored.`, path);
+ continue;
+ }
+ const section = parseSection(child, role, index, sections.length, diagnostics);
+ if (section) {
+ sections.push(section);
+ }
+ }
+ return {
+ kind: 'menu',
+ label,
+ iconHint: boundedAttribute(node, 'cssClassFa', 256, diagnostics, path),
+ sections,
+ path
+ };
+}
+
+function parseSection(node, role, menuIndex, sectionIndex, diagnostics) {
+ const path = `${role}.menu[${menuIndex}].section[${sectionIndex}]`;
+ diagnoseAttributes(node, new Set(), diagnostics, path);
+ const namedNode = node.children.find(child => isMenuNode(child, 'named')) ?? null;
+ const label = boundedText(namedNode, 512, diagnostics, path, 'SECTION_LABEL_TOO_LONG');
+ const actions = [];
+ for (const child of node.children) {
+ if (isMenuNode(child, 'named')) {
+ continue;
+ }
+ if (!isMenuNode(child, 'serviceAction')) {
+ diagnostics.add('UNSUPPORTED_SECTION_CONTENT', `Unsupported section content '${child.localName}' was ignored.`, path);
+ continue;
+ }
+ const action = parseAction(child, path, actions.length, diagnostics);
+ if (action) {
+ actions.push(action);
+ }
+ }
+ return {kind: 'section', label, actions, path};
+}
+
+function parseAction(node, sectionPath, index, diagnostics) {
+ const path = `${sectionPath}.action[${index}]`;
+ diagnoseAttributes(node, ACTION_ATTRIBUTES, diagnostics, path);
+ const serviceLogicalTypeName = node.attributes.get('objectType') ?? '';
+ const actionId = node.attributes.get('id') ?? '';
+ if (!isSafeLogicalType(serviceLogicalTypeName) || !/^[_A-Za-z][_0-9A-Za-z]{0,127}$/.test(actionId)) {
+ diagnostics.add('INVALID_SERVICE_ACTION_REFERENCE', 'A menu service action reference is malformed.', path);
+ return null;
+ }
+ const namedNode = node.children.find(child => isComponentNode(child, 'named') || isMenuNode(child, 'named')) ?? null;
+ const describedNode = node.children.find(child => isComponentNode(child, 'describedAs') || isMenuNode(child, 'describedAs')) ?? null;
+ for (const child of node.children) {
+ if (child === namedNode || child === describedNode || isComponentNode(child, 'link')) {
+ continue;
+ }
+ diagnostics.add('UNSUPPORTED_ACTION_CONTENT', `Unsupported service-action content '${child.localName}' was ignored.`, path);
+ }
+ return {
+ kind: 'service-action',
+ serviceLogicalTypeName,
+ actionId,
+ label: boundedText(namedNode, 512, diagnostics, path, 'ACTION_LABEL_TOO_LONG'),
+ description: boundedText(describedNode, 2_048, diagnostics, path, 'ACTION_DESCRIPTION_TOO_LONG'),
+ cssHint: boundedAttribute(node, 'cssClass', 256, diagnostics, path),
+ iconHint: boundedAttribute(node, 'cssClassFa', 256, diagnostics, path),
+ disabled: null,
+ path
+ };
+}
+
+function diagnoseAttributes(node, supported, diagnostics, path) {
+ for (const name of node.attributes.keys()) {
+ if (name === 'xmlns' || name.startsWith('xmlns:') || DOCUMENT_ATTRIBUTES.has(localAttributeName(name))) {
+ continue;
+ }
+ if (!supported.has(localAttributeName(name))) {
+ diagnostics.add('UNSUPPORTED_MENU_ATTRIBUTE', `Unsupported attribute '${localAttributeName(name)}' was ignored.`, path);
+ }
+ }
+}
+
+function boundedText(node, maximum, diagnostics, path, code) {
+ if (!node) {
+ return '';
+ }
+ const value = structuralTextContent(node).replace(/\s+/g, ' ').trim();
+ if (value.length <= maximum) {
+ return value;
+ }
+ diagnostics.add(code, `Menu text was truncated to ${maximum} characters.`, path);
+ return value.slice(0, maximum);
+}
+
+function boundedAttribute(node, name, maximum, diagnostics, path) {
+ const value = (node.attributes.get(name) ?? '').trim();
+ if (value.length <= maximum) {
+ return value;
+ }
+ diagnostics.add('MENU_HINT_TOO_LONG', `Menu presentation data was truncated to ${maximum} characters.`, path);
+ return value.slice(0, maximum);
+}
+
+function isSafeLogicalType(value) {
+ return typeof value === 'string'
+ && value.length > 0
+ && value.length <= 256
+ && !/[\s<>&"'\\]/.test(value);
+}
+
+function isMenuNode(node, name) {
+ return node?.namespaceURI === CAUSEWAY_MENU_BARS_NAMESPACE && node.localName === name;
+}
+
+function isComponentNode(node, name) {
+ return node?.namespaceURI === CAUSEWAY_MENU_COMPONENT_NAMESPACE && node.localName === name;
+}
+
+function localAttributeName(name) {
+ const separator = name.indexOf(':');
+ return separator < 0 ? name : name.slice(separator + 1);
+}
+
+function barPlan(role, menus) {
+ return {kind: 'bar', role, menus};
+}
+
+function createDiagnosticCollector(maximum) {
+ const diagnostics = [];
+ let truncated = false;
+ return {
+ add(code, message, path = null) {
+ if (diagnostics.length < maximum) {
+ diagnostics.push(Object.freeze({code, message, path}));
+ } else {
+ truncated = true;
+ }
+ },
+ values() {
+ const values = truncated && diagnostics.length > 0
+ ? [...diagnostics.slice(0, maximum - 1), Object.freeze({
+ code: 'MENU_DIAGNOSTICS_TRUNCATED',
+ message: `Additional menu diagnostics were omitted after ${maximum} entries.`,
+ path: null
+ })]
+ : diagnostics;
+ return Object.freeze(values);
+ }
+ };
+}
+
+function deepFreeze(value) {
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
+ return value;
+ }
+ for (const child of Object.values(value)) {
+ deepFreeze(child);
+ }
+ return Object.freeze(value);
+}
diff --git a/viewers/webcomponents/foundation/src/menubar-element.mjs b/viewers/webcomponents/foundation/src/menubar-element.mjs
new file mode 100644
index 00000000000..14fdeb7c6dc
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/menubar-element.mjs
@@ -0,0 +1,381 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {CausewayElementName, CausewaySemanticEvent} from './component-contracts.mjs';
+import {
+ createSemanticEvent,
+ MENU_BARS_DIAGNOSTIC_EVENT,
+ MENU_BARS_STATE_EVENT,
+ requestGraphQLClient,
+ requestMenuBarsContext
+} from './context-events.mjs';
+import {MenuBarsContextController, MenuBarsStatus} from './menu-context-controller.mjs';
+import {escapeHtml} from './rendering.mjs';
+
+const HTMLElementBase = globalThis.HTMLElement ?? class extends EventTarget {};
+let barSequence = 0;
+
+export class CausewayMenubarElement extends HTMLElementBase {
+ constructor() {
+ super();
+ this.sequence = ++barSequence;
+ this._injectedContext = null;
+ this._context = null;
+ this._privateContext = null;
+ this._release = null;
+ this._client = null;
+ this._fetchImpl = null;
+ this.lastDiagnosticGeneration = -1;
+ this.addEventListener('click', event => this.#handleClick(event));
+ this.addEventListener('keydown', event => this.#handleKeydown(event));
+ }
+
+ get role() {
+ return this.constructor.barRole;
+ }
+
+ get context() {
+ return this._injectedContext;
+ }
+
+ set context(value) {
+ this._injectedContext = value ?? null;
+ if (this.isConnected) {
+ this.#connectContext();
+ }
+ }
+
+ get client() {
+ return this._client;
+ }
+
+ set client(value) {
+ this._client = value ?? null;
+ if (this.isConnected && !this._injectedContext) {
+ this.#connectContext();
+ }
+ }
+
+ get fetchImpl() {
+ return this._fetchImpl;
+ }
+
+ set fetchImpl(value) {
+ this._fetchImpl = value ?? null;
+ }
+
+ connectedCallback() {
+ this.#connectContext();
+ }
+
+ disconnectedCallback() {
+ this._release?.();
+ this._release = null;
+ this._privateContext?.disconnect();
+ this._privateContext = null;
+ this._context = null;
+ }
+
+ refresh() {
+ return this._context?.refresh() ?? Promise.resolve(null);
+ }
+
+ #connectContext() {
+ this._release?.();
+ this._release = null;
+ this._privateContext?.disconnect();
+ this._privateContext = null;
+ this._context = this._injectedContext ?? requestMenuBarsContext(this);
+ if (!this._context) {
+ const client = this._client ?? requestGraphQLClient(this);
+ this._privateContext = new MenuBarsContextController({
+ client,
+ fetchImpl: this._fetchImpl ?? globalThis.fetch
+ });
+ this._context = this._privateContext;
+ }
+ this._release = this._context.subscribe(state => this.#renderState(state));
+ if (this._privateContext) {
+ void this._privateContext.refresh();
+ }
+ }
+
+ #renderState(state) {
+ const renderRoot = this.#renderRoot();
+ this.setAttribute('data-menu-state', state.status);
+ this.setAttribute('data-causeway-bar', this.role);
+ const bar = state.plan?.bars?.[this.role] ?? null;
+ if (isLoading(state.status)) {
+ this.hidden = false;
+ renderRoot.innerHTML = `<div class="causeway-menubar-status causeway-loading" role="status">Loading ${escapeHtml(this.role)} application menu…</div>`;
+ } else if (state.status === MenuBarsStatus.TERMINAL_ERROR || state.status === MenuBarsStatus.UNSUPPORTED) {
+ this.hidden = false;
+ const message = state.status === MenuBarsStatus.UNSUPPORTED
+ ? 'Application menus are unavailable.'
+ : 'The application menu could not be loaded.';
+ renderRoot.innerHTML = `<div class="causeway-menubar-status causeway-error" role="alert">${message}</div>`;
+ } else if (!bar || bar.menus.length === 0) {
+ this.hidden = true;
+ renderRoot.innerHTML = '';
+ } else {
+ this.hidden = false;
+ renderRoot.innerHTML = renderBar(bar, this.sequence);
+ }
+ this.dispatchEvent(createSemanticEvent(MENU_BARS_STATE_EVENT, Object.freeze({
+ element: this,
+ role: this.role,
+ state
+ })));
+ if (this._privateContext && state.generation !== this.lastDiagnosticGeneration && state.diagnostics.length > 0) {
+ this.lastDiagnosticGeneration = state.generation;
+ for (const diagnostic of state.diagnostics) {
+ this.dispatchEvent(createSemanticEvent(MENU_BARS_DIAGNOSTIC_EVENT, Object.freeze({
+ element: this,
+ role: this.role,
+ generation: state.generation,
+ diagnostic
+ })));
+ }
+ }
+ if (this._privateContext) {
+ this.#ensureInteractionController();
+ }
+ }
+
+ #handleClick(event) {
+ const menuButton = event.target?.closest?.('[data-causeway-menu-disclosure]');
+ if (menuButton && this.contains?.(menuButton)) {
+ this.#toggleMenu(menuButton);
+ return;
+ }
+ const barButton = event.target?.closest?.('[data-causeway-bar-disclosure]');
+ if (barButton && this.contains?.(barButton)) {
+ this.#toggleDisclosure(barButton);
+ return;
+ }
+ const actionButton = event.target?.closest?.('[data-causeway-service-action]');
+ if (!actionButton || !this.contains?.(actionButton) || actionButton.disabled) {
+ return;
+ }
+ const serviceLogicalTypeName = actionButton.getAttribute('data-service-logical-type');
+ const actionId = actionButton.getAttribute('data-action-id');
+ if (!serviceLogicalTypeName || !actionId) {
+ return;
+ }
+ const context = this._context.serviceContext(serviceLogicalTypeName);
+ actionButton.dispatchEvent(createSemanticEvent(CausewaySemanticEvent.ACTION_REQUEST, Object.freeze({
+ actionId,
+ serviceLogicalTypeName,
+ target: context.interactionTarget,
+ identity: null,
+ context
+ }), {cancelable: true}));
+ }
+
+ #handleKeydown(event) {
+ const menuButton = event.target?.closest?.('[data-causeway-menu-disclosure]');
+ const actionButton = event.target?.closest?.('[data-causeway-service-action]');
+ if (event.key === 'Escape') {
+ const expanded = this.querySelector?.('[data-causeway-menu-disclosure][aria-expanded="true"]');
+ if (expanded) {
+ event.preventDefault();
+ this.#closeMenu(expanded, {focus: true});
+ return;
+ }
+ const barButton = this.querySelector?.('[data-causeway-bar-disclosure][aria-expanded="true"]');
+ if (barButton) {
+ event.preventDefault();
+ this.#toggleDisclosure(barButton, false);
+ barButton.focus?.();
+ }
+ return;
+ }
+ if (menuButton) {
+ const peers = [...(this.querySelectorAll?.('[data-causeway-menu-disclosure]') ?? [])];
+ const index = peers.indexOf(menuButton);
+ let target = null;
+ if (event.key === 'ArrowRight') {
+ target = peers[(index + 1) % peers.length];
+ } else if (event.key === 'ArrowLeft') {
+ target = peers[(index - 1 + peers.length) % peers.length];
+ } else if (event.key === 'Home') {
+ target = peers[0];
+ } else if (event.key === 'End') {
+ target = peers.at(-1);
+ } else if (event.key === 'ArrowDown') {
+ event.preventDefault();
+ this.#toggleMenu(menuButton, true);
+ const panel = this.#controlledElement(menuButton);
+ panel?.querySelector?.('[data-causeway-service-action]:not([disabled])')?.focus?.();
+ return;
+ }
+ if (target) {
+ event.preventDefault();
+ target.focus?.();
+ }
+ return;
+ }
+ if (actionButton && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
+ const panel = actionButton.closest?.('[data-causeway-menu-panel]');
+ const actions = [...(panel?.querySelectorAll?.('[data-causeway-service-action]:not([disabled])') ?? [])];
+ const index = actions.indexOf(actionButton);
+ const target = event.key === 'Home' ? actions[0]
+ : event.key === 'End' ? actions.at(-1)
+ : event.key === 'ArrowDown' ? actions[(index + 1) % actions.length]
+ : actions[(index - 1 + actions.length) % actions.length];
+ if (target) {
+ event.preventDefault();
+ target.focus?.();
+ }
+ }
+ }
+
+ #toggleMenu(button, expanded = button.getAttribute('aria-expanded') !== 'true') {
+ for (const sibling of this.querySelectorAll?.('[data-causeway-menu-disclosure][aria-expanded="true"]') ?? []) {
+ if (sibling !== button) {
+ this.#closeMenu(sibling);
+ }
+ }
+ this.#toggleDisclosure(button, expanded);
+ }
+
+ #closeMenu(button, {focus = false} = {}) {
+ this.#toggleDisclosure(button, false);
+ if (focus) {
+ button.focus?.();
+ }
+ }
+
+ #toggleDisclosure(button, expanded = button.getAttribute('aria-expanded') !== 'true') {
+ button.setAttribute('aria-expanded', String(expanded));
+ const controlled = this.#controlledElement(button);
+ if (controlled) {
+ controlled.hidden = !expanded;
+ }
+ }
+
+ #controlledElement(button) {
+ const id = button.getAttribute('aria-controls');
+ return id ? this.querySelector?.(`#${globalThis.CSS?.escape ? CSS.escape(id) : id}`) : null;
+ }
+
+ #renderRoot() {
+ let root = [...(this.childNodes ?? [])]
+ .find(child => child.getAttribute?.('data-causeway-menubar-render') === 'true');
+ if (!root && globalThis.document?.createElement) {
+ root = document.createElement('div');
+ root.setAttribute('data-causeway-menubar-render', 'true');
+ this.appendChild(root);
+ }
+ return root ?? this;
+ }
+
+ #ensureInteractionController() {
+ const existing = [...(this.childNodes ?? [])]
+ .find(child => child.localName === CausewayElementName.INTERACTION_CONTROLLER);
+ if (!existing && globalThis.document?.createElement) {
+ const controller = document.createElement(CausewayElementName.INTERACTION_CONTROLLER);
+ controller.setAttribute('data-causeway-generated', 'menubar-interaction-controller');
+ this.appendChild(controller);
+ }
+ }
+}
+
+export class CausewayMenubarPrimaryElement extends CausewayMenubarElement {
+ static barRole = 'primary';
+}
+
+export class CausewayMenubarSecondaryElement extends CausewayMenubarElement {
+ static barRole = 'secondary';
+}
+
+export class CausewayMenubarTertiaryElement extends CausewayMenubarElement {
+ static barRole = 'tertiary';
+}
+
+function renderBar(bar, sequence) {
+ const label = `${humanize(bar.role)} application menu`;
+ const contentId = `causeway-menubar-${sequence}-${bar.role}`;
+ return `<div class="causeway-menubar-shell" data-causeway-bar-role="${escapeHtml(bar.role)}">
+ <button class="causeway-menubar-bar-disclosure" type="button" data-causeway-bar-disclosure aria-expanded="true" aria-controls="${contentId}">${escapeHtml(label)}</button>
+ <nav class="causeway-menubar causeway-menubar-${escapeHtml(bar.role)}" id="${contentId}" data-causeway-bar-content aria-label="${escapeHtml(label)}">
+ ${bar.menus.map((menu, index) => renderMenu(menu, bar.role, sequence, index)).join('')}
+ </nav>
+</div>`;
+}
+
+function renderMenu(menu, role, sequence, index) {
+ const panelId = `causeway-menu-${sequence}-${role}-${index}`;
+ const description = menu.description ? ` title="${escapeHtml(menu.description)}"` : '';
+ return `<section class="causeway-menu" data-causeway-menu="${index}"${dataHint('icon-hint', menu.iconHint)}>
+ <button class="causeway-menu-disclosure" type="button" data-causeway-menu-disclosure aria-expanded="false" aria-controls="${panelId}"${description}>${renderIcon(menu.iconHint)}${escapeHtml(menu.label)}</button>
+ <div class="causeway-menu-panel" id="${panelId}" data-causeway-menu-panel hidden>
+ ${menu.sections.map((section, sectionIndex) => renderSection(section, sequence, role, index, sectionIndex)).join('')}
+ </div>
+</section>`;
+}
+
+function renderSection(section, sequence, role, menuIndex, sectionIndex) {
+ const heading = section.label
+ ? `<h2 class="causeway-menu-section-label">${escapeHtml(section.label)}</h2>`
+ : '';
+ return `<section class="causeway-menu-section" data-causeway-menu-section="${sectionIndex}">${heading}${section.actions
+ .map((action, actionIndex) => renderAction(action, sequence, role, menuIndex, sectionIndex, actionIndex))
+ .join('')}</section>`;
+}
+
+function renderAction(action, sequence, role, menuIndex, sectionIndex, actionIndex) {
+ const label = action.label || humanize(action.actionId);
+ const reasonId = `causeway-service-action-reason-${sequence}-${role}-${menuIndex}-${sectionIndex}-${actionIndex}`;
+ const describedBy = action.disabled ? ` aria-describedby="${reasonId}"` : '';
+ const disabled = action.disabled ? ' disabled aria-disabled="true"' : '';
+ const title = action.description ? ` title="${escapeHtml(action.description)}"` : '';
+ const reason = action.disabled
+ ? `<span class="causeway-disabled-reason causeway-visually-hidden" id="${reasonId}">${escapeHtml(action.disabled)}</span>`
+ : '';
+ return `<div class="causeway-service-action" data-causeway-service-action-region${dataHint('css-hint', action.cssHint)}${dataHint('icon-hint', action.iconHint)}>
+ <button class="causeway-service-action-control" type="button" data-causeway-service-action data-service-logical-type="${escapeHtml(action.serviceLogicalTypeName)}" data-action-id="${escapeHtml(action.actionId)}"${disabled}${describedBy}${title}>${renderIcon(action.iconHint)}${escapeHtml(label)}</button>${reason}
+</div>`;
+}
+
+function renderIcon(iconHint) {
+ return iconHint
+ ? `<span class="causeway-menu-icon" data-icon-hint="${escapeHtml(iconHint)}" aria-hidden="true"></span>`
+ : '';
+}
+
+function dataHint(name, value) {
+ return value ? ` data-${name}="${escapeHtml(value)}"` : '';
+}
+
+function isLoading(status) {
+ return [
+ MenuBarsStatus.IDLE,
+ MenuBarsStatus.APPLICATION_LOADING,
+ MenuBarsStatus.RESOURCE_LOADING,
+ MenuBarsStatus.SERVICE_LOADING
+ ].includes(status);
+}
+
+function humanize(value) {
+ return String(value ?? '')
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replaceAll('_', ' ')
+ .replace(/^./, first => first.toUpperCase());
+}
diff --git a/viewers/webcomponents/foundation/src/menubars-element.mjs b/viewers/webcomponents/foundation/src/menubars-element.mjs
new file mode 100644
index 00000000000..c18be03f29c
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/menubars-element.mjs
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {CausewayElementName} from './component-contracts.mjs';
+import {
+ createSemanticEvent,
+ MENU_BARS_CONTEXT_REQUEST_EVENT,
+ MENU_BARS_DIAGNOSTIC_EVENT,
+ MENU_BARS_STATE_EVENT,
+ requestGraphQLClient
+} from './context-events.mjs';
+import {MENU_BAR_ROLES} from './menu-layout.mjs';
+import {MenuBarsContextController, MenuBarsStatus} from './menu-context-controller.mjs';
+
+const HTMLElementBase = globalThis.HTMLElement ?? class extends EventTarget {};
+const ROLE_ELEMENTS = Object.freeze({
+ primary: CausewayElementName.MENUBAR_PRIMARY,
+ secondary: CausewayElementName.MENUBAR_SECONDARY,
+ tertiary: CausewayElementName.MENUBAR_TERTIARY
+});
+
+export class CausewayMenubarsElement extends HTMLElementBase {
+ constructor() {
+ super();
+ this._client = null;
+ this._fetchImpl = null;
+ this._context = null;
+ this._release = null;
+ this.lastDiagnosticGeneration = -1;
+ this.addEventListener(MENU_BARS_CONTEXT_REQUEST_EVENT, event => {
+ if (event.detail?.provide && this._context) {
+ event.detail.provide(this._context);
+ event.stopPropagation();
+ }
+ });
+ }
+
+ get client() {
+ return this._client;
+ }
+
+ set client(value) {
+ this._client = value ?? null;
+ if (this.isConnected) {
+ this.#connectContext();
+ }
+ }
+
+ get fetchImpl() {
+ return this._fetchImpl;
+ }
+
+ set fetchImpl(value) {
+ this._fetchImpl = value ?? null;
+ }
+
+ get context() {
+ return this._context;
+ }
+
+ connectedCallback() {
+ this.#connectContext();
+ }
+
+ disconnectedCallback() {
+ this._release?.();
+ this._release = null;
+ this._context?.disconnect();
+ this._context = null;
+ }
+
+ refresh() {
+ return this._context?.refresh() ?? Promise.resolve(null);
+ }
+
+ #connectContext() {
+ this._release?.();
+ this._release = null;
+ this._context?.disconnect();
+ const client = this._client ?? requestGraphQLClient(this);
+ this._context = new MenuBarsContextController({
+ client,
+ fetchImpl: this._fetchImpl ?? globalThis.fetch
+ });
+ this.#provideContextToDeclarativeBars();
+ this._release = this._context.subscribe(state => this.#acceptState(state));
+ void this._context.refresh();
+ }
+
+ #acceptState(state) {
+ this.setAttribute('data-menu-state', state.status);
+ if (state.plan) {
+ this.#ensureEffectiveBars(state.plan);
+ this.#ensureInteractionController();
+ }
+ const status = this.#statusRoot();
+ if (isLoading(state.status)) {
+ status.hidden = false;
+ status.setAttribute('role', 'status');
+ status.textContent = 'Loading application menus…';
+ } else if (state.status === MenuBarsStatus.TERMINAL_ERROR || state.status === MenuBarsStatus.UNSUPPORTED) {
+ status.hidden = false;
+ status.setAttribute('role', 'alert');
+ status.textContent = state.status === MenuBarsStatus.UNSUPPORTED
+ ? 'Application menus are unavailable.'
+ : 'The application menus could not be loaded.';
+ } else {
+ status.hidden = true;
+ status.removeAttribute?.('role');
+ status.textContent = '';
+ }
+ this.dispatchEvent(createSemanticEvent(MENU_BARS_STATE_EVENT, Object.freeze({element: this, state})));
+ if (state.generation !== this.lastDiagnosticGeneration && state.diagnostics.length > 0) {
+ this.lastDiagnosticGeneration = state.generation;
+ for (const diagnostic of state.diagnostics) {
+ this.dispatchEvent(createSemanticEvent(MENU_BARS_DIAGNOSTIC_EVENT, Object.freeze({
+ element: this,
+ generation: state.generation,
+ diagnostic
+ })));
+ }
+ }
+ }
+
+ #provideContextToDeclarativeBars() {
+ for (const child of this.#directBarChildren()) {
+ child.context = this._context;
+ }
+ }
+
+ #ensureEffectiveBars(plan) {
+ const byRole = new Map(this.#directBarChildren().map(child => [roleForElement(child.localName), child]));
+ for (const [index, role] of MENU_BAR_ROLES.entries()) {
+ let bar = byRole.get(role) ?? null;
+ const present = (plan.bars?.[role]?.menus?.length ?? 0) > 0;
+ if (!bar && present && globalThis.document?.createElement) {
+ bar = document.createElement(ROLE_ELEMENTS[role]);
+ bar.setAttribute('data-causeway-generated', 'menubar');
+ this.appendChild(bar);
+ byRole.set(role, bar);
+ }
+ if (bar) {
+ bar.setAttribute('data-causeway-bar-order', String(index));
+ bar.context = this._context;
+ if (!present && bar.getAttribute('data-causeway-generated') === 'menubar') {
+ this.removeChild(bar);
+ byRole.delete(role);
+ }
+ }
+ }
+ }
+
+ #directBarChildren() {
+ return [...(this.childNodes ?? [])]
+ .filter(child => Object.values(ROLE_ELEMENTS).includes(child.localName));
+ }
+
+ #statusRoot() {
+ let status = [...(this.childNodes ?? [])]
+ .find(child => child.getAttribute?.('data-causeway-menubars-status') === 'true');
+ if (!status && globalThis.document?.createElement) {
+ status = document.createElement('div');
+ status.setAttribute('data-causeway-menubars-status', 'true');
+ status.setAttribute('class', 'causeway-menubars-status causeway-loading');
+ this.appendChild(status);
+ }
+ return status ?? this;
+ }
+
+ #ensureInteractionController() {
+ const existing = [...(this.childNodes ?? [])]
+ .find(child => child.localName === CausewayElementName.INTERACTION_CONTROLLER);
+ if (!existing && globalThis.document?.createElement) {
+ const controller = document.createElement(CausewayElementName.INTERACTION_CONTROLLER);
+ controller.setAttribute('data-causeway-generated', 'menubars-interaction-controller');
+ this.appendChild(controller);
+ }
+ }
+}
+
+function roleForElement(name) {
+ return Object.entries(ROLE_ELEMENTS).find(([, elementName]) => elementName === name)?.[0] ?? null;
+}
+
+function isLoading(status) {
+ return [
+ MenuBarsStatus.IDLE,
+ MenuBarsStatus.APPLICATION_LOADING,
+ MenuBarsStatus.RESOURCE_LOADING,
+ MenuBarsStatus.SERVICE_LOADING
+ ].includes(status);
+}
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index 444abd7b09f..d2956a920b3 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -21,18 +21,10 @@ import {createSemanticEvent, OBJECT_CONTEXT_STATE_EVENT} from './context-events.
import {fieldsByName, namedType} from './introspection.mjs';
import {commandSelection, resultSelectionForType} from './interaction-operations.mjs';
import {deepMerge, differenceSelection, isSelectionEmpty, mergeSelections} from './selection.mjs';
+import {fetchStructuralResource, StructuralResourceError} from './structural-resource.mjs';
import {InteractionResultKind, InteractionStatus, ObjectContextStatus, RequirementStatus} from './types.mjs';
-const MAX_STRUCTURAL_RESOURCE_CHARACTERS = 1_048_576;
-
-export class StructuralResourceError extends Error {
- constructor(code, message, {status = null} = {}) {
- super(message);
- this.name = 'StructuralResourceError';
- this.code = code;
- this.status = status;
- }
-}
+export {StructuralResourceError} from './structural-resource.mjs';
export class ObjectContextController extends EventTarget {
constructor({
@@ -121,12 +113,6 @@ export class ObjectContextController extends EventTarget {
if (this.closed) {
throw new StructuralResourceError('CONTEXT_DISCONNECTED', 'Cannot load a structural resource using a disconnected object context.');
}
- if (!isSafeStructuralResourcePath(resourcePath)) {
- throw new StructuralResourceError('INVALID_RESOURCE_PATH', 'A structural resource must be an opaque origin-relative path beginning with exactly one slash.');
- }
- if (typeof this.fetchImpl !== 'function') {
- throw new StructuralResourceError('FETCH_UNAVAILABLE', 'No Fetch API implementation is available for structural resources.');
- }
const abortController = new AbortController();
this.secondaryAbortControllers.add(abortController);
const abort = () => abortController.abort();
@@ -135,40 +121,11 @@ export class ObjectContextController extends EventTarget {
abortController.abort();
}
try {
- const fetchImpl = this.fetchImpl;
- const response = await fetchImpl(resourcePath, {
- method: 'GET',
- headers: {accept},
- credentials: 'same-origin',
- cache: 'no-store',
- redirect: 'error',
+ return await fetchStructuralResource(resourcePath, {
+ fetchImpl: this.fetchImpl,
+ accept,
signal: abortController.signal
});
- if (!response?.ok) {
- throw new StructuralResourceError(
- 'RESOURCE_REQUEST_FAILED',
- `Structural resource request failed with HTTP ${response?.status ?? 'unknown'}.`,
- {status: response?.status ?? null}
- );
- }
- const contentLength = Number(response.headers?.get?.('content-length'));
- if (Number.isFinite(contentLength) && contentLength > MAX_STRUCTURAL_RESOURCE_CHARACTERS) {
- throw new StructuralResourceError('RESOURCE_TOO_LARGE', `Structural resource exceeds ${MAX_STRUCTURAL_RESOURCE_CHARACTERS} characters.`);
- }
- const text = await response.text();
- if (text.length > MAX_STRUCTURAL_RESOURCE_CHARACTERS) {
- throw new StructuralResourceError('RESOURCE_TOO_LARGE', `Structural resource exceeds ${MAX_STRUCTURAL_RESOURCE_CHARACTERS} characters.`);
- }
- return Object.freeze({
- path: resourcePath,
- mediaType: response.headers?.get?.('content-type') ?? null,
- text
- });
- } catch (error) {
- if (error?.name === 'AbortError' || error instanceof StructuralResourceError) {
- throw error;
- }
- throw new StructuralResourceError('RESOURCE_REQUEST_FAILED', 'The structural resource request failed.');
} finally {
signal?.removeEventListener?.('abort', abort);
this.secondaryAbortControllers.delete(abortController);
@@ -1048,14 +1005,6 @@ function commandError(error) {
});
}
-function isSafeStructuralResourcePath(value) {
- return typeof value === 'string'
- && value.startsWith('/')
- && !value.startsWith('//')
- && !value.includes('\\')
- && !/[\r\n]/.test(value);
-}
-
function validateIdentity(identity, client) {
if (!client) {
return new Error('A Causeway GraphQL client is required.');
diff --git a/viewers/webcomponents/foundation/src/object-layout.mjs b/viewers/webcomponents/foundation/src/object-layout.mjs
index 344aa5b95bc..a148ede37ac 100644
--- a/viewers/webcomponents/foundation/src/object-layout.mjs
+++ b/viewers/webcomponents/foundation/src/object-layout.mjs
@@ -18,18 +18,24 @@
*/
import {escapeHtml} from './rendering.mjs';
+import {
+ MAX_STRUCTURAL_DIAGNOSTICS,
+ MAX_STRUCTURAL_XML_CHARACTERS,
+ MAX_STRUCTURAL_XML_DEPTH,
+ MAX_STRUCTURAL_XML_NODES,
+ parseStructuralXml
+} from './structural-xml.mjs';
export const CAUSEWAY_COMPONENT_NAMESPACE = 'https://causeway.apache.org/applib/layout/component';
export const CAUSEWAY_GRID_NAMESPACE = 'https://causeway.apache.org/applib/layout/grid/bootstrap3';
-export const MAX_GRID_XML_CHARACTERS = 1_048_576;
-export const MAX_GRID_XML_DEPTH = 64;
-export const MAX_GRID_XML_NODES = 4_096;
-export const MAX_LAYOUT_DIAGNOSTICS = 20;
+export const MAX_GRID_XML_CHARACTERS = MAX_STRUCTURAL_XML_CHARACTERS;
+export const MAX_GRID_XML_DEPTH = MAX_STRUCTURAL_XML_DEPTH;
+export const MAX_GRID_XML_NODES = MAX_STRUCTURAL_XML_NODES;
+export const MAX_LAYOUT_DIAGNOSTICS = MAX_STRUCTURAL_DIAGNOSTICS;
const MEMBER_KINDS = Object.freeze(['action', 'property', 'collection']);
const STRUCTURAL_KINDS = Object.freeze(['grid', 'row', 'col', 'tabGroup', 'tab', 'fieldSet', 'domainObject']);
const PRESENTATION_KINDS = Object.freeze(['named', 'describedAs']);
-const XML_ENTITIES = Object.freeze({amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"});
export class CausewayGridError extends Error {
constructor(code, message) {
@@ -41,7 +47,11 @@ export class CausewayGridError extends Error {
export function parseCausewayGridXml(xml, {members = new Map(), maxDiagnostics = MAX_LAYOUT_DIAGNOSTICS} = {}) {
const diagnostics = createDiagnosticCollector(maxDiagnostics);
- const root = parseSafeXml(xml);
+ const root = parseStructuralXml(xml, {
+ codePrefix: 'GRID',
+ resourceLabel: 'effective grid resource',
+ ErrorType: CausewayGridError
+ });
if (elementKind(root) !== 'grid') {
throw new CausewayGridError('GRID_ROOT_REQUIRED', 'The effective grid resource does not contain a supported grid root.');
}
@@ -98,149 +108,6 @@ export function renderObjectLayoutPlan(plan, {idPrefix = 'causeway-object', edit
return (plan?.regions ?? []).map(region => renderNode(region, state)).join('');
}
-function parseSafeXml(value) {
- if (typeof value !== 'string' || value.trim().length === 0) {
- throw new CausewayGridError('GRID_XML_EMPTY', 'The effective grid resource is empty.');
- }
- if (value.length > MAX_GRID_XML_CHARACTERS) {
- throw new CausewayGridError('GRID_XML_TOO_LARGE', `The effective grid resource exceeds ${MAX_GRID_XML_CHARACTERS} characters.`);
- }
- if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(value)) {
- throw new CausewayGridError('GRID_XML_DECLARATION_FORBIDDEN', 'Document type and entity declarations are not supported.');
- }
- if (/<!\s*\[CDATA\[/i.test(value)) {
- throw new CausewayGridError('GRID_XML_CDATA_FORBIDDEN', 'CDATA sections are not supported in effective grid resources.');
- }
- let xml = value.replace(/^\uFEFF/, '');
- xml = xml.replace(/^\s*<\?xml\s+[\s\S]*?\?>/i, '');
- if (/<\?/.test(xml)) {
- throw new CausewayGridError('GRID_XML_PROCESSING_INSTRUCTION_FORBIDDEN', 'Processing instructions are not supported.');
- }
- xml = xml.replace(/<!--[\s\S]*?-->/g, '');
- if (xml.includes('<!--') || xml.includes('-->')) {
- throw new CausewayGridError('GRID_XML_MALFORMED_COMMENT', 'The effective grid resource contains a malformed comment.');
- }
- const tokens = xml.match(/<[^>]*>|[^<]+/g) ?? [];
- if (tokens.join('') !== xml) {
- throw new CausewayGridError('GRID_XML_MALFORMED', 'The effective grid resource contains malformed markup.');
- }
- const documentNode = {qName: '#document', children: [], text: '', namespaces: new Map()};
- const stack = [documentNode];
- let nodeCount = 0;
- for (const token of tokens) {
- if (!token.startsWith('<')) {
- const text = decodeXmlEntities(token);
- if (text.trim() && stack.length === 1) {
- throw new CausewayGridError('GRID_XML_TEXT_OUTSIDE_ROOT', 'Text is not permitted outside the effective grid root.');
- }
- stack.at(-1).text += text;
- continue;
- }
- if (/^<\//.test(token)) {
- const close = token.match(/^<\/\s*([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>$/);
- if (!close || stack.length === 1 || stack.at(-1).qName !== close[1]) {
- throw new CausewayGridError('GRID_XML_MISMATCHED_ELEMENT', 'The effective grid resource contains mismatched elements.');
- }
- stack.pop();
- continue;
- }
- if (/^<!/.test(token)) {
- throw new CausewayGridError('GRID_XML_DECLARATION_FORBIDDEN', 'XML declarations beyond comments are not supported.');
- }
- const open = token.match(/^<\s*([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)([\s\S]*?)(\/?)>$/);
- if (!open) {
- throw new CausewayGridError('GRID_XML_MALFORMED_ELEMENT', 'The effective grid resource contains a malformed element.');
- }
- const attributes = parseAttributes(open[2]);
- const namespaces = new Map(stack.at(-1).namespaces);
- for (const [name, attributeValue] of attributes) {
- if (name === 'xmlns') {
- namespaces.set('', attributeValue);
- } else if (name.startsWith('xmlns:')) {
- namespaces.set(name.slice('xmlns:'.length), attributeValue);
- }
- if (/^on/i.test(localName(name))) {
- throw new CausewayGridError('GRID_XML_EXECUTABLE_ATTRIBUTE', 'Executable event attributes are not supported.');
- }
- }
- const [prefix = '', name = ''] = splitName(open[1]);
- if (['script', 'style'].includes(name.toLowerCase())) {
- throw new CausewayGridError('GRID_XML_EXECUTABLE_ELEMENT', 'Executable or styling elements are not supported.');
- }
- nodeCount += 1;
- if (nodeCount > MAX_GRID_XML_NODES) {
- throw new CausewayGridError('GRID_XML_TOO_MANY_NODES', `The effective grid resource exceeds ${MAX_GRID_XML_NODES} elements.`);
- }
- if (stack.length > MAX_GRID_XML_DEPTH) {
- throw new CausewayGridError('GRID_XML_TOO_DEEP', `The effective grid resource exceeds ${MAX_GRID_XML_DEPTH} nested elements.`);
- }
- const node = {
- qName: open[1],
- prefix,
- localName: name,
- namespaceURI: namespaces.get(prefix) ?? null,
- attributes,
- namespaces,
- children: [],
- text: ''
- };
- stack.at(-1).children.push(node);
- if (open[3] !== '/') {
- stack.push(node);
- }
- }
- if (stack.length !== 1 || documentNode.children.length !== 1) {
- throw new CausewayGridError('GRID_XML_MALFORMED', 'The effective grid resource must contain exactly one complete root element.');
- }
- return documentNode.children[0];
-}
-
-function parseAttributes(source) {
- const result = new Map();
- let rest = source;
- while (rest.trim().length > 0) {
- const match = rest.match(/^\s+([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/);
- if (!match) {
- throw new CausewayGridError('GRID_XML_MALFORMED_ATTRIBUTE', 'The effective grid resource contains a malformed attribute.');
- }
- if (result.has(match[1])) {
- throw new CausewayGridError('GRID_XML_DUPLICATE_ATTRIBUTE', 'The effective grid resource contains a duplicate attribute.');
- }
- result.set(match[1], decodeXmlEntities(match[3] ?? match[4] ?? ''));
- rest = rest.slice(match[0].length);
- }
- return result;
-}
-
-function decodeXmlEntities(value) {
- const entityPattern = /&(#x[0-9a-f]+|#\d+|[A-Za-z][\w.-]*);/gi;
- if (value.replace(entityPattern, '').includes('&')) {
- throw new CausewayGridError('GRID_XML_MALFORMED_ENTITY', 'The effective grid resource contains a malformed entity reference.');
- }
- return value.replace(entityPattern, (match, entity) => {
- if (entity.toLowerCase().startsWith('#x')) {
- return validCodePoint(Number.parseInt(entity.slice(2), 16));
- }
- if (entity.startsWith('#')) {
- return validCodePoint(Number.parseInt(entity.slice(1), 10));
- }
- if (Object.prototype.hasOwnProperty.call(XML_ENTITIES, entity)) {
- return XML_ENTITIES[entity];
- }
- throw new CausewayGridError('GRID_XML_UNKNOWN_ENTITY', 'The effective grid resource contains an unknown entity reference.');
- });
-}
-
-function validCodePoint(codePoint) {
- if (!Number.isSafeInteger(codePoint)
- || codePoint <= 0
- || codePoint > 0x10FFFF
- || codePoint >= 0xD800 && codePoint <= 0xDFFF) {
- throw new CausewayGridError('GRID_XML_INVALID_CHARACTER', 'The effective grid resource contains an invalid character reference.');
- }
- return String.fromCodePoint(codePoint);
-}
-
function claimExplicitMembers(root, members, diagnostics) {
const claims = new Set();
walk(root, node => {
diff --git a/viewers/webcomponents/foundation/src/register.mjs b/viewers/webcomponents/foundation/src/register.mjs
index e1944ec7b0e..93408f613c1 100644
--- a/viewers/webcomponents/foundation/src/register.mjs
+++ b/viewers/webcomponents/foundation/src/register.mjs
@@ -23,6 +23,12 @@ import {captureDeclarativeCollectionColumns, CausewayCollectionElement} from './
import {CausewayElementName} from './component-contracts.mjs';
import {CausewayGraphQLClientElement} from './graphql-client-element.mjs';
import {CausewayInteractionControllerElement} from './interaction-controller-element.mjs';
+import {
+ CausewayMenubarPrimaryElement,
+ CausewayMenubarSecondaryElement,
+ CausewayMenubarTertiaryElement
+} from './menubar-element.mjs';
+import {CausewayMenubarsElement} from './menubars-element.mjs';
import {CausewayObjectContextElement} from './object-context-element.mjs';
import {CausewayObjectElement} from './object-element.mjs';
import {CausewayObjectHeaderElement} from './object-header-element.mjs';
@@ -40,6 +46,10 @@ const DEFINITIONS = Object.freeze([
[CausewayElementName.OBJECT_LINK, CausewayObjectLinkElement],
[CausewayElementName.ACTION, CausewayActionElement],
[CausewayElementName.INTERACTION_CONTROLLER, CausewayInteractionControllerElement],
+ [CausewayElementName.MENUBARS, CausewayMenubarsElement],
+ [CausewayElementName.MENUBAR_PRIMARY, CausewayMenubarPrimaryElement],
+ [CausewayElementName.MENUBAR_SECONDARY, CausewayMenubarSecondaryElement],
+ [CausewayElementName.MENUBAR_TERTIARY, CausewayMenubarTertiaryElement],
[CausewayElementName.COLLECTION_COLUMN, CausewayCollectionColumnElement],
[CausewayElementName.COLLECTION, CausewayCollectionElement]
]);
diff --git a/viewers/webcomponents/foundation/src/service-action-context.mjs b/viewers/webcomponents/foundation/src/service-action-context.mjs
new file mode 100644
index 00000000000..93bafad4be3
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/service-action-context.mjs
@@ -0,0 +1,454 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {fieldsByName, namedType} from './introspection.mjs';
+import {commandSelection, resultSelectionForType} from './interaction-operations.mjs';
+import {InteractionResultKind, InteractionStatus} from './types.mjs';
+
+export class ServiceActionContextController {
+ constructor({
+ client,
+ logicalTypeName,
+ serializeMutation = execute => execute(),
+ onChanged = () => {}
+ } = {}) {
+ if (!client) {
+ throw new Error('A Causeway GraphQL client is required for service actions.');
+ }
+ if (!logicalTypeName) {
+ throw new Error('A public service logical type is required.');
+ }
+ this.client = client;
+ this.logicalTypeName = logicalTypeName;
+ this.identity = null;
+ this.interactionTarget = Object.freeze({kind: 'service', logicalTypeName});
+ this.serializeMutation = serializeMutation;
+ this.onChanged = onChanged;
+ this.description = null;
+ this.capabilities = new Map();
+ this.transientRequests = new Map();
+ this.closed = false;
+ }
+
+ async describeService() {
+ if (this.closed) {
+ throw new Error('Cannot describe a disconnected service-action context.');
+ }
+ this.description ??= await this.client.describeService(this.logicalTypeName);
+ return this.description;
+ }
+
+ async loadActionStates(actionIds, {signal} = {}) {
+ const description = await this.describeService();
+ const selection = {};
+ const states = new Map();
+ for (const actionId of [...new Set(actionIds)]) {
+ const descriptor = description.members.get(actionId);
+ if (!descriptor || descriptor.kind !== 'action') {
+ states.set(actionId, Object.freeze({hidden: false, disabled: null, error: 'SERVICE_ACTION_UNAVAILABLE'}));
+ continue;
+ }
+ const actionSelection = {};
+ if (descriptor.fields.has('hidden')) {
+ actionSelection.hidden = true;
+ }
+ if (descriptor.fields.has('disabled')) {
+ actionSelection.disabled = true;
+ }
+ if (Object.keys(actionSelection).length > 0) {
+ selection[actionId] = actionSelection;
+ } else {
+ states.set(actionId, Object.freeze({hidden: false, disabled: null, error: null}));
+ }
+ }
+ if (Object.keys(selection).length === 0) {
+ return Object.freeze({states, errors: Object.freeze([]), operation: null});
+ }
+ const result = await this.client.executeServiceInteraction({
+ description,
+ selection,
+ operationName: 'CausewayReadServiceActionStates',
+ signal
+ });
+ for (const actionId of Object.keys(selection)) {
+ const error = result.errors?.find(candidate => candidate.path?.includes(actionId));
+ const data = result.data?.[actionId] ?? null;
+ states.set(actionId, Object.freeze({
+ hidden: data == null || data.hidden === true,
+ disabled: typeof data?.disabled === 'string' && data.disabled.length > 0 ? data.disabled : null,
+ error: error ? 'SERVICE_ACTION_STATE_FAILED' : null
+ }));
+ }
+ return Object.freeze({states, errors: result.errors ?? Object.freeze([]), operation: result.operation});
+ }
+
+ describeActionInteraction(actionId) {
+ if (!this.capabilities.has(actionId)) {
+ const promise = this.#describeActionInteraction(actionId).catch(error => {
+ this.capabilities.delete(actionId);
+ throw error;
+ });
+ this.capabilities.set(actionId, promise);
+ }
+ return this.capabilities.get(actionId);
+ }
+
+ async prepareAction(actionId, values = {}, {signal} = {}) {
+ const capabilities = await this.describeActionInteraction(actionId);
+ if (!capabilities.invokable) {
+ return interactionResult(InteractionStatus.UNSUPPORTED, {capabilities}, [commandError(`Service action '${actionId}' does not expose an invocation capability.`)]);
+ }
+ if (capabilities.parameters.length === 0) {
+ return interactionResult(InteractionStatus.SUCCESS, {capabilities, parameters: []});
+ }
+ return this.#runTransient(`action:${actionId}:prepare`, signal, async commandSignal => {
+ const description = await this.describeService();
+ const parameterSelection = {};
+ for (const parameter of capabilities.parameters) {
+ const stateSelection = {};
+ for (const fieldName of ['hidden', 'disabled', 'default', 'choices', 'validity', 'datatype']) {
+ const field = parameter.fields.get(fieldName);
+ if (!field) {
+ continue;
+ }
+ const args = argumentsFromValues(field, values);
+ const resultSelection = resultSelectionForType(field.type, description.types);
+ stateSelection[fieldName] = Object.keys(args).length > 0
+ ? commandSelection(args, resultSelection ?? true)
+ : resultSelection ?? true;
+ }
+ parameterSelection[parameter.id] = stateSelection;
+ }
+ const result = await this.client.executeServiceInteraction({
+ description,
+ selection: {[actionId]: {params: parameterSelection}},
+ operationName: 'CausewayPrepareServiceAction',
+ signal: commandSignal
+ });
+ const parameterData = result.data?.[actionId]?.params ?? null;
+ if (!parameterData && result.errors?.length) {
+ return commandResponse(result, null);
+ }
+ return interactionResult(InteractionStatus.SUCCESS, {
+ capabilities,
+ parameters: Object.freeze(capabilities.parameters.map(parameter => {
+ const parameterError = result.errors?.find(error => error.path?.includes(parameter.id));
+ return Object.freeze({
+ ...parameter,
+ state: Object.freeze({
+ ...(parameterData?.[parameter.id] ?? {}),
+ ...(parameterError ? {error: parameterError.message} : {})
+ })
+ });
+ }))
+ }, result.errors, result.operation);
+ });
+ }
+
+ async autoCompleteActionParameter(actionId, parameterId, search, values = {}, {signal} = {}) {
+ return this.#runTransient(`action:${actionId}:${parameterId}:autocomplete`, signal, async commandSignal => {
+ const description = await this.describeService();
+ const capabilities = await this.describeActionInteraction(actionId);
+ const parameter = capabilities.parameters.find(candidate => candidate.id === parameterId);
+ const field = parameter?.fields.get('autoComplete');
+ if (!field) {
+ return interactionResult(InteractionStatus.UNSUPPORTED, null, [commandError(`Service-action parameter '${actionId}.${parameterId}' does not expose autocomplete.`)]);
+ }
+ const args = argumentsFromValues(field, values);
+ const searchArgument = field.args.find(argument => argument.name === 'search')?.name ?? field.args.at(-1)?.name;
+ if (searchArgument) {
+ args[searchArgument] = search;
+ }
+ await ensureResultTypes(this.client, description, field.type, commandSignal);
+ const selection = resultSelectionForType(field.type, description.types);
+ const result = await this.client.executeServiceInteraction({
+ description,
+ selection: {
+ [actionId]: {
+ params: {
+ [parameterId]: {
+ autoComplete: commandSelection(args, selection ?? true)
+ }
+ }
+ }
+ },
+ operationName: 'CausewayServiceActionParameterAutoComplete',
+ signal: commandSignal
+ });
+ return commandResponse(result, result.data?.[actionId]?.params?.[parameterId]?.autoComplete ?? null);
+ });
+ }
+
+ async validateAction(actionId, values = {}, {signal} = {}) {
+ return this.#runTransient(`action:${actionId}:validate`, signal, async commandSignal => {
+ const description = await this.describeService();
+ const descriptor = description.members.get(actionId);
+ const field = descriptor?.fields.get('validate');
+ if (!field) {
+ return interactionResult(InteractionStatus.UNSUPPORTED, null, [commandError(`Service action '${actionId}' does not expose argument validation.`)]);
+ }
+ const result = await this.client.executeServiceInteraction({
+ description,
+ selection: {[actionId]: {validate: commandSelection(argumentsFromValues(field, values))}},
+ operationName: 'CausewayValidateServiceAction',
+ signal: commandSignal
+ });
+ return commandResponse(result, result.data?.[actionId]?.validate ?? null);
+ });
+ }
+
+ async invokeAction(actionId, values = {}, {signal} = {}) {
+ const capabilities = await this.describeActionInteraction(actionId);
+ const execute = async () => {
+ const description = await this.describeService();
+ let result;
+ let resultType;
+ if (capabilities.invocationField) {
+ const invocationField = capabilities.invocationField;
+ const invokeType = description.types.get(namedType(invocationField.type));
+ const resultsField = invokeType ? fieldsByName(invokeType).get('results') : null;
+ if (resultsField) {
+ await ensureResultTypes(this.client, description, resultsField.type, signal);
+ }
+ const resultsSelection = resultsField ? resultSelectionForType(resultsField.type, description.types) : null;
+ result = await this.client.executeServiceInteraction({
+ description,
+ selection: {
+ [actionId]: {
+ [invocationField.name]: commandSelection(
+ argumentsFromValues(invocationField, values),
+ resultsField ? {results: resultsSelection ?? true} : {target: true}
+ )
+ }
+ },
+ operationName: 'CausewayInvokeServiceAction',
+ signal
+ });
+ resultType = resultsField?.type ?? null;
+ result = {...result, data: result.data?.[actionId]?.[invocationField.name]?.results ?? null};
+ } else {
+ const mutationType = await this.client.describeMutation({signal});
+ const mutationField = mutationType ? fieldsByName(mutationType).get(capabilities.mutationFieldName) : null;
+ if (!mutationField) {
+ return interactionResult(InteractionStatus.UNSUPPORTED, null, [commandError(`Service action '${actionId}' does not expose an invocation mutation.`)]);
+ }
+ resultType = mutationField.type;
+ await ensureResultTypes(this.client, description, resultType, signal);
+ result = await this.client.executeMutationInteraction({
+ description,
+ mutationType,
+ fieldName: capabilities.mutationFieldName,
+ args: argumentsFromValues(mutationField, values),
+ resultSelection: resultSelectionForType(resultType, description.types),
+ operationName: 'CausewayInvokeServiceAction',
+ signal
+ });
+ }
+ const response = commandResponse(result, normalizeActionResult(result.data, resultType));
+ if (response.status === InteractionStatus.SUCCESS && capabilities.mutating) {
+ this.onChanged();
+ }
+ return response;
+ };
+ return capabilities.mutating ? this.serializeMutation(execute) : execute();
+ }
+
+ disconnect() {
+ this.closed = true;
+ for (const request of this.transientRequests.values()) {
+ request.abortController.abort();
+ }
+ this.transientRequests.clear();
+ }
+
+ async #describeActionInteraction(actionId) {
+ const description = await this.describeService();
+ const descriptor = description.members.get(actionId);
+ if (!descriptor || descriptor.kind !== 'action') {
+ throw new Error(`Service action '${this.logicalTypeName}.${actionId}' is unavailable.`);
+ }
+ const paramsField = descriptor.fields.get('params');
+ const paramsType = paramsField ? description.types.get(namedType(paramsField.type)) ?? null : null;
+ const validateField = descriptor.fields.get('validate');
+ const parameters = (paramsType?.fields ?? []).map(field => {
+ const wrapper = description.types.get(namedType(field.type)) ?? null;
+ const actionArgument = validateField?.args.find(argument => argument.name === field.name) ?? null;
+ const inputType = actionArgument?.type ?? parameterInputType(wrapper);
+ const enumType = inputType ? description.types.get(namedType(inputType)) ?? null : null;
+ return Object.freeze({
+ id: field.name,
+ description: field.description ?? null,
+ generatedTypeName: wrapper?.name ?? namedType(field.type),
+ fields: fieldsByName(wrapper),
+ inputType,
+ enumValues: Object.freeze(enumType?.enumValues?.map(value => value.name) ?? [])
+ });
+ });
+ const invocationField = ['invoke', 'invokeIdempotent']
+ .map(name => descriptor.fields.get(name))
+ .find(Boolean) ?? null;
+ const legacyMutatingField = descriptor.fields.get('invokeNonIdempotent') ?? null;
+ const mutationType = invocationField ? null : await this.client.describeMutation();
+ const mutationFieldName = `${description.generatedFieldName}__${actionId}`;
+ const mutationField = mutationType ? fieldsByName(mutationType).get(mutationFieldName) ?? null : null;
+ const effectiveInvocationField = invocationField ?? (!mutationField ? legacyMutatingField : null);
+ return Object.freeze({
+ descriptor,
+ parameters: Object.freeze(parameters),
+ validate: Boolean(validateField),
+ invocationField: effectiveInvocationField,
+ mutationFieldName: mutationField ? mutationFieldName : null,
+ mutating: Boolean(mutationField || legacyMutatingField && !invocationField),
+ invokable: Boolean(effectiveInvocationField || mutationField)
+ });
+ }
+
+ async #runTransient(key, signal, execute) {
+ const previous = this.transientRequests.get(key);
+ previous?.abortController.abort();
+ const generation = (previous?.generation ?? 0) + 1;
+ const abortController = new AbortController();
+ const abort = () => abortController.abort();
+ signal?.addEventListener?.('abort', abort, {once: true});
+ if (signal?.aborted) {
+ abortController.abort();
+ }
+ this.transientRequests.set(key, {generation, abortController});
+ try {
+ const result = await execute(abortController.signal);
+ if (this.transientRequests.get(key)?.generation !== generation) {
+ return interactionResult(InteractionStatus.OBSOLETE);
+ }
+ return result;
+ } catch (error) {
+ if (error?.name === 'AbortError') {
+ return interactionResult(InteractionStatus.OBSOLETE);
+ }
+ return interactionResult(InteractionStatus.FAILED, null, [commandError(error)]);
+ } finally {
+ signal?.removeEventListener?.('abort', abort);
+ if (this.transientRequests.get(key)?.generation === generation) {
+ this.transientRequests.delete(key);
+ }
+ }
+ }
+}
+
+async function ensureResultTypes(client, description, typeRef, signal) {
+ const resultTypeName = namedType(typeRef);
+ if (!resultTypeName || description.types.has(resultTypeName)) {
+ return;
+ }
+ const resultTypes = await client.describeTypes([resultTypeName], {signal});
+ const resultType = resultTypes.get(resultTypeName);
+ if (!resultType) {
+ return;
+ }
+ description.types.set(resultTypeName, resultType);
+ const metadataTypeName = namedType(fieldsByName(resultType).get('_meta')?.type);
+ if (metadataTypeName && !description.types.has(metadataTypeName)) {
+ const metadataTypes = await client.describeTypes([metadataTypeName], {signal});
+ const metadataType = metadataTypes.get(metadataTypeName);
+ if (metadataType) {
+ description.types.set(metadataTypeName, metadataType);
+ }
+ }
+}
+
+function parameterInputType(wrapper) {
+ if (!wrapper) {
+ return null;
+ }
+ for (const fieldName of ['validity', 'default', 'choices', 'autoComplete']) {
+ const field = fieldsByName(wrapper).get(fieldName);
+ const type = field?.args.at(-1)?.type;
+ if (type) {
+ return type;
+ }
+ }
+ return null;
+}
+
+function argumentsFromValues(field, values) {
+ const result = {};
+ for (const argument of field?.args ?? []) {
+ if (Object.prototype.hasOwnProperty.call(values, argument.name)) {
+ result[argument.name] = values[argument.name];
+ }
+ }
+ return result;
+}
+
+function commandResponse(result, data) {
+ if (result.errors?.length) {
+ return interactionResult(InteractionStatus.FAILED, data, result.errors, result.operation);
+ }
+ return interactionResult(InteractionStatus.SUCCESS, data, [], result.operation);
+}
+
+function interactionResult(status, data = null, errors = [], operation = null) {
+ return Object.freeze({
+ status,
+ data,
+ errors: Object.freeze([...(errors ?? [])]),
+ operation
+ });
+}
+
+function normalizeActionResult(value, typeRef) {
+ const inner = innermostType(typeRef);
+ const list = unwrapNonNull(typeRef)?.kind === 'LIST';
+ if (value == null) {
+ return Object.freeze({kind: InteractionResultKind.VOID, value: null});
+ }
+ if (list || Array.isArray(value)) {
+ return Object.freeze({kind: InteractionResultKind.COLLECTION, value});
+ }
+ if (inner?.kind === 'SCALAR' || inner?.kind === 'ENUM' || typeof value !== 'object') {
+ return Object.freeze({kind: InteractionResultKind.SCALAR, value});
+ }
+ return Object.freeze({kind: InteractionResultKind.OBJECT, value});
+}
+
+function unwrapNonNull(typeRef) {
+ let current = typeRef;
+ while (current?.kind === 'NON_NULL') {
+ current = current.ofType;
+ }
+ return current;
+}
+
+function innermostType(typeRef) {
+ let current = typeRef;
+ while (current?.ofType) {
+ current = current.ofType;
+ }
+ return current;
+}
+
+function commandError(error) {
+ if (typeof error === 'string') {
+ return Object.freeze({message: error, path: [], extensions: Object.freeze({})});
+ }
+ return Object.freeze({
+ message: error?.message ?? String(error),
+ path: Object.freeze([...(error?.path ?? [])]),
+ extensions: Object.freeze({...error?.extensions})
+ });
+}
diff --git a/viewers/webcomponents/foundation/src/structural-resource.mjs b/viewers/webcomponents/foundation/src/structural-resource.mjs
new file mode 100644
index 00000000000..a0b941bc4a3
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/structural-resource.mjs
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {MAX_STRUCTURAL_XML_CHARACTERS} from './structural-xml.mjs';
+
+export class StructuralResourceError extends Error {
+ constructor(code, message, {status = null} = {}) {
+ super(message);
+ this.name = 'StructuralResourceError';
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export async function fetchStructuralResource(resourcePath, {
+ fetchImpl = globalThis.fetch,
+ accept = 'application/xml',
+ signal,
+ maximumCharacters = MAX_STRUCTURAL_XML_CHARACTERS
+} = {}) {
+ if (!isSafeStructuralResourcePath(resourcePath)) {
+ throw new StructuralResourceError('INVALID_RESOURCE_PATH', 'A structural resource must be an opaque origin-relative path beginning with exactly one slash.');
+ }
+ if (typeof fetchImpl !== 'function') {
+ throw new StructuralResourceError('FETCH_UNAVAILABLE', 'No Fetch API implementation is available for structural resources.');
+ }
+ try {
+ const response = await fetchImpl(resourcePath, {
+ method: 'GET',
+ headers: {accept},
+ credentials: 'same-origin',
+ cache: 'no-store',
+ redirect: 'error',
+ signal
+ });
+ if (!response?.ok) {
+ throw new StructuralResourceError(
+ 'RESOURCE_REQUEST_FAILED',
+ `Structural resource request failed with HTTP ${response?.status ?? 'unknown'}.`,
+ {status: response?.status ?? null}
+ );
+ }
+ const contentLength = Number(response.headers?.get?.('content-length'));
+ if (Number.isFinite(contentLength) && contentLength > maximumCharacters) {
+ throw new StructuralResourceError('RESOURCE_TOO_LARGE', `Structural resource exceeds ${maximumCharacters} characters.`);
+ }
+ const text = await response.text();
+ if (text.length > maximumCharacters) {
+ throw new StructuralResourceError('RESOURCE_TOO_LARGE', `Structural resource exceeds ${maximumCharacters} characters.`);
+ }
+ return Object.freeze({
+ path: resourcePath,
+ mediaType: response.headers?.get?.('content-type') ?? null,
+ text
+ });
+ } catch (error) {
+ if (error?.name === 'AbortError' || error instanceof StructuralResourceError) {
+ throw error;
+ }
+ throw new StructuralResourceError('RESOURCE_REQUEST_FAILED', 'The structural resource request failed.');
+ }
+}
+
+export function isSafeStructuralResourcePath(value) {
+ return typeof value === 'string'
+ && value.startsWith('/')
+ && !value.startsWith('//')
+ && !value.includes('\\')
+ && !/[\r\n]/.test(value);
+}
diff --git a/viewers/webcomponents/foundation/src/structural-xml.mjs b/viewers/webcomponents/foundation/src/structural-xml.mjs
new file mode 100644
index 00000000000..9b75daf43cc
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/structural-xml.mjs
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const MAX_STRUCTURAL_XML_CHARACTERS = 1_048_576;
+export const MAX_STRUCTURAL_XML_DEPTH = 64;
+export const MAX_STRUCTURAL_XML_NODES = 4_096;
+export const MAX_STRUCTURAL_DIAGNOSTICS = 20;
+
+const XML_ENTITIES = Object.freeze({amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"});
+
+export class StructuralXmlError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.name = 'StructuralXmlError';
+ this.code = code;
+ }
+}
+
+export function parseStructuralXml(value, {
+ codePrefix = 'STRUCTURAL',
+ resourceLabel = 'structural resource',
+ ErrorType = StructuralXmlError,
+ maximumCharacters = MAX_STRUCTURAL_XML_CHARACTERS,
+ maximumDepth = MAX_STRUCTURAL_XML_DEPTH,
+ maximumNodes = MAX_STRUCTURAL_XML_NODES
+} = {}) {
+ const fail = (suffix, message) => {
+ throw new ErrorType(`${codePrefix}_XML_${suffix}`, message);
+ };
+ const label = `The ${resourceLabel}`;
+ if (typeof value !== 'string' || value.trim().length === 0) {
+ fail('EMPTY', `${label} is empty.`);
+ }
+ if (value.length > maximumCharacters) {
+ fail('TOO_LARGE', `${label} exceeds ${maximumCharacters} characters.`);
+ }
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
+ fail('INVALID_CHARACTER', `${label} contains an invalid XML character.`);
+ }
+ if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(value)) {
+ fail('DECLARATION_FORBIDDEN', 'Document type and entity declarations are not supported.');
+ }
+ if (/<!\s*\[CDATA\[/i.test(value)) {
+ fail('CDATA_FORBIDDEN', `CDATA sections are not supported in ${resourceLabel}s.`);
+ }
+ let xml = value.replace(/^\uFEFF/, '');
+ xml = xml.replace(/^\s*<\?xml\s+[\s\S]*?\?>/i, '');
+ if (/<\?/.test(xml)) {
+ fail('PROCESSING_INSTRUCTION_FORBIDDEN', 'Processing instructions are not supported.');
+ }
+ for (const comment of xml.matchAll(/<!--([\s\S]*?)-->/g)) {
+ if (comment[1].includes('--')) {
+ fail('MALFORMED_COMMENT', `${label} contains a malformed comment.`);
+ }
+ }
+ xml = xml.replace(/<!--[\s\S]*?-->/g, '');
+ if (xml.includes('<!--') || xml.includes('-->')) {
+ fail('MALFORMED_COMMENT', `${label} contains a malformed comment.`);
+ }
+ const tokens = xml.match(/<[^>]*>|[^<]+/g) ?? [];
+ if (tokens.join('') !== xml) {
+ fail('MALFORMED', `${label} contains malformed markup.`);
+ }
+ const documentNode = {qName: '#document', children: [], text: '', namespaces: new Map()};
+ const stack = [documentNode];
+ let nodeCount = 0;
+ for (const token of tokens) {
+ if (!token.startsWith('<')) {
+ const text = decodeXmlEntities(token, fail, resourceLabel);
+ if (text.trim() && stack.length === 1) {
+ fail('TEXT_OUTSIDE_ROOT', `Text is not permitted outside the ${resourceLabel} root.`);
+ }
+ stack.at(-1).text += text;
+ continue;
+ }
+ if (/^<\//.test(token)) {
+ const close = token.match(/^<\/\s*([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)\s*>$/);
+ if (!close || stack.length === 1 || stack.at(-1).qName !== close[1]) {
+ fail('MISMATCHED_ELEMENT', `${label} contains mismatched elements.`);
+ }
+ stack.pop();
+ continue;
+ }
+ if (/^<!/.test(token)) {
+ fail('DECLARATION_FORBIDDEN', 'XML declarations beyond comments are not supported.');
+ }
+ const open = token.match(/^<\s*([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)([\s\S]*?)(\/?)>$/);
+ if (!open) {
+ fail('MALFORMED_ELEMENT', `${label} contains a malformed element.`);
+ }
+ const attributes = parseAttributes(open[2], fail, resourceLabel);
+ const namespaces = new Map(stack.at(-1).namespaces);
+ for (const [name, attributeValue] of attributes) {
+ if (name === 'xmlns') {
+ namespaces.set('', attributeValue);
+ } else if (name.startsWith('xmlns:')) {
+ namespaces.set(name.slice('xmlns:'.length), attributeValue);
+ }
+ if (/^on/i.test(structuralLocalName(name))) {
+ fail('EXECUTABLE_ATTRIBUTE', 'Executable event attributes are not supported.');
+ }
+ }
+ const [prefix = '', name = ''] = splitStructuralName(open[1]);
+ if (['script', 'style'].includes(name.toLowerCase())) {
+ fail('EXECUTABLE_ELEMENT', 'Executable or styling elements are not supported.');
+ }
+ nodeCount += 1;
+ if (nodeCount > maximumNodes) {
+ fail('TOO_MANY_NODES', `${label} exceeds ${maximumNodes} elements.`);
+ }
+ if (stack.length > maximumDepth) {
+ fail('TOO_DEEP', `${label} exceeds ${maximumDepth} nested elements.`);
+ }
+ const node = {
+ qName: open[1],
+ prefix,
+ localName: name,
+ namespaceURI: namespaces.get(prefix) ?? null,
+ attributes,
+ namespaces,
+ children: [],
+ text: ''
+ };
+ stack.at(-1).children.push(node);
+ if (open[3] !== '/') {
+ stack.push(node);
+ }
+ }
+ if (stack.length !== 1 || documentNode.children.length !== 1) {
+ fail('MALFORMED', `${label} must contain exactly one complete root element.`);
+ }
+ return documentNode.children[0];
+}
+
+export function walkStructuralXml(node, visit) {
+ visit(node);
+ for (const child of node?.children ?? []) {
+ walkStructuralXml(child, visit);
+ }
+}
+
+export function structuralTextContent(node) {
+ return `${node?.text ?? ''}${(node?.children ?? []).map(structuralTextContent).join('')}`;
+}
+
+export function splitStructuralName(qName) {
+ const separator = qName.indexOf(':');
+ return separator < 0 ? ['', qName] : [qName.slice(0, separator), qName.slice(separator + 1)];
+}
+
+export function structuralLocalName(qName) {
+ return splitStructuralName(qName)[1];
+}
+
+function parseAttributes(source, fail, resourceLabel) {
+ const result = new Map();
+ let rest = source;
+ while (rest.trim().length > 0) {
+ const match = rest.match(/^\s+([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/);
+ if (!match) {
+ fail('MALFORMED_ATTRIBUTE', `The ${resourceLabel} contains a malformed attribute.`);
+ }
+ if (result.has(match[1])) {
+ fail('DUPLICATE_ATTRIBUTE', `The ${resourceLabel} contains a duplicate attribute.`);
+ }
+ result.set(match[1], decodeXmlEntities(match[3] ?? match[4] ?? '', fail, resourceLabel));
+ rest = rest.slice(match[0].length);
+ }
+ return result;
+}
+
+function decodeXmlEntities(value, fail, resourceLabel) {
+ const entityPattern = /&(#x[0-9a-f]+|#\d+|[A-Za-z][\w.-]*);/gi;
+ if (value.replace(entityPattern, '').includes('&')) {
+ fail('MALFORMED_ENTITY', `The ${resourceLabel} contains a malformed entity reference.`);
+ }
+ return value.replace(entityPattern, (match, entity) => {
+ if (entity.toLowerCase().startsWith('#x')) {
+ return validCodePoint(Number.parseInt(entity.slice(2), 16), fail, resourceLabel);
+ }
+ if (entity.startsWith('#')) {
+ return validCodePoint(Number.parseInt(entity.slice(1), 10), fail, resourceLabel);
+ }
+ if (Object.prototype.hasOwnProperty.call(XML_ENTITIES, entity)) {
+ return XML_ENTITIES[entity];
+ }
+ fail('UNKNOWN_ENTITY', `The ${resourceLabel} contains an unknown entity reference.`);
+ });
+}
+
+function validCodePoint(codePoint, fail, resourceLabel) {
+ if (!Number.isSafeInteger(codePoint)
+ || codePoint <= 0
+ || codePoint > 0x10FFFF
+ || codePoint >= 0xD800 && codePoint <= 0xDFFF) {
+ fail('INVALID_CHARACTER', `The ${resourceLabel} contains an invalid character reference.`);
+ }
+ return String.fromCodePoint(codePoint);
+}
diff --git a/viewers/webcomponents/foundation/test/application-menu-client.test.mjs b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
new file mode 100644
index 00000000000..3c99d87430f
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {CausewayGraphQLClient} from '../src/graphql-client.mjs';
+import {ServiceActionContextController} from '../src/service-action-context.mjs';
+import {InteractionStatus} from '../src/types.mjs';
+import {
+ createMenuGraphQLExecutor,
+ createMenuGraphQLTypes,
+ SAMPLE_SERVICE_LOGICAL_TYPE
+} from './fixtures/menu-graphql-fixture.mjs';
+import {waitFor} from './fixtures/rich-schema-fixture.mjs';
+
+test('targeted application discovery reads the effective menu descriptor and caches capability types', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ const capability = await client.describeApplicationEntry();
+
+ assert.equal(capability.supported, true);
+ assert.equal(capability.menuBarsType.name, 'rich__gqlv_application_menu_bars');
+ assert.equal(executor.calls.filter(call => call.operationName === 'CausewayDescribeTypes').every(call => Object.keys(call.variables).length === 1), true);
+ const introspectionCount = executor.calls.filter(call => call.operationName === 'CausewayDescribeTypes').length;
+ assert.equal(await client.describeApplicationEntry(), capability);
+ assert.equal(executor.calls.filter(call => call.operationName === 'CausewayDescribeTypes').length, introspectionCount);
+
+ const result = await client.readApplicationEntry({description: capability});
+ assert.equal(result.data.menuBars.href, '/graphql/application/menu-bars');
+ assert.match(executor.applicationCalls[0].document, /application\s*\{/);
+ assert.match(executor.applicationCalls[0].document, /menuBars\s*\{/);
+ assert.doesNotMatch(executor.applicationCalls[0].document, /home\s*\{/);
+});
+
+test('one GraphQL client serializes initial targeted schema discovery across application and service contexts', async () => {
+ const base = createMenuGraphQLExecutor();
+ let active = 0;
+ let maximumActive = 0;
+ const executor = async request => {
+ if (request.operationName === 'CausewayDescribeTypes' || request.operationName === 'CausewayDescribeOperationRoots') {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ await new Promise(resolve => setTimeout(resolve, 2));
+ try {
+ return await base(request);
+ } finally {
+ active -= 1;
+ }
+ }
+ return base(request);
+ };
+ const client = new CausewayGraphQLClient({executor});
+
+ await Promise.all([
+ client.describeApplicationEntry(),
+ client.describeService(SAMPLE_SERVICE_LOGICAL_TYPE)
+ ]);
+ assert.equal(maximumActive, 1);
+});
+
+test('application discovery reports omitted structural capability without issuing an invalid read', async () => {
+ const executor = createMenuGraphQLExecutor({types: createMenuGraphQLTypes({menuBarsAvailable: false})});
+ const client = new CausewayGraphQLClient({executor});
+ const capability = await client.describeApplicationEntry();
+
+ assert.equal(capability.supported, false);
+ assert.equal(capability.reason, 'MENU_BARS_UNAVAILABLE');
+ assert.equal(executor.applicationCalls.length, 0);
+});
+
+test('service descriptions and current action state use one logical-service query without object identity', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ const context = new ServiceActionContextController({client, logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE});
+ const state = await context.loadActionStates(['welcomeMessage', 'disabledAction', 'hiddenAction']);
+
+ assert.equal(state.states.get('welcomeMessage').hidden, false);
+ assert.equal(state.states.get('disabledAction').disabled, 'Available to administrators only.');
+ assert.equal(state.states.get('hiddenAction').hidden, true);
+ assert.equal(executor.serviceCalls.length, 1);
+ assert.match(executor.serviceCalls[0].document, /causeway_webcomponents_sample_SampleMenu\s*\{/);
+ assert.doesNotMatch(executor.serviceCalls[0].document, /\$object|object:/);
+ assert.deepEqual(executor.serviceCalls[0].variables, {});
+});
+
+test('service action adapter reuses parameter preparation, validation, safe invocation, and typed results', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ const context = new ServiceActionContextController({client, logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE});
+
+ const prepared = await context.prepareAction('greet', {});
+ assert.equal(prepared.status, InteractionStatus.SUCCESS);
+ assert.equal(prepared.data.parameters[0].id, 'name');
+ assert.equal(prepared.data.parameters[0].state.default, 'Ada');
+ assert.deepEqual(prepared.data.parameters[0].state.choices, ['Ada', 'Grace']);
+
+ const invalid = await context.validateAction('greet', {name: ''});
+ assert.equal(invalid.data, 'A name is required.');
+ const valid = await context.validateAction('greet', {name: 'Grace'});
+ assert.equal(valid.status, InteractionStatus.SUCCESS);
+ assert.equal(valid.data, null);
+
+ const invoked = await context.invokeAction('greet', {name: 'Grace'});
+ assert.equal(invoked.status, InteractionStatus.SUCCESS);
+ assert.deepEqual(invoked.data, {kind: 'scalar', value: 'Hello, Grace!'});
+ assert.equal(context.identity, null);
+ assert.deepEqual(context.interactionTarget, {kind: 'service', logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE});
+});
+
+test('superseded service parameter preparation cannot replace the latest response', async () => {
+ const base = createMenuGraphQLExecutor();
+ let releaseFirst;
+ let prepareCount = 0;
+ const gate = new Promise(resolve => releaseFirst = resolve);
+ const executor = async request => {
+ if (request.operationName === 'CausewayPrepareServiceAction' && ++prepareCount === 1) {
+ await gate;
+ }
+ return base(request);
+ };
+ const context = new ServiceActionContextController({
+ client: new CausewayGraphQLClient({executor}),
+ logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE
+ });
+
+ const first = context.prepareAction('greet', {name: 'Ada'});
+ await waitFor(() => prepareCount === 1);
+ const second = context.prepareAction('greet', {name: 'Grace'});
+ assert.equal((await second).status, InteractionStatus.SUCCESS);
+ releaseFirst();
+ assert.equal((await first).status, InteractionStatus.OBSOLETE);
+});
+
+test('mutating service actions use the existing top-level mutation without a manufactured target', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ let changed = 0;
+ const context = new ServiceActionContextController({
+ client,
+ logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE,
+ onChanged: () => changed += 1
+ });
+
+ const invoked = await context.invokeAction('clearNotes', {});
+ assert.equal(invoked.status, InteractionStatus.SUCCESS);
+ assert.deepEqual(invoked.data, {kind: 'scalar', value: 'Cleared'});
+ assert.equal(executor.mutationCalls.length, 1);
+ assert.match(executor.mutationCalls[0].document, /^mutation CausewayInvokeServiceAction \{/);
+ assert.doesNotMatch(executor.mutationCalls[0].document, /CausewayInvokeServiceAction\(\)|_target|\$object/);
+ assert.equal(changed, 1);
+});
+
+test('mutating service actions delegate to application-scope serialization', async () => {
+ const executor = createMenuGraphQLExecutor();
+ let tail = Promise.resolve();
+ let active = 0;
+ let maximumActive = 0;
+ const serializeMutation = execute => {
+ const result = tail.then(async () => {
+ active += 1;
+ maximumActive = Math.max(maximumActive, active);
+ await new Promise(resolve => setTimeout(resolve, 5));
+ try {
+ return await execute();
+ } finally {
+ active -= 1;
+ }
+ });
+ tail = result.catch(() => undefined);
+ return result;
+ };
+ const context = new ServiceActionContextController({
+ client: new CausewayGraphQLClient({executor}),
+ logicalTypeName: SAMPLE_SERVICE_LOGICAL_TYPE,
+ serializeMutation
+ });
+
+ const results = await Promise.all([
+ context.invokeAction('clearNotes', {}),
+ context.invokeAction('clearNotes', {})
+ ]);
+ assert.equal(results.every(result => result.status === InteractionStatus.SUCCESS), true);
+ assert.equal(maximumActive, 1);
+ assert.equal(executor.mutationCalls.length, 2);
+});
diff --git a/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
new file mode 100644
index 00000000000..2bfa9c78b41
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {CAUSEWAY_MENU_BARS_NAMESPACE} from '../../src/menu-layout.mjs';
+
+export const SAMPLE_SERVICE_LOGICAL_TYPE = 'causeway.webcomponents.sample.SampleMenu';
+export const SAMPLE_SERVICE_FIELD = 'causeway_webcomponents_sample_SampleMenu';
+export const SAMPLE_SERVICE_TYPE = 'rich__causeway_webcomponents_sample_SampleMenu';
+
+export function createMenuGraphQLTypes({menuBarsAvailable = true} = {}) {
+ const applicationFields = [
+ ...(menuBarsAvailable ? [field('menuBars', named('rich__gqlv_application_menu_bars'))] : []),
+ field('issues', list(named('rich__gqlv_application_issue')))
+ ];
+ return new Map([
+ ['SimpleAndRich', objectType('SimpleAndRich', [field('rich', named('RICHSchema'))])],
+ ['RICHSchema', objectType('RICHSchema', [
+ field('application', nonNull(named('rich__gqlv_application_entry'))),
+ field(SAMPLE_SERVICE_FIELD, named(SAMPLE_SERVICE_TYPE))
+ ])],
+ ['rich__gqlv_application_entry', objectType('rich__gqlv_application_entry', applicationFields)],
+ ['rich__gqlv_application_menu_bars', objectType('rich__gqlv_application_menu_bars', [
+ field('href', nonNull(scalar('String'))),
+ field('mediaType', nonNull(scalar('String'))),
+ field('formatVersion', nonNull(scalar('String'))),
+ field('generation', nonNull(scalar('String'))),
+ field('cacheControl', nonNull(scalar('String')))
+ ])],
+ ['rich__gqlv_application_issue', objectType('rich__gqlv_application_issue', [
+ field('code', nonNull(scalar('String'))),
+ field('message', nonNull(scalar('String')))
+ ])],
+ [SAMPLE_SERVICE_TYPE, objectType(SAMPLE_SERVICE_TYPE, [
+ field('welcomeMessage', named(`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`)),
+ field('greet', named(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action`)),
+ field('disabledAction', named(`${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action`)),
+ field('hiddenAction', named(`${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action`)),
+ field('clearNotes', named(`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action`))
+ ])],
+ [`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`, actionType(
+ `${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`,
+ field('invoke', named(`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action_invoke`))
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action_invoke`, invokeType(
+ `${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action_invoke`, scalar('String')
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action`, actionType(
+ `${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action`,
+ field('invoke', named(`${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action_invoke`))
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action_invoke`, invokeType(
+ `${SAMPLE_SERVICE_TYPE}__disabledAction__gqlv_action_invoke`, scalar('String')
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action`, actionType(
+ `${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action`,
+ field('invoke', named(`${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action_invoke`))
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action_invoke`, invokeType(
+ `${SAMPLE_SERVICE_TYPE}__hiddenAction__gqlv_action_invoke`, scalar('String')
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action`, objectType(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action`, [
+ field('hidden', scalar('Boolean')),
+ field('disabled', scalar('String')),
+ field('params', named(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_params`)),
+ field('validate', scalar('String'), [argument('name', scalar('String'))]),
+ field('invoke', named(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_invoke`), [argument('name', nonNull(scalar('String')))])
+ ])],
+ [`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_params`, objectType(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_params`, [
+ field('name', named(`${SAMPLE_SERVICE_TYPE}__greet__name__gqlv_action_parameter`))
+ ])],
+ [`${SAMPLE_SERVICE_TYPE}__greet__name__gqlv_action_parameter`, objectType(`${SAMPLE_SERVICE_TYPE}__greet__name__gqlv_action_parameter`, [
+ field('hidden', scalar('Boolean'), [argument('name', scalar('String'))]),
+ field('disabled', scalar('String'), [argument('name', scalar('String'))]),
+ field('default', scalar('String'), [argument('name', scalar('String'))]),
+ field('choices', list(scalar('String')), [argument('name', scalar('String'))]),
+ field('validity', scalar('String'), [argument('name', scalar('String'))]),
+ field('datatype', scalar('String'))
+ ])],
+ [`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_invoke`, invokeType(
+ `${SAMPLE_SERVICE_TYPE}__greet__gqlv_action_invoke`, scalar('String')
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action`, actionType(
+ `${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action`,
+ field('invokeNonIdempotent', named(`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action_invoke`))
+ )],
+ [`${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action_invoke`, invokeType(
+ `${SAMPLE_SERVICE_TYPE}__clearNotes__gqlv_action_invoke`, scalar('String')
+ )],
+ ['Mutation', objectType('Mutation', [
+ field(`${SAMPLE_SERVICE_FIELD}__clearNotes`, scalar('String'))
+ ])]
+ ]);
+}
+
+export function createMenuGraphQLExecutor({
+ types = createMenuGraphQLTypes(),
+ applicationEntries = [applicationEntryResponse()],
+ actionState = null,
+ delayApplication = null
+} = {}) {
+ const calls = [];
+ const applicationCalls = [];
+ const serviceCalls = [];
+ const mutationCalls = [];
+ let applicationIndex = 0;
+ const executor = async request => {
+ if (request.signal?.aborted) {
+ throw abortError();
+ }
+ calls.push(request);
+ if (request.operationName === 'CausewayDescribeTypes') {
+ const data = {};
+ for (const [variable, typeName] of Object.entries(request.variables)) {
+ data[`describedType${Number(variable.slice('type'.length))}`] = types.get(typeName) ?? null;
+ }
+ return {data};
+ }
+ if (request.operationName === 'CausewayDescribeOperationRoots') {
+ return {data: {__schema: {queryType: {name: 'SimpleAndRich'}, mutationType: {name: 'Mutation'}}}};
+ }
+ if (request.operationName === 'CausewayReadApplicationEntry') {
+ applicationCalls.push(request);
+ const currentIndex = applicationIndex;
+ applicationIndex += 1;
+ if (delayApplication) {
+ await delayApplication(request, currentIndex);
+ }
+ const response = applicationEntries[Math.min(currentIndex, applicationEntries.length - 1)];
+ return typeof response === 'function' ? response(request) : response;
+ }
+ if (request.operationName === 'CausewayReadServiceActionStates') {
+ serviceCalls.push(request);
+ return {data: {rich: {[SAMPLE_SERVICE_FIELD]: actionState ?? {
+ welcomeMessage: {hidden: false, disabled: null},
+ greet: {hidden: false, disabled: null},
+ disabledAction: {hidden: false, disabled: 'Available to administrators only.'},
+ hiddenAction: {hidden: true, disabled: null},
+ clearNotes: {hidden: false, disabled: null}
+ }}}};
+ }
+ if (request.operationName === 'CausewayPrepareServiceAction') {
+ serviceCalls.push(request);
+ return {data: {rich: {[SAMPLE_SERVICE_FIELD]: {greet: {params: {name: {
+ hidden: false,
+ disabled: null,
+ default: 'Ada',
+ choices: ['Ada', 'Grace'],
+ validity: null,
+ datatype: 'String'
+ }}}}}}};
+ }
+ if (request.operationName === 'CausewayValidateServiceAction') {
+ serviceCalls.push(request);
+ const value = Object.values(request.variables)[0];
+ return {data: {rich: {[SAMPLE_SERVICE_FIELD]: {greet: {validate: value ? null : 'A name is required.'}}}}};
+ }
+ if (request.operationName === 'CausewayInvokeServiceAction') {
+ if (request.document.startsWith('mutation')) {
+ mutationCalls.push(request);
+ return {data: {[`${SAMPLE_SERVICE_FIELD}__clearNotes`]: 'Cleared'}};
+ }
+ serviceCalls.push(request);
+ const value = Object.values(request.variables)[0];
+ const actionId = request.document.includes('greet') ? 'greet' : 'welcomeMessage';
+ const result = actionId === 'greet' ? `Hello, ${value}!` : 'Welcome!';
+ return {data: {rich: {[SAMPLE_SERVICE_FIELD]: {[actionId]: {invoke: {results: result}}}}}};
+ }
+ throw new Error(`Unexpected menu fixture operation '${request.operationName}'.`);
+ };
+ Object.assign(executor, {calls, applicationCalls, serviceCalls, mutationCalls, types});
+ return executor;
+}
+
+export function applicationEntryResponse({href = '/graphql/application/menu-bars', issues = []} = {}) {
+ return {data: {rich: {application: {
+ menuBars: {
+ href,
+ mediaType: 'application/xml',
+ formatVersion: CAUSEWAY_MENU_BARS_NAMESPACE,
+ generation: 'fixture-generation',
+ cacheControl: 'private, no-store'
+ },
+ issues
+ }}}};
+}
+
+function actionType(name, invocationField) {
+ return objectType(name, [
+ field('hidden', scalar('Boolean')),
+ field('disabled', scalar('String')),
+ invocationField
+ ]);
+}
+
+function invokeType(name, resultType) {
+ return objectType(name, [field('results', resultType), field('target', scalar('String'))]);
+}
+
+function objectType(name, fields) {
+ return {kind: 'OBJECT', name, description: null, fields};
+}
+
+function field(name, type, args = []) {
+ return {name, description: null, args, type};
+}
+
+function argument(name, type) {
+ return {name, description: null, defaultValue: null, type};
+}
+
+function scalar(name) {
+ return {kind: 'SCALAR', name, ofType: null};
+}
+
+function named(name) {
+ return {kind: 'OBJECT', name, ofType: null};
+}
+
+function list(ofType) {
+ return {kind: 'LIST', name: null, ofType};
+}
+
+function nonNull(ofType) {
+ return {kind: 'NON_NULL', name: null, ofType};
+}
+
+function abortError() {
+ const error = new Error('Aborted');
+ error.name = 'AbortError';
+ return error;
+}
diff --git a/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs b/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
new file mode 100644
index 00000000000..a95384c978a
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/fixtures/menu-layout-fixtures.mjs
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const MENU_BARS_XML = `<?xml version="1.0" encoding="UTF-8"?>
+<mb:menuBars
+ xmlns:mb="https://causeway.apache.org/applib/layout/menubars/bootstrap3"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/menubars/bootstrap3 menubars.xsd">
+ <mb:primary>
+ <mb:menu cssClassFa="fa-building">
+ <mb:named>Samples & Objects</mb:named>
+ <mb:section>
+ <mb:named>Explore</mb:named>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="welcomeMessage" cssClassFa="fa-message">
+ <cpt:named>Welcome Message</cpt:named>
+ <cpt:describedAs>Return a friendly greeting.</cpt:describedAs>
+ </mb:serviceAction>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="disabledAction">
+ <cpt:named>Disabled Action</cpt:named>
+ </mb:serviceAction>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="hiddenAction"/>
+ </mb:section>
+ </mb:menu>
+ </mb:primary>
+ <mb:secondary>
+ <mb:menu>
+ <mb:named>Tools</mb:named>
+ <mb:section>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="greet">
+ <cpt:named>Personal Greeting</cpt:named>
+ </mb:serviceAction>
+ </mb:section>
+ </mb:menu>
+ </mb:secondary>
+ <mb:tertiary>
+ <mb:menu>
+ <mb:named>Administration</mb:named>
+ <mb:section>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.AdminMenu" id="clearNotes"/>
+ </mb:section>
+ </mb:menu>
+ </mb:tertiary>
+</mb:menuBars>`;
+
+export const MENU_BARS_WITH_UNSUPPORTED_CONTENT_XML = MENU_BARS_XML.replace(
+ '<mb:named>Explore</mb:named>',
+ '<mb:named>Explore</mb:named><mb:future-setting value="ignored"/>'
+);
+
+export const MENU_BARS_WITH_INVALID_REFERENCE_XML = MENU_BARS_XML.replace(
+ '<mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="hiddenAction"/>',
+ '<mb:serviceAction objectType="unsafe service" id="not-valid!"/>'
+);
+
+export const MENU_ACTION_STATES = new Map([
+ ['causeway.webcomponents.sample.SampleMenu#welcomeMessage', Object.freeze({hidden: false, disabled: null, error: null})],
+ ['causeway.webcomponents.sample.SampleMenu#disabledAction', Object.freeze({hidden: false, disabled: 'Available to administrators only.', error: null})],
+ ['causeway.webcomponents.sample.SampleMenu#hiddenAction', Object.freeze({hidden: true, disabled: null, error: null})],
+ ['causeway.webcomponents.sample.SampleMenu#greet', Object.freeze({hidden: false, disabled: null, error: null})],
+ ['causeway.webcomponents.sample.AdminMenu#clearNotes', Object.freeze({hidden: false, disabled: null, error: null})]
+]);
diff --git a/viewers/webcomponents/foundation/test/menu-context.test.mjs b/viewers/webcomponents/foundation/test/menu-context.test.mjs
new file mode 100644
index 00000000000..d7aa10f5681
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/menu-context.test.mjs
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {CausewayGraphQLClient} from '../src/graphql-client.mjs';
+import {MenuBarsContextController, MenuBarsStatus} from '../src/menu-context-controller.mjs';
+import {MENU_BARS_XML} from './fixtures/menu-layout-fixtures.mjs';
+import {
+ applicationEntryResponse,
+ createMenuGraphQLExecutor,
+ createMenuGraphQLTypes
+} from './fixtures/menu-graphql-fixture.mjs';
+import {waitFor} from './fixtures/rich-schema-fixture.mjs';
+
+const ONE_SERVICE_XML = MENU_BARS_XML.replaceAll('causeway.webcomponents.sample.AdminMenu', 'causeway.webcomponents.sample.SampleMenu');
+
+test('application-menu generation loads one no-store resource and groups action state by logical service', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const client = new CausewayGraphQLClient({executor});
+ const fetchCalls = [];
+ const context = new MenuBarsContextController({
+ client,
+ fetchImpl: async (path, options) => {
+ fetchCalls.push({path, options});
+ return xmlResponse(ONE_SERVICE_XML);
+ }
+ });
+ const states = [];
+ context.subscribe(state => states.push(state.status));
+ const state = await context.refresh();
+
+ assert.equal(state.status, MenuBarsStatus.READY);
+ assert.equal(fetchCalls.length, 1);
+ assert.equal(fetchCalls[0].path, '/graphql/application/menu-bars');
+ assert.equal(fetchCalls[0].options.credentials, 'same-origin');
+ assert.equal(fetchCalls[0].options.cache, 'no-store');
+ assert.equal(fetchCalls[0].options.redirect, 'error');
+ assert.equal(executor.applicationCalls.length, 1);
+ assert.equal(executor.serviceCalls.filter(call => call.operationName === 'CausewayReadServiceActionStates').length, 1);
+ assert.deepEqual(state.plan.bars.primary.menus[0].sections[0].actions.map(action => action.actionId), [
+ 'welcomeMessage',
+ 'disabledAction'
+ ]);
+ assert.equal(state.plan.bars.primary.menus[0].sections[0].actions[1].disabled, 'Available to administrators only.');
+ assert.ok(states.includes(MenuBarsStatus.APPLICATION_LOADING));
+ assert.ok(states.includes(MenuBarsStatus.RESOURCE_LOADING));
+ assert.ok(states.includes(MenuBarsStatus.SERVICE_LOADING));
+ context.disconnect();
+});
+
+test('missing service types remain local partial errors and preserve unrelated bars', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const context = new MenuBarsContextController({
+ client: new CausewayGraphQLClient({executor}),
+ fetchImpl: async () => xmlResponse(MENU_BARS_XML)
+ });
+ const state = await context.refresh();
+
+ assert.equal(state.status, MenuBarsStatus.PARTIAL_ERROR);
+ assert.equal(state.plan.bars.primary.menus.length, 1);
+ assert.equal(state.plan.bars.secondary.menus.length, 1);
+ assert.equal(state.plan.bars.tertiary.menus.length, 0);
+ assert.ok(state.diagnostics.some(entry => entry.code === 'SERVICE_TYPE_UNAVAILABLE'));
+ assert.equal(JSON.stringify(state.diagnostics).includes('Rich GraphQL object type'), false);
+ context.disconnect();
+});
+
+test('omitted application menu capability produces unsupported state without fetching', async () => {
+ const executor = createMenuGraphQLExecutor({types: createMenuGraphQLTypes({menuBarsAvailable: false})});
+ let fetched = false;
+ const context = new MenuBarsContextController({
+ client: new CausewayGraphQLClient({executor}),
+ fetchImpl: async () => {
+ fetched = true;
+ return xmlResponse(ONE_SERVICE_XML);
+ }
+ });
+ const state = await context.refresh();
+
+ assert.equal(state.status, MenuBarsStatus.UNSUPPORTED);
+ assert.equal(state.diagnostics[0].code, 'MENU_BARS_UNAVAILABLE');
+ assert.equal(fetched, false);
+ context.disconnect();
+});
+
+test('explicit refresh rejects a superseded application response', async () => {
+ let releaseFirst;
+ let applicationNumber = 0;
+ const firstGate = new Promise(resolve => releaseFirst = resolve);
+ const executor = createMenuGraphQLExecutor({
+ applicationEntries: [
+ applicationEntryResponse({href: '/old-menu.xml'}),
+ applicationEntryResponse({href: '/new-menu.xml'})
+ ],
+ delayApplication: async () => {
+ applicationNumber += 1;
+ if (applicationNumber === 1) {
+ await firstGate;
+ }
+ }
+ });
+ const fetched = [];
+ const context = new MenuBarsContextController({
+ client: new CausewayGraphQLClient({executor}),
+ fetchImpl: async path => {
+ fetched.push(path);
+ return xmlResponse(ONE_SERVICE_XML);
+ }
+ });
+
+ const first = context.refresh();
+ await waitFor(() => executor.applicationCalls.length === 1);
+ const second = context.refresh();
+ await second;
+ assert.equal(context.state.resource.href, '/new-menu.xml');
+ releaseFirst();
+ await first;
+ assert.equal(context.state.resource.href, '/new-menu.xml');
+ assert.deepEqual(fetched, ['/new-menu.xml']);
+ context.disconnect();
+});
+
+test('malformed resource failures are bounded and do not disclose response content', async () => {
+ const context = new MenuBarsContextController({
+ client: new CausewayGraphQLClient({executor: createMenuGraphQLExecutor()}),
+ fetchImpl: async () => xmlResponse('<!DOCTYPE x [<!ENTITY secret SYSTEM "file:///secret">]><x>&secret;</x>')
+ });
+ const state = await context.refresh();
+
+ assert.equal(state.status, MenuBarsStatus.TERMINAL_ERROR);
+ assert.equal(state.diagnostics[0].code, 'MENU_XML_DECLARATION_FORBIDDEN');
+ assert.equal(JSON.stringify(state).includes('file:///secret'), false);
+ context.disconnect();
+});
+
+function xmlResponse(text) {
+ return {
+ ok: true,
+ status: 200,
+ headers: {get: name => name === 'content-type' ? 'application/xml' : null},
+ text: async () => text
+ };
+}
diff --git a/viewers/webcomponents/foundation/test/menu-layout.test.mjs b/viewers/webcomponents/foundation/test/menu-layout.test.mjs
new file mode 100644
index 00000000000..d9f34cdf8d6
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/menu-layout.test.mjs
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ applyServiceActionStates,
+ CAUSEWAY_MENU_BARS_NAMESPACE,
+ MAX_MENU_DIAGNOSTICS,
+ menuPlanActionReferences,
+ parseCausewayMenuBarsXml
+} from '../src/menu-layout.mjs';
+import {MAX_STRUCTURAL_XML_CHARACTERS} from '../src/structural-xml.mjs';
+import {
+ MENU_ACTION_STATES,
+ MENU_BARS_WITH_INVALID_REFERENCE_XML,
+ MENU_BARS_WITH_UNSUPPORTED_CONTENT_XML,
+ MENU_BARS_XML
+} from './fixtures/menu-layout-fixtures.mjs';
+
+test('effective menu parser preserves semantic bars, ordering, text, and inert hints', () => {
+ const parsed = parseCausewayMenuBarsXml(MENU_BARS_XML);
+
+ assert.equal(parsed.usable, true);
+ assert.deepEqual(Object.keys(parsed.plan.bars), ['primary', 'secondary', 'tertiary']);
+ assert.equal(parsed.plan.bars.primary.menus[0].label, 'Samples & Objects');
+ assert.equal(parsed.plan.bars.primary.menus[0].iconHint, 'fa-building');
+ assert.equal(parsed.plan.bars.primary.menus[0].sections[0].label, 'Explore');
+ const first = parsed.plan.bars.primary.menus[0].sections[0].actions[0];
+ assert.equal(first.serviceLogicalTypeName, 'causeway.webcomponents.sample.SampleMenu');
+ assert.equal(first.actionId, 'welcomeMessage');
+ assert.equal(first.label, 'Welcome Message');
+ assert.equal(first.description, 'Return a friendly greeting.');
+ assert.equal(first.iconHint, 'fa-message');
+ assert.equal(parsed.diagnostics.length, 0);
+ assert.ok(Object.isFrozen(parsed.plan));
+});
+
+test('current action states omit hidden actions, retain disabled reasons, and collapse empty groups', () => {
+ const parsed = parseCausewayMenuBarsXml(MENU_BARS_XML);
+ const plan = applyServiceActionStates(parsed.plan, MENU_ACTION_STATES);
+ const primaryActions = plan.bars.primary.menus[0].sections[0].actions;
+
+ assert.deepEqual(primaryActions.map(action => action.actionId), ['welcomeMessage', 'disabledAction']);
+ assert.equal(primaryActions[1].disabled, 'Available to administrators only.');
+ assert.equal(plan.bars.secondary.menus[0].sections[0].actions[0].actionId, 'greet');
+ assert.equal(plan.bars.tertiary.menus[0].sections[0].actions[0].actionId, 'clearNotes');
+
+ const emptyStates = new Map([...MENU_ACTION_STATES].map(([key, value]) => [key, {...value, hidden: true}]));
+ const empty = applyServiceActionStates(parsed.plan, emptyStates);
+ assert.equal(empty.bars.primary.menus.length, 0);
+ assert.equal(empty.bars.secondary.menus.length, 0);
+ assert.equal(empty.bars.tertiary.menus.length, 0);
+});
+
+test('references are extracted in effective document order', () => {
+ const references = menuPlanActionReferences(parseCausewayMenuBarsXml(MENU_BARS_XML).plan);
+ assert.deepEqual(references.map(action => `${action.serviceLogicalTypeName}#${action.actionId}`), [
+ 'causeway.webcomponents.sample.SampleMenu#welcomeMessage',
+ 'causeway.webcomponents.sample.SampleMenu#disabledAction',
+ 'causeway.webcomponents.sample.SampleMenu#hiddenAction',
+ 'causeway.webcomponents.sample.SampleMenu#greet',
+ 'causeway.webcomponents.sample.AdminMenu#clearNotes'
+ ]);
+});
+
+test('unsupported local content and malformed references are bounded without losing valid menus', () => {
+ const unsupported = parseCausewayMenuBarsXml(MENU_BARS_WITH_UNSUPPORTED_CONTENT_XML);
+ assert.ok(unsupported.diagnostics.some(entry => entry.code === 'UNSUPPORTED_SECTION_CONTENT'));
+ assert.equal(unsupported.plan.bars.primary.menus.length, 1);
+
+ const invalid = parseCausewayMenuBarsXml(MENU_BARS_WITH_INVALID_REFERENCE_XML);
+ assert.ok(invalid.diagnostics.some(entry => entry.code === 'INVALID_SERVICE_ACTION_REFERENCE'));
+ assert.equal(menuPlanActionReferences(invalid.plan).length, 4);
+
+ const noisy = MENU_BARS_XML.replace(
+ '</mb:primary>',
+ `${'<mb:unsupported/>'.repeat(MAX_MENU_DIAGNOSTICS + 10)}</mb:primary>`
+ );
+ const bounded = parseCausewayMenuBarsXml(noisy);
+ assert.equal(bounded.diagnostics.length, MAX_MENU_DIAGNOSTICS);
+ assert.equal(bounded.diagnostics.at(-1).code, 'MENU_DIAGNOSTICS_TRUNCATED');
+});
+
+test('shared structural parser rejects unsafe and oversized menu resources', () => {
+ const attacks = [
+ ['<!DOCTYPE menuBars [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><mb:menuBars xmlns:mb="' + CAUSEWAY_MENU_BARS_NAMESPACE + '">&xxe;</mb:menuBars>', 'MENU_XML_DECLARATION_FORBIDDEN'],
+ ['<mb:menuBars xmlns:mb="' + CAUSEWAY_MENU_BARS_NAMESPACE + '"><script>alert(1)</script></mb:menuBars>', 'MENU_XML_EXECUTABLE_ELEMENT'],
+ ['<mb:menuBars xmlns:mb="' + CAUSEWAY_MENU_BARS_NAMESPACE + '" onclick="alert(1)"/>', 'MENU_XML_EXECUTABLE_ATTRIBUTE'],
+ ['<mb:menuBars xmlns:mb="' + CAUSEWAY_MENU_BARS_NAMESPACE + '"><mb:primary></mb:secondary></mb:menuBars>', 'MENU_XML_MISMATCHED_ELEMENT']
+ ];
+ for (const [xml, code] of attacks) {
+ assert.throws(() => parseCausewayMenuBarsXml(xml), error => error.code === code);
+ }
+ assert.throws(
+ () => parseCausewayMenuBarsXml(`<mb:menuBars xmlns:mb="${CAUSEWAY_MENU_BARS_NAMESPACE}">${' '.repeat(MAX_STRUCTURAL_XML_CHARACTERS)}</mb:menuBars>`),
+ error => error.code === 'MENU_XML_TOO_LARGE'
+ );
+});
diff --git a/viewers/webcomponents/foundation/test/menubar-component.test.mjs b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
new file mode 100644
index 00000000000..6df13e435cc
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/menubar-component.test.mjs
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {installDomShim} from './dom-shim.mjs';
+
+const {document} = installDomShim();
+const {
+ CausewayElementName,
+ CausewayInteractionControllerElement,
+ CausewayMenubarPrimaryElement,
+ CausewayMenubarsElement,
+ CausewaySemanticEvent,
+ defineCausewayWebComponents,
+ MenuBarsStatus,
+ parseCausewayMenuBarsXml,
+ applyServiceActionStates,
+ CausewayGraphQLClient
+} = await import('../src/index.mjs');
+const {MENU_ACTION_STATES, MENU_BARS_XML} = await import('./fixtures/menu-layout-fixtures.mjs');
+const {createMenuGraphQLExecutor} = await import('./fixtures/menu-graphql-fixture.mjs');
+const {waitFor} = await import('./fixtures/rich-schema-fixture.mjs');
+
+defineCausewayWebComponents();
+const ONE_SERVICE_XML = MENU_BARS_XML.replaceAll('causeway.webcomponents.sample.AdminMenu', 'causeway.webcomponents.sample.SampleMenu');
+
+test('semantic bar renders labelled navigation, disclosures, text-safe hints, disabled reasons, and no hidden actions', () => {
+ const parsed = parseCausewayMenuBarsXml(MENU_BARS_XML);
+ const plan = applyServiceActionStates(parsed.plan, MENU_ACTION_STATES);
+ const context = fakeMenuContext(plan);
+ const bar = new CausewayMenubarPrimaryElement();
+ bar.context = context;
+ document.body.appendChild(bar);
+ const markup = renderMarkup(bar);
+
+ assert.match(markup, /<nav[^>]+aria-label="Primary application menu"/);
+ assert.match(markup, /data-causeway-menu-disclosure/);
+ assert.match(markup, /aria-expanded="false"/);
+ assert.match(markup, /data-icon-hint="fa-building"/);
+ assert.match(markup, /Welcome Message/);
+ assert.match(markup, /disabled aria-disabled="true"/);
+ assert.match(markup, /Available to administrators only/);
+ assert.doesNotMatch(markup, /hiddenAction/);
+ assert.doesNotMatch(markup, /role="menubar"|role="menuitem"/);
+ document.body.removeChild(bar);
+});
+
+test('composite preserves declarative bars, generates only missing effective roles, and shares one generation', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const composite = new CausewayMenubarsElement();
+ composite.client = new CausewayGraphQLClient({executor});
+ let fetchCount = 0;
+ composite.fetchImpl = async () => {
+ fetchCount += 1;
+ return xmlResponse(ONE_SERVICE_XML);
+ };
+ const declarativePrimary = document.createElement(CausewayElementName.MENUBAR_PRIMARY);
+ declarativePrimary.setAttribute('data-authored', 'true');
+ composite.appendChild(declarativePrimary);
+ document.body.appendChild(composite);
+ await waitFor(() => [MenuBarsStatus.READY, MenuBarsStatus.PARTIAL_ERROR].includes(composite.context?.state.status));
+
+ const bars = composite.childNodes.filter(child => [
+ CausewayElementName.MENUBAR_PRIMARY,
+ CausewayElementName.MENUBAR_SECONDARY,
+ CausewayElementName.MENUBAR_TERTIARY
+ ].includes(child.localName));
+ assert.equal(bars.length, 3);
+ assert.equal(bars.filter(child => child.localName === CausewayElementName.MENUBAR_PRIMARY).length, 1);
+ assert.equal(bars.find(child => child.localName === CausewayElementName.MENUBAR_PRIMARY), declarativePrimary);
+ assert.equal(fetchCount, 1);
+ assert.equal(executor.applicationCalls.length, 1);
+ assert.equal(executor.serviceCalls.filter(call => call.operationName === 'CausewayReadServiceActionStates').length, 1);
+ assert.ok(composite.childNodes.some(child => child.localName === CausewayElementName.INTERACTION_CONTROLLER));
+ document.body.removeChild(composite);
+});
+
+test('standalone bar owns a private generation and generated interaction controller', async () => {
+ const executor = createMenuGraphQLExecutor();
+ const bar = new CausewayMenubarPrimaryElement();
+ bar.client = new CausewayGraphQLClient({executor});
+ let fetchCount = 0;
+ bar.fetchImpl = async () => {
+ fetchCount += 1;
+ return xmlResponse(ONE_SERVICE_XML);
+ };
+ document.body.appendChild(bar);
+ await waitFor(() => bar.getAttribute('data-menu-state') === MenuBarsStatus.READY);
+
+ assert.equal(fetchCount, 1);
+ assert.ok(bar.childNodes.some(child => child.localName === CausewayElementName.INTERACTION_CONTROLLER));
+ assert.match(renderMarkup(bar), /Welcome Message/);
+ document.body.removeChild(bar);
+});
+
+test('standard interaction result adds public service target detail', async () => {
+ const controller = new CausewayInteractionControllerElement();
+ const source = document.createElement('button');
+ source.focus = () => {};
+ source.matches = () => true;
+ const context = {
+ identity: null,
+ interactionTarget: {kind: 'service', logicalTypeName: 'causeway.webcomponents.sample.SampleMenu'},
+ async prepareAction() {
+ return {status: 'success', data: {parameters: []}, errors: []};
+ },
+ async invokeAction() {
+ return {status: 'success', data: {kind: 'scalar', value: 'Welcome!'}, errors: []};
+ }
+ };
+ let detail;
+ controller.addEventListener(CausewaySemanticEvent.ACTION_RESULT, event => detail = event.detail);
+ document.body.appendChild(controller);
+ await controller.beginAction('welcomeMessage', context, source);
+ await new Promise(resolve => queueMicrotask(resolve));
+
+ assert.equal(detail.identity, null);
+ assert.deepEqual(detail.target, {kind: 'service', logicalTypeName: 'causeway.webcomponents.sample.SampleMenu'});
+ assert.equal(detail.serviceLogicalTypeName, 'causeway.webcomponents.sample.SampleMenu');
+ assert.equal(detail.result.value, 'Welcome!');
+ document.body.removeChild(controller);
+});
+
+function fakeMenuContext(plan) {
+ const state = Object.freeze({
+ status: MenuBarsStatus.READY,
+ generation: 1,
+ plan,
+ diagnostics: Object.freeze([]),
+ error: null,
+ resource: null
+ });
+ return {
+ state,
+ subscribe(listener) {
+ listener(state);
+ return () => {};
+ },
+ serviceContext(logicalTypeName) {
+ return {logicalTypeName};
+ },
+ refresh: async () => state
+ };
+}
+
+function renderMarkup(bar) {
+ return bar.childNodes.find(child => child.getAttribute?.('data-causeway-menubar-render') === 'true')?.innerHTML ?? '';
+}
+
+function xmlResponse(text) {
+ return {
+ ok: true,
+ status: 200,
+ headers: {get: name => name === 'content-type' ? 'application/xml' : null},
+ text: async () => text
+ };
+}
diff --git a/viewers/webcomponents/sample-html/README.adoc b/viewers/webcomponents/sample-html/README.adoc
index d67dc6cc3ad..a742cdefd21 100644
--- a/viewers/webcomponents/sample-html/README.adoc
+++ b/viewers/webcomponents/sample-html/README.adoc
@@ -2,7 +2,7 @@
:Notice: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at. http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or ag [...]
-The `sample-html` module is a runnable Causeway Spring Boot application and illustrative reference showcase for framework-neutral composite, read-only, and interaction web components against a real rich GraphQL endpoint.
+The `sample-html` module is a runnable Causeway Spring Boot application and illustrative reference showcase for framework-neutral application-menu, composite-object, read-only, and interaction web components against a real rich GraphQL endpoint.
Its frontend uses a page-specific responsive application theme built with vanilla HTML, native custom elements, ECMAScript modules, and plain CSS.
It has no npm build, HTMX, Playwright, or frontend-framework dependency.
@@ -10,7 +10,8 @@ It has no npm build, HTMX, Playwright, or frontend-framework dependency.
The application uses the Causeway GraphQL viewer, Causeway JPA persistence with EclipseLink, an in-memory H2 database, security bypass for local verification, and the packaged web-component foundation artifact.
The sample HTML, foundation modules, and `/graphql` endpoint are served from the same origin.
-The sample explicitly enables direct structural metadata so `<causeway-object>` can dereference the authorized effective-grid resource; applications that keep this policy forbidden receive canonical fallback composition instead.
+The sample explicitly enables direct structural metadata so `<causeway-menubars>` can dereference the authorized effective application menu and `<causeway-object>` can dereference the authorized effective grid.
+Applications that keep this policy forbidden receive bounded unavailable menu state and canonical object fallback composition without resource bodies.
The module is an executable integration fixture and is skipped during Maven install and deployment.
@@ -139,6 +140,38 @@ The root identity and expected values are:
|`clearNotes`, invoked through a top-level mutation and followed by context refresh
|===
+The deterministic `causeway.webcomponents.sample.SampleMenu` service contributes effective actions across all three application bars:
+
+[cols="1,2"]
+|===
+|Action |Contract
+
+|`welcomeMessage`
+|Safe parameterless scalar result
+
+|`greet(name)`
+|Safe parameterized scalar result with default `Ada`, choices `Ada`, `Grace`, and `Linus`, and mandatory-name validation
+
+|`findSample`
+|Safe object result
+
+|`findRelatedObjects`
+|Safe object-collection result
+
+|`statusMessage(status)`
+|Safe enum-parameter scalar result with default `ACTIVE`
+
+|`clearSampleNotes`
+|Void mutating result through the top-level mutation
+
+|`disabledAction`
+|Visible and disabled because `Available to administrators only.`
+
+|`hiddenAction`
+|Hidden and omitted from the authorized effective menu
+|===
+
+The explicit menu layout also contains one malformed action reference so the application entry reports bounded `INVALID_ACTION_REFERENCE` while preserving valid bars and actions.
The populated `relatedObjects` collection contains these deterministic rows:
[cols="1,1,2,1"]
@@ -162,7 +195,9 @@ The `_meta.version` values are supplied through the JPA `HasVersion` contract.
== Expected page and interaction states
-The reference page groups semantic content into labelled object-summary, property, action, collection, live-diagnostic, and automatically composed object cards.
+The reference page groups semantic content into an application-menu shell and labelled object-summary, property, action, collection, live-diagnostic, and automatically composed object cards.
+The menu shell renders one shared `<causeway-menubars>` generation plus independently loading primary, secondary, and tertiary examples.
+The shared composite reaches bounded `partial-error` because the fixture deliberately includes one invalid reference, while all three valid bars, hidden filtering, disabled reason, prompts, and typed service results remain available.
The automatically composed card uses only an independent `<causeway-object-context>`, `<causeway-object editable>`, and the standard interaction controller.
It retrieves the authorized effective fallback grid, discovers members through targeted schema introspection, and generates the established semantic children without page-authored member structure.
The object header displays `Framework-neutral components [WC-001]` and the root bookmark.
@@ -197,6 +232,24 @@ The following selectors are intended as public test hooks for browser automation
|`[data-testid="graphql-client"]`
|GraphQL client provider
+|`[data-testid="section-menubars"]`
+|Application-menu showcase
+
+|`[data-testid="menubars-composite"]`
+|Shared application-menu coordinator; wait for `data-menu-state="ready"` or bounded `partial-error`
+
+|`[data-testid="menubar-primary-standalone"]`
+|Independent primary bar
+
+|`[data-testid="menubar-secondary-standalone"]`
+|Independent secondary bar
+
+|`[data-testid="menubar-tertiary-standalone"]`
+|Independent tertiary bar
+
+|`[data-testid="menubars-state"]`
+|Live composite menu generation state
+
|`[data-testid="sample-object"]`
|Object context for the deterministic root entity
@@ -338,6 +391,12 @@ Playwright itself is intentionally not introduced by this slice.
== Manual event verification
+Open the composite `Samples` menu and select `Personal Greeting`.
+The standard prompt initializes the `name` choice editor from service-action defaults, validates the pending value, and reports a scalar result with service target `causeway.webcomponents.sample.SampleMenu`.
+Select `Find Sample` and `Related Objects` to observe object and collection results without automatic navigation.
+Select `Clear Sample Notes` in the tertiary bar to observe top-level mutation placement, void result, and menu-generation refresh.
+The disabled action remains represented with its reason, while the hidden and malformed actions remain absent.
+
Select the object link rendered inside `property-related-object`.
The page prevents no routing, but the plain application listener reports `navigate:causeway.webcomponents.sample.SampleRelatedObject:s_related-1` through `sample-event`.
@@ -351,6 +410,10 @@ After the populated collection loads, `collection-related-count` reports `2 rows
. Start the sample with `mvn -f viewers/webcomponents/pom.xml -Prun-sample-html` and wait for `Started SampleHtmlApplication`.
. Probe `/graphql` directly as shown below, open `/sample-html/index.html`, and wait for `sample-app[data-state="ready"]`.
+. Confirm the composite application menus reach bounded `partial-error`, render labelled primary, secondary, and tertiary navigation from one menu-resource request, preserve valid entries, omit hidden and malformed entries, and retain the disabled service action with its reason.
+. Confirm each standalone bar independently loads only its primary, secondary, or tertiary role and owns its interaction controller.
+. Open menu disclosures with pointer, Enter, and Space; use arrows, Home, and End among peers; close with Escape; and confirm focus restoration, sibling closing, and ordinary Tab order.
+. Invoke the greeting, object, collection, and void service actions and confirm defaults, choices, validation, typed service-target events, safe query or top-level mutation placement, and no automatic navigation.
. Confirm the explicit-composition cards and automatically composed object card, interaction controls, static coverage guide, existing read-only values, deliberate disabled reasons, hidden states, two collection rows, row count, and empty collection.
. Confirm `composite-object` reaches `data-layout-state="ready"`, contains generated header, property, action, collection, group, and tab regions, and exposes no hidden semantic content.
. Select `Edit Summary`, cancel, and confirm that no mutation ran and the original value remains.
@@ -368,7 +431,14 @@ After the populated collection loads, `collection-related-count` reports `2 rows
=== Recorded interaction verification
On 2026-08-16 the exact Maven profile and direct GraphQL probe returned HTTP 200, and the page reached `sample-app[data-state="ready"]` with the stable read-only content intact.
-On 2026-08-18 the composite reached `data-layout-state="ready"` from one authorized effective-grid request with no diagnostics and generated one header, eight visible properties, five visible actions, and two collections while hidden members remained empty hosts.
+On 2026-08-18 the menu composite reached bounded `data-menu-state="partial-error"` from one authorized effective-menu request, preserved primary, secondary, and tertiary bars, exposed the disabled reason, omitted hidden and malformed entries, and published only the generic `INVALID_ACTION_REFERENCE` diagnostic.
+The three standalone examples each rendered only their own role with a private interaction controller and menu-resource generation.
+Pointer and keyboard checks covered native disclosure toggling, sibling closing, Tab order, arrow, Home, End, Enter, Space, and Escape focus restoration; narrow dark and wide light layouts retained visible focus and had no overflow.
+Service interaction checks covered default and choice recomputation, prompt cancellation, scalar, object, collection, and void outcomes, explicit service targets, top-level mutation placement, and generation refresh without automatic navigation.
+A synthetic capability-absent component reached bounded `unsupported` state without an application read or resource fetch.
+Cold concurrent startup completed without GraphQL schema races, and every browser GraphQL request succeeded without console errors.
+Desktop light-mode and mobile dark-mode Lighthouse navigation audits scored 100 for Accessibility, Best Practices, and SEO.
+On 2026-08-18 the object composite reached `data-layout-state="ready"` from one authorized effective-grid request with no diagnostics and generated one header, eight visible properties, five visible actions, and two collections while hidden members remained empty hosts.
Pointer and keyboard checks covered composite property validation and cancellation, action-prompt opening and Escape focus restoration, ArrowRight tab selection, and bounded collection-window activation with two hydrated rows.
A browser-injected partial-error context preserved a successful sibling property and announced the bounded failed member.
Forced fallback produced canonical 12/4/8 spans without another grid request, and wide light-mode and narrow dark-mode layouts had no horizontal overflow or console errors.
@@ -391,6 +461,9 @@ mvn -f viewers/webcomponents/pom.xml -pl sample-html -am test
The sample integration test starts the application on a random port and verifies:
* `/sample-html/index.html` is delivered with all documented selectors.
+* The rich application entry returns an authorized effective menu descriptor and bounded invalid-reference issue.
+* `/graphql/application/menu-bars` returns primary, secondary, and tertiary XML using `private, no-store`, retains the disabled action, and omits hidden, malformed, and sensitive content.
+* Real service wrappers expose current hidden and disabled state, defaults, choices, validation, and safe scalar invocation, while the void service mutation uses the existing top-level field.
* `/causeway-webcomponents/index.mjs` is delivered from the foundation dependency and exports the object element and safe layout planner.
* The authorized effective-grid resource returns XML with `private, no-store` and never contains the hidden value.
* The page includes a context-plus-`<causeway-object>` composition with stable acceptance selectors.
@@ -402,7 +475,7 @@ The sample integration test starts the application on a random port and verifies
* A real secondary-shaped collection operation returns deterministic row metadata and selected `name` and `code` wrappers.
* The empty collection returns zero rows.
-The foundation's dependency-free Node tests verify safe grid parsing, deterministic member allocation, canonical fallback, composite lifecycle and diagnostics, context-owned resource retrieval, renderer and editor selection, semantic commands, property state transitions, action prompts and results, accessibility markup, semantic events, lazy collection state, partial errors, cancellation, mutation serialization, stale-response rejection, row hydration reuse, and delta reads without requi [...]
+The foundation's dependency-free Node tests verify targeted application and service discovery, shared safe menu and grid parsing, grouped service state, menu generations and refresh, declarative and standalone bars, service prompts and typed targets, deterministic member allocation, canonical object fallback, composite lifecycle and diagnostics, context-owned resource retrieval, renderer and editor selection, property state transitions, object prompts and results, accessibility markup, s [...]
== GraphQL readiness probe and troubleshooting
diff --git a/viewers/webcomponents/sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/domain/SampleMenu.java b/viewers/webcomponents/sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/domain/SampleMenu.java
new file mode 100644
index 00000000000..71677c3dbd1
--- /dev/null
+++ b/viewers/webcomponents/sample-html/src/main/java/org/apache/causeway/viewer/webcomponents/sample/html/domain/SampleMenu.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.html.domain;
+
+import java.util.List;
+
+import jakarta.inject.Named;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.DomainService;
+import org.apache.causeway.applib.annotation.DomainServiceLayout;
+import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Named(SampleMenu.LOGICAL_TYPE_NAME)
+@DomainService
+@DomainServiceLayout(named = "Web Components", menuBar = DomainServiceLayout.MenuBar.PRIMARY)
+public class SampleMenu {
+
+ public static final String LOGICAL_TYPE_NAME = "causeway.webcomponents.sample.SampleMenu";
+ public static final String DISABLED_REASON = "Available to administrators only.";
+ public static final String GREETING_VALIDATION_REASON = "A name is required.";
+
+ private final SampleObjectRepository repository;
+
+ public SampleMenu(final SampleObjectRepository repository) {
+ this.repository = repository;
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Welcome Message", describedAs = "Returns a deterministic scalar result.")
+ public String welcomeMessage() {
+ return "Welcome to Causeway web components.";
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Personal Greeting", describedAs = "Demonstrates a parameterized service action.")
+ public String greet(@Parameter(maxLength = 40) final String name) {
+ return "Hello, " + name + "!";
+ }
+
+ public String default0Greet() {
+ return "Ada";
+ }
+
+ public List<String> choices0Greet() {
+ return List.of("Ada", "Grace", "Linus");
+ }
+
+ public String validate0Greet(final String name) {
+ return name == null || name.isBlank() ? GREETING_VALIDATION_REASON : null;
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Find Sample", describedAs = "Returns the deterministic sample object.")
+ public SampleObject findSample() {
+ return repository.findById(SampleObject.SAMPLE_ID);
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Related Objects", describedAs = "Returns a deterministic object collection.")
+ public List<SampleRelatedObject> findRelatedObjects() {
+ return repository.findById(SampleObject.SAMPLE_ID).getRelatedObjects();
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Status Message", describedAs = "Returns an enum-derived scalar result.")
+ public String statusMessage(final SampleStatus status) {
+ return "Selected status: " + status.name();
+ }
+
+ public SampleStatus default0StatusMessage() {
+ return SampleStatus.ACTIVE;
+ }
+
+ @Action(semantics = SemanticsOf.NON_IDEMPOTENT)
+ @ActionLayout(named = "Clear Sample Notes", describedAs = "Demonstrates a void mutating service action.")
+ public void clearSampleNotes() {
+ repository.findById(SampleObject.SAMPLE_ID).setNotes(null);
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Disabled Action")
+ public String disabledAction() {
+ return "Unavailable";
+ }
+
+ public String disableDisabledAction() {
+ return DISABLED_REASON;
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ @ActionLayout(named = "Hidden Action")
+ public String hiddenAction() {
+ return "Hidden";
+ }
+
+ public boolean hideHiddenAction() {
+ return true;
+ }
+}
diff --git a/viewers/webcomponents/sample-html/src/main/resources/menubars.layout.xml b/viewers/webcomponents/sample-html/src/main/resources/menubars.layout.xml
new file mode 100644
index 00000000000..3ebf3791f90
--- /dev/null
+++ b/viewers/webcomponents/sample-html/src/main/resources/menubars.layout.xml
@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<mb:menuBars
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/menubars/bootstrap3 https://causeway.apache.org/applib/layout/menubars/bootstrap3/menubars.xsd https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:mb="https://causeway.apache.org/applib/layout/menubars/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <mb:primary>
+ <mb:menu cssClassFa="fa-cubes">
+ <mb:named>Samples</mb:named>
+ <mb:section>
+ <mb:named>Explore</mb:named>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="welcomeMessage">
+ <cpt:named>Welcome Message</cpt:named>
+ </mb:serviceAction>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="greet">
+ <cpt:named>Personal Greeting</cpt:named>
+ </mb:serviceAction>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="findSample"/>
+ </mb:section>
+ </mb:menu>
+ <mb:menu unreferencedActions="true">
+ <mb:named>System</mb:named>
+ </mb:menu>
+ </mb:primary>
+ <mb:secondary>
+ <mb:menu cssClassFa="fa-list">
+ <mb:named>Data</mb:named>
+ <mb:section>
+ <mb:named>Browse</mb:named>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="findRelatedObjects"/>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="statusMessage"/>
+ </mb:section>
+ </mb:menu>
+ </mb:secondary>
+ <mb:tertiary>
+ <mb:menu cssClassFa="fa-wrench">
+ <mb:named>Administration</mb:named>
+ <mb:section>
+ <mb:named>Maintenance</mb:named>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="clearSampleNotes"/>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="disabledAction"/>
+ </mb:section>
+ <mb:section>
+ <mb:named>Filtered Entries</mb:named>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="hiddenAction"/>
+ <mb:serviceAction objectType="causeway.webcomponents.sample.SampleMenu" id="missingAction"/>
+ </mb:section>
+ </mb:menu>
+ </mb:tertiary>
+</mb:menuBars>
diff --git a/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html b/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
index 101a1aef131..e993c2bc973 100644
--- a/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
+++ b/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
@@ -556,7 +556,7 @@
<div>
<p class="sample-eyebrow">Apache Causeway · Vanilla HTML</p>
<h1>Component interaction showcase</h1>
- <p class="sample-hero-copy">A deterministic domain page for read-only rendering, property editing, action prompts, and semantic results using packaged native custom elements and the same-origin rich GraphQL endpoint.</p>
+ <p class="sample-hero-copy">A deterministic application shell and domain page for semantic menu bars, object rendering, property editing, action prompts, and typed results using packaged native custom elements and the same-origin rich GraphQL endpoint.</p>
</div>
<div class="sample-readiness" aria-label="Object context state">
<output data-testid="sample-state" aria-live="polite">booting</output>
@@ -564,6 +564,21 @@ <h1>Component interaction showcase</h1>
</header>
<causeway-graphql-client endpoint="/graphql" data-testid="graphql-client">
+ <section class="sample-card" data-testid="section-menubars" aria-labelledby="sample-menubars-heading">
+ <div class="sample-section-heading">
+ <p class="sample-section-kicker">Application entry point</p>
+ <h2 id="sample-menubars-heading">Effective semantic menu bars</h2>
+ <p>The composite shares one authorized effective-menu generation across all present bars; the standalone examples exercise the same public bar components independently.</p>
+ </div>
+ <causeway-menubars data-testid="menubars-composite"></causeway-menubars>
+ <details class="sample-standalone-menus">
+ <summary>Standalone semantic bar examples</summary>
+ <causeway-menubar-primary data-testid="menubar-primary-standalone"></causeway-menubar-primary>
+ <causeway-menubar-secondary data-testid="menubar-secondary-standalone"></causeway-menubar-secondary>
+ <causeway-menubar-tertiary data-testid="menubar-tertiary-standalone"></causeway-menubar-tertiary>
+ </details>
+ </section>
+
<causeway-object-context
logical-type="causeway.webcomponents.sample.SampleObject"
object-id="s_sample-1"
@@ -648,6 +663,10 @@ <h2 id="sample-events-heading">Live diagnostics</h2>
<span class="sample-diagnostic-label">Related collection</span>
<output data-testid="collection-related-count" aria-live="polite">Waiting for rows</output>
</div>
+ <div class="sample-diagnostic">
+ <span class="sample-diagnostic-label">Application menu</span>
+ <output data-testid="menubars-state" aria-live="polite">Waiting for menus</output>
+ </div>
</div>
</section>
@@ -700,6 +719,7 @@ <h2 id="sample-composite-heading">Complete object from context plus component</h
const eventOutput = document.querySelector('[data-testid="sample-event"]');
const interactionOutput = document.querySelector('[data-testid="sample-interaction-event"]');
const collectionCountOutput = document.querySelector('[data-testid="collection-related-count"]');
+ const menuStateOutput = document.querySelector('[data-testid="menubars-state"]');
const report = (output, value) => {
output.value = value;
@@ -733,12 +753,19 @@ <h2 id="sample-composite-heading">Complete object from context plus component</h
}
});
+ application.addEventListener('causeway-menubars-state-change', event => {
+ if (event.detail.element?.getAttribute?.('data-testid') === 'menubars-composite') {
+ report(menuStateOutput, event.detail.state.status);
+ }
+ });
+
application.addEventListener('causeway-navigation-request', event => {
report(eventOutput, `navigate:${event.detail.target.logicalTypeName}:${event.detail.target.id}`);
});
application.addEventListener('causeway-action-request', event => {
- report(eventOutput, `action:${event.detail.actionId}`);
+ const service = event.detail.serviceLogicalTypeName ? `:${event.detail.serviceLogicalTypeName}` : '';
+ report(eventOutput, `action:${event.detail.actionId}${service}`);
});
application.addEventListener('causeway-property-interaction-state-change', event => {
@@ -756,7 +783,8 @@ <h2 id="sample-composite-heading">Complete object from context plus component</h
application.addEventListener('causeway-action-result', event => {
const result = event.detail.result;
const value = result?.kind === 'scalar' ? `:${result.value}` : '';
- report(interactionOutput, `result:${event.detail.actionId}:${result?.kind ?? 'unknown'}${value}`);
+ const target = event.detail.target?.kind === 'service' ? `:${event.detail.target.logicalTypeName}` : '';
+ report(interactionOutput, `result:${event.detail.actionId}:${result?.kind ?? 'unknown'}${value}${target}`);
});
const components = await import('/causeway-webcomponents/index.mjs');
diff --git a/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java b/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
index 2868da83197..3392b877dcf 100644
--- a/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
@@ -23,22 +23,30 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.stream.IntStream;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import static org.assertj.core.api.Assertions.assertThat;
+import org.apache.causeway.viewer.webcomponents.sample.html.domain.SampleMenu;
import org.apache.causeway.viewer.webcomponents.sample.html.domain.SampleObject;
import org.apache.causeway.viewer.webcomponents.sample.html.domain.SampleRelatedObject;
@SpringBootTest(
classes = SampleHtmlApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class SampleHtmlApplication_IntegTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -46,6 +54,27 @@ class SampleHtmlApplication_IntegTest {
@LocalServerPort
private int port;
+ @Test
+ @Order(1)
+ void initializesGraphQLSourceOnceForConcurrentFirstRequests() throws Exception {
+ final var start = new CountDownLatch(1);
+ final var executor = Executors.newFixedThreadPool(6);
+ try {
+ final var requests = IntStream.range(0, 6)
+ .mapToObj(index -> executor.submit(() -> {
+ start.await();
+ return graphQL("query CausewayConcurrentStartup" + index + " { __typename }");
+ }))
+ .toList();
+ start.countDown();
+ for (var request : requests) {
+ assertNoGraphQLErrors(request.get());
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
@Test
void servesSamplePageAndPackagedFoundationModule() throws Exception {
final var page = get("/sample-html/index.html");
@@ -55,6 +84,12 @@ void servesSamplePageAndPackagedFoundationModule() throws Exception {
assertThat(page.body())
.contains("data-testid=\"sample-app\"")
.contains("data-testid=\"sample-object\"")
+ .contains("data-testid=\"section-menubars\"")
+ .contains("data-testid=\"menubars-composite\"")
+ .contains("data-testid=\"menubar-primary-standalone\"")
+ .contains("data-testid=\"menubar-secondary-standalone\"")
+ .contains("data-testid=\"menubar-tertiary-standalone\"")
+ .contains("data-testid=\"menubars-state\"")
.contains("data-testid=\"object-header\"")
.contains("data-testid=\"property-name\"")
.contains("data-testid=\"property-code\"")
@@ -104,6 +139,10 @@ void servesSamplePageAndPackagedFoundationModule() throws Exception {
.contains("./editor-registry.mjs")
.contains("./interaction-controller-element.mjs")
.contains("./interaction-operations.mjs")
+ .contains("./menu-context-controller.mjs")
+ .contains("./menu-layout.mjs")
+ .contains("./menubar-element.mjs")
+ .contains("./menubars-element.mjs")
.contains("./object-element.mjs")
.contains("./object-layout.mjs")
.contains("./value-renderers.mjs");
@@ -125,6 +164,106 @@ void exposesAuthorizedEffectiveGridForCompositeObject() throws Exception {
.doesNotContain(SampleObject.SAMPLE_SECRET);
}
+ @Test
+ void exposesAuthorizedEffectiveMenuBarsAndServiceActions() throws Exception {
+ final var application = graphQL("""
+ query CausewaySampleApplicationEntry {
+ rich {
+ application {
+ menuBars {
+ href
+ mediaType
+ formatVersion
+ generation
+ cacheControl
+ }
+ issues { code message }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(application);
+ assertThat(application.at("/data/rich/application/menuBars/href").asString())
+ .isEqualTo("/graphql/application/menu-bars");
+ assertThat(application.at("/data/rich/application/menuBars/cacheControl").asString())
+ .isEqualTo("private, no-store");
+ assertThat(application.at("/data/rich/application/issues").toString())
+ .contains("INVALID_ACTION_REFERENCE")
+ .doesNotContain("missingAction");
+
+ final var menuBars = get(application.at("/data/rich/application/menuBars/href").asString());
+ assertThat(menuBars.statusCode()).isEqualTo(200);
+ assertThat(menuBars.headers().firstValue("content-type").orElse(""))
+ .contains("application/xml");
+ assertThat(menuBars.headers().firstValue("cache-control").orElse(""))
+ .isEqualTo("private, no-store");
+ assertThat(menuBars.body())
+ .contains("<mb:primary")
+ .contains("<mb:secondary")
+ .contains("<mb:tertiary")
+ .contains("objectType=\"" + SampleMenu.LOGICAL_TYPE_NAME + "\"")
+ .contains("id=\"welcomeMessage\"")
+ .contains("id=\"greet\"")
+ .contains("id=\"clearSampleNotes\"")
+ .contains("id=\"disabledAction\"")
+ .doesNotContain("hiddenAction")
+ .doesNotContain("missingAction")
+ .doesNotContain(SampleObject.SAMPLE_SECRET);
+
+ final var service = graphQL("""
+ query CausewaySampleServiceActions {
+ rich {
+ causeway_webcomponents_sample_SampleMenu {
+ welcomeMessage {
+ hidden
+ disabled
+ invoke { results }
+ }
+ greet {
+ hidden
+ disabled
+ params {
+ name {
+ hidden
+ disabled
+ default
+ choices
+ datatype
+ }
+ }
+ validate(name: "")
+ }
+ disabledAction { hidden disabled }
+ hiddenAction { hidden }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(service);
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/welcomeMessage/invoke/results").asString())
+ .isEqualTo("Welcome to Causeway web components.");
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/greet/params/name/default").asString())
+ .isEqualTo("Ada");
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/greet/params/name/choices").toString())
+ .contains("Ada", "Grace", "Linus");
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/greet/validate").asString())
+ .contains(SampleMenu.GREETING_VALIDATION_REASON)
+ .contains("'Name' is mandatory");
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/disabledAction/disabled").asString())
+ .isEqualTo(SampleMenu.DISABLED_REASON);
+ assertThat(service.at("/data/rich/causeway_webcomponents_sample_SampleMenu/hiddenAction/hidden").asBoolean())
+ .isTrue();
+
+ final var mutation = graphQL("""
+ mutation CausewaySampleClearNotes {
+ causeway_webcomponents_sample_SampleMenu__clearSampleNotes
+ }
+ """);
+ assertNoGraphQLErrors(mutation);
+ assertThat(mutation.at("/data/causeway_webcomponents_sample_SampleMenu__clearSampleNotes").isNull())
+ .isTrue();
+ }
+
@Test
void exposesTargetedRichSchemaIntrospectionAndDeterministicObject() throws Exception {
final var introspection = graphQL("""
(causeway) 05/05: adds generic HTMX web component viewer
Posted by da...@apache.org.
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 fd1ce43f7cc97763178ccaaaa70a03a87bf3d10e
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Wed Aug 19 06:18:19 2026 +0100
adds generic HTMX web component viewer
---
bom/pom.xml | 7 +
.../add-generic-htmx-web-component-viewer/tasks.md | 66 +--
viewers/webcomponents/README.adoc | 42 +-
.../webcomponents/canonical-route-fixtures.yaml | 30 +
viewers/webcomponents/foundation/README.adoc | 5 +-
viewers/webcomponents/foundation/USAGE.adoc | 14 +-
.../foundation/src/application-operations.mjs | 32 +
.../foundation/src/collection-element.mjs | 83 ++-
.../{component-styles.mjs => component-styles.css} | 19 +-
.../foundation/src/component-styles.mjs | 2 +-
.../foundation/src/graphql-client.mjs | 16 +-
.../src/interaction-controller-element.mjs | 47 +-
.../webcomponents/foundation/src/introspection.mjs | 8 +
.../foundation/src/object-context-controller.mjs | 5 +-
viewers/webcomponents/foundation/src/theme.css | 654 +++++++++++++++++++++
.../test/application-menu-client.test.mjs | 5 +-
.../foundation/test/collection.test.mjs | 6 +-
.../foundation/test/component-styles.test.mjs | 30 +
.../test/fixtures/menu-graphql-fixture.mjs | 24 +-
.../foundation/test/interaction.test.mjs | 3 +
viewers/webcomponents/htmx/README.adoc | 96 +++
.../webcomponents/{sample-html => htmx}/pom.xml | 71 ++-
.../CausewayModuleViewerWebcomponentsHtmx.java | 50 ++
.../viewer/webcomponents/htmx/HtmxObjectRoute.java | 30 +
.../htmx/HtmxPageFragmentFactory.java | 30 +
.../htmx/HtmxPageFragmentRegistry.java | 51 ++
.../webcomponents/htmx/HtmxPageRenderer.java | 203 +++++++
.../viewer/webcomponents/htmx/HtmxRouteCodec.java | 191 ++++++
.../webcomponents/htmx/HtmxViewerController.java | 105 ++++
.../webcomponents/htmx/HtmxViewerProperties.java | 80 +++
.../htmx/InvalidHtmxRouteException.java | 26 +
.../resources/causeway-htmx/causeway-htmx.css | 252 ++++++++
.../resources/causeway-htmx/causeway-htmx.mjs | 304 ++++++++++
.../resources/causeway-htmx/route-policy.mjs | 68 +++
.../htmx/HtmxPageFragmentRegistryTest.java | 64 ++
.../webcomponents/htmx/HtmxRouteCodecTest.java | 87 +++
.../htmx/HtmxViewerControllerTest.java | 126 ++++
.../webcomponents/htmx/test/route-policy.test.mjs | 64 ++
viewers/webcomponents/pom.xml | 16 +
viewers/webcomponents/sample-html/README.adoc | 6 +-
viewers/webcomponents/sample-html/pom.xml | 4 +-
.../main/resources/static/sample-html/index.html | 528 ++++-------------
.../html/SampleHtmlApplication_IntegTest.java | 14 +-
.../sample-htmx-petclinic/PROVENANCE.adoc | 20 +
.../sample-htmx-petclinic/README.adoc | 66 +++
.../{sample-html => sample-htmx-petclinic}/pom.xml | 23 +-
.../petclinic/PetClinicHomeFragmentFactory.java | 60 ++
.../htmx/petclinic/PetClinicHtmxApplication.java | 57 ++
.../htmx/petclinic/PetClinicLandingController.java | 31 +
.../sample/htmx/petclinic/domain/Pet.java | 143 +++++
.../domain/PetClinicDataConfiguration.java | 101 ++++
.../petclinic/domain/PetClinicDomainModule.java | 31 +
.../htmx/petclinic/domain/PetClinicHomePage.java | 68 +++
.../sample/htmx/petclinic/domain/PetOwner.java | 267 +++++++++
.../htmx/petclinic/domain/PetOwnerRepository.java | 68 +++
.../htmx/petclinic/domain/PetOwner_bookVisit.java | 88 +++
.../htmx/petclinic/domain/PetOwner_visits.java | 44 ++
.../sample/htmx/petclinic/domain/PetOwners.java | 79 +++
.../sample/htmx/petclinic/domain/PetSpecies.java | 28 +
.../sample/htmx/petclinic/domain/Visit.java | 135 +++++
.../htmx/petclinic/domain/VisitRepository.java | 58 ++
.../sample/htmx/petclinic/domain/Visits.java | 51 ++
.../src/main/resources/application.properties | 30 +
.../src/main/resources/menubars.layout.xml | 42 ++
.../sample/htmx/petclinic/domain/Pet.layout.xml | 32 +
.../PetClinicHomePage#futureVisits.columnOrder.txt | 4 +
.../PetClinicHomePage#petOwners.columnOrder.txt | 5 +
.../petclinic/domain/PetClinicHomePage.layout.xml | 31 +
.../petclinic/domain/PetOwner#pets.columnOrder.txt | 3 +
.../domain/PetOwner#visits.columnOrder.txt | 4 +
.../htmx/petclinic/domain/PetOwner.layout.xml | 69 +++
.../sample/htmx/petclinic/domain/Visit.layout.xml | 32 +
.../src/main/resources/static/css/application.css | 66 +++
.../PetClinicHtmxApplication_IntegTest.java | 235 ++++++++
74 files changed, 4986 insertions(+), 549 deletions(-)
diff --git a/bom/pom.xml b/bom/pom.xml
index 08087e9ca68..89f2ba26823 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -571,6 +571,13 @@ identified
<type>jar</type>
<scope>compile</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.causeway.viewer</groupId>
+ <artifactId>causeway-viewer-webcomponents-htmx</artifactId>
+ <version>${project.version}</version>
+ <type>jar</type>
+ <scope>compile</scope>
+ </dependency>
<dependency>
<groupId>org.apache.causeway.viewer</groupId>
diff --git a/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md b/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
index 3acb5581994..ca8400d4114 100644
--- a/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
+++ b/openspec/changes/add-generic-htmx-web-component-viewer/tasks.md
@@ -1,53 +1,53 @@
## 1. Evidence, Module, and Route Foundation
-- [ ] 1.1 Verify matrix entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01`, accepted P0/P1 prerequisites, current Wicket presentation hooks, and pinned Petclinic source `16a10608129ca9ce8ae04d21df1462f4d69ac018`.
-- [ ] 1.2 Establish optional `htmx` and `sample-htmx-petclinic` Maven modules, explicit module enablement, HTMX `2.0.6` WebJar packaging, browser assets, run profile, and integration-test wiring.
-- [ ] 1.3 Implement configurable `/htmx` base path and canonical object-route encoding and strict round-trip parsing with bounded invalid-route errors.
-- [ ] 1.4 Add codec and route tests for Unicode logical types, opaque identifiers, deployment paths, direct links, refresh, malformed escapes, separators, controls, dot segments, overlong values, absent objects, and access-denied outcomes.
+- [x] 1.1 Verify matrix entries `REF-VIEWER-01`, `REF-COMPONENT-01`, `REF-COMPONENT-02`, `REF-HOME-01`, and `REF-MENU-01`, accepted P0/P1 prerequisites, current Wicket presentation hooks, and pinned Petclinic source `16a10608129ca9ce8ae04d21df1462f4d69ac018`.
+- [x] 1.2 Establish optional `htmx` and `sample-htmx-petclinic` Maven modules, explicit module enablement, HTMX `2.0.6` WebJar packaging, browser assets, run profile, and integration-test wiring.
+- [x] 1.3 Implement configurable `/htmx` base path and canonical object-route encoding and strict round-trip parsing with bounded invalid-route errors.
+- [x] 1.4 Add codec and route tests for Unicode logical types, opaque identifiers, deployment paths, direct links, refresh, malformed escapes, separators, controls, dot segments, overlong values, absent objects, and access-denied outcomes.
## 2. Server Fragments and Stable Shell
-- [ ] 2.1 Implement complete-document and `HX-Request` fragment responses with canonical history instructions and no duplicate shell.
-- [ ] 2.2 Implement the exact-logical-type `HtmxPageFragmentFactory` registry with duplicate-registration failure and no metamodel or persistence exposure.
-- [ ] 2.3 Implement generic fallback using `<causeway-object>` beneath exactly one route-level object context.
-- [ ] 2.4 Implement the stable shell with one GraphQL client, `<causeway-menubars>`, branding, main route region, loading indicator, announcement region, result region, and CSP-compatible external assets.
-- [ ] 2.5 Test custom selection, generic fallback, safe escaping, requirement release, shared context, explicit low-level composition, full-page requests, HTMX requests, and duplicate registrations.
+- [x] 2.1 Implement complete-document and `HX-Request` fragment responses with canonical history instructions and no duplicate shell.
+- [x] 2.2 Implement the exact-logical-type `HtmxPageFragmentFactory` registry with duplicate-registration failure and no metamodel or persistence exposure.
+- [x] 2.3 Implement generic fallback using `<causeway-object>` beneath exactly one route-level object context.
+- [x] 2.4 Implement the stable shell with one GraphQL client, `<causeway-menubars>`, branding, main route region, loading indicator, announcement region, result region, and CSP-compatible external assets.
+- [x] 2.5 Test custom selection, generic fallback, safe escaping, requirement release, shared context, explicit low-level composition, full-page requests, HTMX requests, and duplicate registrations.
## 3. HTMX Navigation, History, Home, and Results
-- [ ] 3.1 Implement normal canonical anchors and an HTMX browser bridge using documented target, swap, push, replace, and lifecycle APIs.
-- [ ] 3.2 Translate object-link, object-action, service-action, and explicit navigation events into canonical route requests under replaceable policy.
-- [ ] 3.3 Implement direct-load, refresh, back, forward, history-cache-miss, loading, response-error, superseded-page, and focus behavior.
-- [ ] 3.4 Extend targeted application-entry discovery for the established optional object-home identity and implement bounded landing behavior for absent, hidden, invalid, unsupported, and partial home states.
-- [ ] 3.5 Implement replaceable scalar, object, collection, and void result handlers with object routing, accessible result presentation, and bounded current-context refresh.
-- [ ] 3.6 Verify HTMX owns only shell and fragment lifecycle while semantic components own GraphQL, layouts, domain interactions, menus, validation, mutations, and typed results.
+- [x] 3.1 Implement normal canonical anchors and an HTMX browser bridge using documented target, swap, push, replace, and lifecycle APIs.
+- [x] 3.2 Translate object-link, object-action, service-action, and explicit navigation events into canonical route requests under replaceable policy.
+- [x] 3.3 Implement direct-load, refresh, back, forward, history-cache-miss, loading, response-error, superseded-page, and focus behavior.
+- [x] 3.4 Extend targeted application-entry discovery for the established optional object-home identity and implement bounded landing behavior for absent, hidden, invalid, unsupported, and partial home states.
+- [x] 3.5 Implement replaceable scalar, object, collection, and void result handlers with object routing, accessible result presentation, and bounded current-context refresh.
+- [x] 3.6 Verify HTMX owns only shell and fragment lifecycle while semantic components own GraphQL, layouts, domain interactions, menus, validation, mutations, and typed results.
## 4. Petclinic Copy and Current-Causeway Port
-- [ ] 4.1 Record Apache Petclinic provenance, pinned commit, copied source inventory, license, intentional omissions, and current-API porting notes.
-- [ ] 4.2 Port Pet Owner, Pet, Visit, repositories, services, contributions, home page, fixtures, and deterministic sample data to current Jakarta/JPA and Causeway APIs.
-- [ ] 4.3 Port effective menu bars, object grids, collection columns, labels, icons, choices, defaults, validation, object actions, and service actions needed for cohesive viewer coverage.
-- [ ] 4.4 Configure GraphQL, structural resources, H2 persistence, security bypass, the HTMX module, and the current Wicket viewer over the same application state.
-- [ ] 4.5 Add one exact-logical-type custom Petclinic fragment that demonstrates router precedence and semantic low-level composition while all other types exercise generic fallback.
-- [ ] 4.6 Add integration tests for deterministic fixture identities, application entry, object home, effective menu/grid resources, Petclinic service and object interactions, canonical HTMX routes, and Wicket comparison availability.
+- [x] 4.1 Record Apache Petclinic provenance, pinned commit, copied source inventory, license, intentional omissions, and current-API porting notes.
+- [x] 4.2 Port Pet Owner, Pet, Visit, repositories, services, contributions, home page, fixtures, and deterministic sample data to current Jakarta/JPA and Causeway APIs.
+- [x] 4.3 Port effective menu bars, object grids, collection columns, labels, icons, choices, defaults, validation, object actions, and service actions needed for cohesive viewer coverage.
+- [x] 4.4 Configure GraphQL, structural resources, H2 persistence, security bypass, the HTMX module, and the current Wicket viewer over the same application state.
+- [x] 4.5 Add one exact-logical-type custom Petclinic fragment that demonstrates router precedence and semantic low-level composition while all other types exercise generic fallback.
+- [x] 4.6 Add integration tests for deterministic fixture identities, application entry, object home, effective menu/grid resources, Petclinic service and object interactions, canonical HTMX routes, and Wicket comparison availability.
## 5. Cohesive Wicket-Inspired Theme
-- [ ] 5.1 Define shared design tokens and optional theme assets for navigation, content width, typography, surfaces, borders, links, focus, status colors, spacing, radii, shadows, labels, tables, prompts, and responsive behavior.
-- [ ] 5.2 Style the HTMX shell and documented menubar, object header, action, grid, card, tab, property, collection, table, editor, prompt, result, loading, disabled, diagnostic, and error hooks without Bootstrap coupling.
-- [ ] 5.3 Implement accessible narrow navigation, stacked object regions, contained table scrolling, touch targets, visible focus, reduced motion, forced colors, and light/dark palettes without horizontal page overflow.
-- [ ] 5.4 Compare live Wicket and HTMX Petclinic home and object pages and correct hierarchy, spacing, alignment, action placement, labels, cards, tabs, collections, prompts, and responsive discrepancies.
+- [x] 5.1 Define shared design tokens and optional theme assets for navigation, content width, typography, surfaces, borders, links, focus, status colors, spacing, radii, shadows, labels, tables, prompts, and responsive behavior.
+- [x] 5.2 Style the HTMX shell and documented menubar, object header, action, grid, card, tab, property, collection, table, editor, prompt, result, loading, disabled, diagnostic, and error hooks without Bootstrap coupling.
+- [x] 5.3 Implement accessible narrow navigation, stacked object regions, contained table scrolling, touch targets, visible focus, reduced motion, forced colors, and light/dark palettes without horizontal page overflow.
+- [x] 5.4 Compare live Wicket and HTMX Petclinic home and object pages and correct hierarchy, spacing, alignment, action placement, labels, cards, tabs, collections, prompts, and responsive discrepancies.
## 6. Vanilla Sample Styling Repair
-- [ ] 6.1 Replace the ad hoc `sample-html` page styling with the cohesive tokens, contained shell, consistent hierarchy, cards, spacing, grids, and responsive rules while retaining vanilla HTML and ESM.
-- [ ] 6.2 Correct component-level spacing, alignment, disclosure, overflow, form, table, prompt, result, focus, light/dark, and narrow-layout defects exposed by the vanilla fixture.
-- [ ] 6.3 Preserve `/sample-html/index.html`, `/graphql`, bookmark `s_sample-1`, test selectors, readiness semantics, custom-element ownership, and all existing browser interactions.
-- [ ] 6.4 Extend automated and browser regression coverage for visual structure, control overlap, target sizes, table containment, modal bounds, responsive reflow, focus visibility, contrast, and zero console errors.
+- [x] 6.1 Replace the ad hoc `sample-html` page styling with the cohesive tokens, contained shell, consistent hierarchy, cards, spacing, grids, and responsive rules while retaining vanilla HTML and ESM.
+- [x] 6.2 Correct component-level spacing, alignment, disclosure, overflow, form, table, prompt, result, focus, light/dark, and narrow-layout defects exposed by the vanilla fixture.
+- [x] 6.3 Preserve `/sample-html/index.html`, `/graphql`, bookmark `s_sample-1`, test selectors, readiness semantics, custom-element ownership, and all existing browser interactions.
+- [x] 6.4 Extend automated and browser regression coverage for visual structure, control overlap, target sizes, table containment, modal bounds, responsive reflow, focus visibility, contrast, and zero console errors.
## 7. Documentation and Final Verification
-- [ ] 7.1 Document enablement, HTMX version, canonical routes, base-path deployment, router customization, generic fallback, custom factories, object-home policy, result policy, theming, CSP, history, accessibility, and non-goals.
-- [ ] 7.2 Document Petclinic provenance, launch workflow, stable fixture bookmarks, HTMX routes, Wicket comparison route, and intentional differences from the pinned source.
-- [ ] 7.3 Maintain cross-viewer canonical-route and fallback fixtures shared with planned Vue and Svelte viewers.
-- [ ] 7.4 Run Node, Maven, GraphQL, Petclinic integration, vanilla integration, real-browser pointer and keyboard, direct-link and history, responsive light/dark, Wicket-relative screenshot, console, accessibility, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
+- [x] 7.1 Document enablement, HTMX version, canonical routes, base-path deployment, router customization, generic fallback, custom factories, object-home policy, result policy, theming, CSP, history, accessibility, and non-goals.
+- [x] 7.2 Document Petclinic provenance, launch workflow, stable fixture bookmarks, HTMX routes, Wicket comparison route, and intentional differences from the pinned source.
+- [x] 7.3 Maintain cross-viewer canonical-route and fallback fixtures shared with planned Vue and Svelte viewers.
+- [x] 7.4 Run Node, Maven, GraphQL, Petclinic integration, vanilla integration, real-browser pointer and keyboard, direct-link and history, responsive light/dark, Wicket-relative screenshot, console, accessibility, Lighthouse, AsciiDoc, syntax, source-approval, formatting, and strict OpenSpec validation checks.
diff --git a/viewers/webcomponents/README.adoc b/viewers/webcomponents/README.adoc
index f7c4a498394..88c8b56d54b 100644
--- a/viewers/webcomponents/README.adoc
+++ b/viewers/webcomponents/README.adoc
@@ -3,8 +3,9 @@
:Notice: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at. http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or ag [...]
The Causeway web-component modules provide framework-neutral browser components backed by the Causeway rich GraphQL schema.
-The foundation module includes a GraphQL client provider, coordinated application menu bars, service-action prompts and invocation, coordinated object contexts, safe effective-grid and fallback composite objects, semantic read-only rendering, explicitly enabled property editing, normalized interaction results, and lazy collections.
-It deliberately has no dependency on HTMX, React, Vue, Svelte, or a browser component-authoring framework.
+The foundation module includes a GraphQL client provider, coordinated application menu bars, service-action prompts and invocation, coordinated object contexts, safe effective-grid and fallback composite objects, semantic read-only rendering, explicitly enabled property editing, normalized interaction results, lazy collections, and an optional cohesive theme.
+It deliberately has no dependency on HTMX, React, Vue, Svelte, Wicket, Bootstrap, or a browser component-authoring framework.
+The separate optional HTMX module adds canonical routing, a stable shell, history, custom-page selection, and fragment lifecycle above those components.
This page explains how to run the automated verification and how to exercise the components manually against a Causeway application.
@@ -30,12 +31,16 @@ export PATH="$JAVA_HOME/bin:$PATH"
mvn -f viewers/webcomponents/pom.xml clean verify
----
-A successful run reports the Node test totals and completes both Maven modules:
+A successful run reports the foundation and HTMX Node test totals and completes the foundation, HTMX, vanilla sample, and Petclinic sample modules:
[source,text]
----
-tests 102
-pass 102
+tests 103
+pass 103
+fail 0
+
+tests 4
+pass 4
fail 0
BUILD SUCCESS
----
@@ -74,14 +79,14 @@ mvn -f bom/pom.xml validate
git diff --check
-openspec validate add-menubar-web-components --strict
+openspec validate add-generic-htmx-web-component-viewer --strict
----
The OpenSpec command should report:
[source,text]
----
-Change 'add-menubar-web-components' is valid
+Change 'add-generic-htmx-web-component-viewer' is valid
----
=== Packaged browser resources
@@ -101,9 +106,20 @@ The listing should include `META-INF/resources/causeway-webcomponents/index.mjs`
The preferred manual test serves the page and GraphQL endpoint from the same Causeway application origin.
This avoids cross-origin and authentication-cookie differences that can obscure component behavior.
-The repository provides `viewers/webcomponents/sample-html` as the standard executable vanilla-HTML verification application.
-It supplies deterministic JPA/H2 data, the real rich GraphQL endpoint, packaged foundation modules, and stable hooks for future Playwright tests.
-Start it from the repository root with the reactor profile so that the foundation and GraphQL runtime are included:
+The repository provides `viewers/webcomponents/sample-htmx-petclinic` as the cohesive generic-viewer reference application and `viewers/webcomponents/sample-html` as the stable low-level vanilla-component fixture.
+The Petclinic application supplies deterministic JPA/H2 data, real rich GraphQL, effective menus and grids, canonical HTMX routes, and a side-by-side current Wicket viewer.
+Start Petclinic from the repository root with:
+
+[source,shell]
+----
+mvn -f viewers/webcomponents/pom.xml -Prun-sample-htmx-petclinic
+----
+
+Open `http://localhost:8080/htmx`, or open `/wicket/` and sign in as `sven` with password `pass` for comparison.
+See link:sample-htmx-petclinic/README.adoc[`sample-htmx-petclinic/README.adoc`] and link:htmx/README.adoc[`htmx/README.adoc`] for routes, provenance, customization, and acceptance contracts.
+
+The vanilla sample supplies its own deterministic JPA/H2 data, the same rich GraphQL endpoint, packaged foundation modules, and stable low-level automation hooks.
+Start it from the repository root with:
[source,shell]
----
@@ -122,7 +138,7 @@ When testing from a source checkout, install the web-component artifact into the
mvn -f viewers/webcomponents/pom.xml install
----
-Add the foundation artifact to the application under test:
+Add the foundation artifact to an application using components directly:
[source,xml]
----
@@ -133,11 +149,13 @@ Add the foundation artifact to the application under test:
</dependency>
----
-The artifact exposes its entry module as a standard `META-INF/resources` resource:
+Add `causeway-viewer-webcomponents-htmx` as well when the application requires the generic server router and shell.
+The foundation artifact exposes its entry module and theme as standard `META-INF/resources` resources:
[source,text]
----
/causeway-webcomponents/index.mjs
+/causeway-webcomponents/theme.css
----
=== Configure the GraphQL schema
diff --git a/viewers/webcomponents/canonical-route-fixtures.yaml b/viewers/webcomponents/canonical-route-fixtures.yaml
new file mode 100644
index 00000000000..b4e791889ce
--- /dev/null
+++ b/viewers/webcomponents/canonical-route-fixtures.yaml
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0.
+
+version: 1
+basePath: /viewer
+valid:
+ - logicalTypeName: petclinic.PetOwner
+ objectId: s_owner-mary
+ path: /viewer/object/petclinic.PetOwner/s_owner-mary
+ - logicalTypeName: example.Type With Space
+ objectId: id ?#42 café
+ path: /viewer/object/example.Type%20With%20Space/id%20%3F%2342%20caf%C3%A9
+ - logicalTypeName: example.Δοκιμή
+ objectId: 日本語
+ path: /viewer/object/example.%CE%94%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE/%E6%97%A5%E6%9C%AC%E8%AA%9E
+invalid:
+ - /viewer/object/type
+ - /viewer/object/type/
+ - /viewer/object/type/%
+ - /viewer/object/type/%2F
+ - /viewer/object/type/%5C
+ - /viewer/object/type/%00
+ - /viewer/object/type/%2E%2E
+ - /viewer/object/type/%41
+customPagePrecedence:
+ exactLogicalType: petclinic.HomePage
+ unrelatedLogicalType: petclinic.PetOwner
+fallbackElement: causeway-object
diff --git a/viewers/webcomponents/foundation/README.adoc b/viewers/webcomponents/foundation/README.adoc
index 988a127c19a..80b24aa21bf 100644
--- a/viewers/webcomponents/foundation/README.adoc
+++ b/viewers/webcomponents/foundation/README.adoc
@@ -12,6 +12,7 @@
This module packages framework-neutral ECMAScript modules that consume the Apache Causeway rich GraphQL schema.
The Maven artifact exposes the modules below `META-INF/resources/causeway-webcomponents/` and runs its dependency-free test suite with the Node.js test runner.
-The package provides coordinated and standalone application menu bars, service-action interaction adapters, layout-aware composite objects, semantic object headers, read-only and explicitly editable properties, values, object links, action affordances, the standard accessible action interaction controller, semantic command execution, editor and renderer extension, normalized action outcomes, lazy collections, declarative collection columns, hydrated row contexts, semantic events, and opt [...]
-The foundation deliberately has no HTMX, React, Vue, Svelte, or browser component-authoring runtime dependency.
+The package provides coordinated and standalone application menu bars, service-action interaction adapters, layout-aware composite objects, semantic object headers, read-only and explicitly editable properties, values, object links, action affordances, the standard accessible action interaction controller, semantic command execution, editor and renderer extension, normalized action outcomes, lazy collections, declarative collection columns, hydrated row contexts, semantic events, an exte [...]
+The foundation deliberately has no HTMX, React, Vue, Svelte, Wicket, Bootstrap, or browser component-authoring runtime dependency.
+The packaged `component-styles.css` is the CSP-compatible equivalent of `installCausewayComponentStyles()`, while `theme.css` supplies the Wicket-inspired visual tokens and responsive presentation.
See link:USAGE.adoc[USAGE.adoc] for the public component contracts, application-entry menu coordination, safe effective-menu parsing, service-action prompts and results, standalone bars, effective-grid and fallback object composition, plain-HTML composition, renderer and editor registration, property validation and mutation, application overrides, collection activation and row hydration, accessibility and styling classes, semantic events, and current collection-size limitations.
diff --git a/viewers/webcomponents/foundation/USAGE.adoc b/viewers/webcomponents/foundation/USAGE.adoc
index 748b989be16..bd84c91a028 100644
--- a/viewers/webcomponents/foundation/USAGE.adoc
+++ b/viewers/webcomponents/foundation/USAGE.adoc
@@ -95,7 +95,8 @@ The client derives generated rich-schema names from the Causeway logical type an
It describes the generated object type and then issues independent cached one-type introspection operations for reachable property, collection, action, parameter, metadata, and value wrapper types.
This pattern is compatible with GraphQL Java's good-faith introspection limits because one operation does not repeat `__Type.fields` across many aliases.
The client does not request an application-specific member-list field and does not access Causeway Java metamodel services.
-Application-menu discovery separately introspects the operation roots, configured rich root, `application` result, menu descriptor, and referenced public service types through the same cached one-type operations.
+Application-entry discovery separately introspects the operation roots, configured rich root, `application` result, menu descriptor, optional object-home union, and referenced public service types through the same cached one-type operations.
+The read selects only menu metadata, bounded issues, and `_meta` identity for the supported object home; it does not project home members or invent a home action.
One browser client serializes these initial targeted discovery requests so object and menu contexts do not race a cold GraphQL schema, while the viewer initializes its lazy execution source once without changing data-fetcher or mutation execution policy.
Descriptions include member kind, advertised wrapper fields, output type reference, named type, innermost type kind, collection element type, and reachable value-shape fields.
@@ -386,8 +387,11 @@ These are standard bubbling and composed `CustomEvent` instances where they cros
Generated light DOM uses native buttons, headings, tables, outputs, live statuses, labels, `aria-busy`, `aria-disabled`, `aria-describedby`, and alert roles as appropriate.
Stable classes include `causeway-object`, `causeway-object-row`, `causeway-object-column`, `causeway-object-group`, `causeway-object-tabs`, `causeway-object-header`, `causeway-property`, `causeway-property-editor`, `causeway-value`, `causeway-object-link`, `causeway-action`, `causeway-interaction-controller`, `causeway-action-prompt`, `causeway-action-result`, `causeway-collection`, `causeway-loading`, `causeway-disabled`, `causeway-empty`, `causeway-error`, and `causeway-unsupported`.
-`installCausewayComponentStyles()` installs the optional minimal stylesheet once.
-Applications can replace or override those rules with ordinary design-system CSS:
+`installCausewayComponentStyles()` installs the optional minimal structural stylesheet once.
+The packaged `/causeway-webcomponents/theme.css` adds a cohesive Wicket-inspired visual system for applications that want a ready default.
+It defines `--causeway-*` tokens for typography, navigation, surfaces, borders, text, links, focus, status colors, spacing, radii, shadows, label width, content width, control height, and responsive layout.
+The theme covers light, dark, reduced-motion, and forced-colors preferences without adding Bootstrap, Wicket, or JavaScript behavior.
+Applications can omit it, replace it, or override those rules with ordinary design-system CSS:
[source,css]
----
@@ -411,8 +415,8 @@ causeway-object {
== Deliberate exclusions
-This slice does not provide collection editing, filtering, bulk actions, automatic action-result navigation, routing, menu rendering, HTMX integration, framework-specific custom-page selection, or persistent schema caching.
-Those concerns belong to host viewers or later changes and continue to consume the same semantic context and event contracts.
+This slice does not provide collection editing, filtering, bulk actions, automatic action-result navigation, routing, HTMX history, framework-specific custom-page selection, or persistent schema caching.
+Application menu rendering remains semantic and framework-neutral, while routing, shell lifecycle, history, and custom-page selection belong to host viewers.
== Demonstrator and tests
diff --git a/viewers/webcomponents/foundation/src/application-operations.mjs b/viewers/webcomponents/foundation/src/application-operations.mjs
index 5a5e1ed74b9..d1418968fe5 100644
--- a/viewers/webcomponents/foundation/src/application-operations.mjs
+++ b/viewers/webcomponents/foundation/src/application-operations.mjs
@@ -40,6 +40,10 @@ export function buildApplicationEntryReadOperation({description, schemaNames}) {
...menuSelection,
' }'
];
+ const homeSelection = buildHomeSelection(description);
+ if (homeSelection.length > 0) {
+ applicationSelection.push(...homeSelection);
+ }
if (description.issuesField && description.issueType) {
const availableIssueFields = new Set(description.issueType.fields?.map(field => field.name) ?? []);
const issueSelection = ISSUE_FIELDS
@@ -62,3 +66,31 @@ export function buildApplicationEntryReadOperation({description, schemaNames}) {
applicationPath: richRoot ? [richRoot, applicationField] : [applicationField]
});
}
+
+function buildHomeSelection(description) {
+ const homeField = description.homeField;
+ const homeType = description.homeType;
+ const homeObjectField = description.homeObjectField;
+ const possibleTypes = description.homeObjectUnion?.possibleTypes ?? [];
+ if (!homeField || !homeType || !homeObjectField || possibleTypes.length === 0) {
+ return [];
+ }
+ const homeFields = new Set(homeType.fields?.map(field => field.name) ?? []);
+ const selection = [` ${assertGraphQLName(homeField.name, 'application home field')} {`];
+ for (const fieldName of ['kind', 'logicalTypeName']) {
+ if (homeFields.has(fieldName)) {
+ selection.push(` ${assertGraphQLName(fieldName, 'application home value field')}`);
+ }
+ }
+ selection.push(` ${assertGraphQLName(homeObjectField.name, 'application home object field')} {`);
+ selection.push(' __typename');
+ for (const possibleType of possibleTypes) {
+ const typeName = assertGraphQLName(possibleType.name, 'application home possible type');
+ selection.push(` ... on ${typeName} {`);
+ selection.push(' _meta { id logicalTypeName title }');
+ selection.push(' }');
+ }
+ selection.push(' }');
+ selection.push(' }');
+ return selection;
+}
diff --git a/viewers/webcomponents/foundation/src/collection-element.mjs b/viewers/webcomponents/foundation/src/collection-element.mjs
index 48e1c28fbcc..d46e07d4225 100644
--- a/viewers/webcomponents/foundation/src/collection-element.mjs
+++ b/viewers/webcomponents/foundation/src/collection-element.mjs
@@ -30,7 +30,10 @@ export function captureDeclarativeCollectionColumns(root = globalThis.document)
if (!root?.querySelectorAll) {
return;
}
- for (const collection of root.querySelectorAll('causeway-collection')) {
+ const collections = root.localName === 'causeway-collection'
+ ? [root]
+ : root.querySelectorAll('causeway-collection');
+ for (const collection of collections) {
const columns = [...(collection.children ?? collection.childNodes ?? [])]
.filter(child => child.localName === 'causeway-collection-column' || child?.configuration?.member)
.map(child => ({
@@ -62,19 +65,21 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
this.loadRevision = 0;
this.loadAbortController = null;
this.loadedGeneration = -1;
- this.addEventListener(CausewaySemanticEvent.COLLECTION_CONFIGURATION, event => {
- const column = event.detail?.column;
- if (column?.member) {
- const existing = this._columns.findIndex(candidate => candidate.member === column.member);
- if (existing >= 0) {
- this._columns.splice(existing, 1, column);
- } else {
- this._columns.push(column);
- }
- if (this.active && this.componentState?.status === 'ready') {
- void this.load({force: true});
+ this.connectionStarted = false;
+ this.columnObserver = typeof globalThis.MutationObserver === 'function'
+ ? new MutationObserver(records => {
+ for (const record of records) {
+ for (const node of record.addedNodes ?? []) {
+ if (node.localName === 'causeway-collection-column' && node.configuration?.member) {
+ this.#acceptColumn(node.configuration);
+ }
+ }
}
- }
+ })
+ : null;
+ this.columnObserver?.observe(this, {childList: true});
+ this.addEventListener(CausewaySemanticEvent.COLLECTION_CONFIGURATION, event => {
+ this.#acceptColumn(event.detail?.column);
});
this.addEventListener('click', event => {
if (event.target?.hasAttribute?.('data-causeway-activate')) {
@@ -138,11 +143,19 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
connectedCallback() {
- this.#captureColumns();
- super.connectedCallback();
+ globalThis.setTimeout(() => {
+ if (!this.isConnected) {
+ return;
+ }
+ captureDeclarativeCollectionColumns(this);
+ this.#captureColumns();
+ this.connectionStarted = true;
+ super.connectedCallback();
+ }, 0);
}
disconnectedCallback() {
+ this.connectionStarted = false;
this.loadRevision += 1;
this.loadAbortController?.abort();
this.loadAbortController = null;
@@ -154,7 +167,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
attributeChangedCallback(name, oldValue, newValue) {
- if (oldValue === newValue || !this.isConnected) {
+ if (oldValue === newValue || !this.connectionStarted) {
return;
}
if (name === 'member') {
@@ -182,7 +195,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
activate() {
- if (this.componentState?.data?.hidden === true || this.componentState?.data?.disabled) {
+ if (this.componentState?.data?.hidden === true) {
return false;
}
if (!this.active) {
@@ -296,15 +309,11 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
const disabledReason = typeof state.data?.disabled === 'string'
? state.data.disabled
: state.data?.disabled === true ? 'Disabled' : '';
- if (disabledReason) {
- this.innerHTML = shell(
- `<span class="causeway-collection-disabled-reason">${escapeHtml(disabledReason)}</span>`,
- ' aria-disabled="true"'
- );
- return;
- }
+ const disabledMarkup = disabledReason
+ ? `<p class="causeway-collection-disabled-reason">${escapeHtml(disabledReason)}</p>`
+ : '';
if (!this.active) {
- this.innerHTML = shell(`<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`);
+ this.innerHTML = shell(`${disabledMarkup}<button type="button" data-causeway-activate>Load ${escapeHtml(label)}</button>`);
return;
}
if (this.collectionState.status === 'idle' || this.collectionState.status === 'loading') {
@@ -326,7 +335,7 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
const errors = this.collectionState.errors?.length
? `<p class="causeway-error" role="alert">${escapeHtml(errorMessage(this.collectionState))}</p>`
: '';
- this.innerHTML = shell(`${content}${errors}`);
+ this.innerHTML = shell(`${disabledMarkup}${content}${errors}`);
}
#renderDefaultRows(rows) {
@@ -337,10 +346,12 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
}
#renderTable(rows) {
- const header = this._columns.map(column => `<th scope="col"${column.testId ? ` data-testid="${escapeHtml(column.testId)}"` : ''}>${escapeHtml(column.label || humanize(column.member))}</th>`).join('');
+ const header = `<th scope="col">Item</th>${this._columns.map(column => `<th scope="col"${column.testId ? ` data-testid="${escapeHtml(column.testId)}"` : ''}>${escapeHtml(column.label || humanize(column.member))}</th>`).join('')}`;
const body = rows.map((row, rowIndex) => {
+ const metadata = row?._meta ?? {};
+ const item = `<td><causeway-object-link logical-type="${escapeHtml(metadata.logicalTypeName ?? '')}" object-id="${escapeHtml(metadata.id ?? '')}" title="${escapeHtml(metadata.title ?? metadata.id ?? '')}"></causeway-object-link></td>`;
const cells = this._columns.map(column => this.#renderCell(row, rowIndex, column)).join('');
- return `<tr data-row-index="${rowIndex}">${cells}</tr>`;
+ return `<tr data-row-index="${rowIndex}">${item}${cells}</tr>`;
}).join('');
return `<table class="causeway-collection-table"><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
}
@@ -363,6 +374,22 @@ export class CausewayCollectionElement extends CausewayContextConsumerElement {
return `<td${disabledReason ? ` aria-disabled="true" title="${escapeHtml(disabledReason)}"` : ''}>${rendered.html}</td>`;
}
+ #acceptColumn(column) {
+ if (!column?.member) {
+ return;
+ }
+ const frozen = Object.freeze({...column});
+ const existing = this._columns.findIndex(candidate => candidate.member === column.member);
+ if (existing >= 0) {
+ this._columns.splice(existing, 1, frozen);
+ } else {
+ this._columns.push(frozen);
+ }
+ if (this.active && this.componentState?.status === 'ready') {
+ void this.load({force: true});
+ }
+ }
+
#captureColumns() {
for (const column of initialColumnConfigurations.get(this) ?? []) {
if (!this._columns.some(candidate => candidate.member === column.member)) {
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.css
similarity index 93%
copy from viewers/webcomponents/foundation/src/component-styles.mjs
copy to viewers/webcomponents/foundation/src/component-styles.css
index 5619bb3943b..87f360cfe6c 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.css
@@ -17,7 +17,6 @@
* under the License.
*/
-export const CAUSEWAY_COMPONENT_STYLES = `
causeway-object {
container-name: causeway-object;
container-type: inline-size;
@@ -250,7 +249,7 @@ causeway-menubars > causeway-interaction-controller {
border: var(--causeway-menubar-border, 1px solid color-mix(in srgb, currentColor 25%, transparent));
border-radius: var(--causeway-menubar-control-radius, 0.25rem);
box-shadow: var(--causeway-menu-panel-shadow, 0 0.5rem 1.5rem color-mix(in srgb, #000 22%, transparent));
- color: var(--causeway-menubar-color, CanvasText);
+ color: var(--causeway-menu-panel-color, var(--causeway-menubar-color, CanvasText));
inline-size: max-content;
inset-block-start: 100%;
inset-inline-start: 0;
@@ -318,19 +317,3 @@ causeway-menubars > causeway-interaction-controller {
position: static;
}
}
-`;
-
-export function installCausewayComponentStyles(documentRoot = globalThis.document) {
- if (!documentRoot?.createElement || !documentRoot?.head) {
- return null;
- }
- const existing = documentRoot.getElementById?.('causeway-component-styles');
- if (existing) {
- return existing;
- }
- const style = documentRoot.createElement('style');
- style.id = 'causeway-component-styles';
- style.textContent = CAUSEWAY_COMPONENT_STYLES;
- documentRoot.head.appendChild(style);
- return style;
-}
diff --git a/viewers/webcomponents/foundation/src/component-styles.mjs b/viewers/webcomponents/foundation/src/component-styles.mjs
index 5619bb3943b..cf44f5cdd11 100644
--- a/viewers/webcomponents/foundation/src/component-styles.mjs
+++ b/viewers/webcomponents/foundation/src/component-styles.mjs
@@ -250,7 +250,7 @@ causeway-menubars > causeway-interaction-controller {
border: var(--causeway-menubar-border, 1px solid color-mix(in srgb, currentColor 25%, transparent));
border-radius: var(--causeway-menubar-control-radius, 0.25rem);
box-shadow: var(--causeway-menu-panel-shadow, 0 0.5rem 1.5rem color-mix(in srgb, #000 22%, transparent));
- color: var(--causeway-menubar-color, CanvasText);
+ color: var(--causeway-menu-panel-color, var(--causeway-menubar-color, CanvasText));
inline-size: max-content;
inset-block-start: 100%;
inset-inline-start: 0;
diff --git a/viewers/webcomponents/foundation/src/graphql-client.mjs b/viewers/webcomponents/foundation/src/graphql-client.mjs
index 9fc10e1c98c..539f4865fb9 100644
--- a/viewers/webcomponents/foundation/src/graphql-client.mjs
+++ b/viewers/webcomponents/foundation/src/graphql-client.mjs
@@ -332,12 +332,20 @@ export class CausewayGraphQLClient {
}
const issuesField = applicationFields.get('issues') ?? null;
const issueTypeName = issuesField ? namedType(issuesField.type) : null;
- const requestedTypes = [menuBarsTypeName, issueTypeName].filter(Boolean);
+ const homeField = applicationFields.get('home') ?? null;
+ const homeTypeName = homeField ? namedType(homeField.type) : null;
+ const requestedTypes = [menuBarsTypeName, issueTypeName, homeTypeName].filter(Boolean);
const supportTypes = await this.describeTypes(requestedTypes, {signal});
const menuBarsType = supportTypes.get(menuBarsTypeName) ?? null;
if (!menuBarsType) {
return unsupportedApplicationDescription('MENU_BARS_UNAVAILABLE', {applicationField, applicationType});
}
+ const homeType = homeTypeName ? supportTypes.get(homeTypeName) ?? null : null;
+ const homeObjectField = homeType ? fieldsByName(homeType).get('object') ?? null : null;
+ const homeObjectTypeName = homeObjectField ? namedType(homeObjectField.type) : null;
+ const homeObjectUnion = homeObjectTypeName
+ ? (await this.describeTypes([homeObjectTypeName], {signal})).get(homeObjectTypeName) ?? null
+ : null;
return Object.freeze({
supported: true,
reason: null,
@@ -346,7 +354,11 @@ export class CausewayGraphQLClient {
menuBarsField,
menuBarsType,
issuesField,
- issueType: issueTypeName ? supportTypes.get(issueTypeName) ?? null : null
+ issueType: issueTypeName ? supportTypes.get(issueTypeName) ?? null : null,
+ homeField,
+ homeType,
+ homeObjectField,
+ homeObjectUnion
});
}
diff --git a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
index 67f2ce006e7..de32fdeb922 100644
--- a/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
+++ b/viewers/webcomponents/foundation/src/interaction-controller-element.mjs
@@ -54,8 +54,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
} else if (action === 'submit') {
void this.submitPrompt();
} else if (action === 'dismiss-result') {
- this.resultState = null;
- this.render();
+ this.dismissResult();
}
});
this.addEventListener('input', event => this.#captureParameter(event));
@@ -158,12 +157,14 @@ export class CausewayInteractionControllerElement extends HTMLElement {
if (!this.promptState || this.promptState.status === InteractionStatus.INVOKING) {
return false;
}
+ const focusState = this.#captureControlFocus();
const generation = ++this.generation;
const values = Object.freeze({...this.promptState.values, [parameterId]: value});
this.promptState = Object.freeze({...this.promptState, values, error: null, status: InteractionStatus.EDITING});
this.#publishPromptState();
if (!recompute) {
this.render();
+ this.#restoreControlFocus(focusState);
return true;
}
const prepared = await this.promptState.context.prepareAction(this.promptState.actionId, values);
@@ -204,6 +205,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
}
this.#publishPromptState();
this.render();
+ this.#restoreControlFocus(focusState);
return true;
}
@@ -211,10 +213,12 @@ export class CausewayInteractionControllerElement extends HTMLElement {
if (!this.promptState || this.promptState.status === InteractionStatus.INVOKING) {
return false;
}
+ const focusState = this.#captureControlFocus();
const generation = ++this.generation;
this.promptState = Object.freeze({...this.promptState, status: InteractionStatus.VALIDATING, error: null});
this.#publishPromptState();
this.render();
+ this.#restoreControlFocus(focusState);
const validation = await this.promptState.context.validateAction(this.promptState.actionId, this.promptState.values);
if (generation !== this.generation) {
return false;
@@ -226,6 +230,7 @@ export class CausewayInteractionControllerElement extends HTMLElement {
this.promptState = Object.freeze({...this.promptState, status: InteractionStatus.FAILED, error: validationReason});
this.#publishPromptState();
this.render();
+ this.#restoreControlFocus(focusState);
return false;
}
return this.#invoke(
@@ -257,6 +262,15 @@ export class CausewayInteractionControllerElement extends HTMLElement {
return true;
}
+ dismissResult() {
+ if (!this.resultState) {
+ return false;
+ }
+ this.resultState = null;
+ this.render();
+ return true;
+ }
+
render() {
const promptMarkup = this.promptState ? this.#promptMarkup() : '';
const resultMarkup = this.resultState ? this.#resultMarkup() : '';
@@ -444,6 +458,35 @@ export class CausewayInteractionControllerElement extends HTMLElement {
queueMicrotask(() => this.querySelector?.('[data-causeway-editor]')?.focus?.());
}
+ #captureControlFocus() {
+ const active = globalThis.document?.activeElement;
+ if (!active || !this.contains?.(active)) {
+ return null;
+ }
+ return Object.freeze({
+ editor: active.getAttribute?.('data-causeway-editor') ?? null,
+ action: active.getAttribute?.('data-causeway-action') ?? null,
+ selectionStart: active.selectionStart ?? null,
+ selectionEnd: active.selectionEnd ?? null
+ });
+ }
+
+ #restoreControlFocus(focusState) {
+ if (!focusState) {
+ return;
+ }
+ queueMicrotask(() => {
+ const controls = [...(this.querySelectorAll?.('[data-causeway-editor], [data-causeway-action]') ?? [])];
+ const control = controls.find(candidate => focusState.editor
+ ? candidate.getAttribute?.('data-causeway-editor') === focusState.editor
+ : candidate.getAttribute?.('data-causeway-action') === focusState.action);
+ control?.focus?.();
+ if (focusState.selectionStart != null && typeof control?.setSelectionRange === 'function') {
+ control.setSelectionRange(focusState.selectionStart, focusState.selectionEnd ?? focusState.selectionStart);
+ }
+ });
+ }
+
#containPromptFocus(event) {
const controls = [...(this.querySelectorAll?.('[data-causeway-editor], [data-causeway-action]') ?? [])]
.filter(control => !control.disabled);
diff --git a/viewers/webcomponents/foundation/src/introspection.mjs b/viewers/webcomponents/foundation/src/introspection.mjs
index ab8d2520695..22c595c8eed 100644
--- a/viewers/webcomponents/foundation/src/introspection.mjs
+++ b/viewers/webcomponents/foundation/src/introspection.mjs
@@ -66,6 +66,10 @@ const TYPE_DESCRIPTION_SELECTION = `
enumValues(includeDeprecated: true) {
name
description
+ }
+ possibleTypes {
+ kind
+ name
}`;
export function buildDescribeTypesOperation(typeNames) {
@@ -128,6 +132,10 @@ export function normalizeTypeDescription(type) {
enumValues: Object.freeze((type.enumValues ?? []).map(value => Object.freeze({
name: assertGraphQLName(value.name, 'introspected enum value'),
description: value.description ?? null
+ }))),
+ possibleTypes: Object.freeze((type.possibleTypes ?? []).map(value => Object.freeze({
+ kind: value.kind,
+ name: assertGraphQLName(value.name, 'introspected possible type')
})))
});
}
diff --git a/viewers/webcomponents/foundation/src/object-context-controller.mjs b/viewers/webcomponents/foundation/src/object-context-controller.mjs
index d2956a920b3..068d72a844e 100644
--- a/viewers/webcomponents/foundation/src/object-context-controller.mjs
+++ b/viewers/webcomponents/foundation/src/object-context-controller.mjs
@@ -780,7 +780,10 @@ export class ObjectContextController extends EventTarget {
return;
}
if (result.data === null) {
- const error = new Error(result.errors[0]?.message ?? `Object '${this.identity.logicalTypeName}:${this.identity.id}' was not found.`);
+ const error = new Error(result.errors[0]?.message ?? 'The requested object was not found.');
+ if (result.errors.length === 0) {
+ error.code = 'NOT_FOUND';
+ }
this.#setState({
status: ObjectContextStatus.TERMINAL_ERROR,
generation,
diff --git a/viewers/webcomponents/foundation/src/theme.css b/viewers/webcomponents/foundation/src/theme.css
new file mode 100644
index 00000000000..da4069fa569
--- /dev/null
+++ b/viewers/webcomponents/foundation/src/theme.css
@@ -0,0 +1,654 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+:root {
+ color-scheme: light dark;
+ --causeway-font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ --causeway-font-size-sm: 0.82rem;
+ --causeway-font-size-base: 0.95rem;
+ --causeway-font-size-lg: 1.12rem;
+ --causeway-line-height: 1.5;
+ --causeway-page-background: #f3f5f8;
+ --causeway-surface: #ffffff;
+ --causeway-surface-subtle: #f8fafc;
+ --causeway-surface-raised: #ffffff;
+ --causeway-text: #1f2937;
+ --causeway-muted: #56657a;
+ --causeway-border: #d7dee8;
+ --causeway-border-strong: #aeb9c8;
+ --causeway-nav-background: #1769aa;
+ --causeway-nav-text: #ffffff;
+ --causeway-link: #18704a;
+ --causeway-link-hover: #0e5134;
+ --causeway-action-background: #18704a;
+ --causeway-action-background-hover: #0e5134;
+ --causeway-focus: #f59e0b;
+ --causeway-info: #1976b9;
+ --causeway-success: #18794e;
+ --causeway-warning: #9a6700;
+ --causeway-danger: #b42318;
+ --causeway-disabled: #5d6978;
+ --causeway-space-1: 0.25rem;
+ --causeway-space-2: 0.5rem;
+ --causeway-space-3: 0.75rem;
+ --causeway-space-4: 1rem;
+ --causeway-space-5: 1.5rem;
+ --causeway-space-6: 2rem;
+ --causeway-space-8: 3rem;
+ --causeway-radius-sm: 0.25rem;
+ --causeway-radius-md: 0.45rem;
+ --causeway-radius-lg: 0.75rem;
+ --causeway-radius-pill: 999px;
+ --causeway-shadow-sm: 0 1px 2px rgb(15 23 42 / 0.12);
+ --causeway-shadow-md: 0 0.5rem 1.5rem rgb(15 23 42 / 0.14);
+ --causeway-label-width: 10rem;
+ --causeway-content-width: 82rem;
+ --causeway-shell-width: 96rem;
+ --causeway-nav-height: 3.25rem;
+ --causeway-control-height: 2.35rem;
+ --causeway-object-gap: var(--causeway-space-4);
+ --causeway-object-group-border: var(--causeway-border);
+ --causeway-object-group-radius: var(--causeway-radius-md);
+ --causeway-object-group-padding: var(--causeway-space-4);
+ --causeway-object-tab-accent: var(--causeway-link);
+ --causeway-menubars-gap: var(--causeway-space-2);
+ --causeway-menubar-background: transparent;
+ --causeway-menubar-border: transparent;
+ --causeway-menubar-color: var(--causeway-nav-text);
+ --causeway-menubar-menu-gap: var(--causeway-space-1);
+ --causeway-menubar-padding: 0;
+ --causeway-menubar-control-background: transparent;
+ --causeway-menubar-control-border: transparent;
+ --causeway-menubar-control-radius: var(--causeway-radius-sm);
+ --causeway-menubar-control-padding: 0.65rem 0.7rem;
+ --causeway-menubar-control-active-background: rgb(255 255 255 / 0.22);
+ --causeway-menubar-focus: 0.2rem solid var(--causeway-focus);
+ --causeway-menu-panel-background: var(--causeway-surface-raised);
+ --causeway-menu-panel-color: var(--causeway-text);
+ --causeway-menu-panel-shadow: var(--causeway-shadow-md);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --causeway-page-background: #101722;
+ --causeway-surface: #17202d;
+ --causeway-surface-subtle: #1d2837;
+ --causeway-surface-raised: #202c3c;
+ --causeway-text: #e8edf4;
+ --causeway-muted: #aab6c5;
+ --causeway-border: #3a4859;
+ --causeway-border-strong: #637287;
+ --causeway-nav-background: #104d7d;
+ --causeway-link: #6fd5a3;
+ --causeway-link-hover: #9ce6c2;
+ --causeway-action-background: #176044;
+ --causeway-action-background-hover: #0f5137;
+ --causeway-focus: #fbc14f;
+ --causeway-info: #6cb7e8;
+ --causeway-success: #66d19e;
+ --causeway-warning: #f0bd5b;
+ --causeway-danger: #ff8b82;
+ --causeway-disabled: #98a4b3;
+ --causeway-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
+ --causeway-shadow-md: 0 0.6rem 1.8rem rgb(0 0 0 / 0.45);
+ }
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 20rem;
+ font-family: var(--causeway-font-family);
+ font-size: 100%;
+ line-height: var(--causeway-line-height);
+ background: var(--causeway-page-background);
+ color: var(--causeway-text);
+ text-rendering: optimizeLegibility;
+}
+
+body,
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+body {
+ overflow-x: hidden;
+}
+
+h1,
+h2,
+h3,
+p {
+ overflow-wrap: anywhere;
+}
+
+h1,
+h2,
+h3 {
+ line-height: 1.2;
+ color: var(--causeway-text);
+}
+
+a {
+ color: var(--causeway-link);
+ text-decoration-thickness: 0.08em;
+ text-underline-offset: 0.16em;
+}
+
+a:hover {
+ color: var(--causeway-link-hover);
+}
+
+button,
+input,
+select,
+textarea {
+ max-width: 100%;
+ border: 1px solid var(--causeway-border-strong);
+ border-radius: var(--causeway-radius-sm);
+ background: var(--causeway-surface);
+ color: var(--causeway-text);
+}
+
+button,
+select,
+input:not([type="checkbox"]):not([type="radio"]) {
+ min-height: var(--causeway-control-height);
+}
+
+button {
+ padding: 0.42rem 0.75rem;
+ cursor: pointer;
+}
+
+button:hover:not(:disabled) {
+ border-color: var(--causeway-link);
+ background: var(--causeway-surface-subtle);
+}
+
+button:disabled,
+[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: 0.62;
+}
+
+:where(a, button, input, select, textarea, [tabindex]):focus-visible {
+ outline: 3px solid var(--causeway-focus);
+ outline-offset: 2px;
+}
+
+input,
+select,
+textarea {
+ padding: 0.45rem 0.6rem;
+}
+
+textarea {
+ min-height: 6rem;
+ resize: vertical;
+}
+
+.causeway-visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
+causeway-object,
+causeway-object-context,
+causeway-object-header,
+causeway-property,
+causeway-collection,
+causeway-interaction-controller {
+ min-width: 0;
+ display: block;
+}
+
+causeway-action {
+ min-width: 0;
+ display: inline-block;
+}
+
+.causeway-object-header {
+ min-height: 3.4rem;
+ margin-block-end: var(--causeway-space-4);
+ padding-block: var(--causeway-space-2) var(--causeway-space-3);
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--causeway-space-4);
+ border-block-end: 1px solid var(--causeway-border);
+}
+
+.causeway-object-header h1 {
+ margin: 0;
+ font-size: clamp(1.45rem, 2.2vw, 2rem);
+ font-weight: 520;
+}
+
+.causeway-object-identity {
+ margin: 0;
+ color: var(--causeway-muted);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: var(--causeway-font-size-sm);
+}
+
+.causeway-object-row {
+ align-items: start;
+}
+
+.causeway-object-column {
+ min-width: 0;
+}
+
+.causeway-object-group,
+.causeway-collection {
+ overflow: hidden;
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-md);
+ background: var(--causeway-surface);
+ box-shadow: var(--causeway-shadow-sm);
+}
+
+.causeway-object-group {
+ container-type: inline-size;
+}
+
+.causeway-object-group > h2,
+.causeway-collection-label {
+ margin: 0;
+ padding: var(--causeway-space-3) var(--causeway-space-4);
+ display: block;
+ border-block-end: 1px solid var(--causeway-border);
+ background: var(--causeway-surface-subtle);
+ font-size: 1rem;
+ font-weight: 650;
+}
+
+.causeway-object-group > :not(h2),
+.causeway-collection-content {
+ margin-inline: var(--causeway-space-4);
+}
+
+.causeway-object-tabs [role="tablist"] {
+ gap: 0;
+ border-block-end: 1px solid var(--causeway-border);
+}
+
+.causeway-object-tabs [role="tab"] {
+ min-height: 2.7rem;
+ border: 0;
+ border-block-end: 3px solid transparent;
+ border-radius: 0;
+ background: transparent;
+ color: var(--causeway-muted);
+}
+
+.causeway-object-tabs [role="tab"][aria-selected="true"] {
+ border-block-end-color: var(--causeway-link);
+ background: var(--causeway-surface);
+ color: var(--causeway-text);
+}
+
+.causeway-object-tabs [role="tabpanel"] {
+ padding-block-start: var(--causeway-space-4);
+}
+
+.causeway-property {
+ min-width: 0;
+ padding-block: 0.42rem;
+ display: grid;
+ grid-template-columns: minmax(7rem, var(--causeway-label-width)) minmax(0, 1fr) auto;
+ align-items: start;
+ gap: var(--causeway-space-2) var(--causeway-space-3);
+ border-block-end: 1px solid color-mix(in srgb, var(--causeway-border) 65%, transparent);
+}
+
+causeway-property:last-child .causeway-property {
+ border-block-end: 0;
+}
+
+.causeway-property-label {
+ padding-block-start: 0.28rem;
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+ font-weight: 650;
+}
+
+.causeway-property-value {
+ min-width: 0;
+ padding-block-start: 0.2rem;
+ color: var(--causeway-text);
+ overflow-wrap: anywhere;
+}
+
+.causeway-property-description,
+.causeway-collection-description {
+ grid-column: 2 / -1;
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+}
+
+.causeway-action-description {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
+.causeway-property-edit {
+ min-height: 2rem;
+ padding: 0.25rem 0.55rem;
+ font-size: var(--causeway-font-size-sm);
+}
+
+.causeway-property-disabled-reason,
+.causeway-action-disabled-reason,
+.causeway-collection-disabled-reason,
+.causeway-disabled-reason {
+ color: var(--causeway-disabled);
+ font-size: var(--causeway-font-size-sm);
+}
+
+.causeway-property-editor {
+ min-width: 0;
+}
+
+.causeway-property-editor > :where(input, select, textarea) {
+ width: 100%;
+}
+
+.causeway-property-editor-actions,
+.causeway-action-prompt-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--causeway-space-2);
+}
+
+.causeway-property-validation,
+.causeway-action-prompt-error,
+.causeway-error {
+ color: var(--causeway-danger);
+}
+
+.causeway-action {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--causeway-space-2);
+}
+
+.causeway-action > button,
+.causeway-property-editor-actions button:first-child,
+.causeway-action-prompt-actions button:first-child,
+.causeway-button-primary {
+ border-color: var(--causeway-action-background);
+ background: var(--causeway-action-background);
+ color: #fff;
+}
+
+.causeway-action > button:hover:not(:disabled),
+.causeway-property-editor-actions button:first-child:hover:not(:disabled),
+.causeway-action-prompt-actions button:first-child:hover:not(:disabled),
+.causeway-button-primary:hover {
+ border-color: var(--causeway-action-background-hover);
+ background: var(--causeway-action-background-hover);
+ color: #fff;
+}
+
+.causeway-collection {
+ margin-block-end: var(--causeway-space-4);
+}
+
+.causeway-collection-content {
+ min-width: 0;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+}
+
+.causeway-collection-table {
+ width: 100%;
+ min-width: 32rem;
+ border-collapse: collapse;
+ font-size: var(--causeway-font-size-sm);
+}
+
+.causeway-collection-table th,
+.causeway-collection-table td {
+ padding: 0.58rem 0.7rem;
+ border-block-end: 1px solid var(--causeway-border);
+ text-align: start;
+ vertical-align: top;
+}
+
+.causeway-collection-table th:first-child,
+.causeway-collection-table td:first-child {
+ min-width: 10rem;
+}
+
+.causeway-collection-table td:first-child .causeway-object-link {
+ min-width: 8rem;
+}
+
+.causeway-collection-table th {
+ background: var(--causeway-surface-subtle);
+ color: var(--causeway-text);
+ font-weight: 700;
+}
+
+.causeway-collection-table tbody tr:nth-child(even) {
+ background: color-mix(in srgb, var(--causeway-surface-subtle) 65%, transparent);
+}
+
+.causeway-collection-table tbody tr:hover {
+ background: color-mix(in srgb, var(--causeway-link) 8%, var(--causeway-surface));
+}
+
+.causeway-collection-rows {
+ margin: 0;
+ padding: var(--causeway-space-3) 0;
+ list-style: none;
+}
+
+.causeway-object-link {
+ max-width: 100%;
+ min-height: auto;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--causeway-link);
+ text-align: start;
+}
+
+.causeway-object-link:hover:not(:disabled) {
+ border: 0;
+ background: transparent;
+ color: var(--causeway-link-hover);
+ text-decoration: underline;
+}
+
+.causeway-object-link-title {
+ overflow-wrap: anywhere;
+}
+
+.causeway-object-link-identity {
+ display: none;
+}
+
+causeway-interaction-controller:has(.causeway-action-prompt)::before {
+ content: "";
+ position: fixed;
+ z-index: 999;
+ inset: 0;
+ background: rgb(0 0 0 / 0.46);
+}
+
+.causeway-action-prompt {
+ position: fixed;
+ z-index: 1000;
+ inset: 50% auto auto 50%;
+ width: min(calc(100% - (2 * var(--causeway-space-4))), 40rem);
+ max-height: min(82vh, 46rem);
+ padding: var(--causeway-space-5);
+ overflow: auto;
+ transform: translate(-50%, -50%);
+ border: 1px solid var(--causeway-border-strong);
+ border-radius: var(--causeway-radius-lg);
+ background: var(--causeway-surface-raised);
+ box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
+}
+
+.causeway-action-prompt h2,
+.causeway-action-result h2 {
+ margin-block-start: 0;
+}
+
+.causeway-action-prompt form {
+ display: grid;
+ gap: var(--causeway-space-4);
+}
+
+.causeway-action-parameter {
+ display: grid;
+ gap: var(--causeway-space-1);
+}
+
+.causeway-action-parameter :where(input, select, textarea) {
+ width: 100%;
+}
+
+.causeway-action-result {
+ margin-block: var(--causeway-space-4);
+ padding: var(--causeway-space-4);
+ border: 1px solid var(--causeway-border);
+ border-inline-start: 0.3rem solid var(--causeway-success);
+ border-radius: var(--causeway-radius-md);
+ background: var(--causeway-surface);
+}
+
+.causeway-loading {
+ color: var(--causeway-muted);
+}
+
+.causeway-empty,
+.causeway-unsupported {
+ display: block;
+ padding-block: var(--causeway-space-3);
+ color: var(--causeway-muted);
+ font-style: italic;
+}
+
+@container (max-width: 32rem) {
+ .causeway-property {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .causeway-property-label,
+ .causeway-property-description {
+ grid-column: 1 / -1;
+ }
+
+ .causeway-property-value,
+ .causeway-property-editor {
+ grid-column: 1;
+ }
+}
+
+@media (max-width: 48rem) {
+ :root {
+ --causeway-label-width: 100%;
+ --causeway-object-gap: var(--causeway-space-3);
+ --causeway-object-group-padding: var(--causeway-space-3);
+ }
+
+ .causeway-object-header {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: var(--causeway-space-1);
+ }
+
+ .causeway-object-row {
+ grid-template-columns: minmax(0, 1fr) !important;
+ }
+
+ .causeway-object-column {
+ grid-column: 1 / -1 !important;
+ }
+
+ .causeway-property {
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .causeway-property-label,
+ .causeway-property-description {
+ grid-column: 1 / -1;
+ }
+
+ .causeway-property-value,
+ .causeway-property-editor {
+ grid-column: 1;
+ }
+
+ .causeway-action-prompt {
+ width: calc(100% - (2 * var(--causeway-space-3)));
+ max-height: calc(100vh - (2 * var(--causeway-space-3)));
+ padding: var(--causeway-space-4);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+@media (forced-colors: active) {
+ button,
+ input,
+ select,
+ textarea,
+ .causeway-object-group,
+ .causeway-collection,
+ .causeway-action-prompt {
+ border: 1px solid CanvasText;
+ }
+}
diff --git a/viewers/webcomponents/foundation/test/application-menu-client.test.mjs b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
index 3c99d87430f..1cc03bf4de9 100644
--- a/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
+++ b/viewers/webcomponents/foundation/test/application-menu-client.test.mjs
@@ -44,9 +44,12 @@ test('targeted application discovery reads the effective menu descriptor and cac
const result = await client.readApplicationEntry({description: capability});
assert.equal(result.data.menuBars.href, '/graphql/application/menu-bars');
+ assert.equal(result.data.home.object._meta.id, 'home-1');
assert.match(executor.applicationCalls[0].document, /application\s*\{/);
assert.match(executor.applicationCalls[0].document, /menuBars\s*\{/);
- assert.doesNotMatch(executor.applicationCalls[0].document, /home\s*\{/);
+ assert.match(executor.applicationCalls[0].document, /home\s*\{/);
+ assert.match(executor.applicationCalls[0].document, /\.\.\. on rich__sample_Home/);
+ assert.match(executor.applicationCalls[0].document, /_meta \{ id logicalTypeName title \}/);
});
test('one GraphQL client serializes initial targeted schema discovery across application and service contexts', async () => {
diff --git a/viewers/webcomponents/foundation/test/collection.test.mjs b/viewers/webcomponents/foundation/test/collection.test.mjs
index c4683632667..10e70ac4528 100644
--- a/viewers/webcomponents/foundation/test/collection.test.mjs
+++ b/viewers/webcomponents/foundation/test/collection.test.mjs
@@ -73,7 +73,7 @@ function staffDeltaResponse() {
};
}
-test('captures declarative columns before browser upgrade reactions replace source children', () => {
+test('captures declarative columns before browser upgrade reactions replace source children', async () => {
const collection = new CausewayCollectionElement();
const column = new CausewayCollectionColumnElement();
column.member = 'name';
@@ -83,6 +83,7 @@ test('captures declarative columns before browser upgrade reactions replace sour
captureDeclarativeCollectionColumns({querySelectorAll: () => [collection]});
collection.removeChild(column);
document.body.appendChild(collection);
+ await waitFor(() => collection.columns.length === 1);
assert.deepEqual(collection.columns, [{member: 'name', label: 'Name', testId: 'column-name'}]);
document.body.removeChild(collection);
});
@@ -255,6 +256,7 @@ test('collection component does not read until activated and renders declared co
collection.columns = [{member: 'name', label: 'Name'}, {member: 'code', label: 'Code'}];
collection.context = context;
document.body.appendChild(collection);
+ await waitFor(() => collection.innerHTML.includes('Load Staff Members'));
assert.equal(loadCount, 0);
assert.match(collection.innerHTML, /Load Staff Members/);
@@ -262,6 +264,8 @@ test('collection component does not read until activated and renders declared co
await waitFor(() => collection.collectionState.status === 'ready');
assert.equal(loadCount, 1);
assert.match(collection.innerHTML, /causeway-collection-table/);
+ assert.match(collection.innerHTML, /<th scope="col">Item<\/th>/);
+ assert.match(collection.innerHTML, /<causeway-object-link/);
assert.match(collection.innerHTML, /Dr Ada/);
assert.match(collection.innerHTML, /ADA/);
diff --git a/viewers/webcomponents/foundation/test/component-styles.test.mjs b/viewers/webcomponents/foundation/test/component-styles.test.mjs
new file mode 100644
index 00000000000..603f2f5382f
--- /dev/null
+++ b/viewers/webcomponents/foundation/test/component-styles.test.mjs
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import test from 'node:test';
+
+import {CAUSEWAY_COMPONENT_STYLES} from '../src/component-styles.mjs';
+
+test('external structural stylesheet remains synchronized with the installable styles', async () => {
+ const external = await readFile(new URL('../src/component-styles.css', import.meta.url), 'utf8');
+ const withoutLicense = external.slice(external.indexOf('causeway-object {')).trim();
+ assert.equal(withoutLicense, CAUSEWAY_COMPONENT_STYLES.trim());
+});
diff --git a/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
index 2bfa9c78b41..2c389467269 100644
--- a/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
+++ b/viewers/webcomponents/foundation/test/fixtures/menu-graphql-fixture.mjs
@@ -26,6 +26,7 @@ export const SAMPLE_SERVICE_TYPE = 'rich__causeway_webcomponents_sample_SampleMe
export function createMenuGraphQLTypes({menuBarsAvailable = true} = {}) {
const applicationFields = [
...(menuBarsAvailable ? [field('menuBars', named('rich__gqlv_application_menu_bars'))] : []),
+ field('home', named('rich__gqlv_application_home')),
field('issues', list(named('rich__gqlv_application_issue')))
];
return new Map([
@@ -46,6 +47,12 @@ export function createMenuGraphQLTypes({menuBarsAvailable = true} = {}) {
field('code', nonNull(scalar('String'))),
field('message', nonNull(scalar('String')))
])],
+ ['rich__gqlv_application_home', objectType('rich__gqlv_application_home', [
+ field('kind', nonNull(named('rich__gqlv_application_home_kind'))),
+ field('logicalTypeName', nonNull(scalar('String'))),
+ field('object', nonNull(named('rich__gqlv_application_home_object')))
+ ])],
+ ['rich__gqlv_application_home_object', unionType('rich__gqlv_application_home_object', ['rich__sample_Home'])],
[SAMPLE_SERVICE_TYPE, objectType(SAMPLE_SERVICE_TYPE, [
field('welcomeMessage', named(`${SAMPLE_SERVICE_TYPE}__welcomeMessage__gqlv_action`)),
field('greet', named(`${SAMPLE_SERVICE_TYPE}__greet__gqlv_action`)),
@@ -187,7 +194,11 @@ export function createMenuGraphQLExecutor({
return executor;
}
-export function applicationEntryResponse({href = '/graphql/application/menu-bars', issues = []} = {}) {
+export function applicationEntryResponse({href = '/graphql/application/menu-bars', issues = [], home = {
+ kind: 'OBJECT',
+ logicalTypeName: 'sample.Home',
+ object: {__typename: 'rich__sample_Home', _meta: {id: 'home-1', logicalTypeName: 'sample.Home', title: 'Home'}}
+}} = {}) {
return {data: {rich: {application: {
menuBars: {
href,
@@ -196,6 +207,7 @@ export function applicationEntryResponse({href = '/graphql/application/menu-bars
generation: 'fixture-generation',
cacheControl: 'private, no-store'
},
+ home,
issues
}}}};
}
@@ -216,6 +228,16 @@ function objectType(name, fields) {
return {kind: 'OBJECT', name, description: null, fields};
}
+function unionType(name, possibleTypeNames) {
+ return {
+ kind: 'UNION',
+ name,
+ description: null,
+ fields: [],
+ possibleTypes: possibleTypeNames.map(possibleName => ({kind: 'OBJECT', name: possibleName}))
+ };
+}
+
function field(name, type, args = []) {
return {name, description: null, args, type};
}
diff --git a/viewers/webcomponents/foundation/test/interaction.test.mjs b/viewers/webcomponents/foundation/test/interaction.test.mjs
index e53bd871237..a52013b5e06 100644
--- a/viewers/webcomponents/foundation/test/interaction.test.mjs
+++ b/viewers/webcomponents/foundation/test/interaction.test.mjs
@@ -321,4 +321,7 @@ test('parameterless actions invoke without opening a parameter form', async () =
assert.equal(controller.promptState, null);
assert.equal(controller.resultState.result.kind, 'void');
assert.doesNotMatch(controller.innerHTML, /<dialog/);
+ assert.equal(controller.dismissResult(), true);
+ assert.equal(controller.resultState, null);
+ assert.equal(controller.dismissResult(), false);
});
diff --git a/viewers/webcomponents/htmx/README.adoc b/viewers/webcomponents/htmx/README.adoc
new file mode 100644
index 00000000000..0656231998c
--- /dev/null
+++ b/viewers/webcomponents/htmx/README.adoc
@@ -0,0 +1,96 @@
+= Generic HTMX web-component viewer
+
+// Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.
+// See the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The ASF licenses this file to you under the Apache License, Version 2.0.
+
+The `causeway-viewer-webcomponents-htmx` module provides an optional server-routed application shell over the framework-neutral Causeway custom elements.
+HTMX owns canonical navigation, fragment replacement, browser history, loading, announcements, and route errors.
+The semantic components continue to own GraphQL projections, effective layouts, menus, interactions, validation, mutations, and typed results.
+
+== Enablement
+
+Add `org.apache.causeway.viewer:causeway-viewer-webcomponents-htmx` and import `CausewayModuleViewerWebcomponentsHtmx` in the application bootstrap.
+Merely using the foundation component module does not install any HTMX route or asset.
+
+The viewer packages `org.webjars.npm:htmx.org:2.0.6` and serves the module-owned browser bridge and shell stylesheet from `/causeway-htmx/`.
+The shell uses external same-origin scripts and styles and requires no inline script.
+
+== Configuration
+
+`causeway.viewer.webcomponents.htmx.base-path` defaults to `/htmx` and accepts a bounded non-root origin-relative mount path.
+`causeway.viewer.webcomponents.htmx.graph-ql-endpoint` defaults to `/graphql`.
+`causeway.viewer.webcomponents.htmx.brand` defaults to `Apache Causeway`.
+`causeway.viewer.webcomponents.htmx.language` defaults to `en`.
+`causeway.viewer.webcomponents.htmx.application-stylesheet` optionally adds one same-origin application stylesheet after the default theme.
+`causeway.viewer.webcomponents.htmx.wicket-comparison-path` optionally adds a footer link and is intended only for comparison samples.
+
+The complete shell applies a restrictive same-origin content-security policy and returns `private, no-store`.
+Applications adding third-party resources must deliberately supply an equivalent deployment policy rather than weakening this module silently.
+
+== Canonical routes
+
+The canonical object route is `<base-path>/object/<logical-type>/<identifier>`.
+The public logical type and opaque identifier are independently UTF-8 percent-encoded as path segments.
+Parsing rejects empty values, malformed escapes, encoded separators, backslashes, controls, dot segments, overlong values, and non-canonical spellings.
+
+An ordinary browser request receives a complete document containing one GraphQL client, stable menu bars, announcements, result presentation, and the requested route page.
+A request carrying `HX-Request: true` receives only the route fragment and an `HX-Push-Url` response header.
+A history-restore request receives a complete document, and the shell disables authorization-sensitive HTMX history snapshots.
+Normal home and result links remain canonical anchors, while the browser bridge enhances same-origin navigation with `htmx.ajax()` target, swap, push, and replace options.
+
+== Generic and custom pages
+
+Every object route creates exactly one `<causeway-object-context>`.
+Without an exact custom registration, its child is `<causeway-object editable>`.
+
+Applications can register one Spring bean implementing `HtmxPageFragmentFactory` for a public logical type.
+The factory returns trusted application HTML that the viewer places inside the route context.
+It receives only `HtmxObjectRoute`, not a metamodel object, persistence session, authorization object, or GraphQL response.
+Duplicate registrations for one logical type fail during startup.
+
+Custom pages can compose ordinary HTML, application custom elements, `<causeway-object>`, and low-level Causeway property, action, and collection elements.
+Those elements consume the one nearest route context and therefore do not create a parallel data plane.
+
+== Home and results
+
+The shell extends targeted application-entry discovery only far enough to read the established optional object-home metadata and its public `_meta` identity.
+A valid object home replaces the landing route with its canonical object route.
+Absent, hidden, invalid, unsupported, or partially failing home state leaves an accessible landing page and stable menus.
+The viewer does not invent a home service-action descriptor.
+
+Default result policy routes object results, presents scalar and collection results in the shell result region, and announces void completion before refreshing a current route context.
+An application can provide `window.causewayHtmxPolicy.navigate`, `handleHome`, or `handleResult` functions and return `true` to claim the corresponding policy decision.
+These policy functions receive semantic public payloads and do not replace component interaction behavior.
+
+== Styling and accessibility
+
+The shell loads `/causeway-webcomponents/theme.css` and `/causeway-htmx/causeway-htmx.css`.
+The default theme follows the current Wicket viewer's application hierarchy with a sticky primary navigation surface, contained content, title and actions, cards, tabs, aligned properties, compact tables, bounded prompts, visible disabled state, and responsive collapse.
+It uses ordinary CSS variables and documented light-DOM hooks rather than Bootstrap or Wicket markup.
+
+The theme supports light, dark, reduced-motion, and forced-colors preferences.
+At narrow widths navigation becomes disclosed, object columns and property labels stack, tables scroll inside their own collection regions, and the page does not overflow horizontally.
+Applications may override the documented `--causeway-*` design tokens without replacing semantic behavior.
+
+Navigation exposes loading and route announcements, moves focus to the installed route page, preserves existing content until replacement succeeds, and maps non-disclosing terminal state locally.
+Escape, native controls, menu keyboard behavior, prompt focus containment, and visible focus remain component-owned.
+
+== Petclinic reference application
+
+Run the cohesive sample with:
+
+[source,bash]
+----
+mvn -f viewers/webcomponents/pom.xml -Prun-sample-htmx-petclinic
+----
+
+Open `http://localhost:8080/htmx` for the generic viewer.
+Open `http://localhost:8080/wicket/` and sign in as `sven` with password `pass` for the current Wicket comparison over the same model and fixture data.
+See `../sample-htmx-petclinic/PROVENANCE.adoc` for the pinned Apache Petclinic source and porting boundaries.
+
+== Non-goals
+
+The module does not add routing awareness to `<causeway-object>`.
+It does not construct domain GraphQL operations, translate GraphQL values, parse effective grid or menu XML, expose metamodel internals, or define Vue and Svelte lifecycle APIs.
+It does not require applications that use only the component library to adopt HTMX, Wicket, Petclinic, or the default theme.
diff --git a/viewers/webcomponents/sample-html/pom.xml b/viewers/webcomponents/htmx/pom.xml
similarity index 58%
copy from viewers/webcomponents/sample-html/pom.xml
copy to viewers/webcomponents/htmx/pom.xml
index 5ac0169907c..ff62d53265b 100644
--- a/viewers/webcomponents/sample-html/pom.xml
+++ b/viewers/webcomponents/htmx/pom.xml
@@ -29,49 +29,43 @@
<version>4.0.0-SNAPSHOT</version>
</parent>
- <artifactId>causeway-viewer-webcomponents-sample-html</artifactId>
- <name>Apache Causeway Viewer - Web Components (Vanilla HTML Sample)</name>
+ <artifactId>causeway-viewer-webcomponents-htmx</artifactId>
+ <name>Apache Causeway Viewer - Web Components (Generic HTMX Viewer)</name>
+ <packaging>jar</packaging>
<properties>
- <maven.install.skip>true</maven.install.skip>
- <maven.deploy.skip>true</maven.deploy.skip>
- <jar-plugin.automaticModuleName>org.apache.causeway.viewer.webcomponents.sample.html</jar-plugin.automaticModuleName>
- <git-plugin.propertiesDir>org/apache/causeway/viewer/webcomponents/sample/html</git-plugin.propertiesDir>
- <!-- Override the reactor default so the run profile starts this application only. -->
- <spring-boot.run.skip>false</spring-boot.run.skip>
+ <jar-plugin.automaticModuleName>org.apache.causeway.viewer.webcomponents.htmx</jar-plugin.automaticModuleName>
+ <git-plugin.propertiesDir>org/apache/causeway/viewer/webcomponents/htmx</git-plugin.propertiesDir>
+ <htmx.version>2.0.6</htmx.version>
</properties>
<dependencies>
- <dependency>
- <groupId>org.apache.causeway.mavendeps</groupId>
- <artifactId>causeway-mavendeps-webapp</artifactId>
- <type>pom</type>
- </dependency>
<dependency>
<groupId>org.apache.causeway.viewer</groupId>
- <artifactId>causeway-viewer-graphql-viewer</artifactId>
+ <artifactId>causeway-viewer-webcomponents-foundation</artifactId>
</dependency>
<dependency>
- <groupId>org.apache.causeway.persistence</groupId>
- <artifactId>causeway-persistence-jpa-eclipselink</artifactId>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
- <groupId>org.apache.causeway.security</groupId>
- <artifactId>causeway-security-bypass</artifactId>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
- <groupId>org.apache.causeway.viewer</groupId>
- <artifactId>causeway-viewer-webcomponents-foundation</artifactId>
+ <groupId>jakarta.servlet</groupId>
+ <artifactId>jakarta.servlet-api</artifactId>
+ <scope>provided</scope>
</dependency>
<dependency>
- <groupId>com.h2database</groupId>
- <artifactId>h2</artifactId>
- <scope>runtime</scope>
+ <groupId>org.webjars.npm</groupId>
+ <artifactId>htmx.org</artifactId>
+ <version>${htmx.version}</version>
</dependency>
<dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-test</artifactId>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -79,6 +73,11 @@
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-params</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
@@ -87,15 +86,31 @@
</dependencies>
<build>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <filtering>false</filtering>
+ </resource>
+ </resources>
<plugins>
<plugin>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-maven-plugin</artifactId>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-antrun-plugin</artifactId>
<executions>
<execution>
+ <id>test-htmx-browser-bridge</id>
+ <phase>test</phase>
<goals>
- <goal>repackage</goal>
+ <goal>run</goal>
</goals>
+ <configuration>
+ <target>
+ <exec executable="node" dir="${project.basedir}" failonerror="true">
+ <arg value="--test"/>
+ <arg value="--test-concurrency=1"/>
+ </exec>
+ </target>
+ </configuration>
</execution>
</executions>
</plugin>
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
new file mode 100644
index 00000000000..6bc66926412
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.List;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+@Configuration(proxyBeanMethods = false)
+@EnableConfigurationProperties(HtmxViewerProperties.class)
+@Import(HtmxViewerController.class)
+public class CausewayModuleViewerWebcomponentsHtmx {
+
+ @Bean
+ HtmxRouteCodec htmxRouteCodec(final HtmxViewerProperties properties) {
+ return new HtmxRouteCodec(properties.getBasePath());
+ }
+
+ @Bean
+ HtmxPageFragmentRegistry htmxPageFragmentRegistry(final List<HtmxPageFragmentFactory> factories) {
+ return new HtmxPageFragmentRegistry(factories);
+ }
+
+ @Bean
+ HtmxPageRenderer htmxPageRenderer(
+ final HtmxRouteCodec routeCodec,
+ final HtmxViewerProperties properties,
+ final HtmxPageFragmentRegistry fragmentRegistry) {
+ return new HtmxPageRenderer(routeCodec, properties, fragmentRegistry);
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxObjectRoute.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxObjectRoute.java
new file mode 100644
index 00000000000..6dfe2d613fe
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxObjectRoute.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.Objects;
+
+/** Public logical route identity, independent of its percent-encoded spelling. */
+public record HtmxObjectRoute(String logicalTypeName, String objectId) {
+
+ public HtmxObjectRoute {
+ logicalTypeName = Objects.requireNonNull(logicalTypeName, "logicalTypeName");
+ objectId = Objects.requireNonNull(objectId, "objectId");
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentFactory.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentFactory.java
new file mode 100644
index 00000000000..7aedc3ec712
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentFactory.java
@@ -0,0 +1,30 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+/**
+ * Application-owned trusted HTML rendered inside the viewer-provided route object context.
+ * Implementations must use only public route identity and semantic component contracts.
+ */
+public interface HtmxPageFragmentFactory {
+
+ String logicalTypeName();
+
+ String render(HtmxObjectRoute route);
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistry.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistry.java
new file mode 100644
index 00000000000..e39cf4b7994
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistry.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public final class HtmxPageFragmentRegistry {
+
+ private final Map<String, HtmxPageFragmentFactory> byLogicalType;
+
+ public HtmxPageFragmentRegistry(final List<HtmxPageFragmentFactory> factories) {
+ final var collected = new LinkedHashMap<String, HtmxPageFragmentFactory>();
+ for (var factory : factories) {
+ final var logicalType = factory.logicalTypeName();
+ if (logicalType == null || logicalType.isBlank()) {
+ throw new IllegalStateException("An HTMX page fragment factory must declare a logical type.");
+ }
+ if (collected.putIfAbsent(logicalType, factory) != null) {
+ throw new IllegalStateException("More than one HTMX page fragment factory is registered for " + logicalType + ".");
+ }
+ }
+ this.byLogicalType = Map.copyOf(collected);
+ }
+
+ public Optional<HtmxPageFragmentFactory> find(final String logicalTypeName) {
+ return Optional.ofNullable(byLogicalType.get(logicalTypeName));
+ }
+
+ public int size() {
+ return byLogicalType.size();
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
new file mode 100644
index 00000000000..7cd0cd8eb05
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageRenderer.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.Locale;
+
+final class HtmxPageRenderer {
+
+ private static final String HTMX_VERSION = "2.0.6";
+
+ private final HtmxRouteCodec routeCodec;
+ private final HtmxViewerProperties properties;
+ private final HtmxPageFragmentRegistry fragmentRegistry;
+
+ HtmxPageRenderer(
+ final HtmxRouteCodec routeCodec,
+ final HtmxViewerProperties properties,
+ final HtmxPageFragmentRegistry fragmentRegistry) {
+ this.routeCodec = routeCodec;
+ this.properties = properties;
+ this.fragmentRegistry = fragmentRegistry;
+ }
+
+ String renderObjectFragment(final HtmxObjectRoute route) {
+ final var custom = fragmentRegistry.find(route.logicalTypeName());
+ final var content = custom
+ .map(factory -> factory.render(route))
+ .orElse("<causeway-object editable></causeway-object>");
+ final var pageKind = custom.isPresent() ? "custom" : "generic";
+ return """
+ <section class="causeway-route-page causeway-route-object" data-route-state="loading" data-page-kind="%s" tabindex="-1" aria-label="Object page">
+ <causeway-object-context logical-type="%s" object-id="%s">
+ %s
+ <causeway-interaction-controller data-causeway-route-interactions></causeway-interaction-controller>
+ </causeway-object-context>
+ </section>
+ """.formatted(
+ pageKind,
+ escape(route.logicalTypeName()),
+ escape(route.objectId()),
+ content);
+ }
+
+ String renderLandingFragment() {
+ return """
+ <section class="causeway-route-page causeway-route-landing" data-route-state="landing" tabindex="-1" aria-labelledby="causeway-route-heading">
+ <div class="causeway-landing-card">
+ <p class="causeway-eyebrow">Apache Causeway</p>
+ <h1 id="causeway-route-heading">Welcome</h1>
+ <p data-causeway-home-message>Select an application action or wait while the configured home page is resolved.</p>
+ </div>
+ </section>
+ """;
+ }
+
+ String renderInvalidRouteFragment() {
+ return """
+ <section class="causeway-route-page causeway-route-error" data-route-state="invalid-route" tabindex="-1" aria-labelledby="causeway-route-heading">
+ <div class="causeway-status-card causeway-status-danger" role="alert">
+ <p class="causeway-eyebrow">Invalid route</p>
+ <h1 id="causeway-route-heading">This application route is not valid</h1>
+ <p>Use the application menus or return to the home page.</p>
+ <a class="causeway-button causeway-button-primary" href="%s" data-causeway-route-link>Return home</a>
+ </div>
+ </section>
+ """.formatted(escape(routeCodec.rootPath()));
+ }
+
+ String renderShell(final String contextPath, final String fragment, final String canonicalPath) {
+ final var context = normalizeContextPath(contextPath);
+ final var basePath = context + routeCodec.basePath();
+ final var graphQlEndpoint = context + normalizeOriginPath(properties.getGraphQlEndpoint(), "/graphql");
+ final var language = safeLanguage(properties.getLanguage());
+ final var comparisonLink = comparisonLink(context);
+ final var applicationStylesheet = applicationStylesheet(context);
+ return """
+ <!doctype html>
+ <html lang="%s" data-causeway-htmx-base="%s" data-causeway-canonical-path="%s">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="description" content="Generic Apache Causeway application viewer using semantic web components and HTMX routing.">
+ <meta name="color-scheme" content="light dark">
+ <meta name="htmx-config" content='{"historyCacheSize":0,"historyRestoreAsHxRequest":false,"includeIndicatorStyles":false}'>
+ <title>%s</title>
+ <link rel="stylesheet" href="%s/causeway-webcomponents/component-styles.css">
+ <link rel="stylesheet" href="%s/causeway-webcomponents/theme.css">
+ <link rel="stylesheet" href="%s/causeway-htmx/causeway-htmx.css">
+ %s
+ <script defer src="%s/webjars/htmx.org/%s/dist/htmx.min.js"></script>
+ <script type="module" src="%s/causeway-htmx/causeway-htmx.mjs"></script>
+ </head>
+ <body class="causeway-app-shell">
+ <a class="causeway-skip-link" href="#causeway-route">Skip to main content</a>
+ <causeway-graphql-client endpoint="%s">
+ <header class="causeway-shell-header">
+ <div class="causeway-shell-navbar">
+ <a class="causeway-shell-brand" href="%s" data-causeway-route-link aria-label="%s home">
+ <span class="causeway-shell-mark" aria-hidden="true">C</span>
+ <span>%s</span>
+ </a>
+ <causeway-menubars></causeway-menubars>
+ </div>
+ </header>
+ <div id="causeway-route-loading" class="causeway-route-loading htmx-indicator" role="status" aria-live="polite">Loading page…</div>
+ <div id="causeway-route-announcement" class="causeway-visually-hidden" aria-live="polite" aria-atomic="true"></div>
+ <aside id="causeway-result" class="causeway-shell-result" aria-live="polite" hidden></aside>
+ <main id="causeway-route" class="causeway-shell-main" hx-history="false" aria-busy="false">
+ %s
+ </main>
+ <footer class="causeway-shell-footer">
+ <span>Powered by Apache Causeway</span>
+ %s
+ </footer>
+ </causeway-graphql-client>
+ </body>
+ </html>
+ """.formatted(
+ language,
+ escape(basePath),
+ escape(context + canonicalPath),
+ escape(properties.getBrand()),
+ escape(context),
+ escape(context),
+ escape(context),
+ applicationStylesheet,
+ escape(context),
+ HTMX_VERSION,
+ escape(context),
+ escape(graphQlEndpoint),
+ escape(basePath),
+ escape(properties.getBrand()),
+ escape(properties.getBrand()),
+ fragment,
+ comparisonLink);
+ }
+
+ private String applicationStylesheet(final String contextPath) {
+ final var configured = properties.getApplicationStylesheet();
+ if (configured == null || configured.isBlank()) {
+ return "";
+ }
+ final var path = contextPath + normalizeOriginPath(configured, "/css/application.css");
+ return "<link rel=\"stylesheet\" href=\"" + escape(path) + "\">";
+ }
+
+ private String comparisonLink(final String contextPath) {
+ final var configured = properties.getWicketComparisonPath();
+ if (configured == null || configured.isBlank()) {
+ return "";
+ }
+ final var path = contextPath + normalizeOriginPath(configured, "/wicket/");
+ return "<a href=\"" + escape(path) + "\">Compare Wicket viewer</a>";
+ }
+
+ private static String normalizeOriginPath(final String configured, final String fallback) {
+ final var candidate = configured == null || configured.isBlank() ? fallback : configured.trim();
+ if (!candidate.startsWith("/") || candidate.startsWith("//") || candidate.contains("?") || candidate.contains("#")) {
+ throw new IllegalStateException("The HTMX viewer GraphQL endpoint must be an origin-relative path.");
+ }
+ return candidate;
+ }
+
+ private static String normalizeContextPath(final String contextPath) {
+ if (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) {
+ return "";
+ }
+ return contextPath.endsWith("/") ? contextPath.substring(0, contextPath.length() - 1) : contextPath;
+ }
+
+ private static String safeLanguage(final String configured) {
+ final var candidate = configured == null ? "en" : configured.trim().toLowerCase(Locale.ROOT);
+ return candidate.matches("[a-z]{2,8}(?:-[a-z0-9]{1,8})*") ? candidate : "en";
+ }
+
+ static String escape(final String value) {
+ if (value == null) {
+ return "";
+ }
+ return value
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodec.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodec.java
new file mode 100644
index 00000000000..e956a883241
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodec.java
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+public final class HtmxRouteCodec {
+
+ static final int MAX_DECODED_LENGTH = 1024;
+ static final int MAX_ENCODED_LENGTH = 4096;
+
+ private final String basePath;
+
+ public HtmxRouteCodec(final String basePath) {
+ this.basePath = normalizeBasePath(basePath);
+ }
+
+ public String basePath() {
+ return basePath;
+ }
+
+ public String rootPath() {
+ return basePath;
+ }
+
+ public boolean isRootPath(final String rawPath) {
+ return basePath.equals(rawPath) || (basePath + "/").equals(rawPath);
+ }
+
+ public String objectPath(final HtmxObjectRoute route) {
+ return basePath + "/object/" + encodeSegment(route.logicalTypeName()) + "/" + encodeSegment(route.objectId());
+ }
+
+ public HtmxObjectRoute parseObjectPath(final String rawPath) {
+ if (rawPath == null || rawPath.length() > basePath.length() + (MAX_ENCODED_LENGTH * 2) + 16) {
+ throw invalid();
+ }
+ final var prefix = basePath + "/object/";
+ if (!rawPath.startsWith(prefix)) {
+ throw invalid();
+ }
+ final var remainder = rawPath.substring(prefix.length());
+ final var separator = remainder.indexOf('/');
+ if (separator <= 0 || separator != remainder.lastIndexOf('/') || separator == remainder.length() - 1) {
+ throw invalid();
+ }
+ final var route = new HtmxObjectRoute(
+ decodeSegment(remainder.substring(0, separator)),
+ decodeSegment(remainder.substring(separator + 1)));
+ if (!objectPath(route).equals(rawPath)) {
+ throw invalid();
+ }
+ return route;
+ }
+
+ public static String encodeSegment(final String value) {
+ validateDecoded(value);
+ final var bytes = value.getBytes(StandardCharsets.UTF_8);
+ final var encoded = new StringBuilder(bytes.length);
+ for (byte valueByte : bytes) {
+ final int unsigned = valueByte & 0xff;
+ if (isUnreserved(unsigned)) {
+ encoded.append((char) unsigned);
+ } else {
+ encoded.append('%');
+ encoded.append(Character.toUpperCase(Character.forDigit((unsigned >>> 4) & 0xf, 16)));
+ encoded.append(Character.toUpperCase(Character.forDigit(unsigned & 0xf, 16)));
+ }
+ }
+ if (encoded.length() > MAX_ENCODED_LENGTH) {
+ throw invalid();
+ }
+ return encoded.toString();
+ }
+
+ public static String decodeSegment(final String encoded) {
+ if (encoded == null || encoded.isEmpty() || encoded.length() > MAX_ENCODED_LENGTH) {
+ throw invalid();
+ }
+ final var bytes = new ByteArrayOutputStream(encoded.length());
+ for (int index = 0; index < encoded.length();) {
+ final char current = encoded.charAt(index);
+ if (current == '%') {
+ if (index + 2 >= encoded.length()) {
+ throw invalid();
+ }
+ final int high = Character.digit(encoded.charAt(index + 1), 16);
+ final int low = Character.digit(encoded.charAt(index + 2), 16);
+ if (high < 0 || low < 0) {
+ throw invalid();
+ }
+ bytes.write((high << 4) | low);
+ index += 3;
+ } else {
+ if (current > 0x7f || !isUnreserved(current)) {
+ throw invalid();
+ }
+ bytes.write(current);
+ index++;
+ }
+ }
+ final String decoded;
+ try {
+ decoded = StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes.toByteArray()))
+ .toString();
+ } catch (CharacterCodingException ex) {
+ throw invalid();
+ }
+ validateDecoded(decoded);
+ if (!encodeSegment(decoded).equals(encoded)) {
+ throw invalid();
+ }
+ return decoded;
+ }
+
+ public static String normalizeBasePath(final String configured) {
+ if (configured == null) {
+ throw new IllegalArgumentException("HTMX viewer base path is required.");
+ }
+ var value = configured.trim();
+ if (!value.startsWith("/") || value.contains("//") || value.indexOf('?') >= 0 || value.indexOf('#') >= 0) {
+ throw new IllegalArgumentException("HTMX viewer base path must be an absolute path without query or fragment.");
+ }
+ while (value.length() > 1 && value.endsWith("/")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ if ("/".equals(value) || value.length() > 200) {
+ throw new IllegalArgumentException("HTMX viewer base path must name a bounded non-root path.");
+ }
+ for (String segment : value.substring(1).split("/")) {
+ if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
+ throw new IllegalArgumentException("HTMX viewer base path contains an invalid segment.");
+ }
+ for (int index = 0; index < segment.length(); index++) {
+ final char character = segment.charAt(index);
+ if (!(isUnreserved(character) || character == '-')) {
+ throw new IllegalArgumentException("HTMX viewer base path contains an invalid character.");
+ }
+ }
+ }
+ return value;
+ }
+
+ private static void validateDecoded(final String value) {
+ if (value == null || value.isEmpty() || value.length() > MAX_DECODED_LENGTH
+ || ".".equals(value) || "..".equals(value)) {
+ throw invalid();
+ }
+ for (int offset = 0; offset < value.length();) {
+ final int codePoint = value.codePointAt(offset);
+ if (Character.isISOControl(codePoint) || Character.isSurrogate((char) codePoint)
+ || codePoint == '/' || codePoint == '\\') {
+ throw invalid();
+ }
+ offset += Character.charCount(codePoint);
+ }
+ }
+
+ private static boolean isUnreserved(final int value) {
+ return value >= 'a' && value <= 'z'
+ || value >= 'A' && value <= 'Z'
+ || value >= '0' && value <= '9'
+ || value == '-' || value == '.' || value == '_' || value == '~';
+ }
+
+ private static InvalidHtmxRouteException invalid() {
+ return new InvalidHtmxRouteException("The requested application route is invalid.");
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerController.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerController.java
new file mode 100644
index 00000000000..e93c88fc591
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerController.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.http.CacheControl;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+@RequestMapping("${causeway.viewer.webcomponents.htmx.base-path:/htmx}")
+public class HtmxViewerController {
+
+ private static final MediaType HTML_UTF8 = MediaType.parseMediaType("text/html;charset=UTF-8");
+ private static final String CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'self'; style-src 'self'; "
+ + "img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'";
+
+ private final HtmxRouteCodec routeCodec;
+ private final HtmxPageRenderer renderer;
+
+ public HtmxViewerController(final HtmxRouteCodec routeCodec, final HtmxPageRenderer renderer) {
+ this.routeCodec = routeCodec;
+ this.renderer = renderer;
+ }
+
+ @GetMapping({"", "/", "/**"})
+ public ResponseEntity<String> route(final HttpServletRequest request) {
+ final var rawPath = applicationPath(request);
+ String fragment;
+ String canonicalPath;
+ String routeState;
+ if (routeCodec.isRootPath(rawPath)) {
+ fragment = renderer.renderLandingFragment();
+ canonicalPath = routeCodec.rootPath();
+ routeState = "landing";
+ } else {
+ HtmxObjectRoute objectRoute;
+ try {
+ objectRoute = routeCodec.parseObjectPath(rawPath);
+ fragment = renderer.renderObjectFragment(objectRoute);
+ canonicalPath = routeCodec.objectPath(objectRoute);
+ routeState = "loading";
+ } catch (InvalidHtmxRouteException ex) {
+ fragment = renderer.renderInvalidRouteFragment();
+ canonicalPath = routeCodec.rootPath();
+ routeState = "invalid-route";
+ }
+ }
+
+ final var fragmentRequest = isTrue(request.getHeader("HX-Request"))
+ && !isTrue(request.getHeader("HX-History-Restore-Request"));
+ final var contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+ final var body = fragmentRequest
+ ? fragment
+ : renderer.renderShell(contextPath, fragment, canonicalPath);
+ final var response = ResponseEntity.ok()
+ .contentType(HTML_UTF8)
+ .cacheControl(CacheControl.noStore().cachePrivate())
+ .header(HttpHeaders.VARY, "HX-Request", "HX-History-Restore-Request")
+ .header("X-Causeway-Route-State", routeState);
+ if (fragmentRequest) {
+ response.header("HX-Push-Url", contextPath + canonicalPath);
+ } else {
+ response.header("Content-Security-Policy", CONTENT_SECURITY_POLICY);
+ }
+ return response.body(body);
+ }
+
+ private String applicationPath(final HttpServletRequest request) {
+ final var requestUri = request.getRequestURI();
+ final var contextPath = request.getContextPath();
+ if (contextPath == null || contextPath.isEmpty()) {
+ return requestUri;
+ }
+ if (!requestUri.startsWith(contextPath)) {
+ throw new InvalidHtmxRouteException("The requested application route is invalid.");
+ }
+ return requestUri.substring(contextPath.length());
+ }
+
+ private static boolean isTrue(final String value) {
+ return "true".equalsIgnoreCase(value);
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
new file mode 100644
index 00000000000..4ba78154cfd
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "causeway.viewer.webcomponents.htmx")
+public class HtmxViewerProperties {
+
+ private String basePath = "/htmx";
+ private String graphQlEndpoint = "/graphql";
+ private String brand = "Apache Causeway";
+ private String language = "en";
+ private String wicketComparisonPath;
+ private String applicationStylesheet;
+
+ public String getBasePath() {
+ return basePath;
+ }
+
+ public void setBasePath(final String basePath) {
+ this.basePath = basePath;
+ }
+
+ public String getGraphQlEndpoint() {
+ return graphQlEndpoint;
+ }
+
+ public void setGraphQlEndpoint(final String graphQlEndpoint) {
+ this.graphQlEndpoint = graphQlEndpoint;
+ }
+
+ public String getBrand() {
+ return brand;
+ }
+
+ public void setBrand(final String brand) {
+ this.brand = brand;
+ }
+
+ public String getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(final String language) {
+ this.language = language;
+ }
+
+ public String getWicketComparisonPath() {
+ return wicketComparisonPath;
+ }
+
+ public void setWicketComparisonPath(final String wicketComparisonPath) {
+ this.wicketComparisonPath = wicketComparisonPath;
+ }
+
+ public String getApplicationStylesheet() {
+ return applicationStylesheet;
+ }
+
+ public void setApplicationStylesheet(final String applicationStylesheet) {
+ this.applicationStylesheet = applicationStylesheet;
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/InvalidHtmxRouteException.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/InvalidHtmxRouteException.java
new file mode 100644
index 00000000000..633e87de5ad
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/InvalidHtmxRouteException.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+public final class InvalidHtmxRouteException extends IllegalArgumentException {
+
+ public InvalidHtmxRouteException(final String message) {
+ super(message);
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.css b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.css
new file mode 100644
index 00000000000..d0e063451ee
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.css
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.causeway-app-shell {
+ min-height: 100vh;
+ margin: 0;
+ background: var(--causeway-page-background);
+ color: var(--causeway-text);
+}
+
+.causeway-app-shell > causeway-graphql-client {
+ min-height: 100vh;
+ display: grid;
+ grid-template-rows: auto auto auto 1fr auto;
+}
+
+.causeway-skip-link {
+ position: fixed;
+ z-index: 10000;
+ inset-block-start: var(--causeway-space-2);
+ inset-inline-start: var(--causeway-space-2);
+ padding: var(--causeway-space-2) var(--causeway-space-3);
+ border-radius: var(--causeway-radius-sm);
+ background: var(--causeway-surface);
+ color: var(--causeway-link);
+ transform: translateY(-180%);
+}
+
+.causeway-skip-link:focus {
+ transform: translateY(0);
+}
+
+.causeway-shell-header {
+ position: sticky;
+ z-index: 100;
+ inset-block-start: 0;
+ color: var(--causeway-nav-text);
+ background: var(--causeway-nav-background);
+ box-shadow: var(--causeway-shadow-sm);
+}
+
+.causeway-shell-navbar {
+ width: min(100%, var(--causeway-shell-width));
+ min-height: var(--causeway-nav-height);
+ margin-inline: auto;
+ padding-inline: var(--causeway-space-4);
+ display: flex;
+ align-items: center;
+ gap: var(--causeway-space-3);
+}
+
+.causeway-shell-brand {
+ min-height: 2.75rem;
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--causeway-space-2);
+ color: var(--causeway-nav-text);
+ font-size: var(--causeway-font-size-lg);
+ font-weight: 650;
+ text-decoration: none;
+}
+
+.causeway-shell-brand:hover {
+ color: var(--causeway-nav-text);
+ text-decoration: none;
+}
+
+.causeway-shell-mark {
+ width: 2rem;
+ height: 2rem;
+ display: inline-grid;
+ place-items: center;
+ border: 2px solid currentColor;
+ border-radius: 50%;
+ font-weight: 800;
+}
+
+.causeway-shell-navbar > causeway-menubars {
+ min-width: 0;
+ flex: 1 1 auto;
+ flex-direction: row;
+ align-items: stretch;
+}
+
+.causeway-shell-navbar > causeway-menubars > causeway-menubar-primary {
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.causeway-shell-navbar > causeway-menubars > :is(causeway-menubar-secondary, causeway-menubar-tertiary) {
+ flex: 0 1 auto;
+}
+
+.causeway-route-loading {
+ position: fixed;
+ z-index: 120;
+ inset-block-start: calc(var(--causeway-nav-height) + var(--causeway-space-2));
+ inset-inline-end: var(--causeway-space-4);
+ padding: var(--causeway-space-2) var(--causeway-space-3);
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-pill);
+ background: var(--causeway-surface);
+ color: var(--causeway-text);
+ box-shadow: var(--causeway-shadow-md);
+}
+
+.htmx-indicator {
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.htmx-request .htmx-indicator,
+.htmx-request.htmx-indicator,
+[data-route-loading="true"] .causeway-route-loading {
+ visibility: visible;
+ opacity: 1;
+}
+
+.causeway-shell-result,
+.causeway-shell-main,
+.causeway-shell-footer {
+ width: min(calc(100% - (2 * var(--causeway-space-4))), var(--causeway-content-width));
+ margin-inline: auto;
+}
+
+.causeway-shell-result {
+ margin-block-start: var(--causeway-space-4);
+ padding: var(--causeway-space-4);
+ border: 1px solid var(--causeway-border);
+ border-inline-start: 0.3rem solid var(--causeway-info);
+ border-radius: var(--causeway-radius-md);
+ background: var(--causeway-surface);
+ box-shadow: var(--causeway-shadow-sm);
+}
+
+.causeway-shell-result[hidden] {
+ display: none;
+}
+
+.causeway-shell-result h2 {
+ margin-block: 0 var(--causeway-space-2);
+ font-size: var(--causeway-font-size-lg);
+}
+
+.causeway-result-list {
+ margin-block-end: 0;
+}
+
+.causeway-shell-main {
+ min-width: 0;
+ padding-block: var(--causeway-space-5) var(--causeway-space-8);
+}
+
+.causeway-route-page {
+ min-width: 0;
+ outline: none;
+}
+
+.causeway-shell-main .causeway-object-identity {
+ display: none;
+}
+
+.causeway-route-page:focus-visible {
+ outline: none;
+}
+
+.causeway-landing-card,
+.causeway-status-card {
+ max-width: 48rem;
+ margin: clamp(2rem, 8vh, 6rem) auto;
+ padding: clamp(var(--causeway-space-5), 5vw, var(--causeway-space-8));
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-lg);
+ background: var(--causeway-surface);
+ box-shadow: var(--causeway-shadow-md);
+}
+
+.causeway-eyebrow {
+ margin: 0 0 var(--causeway-space-2);
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.causeway-status-danger {
+ border-inline-start: 0.35rem solid var(--causeway-danger);
+}
+
+.causeway-shell-footer {
+ padding-block: var(--causeway-space-4);
+ display: flex;
+ justify-content: space-between;
+ gap: var(--causeway-space-3);
+ border-block-start: 1px solid var(--causeway-border);
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+}
+
+@media (max-width: 48rem) {
+ .causeway-shell-navbar {
+ min-height: auto;
+ padding-block: var(--causeway-space-2);
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .causeway-shell-navbar > causeway-menubars {
+ flex-basis: 100%;
+ flex-direction: column;
+ }
+
+ .causeway-shell-result,
+ .causeway-shell-main,
+ .causeway-shell-footer {
+ width: min(calc(100% - (2 * var(--causeway-space-3))), var(--causeway-content-width));
+ }
+
+ .causeway-shell-main {
+ padding-block-start: var(--causeway-space-4);
+ }
+
+ .causeway-shell-footer {
+ flex-direction: column;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .htmx-indicator,
+ .causeway-skip-link {
+ transition: none;
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
new file mode 100644
index 00000000000..3e51e264936
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/causeway-htmx.mjs
@@ -0,0 +1,304 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ ACTION_RESULT_EVENT,
+ MENU_BARS_STATE_EVENT,
+ NAVIGATION_REQUEST_EVENT,
+ OBJECT_CONTEXT_STATE_EVENT,
+ defineCausewayWebComponents,
+ requestGraphQLClient
+} from '../causeway-webcomponents/index.mjs';
+import {
+ canonicalObjectPath,
+ homeObjectIdentity,
+ resultObjectIdentity
+} from './route-policy.mjs';
+
+const shell = document.querySelector('causeway-graphql-client');
+const routeRegion = document.querySelector('#causeway-route');
+const announcement = document.querySelector('#causeway-route-announcement');
+const resultRegion = document.querySelector('#causeway-result');
+const basePath = document.documentElement.dataset.causewayHtmxBase;
+let activeRequest = null;
+let navigationGeneration = 0;
+
+defineCausewayWebComponents();
+
+function announce(message) {
+ if (announcement) {
+ announcement.textContent = '';
+ globalThis.requestAnimationFrame?.(() => {
+ announcement.textContent = message;
+ });
+ }
+}
+
+function collapseNarrowBars() {
+ if (!globalThis.matchMedia?.('(max-width: 48rem)').matches) {
+ return;
+ }
+ for (const disclosure of document.querySelectorAll('.causeway-shell-header .causeway-menubar-bar-disclosure[aria-expanded="true"]')) {
+ disclosure.click();
+ }
+}
+
+function setBusy(busy) {
+ routeRegion?.setAttribute('aria-busy', String(busy));
+ document.body.dataset.routeLoading = String(busy);
+}
+
+async function navigate(path, {replace = false} = {}) {
+ const policy = globalThis.causewayHtmxPolicy;
+ if (typeof policy?.navigate === 'function' && await policy.navigate({path, replace}) === true) {
+ return;
+ }
+ if (!globalThis.htmx || !routeRegion) {
+ globalThis.location.assign(path);
+ return;
+ }
+ const generation = ++navigationGeneration;
+ for (const disclosure of document.querySelectorAll('[data-causeway-menu-disclosure][aria-expanded="true"]')) {
+ disclosure.click();
+ }
+ resultRegion?.replaceChildren();
+ if (resultRegion) {
+ resultRegion.hidden = true;
+ }
+ setBusy(true);
+ try {
+ await globalThis.htmx.ajax('GET', path, {
+ target: routeRegion,
+ swap: 'innerHTML',
+ push: replace ? undefined : path,
+ replace: replace ? path : undefined,
+ headers: {'X-Causeway-Route-Generation': String(generation)}
+ });
+ } catch {
+ if (generation === navigationGeneration) {
+ renderRouteFailure('The requested page could not be loaded.');
+ }
+ } finally {
+ if (generation === navigationGeneration) {
+ setBusy(false);
+ }
+ }
+}
+
+function renderRouteFailure(message) {
+ if (!routeRegion) {
+ return;
+ }
+ routeRegion.replaceChildren(statusCard('Page unavailable', message, 'danger'));
+ routeRegion.querySelector('[tabindex="-1"]')?.focus();
+ announce(message);
+}
+
+function statusCard(title, message, tone = 'info') {
+ const section = document.createElement('section');
+ section.className = `causeway-route-page causeway-route-error causeway-status-${tone}`;
+ section.dataset.routeState = 'terminal-error';
+ section.tabIndex = -1;
+ const card = document.createElement('div');
+ card.className = 'causeway-status-card';
+ const heading = document.createElement('h1');
+ heading.textContent = title;
+ const paragraph = document.createElement('p');
+ paragraph.textContent = message;
+ card.append(heading, paragraph);
+ section.append(card);
+ return section;
+}
+
+function presentResult(detail) {
+ const policy = globalThis.causewayHtmxPolicy;
+ if (typeof policy?.handleResult === 'function' && policy.handleResult(detail) === true) {
+ return;
+ }
+ const identity = resultObjectIdentity(detail?.result);
+ if (identity) {
+ navigate(canonicalObjectPath(basePath, identity));
+ return;
+ }
+ if (!resultRegion) {
+ return;
+ }
+ resultRegion.replaceChildren();
+ const heading = document.createElement('h2');
+ heading.textContent = detail?.actionId ? `${detail.actionId} result` : 'Action result';
+ const output = document.createElement('output');
+ const result = detail?.result;
+ if (result?.kind === 'collection') {
+ output.textContent = `${Array.isArray(result.value) ? result.value.length : 0} results`;
+ const list = document.createElement('ul');
+ list.className = 'causeway-result-list';
+ for (const item of result.value ?? []) {
+ const metadata = item?._meta;
+ const entry = document.createElement('li');
+ if (metadata?.logicalTypeName && metadata?.id) {
+ const link = document.createElement('a');
+ link.href = canonicalObjectPath(basePath, metadata);
+ link.dataset.causewayRouteLink = '';
+ link.textContent = metadata.title ?? metadata.id;
+ entry.append(link);
+ } else {
+ entry.textContent = 'Result item';
+ }
+ list.append(entry);
+ }
+ resultRegion.append(heading, output, list);
+ } else if (result?.kind === 'scalar') {
+ output.textContent = result.value == null ? '' : String(result.value);
+ resultRegion.append(heading, output);
+ } else {
+ output.textContent = 'Completed';
+ resultRegion.append(heading, output);
+ routeRegion?.querySelector('causeway-object-context')?.context?.refresh?.();
+ }
+ resultRegion.hidden = false;
+ announce(`${heading.textContent}: ${output.textContent}`);
+}
+
+async function resolveHome() {
+ const landing = routeRegion?.querySelector('[data-route-state="landing"]');
+ if (!landing || !shell) {
+ return;
+ }
+ const client = requestGraphQLClient(shell);
+ if (!client) {
+ return;
+ }
+ try {
+ const description = await client.describeApplicationEntry();
+ if (!description.supported) {
+ landing.dataset.routeState = 'unsupported';
+ landing.querySelector('[data-causeway-home-message]').textContent = 'Choose an application action to begin.';
+ return;
+ }
+ const response = await client.readApplicationEntry({description});
+ const identity = homeObjectIdentity(response.data);
+ const policy = globalThis.causewayHtmxPolicy;
+ if (identity && typeof policy?.handleHome === 'function' && await policy.handleHome(identity) === true) {
+ return;
+ }
+ if (identity) {
+ await navigate(canonicalObjectPath(basePath, identity), {replace: true});
+ } else {
+ landing.dataset.routeState = response.errors?.length ? 'partial-error' : 'ready';
+ landing.querySelector('[data-causeway-home-message]').textContent = 'Choose an application action to begin.';
+ }
+ } catch {
+ landing.dataset.routeState = 'partial-error';
+ landing.querySelector('[data-causeway-home-message]').textContent = 'The home page is unavailable; application menus remain available.';
+ }
+}
+
+document.addEventListener('click', event => {
+ const link = event.target.closest?.('a[data-causeway-route-link]');
+ if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
+ || link.target === '_blank' || link.origin !== globalThis.location.origin) {
+ return;
+ }
+ event.preventDefault();
+ navigate(link.pathname + link.search);
+});
+
+document.addEventListener(NAVIGATION_REQUEST_EVENT, event => {
+ const target = event.detail?.target;
+ if (!target?.logicalTypeName || !target?.id) {
+ return;
+ }
+ event.preventDefault();
+ navigate(canonicalObjectPath(basePath, target));
+});
+
+document.addEventListener(MENU_BARS_STATE_EVENT, () => globalThis.setTimeout(collapseNarrowBars, 0));
+
+document.addEventListener(ACTION_RESULT_EVENT, event => {
+ presentResult(event.detail);
+ queueMicrotask(() => event.target?.dismissResult?.());
+});
+
+document.addEventListener(OBJECT_CONTEXT_STATE_EVENT, event => {
+ const contextElement = event.target;
+ if (!contextElement?.closest?.('#causeway-route')) {
+ return;
+ }
+ const page = contextElement.closest('[data-route-state]');
+ const state = event.detail?.state;
+ if (!page || !state) {
+ return;
+ }
+ page.dataset.routeState = state.status;
+ if (state.status === 'ready' || state.status === 'partial-error') {
+ const objectTitle = state.snapshot?.data?._meta?.title;
+ const brand = document.querySelector('.causeway-shell-brand > span:last-child')?.textContent?.trim();
+ if (objectTitle) {
+ document.title = brand ? `${objectTitle} · ${brand}` : objectTitle;
+ }
+ globalThis.setTimeout(() => {
+ for (const collection of routeRegion?.querySelectorAll('causeway-collection:not([active])') ?? []) {
+ collection.activate?.();
+ }
+ }, 0);
+ announce(state.status === 'ready' ? 'Page ready' : 'Page ready with partial information');
+ } else if (state.status === 'terminal-error') {
+ const code = state.errors?.[0]?.extensions?.classification ?? state.errors?.[0]?.extensions?.code;
+ page.dataset.routeState = code === 'NOT_FOUND' ? 'not-found' : code === 'ACCESS_DENIED' ? 'access-denied' : 'terminal-error';
+ announce(page.dataset.routeState === 'access-denied' ? 'Access denied' : 'Page unavailable');
+ }
+});
+
+document.body.addEventListener('htmx:beforeRequest', event => {
+ if (event.detail?.target?.id !== 'causeway-route') {
+ return;
+ }
+ activeRequest?.abort?.();
+ activeRequest = event.detail.xhr;
+ setBusy(true);
+});
+
+document.body.addEventListener('htmx:afterSwap', event => {
+ if (event.detail?.target?.id !== 'causeway-route') {
+ return;
+ }
+ activeRequest = null;
+ setBusy(false);
+ event.detail.target.querySelector('[tabindex="-1"]')?.focus();
+});
+
+document.body.addEventListener('htmx:afterRequest', event => {
+ if (event.detail?.target?.id === 'causeway-route') {
+ activeRequest = null;
+ setBusy(false);
+ }
+});
+
+document.body.addEventListener('htmx:responseError', event => {
+ if (event.detail?.target?.id === 'causeway-route') {
+ renderRouteFailure('The requested page could not be loaded.');
+ }
+});
+
+document.body.addEventListener('htmx:historyCacheMiss', () => setBusy(true));
+globalThis.matchMedia?.('(max-width: 48rem)').addEventListener?.('change', collapseNarrowBars);
+
+resolveHome();
+
+export {navigate, presentResult, resolveHome};
diff --git a/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/route-policy.mjs b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/route-policy.mjs
new file mode 100644
index 00000000000..f518a3fc70b
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/main/resources/META-INF/resources/causeway-htmx/route-policy.mjs
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export function encodeRouteSegment(value) {
+ const text = String(value ?? '');
+ if (!text || text.length > 1024 || text === '.' || text === '..' || /[\\/\u0000-\u001f\u007f]/u.test(text)) {
+ throw new Error('The requested application route is invalid.');
+ }
+ return encodeURIComponent(text).replace(/[!'()*]/g, character =>
+ `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
+}
+
+export function canonicalObjectPath(basePath, target) {
+ const base = normalizeBasePath(basePath);
+ const logicalTypeName = target?.logicalTypeName;
+ const objectId = target?.id ?? target?.objectId;
+ return `${base}/object/${encodeRouteSegment(logicalTypeName)}/${encodeRouteSegment(objectId)}`;
+}
+
+export function resultObjectIdentity(result) {
+ if (result?.kind !== 'object') {
+ return null;
+ }
+ const metadata = result.value?._meta;
+ if (!metadata?.logicalTypeName || !metadata?.id) {
+ return null;
+ }
+ return Object.freeze({logicalTypeName: metadata.logicalTypeName, id: metadata.id, title: metadata.title ?? metadata.id});
+}
+
+export function homeObjectIdentity(applicationEntry) {
+ if (applicationEntry?.home?.kind !== 'OBJECT') {
+ return null;
+ }
+ const metadata = applicationEntry.home.object?._meta;
+ const logicalTypeName = metadata?.logicalTypeName ?? applicationEntry.home.logicalTypeName;
+ if (!logicalTypeName || !metadata?.id) {
+ return null;
+ }
+ return Object.freeze({logicalTypeName, id: metadata.id, title: metadata.title ?? metadata.id});
+}
+
+export function normalizeBasePath(basePath) {
+ let value = String(basePath ?? '').trim();
+ if (!value.startsWith('/') || value.startsWith('//')) {
+ throw new Error('The HTMX viewer base path is invalid.');
+ }
+ while (value.length > 1 && value.endsWith('/')) {
+ value = value.slice(0, -1);
+ }
+ return value;
+}
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
new file mode 100644
index 00000000000..9e18c1062f8
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HtmxPageFragmentRegistryTest {
+
+ @Test
+ void selectsOnlyAnExactLogicalType() {
+ final var factory = factory("petclinic.PetOwner", "<p>owner</p>");
+ final var registry = new HtmxPageFragmentRegistry(List.of(factory));
+
+ assertThat(registry.find("petclinic.PetOwner")).contains(factory);
+ assertThat(registry.find("petclinic.SpecialPetOwner")).isEmpty();
+ }
+
+ @Test
+ void rejectsDuplicateOrBlankRegistrations() {
+ assertThatThrownBy(() -> new HtmxPageFragmentRegistry(List.of(
+ factory("petclinic.PetOwner", "first"),
+ factory("petclinic.PetOwner", "second"))))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("More than one");
+ assertThatThrownBy(() -> new HtmxPageFragmentRegistry(List.of(factory(" ", "blank"))))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("must declare");
+ }
+
+ private static HtmxPageFragmentFactory factory(final String logicalTypeName, final String html) {
+ return new HtmxPageFragmentFactory() {
+ @Override
+ public String logicalTypeName() {
+ return logicalTypeName;
+ }
+
+ @Override
+ public String render(final HtmxObjectRoute route) {
+ return html;
+ }
+ };
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodecTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodecTest.java
new file mode 100644
index 00000000000..92de7584247
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxRouteCodecTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HtmxRouteCodecTest {
+
+ private final HtmxRouteCodec codec = new HtmxRouteCodec("/apps/petclinic");
+
+ @Test
+ void roundTripsUnicodeLogicalTypeAndOpaqueIdentifier() {
+ final var route = new HtmxObjectRoute("petclinic.Pet Owner/β".replace("/", "∕"), "owner ?#42 café");
+
+ final var path = codec.objectPath(route);
+
+ assertThat(path).isEqualTo("/apps/petclinic/object/petclinic.Pet%20Owner%E2%88%95%CE%B2/owner%20%3F%2342%20caf%C3%A9");
+ assertThat(codec.parseObjectPath(path)).isEqualTo(route);
+ }
+
+ @Test
+ void supportsConfiguredDeploymentBasePath() {
+ assertThat(codec.basePath()).isEqualTo("/apps/petclinic");
+ assertThat(codec.isRootPath("/apps/petclinic/")).isTrue();
+ assertThat(codec.objectPath(new HtmxObjectRoute("petclinic.PetOwner", "1")))
+ .startsWith("/apps/petclinic/object/");
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidPaths")
+ void rejectsMalformedOrNonCanonicalPaths(final String path) {
+ assertThatThrownBy(() -> codec.parseObjectPath(path))
+ .isInstanceOf(InvalidHtmxRouteException.class)
+ .hasMessage("The requested application route is invalid.");
+ }
+
+ static Stream<String> invalidPaths() {
+ return Stream.of(
+ "/apps/petclinic/object/type",
+ "/apps/petclinic/object/type/",
+ "/apps/petclinic/object//id",
+ "/apps/petclinic/object/type/id/extra",
+ "/apps/petclinic/object/type/%",
+ "/apps/petclinic/object/type/%2f",
+ "/apps/petclinic/object/type/%2F",
+ "/apps/petclinic/object/type/%5C",
+ "/apps/petclinic/object/type/%00",
+ "/apps/petclinic/object/type/%2E%2E",
+ "/apps/petclinic/object/type/%41",
+ "/other/object/type/id");
+ }
+
+ @Test
+ void rejectsOverlongValuesAndInvalidBasePaths() {
+ assertThatThrownBy(() -> codec.objectPath(new HtmxObjectRoute("type", "x".repeat(1025))))
+ .isInstanceOf(InvalidHtmxRouteException.class);
+ assertThatThrownBy(() -> new HtmxRouteCodec("/"))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new HtmxRouteCodec("relative"))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new HtmxRouteCodec("/app/../viewer"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
new file mode 100644
index 00000000000..c8f761e7bfb
--- /dev/null
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.htmx;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HtmxViewerControllerTest {
+
+ private final HtmxViewerProperties properties = properties();
+ private final HtmxRouteCodec codec = new HtmxRouteCodec(properties.getBasePath());
+
+ @Test
+ void returnsCompleteStableShellForOrdinaryObjectRequest() {
+ final var controller = controller(List.of());
+ final var request = request("/app/htmx/object/petclinic.PetOwner/owner-1", "/app", false);
+
+ final var response = controller.route(request);
+
+ assertThat(response.getBody())
+ .contains("<!doctype html>")
+ .contains("<causeway-menubars>")
+ .contains("<main id=\"causeway-route\"")
+ .contains("<causeway-object-context logical-type=\"petclinic.PetOwner\" object-id=\"owner-1\">")
+ .contains("<causeway-object editable>")
+ .contains("<causeway-interaction-controller data-causeway-route-interactions>");
+ assertThat(response.getHeaders().getFirst("Content-Security-Policy")).contains("default-src 'self'");
+ assertThat(response.getHeaders().getFirst("HX-Push-Url")).isNull();
+ }
+
+ @Test
+ void returnsOnlyCustomFragmentAndCanonicalHistoryForHtmxRequest() {
+ final var custom = new HtmxPageFragmentFactory() {
+ @Override
+ public String logicalTypeName() {
+ return "petclinic.PetOwner";
+ }
+
+ @Override
+ public String render(final HtmxObjectRoute route) {
+ return "<article data-custom-page><causeway-property member=\"name\"></causeway-property></article>";
+ }
+ };
+ final var controller = controller(List.of(custom));
+ final var request = request("/app/htmx/object/petclinic.PetOwner/owner-1", "/app", true);
+
+ final var response = controller.route(request);
+
+ assertThat(response.getBody())
+ .doesNotContain("<!doctype html>")
+ .contains("data-page-kind=\"custom\"")
+ .contains("data-custom-page")
+ .containsOnlyOnce("<causeway-object-context");
+ assertThat(response.getHeaders().getFirst("HX-Push-Url"))
+ .isEqualTo("/app/htmx/object/petclinic.PetOwner/owner-1");
+ }
+
+ @Test
+ void rendersBoundedInvalidAndLandingStates() {
+ final var controller = controller(List.of());
+
+ final var invalid = controller.route(request("/htmx/object/type/%2F", "", true));
+ final var landing = controller.route(request("/htmx", "", false));
+
+ assertThat(invalid.getBody()).contains("data-route-state=\"invalid-route\"")
+ .doesNotContain("%2F");
+ assertThat(invalid.getHeaders().getFirst("HX-Push-Url")).isEqualTo("/htmx");
+ assertThat(landing.getBody()).contains("data-route-state=\"landing\"")
+ .contains("data-causeway-home-message");
+ }
+
+ @Test
+ void historyRestoreReceivesACompleteDocument() {
+ final var controller = controller(List.of());
+ final var request = request("/htmx/object/petclinic.PetOwner/owner-1", "", true);
+ request.addHeader("HX-History-Restore-Request", "true");
+
+ assertThat(controller.route(request).getBody()).contains("<!doctype html>");
+ }
+
+ private HtmxViewerController controller(final List<HtmxPageFragmentFactory> factories) {
+ final var registry = new HtmxPageFragmentRegistry(factories);
+ return new HtmxViewerController(codec, new HtmxPageRenderer(codec, properties, registry));
+ }
+
+ private static MockHttpServletRequest request(
+ final String requestUri,
+ final String contextPath,
+ final boolean htmx) {
+ final var request = new MockHttpServletRequest("GET", requestUri);
+ request.setRequestURI(requestUri);
+ request.setContextPath(contextPath);
+ if (htmx) {
+ request.addHeader("HX-Request", "true");
+ }
+ return request;
+ }
+
+ private static HtmxViewerProperties properties() {
+ final var properties = new HtmxViewerProperties();
+ properties.setBasePath("/htmx");
+ properties.setBrand("Pet Clinic");
+ properties.setWicketComparisonPath("/wicket/");
+ return properties;
+ }
+}
diff --git a/viewers/webcomponents/htmx/test/route-policy.test.mjs b/viewers/webcomponents/htmx/test/route-policy.test.mjs
new file mode 100644
index 00000000000..2c74838bf95
--- /dev/null
+++ b/viewers/webcomponents/htmx/test/route-policy.test.mjs
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import {readFile} from 'node:fs/promises';
+import test from 'node:test';
+import {
+ canonicalObjectPath,
+ encodeRouteSegment,
+ homeObjectIdentity,
+ resultObjectIdentity
+} from '../src/main/resources/META-INF/resources/causeway-htmx/route-policy.mjs';
+
+test('canonical route independently encodes public identity', () => {
+ assert.equal(
+ canonicalObjectPath('/app/htmx/', {logicalTypeName: 'petclinic.Pet Owner∕β', id: 'owner ?#42 café'}),
+ '/app/htmx/object/petclinic.Pet%20Owner%E2%88%95%CE%B2/owner%20%3F%2342%20caf%C3%A9'
+ );
+});
+
+test('shared cross-viewer route fixtures retain canonical HTMX meaning', async () => {
+ const fixture = await readFile(new URL('../../canonical-route-fixtures.yaml', import.meta.url), 'utf8');
+ const valid = [...fixture.matchAll(/logicalTypeName: (.+)\n\s+objectId: (.+)\n\s+path: (.+)/g)];
+ assert.equal(valid.length, 3);
+ for (const [, logicalTypeName, objectId, path] of valid) {
+ assert.equal(canonicalObjectPath('/viewer', {logicalTypeName, id: objectId}), path);
+ }
+ assert.match(fixture, /customPagePrecedence:\n\s+exactLogicalType: petclinic.HomePage/);
+ assert.match(fixture, /fallbackElement: causeway-object/);
+});
+
+test('route encoding rejects separators controls empties and dot segments', () => {
+ for (const value of ['', '.', '..', 'one/two', 'one\\two', 'one\u0000two']) {
+ assert.throws(() => encodeRouteSegment(value), /invalid/);
+ }
+});
+
+test('object results and object homes expose route identities', () => {
+ const metadata = {_meta: {logicalTypeName: 'petclinic.PetOwner', id: 'owner-1', title: 'Mary Smith'}};
+ assert.deepEqual(resultObjectIdentity({kind: 'object', value: metadata}), {
+ logicalTypeName: 'petclinic.PetOwner', id: 'owner-1', title: 'Mary Smith'
+ });
+ assert.deepEqual(homeObjectIdentity({home: {kind: 'OBJECT', logicalTypeName: 'petclinic.PetOwner', object: metadata}}), {
+ logicalTypeName: 'petclinic.PetOwner', id: 'owner-1', title: 'Mary Smith'
+ });
+ assert.equal(resultObjectIdentity({kind: 'scalar', value: 'done'}), null);
+ assert.equal(homeObjectIdentity({home: null}), null);
+});
diff --git a/viewers/webcomponents/pom.xml b/viewers/webcomponents/pom.xml
index 27ce9b994e6..092af5daf28 100644
--- a/viewers/webcomponents/pom.xml
+++ b/viewers/webcomponents/pom.xml
@@ -38,16 +38,32 @@
<properties>
<!-- The reactor-wide spring-boot:run goal must ignore non-application modules. -->
<spring-boot.run.skip>true</spring-boot.run.skip>
+ <sample-html.run.skip>true</sample-html.run.skip>
+ <sample-htmx-petclinic.run.skip>true</sample-htmx-petclinic.run.skip>
</properties>
<modules>
<module>foundation</module>
+ <module>htmx</module>
<module>sample-html</module>
+ <module>sample-htmx-petclinic</module>
</modules>
<profiles>
<profile>
<id>run-sample-html</id>
+ <properties>
+ <sample-html.run.skip>false</sample-html.run.skip>
+ </properties>
+ <build>
+ <defaultGoal>spring-boot:run</defaultGoal>
+ </build>
+ </profile>
+ <profile>
+ <id>run-sample-htmx-petclinic</id>
+ <properties>
+ <sample-htmx-petclinic.run.skip>false</sample-htmx-petclinic.run.skip>
+ </properties>
<build>
<defaultGoal>spring-boot:run</defaultGoal>
</build>
diff --git a/viewers/webcomponents/sample-html/README.adoc b/viewers/webcomponents/sample-html/README.adoc
index a742cdefd21..ec6156f62c0 100644
--- a/viewers/webcomponents/sample-html/README.adoc
+++ b/viewers/webcomponents/sample-html/README.adoc
@@ -3,7 +3,7 @@
:Notice: Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file distributed with this work for additional information regarding copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at. http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or ag [...]
The `sample-html` module is a runnable Causeway Spring Boot application and illustrative reference showcase for framework-neutral application-menu, composite-object, read-only, and interaction web components against a real rich GraphQL endpoint.
-Its frontend uses a page-specific responsive application theme built with vanilla HTML, native custom elements, ECMAScript modules, and plain CSS.
+Its frontend uses the packaged CSP-compatible structural stylesheet, cohesive Wicket-inspired design tokens, a small page-specific responsive layer, vanilla HTML, native custom elements, ECMAScript modules, and plain CSS.
It has no npm build, HTMX, Playwright, or frontend-framework dependency.
== Application architecture
@@ -438,6 +438,8 @@ Service interaction checks covered default and choice recomputation, prompt canc
A synthetic capability-absent component reached bounded `unsupported` state without an application read or resource fetch.
Cold concurrent startup completed without GraphQL schema races, and every browser GraphQL request succeeded without console errors.
Desktop light-mode and mobile dark-mode Lighthouse navigation audits scored 100 for Accessibility, Best Practices, and SEO.
+The generic-viewer styling pass replaced the page's ad hoc visual system with packaged structural CSS, shared Wicket-inspired tokens, contained cards, consistent headings and controls, container-aware property alignment, internally scrollable tables, bounded modal prompts, restored editor focus, and accessible responsive menu disclosures.
+Desktop and mobile checks found no page overflow, control overlap, failed resources, or application console messages, and the repaired sample retained 100 for Accessibility, Best Practices, and SEO in light and dark navigation audits.
On 2026-08-18 the object composite reached `data-layout-state="ready"` from one authorized effective-grid request with no diagnostics and generated one header, eight visible properties, five visible actions, and two collections while hidden members remained empty hosts.
Pointer and keyboard checks covered composite property validation and cancellation, action-prompt opening and Escape focus restoration, ArrowRight tab selection, and bounded collection-window activation with two hydrated rows.
A browser-injected partial-error context preserved a successful sibling property and announced the bounded failed member.
@@ -464,7 +466,7 @@ The sample integration test starts the application on a random port and verifies
* The rich application entry returns an authorized effective menu descriptor and bounded invalid-reference issue.
* `/graphql/application/menu-bars` returns primary, secondary, and tertiary XML using `private, no-store`, retains the disabled action, and omits hidden, malformed, and sensitive content.
* Real service wrappers expose current hidden and disabled state, defaults, choices, validation, and safe scalar invocation, while the void service mutation uses the existing top-level field.
-* `/causeway-webcomponents/index.mjs` is delivered from the foundation dependency and exports the object element and safe layout planner.
+* `/causeway-webcomponents/index.mjs`, `component-styles.css`, and `theme.css` are delivered from the foundation dependency, with synchronized structural styles and light, dark, reduced-motion, and forced-colors support.
* The authorized effective-grid resource returns XML with `private, no-store` and never contains the hidden value.
* The page includes a context-plus-`<causeway-object>` composition with stable acceptance selectors.
* Independent targeted `__type` operations resolve the root, enum-property, and collection wrapper types without triggering GraphQL Java's bad-faith introspection rejection.
diff --git a/viewers/webcomponents/sample-html/pom.xml b/viewers/webcomponents/sample-html/pom.xml
index 5ac0169907c..7f9d0ab2c36 100644
--- a/viewers/webcomponents/sample-html/pom.xml
+++ b/viewers/webcomponents/sample-html/pom.xml
@@ -37,8 +37,8 @@
<maven.deploy.skip>true</maven.deploy.skip>
<jar-plugin.automaticModuleName>org.apache.causeway.viewer.webcomponents.sample.html</jar-plugin.automaticModuleName>
<git-plugin.propertiesDir>org/apache/causeway/viewer/webcomponents/sample/html</git-plugin.propertiesDir>
- <!-- Override the reactor default so the run profile starts this application only. -->
- <spring-boot.run.skip>false</spring-boot.run.skip>
+ <!-- A parent profile selects this application without starting sibling samples. -->
+ <spring-boot.run.skip>${sample-html.run.skip}</spring-boot.run.skip>
</properties>
<dependencies>
diff --git a/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html b/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
index e993c2bc973..2d210ff0dda 100644
--- a/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
+++ b/viewers/webcomponents/sample-html/src/main/resources/static/sample-html/index.html
@@ -24,156 +24,92 @@
<meta name="description" content="Executable vanilla HTML reference showcase for Apache Causeway semantic web-component rendering and interactions.">
<link rel="icon" href="data:,">
<title>Causeway web-component interaction showcase</title>
+ <link rel="stylesheet" href="/causeway-webcomponents/component-styles.css">
+ <link rel="stylesheet" href="/causeway-webcomponents/theme.css">
<style id="sample-theme">
:root {
- color-scheme: light dark;
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- --sample-bg: #eef3f8;
- --sample-bg-accent: #dcecff;
- --sample-surface: #ffffff;
- --sample-surface-muted: #f6f8fb;
- --sample-text: #172033;
- --sample-muted: #526178;
- --sample-border: #c8d4e3;
- --sample-accent: #145ea8;
- --sample-on-accent: #ffffff;
- --sample-accent-strong: #0b477f;
- --sample-accent-soft: #e5f1ff;
- --sample-success: #146c43;
- --sample-success-soft: #e4f5eb;
- --sample-warning: #81510b;
- --sample-warning-soft: #fff2d6;
- --sample-shadow: 0 1rem 2.5rem rgb(28 52 84 / 12%);
- }
-
- @media (prefers-color-scheme: dark) {
- :root {
- --sample-bg: #111827;
- --sample-bg-accent: #162b46;
- --sample-surface: #1c2637;
- --sample-surface-muted: #222f43;
- --sample-text: #f3f7fc;
- --sample-muted: #bbc8d9;
- --sample-border: #42536b;
- --sample-accent: #7dc1ff;
- --sample-on-accent: #102238;
- --sample-accent-strong: #b7dcff;
- --sample-accent-soft: #193a5d;
- --sample-success: #7ad6a5;
- --sample-success-soft: #173f31;
- --sample-warning: #ffd28a;
- --sample-warning-soft: #4b3617;
- --sample-shadow: 0 1rem 2.5rem rgb(0 0 0 / 28%);
- }
- }
-
- * {
- box-sizing: border-box;
+ --sample-section-width: 82rem;
}
body {
- background:
- radial-gradient(circle at 15% 0%, var(--sample-bg-accent), transparent 32rem),
- var(--sample-bg);
- color: var(--sample-text);
margin: 0;
- min-block-size: 100vh;
- }
-
- button,
- output {
- font: inherit;
- }
-
- button:focus-visible,
- [role="link"]:focus-visible {
- outline: 3px solid var(--sample-accent);
- outline-offset: 3px;
+ background: var(--causeway-page-background);
+ color: var(--causeway-text);
}
.sample-shell {
+ width: min(calc(100% - (2 * var(--causeway-space-4))), var(--sample-section-width));
margin-inline: auto;
- max-inline-size: 76rem;
- padding: clamp(1rem, 3vw, 2.5rem);
+ padding-block: var(--causeway-space-5);
}
.sample-hero {
- align-items: end;
- display: grid;
- gap: 1.25rem;
- grid-template-columns: minmax(0, 1fr) auto;
- margin-block-end: 1.5rem;
- padding: clamp(0.5rem, 2vw, 1.25rem) 0;
+ min-height: 8rem;
+ margin-block-end: var(--causeway-space-5);
+ padding: var(--causeway-space-5);
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--causeway-space-5);
+ border-radius: var(--causeway-radius-lg);
+ background: var(--causeway-nav-background);
+ color: var(--causeway-nav-text);
+ box-shadow: var(--causeway-shadow-md);
+ }
+
+ .sample-hero h1 {
+ margin: 0;
+ color: inherit;
+ font-size: clamp(1.8rem, 4vw, 2.75rem);
+ font-weight: 520;
}
.sample-eyebrow,
.sample-section-kicker {
- color: var(--sample-accent-strong);
- font-size: 0.75rem;
- font-weight: 750;
- letter-spacing: 0.12em;
- margin: 0 0 0.35rem;
+ margin: 0 0 var(--causeway-space-1);
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+ font-weight: 700;
+ letter-spacing: 0.08em;
text-transform: uppercase;
}
- .sample-hero h1 {
- font-size: clamp(2rem, 5vw, 3.75rem);
- letter-spacing: -0.045em;
- line-height: 1;
- margin: 0;
- max-inline-size: 15ch;
+ .sample-hero .sample-eyebrow,
+ .sample-hero-copy {
+ color: color-mix(in srgb, var(--causeway-nav-text) 94%, transparent);
}
.sample-hero-copy,
.sample-section-heading p {
- color: var(--sample-muted);
- line-height: 1.6;
- margin: 0.75rem 0 0;
- max-inline-size: 68ch;
+ max-width: 72ch;
+ margin: var(--causeway-space-2) 0 0;
+ line-height: 1.55;
}
.sample-readiness {
- align-items: center;
- background: var(--sample-success-soft);
- border: 1px solid color-mix(in srgb, var(--sample-success) 45%, var(--sample-border));
- border-radius: 999px;
- color: var(--sample-success);
- display: inline-flex;
- font-weight: 700;
- gap: 0.55rem;
- padding: 0.55rem 0.85rem;
+ flex: 0 0 auto;
+ padding: var(--causeway-space-2) var(--causeway-space-3);
+ border: 1px solid currentColor;
+ border-radius: var(--causeway-radius-pill);
+ background: rgb(0 0 0 / 0.12);
+ font-weight: 650;
white-space: nowrap;
}
- .sample-readiness::before {
- background: currentColor;
- border-radius: 50%;
- content: "";
- inline-size: 0.6rem;
- block-size: 0.6rem;
- }
-
- [data-state="booting"] .sample-readiness,
- [data-state="schema-loading"] .sample-readiness,
- [data-state="object-loading"] .sample-readiness {
- background: var(--sample-warning-soft);
- color: var(--sample-warning);
- }
-
.sample-grid {
display: grid;
- gap: 1.25rem;
grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: var(--causeway-space-4);
}
.sample-card {
- background: var(--sample-surface);
- border: 1px solid var(--sample-border);
- border-radius: 1.15rem;
- box-shadow: var(--sample-shadow);
+ min-width: 0;
grid-column: span 12;
- overflow: hidden;
- padding: clamp(1.1rem, 3vw, 1.75rem);
+ padding: var(--causeway-space-5);
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-md);
+ background: var(--causeway-surface);
+ box-shadow: var(--causeway-shadow-sm);
}
.sample-card-object,
@@ -187,365 +123,161 @@
}
.sample-section-heading {
- border-block-end: 1px solid var(--sample-border);
- margin-block-end: 1rem;
- padding-block-end: 1rem;
+ margin: calc(-1 * var(--causeway-space-5)) calc(-1 * var(--causeway-space-5)) var(--causeway-space-4);
+ padding: var(--causeway-space-3) var(--causeway-space-5);
+ border-block-end: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-md) var(--causeway-radius-md) 0 0;
+ background: var(--causeway-surface-subtle);
}
.sample-section-heading h2 {
- font-size: clamp(1.25rem, 3vw, 1.65rem);
margin: 0;
+ font-size: 1.15rem;
}
- causeway-object-header,
- causeway-property,
- causeway-action,
- causeway-collection {
- display: block;
+ [data-testid="section-menubars"] {
+ --causeway-menubar-background: var(--causeway-surface-subtle);
+ --causeway-menubar-border: 1px solid var(--causeway-border);
+ --causeway-menubar-color: var(--causeway-text);
+ --causeway-menubar-control-active-background: color-mix(in srgb, var(--causeway-link) 12%, transparent);
+ --causeway-menu-panel-color: var(--causeway-text);
}
- causeway-object-header .causeway-object-header {
- margin: 0;
+ .sample-standalone-menus {
+ margin-block-start: var(--causeway-space-4);
+ padding: var(--causeway-space-3);
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-sm);
}
- causeway-object-header h1 {
- font-size: clamp(1.35rem, 3vw, 2rem);
- letter-spacing: -0.025em;
- margin-block: 0 0.5rem;
- }
-
- .causeway-object-identity {
- color: var(--sample-muted);
- font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
- font-size: 0.85rem;
- overflow-wrap: anywhere;
+ .sample-standalone-menus summary {
+ cursor: pointer;
+ font-weight: 650;
}
.sample-property-grid {
display: grid;
- gap: 0.8rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--causeway-space-3) var(--causeway-space-5);
}
- .sample-property-grid causeway-property {
- background: var(--sample-surface-muted);
- border: 1px solid var(--sample-border);
- border-radius: 0.8rem;
- min-inline-size: 0;
- padding: 0.9rem 1rem;
+ .sample-property-grid .causeway-property {
+ grid-template-columns: minmax(7rem, var(--causeway-label-width)) minmax(0, 1fr) auto;
}
.sample-property-grid causeway-property[data-testid="property-summary"],
.sample-property-grid causeway-property[data-testid="property-related-object"] {
- grid-column: span 2;
- }
-
- causeway-property .causeway-property {
- margin: 0;
- }
-
- .causeway-property-label {
- color: var(--sample-muted);
- display: block;
- font-size: 0.72rem;
- font-weight: 750;
- letter-spacing: 0.08em;
- margin-block-end: 0.4rem;
- text-transform: uppercase;
- }
-
- .causeway-property-description {
- color: var(--sample-muted);
- display: block;
- font-size: 0.86rem;
- line-height: 1.45;
- margin-block-end: 0.45rem;
- }
-
- .causeway-property-value {
- color: var(--sample-text);
- display: block;
- font-size: 1rem;
- font-weight: 650;
- line-height: 1.45;
- overflow-wrap: anywhere;
- }
-
- .causeway-property-disabled-reason,
- .causeway-action-disabled-reason {
- background: var(--sample-warning-soft);
- border-radius: 0.45rem;
- color: var(--sample-warning);
- font-size: 0.8rem;
- line-height: 1.4;
- margin: 0.65rem 0 0;
- padding: 0.45rem 0.55rem;
- }
-
- causeway-object-link button {
- align-items: flex-start;
- color: var(--sample-accent-strong);
- flex-direction: column;
- text-align: start;
- }
-
- .causeway-object-link-identity {
- color: var(--sample-muted);
- font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
+ grid-column: 1 / -1;
}
.sample-action-stack {
- display: grid;
- gap: 0.75rem;
- }
-
- causeway-action .causeway-action {
- align-items: start;
- display: grid;
- gap: 0.25rem;
- margin: 0;
- }
-
- causeway-action button,
- causeway-property button,
- causeway-interaction-controller button,
- [data-causeway-activate] {
- background: var(--sample-accent);
- border: 1px solid var(--sample-accent);
- border-radius: 0.6rem;
- color: var(--sample-on-accent);
- cursor: pointer;
- font-weight: 750;
- min-block-size: 2.65rem;
- padding: 0.6rem 0.95rem;
- }
-
- causeway-action button:disabled,
- causeway-property button:disabled,
- causeway-interaction-controller button:disabled {
- background: var(--sample-surface-muted);
- border-color: var(--sample-border);
- color: var(--sample-muted);
- cursor: not-allowed;
- }
-
- .causeway-property-editor input:not([type="checkbox"]),
- .causeway-property-editor select,
- .causeway-action-parameter input:not([type="checkbox"]),
- .causeway-action-parameter select {
- background: var(--sample-surface);
- border: 1px solid var(--sample-border);
- border-radius: 0.5rem;
- color: var(--sample-text);
- font: inherit;
- padding: 0.6rem 0.7rem;
- }
-
- .causeway-property-editor-actions,
- .causeway-action-prompt-actions {
- margin-block-start: 0.65rem;
- }
-
- .causeway-action-prompt {
- background: var(--sample-surface);
- border-color: var(--sample-border);
- box-shadow: var(--sample-shadow);
- color: var(--sample-text);
- }
-
- .causeway-action-prompt::backdrop {
- background: rgb(8 18 32 / 0.58);
- }
-
- .causeway-action-result {
- background: var(--sample-surface-elevated);
- border: 1px solid var(--sample-border);
- border-radius: 0.8rem;
- color: var(--sample-text);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--causeway-space-2);
}
.sample-coverage-list {
- display: grid;
- gap: 0.65rem;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- list-style: none;
margin: 0;
- padding: 0;
- }
-
- .sample-coverage-list li {
- background: var(--sample-surface-muted);
- border: 1px solid var(--sample-border);
- border-radius: 0.7rem;
- color: var(--sample-muted);
- line-height: 1.45;
- padding: 0.75rem;
+ padding-inline-start: 1.2rem;
+ display: grid;
+ gap: var(--causeway-space-2);
}
.sample-coverage-list strong {
- color: var(--sample-text);
display: block;
- margin-block-end: 0.2rem;
}
+ .sample-diagnostic-grid,
.sample-collection-stack {
+ min-width: 0;
display: grid;
- gap: 1rem;
- grid-template-columns: minmax(0, 2fr) minmax(15rem, 1fr);
+ gap: var(--causeway-space-3);
}
.sample-collection-panel {
- background: var(--sample-surface-muted);
- border: 1px solid var(--sample-border);
- border-radius: 0.85rem;
- min-inline-size: 0;
- padding: 1rem;
- }
-
- causeway-collection .causeway-collection {
- margin: 0;
- }
-
- .causeway-collection-label {
- font-size: 1.05rem;
- margin-block: 0 0.35rem;
- }
-
- .causeway-collection-description {
- color: var(--sample-muted);
- font-size: 0.86rem;
- line-height: 1.4;
- margin-block: 0 0.8rem;
- }
-
- .causeway-collection-table {
- background: var(--sample-surface);
- border: 1px solid var(--sample-border);
- border-radius: 0.6rem;
- display: table;
- overflow: hidden;
- }
-
- .causeway-collection-table th {
- background: var(--sample-accent-soft);
- color: var(--sample-accent-strong);
- font-size: 0.74rem;
- letter-spacing: 0.07em;
- text-transform: uppercase;
- }
-
- .causeway-collection-table th,
- .causeway-collection-table td {
- border-color: var(--sample-border);
- padding: 0.7rem 0.8rem;
- }
-
- .causeway-collection-table tbody tr:nth-child(even) {
- background: var(--sample-surface-muted);
- }
-
- .causeway-empty,
- .causeway-value-null {
- color: var(--sample-muted);
- }
-
- .sample-diagnostic-grid {
- display: grid;
- gap: 0.8rem;
- grid-template-columns: minmax(0, 2fr) minmax(10rem, 1fr);
+ min-width: 0;
}
.sample-diagnostic {
- background: #101827;
- border: 1px solid #324158;
- border-radius: 0.8rem;
- color: #edf6ff;
- min-inline-size: 0;
- padding: 1rem;
+ min-width: 0;
+ padding: var(--causeway-space-3);
+ border: 1px solid var(--causeway-border);
+ border-radius: var(--causeway-radius-sm);
+ background: var(--causeway-surface-subtle);
}
.sample-diagnostic-label {
- color: #a8bdd6;
display: block;
- font-size: 0.7rem;
- font-weight: 750;
- letter-spacing: 0.1em;
- margin-block-end: 0.5rem;
- text-transform: uppercase;
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
+ font-weight: 650;
}
.sample-diagnostic output {
- color: #ffffff;
display: block;
- font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
- font-size: 0.9rem;
- line-height: 1.5;
overflow-wrap: anywhere;
- }
-
- .sample-composite-grid {
- margin-block-start: 1.25rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.sample-footer {
- color: var(--sample-muted);
- font-size: 0.85rem;
- line-height: 1.5;
- margin-block-start: 1.5rem;
+ margin-block-start: var(--causeway-space-5);
+ padding-block: var(--causeway-space-4);
+ border-block-start: 1px solid var(--causeway-border);
+ color: var(--causeway-muted);
+ font-size: var(--causeway-font-size-sm);
text-align: center;
}
- [role="alert"] {
- color: #b42318;
+ @media (max-width: 60rem) {
+ .sample-card-object,
+ .sample-card-actions,
+ .sample-card-coverage,
+ .sample-card-events {
+ grid-column: span 12;
+ }
}
- @media (prefers-color-scheme: dark) {
- [role="alert"] {
- color: #ffaaa1;
+ @media (max-width: 48rem) {
+ .sample-shell {
+ width: min(calc(100% - (2 * var(--causeway-space-3))), var(--sample-section-width));
+ padding-block: var(--causeway-space-3);
}
- }
- @media (max-width: 52rem) {
.sample-hero {
- align-items: start;
- grid-template-columns: 1fr;
+ min-height: 0;
+ padding: var(--causeway-space-4);
+ align-items: flex-start;
+ flex-direction: column;
}
- .sample-card-object,
- .sample-card-actions,
- .sample-card-coverage,
- .sample-card-events {
- grid-column: span 12;
+ .sample-card {
+ padding: var(--causeway-space-4);
}
- .sample-collection-stack,
- .sample-diagnostic-grid {
- grid-template-columns: 1fr;
+ .sample-section-heading {
+ margin: calc(-1 * var(--causeway-space-4)) calc(-1 * var(--causeway-space-4)) var(--causeway-space-3);
+ padding-inline: var(--causeway-space-4);
}
- }
- @media (max-width: 36rem) {
- .sample-shell {
- padding: 0.85rem;
+ .sample-property-grid {
+ grid-template-columns: minmax(0, 1fr);
}
- .sample-card {
- border-radius: 0.85rem;
- padding: 1rem;
+ .sample-property-grid .causeway-property {
+ grid-template-columns: minmax(0, 1fr) auto;
}
- .sample-property-grid,
- .sample-coverage-list {
- grid-template-columns: 1fr;
+ .sample-property-grid :is(.causeway-property-label, .causeway-property-description) {
+ grid-column: 1 / -1;
}
- .sample-property-grid causeway-property[data-testid="property-summary"],
- .sample-property-grid causeway-property[data-testid="property-related-object"] {
- grid-column: span 1;
+ .sample-property-grid :is(.causeway-property-value, .causeway-property-editor) {
+ grid-column: 1;
}
- .causeway-collection-table {
- inline-size: 100%;
+ .sample-property-grid causeway-property[data-testid="property-summary"],
+ .sample-property-grid causeway-property[data-testid="property-related-object"] {
+ grid-column: auto;
}
}
</style>
@@ -787,9 +519,7 @@ <h2 id="sample-composite-heading">Complete object from context plus component</h
report(interactionOutput, `result:${event.detail.actionId}:${result?.kind ?? 'unknown'}${value}${target}`);
});
- const components = await import('/causeway-webcomponents/index.mjs');
- components.installCausewayComponentStyles();
- document.head.append(document.querySelector('#sample-theme'));
+ await import('/causeway-webcomponents/index.mjs');
</script>
</body>
</html>
diff --git a/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java b/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
index 3392b877dcf..de3dadeef57 100644
--- a/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-html/src/test/java/org/apache/causeway/viewer/webcomponents/sample/html/SampleHtmlApplication_IntegTest.java
@@ -125,10 +125,22 @@ void servesSamplePageAndPackagedFoundationModule() throws Exception {
.contains("data-testid=\"composite-object\"")
.contains("<causeway-object editable")
.contains("Component interaction showcase")
- .contains("prefers-color-scheme: dark")
+ .contains("/causeway-webcomponents/component-styles.css")
+ .contains("/causeway-webcomponents/theme.css")
.contains("await import('/causeway-webcomponents/index.mjs')")
.doesNotContain(SampleObject.SAMPLE_SECRET);
+ final var structuralStyles = get("/causeway-webcomponents/component-styles.css");
+ assertThat(structuralStyles.statusCode()).isEqualTo(200);
+ assertThat(structuralStyles.body()).contains(".causeway-menubar-bar-disclosure");
+
+ final var theme = get("/causeway-webcomponents/theme.css");
+ assertThat(theme.statusCode()).isEqualTo(200);
+ assertThat(theme.body())
+ .contains("prefers-color-scheme: dark")
+ .contains("prefers-reduced-motion: reduce")
+ .contains("forced-colors: active");
+
final var module = get("/causeway-webcomponents/index.mjs");
assertThat(module.statusCode()).isEqualTo(200);
assertThat(module.body())
diff --git a/viewers/webcomponents/sample-htmx-petclinic/PROVENANCE.adoc b/viewers/webcomponents/sample-htmx-petclinic/PROVENANCE.adoc
new file mode 100644
index 00000000000..c0372e99128
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/PROVENANCE.adoc
@@ -0,0 +1,20 @@
+= Petclinic source provenance
+
+// Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.
+// See the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The ASF licenses this file to you under the Apache License, Version 2.0.
+
+This executable sample is a current-Apache-Causeway port of material from https://github.com/apache/causeway-app-petclinic[`apache/causeway-app-petclinic`].
+The pinned source revision is `16a10608129ca9ce8ae04d21df1462f4d69ac018`, dated 2026-05-17.
+Both the source application and this repository are licensed under the Apache License, Version 2.0.
+
+The port retains the recognizable Pet Owner, Pet, Visit, Pet Owners service, visit contribution, home-page dashboard, deterministic sample data, menu bars, and effective object-grid concepts.
+Class and member names, labels, validation examples, choices, defaults, collection relationships, and representative actions follow the source where current public contracts support them.
+
+The source targets Apache Causeway 2.0.0 and Java 11.
+This port uses the current reactor's Java, Jakarta Persistence, Causeway programming model, GraphQL application entry, structural resources, collection windows, and web-component contracts.
+Stable string identifiers replace generated numeric identifiers so executable browser routes remain deterministic.
+The custom email value, Blob/PDF attachment, full-calendar integration, starter SimpleObject module, SecMan administration, session/audit/command/execution extensions, Quartz, Flyway, REST examples, and historical deployment configuration are intentionally omitted because they do not contribute to the generic-viewer acceptance scope.
+
+The current Causeway Wicket viewer is enabled in the sample at `/wicket/` solely as a side-by-side visual and interaction reference.
+The reusable HTMX viewer and framework-neutral component modules do not depend on Wicket, Petclinic, or Bootstrap.
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
new file mode 100644
index 00000000000..9b8ee34695a
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -0,0 +1,66 @@
+= HTMX Petclinic reference application
+
+// Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.
+// See the NOTICE file distributed with this work for additional information regarding copyright ownership.
+// The ASF licenses this file to you under the Apache License, Version 2.0.
+
+This module is the cohesive executable acceptance application for the generic HTMX web-component viewer.
+It ports the Pet Owner, Pet, Visit, home, fixture, menu, and grid concepts from the pinned Apache Petclinic source recorded in `PROVENANCE.adoc`.
+
+The application runs the GraphQL, generic HTMX, and current Wicket viewers over one Causeway metamodel, H2 database, security policy, effective menu definition, effective grid definitions, and deterministic fixture.
+The Wicket viewer is a side-by-side presentation reference rather than a dependency of the generic HTMX module.
+
+== Run
+
+Use JDK 17 or later.
+
+[source,bash]
+----
+mvn -f viewers/webcomponents/pom.xml -Prun-sample-htmx-petclinic
+----
+
+The root URL redirects to `http://localhost:8080/htmx`.
+The Wicket comparison is available at `http://localhost:8080/wicket/`.
+Sign in to Wicket with username `sven` and password `pass`; the bypass authenticator accepts the demonstration credentials.
+GraphiQL remains available through the configured Spring GraphQL endpoint when enabled.
+
+== Stable fixture routes
+
+The HTMX root discovers the configured `petclinic.HomePage` object through targeted application-entry introspection and replaces the landing page with its canonical object route.
+The home memento identifier is execution-generated, so tests discover it rather than treating it as durable.
+
+Mary Smith is the stable representative entity.
+Her database identifier is `owner-mary`, her GraphQL object identifier is `s_owner-mary`, and her canonical generic route is `/htmx/object/petclinic.PetOwner/s_owner-mary`.
+The corresponding Wicket route is `/wicket/object/petclinic.PetOwner:s_owner-mary` after authentication.
+
+The sample contains four pet owners, five pets, and three upcoming visits.
+Mary owns Basil and Samantha and has two scheduled visits.
+
+== Viewer coverage
+
+The custom home fragment is registered only for exact logical type `petclinic.HomePage`.
+It composes a standard object header and declared collection tables beneath the viewer-provided route context.
+Pet Owner, Pet, and Visit routes exercise generic `<causeway-object>` fallback and their effective grids.
+
+The primary menus cover Pet Owner creation, search, list, and upcoming visits.
+Object pages cover editable properties, enum values, references, tabs, grouped actions, choices, defaults, validation, related Pets, contributed Visits, and contributed booking.
+The sample's current integration tests cover fixture identities, application home, menu and grid resources, rich service and object operations, shell and fragment responses, custom precedence, generic fallback, browser assets, and Wicket availability.
+
+== Wicket-relative presentation
+
+The HTMX theme intentionally follows Wicket's recognizable hierarchy rather than copying its DOM.
+Both viewers present a primary-color application bar, ordered menus, contained page content, object title and actions, cards, tabs, labels and values, related-object links, compact collections, modal interactions, and responsive navigation.
+Framework-specific URLs, markup, table widgets, authentication chrome, and lifecycle behavior remain different.
+
+Browser acceptance records desktop and mobile screenshots of home and Mary Smith routes, checks responsive light and dark presentation, and verifies keyboard focus, prompts, navigation history, overflow, console output, and accessibility.
+
+On 2026-08-19 the current Wicket and HTMX home and Mary Smith pages were compared over the same running model.
+The HTMX shell matched Wicket's primary navigation, right-aligned system menu, contained content, object hierarchy, actions, tabs, labels, collection cards, related-object links, and responsive collapse while retaining independent semantic markup.
+Canonical direct loads, home replacement, custom-page precedence, generic fallback, menu-originated collection results, object-result routing, back, forward, invalid routes, absent objects, automatic first collection windows, prompt validation, Escape cancellation, and focus restoration were exercised in a real browser.
+Desktop light-mode and mobile dark-mode Lighthouse navigation audits each scored 100 for Accessibility, Best Practices, and SEO.
+No HTMX application console messages, failed GraphQL requests, page overflow, or overlapping controls were observed.
+
+== Provenance and omissions
+
+Read `PROVENANCE.adoc` before copying or expanding this sample.
+The port intentionally omits the upstream starter SimpleObject module and historical operational, security-administration, calendar, PDF, Flyway, Quartz, and deployment integrations that are not needed to demonstrate the generic viewer.
diff --git a/viewers/webcomponents/sample-html/pom.xml b/viewers/webcomponents/sample-htmx-petclinic/pom.xml
similarity index 85%
copy from viewers/webcomponents/sample-html/pom.xml
copy to viewers/webcomponents/sample-htmx-petclinic/pom.xml
index 5ac0169907c..c5e636c5c33 100644
--- a/viewers/webcomponents/sample-html/pom.xml
+++ b/viewers/webcomponents/sample-htmx-petclinic/pom.xml
@@ -29,16 +29,15 @@
<version>4.0.0-SNAPSHOT</version>
</parent>
- <artifactId>causeway-viewer-webcomponents-sample-html</artifactId>
- <name>Apache Causeway Viewer - Web Components (Vanilla HTML Sample)</name>
+ <artifactId>causeway-viewer-webcomponents-sample-htmx-petclinic</artifactId>
+ <name>Apache Causeway Viewer - Web Components (HTMX Petclinic Sample)</name>
<properties>
<maven.install.skip>true</maven.install.skip>
<maven.deploy.skip>true</maven.deploy.skip>
- <jar-plugin.automaticModuleName>org.apache.causeway.viewer.webcomponents.sample.html</jar-plugin.automaticModuleName>
- <git-plugin.propertiesDir>org/apache/causeway/viewer/webcomponents/sample/html</git-plugin.propertiesDir>
- <!-- Override the reactor default so the run profile starts this application only. -->
- <spring-boot.run.skip>false</spring-boot.run.skip>
+ <jar-plugin.automaticModuleName>org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic</jar-plugin.automaticModuleName>
+ <git-plugin.propertiesDir>org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic</git-plugin.propertiesDir>
+ <spring-boot.run.skip>${sample-htmx-petclinic.run.skip}</spring-boot.run.skip>
</properties>
<dependencies>
@@ -51,6 +50,14 @@
<groupId>org.apache.causeway.viewer</groupId>
<artifactId>causeway-viewer-graphql-viewer</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.causeway.viewer</groupId>
+ <artifactId>causeway-viewer-webcomponents-htmx</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.causeway.viewer</groupId>
+ <artifactId>causeway-viewer-wicket-viewer</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.causeway.persistence</groupId>
<artifactId>causeway-persistence-jpa-eclipselink</artifactId>
@@ -59,10 +66,6 @@
<groupId>org.apache.causeway.security</groupId>
<artifactId>causeway-security-bypass</artifactId>
</dependency>
- <dependency>
- <groupId>org.apache.causeway.viewer</groupId>
- <artifactId>causeway-viewer-webcomponents-foundation</artifactId>
- </dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHomeFragmentFactory.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHomeFragmentFactory.java
new file mode 100644
index 00000000000..4e3f8151de7
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHomeFragmentFactory.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic;
+
+import org.springframework.stereotype.Component;
+
+import org.apache.causeway.viewer.webcomponents.htmx.HtmxObjectRoute;
+import org.apache.causeway.viewer.webcomponents.htmx.HtmxPageFragmentFactory;
+
+@Component
+public class PetClinicHomeFragmentFactory implements HtmxPageFragmentFactory {
+
+ @Override
+ public String logicalTypeName() {
+ return "petclinic.HomePage";
+ }
+
+ @Override
+ public String render(final HtmxObjectRoute route) {
+ return """
+ <article class="petclinic-dashboard" data-testid="petclinic-custom-home">
+ <causeway-object-header></causeway-object-header>
+ <div class="petclinic-dashboard-grid">
+ <section aria-labelledby="petclinic-owner-heading">
+ <h2 id="petclinic-owner-heading">Pet owners</h2>
+ <causeway-collection member="petOwners" label="Pet owners" active offset="0" size="10">
+ <causeway-collection-column member="name" label="Owner"></causeway-collection-column>
+ <causeway-collection-column member="telephoneNumber" label="Telephone"></causeway-collection-column>
+ <causeway-collection-column member="emailAddress" label="Email"></causeway-collection-column>
+ </causeway-collection>
+ </section>
+ <section aria-labelledby="petclinic-visit-heading">
+ <h2 id="petclinic-visit-heading">Upcoming visits</h2>
+ <causeway-collection member="futureVisits" label="Upcoming visits" active offset="0" size="10">
+ <causeway-collection-column member="visitAt" label="When"></causeway-collection-column>
+ <causeway-collection-column member="reason" label="Reason"></causeway-collection-column>
+ <causeway-collection-column member="notes" label="Notes"></causeway-collection-column>
+ </causeway-collection>
+ </section>
+ </div>
+ </article>
+ """;
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication.java
new file mode 100644
index 00000000000..dbf638d27eb
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+
+import org.apache.causeway.core.config.presets.CausewayPresets;
+import org.apache.causeway.core.runtimeservices.CausewayModuleCoreRuntimeServices;
+import org.apache.causeway.persistence.jpa.eclipselink.CausewayModulePersistenceJpaEclipselink;
+import org.apache.causeway.security.bypass.CausewayModuleSecurityBypass;
+import org.apache.causeway.viewer.graphql.viewer.CausewayModuleViewerGraphqlViewer;
+import org.apache.causeway.viewer.webcomponents.htmx.CausewayModuleViewerWebcomponentsHtmx;
+import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.PetClinicDomainModule;
+import org.apache.causeway.viewer.wicket.viewer.CausewayModuleViewerWicketViewer;
+
+@SpringBootApplication
+@Import({
+ CausewayModuleCoreRuntimeServices.class,
+ CausewayModuleSecurityBypass.class,
+ CausewayModulePersistenceJpaEclipselink.class,
+ CausewayModuleViewerGraphqlViewer.class,
+ CausewayModuleViewerWebcomponentsHtmx.class,
+ CausewayModuleViewerWicketViewer.class,
+ PetClinicDomainModule.class
+})
+@PropertySources({
+ @PropertySource(CausewayPresets.H2InMemory_withUniqueSchema),
+ @PropertySource(CausewayPresets.SilenceMetaModel),
+ @PropertySource(CausewayPresets.SilenceProgrammingModel)
+})
+public class PetClinicHtmxApplication extends SpringBootServletInitializer {
+
+ public static void main(final String[] args) {
+ SpringApplication.run(PetClinicHtmxApplication.class, args);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicLandingController.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicLandingController.java
new file mode 100644
index 00000000000..b457edd7774
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicLandingController.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Controller
+public class PetClinicLandingController {
+
+ @GetMapping("/")
+ public String home() {
+ return "redirect:/htmx";
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
new file mode 100644
index 00000000000..2513ff45161
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.Comparator;
+import java.util.Set;
+import java.util.TreeSet;
+
+import jakarta.inject.Named;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+
+@Entity
+@Table(name = "petclinic_pet")
+@Named(PetClinicDomainModule.NAMESPACE + ".Pet")
+@DomainObject
+@DomainObjectLayout
+public class Pet implements Comparable<Pet> {
+
+ @Id
+ @Column(nullable = false, length = 40)
+ private String id;
+
+ @Version
+ @Column(nullable = false)
+ @PropertyLayout(fieldSetId = "metadata", sequence = "999")
+ private long version;
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "owner_id", nullable = false)
+ @PropertyLayout(fieldSetId = "identity", sequence = "1")
+ private PetOwner petOwner;
+
+ @Column(nullable = false, length = 40)
+ @PropertyLayout(fieldSetId = "identity", sequence = "2")
+ private String name;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ @PropertyLayout(fieldSetId = "details", sequence = "1")
+ private PetSpecies species;
+
+ @Property(editing = Editing.ENABLED)
+ @Column(length = 400)
+ @PropertyLayout(fieldSetId = "details", sequence = "2")
+ private String notes;
+
+ @OneToMany(mappedBy = "pet", cascade = CascadeType.ALL, orphanRemoval = true)
+ private Set<Visit> visits = new TreeSet<>();
+
+ protected Pet() {
+ }
+
+ public Pet(final String id, final PetOwner petOwner, final String name, final PetSpecies species) {
+ this.id = id;
+ this.petOwner = petOwner;
+ this.name = name;
+ this.species = species;
+ }
+
+ @ObjectSupport
+ public String title() {
+ return name + " · " + species.name().toLowerCase();
+ }
+
+ @ObjectSupport
+ public String iconName() {
+ return species.name().toLowerCase();
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public long getVersion() {
+ return version;
+ }
+
+ public PetOwner getPetOwner() {
+ return petOwner;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(final String name) {
+ this.name = name;
+ }
+
+ public PetSpecies getSpecies() {
+ return species;
+ }
+
+ public void setSpecies(final PetSpecies species) {
+ this.species = species;
+ }
+
+ public String getNotes() {
+ return notes;
+ }
+
+ public void setNotes(final String notes) {
+ this.notes = notes;
+ }
+
+ @Override
+ public int compareTo(final Pet other) {
+ return Comparator.comparing(Pet::getName).thenComparing(Pet::getId).compare(this, other);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDataConfiguration.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDataConfiguration.java
new file mode 100644
index 00000000000..73975f17934
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDataConfiguration.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDate;
+
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.transaction.annotation.Propagation;
+
+import org.apache.causeway.applib.services.clock.ClockService;
+import org.apache.causeway.applib.services.xactn.TransactionService;
+
+@Configuration
+public class PetClinicDataConfiguration {
+
+ @Bean
+ ApplicationRunner loadPetClinicData(
+ final TransactionService transactionService,
+ final ClockService clockService,
+ final PetOwnerRepository ownerRepository,
+ final VisitRepository visitRepository) {
+ return args -> transactionService.runTransactional(Propagation.REQUIRED, () -> {
+ if (ownerRepository.findById(PetOwner.MARY_ID) != null) {
+ return;
+ }
+ final LocalDate today = clockService.getClock().nowAsLocalDate();
+
+ final var mary = owner("owner-mary", "Mary Smith", "Mary", "020 7946 0312", "mary@example.com", "Prefers morning appointments.", today.minusDays(18));
+ final var basil = pet("pet-basil", mary, "Basil", PetSpecies.DOG, "Friendly terrier");
+ final var samantha = pet("pet-samantha", mary, "Samantha", PetSpecies.CAT, "Indoor cat");
+ mary.addSeedPet(basil);
+ mary.addSeedPet(samantha);
+ ownerRepository.persist(mary);
+
+ final var james = owner("owner-james", "James Carter", "Jim", "020 7946 0544", "james@example.com", "Call before booking.", today.minusDays(41));
+ final var leo = pet("pet-leo", james, "Leo", PetSpecies.HAMSTER, "Very small carrier");
+ james.addSeedPet(leo);
+ ownerRepository.persist(james);
+
+ final var helen = owner("owner-helen", "Helen Leary", null, "020 7946 0780", "helen@example.com", null, today.minusDays(7));
+ final var max = pet("pet-max", helen, "Max", PetSpecies.DOG, "Allergic to chicken");
+ helen.addSeedPet(max);
+ ownerRepository.persist(helen);
+
+ final var peter = owner("owner-peter", "Peter McTavish", "Pete", "020 7946 0911", "peter@example.com", "New client", null);
+ final var tweety = pet("pet-tweety", peter, "Tweety", PetSpecies.BIRD, null);
+ peter.addSeedPet(tweety);
+ ownerRepository.persist(peter);
+
+ visitRepository.persist(new Visit("visit-basil-checkup", basil, today.plusDays(1).atTime(9, 30), "Annual check-up"));
+ visitRepository.persist(new Visit("visit-samantha-vaccine", samantha, today.plusDays(3).atTime(11, 0), "Vaccination"));
+ visitRepository.persist(new Visit("visit-max-followup", max, today.plusDays(7).atTime(14, 15), "Diet follow-up"));
+ });
+ }
+
+ private static PetOwner owner(
+ final String id,
+ final String name,
+ final String knownAs,
+ final String phone,
+ final String email,
+ final String notes,
+ final LocalDate lastVisit) {
+ final var owner = new PetOwner(id, name);
+ owner.setKnownAs(knownAs);
+ owner.setTelephoneNumber(phone);
+ owner.setEmailAddress(email);
+ owner.setNotes(notes);
+ owner.setLastVisit(lastVisit);
+ return owner;
+ }
+
+ private static Pet pet(
+ final String id,
+ final PetOwner owner,
+ final String name,
+ final PetSpecies species,
+ final String notes) {
+ final var pet = new Pet(id, owner, name, species);
+ pet.setNotes(notes);
+ return pet;
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDomainModule.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDomainModule.java
new file mode 100644
index 00000000000..08fffd69475
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicDomainModule.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import org.springframework.boot.persistence.autoconfigure.EntityScan;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@ComponentScan
+@EntityScan(basePackageClasses = PetClinicDomainModule.class)
+public class PetClinicDomainModule {
+
+ public static final String NAMESPACE = "petclinic";
+}
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
new file mode 100644
index 00000000000..0851b3203da
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.apache.causeway.applib.annotation.Collection;
+import org.apache.causeway.applib.annotation.CollectionLayout;
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.HomePage;
+import org.apache.causeway.applib.annotation.Nature;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.services.clock.ClockService;
+
+@Named(PetClinicDomainModule.NAMESPACE + ".HomePage")
+@DomainObject(nature = Nature.VIEW_MODEL)
+@DomainObjectLayout
+@HomePage
+public class PetClinicHomePage {
+
+ @Inject
+ private PetOwners petOwners;
+
+ @Inject
+ private VisitRepository visitRepository;
+
+ @Inject
+ private ClockService clockService;
+
+ @ObjectSupport
+ public String title() {
+ return getPetOwners().size() + " pet owners · " + getFutureVisits().size() + " upcoming visits";
+ }
+
+ @Collection
+ @CollectionLayout(paged = 10)
+ public List<PetOwner> getPetOwners() {
+ return petOwners.listAll();
+ }
+
+ @Collection
+ @CollectionLayout(paged = 10)
+ 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
new file mode 100644
index 00000000000..e871e6a9936
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import jakarta.persistence.Transient;
+import jakarta.persistence.Version;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.BookmarkPolicy;
+import org.apache.causeway.applib.annotation.Collection;
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.annotation.Optionality;
+import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.clock.ClockService;
+import org.apache.causeway.applib.services.message.MessageService;
+import org.apache.causeway.applib.services.repository.RepositoryService;
+import org.apache.causeway.applib.services.title.TitleService;
+
+@Entity
+@Table(name = "petclinic_pet_owner")
+@Named(PetClinicDomainModule.NAMESPACE + ".PetOwner")
+@DomainObject
+@DomainObjectLayout(bookmarking = BookmarkPolicy.AS_ROOT)
+public class PetOwner implements Comparable<PetOwner> {
+
+ public static final String MARY_ID = "owner-mary";
+
+ @Id
+ @Column(nullable = false, length = 40)
+ private String id;
+
+ @Version
+ @Column(nullable = false)
+ @PropertyLayout(fieldSetId = "metadata", sequence = "999")
+ private long version;
+
+ @Column(nullable = false, unique = true, length = 80)
+ @PropertyLayout(fieldSetId = "identity", sequence = "1")
+ private String name;
+
+ @Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL)
+ @Column(length = 40)
+ @PropertyLayout(fieldSetId = "identity", sequence = "2")
+ private String knownAs;
+
+ @Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL)
+ @Column(length = 40)
+ @PropertyLayout(fieldSetId = "contact", sequence = "1")
+ private String telephoneNumber;
+
+ @Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL)
+ @Column(length = 120)
+ @PropertyLayout(fieldSetId = "contact", sequence = "2")
+ private String emailAddress;
+
+ @Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL)
+ @Column(length = 400)
+ @PropertyLayout(fieldSetId = "details", sequence = "1")
+ private String notes;
+
+ @Property(editing = Editing.ENABLED, optionality = Optionality.OPTIONAL)
+ @Column
+ @PropertyLayout(fieldSetId = "details", sequence = "2")
+ private LocalDate lastVisit;
+
+ @Collection
+ @OneToMany(mappedBy = "petOwner", cascade = CascadeType.ALL, orphanRemoval = true)
+ private Set<Pet> pets = new TreeSet<>();
+
+ @Inject
+ @Transient
+ private ClockService clockService;
+
+ @Inject
+ @Transient
+ private RepositoryService repositoryService;
+
+ @Inject
+ @Transient
+ private TitleService titleService;
+
+ @Inject
+ @Transient
+ private MessageService messageService;
+
+ protected PetOwner() {
+ }
+
+ public PetOwner(final String id, final String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ @ObjectSupport
+ public String title() {
+ return name + (knownAs == null || knownAs.isBlank() ? "" : " (" + knownAs + ")");
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public long getVersion() {
+ return version;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(final String name) {
+ this.name = name;
+ }
+
+ public String getKnownAs() {
+ return knownAs;
+ }
+
+ public void setKnownAs(final String knownAs) {
+ this.knownAs = knownAs;
+ }
+
+ public String getTelephoneNumber() {
+ return telephoneNumber;
+ }
+
+ public void setTelephoneNumber(final String telephoneNumber) {
+ this.telephoneNumber = telephoneNumber;
+ }
+
+ public String getEmailAddress() {
+ return emailAddress;
+ }
+
+ public void setEmailAddress(final String emailAddress) {
+ this.emailAddress = emailAddress;
+ }
+
+ public String getNotes() {
+ return notes;
+ }
+
+ public void setNotes(final String notes) {
+ this.notes = notes;
+ }
+
+ public LocalDate getLastVisit() {
+ return lastVisit;
+ }
+
+ public void setLastVisit(final LocalDate lastVisit) {
+ this.lastVisit = lastVisit;
+ }
+
+ public Set<Pet> getPets() {
+ return pets;
+ }
+
+ @Property
+ @PropertyLayout(fieldSetId = "details", sequence = "3")
+ public Long getDaysSinceLastVisit() {
+ return lastVisit == null || clockService == null
+ ? null
+ : ChronoUnit.DAYS.between(lastVisit, clockService.getClock().nowAsLocalDate());
+ }
+
+ @Action
+ @ActionLayout(associateWith = "pets", sequence = "1")
+ public PetOwner addPet(
+ @Parameter(maxLength = 40) final String name,
+ final PetSpecies species) {
+ final var pet = new Pet("pet-" + UUID.randomUUID(), this, name, species);
+ pets.add(pet);
+ return this;
+ }
+
+ @MemberSupport
+ public String validate0AddPet(final String candidateName) {
+ return pets.stream().anyMatch(pet -> Objects.equals(pet.getName(), candidateName))
+ ? "This owner already has a pet called '" + candidateName + "'."
+ : null;
+ }
+
+ @Action(choicesFrom = "pets")
+ @ActionLayout(associateWith = "pets", sequence = "2")
+ public PetOwner removePet(final Pet pet) {
+ pets.remove(pet);
+ return this;
+ }
+
+ @Action(semantics = SemanticsOf.IDEMPOTENT)
+ @ActionLayout(describedAs = "Updates the owner's full name.")
+ public PetOwner updateName(@Parameter(maxLength = 80) final String name) {
+ setName(name);
+ return this;
+ }
+
+ @MemberSupport
+ public String default0UpdateName() {
+ return name;
+ }
+
+ @MemberSupport
+ public String validate0UpdateName(final String candidateName) {
+ if (candidateName == null || candidateName.isBlank()) {
+ return "A name is required.";
+ }
+ return candidateName.matches(".*[!&%].*") ? "The name cannot contain !, &, or %." : null;
+ }
+
+ @Action(semantics = SemanticsOf.NON_IDEMPOTENT_ARE_YOU_SURE)
+ @ActionLayout(describedAs = "Deletes this pet owner and their related pets and visits.")
+ public void delete() {
+ final var title = titleService.titleOf(this);
+ repositoryService.removeAndFlush(this);
+ messageService.informUser("'" + title + "' deleted");
+ }
+
+ void addSeedPet(final Pet pet) {
+ pets.add(pet);
+ }
+
+ @Override
+ public int compareTo(final PetOwner other) {
+ return Comparator.comparing(PetOwner::getName).thenComparing(PetOwner::getId).compare(this, other);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerRepository.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerRepository.java
new file mode 100644
index 00000000000..98603ced8a1
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwnerRepository.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.List;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+
+import org.springframework.stereotype.Repository;
+
+@Repository
+public class PetOwnerRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ public PetOwner findById(final String id) {
+ return entityManager.find(PetOwner.class, id);
+ }
+
+ public PetOwner findByName(final String name) {
+ return entityManager.createQuery(
+ "select p from PetOwner p where lower(p.name) = lower(:name)", PetOwner.class)
+ .setParameter("name", name)
+ .getResultStream()
+ .findFirst()
+ .orElse(null);
+ }
+
+ public List<PetOwner> findByNameContaining(final String name) {
+ return entityManager.createQuery(
+ "select p from PetOwner p where lower(p.name) like lower(:name) order by p.name", PetOwner.class)
+ .setParameter("name", "%" + name + "%")
+ .getResultList();
+ }
+
+ public List<PetOwner> findAll() {
+ return entityManager.createQuery("select p from PetOwner p order by p.name", PetOwner.class)
+ .getResultList();
+ }
+
+ public long count() {
+ return entityManager.createQuery("select count(p) from PetOwner p", Long.class).getSingleResult();
+ }
+
+ public PetOwner persist(final PetOwner owner) {
+ entityManager.persist(owner);
+ entityManager.flush();
+ return owner;
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_bookVisit.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_bookVisit.java
new file mode 100644
index 00000000000..7fc6ad443e0
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_bookVisit.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+import java.util.UUID;
+
+import jakarta.inject.Inject;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.MemberSupport;
+import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.services.clock.ClockService;
+
+@Action
+@ActionLayout(associateWith = "visits")
+public class PetOwner_bookVisit {
+
+ private final PetOwner petOwner;
+
+ @Inject
+ private ClockService clockService;
+
+ @Inject
+ private VisitRepository visitRepository;
+
+ public PetOwner_bookVisit(final PetOwner petOwner) {
+ this.petOwner = petOwner;
+ }
+
+ @MemberSupport
+ public PetOwner act(
+ final Pet pet,
+ final LocalDateTime visitAt,
+ @Parameter(maxLength = 120) final String reason) {
+ visitRepository.persist(new Visit("visit-" + UUID.randomUUID(), pet, visitAt, reason));
+ return petOwner;
+ }
+
+ @MemberSupport
+ public Set<Pet> choices0Act() {
+ return petOwner.getPets();
+ }
+
+ @MemberSupport
+ public Pet default0Act() {
+ return petOwner.getPets().size() == 1 ? petOwner.getPets().iterator().next() : null;
+ }
+
+ @MemberSupport
+ public LocalDateTime default1Act() {
+ return nextOfficeOpening();
+ }
+
+ @MemberSupport
+ public String default2Act() {
+ return "Routine check-up";
+ }
+
+ @MemberSupport
+ public String validate1Act(final LocalDateTime visitAt) {
+ return visitAt != null && visitAt.isBefore(nextOfficeOpening())
+ ? "The visit must be booked in the future."
+ : null;
+ }
+
+ private LocalDateTime nextOfficeOpening() {
+ return clockService.getClock().nowAsLocalDate().plusDays(1).atTime(9, 0);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_visits.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_visits.java
new file mode 100644
index 00000000000..4c50ff69782
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner_visits.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.List;
+
+import jakarta.inject.Inject;
+
+import org.apache.causeway.applib.annotation.Collection;
+import org.apache.causeway.applib.annotation.MemberSupport;
+
+@Collection
+public class PetOwner_visits {
+
+ private final PetOwner petOwner;
+
+ @Inject
+ private VisitRepository visitRepository;
+
+ public PetOwner_visits(final PetOwner petOwner) {
+ this.petOwner = petOwner;
+ }
+
+ @MemberSupport
+ public List<Visit> coll() {
+ return visitRepository.findByPetOwner(petOwner);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
new file mode 100644
index 00000000000..97772843dee
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwners.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.List;
+
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.ActionLayout;
+import org.apache.causeway.applib.annotation.DomainService;
+import org.apache.causeway.applib.annotation.Optionality;
+import org.apache.causeway.applib.annotation.Parameter;
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+
+@Named(PetClinicDomainModule.NAMESPACE + ".PetOwners")
+@DomainService
+@Priority(PriorityPrecedence.EARLY)
+public class PetOwners {
+
+ private final PetOwnerRepository repository;
+
+ @Inject
+ public PetOwners(final PetOwnerRepository repository) {
+ this.repository = repository;
+ }
+
+ @Action(semantics = SemanticsOf.NON_IDEMPOTENT)
+ @ActionLayout(describedAs = "Registers a new pet owner.")
+ public PetOwner create(
+ @Parameter(maxLength = 80) final String name,
+ @Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) final String knownAs,
+ @Parameter(maxLength = 40, optionality = Optionality.OPTIONAL) final String telephoneNumber,
+ @Parameter(maxLength = 120, optionality = Optionality.OPTIONAL) final String emailAddress) {
+ final var owner = new PetOwner("owner-" + (repository.count() + 1), name);
+ owner.setKnownAs(knownAs);
+ owner.setTelephoneNumber(telephoneNumber);
+ owner.setEmailAddress(emailAddress);
+ return repository.persist(owner);
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ public List<PetOwner> findByName(@Parameter(maxLength = 80) final String name) {
+ return repository.findByNameContaining(name);
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ public List<PetOwner> findByNameLike(@Parameter(maxLength = 80) final String name) {
+ return repository.findByNameContaining(name);
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ public List<PetOwner> listAll() {
+ return repository.findAll();
+ }
+
+ public PetOwner findByNameExact(final String name) {
+ return repository.findByName(name);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetSpecies.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetSpecies.java
new file mode 100644
index 00000000000..587f9f5ebee
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetSpecies.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+public enum PetSpecies {
+ BIRD,
+ CAT,
+ DOG,
+ HAMSTER,
+ LIZARD,
+ SNAKE
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
new file mode 100644
index 00000000000..34c6ed487d1
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+
+import jakarta.inject.Named;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import org.apache.causeway.applib.annotation.DomainObject;
+import org.apache.causeway.applib.annotation.DomainObjectLayout;
+import org.apache.causeway.applib.annotation.Editing;
+import org.apache.causeway.applib.annotation.ObjectSupport;
+import org.apache.causeway.applib.annotation.Property;
+import org.apache.causeway.applib.annotation.PropertyLayout;
+
+@Entity
+@Table(name = "petclinic_visit")
+@Named(PetClinicDomainModule.NAMESPACE + ".Visit")
+@DomainObject
+@DomainObjectLayout
+public class Visit implements Comparable<Visit> {
+
+ @Id
+ @Column(nullable = false, length = 40)
+ private String id;
+
+ @Version
+ @Column(nullable = false)
+ @PropertyLayout(fieldSetId = "metadata", sequence = "999")
+ private long version;
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "pet_id", nullable = false)
+ @PropertyLayout(fieldSetId = "identity", sequence = "1")
+ private Pet pet;
+
+ @Column(nullable = false)
+ @PropertyLayout(fieldSetId = "identity", sequence = "2")
+ private LocalDateTime visitAt;
+
+ @Property(editing = Editing.ENABLED)
+ @Column(nullable = false, length = 120)
+ @PropertyLayout(fieldSetId = "details", sequence = "1")
+ private String reason;
+
+ @Property(editing = Editing.ENABLED)
+ @Column(length = 400)
+ @PropertyLayout(fieldSetId = "details", sequence = "2")
+ private String notes;
+
+ protected Visit() {
+ }
+
+ public Visit(
+ final String id,
+ final Pet pet,
+ final LocalDateTime visitAt,
+ final String reason) {
+ this.id = id;
+ this.pet = pet;
+ this.visitAt = visitAt;
+ this.reason = reason;
+ }
+
+ @ObjectSupport
+ public String title() {
+ return pet.getName() + " · " + visitAt.format(DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm"));
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public long getVersion() {
+ return version;
+ }
+
+ public Pet getPet() {
+ return pet;
+ }
+
+ public LocalDateTime getVisitAt() {
+ return visitAt;
+ }
+
+ public void setVisitAt(final LocalDateTime visitAt) {
+ this.visitAt = visitAt;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ public void setReason(final String reason) {
+ this.reason = reason;
+ }
+
+ public String getNotes() {
+ return notes;
+ }
+
+ public void setNotes(final String notes) {
+ this.notes = notes;
+ }
+
+ @Override
+ public int compareTo(final Visit other) {
+ return Comparator.comparing(Visit::getVisitAt).thenComparing(Visit::getId).compare(this, other);
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/VisitRepository.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/VisitRepository.java
new file mode 100644
index 00000000000..7dd3e122dd1
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/VisitRepository.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+
+import org.springframework.stereotype.Repository;
+
+@Repository
+public class VisitRepository {
+
+ @PersistenceContext
+ private EntityManager entityManager;
+
+ public Visit findById(final String id) {
+ return entityManager.find(Visit.class, id);
+ }
+
+ public List<Visit> findByPetOwner(final PetOwner owner) {
+ return entityManager.createQuery(
+ "select v from Visit v where v.pet.petOwner = :owner order by v.visitAt desc", Visit.class)
+ .setParameter("owner", owner)
+ .getResultList();
+ }
+
+ public List<Visit> findByVisitAtAfter(final LocalDateTime visitAt) {
+ return entityManager.createQuery(
+ "select v from Visit v where v.visitAt >= :visitAt order by v.visitAt", Visit.class)
+ .setParameter("visitAt", visitAt)
+ .getResultList();
+ }
+
+ public Visit persist(final Visit visit) {
+ entityManager.persist(visit);
+ entityManager.flush();
+ return visit;
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visits.java b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visits.java
new file mode 100644
index 00000000000..13a43b92ec8
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visits.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain;
+
+import java.util.List;
+
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+
+import org.apache.causeway.applib.annotation.Action;
+import org.apache.causeway.applib.annotation.DomainService;
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.applib.annotation.SemanticsOf;
+import org.apache.causeway.applib.services.clock.ClockService;
+
+@Named(PetClinicDomainModule.NAMESPACE + ".Visits")
+@DomainService
+@Priority(PriorityPrecedence.EARLY)
+public class Visits {
+
+ private final VisitRepository repository;
+ private final ClockService clockService;
+
+ @Inject
+ public Visits(final VisitRepository repository, final ClockService clockService) {
+ this.repository = repository;
+ this.clockService = clockService;
+ }
+
+ @Action(semantics = SemanticsOf.SAFE)
+ public List<Visit> listUpcoming() {
+ return repository.findByVisitAtAfter(clockService.getClock().nowAsLocalDateTime());
+ }
+}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties
new file mode 100644
index 00000000000..3e8abfbe9b9
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties
@@ -0,0 +1,30 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+spring.application.name=Causeway Pet Clinic
+spring.graphql.path=/graphql
+spring.graphql.graphiql.enabled=true
+causeway.viewer.graphql.api-scope=ALL
+causeway.viewer.graphql.schema-style=SIMPLE_AND_RICH
+causeway.viewer.graphql.resources.structural-metadata-response-type=DIRECT
+causeway.viewer.wicket.base-path=/wicket/
+causeway.viewer.webcomponents.htmx.base-path=/htmx
+causeway.viewer.webcomponents.htmx.graph-ql-endpoint=/graphql
+causeway.viewer.webcomponents.htmx.brand=Pet Clinic
+causeway.viewer.webcomponents.htmx.wicket-comparison-path=/wicket/
+causeway.viewer.webcomponents.htmx.application-stylesheet=/css/application.css
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml
new file mode 100644
index 00000000000..44b9bc23484
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/menubars.layout.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<mb:menuBars
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/menubars/bootstrap3 https://causeway.apache.org/applib/layout/menubars/bootstrap3/menubars.xsd https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:mb="https://causeway.apache.org/applib/layout/menubars/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <mb:primary>
+ <mb:menu cssClassFa="fa-paw">
+ <mb:named>Pet Owners</mb:named>
+ <mb:section>
+ <mb:named>Manage</mb:named>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="create"/>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="findByName"/>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="findByNameLike"/>
+ <mb:serviceAction objectType="petclinic.PetOwners" id="listAll"/>
+ </mb:section>
+ </mb:menu>
+ <mb:menu cssClassFa="fa-calendar">
+ <mb:named>Visits</mb:named>
+ <mb:section>
+ <mb:named>Schedule</mb:named>
+ <mb:serviceAction objectType="petclinic.Visits" id="listUpcoming"/>
+ </mb:section>
+ </mb:menu>
+ </mb:primary>
+ <mb:secondary>
+ <mb:menu unreferencedActions="true">
+ <mb:named>System</mb:named>
+ </mb:menu>
+ </mb:secondary>
+ <mb:tertiary>
+ <mb:menu>
+ <mb:named>Account</mb:named>
+ </mb:menu>
+ </mb:tertiary>
+</mb:menuBars>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.layout.xml
new file mode 100644
index 00000000000..9b730f4064e
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Pet.layout.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<bs3:grid
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:bs3="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <bs3:row>
+ <bs3:col span="12" unreferencedActions="true">
+ <cpt:domainObject bookmarking="AS_ROOT"/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="6">
+ <cpt:fieldSet name="Identity" id="identity">
+ <cpt:property id="petOwner"/>
+ <cpt:property id="name"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ <bs3:col span="6" unreferencedCollections="true">
+ <cpt:fieldSet name="Details" id="details" unreferencedProperties="true">
+ <cpt:property id="species"/>
+ <cpt:property id="notes"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ </bs3:row>
+</bs3:grid>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#futureVisits.columnOrder.txt b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#futureVisits.columnOrder.txt
new file mode 100644
index 00000000000..96cd24ee120
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#futureVisits.columnOrder.txt
@@ -0,0 +1,4 @@
+pet
+visitAt
+reason
+notes
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#petOwners.columnOrder.txt b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#petOwners.columnOrder.txt
new file mode 100644
index 00000000000..ac0f2d0c2c3
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage#petOwners.columnOrder.txt
@@ -0,0 +1,5 @@
+name
+knownAs
+telephoneNumber
+emailAddress
+lastVisit
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.layout.xml
new file mode 100644
index 00000000000..622a2af15f6
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetClinicHomePage.layout.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<bs3:grid
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:bs3="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <bs3:row>
+ <bs3:col span="12" unreferencedActions="true">
+ <cpt:domainObject/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="6" unreferencedCollections="true">
+ <cpt:collection id="petOwners" defaultView="table"/>
+ </bs3:col>
+ <bs3:col span="6">
+ <cpt:collection id="futureVisits" defaultView="table"/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="0">
+ <cpt:fieldSet name="General" id="general" unreferencedProperties="true"/>
+ </bs3:col>
+ </bs3:row>
+</bs3:grid>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#pets.columnOrder.txt b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#pets.columnOrder.txt
new file mode 100644
index 00000000000..0963ac2cc9a
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#pets.columnOrder.txt
@@ -0,0 +1,3 @@
+name
+species
+notes
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#visits.columnOrder.txt b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#visits.columnOrder.txt
new file mode 100644
index 00000000000..96cd24ee120
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner#visits.columnOrder.txt
@@ -0,0 +1,4 @@
+pet
+visitAt
+reason
+notes
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.layout.xml
new file mode 100644
index 00000000000..e22d53a25fb
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/PetOwner.layout.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<bs3:grid
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:bs3="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <bs3:row>
+ <bs3:col span="12" unreferencedActions="true">
+ <cpt:domainObject bookmarking="AS_ROOT"/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="6">
+ <bs3:tabGroup>
+ <bs3:tab name="Identity">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:fieldSet name="Identity" id="identity">
+ <cpt:property id="name">
+ <cpt:action id="updateName"/>
+ </cpt:property>
+ <cpt:property id="knownAs"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ </bs3:row>
+ </bs3:tab>
+ <bs3:tab name="Contact">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:fieldSet name="Contact" id="contact">
+ <cpt:property id="telephoneNumber"/>
+ <cpt:property id="emailAddress"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ </bs3:row>
+ </bs3:tab>
+ <bs3:tab name="Details">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:fieldSet name="Details" id="details" unreferencedProperties="true">
+ <cpt:property id="notes"/>
+ <cpt:property id="lastVisit"/>
+ <cpt:property id="daysSinceLastVisit"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ </bs3:row>
+ </bs3:tab>
+ </bs3:tabGroup>
+ </bs3:col>
+ <bs3:col span="6" unreferencedCollections="true">
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:collection id="pets"/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="12">
+ <cpt:collection id="visits"/>
+ </bs3:col>
+ </bs3:row>
+ </bs3:col>
+ </bs3:row>
+</bs3:grid>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.layout.xml b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.layout.xml
new file mode 100644
index 00000000000..97ff5d57c0f
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/domain/Visit.layout.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to you under the Apache License, Version 2.0.
+-->
+<bs3:grid
+ xsi:schemaLocation="https://causeway.apache.org/applib/layout/component https://causeway.apache.org/applib/layout/component/component.xsd https://causeway.apache.org/applib/layout/grid/bootstrap3 https://causeway.apache.org/applib/layout/grid/bootstrap3/bootstrap3.xsd"
+ xmlns:cpt="https://causeway.apache.org/applib/layout/component"
+ xmlns:bs3="https://causeway.apache.org/applib/layout/grid/bootstrap3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+ <bs3:row>
+ <bs3:col span="12" unreferencedActions="true">
+ <cpt:domainObject bookmarking="AS_ROOT"/>
+ </bs3:col>
+ </bs3:row>
+ <bs3:row>
+ <bs3:col span="6">
+ <cpt:fieldSet name="Appointment" id="identity">
+ <cpt:property id="pet"/>
+ <cpt:property id="visitAt"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ <bs3:col span="6" unreferencedCollections="true">
+ <cpt:fieldSet name="Details" id="details" unreferencedProperties="true">
+ <cpt:property id="reason"/>
+ <cpt:property id="notes"/>
+ </cpt:fieldSet>
+ </bs3:col>
+ </bs3:row>
+</bs3:grid>
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/static/css/application.css b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/static/css/application.css
new file mode 100644
index 00000000000..4beb4f6d4cd
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/static/css/application.css
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.petclinic-dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--causeway-space-4);
+}
+
+.petclinic-dashboard-grid > section {
+ min-width: 0;
+}
+
+.petclinic-dashboard-grid > section > h2 {
+ margin: 0;
+ padding: var(--causeway-space-3) var(--causeway-space-4);
+ border: 1px solid var(--causeway-border);
+ border-block-end: 0;
+ border-radius: var(--causeway-radius-md) var(--causeway-radius-md) 0 0;
+ background: var(--causeway-surface-subtle);
+ font-size: 1rem;
+}
+
+.petclinic-dashboard-grid > section > causeway-collection :is(.causeway-collection-label, .causeway-collection-description) {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
+.petclinic-dashboard-grid > section > causeway-collection .causeway-collection {
+ border-start-start-radius: 0;
+ border-start-end-radius: 0;
+}
+
+body > nav > div > a > img,
+.navbar-brand-logo {
+ max-height: 2rem;
+}
+
+@media (max-width: 60rem) {
+ .petclinic-dashboard-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
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
new file mode 100644
index 00000000000..a1056d09f5d
--- /dev/null
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.PetOwner;
+import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.PetOwnerRepository;
+import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.VisitRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(
+ classes = PetClinicHtmxApplication.class,
+ webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class PetClinicHtmxApplication_IntegTest {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private PetOwnerRepository ownerRepository;
+
+ @Autowired
+ private VisitRepository visitRepository;
+
+ @Test
+ void loadsDeterministicPetclinicFixture() {
+ final var mary = ownerRepository.findById(PetOwner.MARY_ID);
+
+ assertThat(mary).isNotNull();
+ assertThat(mary.getName()).isEqualTo("Mary Smith");
+ assertThat(mary.getPets()).extracting("id")
+ .containsExactlyInAnyOrder("pet-basil", "pet-samantha");
+ assertThat(visitRepository.findByPetOwner(mary)).extracting("id")
+ .containsExactlyInAnyOrder("visit-basil-checkup", "visit-samantha-vaccine");
+ }
+
+ @Test
+ void servesStableShellCanonicalFragmentsAssetsAndWicketComparison() throws Exception {
+ final var shell = get("/htmx");
+ assertThat(shell.statusCode()).isEqualTo(200);
+ assertThat(shell.headers().firstValue("content-security-policy").orElse(""))
+ .contains("default-src 'self'");
+ assertThat(shell.body())
+ .contains("<causeway-menubars>")
+ .contains("id=\"causeway-route\"")
+ .contains("/causeway-htmx/causeway-htmx.mjs")
+ .contains("/webjars/htmx.org/2.0.6/dist/htmx.min.js")
+ .contains("Compare Wicket viewer");
+
+ final var generic = get("/htmx/object/petclinic.PetOwner/s_owner-mary", "HX-Request", "true");
+ assertThat(generic.statusCode()).isEqualTo(200);
+ assertThat(generic.headers().firstValue("hx-push-url").orElse(""))
+ .isEqualTo("/htmx/object/petclinic.PetOwner/s_owner-mary");
+ assertThat(generic.body())
+ .doesNotContain("<!doctype html>")
+ .contains("data-page-kind=\"generic\"")
+ .contains("logical-type=\"petclinic.PetOwner\"")
+ .contains("object-id=\"s_owner-mary\"");
+
+ final var custom = get("/htmx/object/petclinic.HomePage/home-fixture", "HX-Request", "true");
+ assertThat(custom.body())
+ .contains("data-page-kind=\"custom\"")
+ .contains("data-testid=\"petclinic-custom-home\"")
+ .containsOnlyOnce("<causeway-object-context");
+
+ assertThat(get("/causeway-htmx/causeway-htmx.mjs").statusCode()).isEqualTo(200);
+ assertThat(get("/causeway-webcomponents/component-styles.css").statusCode()).isEqualTo(200);
+ assertThat(get("/causeway-webcomponents/theme.css").statusCode()).isEqualTo(200);
+ assertThat(get("/webjars/htmx.org/2.0.6/dist/htmx.min.js").statusCode()).isEqualTo(200);
+
+ final var wicket = getWithoutRedirect("/wicket/");
+ assertThat(wicket.statusCode()).isIn(200, 302);
+ if (wicket.statusCode() == 302) {
+ assertThat(wicket.headers().firstValue("location").orElse(""))
+ .contains("/wicket/signin");
+ }
+ }
+
+ @Test
+ void exposesObjectHomeEffectiveMenusAndEffectiveGrid() throws Exception {
+ final var application = graphQL("""
+ query PetClinicApplicationEntry {
+ rich {
+ application {
+ home {
+ kind
+ logicalTypeName
+ object {
+ __typename
+ ... on rich__petclinic_HomePage {
+ _meta { id logicalTypeName title }
+ }
+ }
+ }
+ menuBars { href mediaType formatVersion generation cacheControl }
+ issues { code message }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(application);
+ assertThat(application.at("/data/rich/application/home/kind").asText()).isEqualTo("OBJECT");
+ assertThat(application.at("/data/rich/application/home/logicalTypeName").asText()).isEqualTo("petclinic.HomePage");
+ assertThat(application.at("/data/rich/application/home/object/_meta/title").asText())
+ .contains("4 pet owners", "3 upcoming visits");
+
+ final var menuHref = application.at("/data/rich/application/menuBars/href").asText();
+ final var menuBars = get(menuHref);
+ assertThat(menuBars.statusCode()).isEqualTo(200);
+ assertThat(menuBars.headers().firstValue("cache-control").orElse(""))
+ .isEqualTo("private, no-store");
+ assertThat(menuBars.body())
+ .contains("Pet Owners")
+ .contains("objectType=\"petclinic.PetOwners\"")
+ .contains("objectType=\"petclinic.Visits\"");
+
+ final var grid = get("/graphql/object/petclinic.PetOwner:s_owner-mary/_meta/grid");
+ assertThat(grid.statusCode()).isEqualTo(200);
+ assertThat(grid.headers().firstValue("cache-control").orElse(""))
+ .isEqualTo("private, no-store");
+ assertThat(grid.body())
+ .contains("<bs:grid")
+ .contains("<cpt:collection id=\"pets\"")
+ .contains("<cpt:collection id=\"visits\"");
+ }
+
+ @Test
+ void exposesPetclinicServiceAndObjectInteractionsThroughRichGraphQL() throws Exception {
+ final var service = graphQL("""
+ query PetClinicService {
+ rich {
+ petclinic_PetOwners {
+ listAll { hidden disabled invoke { results { _meta { id logicalTypeName title } } } }
+ findByName { hidden disabled }
+ create { hidden disabled }
+ }
+ petclinic_Visits {
+ listUpcoming { hidden disabled invoke { results { _meta { id logicalTypeName title } } } }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(service);
+ assertThat(service.at("/data/rich/petclinic_PetOwners/listAll/invoke/results").size()).isEqualTo(4);
+ assertThat(service.at("/data/rich/petclinic_Visits/listUpcoming/invoke/results").size()).isEqualTo(3);
+
+ final var owner = graphQL("""
+ query PetClinicOwner {
+ rich {
+ 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 } } }
+ addPet { hidden disabled params { name { validity datatype } } }
+ bookVisit { hidden disabled }
+ }
+ }
+ }
+ """);
+ assertNoGraphQLErrors(owner);
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/name/get").asText()).isEqualTo("Mary Smith");
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/pets/get").size()).isEqualTo(2);
+ assertThat(owner.at("/data/rich/petclinic_PetOwner/visits/get").size()).isEqualTo(2);
+ }
+
+ private JsonNode graphQL(final String query) throws Exception {
+ final var request = HttpRequest.newBuilder(uri("/graphql"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(
+ OBJECT_MAPPER.writeValueAsString(java.util.Map.of("query", query)),
+ StandardCharsets.UTF_8))
+ .build();
+ return OBJECT_MAPPER.readTree(client().send(request, HttpResponse.BodyHandlers.ofString()).body());
+ }
+
+ private void assertNoGraphQLErrors(final JsonNode response) {
+ assertThat(response.path("errors").isMissingNode() || response.path("errors").isEmpty())
+ .as(response.toPrettyString())
+ .isTrue();
+ }
+
+ private HttpResponse<String> get(final String path, final String... header) throws Exception {
+ final var builder = HttpRequest.newBuilder(uri(path)).GET();
+ if (header.length == 2) {
+ builder.header(header[0], header[1]);
+ }
+ return client().send(builder.build(), HttpResponse.BodyHandlers.ofString());
+ }
+
+ private HttpResponse<String> getWithoutRedirect(final String path) throws Exception {
+ final var request = HttpRequest.newBuilder(uri(path)).GET().build();
+ return HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build()
+ .send(request, HttpResponse.BodyHandlers.ofString());
+ }
+
+ private HttpClient client() {
+ return HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
+ }
+
+ private URI uri(final String path) {
+ return URI.create("http://localhost:" + port + path);
+ }
+}