You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/08 14:59:40 UTC

(juneau) branch master updated: Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race

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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 718ea8ce1d Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race
718ea8ce1d is described below

commit 718ea8ce1dca3966f3075b1d0d020e74a234a7c8
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Oct 8 10:59:33 2026 -0400

    Add open card-type registry with datatables as a registered card, card class attribute, run-report module, and views hooks (visibleWhen, confirm pipeline, URL state, loud export, badges, inline edit, related lists); remove the datatables module; use Shorts in console-ui-freemarker; default fetchJson Accept to JSON; fix RunView a04 race
---
 juneau-bom/pom.xml                                 |   2 +-
 .../ConsoleDataTablesFreemarkerMixin.java          | 154 ----
 .../console/datatables/DataTableMethodModel.java   | 106 ---
 .../console/datatables/package-info.java           |  25 -
 .../juneau/console/datatables/datatable.ftlh       |  33 -
 .../ConsoleDataTablesFreemarkerMixin_Test.java     | 313 -------
 .../test/resources/templates/admin/releases.ftlh   |  22 -
 .../freemarker/console/AssetUrlMethodModel.java    |   4 +-
 .../freemarker/console/CardDirectiveModel.java     |  54 +-
 .../server/view/freemarker/console/CardSpec.java   |  13 +-
 .../freemarker/console/ConsoleDirectiveModel.java  |  24 +-
 .../freemarker/console/ConsoleFreemarkerMixin.java |  40 +-
 .../freemarker/console/ConsoleOutputCardType.java  |   4 +-
 .../console/ConsoleSlotDirectiveModel.java         |  10 +-
 .../console/ConsoleTemplateValidator.java          |  70 +-
 .../freemarker/console/DatatablesCardType.java     |  72 +-
 .../view/freemarker/console/FtlAttrLists.java      |  12 +-
 .../freemarker/console/HasToolkitMethodModel.java  |   4 +-
 .../freemarker/console/MainDirectiveModel.java     |   6 +-
 .../server/view/freemarker/console/NavNode.java    |  10 +-
 .../console/NavigationDirectiveModel.java          |   4 +-
 .../freemarker/console/NodeDirectiveModel.java     |   8 +-
 .../view/freemarker/console/PageCapture.java       |  32 +-
 .../freemarker/console/PageDirectiveModel.java     |   4 +-
 .../view/freemarker/console/TagMethodModel.java    |   4 +-
 .../freemarker/console/ThemeDirectiveModel.java    |   8 +-
 .../freemarker/console/TokenDirectiveModel.java    |   6 +-
 .../view/freemarker/console/ToolkitPack.java       |  10 +-
 .../freemarker/console/ToolkitPackRegistry.java    |  11 +-
 .../freemarker/console/CardDirective_Test.java     |  31 +
 .../ConsoleTemplateValidator_SelfLint_Test.java    |   4 +-
 .../console/ConsoleTemplateValidator_Test.java     |  37 +-
 .../console/DatatablesCardType_Test.java           |  25 +-
 .../console/ModuleGraph_ImportScan_Test.java       |   4 +-
 .../freemarker/console/PageDirective_Test.java     |   2 +-
 .../console/ViewContractVersion_Test.java          |  94 +--
 .../templates/admin/page-cards-class-bad.ftlh}     |   9 +-
 .../templates/admin/page-cards-class-digit.ftlh}   |   5 +-
 .../cv-omit.ftlh => admin/page-cards-class.ftlh}   |  18 +-
 .../test/resources/templates/c1/cv-pinned-ok.ftlh  |  29 -
 .../rest/server/console/ConsoleChromeMixin.java    |  34 +
 .../resources/org/apache/juneau/console/chrome.css |  62 ++
 .../org/apache/juneau/console/juneau-badges.js     | 396 +++++++++
 .../org/apache/juneau/console/juneau-console.js    |  45 +-
 .../apache/juneau/console/juneau-page.schema.json  |  92 ++-
 .../console/ConsoleChromeMixin_Serving_Test.java   |  11 +
 .../rest/server/console/ModuleGraph_Test.java      |  13 +-
 .../server/console/PageContractSchema_Test.java    |   5 +-
 .../contracts/invalid-badge-refreshms.json         |   1 +
 .../contracts/invalid-badge-unknown-key.json       |   1 +
 ...tmeta.json => invalid-datatables-slotmeta.json} |   0
 .../src/test/resources/contracts/valid-badge.json  |   1 +
 .../DataTablesTable_HtmlRenderHonoring_Test.java   |   1 -
 juneau-rest/juneau-rest-server-views/pom.xml       |   8 +
 .../apache/juneau/rest/server/views/BadgeDef.java  | 354 ++++++++
 .../rest/server/views/ConsoleOutputChecks.java     |  16 +-
 .../rest/server/views/FileRunViewSource.java       |   1 +
 .../apache/juneau/rest/server/views/RegionDef.java |  55 +-
 .../apache/juneau/rest/server/views/RowAction.java | 126 ++-
 .../rest/server/views/RowActionEnabledRule.java    |   2 +
 .../juneau/rest/server/views/RunViewDef.java       |   1 +
 .../juneau/rest/server/views/RunViewEndpoints.java |   1 +
 .../juneau/rest/server/views/RunViewLog.java       |   1 +
 .../juneau/rest/server/views/RunViewPage.java      |   1 +
 .../juneau/rest/server/views/ViewsMixin.java       |  23 +-
 .../juneau/rest/server/views/VisibilityRule.java   | 207 +++++
 .../rest/server/views/report/package-info.java     |  32 -
 .../org/apache/juneau/views/juneau-helpers.js      |   2 +
 .../org/apache/juneau/views/juneau-regions.js      |  35 +-
 .../org/apache/juneau/views/juneau-renders.js      |  18 +
 .../org/apache/juneau/views/juneau-ribbon.js       |  98 ++-
 .../org/apache/juneau/views/juneau-run-view.js     | 106 ++-
 .../org/apache/juneau/views/juneau-urlstate.js     | 180 ++++-
 .../org/apache/juneau/views/juneau-views.css       |  78 ++
 .../org/apache/juneau/views/juneau-views.js        | 896 +++++++++++++++++++--
 .../juneau/rest/server/views/BadgeDef_Test.java    | 126 +++
 .../server/views/ConfirmRenderer_BrowserTest.java  | 126 +++
 .../rest/server/views/ConsoleBadges_Test.java      | 121 +++
 .../rest/server/views/ConsoleCards_Shell_Test.java |   4 +-
 .../rest/server/views/ConsoleVisibility_Test.java  |  63 ++
 .../server/views/DialogWizard_BrowserTest.java     | 124 +++
 .../rest/server/views/ExportLoud_BrowserTest.java  | 128 +++
 .../rest/server/views/FileRunViewSource_Test.java  |   1 +
 .../juneau/rest/server/views/NodeHarness.java      | 133 +++
 .../server/views/PendingBadge_BrowserTest.java     | 129 +++
 .../rest/server/views/RelatedList_BrowserTest.java | 324 ++++++++
 .../rest/server/views/RowAction_Tone_Test.java     |  67 ++
 .../rest/server/views/RunViewChecks_Test.java      |   1 +
 .../juneau/rest/server/views/RunViewLog_Test.java  |  16 +-
 .../rest/server/views/RunViewMixin_Test.java       |   3 +-
 .../juneau/rest/server/views/RunViewPage_Test.java |   3 +-
 .../rest/server/views/RunView_BrowserTest.java     |   3 +-
 .../views/UrlStateDirectives_BrowserTest.java      | 126 +++
 .../rest/server/views/ViewsJs_Confirm_Test.java    | 107 +++
 .../server/views/ViewsJs_DialogHandle_Test.java    | 124 +++
 .../server/views/ViewsJs_ExportDetect_Test.java    |  83 ++
 .../rest/server/views/ViewsJs_Hooks_Test.java      | 126 +++
 .../rest/server/views/ViewsJs_InlineEdit_Test.java | 122 +++
 .../server/views/ViewsJs_RowActionsRun_Test.java   |  92 +++
 .../rest/server/views/ViewsJs_Rules_Test.java      |  76 ++
 .../rest/server/views/ViewsJs_RunView_Test.java    |  10 +-
 .../views/ViewsJs_UrlStateDirectives_Test.java     | 129 +++
 .../views/ViewsJs_VisibilityWiring_Test.java       |  92 +++
 .../server/views/VisibilityRule_Corpus_Test.java   |  70 ++
 .../rest/server/views/VisibilityRule_Test.java     | 123 +++
 .../src/test/js/badges.cjs                         | 250 ++++++
 .../src/test/js/badges.html.cjs                    |  93 +++
 .../src/test/js/confirm-renderer.html.cjs          | 139 ++++
 .../src/test/js/confirm.cjs                        |  90 +++
 .../src/test/js/console-visibility.cjs             |  93 +++
 .../src/test/js/dialog-handle.cjs                  | 163 ++++
 .../src/test/js/dialog-wizard.html.cjs             | 107 +++
 .../src/test/js/edit-renderer.cjs                  | 203 +++++
 .../src/test/js/export-detect.cjs                  |  94 +++
 .../src/test/js/export-loud.html.cjs               |  81 ++
 .../src/test/js/hooks-api.cjs                      | 104 +++
 .../src/test/js/related-list.cjs                   | 171 ++++
 .../src/test/js/ribbon-normalize.cjs               |   2 +-
 .../src/test/js/row-actions-run.cjs                |  94 +++
 .../juneau-rest-server-views/src/test/js/rules.cjs |  79 ++
 .../src/test/js/run-view-browser.cjs               |   7 +
 .../src/test/js/run-view.cjs                       |  69 ++
 .../src/test/js/url-state-directives.html.cjs      |  99 +++
 .../src/test/js/urlstate-directives.cjs            | 151 ++++
 .../src/test/js/visibility-corpus.json             |  26 +
 .../src/test/js/visibility-wiring.cjs              | 134 +++
 .../src/test/resources/card-lift-corpus.json       |  18 -
 .../juneau/rest/server/widgets/ActionRef.java      |   7 +-
 .../org/apache/juneau/rest/server/widgets/Op.java  |  30 +-
 .../pom.xml                                        |  20 +-
 .../rest/server/runreport}/JUnitXmlParser.java     |   8 +-
 .../server/runreport}/JUnitXmlReportReader.java    |   3 +-
 .../server/runreport}/JestJsonReportReader.java    |  10 +-
 .../runreport}/PlaywrightJsonReportReader.java     |   9 +-
 .../rest/server/runreport}/ReportBuilder.java      |   2 +-
 .../juneau/rest/server/runreport}/ReportJson.java  |   5 +-
 .../rest/server/runreport}/ReportLimits.java       |   2 +-
 .../rest/server/runreport}/ReportReader.java       |   2 +-
 .../rest/server/runreport}/ReportReaders.java      |   2 +-
 .../rest/server/runreport}/ReportResult.java       |   5 +-
 .../juneau/rest/server/runreport}/ReportTest.java  |   4 +-
 .../juneau/rest/server/runreport}/ReportText.java  |   2 +-
 .../juneau/rest/server/runreport}/RunEvent.java    |  12 +-
 .../rest/server/runreport}/RunViewChecks.java      |  64 +-
 .../server/runreport}/SurefireReportReader.java    |   4 +-
 .../juneau/rest/server/runreport/package-info.java |  38 +
 .../runreport}/JUnitXmlReportReader_Test.java      |   4 +-
 .../runreport}/JestJsonReportReader_Test.java      |   4 +-
 .../PlaywrightJsonReportReader_Test.java           |   4 +-
 .../rest/server/runreport}/ReportReaders_Test.java |  14 +-
 .../rest/server/runreport}/ReportResult_Test.java  |   6 +-
 .../rest/server/runreport}/ReportTestSupport.java  |   4 +-
 .../rest/server/runreport}/ReportText_Test.java    |   2 +-
 .../rest/server/runreport}/RunEvent_Test.java      |   4 +-
 .../runreport}/SurefireReportReader_Test.java      |   4 +-
 .../rest/server/runreport}/jest-crashed.json       |   0
 .../juneau/rest/server/runreport}/jest-mixed.json  |   0
 .../rest/server/runreport}/jest-truncated.json     |   0
 .../juneau/rest/server/runreport}/junit-dtd.xml    |   0
 .../rest/server/runreport}/junit-truncated.xml     |   0
 .../rest/server/runreport}/playwright-mixed.json   |   0
 .../server/runreport}/playwright-truncated.json    |   0
 .../juneau/rest/server/runreport}/pytest.xml       |   0
 .../surefire-mixed/TEST-com.example.BetaTest.xml   |   0
 .../surefire-ok/TEST-com.example.AlphaTest.xml     |   0
 juneau-rest/pom.xml                                |   2 +-
 166 files changed, 8072 insertions(+), 1350 deletions(-)

diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 35bd56f906..af134dc4bc 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -65,11 +65,11 @@
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-management-logging</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-datatables</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-widgets</artifactId><version>${project.version}</version></dependency>
+			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-run-report</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views-markdown</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker</artifactId><version>${project.version}</version></dependency>
-			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker-datatables</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-reactive</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-reactive-reactor</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-view-jsp</artifactId><version>${project.version}</version></dependency>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java
deleted file mode 100644
index 87e48bde5d..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin.java
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import org.apache.juneau.rest.server.*;
-import org.apache.juneau.rest.server.view.freemarker.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-
-import freemarker.template.*;
-
-/**
- * A {@link ConsoleFreemarkerMixin} that additionally makes the reserved-classpath {@code <@datatable>} macro
- * template ({@code org/apache/juneau/console/datatables/datatable.ftlh}) resolvable from any consumer template.
- *
- * <h5 class='section'>Used INSTEAD OF {@link ConsoleFreemarkerMixin}/{@link org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin},
- * not beside it:</h5>
- * <p>
- * Same load-bearing-return-type rule as {@link ConsoleFreemarkerMixin}'s own class javadoc: register it typed as
- * {@code FreemarkerMixin} so {@code FreemarkerViewRenderer}'s exact-type {@code getBean(FreemarkerMixin.class)}
- * lookup finds it.
- *
- * <p class='bjava'>
- * 	<ja>@Bean</ja> <jk>public</jk> FreemarkerMixin freemarker() {
- * 		<jk>return</jk> ConsoleDataTablesFreemarkerMixin.<jsm>create</jsm>().basePath(<js>"/templates/"</js>).build();
- * 	}
- * </p>
- *
- * <h5 class='section'>No second {@code ClassTemplateLoader} needed:</h5>
- * <p>
- * {@link ConsoleFreemarkerMixin#resolveConfiguration} already splices in a classpath-<b>root</b>-rooted
- * {@code ClassTemplateLoader(getClass().getClassLoader(), "")}. Because {@code getClass()} resolves to <b>this</b>
- * subclass's runtime class, its classloader sees every module's classpath resources (this module's
- * {@code datatable.ftlh} included) &mdash; so the reserved {@link #DATATABLE_TEMPLATE_PATH} resolves through that
- * same loader without any additional wiring here.
- *
- * <h5 class='section'>Consumer-supplied {@code Configuration} is the same instance, then fill-missing stamped:</h5>
- * <p>
- * Same rule as {@link ConsoleFreemarkerMixin}: {@link #resolveConfiguration} returns the consumer bean
- * {@code ==}-identical so settings win, then fill-missing-stamps {@code jcDataTableHtml} (and the parent
- * reserved names) and splices the console loader only when {@link ConsoleFreemarkerMixin#BASE_TEMPLATE_PATH}
- * would not already resolve. Opt out by registering {@link org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin}
- * instead of this class.
- *
- * @since 10.0.0
- */
-public class ConsoleDataTablesFreemarkerMixin extends ConsoleFreemarkerMixin {
-
-	static {
-		FreemarkerMixin.registerSubtype(ConsoleDataTablesFreemarkerMixin.class);
-	}
-
-	/** The reserved classpath-root-relative location of the shipped {@code <@datatable>} macro template. */
-	public static final String DATATABLE_TEMPLATE_PATH = "org/apache/juneau/console/datatables/datatable.ftlh";
-
-	/** No-arg constructor &mdash; mirrors {@link ConsoleFreemarkerMixin#ConsoleFreemarkerMixin()}. */
-	public ConsoleDataTablesFreemarkerMixin() {
-		this(create());
-	}
-
-	/**
-	 * Builder constructor.
-	 *
-	 * @param builder The builder. Must not be {@code null}.
-	 */
-	protected ConsoleDataTablesFreemarkerMixin(Builder builder) {
-		super(builder);
-	}
-
-	/**
-	 * Creates a new builder.
-	 *
-	 * @return A new builder.
-	 */
-	@SuppressWarnings({
-		"java:S9149" // Intentional per-subclass builder-factory override matching FreemarkerMixin.create()'s own convention; each mixin subclass returns its own nested Builder type.
-	})
-	public static Builder create() {
-		return new Builder();
-	}
-
-	// Same wrap-once identity-cache pattern as ConsoleFreemarkerMixin's own field, kept as a SEPARATE field (not
-	// shared with the parent's private one) since this class needs to answer "have I already fill-missing-stamped
-	// jcDataTableHtml on THIS config" independently of the parent's loader / reserved-name stamp. Presence
-	// checks still run on a cache miss so two mixin instances sharing one Spring singleton do not re-set.
-	@SuppressWarnings({
-		"java:S3077" // volatile required for correct double-checked-locking safe-publication; see ConsoleFreemarkerMixin's identical field.
-	})
-	private volatile Configuration wrappedConfiguration;
-
-	/**
-	 * Resolves the active {@link Configuration}: delegates to {@link ConsoleFreemarkerMixin#resolveConfiguration}
-	 * for console-loader / reserved-name stamping, then fill-missing-registers {@code jcDataTableHtml}.
-	 *
-	 * <p>
-	 * A consumer {@code Configuration} bean is the same {@code ==} instance (settings win). {@code jcDataTableHtml}
-	 * is set only when unset. Identity wrap-once plus the parent's loader presence check keep a shared Spring
-	 * singleton from nesting {@code MultiTemplateLoader}.
-	 *
-	 * @param req The current REST request.
-	 * @return The active FreeMarker configuration. Never {@code null}.
-	 */
-	@Override
-	public Configuration resolveConfiguration(RestRequest req) {
-		var cfg = super.resolveConfiguration(req);
-		if (cfg == wrappedConfiguration)
-			return cfg;
-		synchronized (this) {
-			if (cfg != wrappedConfiguration) {
-				if (cfg.getSharedVariable(DataTableMethodModel.NAME) == null)
-					cfg.setSharedVariable(DataTableMethodModel.NAME, new DataTableMethodModel());
-				wrappedConfiguration = cfg;
-			}
-		}
-		return cfg;
-	}
-
-	/**
-	 * Builder for {@link ConsoleDataTablesFreemarkerMixin}.
-	 */
-	public static class Builder extends ConsoleFreemarkerMixin.Builder {
-
-		/** Constructor &mdash; package access for {@link ConsoleDataTablesFreemarkerMixin#create()}. */
-		protected Builder() {}
-
-		/**
-		 * Builds the {@link ConsoleDataTablesFreemarkerMixin}.
-		 *
-		 * <p>
-		 * Overrides {@link org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin.Builder#build()}
-		 * &mdash; without this override, the inherited method would silently return a plain
-		 * {@link ConsoleFreemarkerMixin} with no {@code <@datatable>} macro.
-		 *
-		 * @return A new {@link ConsoleDataTablesFreemarkerMixin} instance.
-		 */
-		@Override
-		public ConsoleDataTablesFreemarkerMixin build() {
-			return new ConsoleDataTablesFreemarkerMixin(this);
-		}
-	}
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java
deleted file mode 100644
index e609f4f2c3..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/DataTableMethodModel.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import java.util.*;
-
-import org.apache.juneau.bean.html5.*;
-import org.apache.juneau.marshall.html.*;
-import org.apache.juneau.rest.server.datatables.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-
-import freemarker.core.*;
-import freemarker.template.*;
-import freemarker.template.utility.*;
-
-/**
- * The FreeMarker shared-variable method backing the {@code <@datatable id=... rows=... rowType=.../>} macro defined
- * in the reserved {@code datatable.ftlh} template.
- *
- * <p>
- * Reuses the exact serialize-then-mark-trusted adapter {@code console-ui-freemarker}'s {@code TagMethodModel} built
- * for {@code <@tag>} (ticket 361 Phase 5) &mdash; not reinvented here: (1) call {@link DataTablesTable#of(String,
- * Collection, Class)} (which additively honors each row property's {@code @Html(render=...)} as of Phase 6), (2) add
- * {@code class="jc-table"} alongside the {@link DataTablesTable#MARKER_ATTR} marker the shipped
- * {@code juneau-datatables.js} glue already looks for, (3) serialize the returned {@link Table} via its own
- * {@code toString()} ({@code HtmlSerializer.DEFAULT_SIMPLE_SQ}), (4) wrap the resulting markup string via
- * {@link HTMLOutputFormat#fromMarkup(String) HTMLOutputFormat.INSTANCE.fromMarkup(...)} so a {@code .ftlh}
- * template's default {@code ${...}} auto-escaping does not double-escape it.
- *
- * <p>
- * Registered as a {@link freemarker.template.Configuration#setSharedVariable(String, TemplateModel) shared variable}
- * by {@link ConsoleDataTablesFreemarkerMixin#resolveConfiguration}, visible to {@code datatable.ftlh} and,
- * transitively (once included), to the consumer's own template.
- *
- * <h5 class='section'>Argument coercion:</h5>
- * <p>
- * {@code id} and {@code rowType} accept a plain FTL string literal. {@code rows} accepts an FTL sequence of beans
- * (or {@code Map}s) &mdash; e.g. a Java {@code List} bound as a template attribute &mdash; unwrapped recursively via
- * {@link DeepUnwrap#unwrap(TemplateModel)}: each element unwraps to its underlying Java bean/{@code Map}, not a
- * FreeMarker bean-model wrapper, exactly as {@code DataTablesTable.of(...)} needs.
- *
- * @since 10.0.0
- */
-final class DataTableMethodModel implements TemplateMethodModelEx {
-
-	/** The shared-variable name {@code datatable.ftlh}'s {@code <#macro datatable ...>} calls. */
-	static final String NAME = "jcDataTableHtml";
-
-	@Override
-	public Object exec(@SuppressWarnings("rawtypes") List arguments) throws TemplateModelException {
-		if (arguments.size() != 3)
-			throw new TemplateModelException(NAME + "(id, rows, rowType) requires exactly 3 arguments, got " + arguments.size() + ".");
-		var id = asPlainString(arguments.get(0));
-		var rows = asRows(arguments.get(1));
-		var rowTypeName = asPlainString(arguments.get(2));
-		var rowType = resolveRowType(rowTypeName);
-		Table table;
-		try {
-			table = DataTablesTable.of(id, rows, rowType);
-		} catch (RuntimeException ex) {
-			throw new TemplateModelException(ex.getMessage(), ex);
-		}
-		table.class_("jc-table");
-		// A server-mode table outside a <@card> still needs the glue; record it on the enclosing <@page>.
-		var cap = PageCapture.get(Environment.getCurrentEnvironment());
-		if (cap != null && cap.inPage())
-			cap.require(List.of(ToolkitPackRegistry.PACK_DATATABLES_GLUE));
-		var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(table);
-		return HTMLOutputFormat.INSTANCE.fromMarkup(markup);
-	}
-
-	private static Class<?> resolveRowType(String rowTypeName) throws TemplateModelException {
-		try {
-			return Class.forName(rowTypeName);
-		} catch (ClassNotFoundException ex) {
-			throw new TemplateModelException("Unknown rowType class '" + rowTypeName + "'.", ex);
-		}
-	}
-
-	private static String asPlainString(Object arg) throws TemplateModelException {
-		var raw = DeepUnwrap.unwrap((TemplateModel) arg);
-		return String.valueOf(raw);
-	}
-
-	private static Collection<?> asRows(Object arg) throws TemplateModelException {
-		var raw = DeepUnwrap.unwrap((TemplateModel) arg);
-		if (raw instanceof Collection<?> c)
-			return c;
-		throw new TemplateModelException(NAME + "(id, rows, rowType): 'rows' must unwrap to a Collection, got "
-			+ (raw == null ? "null" : raw.getClass().getName()) + ".");
-	}
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java
deleted file mode 100644
index 91bc8a26ee..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/package-info.java
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * The {@code <@datatable>} FreeMarker macro &mdash; the one module allowed to depend on both the FreeMarker chrome
- * add-on ({@code juneau-rest-server-console-ui-freemarker}) and {@code juneau-rest-server-datatables}, so a
- * chrome-only consumer never sees {@code -datatables} on the classpath.
- *
- * @since 10.0.0
- */
-package org.apache.juneau.rest.server.view.freemarker.console.datatables;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh
deleted file mode 100644
index 4078f8012c..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/main/resources/org/apache/juneau/console/datatables/datatable.ftlh
+++ /dev/null
@@ -1,33 +0,0 @@
-<#--
- 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.
--->
-<#--
- Reserved <@datatable> macro template (org/apache/juneau/console/datatables/datatable.ftlh).
-
- Shipped on the classpath ROOT by juneau-rest-server-console-ui-freemarker-datatables and made resolvable by
- ConsoleDataTablesFreemarkerMixin's augmented template loader (the SAME classpath-root ClassTemplateLoader
- ConsoleFreemarkerMixin already splices in for base.ftlh -- see that class's javadoc). Consumer templates pull it in
- with a loader-root-absolute include:
-
-   <#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-
- v1 scope: this file's only job is to define the <@datatable id=... rows=... rowType=.../> macro, which renders a
- DataTablesTable.of(...)-built <table class="jc-table" data-juneau-datatable ...> (additively honoring each row
- property's @Html(render=...), per TODO-361 Phase 6) through the same serialize-then-mark-trusted adapter <@tag>
- uses. rowType is the row bean's fully-qualified class name as a plain string (e.g. rowType="com.example.Release"),
- not a FTL type reference -- FreeMarker templates have no way to express a Java Class literal directly.
--->
-<#macro datatable id rows rowType>${jcDataTableHtml(id, rows, rowType)}</#macro>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
deleted file mode 100644
index 8efe9ede5f..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
+++ /dev/null
@@ -1,313 +0,0 @@
-/*
- * 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.juneau.rest.server.view.freemarker.console.datatables;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.util.*;
-import java.util.regex.*;
-
-import org.apache.juneau.*;
-import org.apache.juneau.bean.html5.Span;
-import org.apache.juneau.commons.inject.*;
-import org.apache.juneau.marshall.html.*;
-import org.apache.juneau.marshall.serializer.*;
-import org.apache.juneau.rest.mock.classic.*;
-import org.apache.juneau.rest.server.*;
-import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.view.*;
-import org.apache.juneau.rest.server.view.freemarker.*;
-import org.apache.juneau.rest.server.view.freemarker.console.*;
-import org.apache.juneau.rest.server.views.*;
-import org.junit.jupiter.api.*;
-
-import freemarker.cache.*;
-import freemarker.template.Configuration;
-import freemarker.template.SimpleScalar;
-import freemarker.template.TemplateModel;
-
-/**
- * Ticket 361 Phase 7 gate: the {@code <@datatable>} macro (this module's only deliverable) &mdash; a golden-HTML
- * integration proof that a row bean's {@code @Html(render=...)} enum property renders as
- * pill markup (Phase 6's now-render-aware {@code DataTablesTable}) nested inside a {@code jc-table} through the
- * same trusted-HTML adapter Phase 5 built for {@code <@tag>}.
- */
-@SuppressWarnings({
-	"resource" // Test-fixture AutoCloseables are managed by the test lifecycle, not real leaks (mixed-module resource analysis on test code).
-})
-class ConsoleDataTablesFreemarkerMixin_Test extends TestBase {
-
-	public enum Release { RELEASED, DRAFT }
-
-	/** Local stand-in for the retired {@code TagHtmlRender}: a status pill span. */
-	public static class StatusPillRender extends HtmlRender<Enum<?>> {
-		@Override
-		public Object getContent(SerializerSession session, Enum<?> value) {
-			if (value == null)
-				return null;
-			return new Span().class_("tag status " + value.name().toLowerCase(Locale.ROOT));
-		}
-	}
-
-	/** Row bean: one plain property, one {@code @Html(render=...)}-annotated enum property. */
-	public static class ReleaseRow {
-		public String name = "widget";
-		@Html(render=StatusPillRender.class) public Release status = Release.RELEASED;
-	}
-
-	//-----------------------------------------------------------------------------------------------------------------
-	// RED control: a host wired with plain ConsoleFreemarkerMixin (the datatable macro's backing shared variable is
-	// never registered) -- the reserved datatable.ftlh template itself DOES resolve (same classpath-root loader
-	// ConsoleFreemarkerMixin already splices in sees every module's resources), but calling <@datatable> inside it
-	// fails because jcDataTableHtml is undefined. Proves the gap is real: neither the macro's OWN registration nor
-	// the Phase-6-updated DataTablesTable overload it depends on are wired without ConsoleDataTablesFreemarkerMixin.
-	//-----------------------------------------------------------------------------------------------------------------
-
-	@Rest(mixins=FreemarkerMixin.class)
-	public static class PlainControlHost extends BasicRestServlet {
-		private static final long serialVersionUID = 1L;
-		@Bean public FreemarkerMixin freemarker() {
-			return ConsoleFreemarkerMixin.create().basePath("/templates/").build();
-		}
-		@RestGet(path="/releases")
-		public View releases() {
-			return FreemarkerView.of("admin/releases.ftlh")
-				.attr("releases", List.of(new ReleaseRow()))
-				.attr("rowTypeName", ReleaseRow.class.getName());
-		}
-	}
-
-	@Test void a01_plainConsoleFreemarkerMixin_datatableMacroUnresolvable() throws Exception {
-		var c = MockRestClient.buildLax(PlainControlHost.class);
-		c.get("/releases").run().assertStatus(500);
-	}
-
-	//-----------------------------------------------------------------------------------------------------------------
-	// GREEN: ConsoleDataTablesFreemarkerMixin registers the <@datatable> macro's backing shared variable (on top of
-	// everything ConsoleFreemarkerMixin already wires) -- the rendered output must contain the pill markup nested
-	// inside a <td> inside a <table class="jc-table" data-juneau-datatable ...>.
-	//-----------------------------------------------------------------------------------------------------------------
-
-	@Rest(mixins={FreemarkerMixin.class, ViewsMixin.class})
-	public static class DataTablesHost extends BasicRestServlet {
-		private static final long serialVersionUID = 1L;
-		@Bean public FreemarkerMixin freemarker() {
-			return ConsoleDataTablesFreemarkerMixin.create().basePath("/templates/").chromeTemplate("admin/chrome.ftlh").build();
-		}
-		@RestGet(path="/page-releases")
-		public View pageReleases() {
-			return FreemarkerView.of("admin/page-datatable.ftlh")
-				.attr("releases", List.of(new ReleaseRow()))
-				.attr("rowTypeName", ReleaseRow.class.getName());
-		}
-		@RestGet(path="/releases")
-		public View releases() {
-			return FreemarkerView.of("admin/releases.ftlh")
-				.attr("releases", List.of(new ReleaseRow()))
-				.attr("rowTypeName", ReleaseRow.class.getName());
-		}
-	}
-
-	@Test void a02_consoleDataTablesFreemarkerMixin_rendersPillMarkupInsideJcTable() throws Exception {
-		var c = MockRestClient.buildLax(DataTablesHost.class);
-		var body = c.get("/releases").run().assertStatus(200).getContent().asString();
-		// find() on an anchor-free pattern (no wrapping .*) avoids the super-linear backtracking risk of
-		// String.matches() with unbounded quantifiers at both ends.
-		assertTrue(Pattern.compile("<table(?=[^>]*class=['\"]jc-table['\"])(?=[^>]*data-juneau-datatable)[^>]*>").matcher(body).find(),
-			() -> "expected <table class='jc-table' ...data-juneau-datatable...> (attribute order not asserted), body:\n" + body);
-		assertTrue(Pattern.compile("<td[^>]*>\\s*<span(?=[^>]*class=['\"]tag status released['\"])[^>]*>.*?</td>", Pattern.DOTALL).matcher(body).find(),
-			() -> "expected <span class='tag status released'> nested inside a <td>, body:\n" + body);
-		assertTrue(body.contains("widget"), () -> "expected the plain property's raw value too, body:\n" + body);
-		assertFalse(body.contains("&lt;span"), () -> "macro output was HTML-escaped (double-escaped), body:\n" + body);
-		assertFalse(body.contains("jquery"), () -> "no <@page>, nothing recorded, body:\n" + body);
-	}
-
-	@Test void a02b_datatableInsidePage_recordsTheGlue() throws Exception {
-		var c = MockRestClient.buildLax(DataTablesHost.class);
-		var body = c.get("/page-releases").run().assertStatus(200).getContent().asString();
-		var jq = body.indexOf("/jquery.min.js");
-		var dt = body.indexOf("/js/dataTables.min.js");
-		var glue = body.indexOf("juneau-datatables.js");
-		var views = body.indexOf("juneau-views.js");
-		assertTrue(jq >= 0 && jq < dt && dt < glue && glue < views, () -> body);
-	}
-
-	//-----------------------------------------------------------------------------------------------------------------
-	// Module-graph re-check (S6): this module's main source is the ONLY one of the three new modules whose imports
-	// include org.apache.juneau.rest.server.datatables.* -- console-ui and console-ui-freemarker main sources
-	// still don't (re-verified here alongside the real code, not just at the POM level).
-	//-----------------------------------------------------------------------------------------------------------------
-
-	@Test void a03_onlyThisModuleImportsDatatablesPackage() throws Exception {
-		var restDir = new java.io.File(System.getProperty("user.dir")).getParentFile();
-
-		var thisModuleImportsDatatables = importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui-freemarker-datatables/src/main/java"));
-		assertTrue(thisModuleImportsDatatables, "console-ui-freemarker-datatables MUST import org.apache.juneau.rest.server.datatables.* (it's the only module allowed to)");
-
-		assertFalse(importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui/src/main/java")),
-			"console-ui main source must NOT import org.apache.juneau.rest.server.datatables.*");
-		assertFalse(importsDatatablesPackage(new java.io.File(restDir, "juneau-rest-server-console-ui-freemarker/src/main/java")),
-			"console-ui-freemarker main source must NOT import org.apache.juneau.rest.server.datatables.*");
-	}
-
-	//-----------------------------------------------------------------------------------------------------------------
-	// f) Consumer Configuration is the same instance, then fill-missing stamped
-	//-----------------------------------------------------------------------------------------------------------------
-
-	@Rest(mixins=FreemarkerMixin.class)
-	public static class ConsumerConfigHost extends BasicRestServlet {
-		private static final long serialVersionUID = 1L;
-		static final Configuration USER_CFG = new Configuration(Configuration.VERSION_2_3_34);
-		static {
-			USER_CFG.setTemplateLoader(new StringTemplateLoader());
-		}
-		static final Object ORIGINAL_WRAPPER = USER_CFG.getObjectWrapper();
-		static final String ORIGINAL_ENCODING = USER_CFG.getDefaultEncoding();
-		static final Object ORIGINAL_OUTPUT_FORMAT = USER_CFG.getOutputFormat();
-		static final long ORIGINAL_UPDATE_DELAY = USER_CFG.getTemplateUpdateDelayMilliseconds();
-		@Bean public Configuration configuration() { return USER_CFG; }
-		@Bean public FreemarkerMixin freemarker() { return ConsoleDataTablesFreemarkerMixin.create().build(); }
-		@RestGet(path="/probe")
-		public String probe(RestRequest req) {
-			var mixin = (ConsoleDataTablesFreemarkerMixin) req.getContext().getBeanStore().getBean(FreemarkerMixin.class).orElseThrow();
-			var cfg1 = mixin.resolveConfiguration(req);
-			var cfg2 = mixin.resolveConfiguration(req);
-			var sameInstance = cfg1 == USER_CFG && cfg2 == USER_CFG;
-			var settingsUnchanged = cfg1.getObjectWrapper() == ORIGINAL_WRAPPER
-				&& eq(cfg1.getDefaultEncoding(), ORIGINAL_ENCODING)
-				&& cfg1.getOutputFormat() == ORIGINAL_OUTPUT_FORMAT
-				&& cfg1.getTemplateUpdateDelayMilliseconds() == ORIGINAL_UPDATE_DELAY;
-			var varsFilled = cfg1.getSharedVariable(PageDirectiveModel.NAME) != null
-				&& cfg1.getSharedVariable(CardDirectiveModel.NAME) != null
-				&& cfg1.getSharedVariable(NavigationDirectiveModel.NAME) != null
-				&& cfg1.getSharedVariable(NodeDirectiveModel.NAME) != null
-				&& cfg1.getSharedVariable(ThemeDirectiveModel.NAME) != null
-				&& cfg1.getSharedVariable("jcTagHtml") != null
-				&& cfg1.getSharedVariable(DataTableMethodModel.NAME) != null;
-			var reservedResolves = true;
-			try {
-				cfg1.getTemplate(ConsoleFreemarkerMixin.BASE_TEMPLATE_PATH);
-			} catch (@SuppressWarnings("unused") Exception ex) {
-				reservedResolves = false;
-			}
-			return sameInstance + ":" + settingsUnchanged + ":" + varsFilled + ":" + reservedResolves
-				+ ":" + loaderCount(cfg1) + ":" + loaderCount(cfg2);
-		}
-	}
-
-	/**
-	 * Same assertions as {@code ConsoleFreemarkerMixin_Test.f01}, plus {@code jcDataTableHtml} is set.
-	 */
-	@Test void f01_consumerSuppliedConfiguration_isAugmentedInPlace() throws Exception {
-		var c = MockRestClient.buildLax(ConsumerConfigHost.class);
-		var body = c.get("/probe").accept("text/plain").run().getContent().asString();
-		assertEquals("true:true:true:true:2:2", body,
-			() -> "expected same-instance, settings-unchanged, vars-filled (incl jcDataTableHtml), reserved-resolves, loader-count 2 then 2; got " + body);
-	}
-
-	@Rest(mixins=FreemarkerMixin.class)
-	public static class CollisionConfigHost extends BasicRestServlet {
-		private static final long serialVersionUID = 1L;
-		static final Configuration COLLISION_CFG = new Configuration(Configuration.VERSION_2_3_34);
-		static final TemplateModel PAGE_SENTINEL = new SimpleScalar("consumer-page");
-		static {
-			COLLISION_CFG.setSharedVariable(PageDirectiveModel.NAME, PAGE_SENTINEL);
-		}
-		@Bean public Configuration configuration() { return COLLISION_CFG; }
-		@Bean public FreemarkerMixin freemarker() { return ConsoleDataTablesFreemarkerMixin.create().build(); }
-		@RestGet(path="/probe")
-		public String probe(RestRequest req) {
-			var mixin = (ConsoleDataTablesFreemarkerMixin) req.getContext().getBeanStore().getBean(FreemarkerMixin.class).orElseThrow();
-			var cfg = mixin.resolveConfiguration(req);
-			var pageKept = cfg.getSharedVariable(PageDirectiveModel.NAME) == PAGE_SENTINEL;
-			var othersFilled = cfg.getSharedVariable(CardDirectiveModel.NAME) != null
-				&& cfg.getSharedVariable(NavigationDirectiveModel.NAME) != null
-				&& cfg.getSharedVariable(NodeDirectiveModel.NAME) != null
-				&& cfg.getSharedVariable(ThemeDirectiveModel.NAME) != null
-				&& cfg.getSharedVariable("jcTagHtml") != null
-				&& cfg.getSharedVariable(DataTableMethodModel.NAME) != null;
-			return pageKept + ":" + othersFilled;
-		}
-	}
-
-	/**
-	 * Consumer bean already has {@code page} set: that sentinel is kept; the other Juneau reserved names
-	 * (including {@code jcDataTableHtml}) are still fill-missing stamped.
-	 */
-	@Test void f02_consumerPageSentinel_isKeptAndOtherNamesFilled() throws Exception {
-		var c = MockRestClient.buildLax(CollisionConfigHost.class);
-		var body = c.get("/probe").accept("text/plain").run().getContent().asString();
-		assertEquals("true:true", body,
-			() -> "expected page-sentinel-kept=true, other-names-filled=true; got " + body);
-	}
-
-	@Rest
-	public static class SharedConfigDummyHost extends BasicRestServlet {
-		private static final long serialVersionUID = 1L;
-		static final Configuration SHARED_CFG = new Configuration(Configuration.VERSION_2_3_34);
-		static {
-			SHARED_CFG.setTemplateLoader(new StringTemplateLoader());
-		}
-		static final ThreadLocal<RestRequest> CAPTURED = new ThreadLocal<>();
-		@Bean public Configuration configuration() { return SHARED_CFG; }
-		@RestGet(path="/x")
-		public String x(RestRequest req) {
-			CAPTURED.set(req);
-			return "x";
-		}
-	}
-
-	/**
-	 * Two {@link ConsoleDataTablesFreemarkerMixin} instances sharing one consumer {@code Configuration} bean
-	 * splice the console loader once: {@code MultiTemplateLoader} count stays 2.
-	 */
-	@Test void f03_twoMixinInstances_sharedConsumerBean_loaderCountStays2() throws Exception {
-		MockRestClient.buildLax(SharedConfigDummyHost.class).get("/x").run();
-		var req = SharedConfigDummyHost.CAPTURED.get();
-		var m1 = ConsoleDataTablesFreemarkerMixin.create().build();
-		var m2 = ConsoleDataTablesFreemarkerMixin.create().build();
-		var c1 = m1.resolveConfiguration(req);
-		var c2 = m2.resolveConfiguration(req);
-		assertSame(SharedConfigDummyHost.SHARED_CFG, c1);
-		assertSame(SharedConfigDummyHost.SHARED_CFG, c2);
-		assertEquals(2, loaderCount(c1));
-		assertEquals(2, loaderCount(c2));
-		assertNotNull(c1.getSharedVariable(DataTableMethodModel.NAME));
-		assertNotNull(c2.getSharedVariable(DataTableMethodModel.NAME));
-	}
-
-	private static int loaderCount(Configuration cfg) {
-		var loader = cfg.getTemplateLoader();
-		return loader instanceof MultiTemplateLoader multi ? multi.getTemplateLoaderCount() : -1;
-	}
-
-	private static boolean importsDatatablesPackage(java.io.File srcMain) throws Exception {
-		assertTrue(srcMain.isDirectory(), () -> "Expected sibling src/main/java not found: " + srcMain.getAbsolutePath());
-		try (var files = java.nio.file.Files.walk(srcMain.toPath())) {
-			return files
-				.filter(p -> p.toString().endsWith(".java"))
-				.anyMatch(p -> {
-					try {
-						return java.nio.file.Files.readString(p).contains("org.apache.juneau.rest.server.datatables");
-					} catch (java.io.IOException e) {
-						throw new java.io.UncheckedIOException(e);
-					}
-				});
-		}
-	}
-}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh
deleted file mode 100644
index d94b9f773d..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/releases.ftlh
+++ /dev/null
@@ -1,22 +0,0 @@
-<#--
-  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.
--->
-<#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-<html>
-<body>
-<@datatable id="releases" rows=releases rowType=rowTypeName/>
-</body>
-</html>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
index 5af522d7f9..16f9d90f39 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/AssetUrlMethodModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 import java.util.concurrent.*;
@@ -85,7 +87,7 @@ public final class AssetUrlMethodModel implements TemplateMethodModelEx {
 	private String token(String path) {
 		var resource = root + path.substring(1);
 		try (var in = loader.getResourceAsStream(resource)) {
-			if (in != null)
+			if (nn(in))
 				return ChecksumUtils.hash8(in.readAllBytes());
 		} catch (IOException e) {
 			LOG.log(Level.WARNING, e, () -> "assetUrl: cannot read bundled resource '" + resource + "'.");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
index 8820a52332..a65976d416 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirectiveModel.java
@@ -69,21 +69,21 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 	/** The shared-variable name this directive registers under. */
 	public static final String NAME = "card";
 
-	static final Set<String> ATTRS = Set.of("type", "id", "title", "src", "template", "requires");
+	static final Set<String> ATTRS = Set.of("type", "id", "title", "src", "template", "requires", "class");
 
 	private static final Set<String> REMOVED_TYPES = Set.of("js", "json", "calendar");
 
+	private static final Pattern CLASS_TOKEN_RE = Pattern.compile("^[A-Za-z_-][A-Za-z0-9_-]*$");
+
 	private static final Pattern TYPE_RE = Pattern.compile("^[a-z][a-z0-9-]{0,31}$");
 
 	/** Contract base keys copied onto {@link CardSpec}'s own setters rather than {@link CardSpec#body}. */
 	private static final Set<String> BASE_KEYS = Set.of("id", "type", "title", "src", "template");
 
-	private final boolean devMode;
 	private final CardRequirements requirements;
 	private final CardTypeRegistry cardTypes;
 
-	CardDirectiveModel(boolean devMode, CardRequirements requirements, CardTypeRegistry cardTypes) {
-		this.devMode = devMode;
+	CardDirectiveModel(CardRequirements requirements, CardTypeRegistry cardTypes) {
 		this.requirements = requirements;
 		this.cardTypes = cardTypes;
 	}
@@ -102,6 +102,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		var type = FtlAttrLists.scalar(p, "type");
 		var authoredId = FtlAttrLists.scalar(p, "id");
 		var requires = FtlAttrLists.list(p, NAME, "requires");
+		var cssClass = cssClass(p, authoredId);
 		if (REMOVED_TYPES.contains(type))
 			throw FtlAttrLists.reject(String.format(
 				"<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
@@ -110,7 +111,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 			throw FtlAttrLists.reject(String.format("<@card id='%s'> type='%s' must match ^[a-z][a-z0-9-]{0,31}$.", authoredId, type));
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.pageOpen)
+		if (n(cap) || ! cap.pageOpen)
 			throw FtlAttrLists.reject("<@card> must be nested inside <@page>.");
 		if (cap.cardOpen)
 			throw FtlAttrLists.reject("<@card> cannot be nested inside another <@card>.");
@@ -134,14 +135,31 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		var cardType = type.isEmpty() ? "html" : type;
 		var id = authoredId.isEmpty() && eq(cardType, "html") ? cap.nextCardId() : authoredId;
 		if (eq(cardType, "run-view"))
-			runViewCard(cap, id, title, src, template, markup);
+			runViewCard(cap, id, title, src, template, markup, cssClass);
 		else if (eq(cardType, "html"))
-			htmlCard(cap, id, title, src, template, markup);
+			htmlCard(cap, id, title, src, template, markup, cssClass);
 		else
-			registryCard(cap, cardType, id, title, src, template, markup, env.getCurrentTemplate().getName());
+			registryCard(cap, cardType, id, title, src, template, markup, cssClass);
 		recordRequirements(cap, cardType, id, requires);
 	}
 
+	/**
+	 * Reads {@code class=}: a space-separated list of CSS class tokens, each matching
+	 * {@code [A-Za-z_-][A-Za-z0-9_-]*}. Returns the tokens joined by single spaces, or {@code ""} when absent.
+	 */
+	private static String cssClass(Map<String, TemplateModel> p, String authoredId) throws TemplateModelException {
+		var raw = FtlAttrLists.scalar(p, "class");
+		if (raw.isEmpty())
+			return "";
+		var tokens = raw.split("\\s+");
+		for (var t : tokens)
+			if (! CLASS_TOKEN_RE.matcher(t).matches())
+				throw FtlAttrLists.reject(String.format(
+					"<@card id='%s'> class='%s' must be a space-separated list of CSS class names matching [A-Za-z_-][A-Za-z0-9_-]*.",
+					authoredId, raw));
+		return String.join(" ", tokens);
+	}
+
 	// The one place a card's packs are recorded; <@page> resolves them after its body.
 	private void recordRequirements(PageCapture cap, String type, String id, List<String> requires) throws TemplateModelException {
 		try {
@@ -151,7 +169,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		}
 	}
 
-	private static void htmlCard(PageCapture cap, String id, String title, String src, String template, String markup)
+	private static void htmlCard(PageCapture cap, String id, String title, String src, String template, String markup,
+			String cssClass)
 			throws TemplateModelException {
 		var hasBody = ! markup.isBlank();
 		var sources = (hasBody ? 1 : 0) + (src.isEmpty() ? 0 : 1) + (template.isEmpty() ? 0 : 1);
@@ -167,6 +186,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 			card.src(src);
 		if (! template.isEmpty())
 			card.template(template);
+		if (! cssClass.isEmpty())
+			card.cssClass(cssClass);
 		cap.addCard(card, hasBody ? markup : null);
 	}
 
@@ -176,7 +197,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 	 * generic passthrough for an unregistered type).
 	 */
 	private void registryCard(PageCapture cap, String type, String id, String title, String src, String template,
-			String markup, String ftlName) throws TemplateModelException {
+			String markup, String cssClass) throws TemplateModelException {
 		if (id.isEmpty())
 			throw FtlAttrLists.reject(String.format("<@card type=\"%s\"> requires id=.", type));
 		var hasBody = ! markup.isBlank();
@@ -205,12 +226,13 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		JsonMap card;
 		try {
 			card = cardTypes.toCard(source.build());
-			if (eq(type, "datatables") && card.get("table") instanceof JsonMap t && t.get("view") instanceof Map)
-				DatatablesCardType.reconcileViewVersion(id, t, ftlName, devMode);
 		} catch (IllegalArgumentException e) {
 			throw FtlAttrLists.reject(e.getMessage());
 		}
-		cap.addCard(toCardSpec(card), captured[0] ? markup : null);
+		var spec = toCardSpec(card);
+		if (! cssClass.isEmpty())
+			spec.cssClass(cssClass);
+		cap.addCard(spec, captured[0] ? markup : null);
 	}
 
 	/** Converts the flat {@link CardTypeRegistry#toCard} result into the {@link CardSpec} {@link PageCapture} wants. */
@@ -239,7 +261,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		"unchecked" // JSON5 object keys are always strings.
 	})
 	private static void runViewCard(PageCapture cap, String id, String title, String src, String template,
-			String markup) throws TemplateModelException {
+			String markup, String cssClass) throws TemplateModelException {
 		if (id.isEmpty())
 			throw FtlAttrLists.reject("<@card type=\"run-view\"> requires id=.");
 		if (! (src.isEmpty() && template.isEmpty()))
@@ -260,7 +282,7 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 				throw FtlAttrLists.reject(String.format(
 					"<@card id='%s'> type='run-view' unknown key '%s'; allowed: contractVersion, runView.", id, k));
 		var version = envelope.get("contractVersion");
-		if (! "1".equals(version))
+		if (! eq("1", version))
 			throw FtlAttrLists.reject(String.format(
 				"<@card id='%s'> type='run-view' requires contractVersion: '1'; got '%s'.", id, version));
 		if (! (envelope.get("runView") instanceof Map<?,?> runView))
@@ -269,6 +291,8 @@ public final class CardDirectiveModel implements TemplateDirectiveModel {
 		var card = CardSpec.of("run-view", id);
 		if (! title.isEmpty())
 			card.title(title);
+		if (! cssClass.isEmpty())
+			card.cssClass(cssClass);
 		card.body("runView", def.toMap());
 		cap.addCard(card, null);
 	}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
index 859cc72d36..8e3dffead2 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardSpec.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 /**
@@ -40,6 +42,7 @@ public final class CardSpec {
 	private String src;
 	private String template;
 	private boolean bare;
+	private String cssClass;
 	private final Map<String,Object> body = new LinkedHashMap<>();
 
 	private CardSpec(String type, String id) {
@@ -69,6 +72,9 @@ public final class CardSpec {
 	/** @param v Whether an html card is inserted without the {@code .jc-card} wrapper. @return This object. */
 	public CardSpec bare(boolean v) { bare = v; return this; }
 
+	/** @param v Space-separated CSS class names stamped on the card wrapper element. @return This object. */
+	public CardSpec cssClass(String v) { cssClass = v; return this; }
+
 	/** @param key A type-specific field. @param value Its value. @return This object. */
 	public CardSpec body(String key, Object value) { body.put(key, value); return this; }
 
@@ -86,10 +92,11 @@ public final class CardSpec {
 		var m = new LinkedHashMap<String,Object>();
 		m.put("id", id);
 		m.put("type", type);
-		if (title != null) m.put("title", title);
-		if (src != null) m.put("src", src);
-		if (template != null) m.put("template", template);
+		if (nn(title)) m.put("title", title);
+		if (nn(src)) m.put("src", src);
+		if (nn(template)) m.put("template", template);
 		if (bare) m.put("bare", true);
+		if (nn(cssClass)) m.put("class", cssClass);
 		m.putAll(body);
 		return m;
 	}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
index 480c5f4b3e..e8055b85d5 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -78,7 +80,7 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 		FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
 
 		var req = FreemarkerRenderScope.request();
-		if (req == null)
+		if (n(req))
 			throw FtlAttrLists.reject("<@console> needs FreemarkerRenderScope.request() (renderer wrap).");
 
 		var cap = PageCapture.of(env);
@@ -111,7 +113,7 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 
 		cap.consoleOpen = true;
 		try {
-			if (body != null)
+			if (nn(body))
 				body.render(cap.consoleBuffer());
 		} finally {
 			cap.consoleOpen = false;
@@ -119,14 +121,14 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 		cap.checkConsoleClose();
 		cap.resolveActiveNav();
 
-		if (cap.themeCssUrl == null) {
+		if (n(cap.themeCssUrl)) {
 			cap.themeCssUrl = ConsoleChromeMixin.themeAssetUrl(req, themeName);
 			cap.theme(themeName);
 		}
 
 		var token = req.getAttribute(LoopbackBoundaryFilter.TOKEN_ATTRIBUTE).asString().orElse(null);
 		var csrfHeader = req.getAttribute(LoopbackBoundaryFilter.HEADER_ATTRIBUTE).asString().orElse(null);
-		var hasCsrf = token != null && ! token.isBlank();
+		var hasCsrf = inb(token);
 
 		if (devMode) {
 			var findings = PageContractSchema.get().validate(cap.toContractJson(), cap.templates().keySet());
@@ -148,10 +150,10 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 			out.write("<link rel=\"icon\" href=\"" + attrEscape(favicon) + "\">\n");
 		out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(ConsoleChromeMixin.chromeCssUrl(req)) + "\">\n");
 		out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(cap.themeCssUrl) + "\">\n");
-		if (cap.themeOverrideBlock != null)
+		if (nn(cap.themeOverrideBlock))
 			out.write("<style>" + cap.themeOverrideBlock + "</style>\n");
 		// App CSS that must lose to page-local css=: emitted BEFORE the page css so a page rule still wins.
-		if (cap.headBeforePageCss != null)
+		if (nn(cap.headBeforePageCss))
 			out.write(cap.headBeforePageCss);
 		for (var href : cap.vendorCss())
 			out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n");
@@ -159,17 +161,17 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 			out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\">\n");
 		for (var href : cap.runtimeCss())
 			out.write("<link rel=\"stylesheet\" href=\"" + attrEscape(href) + "\" data-toolkit-css>\n");
-		if (cap.head != null)
+		if (nn(cap.head))
 			out.write(cap.head);
 		out.write("\n</head>\n");
 
 		out.write("<body");
 		if (hasCsrf) {
 			out.write(" data-juneau-csrf=\"" + attrEscape(token) + "\"");
-			if (csrfHeader != null && ! csrfHeader.isBlank())
+			if (inb(csrfHeader))
 				out.write(" data-juneau-csrf-header=\"" + attrEscape(csrfHeader) + "\"");
 		}
-		if (cap.bodyAttrs != null && ! cap.bodyAttrs.isBlank())
+		if (inb(cap.bodyAttrs))
 			out.write(" " + cap.bodyAttrs.trim());
 		out.write(">\n");
 
@@ -182,11 +184,11 @@ public final class ConsoleDirectiveModel implements TemplateDirectiveModel {
 		for (var src : cap.runtimeJs())
 			out.write("<script src=\"" + attrEscape(src) + "\" data-toolkit-js></script>\n");
 		// App scripts that must run after the toolkit pack but before the page-local init= scripts.
-		if (cap.scriptsAfterToolkit != null)
+		if (nn(cap.scriptsAfterToolkit))
 			out.write(cap.scriptsAfterToolkit);
 		for (var src : cap.initScripts())
 			out.write("<script src=\"" + attrEscape(src) + "\"></script>\n");
-		if (cap.scripts != null)
+		if (nn(cap.scripts))
 			out.write(cap.scripts);
 		out.write("\n</body>\n</html>\n");
 		cap.consoleDone = true;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
index ad61a5868a..48b057e22b 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 import org.apache.juneau.rest.server.*;
@@ -200,7 +202,7 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
 			return;
 		var console = new ClassTemplateLoader(getClass().getClassLoader(), "");
 		var existing = cfg.getTemplateLoader();
-		if (existing == null)
+		if (n(existing))
 			cfg.setTemplateLoader(console);
 		else
 			cfg.setTemplateLoader(new MultiTemplateLoader(new TemplateLoader[]{ existing, console }));
@@ -213,11 +215,11 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
 	 */
 	private static boolean reservedTemplateResolves(Configuration cfg) {
 		var loader = cfg.getTemplateLoader();
-		if (loader == null)
+		if (n(loader))
 			return false;
 		try {
 			var source = loader.findTemplateSource(BASE_TEMPLATE_PATH);
-			if (source == null)
+			if (n(source))
 				return false;
 			loader.closeTemplateSource(source);
 			return true;
@@ -230,32 +232,32 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
 	 * Sets each reserved console shared variable only when that name is unset on {@code cfg}.
 	 */
 	private void fillMissingConsoleSharedVariables(Configuration cfg) {
-		if (cfg.getSharedVariable(TagMethodModel.NAME) == null)
+		if (n(cfg.getSharedVariable(TagMethodModel.NAME)))
 			cfg.setSharedVariable(TagMethodModel.NAME, new TagMethodModel());
-		if (cfg.getSharedVariable(PageDirectiveModel.NAME) == null) {
+		if (n(cfg.getSharedVariable(PageDirectiveModel.NAME))) {
 			cfg.setSharedVariable(PageDirectiveModel.NAME, new PageDirectiveModel(chromeTemplate, packs));
 		}
-		if (cfg.getSharedVariable(CardDirectiveModel.NAME) == null)
-			cfg.setSharedVariable(CardDirectiveModel.NAME, new CardDirectiveModel(devMode, cardRequirements, cardTypes));
-		if (cfg.getSharedVariable(NavigationDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(CardDirectiveModel.NAME)))
+			cfg.setSharedVariable(CardDirectiveModel.NAME, new CardDirectiveModel(cardRequirements, cardTypes));
+		if (n(cfg.getSharedVariable(NavigationDirectiveModel.NAME)))
 			cfg.setSharedVariable(NavigationDirectiveModel.NAME, new NavigationDirectiveModel());
-		if (cfg.getSharedVariable(NodeDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(NodeDirectiveModel.NAME)))
 			cfg.setSharedVariable(NodeDirectiveModel.NAME, new NodeDirectiveModel());
-		if (cfg.getSharedVariable(ThemeDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(ThemeDirectiveModel.NAME)))
 			cfg.setSharedVariable(ThemeDirectiveModel.NAME, new ThemeDirectiveModel());
-		if (cfg.getSharedVariable(TokenDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(TokenDirectiveModel.NAME)))
 			cfg.setSharedVariable(TokenDirectiveModel.NAME, new TokenDirectiveModel());
-		if (cfg.getSharedVariable(ConsoleDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(ConsoleDirectiveModel.NAME)))
 			cfg.setSharedVariable(ConsoleDirectiveModel.NAME, new ConsoleDirectiveModel(devMode));
-		if (cfg.getSharedVariable(MainDirectiveModel.NAME) == null)
+		if (n(cfg.getSharedVariable(MainDirectiveModel.NAME)))
 			cfg.setSharedVariable(MainDirectiveModel.NAME, new MainDirectiveModel());
-		if (cfg.getSharedVariable(HasToolkitMethodModel.NAME) == null)
+		if (n(cfg.getSharedVariable(HasToolkitMethodModel.NAME)))
 			cfg.setSharedVariable(HasToolkitMethodModel.NAME, new HasToolkitMethodModel());
-		if (adopterLoader != null && cfg.getSharedVariable(AssetUrlMethodModel.NAME) == null)
+		if (nn(adopterLoader) && n(cfg.getSharedVariable(AssetUrlMethodModel.NAME)))
 			cfg.setSharedVariable(AssetUrlMethodModel.NAME, new AssetUrlMethodModel(adopterLoader, adopterRoot, devMode));
 		// The seven capture-only slot directives share one parameterized class, one instance registered per slot name.
 		for (var slot : ConsoleSlotDirectiveModel.SLOT_NAMES)
-			if (cfg.getSharedVariable(slot) == null)
+			if (n(cfg.getSharedVariable(slot)))
 				cfg.setSharedVariable(slot, new ConsoleSlotDirectiveModel(slot));
 	}
 
@@ -331,7 +333,7 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
 		 * @return This object.
 		 */
 		public Builder registerToolkitPack(ToolkitPack pack) {
-			extraPacks.add(Objects.requireNonNull(pack, "pack"));
+			extraPacks.add(rnn(pack));
 			return this;
 		}
 
@@ -432,8 +434,8 @@ public class ConsoleFreemarkerMixin extends FreemarkerMixin {
 		 */
 		public Builder adopterAssets(ClassLoader loader, String resourceRoot) {
 			// Q:  Use Shorts here and in this module.
-			this.adopterLoader = Objects.requireNonNull(loader, "loader");
-			this.adopterRoot = Objects.requireNonNull(resourceRoot, "resourceRoot");
+			this.adopterLoader = rnn(loader);
+			this.adopterRoot = rnn(resourceRoot);
 			return this;
 		}
 
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
index 26343d7742..eb3e7c994e 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleOutputCardType.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 import org.apache.juneau.marshall.collections.*;
@@ -58,7 +60,7 @@ public final class ConsoleOutputCardType implements CardTypeHandler {
 			if (! BODY_KEYS.contains(k))
 				throw source.error("type='console-output' unknown key '%s'; allowed: contractVersion, output.", k);
 		var version = envelope.get("contractVersion");
-		if (! "1".equals(version))
+		if (! eq("1", version))
 			throw source.error("type='console-output' requires contractVersion: '1'; got '%s'.", version);
 		if (! (envelope.get("output") instanceof Map<?,?> output))
 			throw source.error("type='console-output' requires an output object.");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
index 1663517ea0..9fae6b6860 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleSlotDirectiveModel.java
@@ -86,9 +86,9 @@ public final class ConsoleSlotDirectiveModel implements TemplateDirectiveModel {
 
 	/** @return The attributes this slot accepts (read by {@link ConsoleTemplateValidator}). */
 	Set<String> attrs() {
-		if (HEAD.equals(slot) || SCRIPTS.equals(slot))
+		if (eq(HEAD, slot) || eq(SCRIPTS, slot))
 			return Set.of(PHASE);
-		if (FOOTER.equals(slot))
+		if (eq(FOOTER, slot))
 			return Set.of(TEXT);
 		return Set.of();
 	}
@@ -107,14 +107,14 @@ public final class ConsoleSlotDirectiveModel implements TemplateDirectiveModel {
 
 		var phase = FtlAttrLists.scalar(p, PHASE);
 		if (! phase.isEmpty()) {
-			var ok = (HEAD.equals(slot) && eq(phase, PHASE_BEFORE_PAGE_CSS))
-				|| (SCRIPTS.equals(slot) && eq(phase, PHASE_AFTER_TOOLKIT));
+			var ok = (eq(HEAD, slot) && eq(phase, PHASE_BEFORE_PAGE_CSS))
+				|| (eq(SCRIPTS, slot) && eq(phase, PHASE_AFTER_TOOLKIT));
 			if (! ok)
 				throw FtlAttrLists.reject("<@" + slot + "> unknown phase '" + phase + "'.");
 		}
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.consoleOpen)
+		if (n(cap) || ! cap.consoleOpen)
 			throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", slot));
 
 		var markup = PageCapture.render(body);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
index f3fce1e040..68444906d6 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator.java
@@ -176,7 +176,7 @@ public final class ConsoleTemplateValidator {
 	 */
 	public List<Finding> validate(String templateName) {
 		var src = load(templateName);
-		if (src == null)
+		if (n(src))
 			throw new IllegalArgumentException(String.format("Template '%s' not found under templateRoot or classpathRoot.", templateName));
 		return validateSource(templateName, src);
 	}
@@ -188,7 +188,7 @@ public final class ConsoleTemplateValidator {
 	 * @throws IllegalStateException If no template root is set.
 	 */
 	public List<Finding> validateAll() {
-		if (templateRoot == null)
+		if (n(templateRoot))
 			throw new IllegalStateException("validateAll() requires templateRoot(Path); a classpath root cannot be listed.");
 		try (Stream<Path> s = Files.walk(templateRoot)) {
 			var names = s.filter(Files::isRegularFile)
@@ -218,9 +218,9 @@ public final class ConsoleTemplateValidator {
 		var macros = new HashSet<String>(allowed);
 		var namespaces = new HashSet<String>();
 		collectScope(name, text, macros, namespaces, new HashSet<>());
-		if (chromeTemplate != null && neq(chromeTemplate, name)) {
+		if (nn(chromeTemplate) && neq(chromeTemplate, name)) {
 			var chrome = load(chromeTemplate);
-			if (chrome == null)
+			if (n(chrome))
 				throw new IllegalArgumentException(String.format("Chrome template '%s' not found under templateRoot or classpathRoot.", chromeTemplate));
 			collectScope(chromeTemplate, blankComments(chrome), macros, namespaces, new HashSet<>());
 		}
@@ -242,7 +242,7 @@ public final class ConsoleTemplateValidator {
 			if (closing) {
 				if ((dname.equals(NodeDirectiveModel.NAME) || dname.equals(NavigationDirectiveModel.NAME)) && siblings.size() > 1)
 					siblings.pop();
-			} else if (known == null) {
+			} else if (n(known)) {
 				var dot = dname.indexOf('.');
 				var ok = dot > 0 ? namespaces.contains(dname.substring(0, dot)) : macros.contains(dname);
 				if (! ok)
@@ -254,7 +254,7 @@ public final class ConsoleTemplateValidator {
 						add(out, name, lines, at, "unknown-attribute", String.format("<@%s> unknown attribute '%s'.", dname, a));
 				for (var a : STRICT.getOrDefault(dname, Set.of())) {
 					var v = attrs.get(a);
-					if (v != null && v.literal && !eqa(v.value, "true", "false"))
+					if (nn(v) && v.literal && !eqa(v.value, "true", "false"))
 						add(out, name, lines, at, "strict-boolean", String.format("<@%s> %s= must be true or false; got '%s'.", dname, a, v.value));
 				}
 				var id = attrs.get("id");
@@ -264,26 +264,26 @@ public final class ConsoleTemplateValidator {
 							siblings.push(new HashSet<>());
 					}
 					case NodeDirectiveModel.NAME -> {
-						if (id != null && id.literal && ! siblings.peek().add(id.value))
+						if (nn(id) && id.literal && ! siblings.peek().add(id.value))
 							add(out, name, lines, at, "duplicate-id", String.format("<@node id='%s'> duplicates a sibling id.", id.value));
 						if (! selfClosing)
 							siblings.push(new HashSet<>());
 					}
 					case CardDirectiveModel.NAME -> {
-						if (id != null && id.literal && ! cardIds.add(id.value))
+						if (nn(id) && id.literal && ! cardIds.add(id.value))
 							add(out, name, lines, at, "duplicate-id", String.format("<@card id='%s'> duplicates an existing card id.", id.value));
 						var type = attrs.get("type");
-						if (type != null && type.literal && REMOVED_TYPES.contains(type.value))
+						if (nn(type) && type.literal && REMOVED_TYPES.contains(type.value))
 							add(out, name, lines, at, "removed-card-type", String.format(
 								"<@card id='%s'> type='%s' was removed in 10.0.0; use type='html' with a <template>, or a registered card type.",
-								id == null ? "" : id.value, type.value));
+								n(id) ? "" : id.value, type.value));
 						var tpl = attrs.get("template");
-						if (tpl != null && tpl.literal)
+						if (nn(tpl) && tpl.literal)
 							templateRefs.add(new Object[]{tpl.value, at});
-						if (type != null && type.literal && eq(type.value, "datatables") && ! selfClosing)
-							checkViewVersion(out, name, lines, text, m.end(), id == null ? "" : id.value);
+						if (nn(type) && type.literal && eq(type.value, "datatables") && ! selfClosing)
+							checkViewVersion(out, name, lines, text, m.end(), n(id) ? "" : id.value);
 						if (! selfClosing)
-							checkCardBody(out, name, lines, at, text, m.end(), id, type, attrs.get("src"));
+							checkCardBody(out, name, lines, at, text, m.end(), id, attrs.get("src"));
 					}
 					case ConsoleDirectiveModel.NAME -> {
 						if (consoleAt < 0)
@@ -311,7 +311,7 @@ public final class ConsoleTemplateValidator {
 
 		var a = ASSET_REF.matcher(text);
 		while (a.find()) {
-			var url = a.group(1) != null ? a.group(1) : a.group(2);
+			var url = nn(a.group(1)) ? a.group(1) : a.group(2);
 			if (! url.contains("${") && ! url.contains("<#") && JUNEAU_ASSET.matcher(url).find())
 				add(out, name, lines, a.start(), "hardcoded-asset-url", String.format(
 					"Hard-coded Juneau asset URL '%s' is unversioned and goes stale in browser caches; use viewAssetUrl(...), consoleJsUrl(...), chromeCssUrl(...) or themeAssetUrl(...) so it carries ?v= (an adopter's own assets can use assetUrl(...)).", url));
@@ -358,33 +358,28 @@ public final class ConsoleTemplateValidator {
 	private static final Set<String> RESERVED_BODY_KEYS = Set.of("id", "type", "title", "src", "template", "ref");
 
 	/**
-	 * The C2 card-body rules: {@code card-src-and-body}, {@code inline-slot-meta} and {@code card-reserved-key}.
+	 * The C2 card-body rules: {@code card-src-and-body} and {@code card-reserved-key}.
 	 * Reads the body the same way {@link #checkViewVersion} does, up to the next {@code </@card}.
 	 */
-	private static void checkCardBody(List<Finding> out, String name, int[] lines, int at, String text, int bodyStart, Value id, Value type, Value src) {
+	private static void checkCardBody(List<Finding> out, String name, int[] lines, int at, String text, int bodyStart, Value id, Value src) {
 		var end = text.indexOf("</@card", bodyStart);
 		var body = text.substring(bodyStart, end < 0 ? text.length() : end);
-		var cardId = id == null ? "" : id.value;
+		var cardId = n(id) ? "" : id.value;
 		var trimmed = body.trim();
-		if (! trimmed.isEmpty() && src != null && src.literal)
+		if (! trimmed.isEmpty() && nn(src) && src.literal)
 			add(out, name, lines, at, "card-src-and-body", String.format(
 				"<@card id='%s'> has both src='%s' and a body; use exactly one.", cardId, src.value));
 		if (trimmed.isEmpty() || body.contains("${"))
 			return;
 		var keys = topLevelJson5Keys(trimmed);
-		// Same detection as DatatablesCardType's escape hatch (a top-level 'view' object).
-		if (type != null && type.literal && eq(type.value, "datatables") && keys.contains("view"))
-			add(out, name, lines, at, "inline-slot-meta", String.format(
-				"<@card id='%s'> body is a pre-built SLOT_META (has a 'view' object); author the table catalog "
-					+ "in author shape instead. This becomes an error at GC-1.", cardId));
 		for (var k : keys)
 			if (RESERVED_BODY_KEYS.contains(k))
 				add(out, name, lines, at, "card-reserved-key", String.format(
 					"<@card id='%s'> body key '%s' is reserved; set it as an attribute.", cardId, k));
 	}
 
-	// Hand-rolled, like attrs() below: finds JSON5 object keys at brace-depth 0, skipping quoted strings. Not a
-	// general parser — enough to catch literal top-level keys for the three card-body lint rules above.
+	// Hand-rolled, like attrs() below: finds JSON5 object keys at brace-depth 0, skipping quoted strings (backslash escapes honored). Not a
+	// general parser — enough to catch literal top-level keys for the two card-body lint rules above.
 	private static Set<String> topLevelJson5Keys(String body) {
 		var s = body.trim();
 		if (s.startsWith("{") && s.endsWith("}"))
@@ -395,8 +390,11 @@ public final class ConsoleTemplateValidator {
 		while (i < s.length()) {
 			var c = s.charAt(i);
 			if (c == '"' || c == '\'') {
-				var close = s.indexOf(c, i + 1);
-				i = close < 0 ? s.length() : close + 1;
+				// Honor backslash escapes in both quote styles: skip the escaped character wholesale.
+				var j = i + 1;
+				while (j < s.length() && s.charAt(j) != c)
+					j += s.charAt(j) == '\\' ? 2 : 1;
+				i = j >= s.length() ? s.length() : j + 1;
 			} else if (c == '{' || c == '[') {
 				depth++;
 				i++;
@@ -426,8 +424,8 @@ public final class ConsoleTemplateValidator {
 		while (m.find()) {
 			String v;
 			boolean literal;
-			if (m.group(2) != null || m.group(3) != null) {
-				v = m.group(2) != null ? m.group(2) : m.group(3);
+			if (nn(m.group(2)) || nn(m.group(3))) {
+				v = nn(m.group(2)) ? m.group(2) : m.group(3);
 				literal = ! v.contains("${");
 			} else {
 				v = m.group(4);
@@ -451,7 +449,7 @@ public final class ConsoleTemplateValidator {
 		while (inc.find()) {
 			var target = resolve(name, inc.group(1));
 			var src = load(target);
-			if (src != null)
+			if (nn(src))
 				collectScope(target, blankComments(src), macros, namespaces, seen);
 		}
 	}
@@ -467,14 +465,14 @@ public final class ConsoleTemplateValidator {
 	private String load(String name) {
 		var rel = name.startsWith("/") ? name.substring(1) : name;
 		try {
-			if (templateRoot != null) {
+			if (nn(templateRoot)) {
 				var p = templateRoot.resolve(rel);
 				if (Files.isRegularFile(p))
 					return Files.readString(p, UTF_8);
 			}
-			if (classpathRoot != null) {
+			if (nn(classpathRoot)) {
 				var s = resource(classpathRoot + rel);
-				if (s != null)
+				if (nn(s))
 					return s;
 			}
 			return name.startsWith("/") ? resource(name) : null;
@@ -485,10 +483,10 @@ public final class ConsoleTemplateValidator {
 
 	private static String resource(String absPath) throws IOException {
 		var cl = Thread.currentThread().getContextClassLoader();
-		if (cl == null)
+		if (n(cl))
 			cl = ConsoleTemplateValidator.class.getClassLoader();
 		try (var in = cl.getResourceAsStream(absPath.substring(1))) {
-			return in == null ? null : new String(in.readAllBytes(), UTF_8);
+			return n(in) ? null : new String(in.readAllBytes(), UTF_8);
 		}
 	}
 
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
index 8d6fe31674..04bcfd1bd0 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 import java.util.logging.*;
 
@@ -49,6 +51,8 @@ public final class DatatablesCardType implements CardTypeHandler {
 
 	private static final String E27 = "type='datatables' requires src= or a body with 'columns' and exactly one of 'dataUrl' or 'rows'.";
 
+	private static final String E28 = "type='datatables' body is a pre-built SLOT_META envelope; remove 'contractVersion', 'layout' and 'view' and author the catalog form.";
+
 	private static final Set<String> KNOWN_COLUMN_KEYS = Set.of(
 		"key", "data", "label", "title", "search", "render", "href", "className",
 		"orderable", "searchable", "defaultVisible", "pinned", "formats",
@@ -65,8 +69,8 @@ public final class DatatablesCardType implements CardTypeHandler {
 	@Override
 	@SuppressWarnings("unchecked")
 	public JsonMap toFragment(CardSource source) {
-		var hasSrc = source.src() != null && ! source.src().isEmpty();
-		var hasBody = source.body() != null && ! source.body().isEmpty();
+		var hasSrc = nn(source.src()) && ! source.src().isEmpty();
+		var hasBody = nn(source.body()) && ! source.body().isEmpty();
 		if (hasSrc && hasBody)
 			throw source.error("type='datatables' takes src= or a body, not both.");
 		if (hasSrc)
@@ -76,18 +80,13 @@ public final class DatatablesCardType implements CardTypeHandler {
 
 		var catalog = source.json(); // E-25 for a non-JSON5 body (the dropped bare-URL form), E-24 for bad JSON5.
 
-		// The pre-built SLOT_META escape hatch (until GC-1): pass through unchanged, no further validation.
-		// CardDirectiveModel calls reconcileViewVersion on it, since only it knows the template name and devMode.
-		if (catalog.get("view") instanceof Map) {
-			var frag = new JsonMap();
-			frag.put("table", catalog);
-			return frag;
-		}
+		if (catalog.get("view") instanceof Map)
+			throw source.error(E28);
 
 		var columns = catalog.getList("columns");
-		var hasDataUrl = catalog.get("dataUrl") != null;
+		var hasDataUrl = nn(catalog.get("dataUrl"));
 		var hasRows = catalog.containsKey("rows");
-		if (columns == null || hasDataUrl == hasRows)
+		if (n(columns) || hasDataUrl == hasRows)
 			throw source.error(E27);
 		if (hasRows)
 			validateRows(source, catalog);
@@ -100,7 +99,7 @@ public final class DatatablesCardType implements CardTypeHandler {
 				throw source.error("columns['%s'] requires 'key'.", i);
 			var col = (Map<String,Object>) raw;
 			var ident = columnIdent(col);
-			if (ident == null)
+			if (n(ident))
 				throw source.error("columns['%s'] requires 'key'.", i);
 			for (var key : col.keySet())
 				if (! KNOWN_COLUMN_KEYS.contains(key))
@@ -111,13 +110,13 @@ public final class DatatablesCardType implements CardTypeHandler {
 			resolveSearch(source, col, ident);
 		}
 
-		if (catalog.get("bulk") != null && catalog.get("selection") == null)
+		if (nn(catalog.get("bulk")) && n(catalog.get("selection")))
 			throw source.error("sets bulk without selection.");
 		checkAggregateEndpoints(catalog);
 
 		var frag = new JsonMap();
 		var page = catalog.remove("page");
-		if (page != null)
+		if (nn(page))
 			frag.put("page", page);
 		frag.put("table", catalog);
 		return frag;
@@ -131,45 +130,18 @@ public final class DatatablesCardType implements CardTypeHandler {
 			if (! (r instanceof Map<?, ?>))
 				throw source.error("type='datatables' each 'rows' element must be an object.");
 		// Q:  Use Shorts here and elsewhere in this module.
-		if ("server".equals(catalog.getString("dataMode")))
+		if (eq("server", catalog.getString("dataMode")))
 			throw source.error("type='datatables' inline 'rows' cannot be combined with dataMode:'server'; use 'dataUrl'.");
-		if (catalog.get("pollIntervalMs") != null)
+		if (nn(catalog.get("pollIntervalMs")))
 			throw source.error("type='datatables' inline 'rows' cannot be combined with 'pollIntervalMs' (nothing to poll); use 'dataUrl'.");
 		if (list.size() > ROWS_WARN_THRESHOLD)
 			LOG.log(Level.WARNING, "<@card type=\"datatables\"> inline 'rows' has {0} entries; prefer 'dataUrl' for large data sets.", list.size());
 	}
 
-	/**
-	 * Stamps a missing slot/view {@code contractVersion} on a pre-built SLOT_META and warns (throws in devMode) on a
-	 * stale pinned view version.
-	 */
-	@SuppressWarnings({
-		"unchecked" // The view of a pre-built envelope is a parsed JsonMap; it is mutated in place to inject the version.
-	})
-	static void reconcileViewVersion(String cardId, JsonMap slot, String template, boolean devMode) {
-		if (! slot.containsKey("contractVersion"))
-			slot.put("contractVersion", ViewsMixin.SLOT_CONTRACT_VERSION);
-		var view = (Map<String,Object>)slot.get("view");
-		var stated = view.get("contractVersion");
-		if (stated == null) {
-			view.put("contractVersion", ViewsMixin.CONTRACT_VERSION);
-			return;
-		}
-		if (ViewsMixin.CONTRACT_VERSION.equals(String.valueOf(stated)))
-			return;
-		var msg = String.format(
-			"Template '%s' card '%s': view.contractVersion is '%s' but the runtime ViewsMixin.CONTRACT_VERSION is '%s'. "
-			+ "Omit view.contractVersion to track the runtime automatically.",
-			template, cardId, stated, ViewsMixin.CONTRACT_VERSION);
-		LOG.log(Level.WARNING, msg);
-		if (devMode)
-			throw new IllegalArgumentException(msg);
-	}
-
 	private static String columnIdent(Map<String,Object> col) {
 		var data = col.get("data");
 		var key = col.get("key");
-		return data != null ? data.toString() : (key != null ? key.toString() : null);
+		return nn(data) ? data.toString() : (nn(key) ? key.toString() : null);
 	}
 
 	/** E-31: a {@code contractVersion} key anywhere in the catalog (table, detail, quickStats, bulk) — JS stamps versions. */
@@ -188,9 +160,9 @@ public final class DatatablesCardType implements CardTypeHandler {
 	@SuppressWarnings("unchecked")
 	private static void resolveSearch(CardSource source, Map<String,Object> col, String ident) {
 		var searchType = str(col.get("searchType"));
-		if (searchType == null || searchType.isBlank())
+		if (ib(searchType))
 			return; // Non-searchable column: no search block.
-		if (SearchType.fromWire(searchType) == null)
+		if (n(SearchType.fromWire(searchType)))
 			throw source.error("column '%s' searchType '%s' is unknown; known: '%s'.", ident, searchType, knownSearchTypes());
 
 		var customs = new LinkedHashMap<String,String>(); // name -> help
@@ -207,7 +179,7 @@ public final class DatatablesCardType implements CardTypeHandler {
 			allow = new ArrayList<>();
 			for (var n : a) {
 				var name = str(n);
-				if (! customs.containsKey(name) && SearchOperatorSet.standard().get(name) == null)
+				if (! customs.containsKey(name) && n(SearchOperatorSet.standard().get(name)))
 					throw source.error("column '%s' searchOperators names unknown operator '%s'; known: '%s'.",
 						ident, name, knownOperators(customs.keySet()));
 				allow.add(name);
@@ -255,10 +227,10 @@ public final class DatatablesCardType implements CardTypeHandler {
 			return;
 		for (var raw : actions) {
 			var action = (Map<String,Object>) raw;
-			if (! "aggregate".equals(action.get("mode")))
+			if (! eq("aggregate", action.get("mode")))
 				continue;
 			var endpoint = str(action.get("endpoint"));
-			if (endpoint != null && endpoint.matches(".*\\{[^}]+\\}.*"))
+			if (nn(endpoint) && endpoint.matches(".*\\{[^}]+\\}.*"))
 				throw new IllegalArgumentException(String.format(
 					"Bulk action '%s' has mode 'aggregate' but its endpoint '%s' contains a {field} token; "
 					+ "aggregate endpoints receive {ids} in the body.", action.get("id"), endpoint));
@@ -266,6 +238,6 @@ public final class DatatablesCardType implements CardTypeHandler {
 	}
 
 	private static String str(Object o) {
-		return o == null ? null : o.toString();
+		return n(o) ? null : o.toString();
 	}
 }
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
index d1a75c1494..1174f3e368 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/FtlAttrLists.java
@@ -59,10 +59,10 @@ final class FtlAttrLists {
 
 	static String scalar(Map<String, TemplateModel> params, String name) throws TemplateModelException {
 		var raw = params.get(name);
-		if (raw == null)
+		if (n(raw))
 			return "";
 		var u = DeepUnwrap.unwrap(raw);
-		return u == null ? "" : String.valueOf(u).trim();
+		return n(u) ? "" : String.valueOf(u).trim();
 	}
 
 	@SuppressWarnings({
@@ -70,7 +70,7 @@ final class FtlAttrLists {
 	})
 	static List<String> list(Map<String, TemplateModel> params, String directive, String name) throws TemplateModelException {
 		var raw = params.get(name);
-		if (raw == null)
+		if (n(raw))
 			return List.of();
 		var u = DeepUnwrap.unwrap(raw);
 		if (u instanceof String s) {
@@ -87,7 +87,7 @@ final class FtlAttrLists {
 		if (u instanceof Collection<?> c) {
 			var out = new ArrayList<String>();
 			for (var item : c) {
-				if (item == null)
+				if (n(item))
 					continue;
 				var t = String.valueOf(item).trim();
 				if (! t.isEmpty())
@@ -135,14 +135,14 @@ final class FtlAttrLists {
 	static boolean strictBoolean(Map<String, TemplateModel> params, String directive, String name, boolean dflt)
 			throws TemplateModelException {
 		var raw = params.get(name);
-		if (raw == null)
+		if (n(raw))
 			return dflt;
 		if (raw instanceof TemplateBooleanModel b)
 			return b.getAsBoolean();
 		var u = DeepUnwrap.unwrap(raw);
 		if (u instanceof Boolean b)
 			return b;
-		var s = u == null ? "" : String.valueOf(u);
+		var s = n(u) ? "" : String.valueOf(u);
 		if (eq(s, "true"))
 			return true;
 		if (eq(s, "false"))
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
index dba86b22ad..7d57bd991e 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/HasToolkitMethodModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 import freemarker.core.*;
@@ -53,6 +55,6 @@ public final class HasToolkitMethodModel implements TemplateMethodModelEx {
 		if (! (a instanceof TemplateScalarModel s))
 			throw FtlAttrLists.reject(String.format("%s(name) takes a string; got '%s'.", NAME, a));
 		var cap = PageCapture.get(Environment.getCurrentEnvironment());
-		return cap != null && cap.toolkits().contains(s.getAsString().trim()) ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE;
+		return nn(cap) && cap.toolkits().contains(s.getAsString().trim()) ? TemplateBooleanModel.TRUE : TemplateBooleanModel.FALSE;
 	}
 }
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
index f77b148a2e..9f15da7e5b 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/MainDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -52,11 +54,11 @@ public final class MainDirectiveModel implements TemplateDirectiveModel {
 		if (p.containsKey("format"))
 			throw FtlAttrLists.reject("<@main> has no format= attribute.");
 		FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
-		if (body != null)
+		if (nn(body))
 			throw FtlAttrLists.reject("<@main/> is self-closing; the page body is the main content and takes no nested body.");
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.consoleOpen)
+		if (n(cap) || ! cap.consoleOpen)
 			throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", NAME));
 		cap.markMain();
 	}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
index 9d09fe10af..e11035f173 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavNode.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 import freemarker.template.*;
@@ -66,7 +68,7 @@ public final class NavNode {
 		for (var c : children)
 			if (c.id.equals(nodeId))
 				throw FtlAttrLists.reject(String.format("<@node id='%s'> duplicates a sibling id under '%s'.", nodeId,
-					this.id == null ? "(root)" : String.join("/", idPath())));
+					n(this.id) ? "(root)" : String.join("/", idPath())));
 		var n = new NavNode(this, nodeId, label, hrefOrNull);
 		children.add(n);
 		return n;
@@ -85,7 +87,7 @@ public final class NavNode {
 			for (var c : n.children)
 				if (c.id.equals(step))
 					next = c;
-			if (next == null)
+			if (n(next))
 				return Optional.empty();
 			n = next;
 		}
@@ -106,7 +108,7 @@ public final class NavNode {
 
 	List<String> idPath() {
 		var out = new LinkedList<String>();
-		for (var n = this; n != null && n.id != null; n = n.parent)
+		for (var n = this; nn(n) && nn(n.id); n = n.parent)
 			out.addFirst(n.id);
 		return out;
 	}
@@ -126,7 +128,7 @@ public final class NavNode {
 			var m = new LinkedHashMap<String,Object>();
 			m.put("id", c.id);
 			m.put("label", c.label);
-			if (c.href != null)
+			if (nn(c.href))
 				m.put("href", c.href);
 			if (! c.children.isEmpty())
 				m.put("children", c.toList());
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
index ff4e590574..e949b0612d 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NavigationDirectiveModel.java
@@ -56,7 +56,7 @@ public final class NavigationDirectiveModel implements TemplateDirectiveModel {
 		FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.consoleOpen)
+		if (n(cap) || ! cap.consoleOpen)
 			throw FtlAttrLists.reject(String.format("<@%s> must be nested inside <@console>.", NAME));
 		if (cap.navOpen)
 			throw FtlAttrLists.reject("<@navigation> cannot be nested inside another <@navigation>.");
@@ -72,7 +72,7 @@ public final class NavigationDirectiveModel implements TemplateDirectiveModel {
 		cap.navOpen = true;
 		cap.navCursor.push(cap.navRoot());
 		try (var sink = new StringWriter()) {
-			if (body != null)
+			if (nn(body))
 				body.render(sink);
 		} finally {
 			cap.navCursor.pop();
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
index bab4a77a31..8fb7b7140f 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/NodeDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -81,7 +83,7 @@ public final class NodeDirectiveModel implements TemplateDirectiveModel {
 		var selected = FtlAttrLists.strictBoolean(p, NAME, "selected", false);
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.navOpen)
+		if (n(cap) || ! cap.navOpen)
 			throw FtlAttrLists.reject("<@node> must be nested inside <@navigation>.");
 
 		// visible=false omits the node and its whole subtree; the body is not rendered.
@@ -91,13 +93,13 @@ public final class NodeDirectiveModel implements TemplateDirectiveModel {
 		var node = cap.navCursor.peek().add(id, label, href.isEmpty() ? null : href);
 		cap.navCursor.push(node);
 		try (var sink = new StringWriter()) {
-			if (body != null)
+			if (nn(body))
 				body.render(sink);
 		} finally {
 			cap.navCursor.pop();
 		}
 
-		if (node.children().isEmpty() && node.href() == null)
+		if (node.children().isEmpty() && n(node.href()))
 			throw FtlAttrLists.reject(String.format("<@node id='%s'> leaf requires href=.", id));
 		if (selected)
 			cap.select(node.idPath());
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
index cb0f72616f..78ca675371 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageCapture.java
@@ -180,7 +180,7 @@ public final class PageCapture {
 	 */
 	public static PageCapture of(Environment env) {
 		var c = (PageCapture)env.getCustomState(KEY);
-		if (c == null) {
+		if (n(c)) {
 			c = new PageCapture();
 			env.setCustomState(KEY, c);
 		}
@@ -206,7 +206,7 @@ public final class PageCapture {
 	 * @throws IOException On an I/O error.
 	 */
 	public static String render(TemplateDirectiveBody body) throws TemplateException, IOException {
-		if (body == null)
+		if (n(body))
 			return "";
 		var sw = new StringWriter();
 		body.render(sw);
@@ -224,7 +224,7 @@ public final class PageCapture {
 	public PageCapture theme(String name) { theme = name; return this; }
 
 	/** @param v The {@code <@page tab>} value. @return This object. */
-	public PageCapture tab(String v) { tab = v == null ? "" : v; return this; }
+	public PageCapture tab(String v) { tab = n(v) ? "" : v; return this; }
 
 	/** @param names Toolkit names from {@code <@page toolkit>}. @return This object. */
 	public PageCapture toolkit(List<String> names) { toolkits = List.copyOf(names); return this; }
@@ -249,7 +249,7 @@ public final class PageCapture {
 	 * @throws TemplateModelException If a path is already selected (E-6).
 	 */
 	public PageCapture select(List<String> idPath) throws TemplateModelException {
-		if (selected != null)
+		if (nn(selected))
 			throw FtlAttrLists.reject(String.format("<@node id='%s'> selected=true but '%s' is already selected.",
 				idPath.get(idPath.size() - 1), String.join("/", selected)));
 		selected = List.copyOf(idPath);
@@ -268,7 +268,7 @@ public final class PageCapture {
 		for (var c : cards)
 			if (c.id().equals(card.id()))
 				throw FtlAttrLists.reject(String.format("<@card id='%s'> duplicates an existing card id.", card.id()));
-		if (markupOrNull != null) {
+		if (nn(markupOrNull)) {
 			card.template(card.id());
 			templates.put(card.id(), markupOrNull);
 		}
@@ -327,7 +327,7 @@ public final class PageCapture {
 	/** @return <jk>true</jk> while a {@code <@page>} body is rendering. */
 	public boolean inPage() { return pageOpen; }
 	String title() { return title; }
-	boolean hasHeaderTitle() { return header.title != null; }
+	boolean hasHeaderTitle() { return nn(header.title); }
 
 	//-----------------------------------------------------------------------------------------------------------------
 	// Lifecycle hooks used by the directives
@@ -367,7 +367,7 @@ public final class PageCapture {
 
 	/** §4.3: explicit selection, else tab (E-7 when it does not resolve), else empty. */
 	void resolveActiveNav() throws TemplateModelException {
-		if (selected != null) {
+		if (nn(selected)) {
 			activeNav = selected;
 			return;
 		}
@@ -439,7 +439,7 @@ public final class PageCapture {
 		var m = new LinkedHashMap<String,Object>();
 		m.put("contractVersion", CONTRACT_VERSION);
 		m.put("title", title);
-		if (theme != null)
+		if (nn(theme))
 			m.put("theme", Map.of("name", theme));
 		var h = headerMap();
 		if (! h.isEmpty())
@@ -447,7 +447,7 @@ public final class PageCapture {
 		var f = footerMap();
 		if (! f.isEmpty())
 			m.put("footer", f);
-		if (navLayout != null)
+		if (nn(navLayout))
 			m.put("navLayout", navLayout);
 		m.put("nav", navRoot.toList());
 		m.put("activeNav", activeNav);
@@ -480,17 +480,17 @@ public final class PageCapture {
 
 	private Map<String,Object> headerMap() {
 		var m = new LinkedHashMap<String,Object>();
-		if (header.title != null) m.put("title", header.title);
-		if (header.subtitle != null) m.put("subtitle", header.subtitle);
-		if (header.logoSrc != null) {
+		if (nn(header.title)) m.put("title", header.title);
+		if (nn(header.subtitle)) m.put("subtitle", header.subtitle);
+		if (nn(header.logoSrc)) {
 			var l = new LinkedHashMap<String,Object>();
 			l.put("src", header.logoSrc);
-			if (header.logoHref != null) l.put("href", header.logoHref);
-			if (header.logoAlt != null) l.put("alt", header.logoAlt);
+			if (nn(header.logoHref)) l.put("href", header.logoHref);
+			if (nn(header.logoAlt)) l.put("alt", header.logoAlt);
 			m.put("logo", l);
 		}
 		if (! header.links.isEmpty()) m.put("links", header.links);
-		if (header.userMenu != null) m.put("userMenu", header.userMenu);
+		if (nn(header.userMenu)) m.put("userMenu", header.userMenu);
 		if (header.chrome) m.put("chrome", true);
 		if (slots.containsKey("header")) m.put("slots", slots.get("header"));
 		return m;
@@ -498,7 +498,7 @@ public final class PageCapture {
 
 	private Map<String,Object> footerMap() {
 		var m = new LinkedHashMap<String,Object>();
-		if (footer.text != null) m.put("text", footer.text);
+		if (nn(footer.text)) m.put("text", footer.text);
 		if (! footer.links.isEmpty()) m.put("links", footer.links);
 		if (slots.containsKey("footer")) m.put("slots", slots.get("footer"));
 		return m;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
index 3593883a00..8d3122c048 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -60,7 +62,7 @@ public final class PageDirectiveModel implements TemplateDirectiveModel {
 		if (p.containsKey("format"))
 			throw FtlAttrLists.reject("<@page> has no format= attribute.");
 		FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
-		if (body == null)
+		if (n(body))
 			throw FtlAttrLists.reject("<@page> requires a nested body.");
 
 		var cap = PageCapture.of(env);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
index 3908a23ab9..cda037d90d 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TagMethodModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 import org.apache.juneau.bean.html5.*;
@@ -80,7 +82,7 @@ final class TagMethodModel implements TemplateMethodModelEx {
 	}
 
 	private static String normalize(String argName, String raw) {
-		if (raw == null)
+		if (n(raw))
 			throw new IllegalArgumentException("Tag " + argName + " must not be null.");
 		var lower = raw.toLowerCase(Locale.ROOT);
 		if (! lower.matches("^[a-z0-9_-]+$"))
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
index 7cea5c68f4..3529c3a5f1 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ThemeDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -93,12 +95,12 @@ public final class ThemeDirectiveModel implements TemplateDirectiveModel {
 				+ String.join(", ", ConsoleChromeMixin.BUILTIN_THEME_NAMES) + ".");
 
 		var cap = PageCapture.get(env);
-		if (cap == null || ! cap.consoleOpen)
+		if (n(cap) || ! cap.consoleOpen)
 			throw FtlAttrLists.reject(String.format(
 				"<@theme name='%s'> must be nested inside <@console>; the legacy pageThemeCss path was removed in 10.0.0.", name));
 
 		var req = FreemarkerRenderScope.request();
-		if (req == null)
+		if (n(req))
 			throw FtlAttrLists.reject("<@theme> needs FreemarkerRenderScope.request() (renderer wrap).");
 
 		// Capture any nested <@token> overrides against the stock-palette seed.  The context is cleared afterward so a
@@ -110,7 +112,7 @@ public final class ThemeDirectiveModel implements TemplateDirectiveModel {
 		var ctx = new ThemeBuildContext(name, themeBuilder);
 		cap.themeBuild = ctx;
 		try (var sink = new StringWriter()) {
-			if (body != null)
+			if (nn(body))
 				body.render(sink);
 		} finally {
 			cap.themeBuild = null;
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
index 466b549c10..fa70865e41 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/TokenDirectiveModel.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.io.*;
 import java.util.*;
 
@@ -85,8 +87,8 @@ public final class TokenDirectiveModel implements TemplateDirectiveModel {
 		FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
 
 		var cap = PageCapture.get(env);
-		var ctx = cap == null ? null : cap.themeBuild;
-		if (ctx == null)
+		var ctx = n(cap) ? null : cap.themeBuild;
+		if (n(ctx))
 			throw FtlAttrLists.reject("<@token> must be nested inside <@theme>.");
 
 		var name = FtlAttrLists.scalar(p, "name");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
index d560c67a97..e316c1cb99 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPack.java
@@ -16,6 +16,8 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
+
 import java.util.*;
 
 /**
@@ -78,7 +80,7 @@ public final class ToolkitPack {
 	 * @return A new builder.
 	 */
 	public static Builder create(String name) {
-		if (name == null || name.isBlank())
+		if (ib(name))
 			throw new IllegalArgumentException("Toolkit pack name must not be blank.");
 		return new Builder(name.trim());
 	}
@@ -185,7 +187,7 @@ public final class ToolkitPack {
 		 * @param value The kind.
 		 * @return This object.
 		 */
-		public Builder kind(Kind value) { kind = Objects.requireNonNull(value, "kind"); return this; }
+		public Builder kind(Kind value) { kind = rnn(value); return this; }
 
 		/**
 		 * Sets the asset-URL resolver.  Defaults to {@link ToolkitPackRegistry#VIEWS_RESOLVER}.
@@ -193,7 +195,7 @@ public final class ToolkitPack {
 		 * @param value The resolver.
 		 * @return This object.
 		 */
-		public Builder resolver(ToolkitPackRegistry.AssetUrlResolver value) { resolver = Objects.requireNonNull(value, "resolver"); return this; }
+		public Builder resolver(ToolkitPackRegistry.AssetUrlResolver value) { resolver = rnn(value); return this; }
 
 		/**
 		 * Builds the pack.
@@ -202,7 +204,7 @@ public final class ToolkitPack {
 		 * @throws IllegalArgumentException If no kind was set.
 		 */
 		public ToolkitPack build() {
-			if (kind == null)
+			if (n(kind))
 				throw new IllegalArgumentException(String.format("Toolkit pack '%s' needs kind(VENDOR) or kind(RUNTIME).", name));
 			return new ToolkitPack(this);
 		}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
index e4f1b169f4..61910857bd 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
@@ -16,6 +16,7 @@
  */
 package org.apache.juneau.rest.server.view.freemarker.console;
 
+import static org.apache.juneau.commons.utils.Shorts.*;
 import static org.apache.juneau.rest.server.view.freemarker.console.ToolkitPack.Kind.*;
 
 import java.util.*;
@@ -227,7 +228,7 @@ public final class ToolkitPackRegistry {
 		for (var pack : packs.values()) {
 			for (var dep : pack.dependsOn()) {
 				var target = packs.get(dep);
-				if (target == null)
+				if (n(target))
 					throw new IllegalArgumentException(String.format("Unknown toolkit pack '%s' (dependsOn of pack '%s').", dep, pack.name()));
 				if (pack.kind() == VENDOR && target.kind() == RUNTIME)
 					throw new IllegalArgumentException(String.format("VENDOR toolkit pack '%s' cannot depend on RUNTIME pack '%s'.", pack.name(), dep));
@@ -278,9 +279,9 @@ public final class ToolkitPackRegistry {
 	 * @throws IllegalStateException If the request is missing or a resolver fails (e.g. a WebJar isn't on the classpath).
 	 */
 	public Resolved resolve(List<String> names, RestRequest req, List<String> pageUrls, String template) {
-		if (names == null || names.isEmpty())
+		if (n(names) || names.isEmpty())
 			return Resolved.EMPTY;
-		if (req == null)
+		if (n(req))
 			throw new IllegalStateException("<@page toolkit> needs FreemarkerRenderScope.request() (renderer wrap).");
 		List<ToolkitPack> order;
 		Set<String> skip;
@@ -331,7 +332,7 @@ public final class ToolkitPackRegistry {
 				throw new IllegalStateException("Pack '" + pack.name() + "' " + e.getMessage(), e);
 			}
 			var dup = pageFiles.get(fileName(url));
-			if (dup != null) {
+			if (nn(dup)) {
 				LOG.warning(String.format("Template '%s': skipped pack '%s' asset %s; the page already loads %s.", template, pack.name(), url, dup));
 				continue;
 			}
@@ -357,7 +358,7 @@ public final class ToolkitPackRegistry {
 			throw new IllegalArgumentException("Toolkit pack cycle: " + String.join(" → ", cycle) + ".");
 		}
 		var pack = packs.get(name);
-		if (pack == null)
+		if (n(pack))
 			throw new IllegalArgumentException(String.format("Unknown toolkit pack '%s' (%s).", name, source));
 		path.add(name);
 		for (var dep : pack.dependsOn())
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
index 5f4d6b21ed..f0fffe0eac 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardDirective_Test.java
@@ -72,6 +72,12 @@ class CardDirective_Test extends TestBase {
 		public View cardDatatablesSearch() { return FreemarkerView.of("admin/page-card-datatables-search.ftlh"); }
 		@RestGet(path="/card-datatables-badcustom")
 		public View cardDatatablesBadCustom() { return FreemarkerView.of("admin/page-card-datatables-badcustom.ftlh"); }
+		@RestGet(path="/cards-class")
+		public View cardsClass() { return FreemarkerView.of("admin/page-cards-class.ftlh"); }
+		@RestGet(path="/cards-class-bad")
+		public View cardsClassBad() { return FreemarkerView.of("admin/page-cards-class-bad.ftlh"); }
+		@RestGet(path="/cards-class-digit")
+		public View cardsClassDigit() { return FreemarkerView.of("admin/page-cards-class-digit.ftlh"); }
 		@RestGet(path="/card-html-template")
 		public View cardHtmlTemplate() { return FreemarkerView.of("admin/page-card-html-template.ftlh"); }
 	}
@@ -206,4 +212,29 @@ class CardDirective_Test extends TestBase {
 		for (var path : new String[] {"/cards-html", "/card-datatables", "/card-html-template"})
 			assertFalse(get(path).contains("juneau-card-sidecar"), path);
 	}
+
+	@Test void b15_classAttr_singleClass_isInContract() throws Exception {
+		var body = get("/cards-class");
+		assertEquals("ssc-skills", card(body, "one").getString("class"));
+		assertEquals("ssc-scripts", card(body, "dt").getString("class"));
+	}
+
+	@Test void b16_classAttr_multipleClasses_areNormalizedToSingleSpaces() throws Exception {
+		assertEquals("ssc-skills x_1 -y", card(get("/cards-class"), "many").getString("class"));
+	}
+
+	@Test void b17_classAttr_absent_emitsNoClassKey() throws Exception {
+		assertFalse(card(get("/cards-class"), "none").containsKey("class"));
+	}
+
+	@Test void b18_classAttr_invalidToken_failsTheRender() throws Exception {
+		for (var path : new String[] {"/cards-class-bad", "/cards-class-digit"})
+			try (var c = MockRestClient.buildLax(Host.class);
+				var rsp = c.get(path).run()) {
+				rsp.assertStatus(500);
+				var body = rsp.getContent().asString();
+				assertTrue(body.contains("<@card id='bad'> class='"), () -> body);
+				assertTrue(body.contains("must be a space-separated list of CSS class names"), () -> body);
+			}
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
index c90757f7a4..785862fb5f 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_SelfLint_Test.java
@@ -49,9 +49,7 @@ class ConsoleTemplateValidator_SelfLint_Test extends TestBase {
 		"c1/bool-chrome-empty.ftlh",      // NodeDirective_StrictBoolean_Test.a04_rejected_E5 expects E-5 strict-boolean
 		"c1/rv-card-src.ftlh",            // CardDirective_RunView_Test expects the src= rejection; card-src-and-body also flags it
 		"c1/co-card-src.ftlh",            // CardDirective_ConsoleOutput_Test expects the src= rejection; card-src-and-body also flags it
-		"c1/cv-omit.ftlh",                // ViewContractVersion_Test renders it through the pre-built SLOT_META escape hatch; inline-slot-meta flags it (removed with the hatch)
-		"c1/cv-pinned-ok.ftlh",           // ViewContractVersion_Test renders it through the pre-built SLOT_META escape hatch; inline-slot-meta flags it (removed with the hatch)
-		"c1/cv-pinned-stale.ftlh",        // ViewContractVersion_Test.b03_render_mismatched_failsInDevMode expects the stale-pin failure (and inline-slot-meta)
+		"c1/cv-pinned-stale.ftlh",        // ViewContractVersion_Test.b03_render_prebuiltSlotMeta_failsWithE28 expects E-28
 		"c1/rv-card-template.ftlh"        // CardDirective_RunView_Test rejects template= on a run-view card
 	);
 
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
index 820e1d9285..730cdff487 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleTemplateValidator_Test.java
@@ -163,7 +163,7 @@ class ConsoleTemplateValidator_Test extends TestBase {
 	}
 
 	//-----------------------------------------------------------------------------------------------------------------
-	// card-src-and-body, inline-slot-meta, card-reserved-key (C2)
+	// card-src-and-body, card-reserved-key (C2)
 	//-----------------------------------------------------------------------------------------------------------------
 
 	@Test void cb01_cardSrcAndBody() {
@@ -176,20 +176,10 @@ class ConsoleTemplateValidator_Test extends TestBase {
 		assertClean("<@page><@card id=\"t\" type=\"datatables\" src=\"/rest/t\"/></...@page>");
 	}
 
-	@Test void cb03_inlineSlotMeta() {
-		assertOne("<@page><@card id=\"t\" type=\"datatables\">{layout:'wide',view:{}}</@...@page>",
-			1, 8, "inline-slot-meta",
-			"<@card id='t'> body is a pre-built SLOT_META (has a 'view' object); author the table catalog in author shape instead. This becomes an error at GC-1.");
-	}
-
-	@Test void cb03b_inlineSlotMeta_staleVersionAlsoReportsViewContractVersion() {
-		// C1's view-contract-version rule stays (Auto-decision 4); both rules fire on a stale pinned escape hatch.
+	@Test void cb03_prebuiltSlotMeta_reportsOnlyViewContractVersionAfterGc1() {
+		// inline-slot-meta is gone at GC-1 (E-28 at render); C1's view-contract-version rule stays (Auto-decision 4).
 		var f = lint("<@page><@card id=\"t\" type=\"datatables\">{view:{contractVersion:'0',id:'t'}}</@...@page>");
-		assertEquals(List.of("inline-slot-meta", "view-contract-version"), f.stream().map(x -> x.rule()).sorted().toList());
-	}
-
-	@Test void cb04_inlineSlotMeta_onlyForDatatables() {
-		assertClean("<@page><@card id=\"t\" type=\"kpi\">{contractVersion:'1',view:{}}</@...@page>");
+		assertEquals(List.of("view-contract-version"), f.stream().map(x -> x.rule()).toList());
 	}
 
 	@Test void cb05_cardReservedKey() {
@@ -209,6 +199,25 @@ class ConsoleTemplateValidator_Test extends TestBase {
 		assertClean("<@page><@card id=\"t\">{value:1}</@...@page>");
 	}
 
+	@Test void cb09_classAttr_isAcceptedOnCard() {
+		assertClean("<@page><@card id=\"t\" class=\"ssc-skills x\"><p>...@page>");
+	}
+
+	@Test void cb10_escapedQuoteInSingleQuotedString_isNotAReservedKey() {
+		assertClean("<@page><@card id=\"t\" type=\"kpi\">{ label: 'item\\'s', note: 'id: 1, type: 2', value: 1 }</@...@page>");
+	}
+
+	@Test void cb11_escapedQuoteInDoubleQuotedString_isNotAReservedKey() {
+		assertClean("<@page><@card id=\"t\" type=\"kpi\">{ \"say\": \"say \\\"hi\\\" id: x\", value: 1 }</@...@page>");
+	}
+
+	@Test void cb12_reservedKeyAfterEscapedQuote_isStillCaught() {
+		assertOne("<@page><@card id=\"t\" type=\"kpi\">{ label: 'item\\'s', src: 'x', value: 1 }</@...@page>",
+			1, 8, "card-reserved-key", "<@card id='t'> body key 'src' is reserved; set it as an attribute.");
+		assertOne("<@page><@card id=\"t\" type=\"kpi\">{ label: \"say \\\"hi\\\"\", id: 'x' }</@...@page>",
+			1, 8, "card-reserved-key", "<@card id='t'> body key 'id' is reserved; set it as an attribute.");
+	}
+
 	//-----------------------------------------------------------------------------------------------------------------
 	// removed-card-type, removed-global
 	//-----------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
index e808f56d61..3049e43913 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/DatatablesCardType_Test.java
@@ -60,10 +60,15 @@ class DatatablesCardType_Test extends TestBase {
 		assertEquals("<@card id='t'> type='datatables' requires src= or a body with 'columns' and exactly one of 'dataUrl' or 'rows'.", ex.getMessage());
 	}
 
-	@Test void preBuiltSlotMetaEscapeHatch_passesThroughUnchanged() {
-		var card = h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/x', columns:[]}}"));
-		var table = (JsonMap) card.get("table");
-		assertEquals("1", table.get("contractVersion"));
+	@Test void preBuiltSlotMeta_throwsE28() {
+		var expected = "<@card id='t'> type='datatables' body is a pre-built SLOT_META envelope; "
+			+ "remove 'contractVersion', 'layout' and 'view' and author the catalog form.";
+		var ex = assertThrows(IllegalArgumentException.class,
+			() -> h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/x', columns:[]}}")));
+		assertEquals(expected, ex.getMessage());
+		ex = assertThrows(IllegalArgumentException.class,
+			() -> h.toFragment(src("t", "{view:{id:'t', dataUrl:'/x', columns:[]}}")));
+		assertEquals(expected, ex.getMessage());
 	}
 
 	@Test void columnMissingKeyAndData_throwsE29() {
@@ -131,12 +136,6 @@ class DatatablesCardType_Test extends TestBase {
 		assertEquals("a", table.getList("columns").getMap(0).get("key"));
 	}
 
-	@Test void preBuiltEnvelope_withoutSlotContractVersion_passesThrough() {
-		var card = h.toFragment(src("t", "{view:{id:'t', dataUrl:'/x', columns:[]}}"));
-		assertNull(((JsonMap) card.get("table")).get("contractVersion"));
-		assertNotNull(((JsonMap) card.get("table")).get("view"));
-	}
-
 	@Test void inlineRows_only_ok() {
 		var table = (JsonMap) h.toFragment(src("t", "{rows:[{a:1}], columns:[{key:'a'}]}")).get("table");
 		assertEquals(1, table.getList("rows").size());
@@ -174,12 +173,6 @@ class DatatablesCardType_Test extends TestBase {
 		assertDoesNotThrow(() -> h.toFragment(src("t", "{rows:[], dataMode:'client', columns:[{key:'a'}]}")));
 	}
 
-	@Test void prebuiltEnvelope_withRowsAndDataUrl_passesThroughUntouched() {
-		var table = (JsonMap) h.toFragment(src("t", "{contractVersion:'1', view:{id:'t', dataUrl:'/d', columns:[]}, rows:[{a:1}]}")).get("table");
-		assertNotNull(table.get("view"));
-		assertEquals(1, table.getList("rows").size());
-	}
-
 	@Test void pageKey_isHoistedToFragment() {
 		var frag = h.toFragment(src("t", "{dataUrl:'/x', columns:[{key:'a'}], page:{size:25}}"));
 		assertNotNull(frag.get("page"));
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
index 71acf7c325..4c5de2d967 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ModuleGraph_ImportScan_Test.java
@@ -28,8 +28,8 @@ import org.junit.jupiter.api.*;
 /**
  * Phase 5 module-graph re-check (now with real code): no class under {@code console-ui-freemarker}'s MAIN source
  * imports {@code org.apache.juneau.rest.server.datatables.*}. A chrome-only consumer of this module never drags
- * {@code -datatables} onto the classpath (the {@code console-ui-freemarker-datatables} module is the only one
- * allowed to import that package &mdash; see Phase 7's own module-graph re-check).
+ * {@code -datatables} onto the classpath (no console module imports that package; {@code datatables} cards reach
+ * DataTables through the {@code datatables-glue} toolkit pack).
  */
 class ModuleGraph_ImportScan_Test extends TestBase {
 
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
index fc66532caa..ea5765046c 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/PageDirective_Test.java
@@ -126,7 +126,7 @@ class PageDirective_Test extends TestBase {
 	}
 
 	@Test void freeze_demoDatatableMacro_isNotTypeDatatables() throws Exception {
-		// The demo <@datatable> macro lives in the datatables module; this module must NOT define a shared
+		// The old <@datatable> macro module is gone; nothing may re-introduce a shared variable named "datatable".
 		// variable named "datatable".  type="datatables" on <@card> is the framework surface, not a directive.
 		var cfg = ConsoleFreemarkerMixin.create().basePath("/templates/")
 			.chromeTemplate("admin/console-chrome-bare.ftlh").build()
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
index ac54bba1d8..8278ef1136 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ViewContractVersion_Test.java
@@ -16,14 +16,10 @@ import static org.apache.juneau.test.bct.BctAssertions.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 import java.util.*;
-import java.util.logging.*;
 
 import org.apache.juneau.*;
 import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.http.Path;
-import org.apache.juneau.marshall.collections.*;
-import org.apache.juneau.marshall.json5.*;
-import org.apache.juneau.marshall.parser.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.server.*;
 import org.apache.juneau.rest.server.servlet.*;
@@ -33,8 +29,8 @@ import org.apache.juneau.rest.server.views.*;
 import org.junit.jupiter.api.*;
 
 /**
- * {@code view.contractVersion} auto-injection: omitted &rarr; injected, matching &rarr; silent, mismatched
- * &rarr; WARNING naming the template and both versions, plus a loud failure in dev mode and a static validator finding.
+ * A pre-built SLOT_META body fails at render time with E-28; the static {@code view-contract-version} validator rule
+ * still flags a stale pinned version.
  *
  * @since 10.0.0
  */
@@ -45,73 +41,6 @@ class ViewContractVersion_Test extends TestBase {
 
 	private static final String RUNTIME = ViewsMixin.CONTRACT_VERSION;
 
-	/** Captures WARNING-and-above records emitted by {@link DatatablesCardType}. */
-	private static final class Capture extends Handler {
-		final List<String> messages = new ArrayList<>();
-		@Override public void publish(LogRecord r) { messages.add(r.getLevel() + ": " + r.getMessage()); }
-		@Override public void flush() { /* no-op */ }
-		@Override public void close() { /* no-op */ }
-	}
-
-	private static List<String> warningsFrom(Runnable r) {
-		var log = Logger.getLogger(DatatablesCardType.class.getName());
-		var cap = new Capture();
-		log.addHandler(cap);
-		try {
-			r.run();
-		} finally {
-			log.removeHandler(cap);
-		}
-		return cap.messages;
-	}
-
-	private static JsonMap parse(String json5) {
-		try {
-			return Json5Parser.DEFAULT.read(json5, JsonMap.class);
-		} catch (ParseException e) {
-			throw new IllegalArgumentException(e);
-		}
-	}
-
-	private static JsonMap catalog(String versionClause) {
-		return parse("{contractVersion:'1', layout:'wide', view:{" + versionClause + "id:'t', dataUrl:'/d', columns:[]}}");
-	}
-
-	@Test void a01_omitted_isInjected() {
-		var slot = catalog("");
-		var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
-		assertEquals(RUNTIME, slot.getMap("view").getString("contractVersion"));
-		assertTrue(warnings.isEmpty(), warnings::toString);
-	}
-
-	@Test void a02_omittedSlotVersion_isAlsoDefaulted() {
-		var slot = parse("{view:{id:'t', dataUrl:'/d', columns:[]}}");
-		DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", false);
-		assertBean(slot, "contractVersion,view{contractVersion}", "1,{" + RUNTIME + "}");
-	}
-
-	@Test void a03_matching_noWarning() {
-		var slot = catalog("contractVersion:'" + RUNTIME + "',");
-		var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
-		assertTrue(warnings.isEmpty(), warnings::toString);
-		assertEquals(RUNTIME, slot.getMap("view").getString("contractVersion"));
-	}
-
-	@Test void a04_mismatched_warnsNamingTemplateAndBothVersions_notDev() {
-		var slot = catalog("contractVersion:'0',");
-		var warnings = warningsFrom(() -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", false));
-		assertEquals(1, warnings.size(), warnings::toString);
-		assertTrue(warnings.get(0).startsWith("WARNING: "), warnings::toString);
-		assertContainsAll(warnings.get(0), "'p.ftlh'", "card 't'", "is '0'", "is '" + RUNTIME + "'");
-		assertEquals("0", slot.getMap("view").getString("contractVersion"), "an explicit pin is left alone");
-	}
-
-	@Test void a05_mismatched_failsLoudlyInDevMode() {
-		var slot = catalog("contractVersion:'0',");
-		var ex = assertThrows(IllegalArgumentException.class, () -> DatatablesCardType.reconcileViewVersion("t", slot, "p.ftlh", true));
-		assertContainsAll(ex.getMessage(), "'p.ftlh'", "is '0'", "is '" + RUNTIME + "'");
-	}
-
 	//-----------------------------------------------------------------------------------------------------------------
 	// End to end through <@card type="datatables">
 	//-----------------------------------------------------------------------------------------------------------------
@@ -128,23 +57,10 @@ class ViewContractVersion_Test extends TestBase {
 		}
 	}
 
-	@Test void b01_render_omitted_injectsRuntimeVersion() throws Exception {
-		try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-omit").run()) {
-			rsp.assertStatus(200);
-			assertTrue(rsp.getContent().asString().contains("\"contractVersion\":\"" + RUNTIME + "\""));
-		}
-	}
-
-	@Test void b02_render_matching_ok() throws Exception {
-		try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-pinned-ok").run()) {
-			rsp.assertStatus(200);
-		}
-	}
-
-	@Test void b03_render_mismatched_failsInDevMode() throws Exception {
+	@Test void b03_render_prebuiltSlotMeta_failsWithE28() throws Exception {
 		try (var c = MockRestClient.buildLax(DevHost.class); var rsp = c.get("/t/cv-pinned-stale").run()) {
 			rsp.assertStatus(500);
-			assertContainsAll(rsp.getContent().asString(), "c1/cv-pinned-stale.ftlh", "is '4'", "is '" + RUNTIME + "'");
+			assertContainsAll(rsp.getContent().asString(), "c1/cv-pinned-stale.ftlh", "body is a pre-built SLOT_META envelope");
 		}
 	}
 
@@ -153,7 +69,7 @@ class ViewContractVersion_Test extends TestBase {
 	//-----------------------------------------------------------------------------------------------------------------
 
 	private static List<ConsoleTemplateValidator.Finding> lint(String source) {
-		// Only the version rule is under test; the fixtures' pre-built view objects also trip the unrelated inline-slot-meta rule.
+		// Only the version rule is under test.
 		return ConsoleTemplateValidator.create().validateSource("t.ftlh", source).stream().filter(x -> "view-contract-version".equals(x.rule())).toList();
 	}
 
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
similarity index 87%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
index f5ba7e0acd..ca36e934aa 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/chrome.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-bad.ftlh
@@ -13,9 +13,6 @@
   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.
---><@console>
-<@navigation>
-<@node id="home" label="Home" href="/home"/>
-</...@navigation>
-<@main/>
-</...@console>
+--><@page>
+<@card id="bad" class="ok a&quot;b"><p>...@card>
+</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
similarity index 82%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
index 7a090b4023..06ee1821ea 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/resources/templates/admin/page-datatable.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class-digit.ftlh
@@ -13,5 +13,6 @@
   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.
---><#include "/org/apache/juneau/console/datatables/datatable.ftlh">
-<@page toolkit="views"><@datatable id="releases" rows=releases rowType=rowTypeName/></...@page>
+--><@page>
+<@card id="bad" class="9lives"><p>...@card>
+</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
similarity index 73%
rename from juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh
rename to juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
index accdd7b9eb..930b6366ce 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-omit.ftlh
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/admin/page-cards-class.ftlh
@@ -13,17 +13,9 @@
   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.
--->
-<@page>
-<@card id="t" type="datatables">
-{
-    layout: 'wide',
-    view: {
-        
-        id: 't',
-        dataUrl: '/d',
-        columns: [ { data: 'a', title: 'A' } ]
-    }
-}
-</...@card>
+--><@page>
+<@card id="one" class="ssc-skills"><p>...@card>
+<@card id="many" class="  ssc-skills   x_1 -y  "><p>...@card>
+<@card id="none"><p>...@card>
+<@card id="dt" type="datatables" class="ssc-scripts">{ dataUrl: '/rest/t', columns: [ { key: 'name', label: 'Name' } ] }</...@card>
 </...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh
deleted file mode 100644
index 001e6983d9..0000000000
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/resources/templates/c1/cv-pinned-ok.ftlh
+++ /dev/null
@@ -1,29 +0,0 @@
-<#--
-  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.
--->
-<@page>
-<@card id="t" type="datatables">
-{
-    layout: 'wide',
-    view: {
-        contractVersion: '5',
-        id: 't',
-        dataUrl: '/d',
-        columns: [ { data: 'a', title: 'A' } ]
-    }
-}
-</...@card>
-</...@page>
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index 2dd8eb6d03..4555a66111 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -179,6 +179,15 @@ public class ConsoleChromeMixin {
 	/** Classpath location of the console shell script. */
 	public static final String CONSOLE_JS_RESOURCE = "/org/apache/juneau/console/juneau-console.js";
 
+	/** Context-root-absolute path of the count-badge script. */
+	public static final String BADGES_JS_PATH = "/juneau-console/juneau-badges.js";
+
+	/** {@link #BADGES_JS_PATH} minus the {@code /juneau-console} prefix. */
+	public static final String BADGES_JS_PATH_UNPREFIXED = "/juneau-badges.js";
+
+	/** Classpath location of the count-badge script. */
+	public static final String BADGES_JS_RESOURCE = "/org/apache/juneau/console/juneau-badges.js";
+
 	/** Context-root-absolute path of the page-contract schema. */
 	public static final String SCHEMA_PATH = "/juneau-console/juneau-page.schema.json";
 
@@ -354,6 +363,21 @@ public class ConsoleChromeMixin {
 		return ASSET_CACHE.serve(CONSOLE_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
 	}
 
+	/**
+	 * [GET /juneau-console/juneau-badges.js] &mdash; serve the count-badge script.
+	 *
+	 * @return The script.
+	 */
+	@RestGet(
+		path={BADGES_JS_PATH, BADGES_JS_PATH_UNPREFIXED},
+		summary="Console count-badge script",
+		description="Polls and renders the header.badges entries of the #juneau-page contract.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getBadgesJs() {
+		return ASSET_CACHE.serve(BADGES_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
+	}
+
 	/**
 	 * [GET /juneau-console/juneau-page.schema.json] &mdash; serve the page-contract schema.
 	 *
@@ -418,6 +442,16 @@ public class ConsoleChromeMixin {
 		return consoleMountUrl(req, CONSOLE_JS_PATH, CONSOLE_JS_RESOURCE);
 	}
 
+	/**
+	 * Context-root-absolute, cache-busted URL of the count-badge script.
+	 *
+	 * @param req The current request.
+	 * @return The URL.
+	 */
+	public static String badgesJsUrl(RestRequest req) {
+		return consoleMountUrl(req, BADGES_JS_PATH, BADGES_JS_RESOURCE);
+	}
+
 	/**
 	 * Context-root-absolute URL for an asset on the standalone {@code /juneau-console/*} mount.
 	 *
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
index 474e341eef..7d5046d221 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
@@ -240,6 +240,59 @@ a:hover { text-decoration: underline; }
   border: 1px solid var(--jc-tag-neutral-border);
 }
 
+/* Count badges (header.badges[]): inline pills fed by a polled endpoint; tones reuse the StatusTone tokens above. */
+.jc-header-badges {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.jc-count-badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 10px;
+  border-radius: 12px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 1.4;
+  cursor: pointer;
+  background-color: var(--jc-tag-amber-bg);
+  color: var(--jc-tag-amber-text);
+  border: 1px solid var(--jc-tag-amber-border);
+}
+.jc-count-badge[hidden] { display: none; }
+.jc-count-badge[data-juneau-badge-tone="info"]  { background-color: var(--jc-tag-blue-bg); color: var(--jc-tag-blue-text); border-color: var(--jc-tag-blue-border); }
+.jc-count-badge[data-juneau-badge-tone="error"] { background-color: var(--jc-tag-red-bg);  color: var(--jc-tag-red-text);  border-color: var(--jc-tag-red-border); }
+.jc-count-badge--stale { background-color: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); border-color: var(--jc-tag-neutral-border); }
+.jc-count-badge--pulse { animation: jc-count-badge-pulse 0.8s ease-out 1; }
+
+@keyframes jc-count-badge-pulse {
+  0%   { transform: scale(1); }
+  40%  { transform: scale(1.15); }
+  100% { transform: scale(1); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .jc-count-badge--pulse { animation: none; }
+}
+
+.jc-count-badge-popover {
+  position: absolute;
+  z-index: 1000;
+  max-width: 320px;
+  padding: 6px 0;
+  background-color: var(--jc-surface, #fff);
+  color: inherit;
+  border: 1px solid var(--jc-tag-neutral-border);
+  border-radius: 6px;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
+  font-size: 13px;
+}
+.jc-count-badge-popover-item,
+.jc-count-badge-popover-more { display: block; padding: 4px 12px; color: inherit; text-decoration: none; }
+.jc-count-badge-popover-item--mine { font-weight: 600; }
+.jc-count-badge-popover-more { opacity: 0.7; }
+
 /* A dot badge (no count) collapses to a small circle in the same corner. */
 .jc-badge.jc-badge-dot {
   min-width: 8px;
@@ -1329,6 +1382,15 @@ body::after {
   background-color: var(--jc-btn-primary-bg-hover);
 }
 
+.juneau-view-dialog-confirm.juneau-view-dialog-confirm-danger {
+  background-color: var(--jc-danger);
+  color: var(--jc-on-btn-primary);
+  border-color: var(--jc-danger);
+}
+.juneau-view-dialog-confirm.juneau-view-dialog-confirm-danger:hover {
+  filter: brightness(0.92);
+}
+
 .juneau-view-dialog-form-row input:not([type="checkbox"]):not([type="hidden"]),
 .juneau-view-dialog-form-row textarea,
 .juneau-view-dialog-form-row select {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js
new file mode 100644
index 0000000000..3a6c1a9d4a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-badges.js
@@ -0,0 +1,396 @@
+/*
+ * 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.
+ */
+
+/**
+ * Juneau console count badges. Mounts the `header.badges[]` entries of a page contract (see
+ * juneau-page.schema.json): each badge polls a same-origin JSON endpoint returning {total, mine?, items?[]},
+ * renders a labelled count, hides itself at zero, and lists the first items in a popover.
+ *
+ * Badges are presentation only: the endpoint must still authorize the caller. A 401/403 removes the badge and
+ * stops polling for it; any other failure marks it stale and retries with exponential backoff.
+ *
+ * @example
+ * JuneauConsoleBadges.mount([{ id: 'pending', src: '/rest/changes/pending', href: '/ui/changes', refreshMs: 60000 }]);
+ *
+ * Error codes: E-JS-65 (fetch failed, or 401/403), E-JS-66 (response has no numeric `total`).
+ */
+(function () {
+	"use strict";
+
+	const NS = window.JuneauConsoleBadges = window.JuneauConsoleBadges || {};
+
+	const MIN_REFRESH_MS = 5000;
+	const DEFAULT_REFRESH_MS = 60000;
+	const DEFAULT_TOOLTIP_MAX = 10;
+	const MAX_BACKOFF_MS = 5 * 60 * 1000;
+	const KNOWN_OPS = ["eq", "ne", "present", "absent", "in", "contains"];
+	// The contract's `danger` tone paints with the shared `error` status tone; the other two names match.
+	const TONE_TOKEN = { info: "info", warning: "warning", danger: "error" };
+
+	function fmt(msg, ...args) {
+		let i = 0;
+		return msg.replace(/%s/g, () => String(args[i++]));
+	}
+
+	function logError(code, msg, ...args) {
+		console.error("[juneau-badges] " + code + ": " + fmt(msg, ...args));
+	}
+
+	let mounted = {}; // badge id -> state
+
+	/**
+	 * Supplies the page context a badge is scoped and gated by: `{activeNav: [ids], tableTab: {cardId: tabId},
+	 * facts: {...}}`. The default reads the mounted JuneauConsole contract and the `?state=` tab.
+	 */
+	function defaultContext() {
+		const jc = window.JuneauConsole;
+		const contract = jc && typeof jc.contract === "function" ? jc.contract() : null;
+		const ctx = { activeNav: (contract && contract.activeNav) || [], facts: (contract && contract.facts) || {}, tableTab: {} };
+		const urlState = window.JuneauViews && window.JuneauViews.urlState;
+		const loc = window.location;
+		if (urlState && typeof urlState.decode === "function" && loc && loc.search) {
+			const raw = new window.URLSearchParams(loc.search).get("state");
+			const tab = raw ? urlState.decode(raw).tab : null;
+			if (tab) ctx.tableTab = new Proxy({}, { get: () => tab });
+		}
+		return ctx;
+	}
+
+	let contextProvider = defaultContext;
+	NS.setContextProvider = function (fn) { contextProvider = typeof fn === "function" ? fn : defaultContext; };
+
+	/**
+	 * Mounts badge definitions (the `header.badges[]` wire shape).
+	 * @param {Array<Object>} defs
+	 */
+	NS.mount = function (defs) {
+		for (const def of defs || []) mountOne(def);
+	};
+
+	/** Unmounts every badge and stops polling. */
+	NS.unmountAll = function () {
+		for (const id of Object.keys(mounted)) {
+			const st = mounted[id];
+			if (st.timer) clearTimeout(st.timer);
+			closeTooltip(st);
+			if (st.el && st.el.parentNode) st.el.parentNode.removeChild(st.el);
+		}
+		mounted = {};
+	};
+
+	// ---- visibleWhen (same semantics as the shell's card visibility: a missing fact fails closed except `absent`) ----
+
+	function factAt(facts, path) {
+		let cur = facts;
+		for (const part of String(path || "").split(".")) {
+			if (cur == null || typeof cur !== "object" || !(part in cur)) return { missing: true };
+			cur = cur[part];
+		}
+		return { value: cur };
+	}
+
+	function ruleMatches(rule, facts) {
+		const f = factAt(facts, rule && rule.field);
+		const blank = f.missing || f.value == null || (typeof f.value === "string" && f.value.trim() === "");
+		switch (rule && rule.op) {
+			case "eq": return !f.missing && f.value === rule.value;
+			case "ne": return !f.missing && f.value !== rule.value;
+			case "present": return !blank;
+			case "absent": return blank;
+			case "in": return !f.missing && Array.isArray(rule.value) && rule.value.includes(f.value);
+			case "contains": return !f.missing && Array.isArray(f.value) && f.value.includes(rule.value);
+			default:
+				console.error("[juneau-badges] " + fmt("visibleWhen rule on field '%s' has unknown op '%s' (known: '%s')",
+					rule && rule.field, rule && rule.op, KNOWN_OPS.join(", ")));
+				return false;
+		}
+	}
+
+	function visible(def) {
+		if (!def.visibleWhen) return true;
+		const rules = Array.isArray(def.visibleWhen) ? def.visibleWhen : [def.visibleWhen];
+		const facts = (contextProvider() || {}).facts || {};
+		return rules.every(r => ruleMatches(r, facts));
+	}
+
+	// ---- polling ----
+
+	function mountOne(def) {
+		if (!def || !def.id || mounted[def.id]) return;
+		if (!visible(def)) return;
+		const st = { def: def, seenIds: null, backoffMs: 0, timer: null, el: null, popover: null, lastTotal: null, lastBody: null };
+		mounted[def.id] = st;
+		poll(st);
+	}
+
+	function resolveScope(def) {
+		if (!def.scope) return {};
+		const ctx = contextProvider() || {};
+		const by = def.scope.by || "nav";
+		const key = by === "view"
+			? (ctx.tableTab && ctx.tableTab[def.table])
+			: (ctx.activeNav && ctx.activeNav[ctx.activeNav.length - 1]);
+		if (def.scope.values) {
+			const vals = key != null ? def.scope.values[key] : null;
+			if (!vals || !vals.length) return null; // no scope values for this page: hide, keep polling
+			return { param: def.scope.param, values: vals };
+		}
+		return {};
+	}
+
+	function enc(v) { return encodeURIComponent(String(v)); }
+
+	function pollUrl(def, scope) {
+		const params = [];
+		if (def.params) for (const k of Object.keys(def.params)) params.push(enc(k) + "=" + enc(def.params[k]));
+		if (scope && scope.param) params.push(enc(scope.param) + "=" + scope.values.map(enc).join(","));
+		return params.length ? def.src + (def.src.indexOf("?") >= 0 ? "&" : "?") + params.join("&") : def.src;
+	}
+
+	function interval(def) { return Math.max(def.refreshMs || DEFAULT_REFRESH_MS, MIN_REFRESH_MS); }
+
+	function scheduleNext(st, ms) {
+		if (st.timer) clearTimeout(st.timer);
+		st.timer = setTimeout(() => poll(st), Math.max(ms, MIN_REFRESH_MS));
+	}
+
+	function backoffAndRetry(st) {
+		st.backoffMs = st.backoffMs ? Math.min(st.backoffMs * 2, MAX_BACKOFF_MS) : interval(st.def);
+		scheduleNext(st, st.backoffMs);
+	}
+
+	function poll(st) {
+		if (mounted[st.def.id] !== st) return;
+		const def = st.def;
+		const scope = resolveScope(def);
+		if (scope === null) {
+			if (st.el) st.el.hidden = true;
+			scheduleNext(st, interval(def));
+			return;
+		}
+		fetch(pollUrl(def, scope), { cache: "no-store", credentials: "same-origin" }).then(res => {
+			if (res.status === 401 || res.status === 403) {
+				logError("E-JS-65", "badge '%s' fetch failed: '%s'", def.id, res.status);
+				removeBadge(st);
+				return undefined;
+			}
+			return res.json().then(body => {
+				if (mounted[def.id] !== st) return;
+				if (!body || typeof body.total !== "number") {
+					logError("E-JS-66", "badge '%s' response has no numeric 'total'", def.id);
+					markStale(st);
+					backoffAndRetry(st);
+					return;
+				}
+				st.backoffMs = 0;
+				render(st, body);
+				detectDrain(st, body);
+				scheduleNext(st, interval(def));
+			});
+		}).catch(err => {
+			if (mounted[def.id] !== st) return;
+			logError("E-JS-65", "badge '%s' fetch failed: '%s'", def.id, err && err.message);
+			markStale(st);
+			backoffAndRetry(st);
+		});
+	}
+
+	// ---- rendering ----
+
+	function findHost(def) {
+		const doc = window.document;
+		if (def.placement === "toolbar" && def.table) {
+			const card = doc.getElementById(def.table);
+			return card ? (card.querySelector(".jc-ribbon") || card) : null;
+		}
+		let host = doc.querySelector(".jc-header-badges");
+		if (!host) {
+			const parent = doc.querySelector(".jc-header-actions") || doc.querySelector(".jc-header");
+			if (parent) {
+				host = doc.createElement("div");
+				host.className = "jc-header-badges";
+				parent.appendChild(host);
+			}
+		}
+		return host;
+	}
+
+	function ensureEl(st) {
+		if (st.el) return st.el;
+		const def = st.def;
+		const doc = window.document;
+		const el = doc.createElement("button");
+		el.type = "button";
+		el.className = "jc-count-badge";
+		el.setAttribute("role", "status");
+		el.setAttribute("aria-live", "polite");
+		el.setAttribute("data-juneau-badge-tone", TONE_TOKEN[def.tone] || "warning");
+		el.setAttribute("data-juneau-badge-id", def.id);
+		el.hidden = true;
+		el.addEventListener("click", () => onClick(st));
+		el.addEventListener("mouseenter", () => openTooltip(st));
+		el.addEventListener("focus", () => openTooltip(st));
+		el.addEventListener("mouseleave", () => closeTooltip(st));
+		el.addEventListener("blur", () => closeTooltip(st));
+		const host = findHost(def);
+		if (host) host.appendChild(el);
+		st.el = el;
+		return el;
+	}
+
+	function prefersReducedMotion() {
+		return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
+	}
+
+	function render(st, body) {
+		const el = ensureEl(st);
+		const label = st.def.label || {};
+		const prev = st.lastTotal;
+		st.lastTotal = body.total;
+		st.lastBody = body;
+		el.classList.remove("jc-count-badge--stale");
+		el.removeAttribute("aria-label");
+		if (body.total === 0) {
+			el.hidden = true;
+			return;
+		}
+		el.hidden = false;
+		el.setAttribute("data-jc-total", String(body.total));
+		const tmpl = body.total === 1 ? (label.one || "{total} change pending") : (label.other || "{total} changes pending");
+		let text = tmpl.replace(/\{total\}/g, body.total);
+		if (body.mine > 0) text += (label.mine || " ({mine} yours)").replace(/\{mine\}/g, body.mine);
+		el.textContent = text;
+		if (prev != null && body.total > prev) {
+			el.classList.remove("jc-count-badge--pulse");
+			if (!prefersReducedMotion()) el.classList.add("jc-count-badge--pulse");
+		}
+	}
+
+	function markStale(st) {
+		const el = ensureEl(st);
+		el.hidden = false;
+		el.classList.add("jc-count-badge--stale");
+		el.textContent = "!";
+		el.setAttribute("aria-label", "Could not refresh " + st.def.id + " (retrying)");
+	}
+
+	function removeBadge(st) {
+		if (st.timer) clearTimeout(st.timer);
+		closeTooltip(st);
+		if (st.el && st.el.parentNode) st.el.parentNode.removeChild(st.el);
+		if (mounted[st.def.id] === st) delete mounted[st.def.id];
+	}
+
+	// ---- drain detection: an item that left the response means linked cards are stale ----
+
+	function reloadCard(cardId) {
+		const jc = window.JuneauConsole;
+		try {
+			if (jc && typeof jc.refreshCard === "function") {
+				const p = jc.refreshCard(cardId);
+				if (p && typeof p.catch === "function") p.catch(() => {});
+				return;
+			}
+		} catch (e) {
+			// card not mounted on this page: nothing to refresh
+			return;
+		}
+		const card = window.JuneauViews && window.JuneauViews.card;
+		if (card && typeof card.reload === "function") card.reload(cardId);
+	}
+
+	function detectDrain(st, body) {
+		const ids = (body.items || []).map(it => it.id);
+		if (st.seenIds && st.def.refreshes && st.seenIds.some(id => ids.indexOf(id) < 0))
+			for (const cardId of st.def.refreshes) reloadCard(cardId);
+		st.seenIds = ids;
+	}
+
+	// ---- tooltip popover ----
+
+	function openTooltip(st) {
+		const body = st.lastBody;
+		if (!body || !body.items || !body.items.length || !st.el || st.el.hidden) return;
+		closeTooltip(st);
+		const doc = window.document;
+		const def = st.def;
+		const max = def.tooltipMax != null ? Math.max(0, Math.min(50, def.tooltipMax)) : DEFAULT_TOOLTIP_MAX;
+		if (max === 0) return;
+		const pop = doc.createElement("div");
+		pop.className = "jc-count-badge-popover";
+		pop.id = "jc-count-badge-pop-" + def.id;
+		pop.setAttribute("role", "tooltip");
+		const shown = body.items.slice(0, max);
+		for (const it of shown) {
+			const row = doc.createElement(it.href ? "a" : "div");
+			row.className = "jc-count-badge-popover-item" + (it.mine ? " jc-count-badge-popover-item--mine" : "");
+			if (it.href) row.setAttribute("href", it.href);
+			row.textContent = it.label || it.id;
+			pop.appendChild(row);
+		}
+		if (body.items.length > shown.length) {
+			const more = doc.createElement("div");
+			more.className = "jc-count-badge-popover-more";
+			more.textContent = "and " + (body.items.length - shown.length) + " more";
+			pop.appendChild(more);
+		}
+		doc.body.appendChild(pop);
+		st.el.setAttribute("aria-describedby", pop.id);
+		st.popover = pop;
+	}
+
+	function closeTooltip(st) {
+		if (st.popover && st.popover.parentNode) st.popover.parentNode.removeChild(st.popover);
+		st.popover = null;
+		if (st.el) st.el.removeAttribute("aria-describedby");
+	}
+
+	window.document.addEventListener("keydown", ev => {
+		if (ev.key !== "Escape") return;
+		for (const id of Object.keys(mounted)) closeTooltip(mounted[id]);
+	});
+
+	// ---- click navigation ----
+
+	function onClick(st) {
+		const def = st.def;
+		if (!def.href) return;
+		const scope = resolveScope(def);
+		let dest = def.href;
+		const urlState = window.JuneauViews && window.JuneauViews.urlState;
+		if (def.stateFilter && scope && scope.values && scope.values.length && urlState && typeof urlState.encode === "function") {
+			const expr = scope.values.length === 1
+				? "$eq(" + scope.values[0] + ")"
+				: "$in(" + scope.values.join(",") + ")";
+			const state = urlState.encode({ filters: [{ column: def.stateFilter.column || def.stateFilter, expr: expr }] });
+			dest = def.href + (def.href.indexOf("?") >= 0 ? "&" : "?") + "state=" + enc(state);
+		}
+		window.location.assign(dest);
+	}
+
+	// ---- auto-mount from the console contract ----
+
+	/** Mounts `header.badges` of the contract the console shell has mounted, if any. */
+	NS.mountFromContract = function () {
+		const jc = window.JuneauConsole;
+		const contract = jc && typeof jc.contract === "function" ? jc.contract() : null;
+		const defs = contract && contract.header && contract.header.badges;
+		if (defs && defs.length) NS.mount(defs);
+	};
+
+	NS.mountFromContract();
+})();
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
index 223ac80036..b4b08c5008 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-console.js
@@ -79,6 +79,42 @@
 		return msg.replace(/%s/g, () => String(args[i++]));
 	}
 
+	// Card visibility: the page's top-level `facts` tested by the card's `visibleWhen` rule (one rule object, or a
+	// list that must all match).  Presentation only.  This is a deliberate small twin of JuneauViews.rules.test,
+	// because the shell must decide before any card handler (and possibly before the views toolkit) has loaded;
+	// both are held to one shared corpus.  A field absent from the facts fails closed, except for `absent`.
+	const FACT_MISSING = {};
+	function factAt(facts, path) {
+		let cur = facts;
+		for (const part of String(path).split(".")) {
+			if (cur === null || typeof cur !== "object" || !(part in cur)) return FACT_MISSING;
+			cur = cur[part];
+		}
+		return cur;
+	}
+	function blankFact(v) { return v === null || v === undefined || String(v).trim() === ""; }
+	function factRuleMatches(rule, facts) {
+		const actual = factAt(facts, rule && rule.field);
+		const missing = actual === FACT_MISSING;
+		switch (rule && rule.op) {
+			case "eq": return !missing && actual === rule.value;
+			case "ne": return !missing && actual !== rule.value;
+			case "present": return !missing && !blankFact(actual);
+			case "absent": return missing || blankFact(actual);
+			case "in": return !missing && Array.isArray(rule.value) && rule.value.includes(actual);
+			case "contains": return !missing && Array.isArray(actual) && actual.includes(rule.value);
+			default:
+				console.error("[juneau-console] " + fmt("visibleWhen rule on field '%s' has unknown op '%s'", rule && rule.field, rule && rule.op));
+				return false;
+		}
+	}
+	function cardVisible(card, facts) {
+		const w = card.visibleWhen;
+		if (w === undefined || w === null) return true;
+		const rules = Array.isArray(w) ? w : [w];
+		return rules.every(r => factRuleMatches(r, facts));
+	}
+
 	// Every loud failure: one banner listing all failures, console.error, and a throw for fatal codes.
 	function fail(doc, msgKey, ...args) {
 		const msg = fmt(MSG[msgKey], ...args);
@@ -582,7 +618,8 @@
 			const opts = Object.assign({ credentials: "same-origin" }, init);
 			const method = (opts.method || "GET").toUpperCase();
 			opts.method = method;
-			opts.headers = Object.assign({}, init && init.headers, csrfHeaders(doc, method));
+			// Ask for JSON explicitly: a content-negotiating endpoint answers the browser's default */* with HTML.
+			opts.headers = Object.assign({ Accept: "application/json" }, init && init.headers, csrfHeaders(doc, method));
 			opts.signal = controller.signal;
 			return fetch(url, opts).then(function (r) {
 				if (state.inflight === controller) state.inflight = null;
@@ -737,7 +774,9 @@
 			if (!t) throw new Error("no <template data-card=\"" + id + "\">");
 			return clone(doc, t);
 		};
+		const facts = contract.facts && typeof contract.facts === "object" ? contract.facts : {};
 		for (const card of contract.cards || []) {
+			if (!cardVisible(card, facts)) continue;   // hidden by its visibleWhen rule: never mounted, never fetched
 			frozenCards.set(card.id, Object.freeze(Object.assign({}, card)));
 			const bare = card.type === "html" && card.bare === true;
 			let host;
@@ -746,6 +785,10 @@
 			} else {
 				host = el(doc, "div", "jc-card");
 				host.id = card.id;
+				// Adopter classes follow the built-in one; only well-formed tokens are applied.
+				if (typeof card.class === "string")
+					for (const t of card.class.split(/\s+/))
+						if (/^[A-Za-z_-][A-Za-z0-9_-]*$/.test(t)) host.classList.add(t);
 				if (card.title) {
 					const t = el(doc, "h2", "jc-card-title");
 					t.textContent = card.title;
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
index 3983358c72..383e264d97 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/juneau-page.schema.json
@@ -14,7 +14,8 @@
     "navLayout": { "enum": ["horizontal", "vertical"], "default": "horizontal" },
     "nav":       { "type": "array", "items": { "$ref": "#/$defs/navNode" } },
     "activeNav": { "type": "array", "items": { "$ref": "#/$defs/id" } },
-    "cards":     { "type": "array", "items": { "$ref": "#/$defs/card" } }
+    "cards":     { "type": "array", "items": { "$ref": "#/$defs/card" } },
+    "facts":     { "type": "object", "description": "Page-level facts (for example the viewer's roles) that visibleWhen rules test; presentation only, never an authorization gate." }
   },
   "$defs": {
     "id":       { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$" },
@@ -76,7 +77,8 @@
         "slots": {
           "allOf": [ { "$ref": "#/$defs/slots" } ],
           "propertyNames": { "enum": ["brand", "actions", "replace", "banner"] }
-        }
+        },
+        "badges":   { "type": "array", "items": { "$ref": "#/$defs/badge" } }
       }
     },
     "footer": {
@@ -116,6 +118,7 @@
         "src":      { "$ref": "#/$defs/url" },
         "title":    { "type": "string" },
         "template": { "type": "string", "description": "id of a <template data-card> element" },
+        "class":    { "type": "string", "pattern": "^[A-Za-z_-][A-Za-z0-9_-]*( [A-Za-z_-][A-Za-z0-9_-]*)*$", "description": "space-separated CSS classes stamped on the card wrapper element" },
         "visibleWhen": { "$ref": "#/$defs/visibleWhen" }
       },
       "oneOf": [
@@ -129,7 +132,7 @@
     "htmlCard": {
       "properties": {
         "type": { "const": "html" },
-        "id": true, "src": true, "title": true, "template": true,
+        "id": true, "src": true, "title": true, "template": true, "class": true,
         "bare": { "type": "boolean", "default": false }
       },
       "additionalProperties": false,
@@ -138,13 +141,10 @@
     "datatablesCard": {
       "properties": {
         "type":  { "const": "datatables" },
-        "id": true, "title": true, "visibleWhen": true,
+        "id": true, "title": true, "visibleWhen": true, "class": true,
         "src":   { "$ref": "#/$defs/url" },
         "page":  { "type": "object" },
-        "table": { "anyOf": [
-          { "$ref": "#/$defs/tableCatalog" },
-          { "type": "object", "required": ["view"], "properties": { "view": { "type": "object" } } }
-        ] }
+        "table": { "$ref": "#/$defs/tableCatalog" }
       },
       "additionalProperties": false,
       "oneOf": [
@@ -226,13 +226,81 @@
     "rowAction":   { "type": "object", "required": ["id", "endpoint"], "description": "RowAction wire shape; later work adds tone/confirmLabel/confirmRenderer/visibleWhen." },
     "enabledRule": { "type": "object", "required": ["field", "op"], "properties": { "field": { "type": "string" }, "op": { "type": "string" }, "value": true, "reason": { "type": "string" } } },
     "rowDetail":   { "type": "object", "required": ["endpoint"], "properties": { "contractVersion": false } },
-    "visibleWhen": { "description": "Declarative per-card visibility rule; shape defined with the visibility work." },
-    "urlState":    { "description": "URL-state options; shape defined with the URL-state work." },
+    "visibleRule": {
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["field", "op"],
+      "properties": {
+        "field": { "type": "string", "minLength": 1, "description": "Dotted path into the page facts, e.g. viewer.roles." },
+        "op":    { "enum": ["eq", "ne", "present", "absent", "in", "contains"] },
+        "value": true
+      }
+    },
+    "badge": {
+      "description": "A count badge fed by a same-origin JSON endpoint returning {total, mine?, items?[]}. Presentation only; the endpoint must authorize the caller.",
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["id", "src"],
+      "properties": {
+        "id":          { "$ref": "#/$defs/id" },
+        "src":         { "$ref": "#/$defs/url" },
+        "params":      { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
+        "refreshMs":   { "type": "integer", "default": 60000 },
+        "scope":       { "type": "object", "additionalProperties": false, "required": ["param"],
+                         "properties": { "param": { "type": "string" }, "by": { "enum": ["nav", "view"], "default": "nav" },
+                                         "values": { "type": "object", "additionalProperties": { "type": "array", "items": { "type": "string" } } } } },
+        "label":       { "type": "object", "additionalProperties": false,
+                         "properties": { "one": { "type": "string" }, "other": { "type": "string" }, "mine": { "type": "string" } } },
+        "tooltipMax":  { "type": "integer", "default": 10 },
+        "href":        { "$ref": "#/$defs/url" },
+        "stateFilter": { "type": "object", "additionalProperties": false, "required": ["column"], "properties": { "column": { "type": "string" } } },
+        "refreshes":   { "type": "array", "items": { "$ref": "#/$defs/id" } },
+        "placement":   { "enum": ["header", "toolbar"], "default": "header" },
+        "table":       { "$ref": "#/$defs/id" },
+        "tone":        { "enum": ["info", "warning", "danger"], "default": "warning" },
+        "visibleWhen": { "$ref": "#/$defs/visibleWhen" }
+      }
+    },
+    "visibleWhen": {
+      "description": "Declarative visibility rule, or a list of rules that must all match; tested against the page's top-level facts. Presentation only.",
+      "oneOf": [
+        { "$ref": "#/$defs/visibleRule" },
+        { "type": "array", "items": { "$ref": "#/$defs/visibleRule" } }
+      ]
+    },
+    "urlState": {
+      "description": "Registers the built-in ?state= directives for this table. Each key present enables that directive.",
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "window": {
+          "description": "window(start=<iso>;end=<iso>): a time window sent as two request params.",
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "params": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": { "start": { "type": "string", "minLength": 1 }, "end": { "type": "string", "minLength": 1 } }
+            }
+          }
+        },
+        "ids": {
+          "description": "ids(a,b,c): a de-duplicated id list sent as one comma-joined request param.",
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "param": { "type": "string", "minLength": 1 },
+            "max": { "type": "integer" }
+          }
+        }
+      }
+    },
     "consoleOutputCard": {
       "description": "Console-output bridge card; the shell calls JuneauViews.consoleOutput.mount(body, output). Ranges are checked by ConsoleOutputDef.",
       "properties": {
         "type":   { "const": "console-output" },
-        "id": true, "title": true,
+        "id": true, "title": true, "class": true,
         "output": {
           "type": "object",
           "properties": {
@@ -259,7 +327,7 @@
       "description": "Run-view bridge card; the shell calls JuneauViews.runView.mount(body, runView). Ranges are checked by RunViewDef. eventsUrl is required unless poll is false (anyOf, because the subset validator has no if/then).",
       "properties": {
         "type":   { "const": "run-view" },
-        "id": true, "title": true,
+        "id": true, "title": true, "class": true,
         "runView": {
           "type": "object",
           "properties": {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
index c7e3966922..3b344eaf58 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Serving_Test.java
@@ -59,6 +59,17 @@ class ConsoleChromeMixin_Serving_Test extends TestBase {
 		assertEquals(PageContractSchema.get().schemaJson(), body);
 	}
 
+	@ParameterizedTest
+	@ValueSource(strings = {"/juneau-console/juneau-badges.js", "/juneau-badges.js"})
+	void a04_badgesJs(String path) throws Exception {
+		var body = C.get(path).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.getContent().asString();
+		assertTrue(body.contains("window.JuneauConsoleBadges"), "badges body");
+	}
+
 	@Test void a03_constants() {
 		assertEquals("/juneau-console/juneau-console.js", ConsoleChromeMixin.CONSOLE_JS_PATH);
 		assertEquals("/juneau-console/juneau-page.schema.json", ConsoleChromeMixin.SCHEMA_PATH);
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
index c562ece902..0028cadb96 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
@@ -39,7 +39,6 @@ import org.junit.jupiter.params.provider.*;
  * <pre>
  * console-ui                       → rest-server
  * console-ui-freemarker            → console-ui + view-freemarker     (NOT -datatables)
- * console-ui-freemarker-datatables → console-ui-freemarker + datatables (the ONLY module allowed to touch both)
  * datatables                       → rest-server + bean-html5          (NOT console-ui — stays generic)
  * view-freemarker                  → rest-server + freemarker          (NOT -datatables — untouched)
  * </pre>
@@ -77,12 +76,6 @@ class ModuleGraph_Test extends TestBase {
 		var pom = pomOf("juneau-rest-server-datatables");
 		assertFalse(hasDependency(pom, "juneau-rest-server-console-ui"));
 		assertFalse(hasDependency(pom, "juneau-rest-server-console-ui-freemarker"));
-		assertFalse(hasDependency(pom, "juneau-rest-server-console-ui-freemarker-datatables"));
-	}
-
-	@Test void a05_consoleUiFreemarkerDatatables_dependsOnBothHalves() throws IOException {
-		var pom = pomOf("juneau-rest-server-console-ui-freemarker-datatables");
-		assertContainsAll(pom, "<artifactId>juneau-rest-server-console-ui-freemarker</artifactId>", "<artifactId>juneau-rest-server-datatables</artifactId>");
 	}
 
 	@Test void a06_consoleUi_dependsOnlyOnRestServer() throws IOException {
@@ -109,6 +102,12 @@ class ModuleGraph_Test extends TestBase {
 		assertFalse(restPom.contains("<module>juneau-rest-server-theme-packs</module>"));
 	}
 
+	/** The console-ui-freemarker-datatables module (the old <@datatable> macro) was deleted; datatables cards go through the card registry. */
+	@Test void a14_restParentPom_listsNoConsoleUiFreemarkerDatatablesModule() throws IOException {
+		var restPom = Files.readString(new File(new File(System.getProperty("user.dir")).getParentFile(), "pom.xml").toPath());
+		assertFalse(restPom.contains("<module>juneau-rest-server-console-ui-freemarker-datatables</module>"));
+	}
+
 	/**
 	 * {@code PageContractAssert} (C1-D3) ships at main scope so adopters can use it under any test framework;
 	 * it must not pull in JUnit or opentest4j.
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
index 9f01d97efa..c7c4755feb 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/PageContractSchema_Test.java
@@ -43,7 +43,7 @@ class PageContractSchema_Test extends TestBase {
 	@ValueSource(strings = {"valid-example.json", "valid-minimal.json", "valid-deep.json", "valid-console-output.json",
 		"valid-run-view.json", "valid-run-view-nopoll.json",
 		"valid-datatables-table.json", "valid-datatables-table-full.json", "valid-datatables-rows.json",
-		"valid-datatables-src.json", "valid-datatables-slotmeta.json"})
+		"valid-datatables-src.json", "valid-badge.json"})
 	void a01_validCorpusPasses(String name) throws Exception {
 		var errors = PageContractSchema.get().validate(fixture(name));
 		assertTrue(errors.isEmpty(), () -> name + ": " + errors);
@@ -52,6 +52,8 @@ class PageContractSchema_Test extends TestBase {
 	@ParameterizedTest
 	@CsvSource(delimiter = '|', value = {
 		"invalid-version.json|$.contractVersion: must equal 1",
+		"invalid-badge-unknown-key.json|bogus: not allowed",
+		"invalid-badge-refreshms.json|refreshMs",
 		"invalid-legacy-version.json|missing required 'contractVersion'",
 		"invalid-legacy-version.json|$.version: not allowed",
 		"invalid-both-versions.json|$.version: not allowed",
@@ -80,6 +82,7 @@ class PageContractSchema_Test extends TestBase {
 		"invalid-r3.json|failed at 'b'",
 		"invalid-datatables-both.json|oneOf",
 		"invalid-datatables-rows-and-dataurl.json|oneOf",
+		"invalid-datatables-slotmeta.json|oneOf",
 		"invalid-tablecolumn-no-key.json|oneOf",
 		"invalid-tablecolumn-extra.json|oneOf",
 		"invalid-selection-norowid.json|oneOf",
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json
new file mode 100644
index 0000000000..22ef8bd874
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-refreshms.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","refreshMs":"fast"}]}}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json
new file mode 100644
index 0000000000..9f8c62c944
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-badge-unknown-key.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","bogus":true}]}}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-datatables-slotmeta.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-datatables-slotmeta.json
similarity index 100%
rename from juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-datatables-slotmeta.json
rename to juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/invalid-datatables-slotmeta.json
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json
new file mode 100644
index 0000000000..c9a9abe97f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/resources/contracts/valid-badge.json
@@ -0,0 +1 @@
+{"contractVersion":"1","title":"T","nav":[],"activeNav":[],"cards":[],"header":{"badges":[{"id":"pending","src":"/x","refreshMs":60000,"tone":"danger","visibleWhen":[{"field":"viewer.roles","op":"contains","value":"oncall"}]}]}}
diff --git a/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java b/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
index 6d69121176..0e201d9ec3 100644
--- a/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
+++ b/juneau-rest/juneau-rest-server-datatables/src/test/java/org/apache/juneau/rest/server/datatables/DataTablesTable_HtmlRenderHonoring_Test.java
@@ -35,7 +35,6 @@ import org.junit.jupiter.api.*;
  * <p>
  * Per the plan's Phase-6 test-fixture-discipline note (S4), this local {@link LocalFixtureRender} is deliberately
  * NOT {@code TagHtmlRender}/{@code console-ui} &mdash; this module gains no test-scope dependency on {@code console-ui}.
- * The real {@code .tag}-markup-in-a-real-datatable proof is Phase 7's job, via the actual {@code <@datatable>} macro.
  */
 class DataTablesTable_HtmlRenderHonoring_Test extends TestBase {
 
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
index 83f34e3bcf..b65609cfc6 100644
--- a/juneau-rest/juneau-rest-server-views/pom.xml
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -35,6 +35,11 @@
 	</properties>
 
 	<dependencies>
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-run-report</artifactId>
+			<version>${project.version}</version>
+		</dependency>
 		<dependency>
 			<groupId>org.apache.juneau</groupId>
 			<artifactId>juneau-rest-server-widgets</artifactId>
@@ -185,6 +190,9 @@
 								<juneau.jsTests>true</juneau.jsTests>
 								<juneau.jsTests.dir>${js.tests.dir}</juneau.jsTests.dir>
 								<juneau.jsTests.harness>${project.basedir}/src/test/js/row-actions.cjs</juneau.jsTests.harness>
+								<!-- Later *_BrowserTest classes resolve their own prober from this directory by name, so each
+								     needs neither its own system property nor its own pom edit. -->
+								<juneau.jsTests.harnessDir>${project.basedir}/src/test/js</juneau.jsTests.harnessDir>
 								<juneau.jsTests.node>${juneau.jsTests.node}</juneau.jsTests.node>
 								<juneau.jsTests.browsers>${juneau.jsTests.browsers}</juneau.jsTests.browsers>
 							</systemPropertyVariables>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java
new file mode 100644
index 0000000000..228ff58b9c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BadgeDef.java
@@ -0,0 +1,354 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+/**
+ * A count badge in the page contract, fed by a JSON endpoint and polled by {@code juneau-badges.js}.
+ *
+ * <p>
+ * The endpoint returns {@code {total, mine?, items?[]}}.  The badge is presentation only: the endpoint must
+ * still authorize the caller, and a page should not declare a badge for users who cannot read it.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jk>var</jk> <jv>badge</jv> = BadgeDef
+ * 		.<jsm>create</jsm>(<js>"pending"</js>)
+ * 		.src(<js>"/rest/changes/pending"</js>)
+ * 		.scope(<js>"beanType"</js>, BadgeDef.ScopeBy.<jsf>NAV</jsf>, Map.<jsm>of</jsm>(<js>"suspensions"</js>, List.<jsm>of</jsm>(<js>"Suspension"</js>)))
+ * 		.href(<js>"/ui/changes"</js>)
+ * 		.stateFilter(<js>"beanType"</js>)
+ * 		.refreshes(<js>"rules-table"</js>)
+ * 		.refreshMs(60_000);
+ * 	<jv>badge</jv>.validate();  <jc>// throws IllegalArgumentException with an E-6x message</jc>
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public final class BadgeDef {
+
+	/** The minimum poll interval, in milliseconds (schema §4.8 {@code refreshMs.minimum}). */
+	public static final long MIN_REFRESH_MS = 5000L;
+
+	/** Default poll interval, in milliseconds, applied by {@code juneau-badges.js} when {@link #refreshMs} is unset. */
+	public static final long DEFAULT_REFRESH_MS = 60_000L;
+
+	/** Default tooltip item cap, applied by {@code juneau-badges.js} when {@link #tooltipMax} is unset. */
+	public static final int DEFAULT_TOOLTIP_MAX = 10;
+
+	/** {@code scope.by} wire tokens (schema §4.8). */
+	public enum ScopeBy {
+		/** Keys on the leaf of the page's {@code activeNav}. */
+		NAV("nav"),
+		/** Keys on the active tab of the card named by {@link BadgeDef#table}. */
+		VIEW("view");
+
+		private final String wire;
+		ScopeBy(String wire) { this.wire = wire; }
+		/** @return The lowercase wire token (e.g. <c>"nav"</c>). */
+		public String wire() { return wire; }
+	}
+
+	/** {@code placement} wire tokens (schema §4.8). */
+	public enum Placement {
+		/** Renders into {@code .jc-header-badges} at the end of the header actions area. */
+		HEADER("header"),
+		/** Renders at the right end of {@link BadgeDef#table}'s ribbon. */
+		TOOLBAR("toolbar");
+
+		private final String wire;
+		Placement(String wire) { this.wire = wire; }
+		/** @return The lowercase wire token (e.g. <c>"header"</c>). */
+		public String wire() { return wire; }
+	}
+
+	/** {@code tone} wire tokens (schema §4.8). */
+	public enum Tone {
+		/** Informational tone. */
+		INFO("info"),
+		/** Warning tone (the default). */
+		WARNING("warning"),
+		/** Danger tone. */
+		DANGER("danger");
+
+		private final String wire;
+		Tone(String wire) { this.wire = wire; }
+		/** @return The lowercase wire token (e.g. <c>"warning"</c>). */
+		public String wire() { return wire; }
+	}
+
+	private String id;
+	private String src;
+	private Map<String,Object> params;
+	private Long refreshMs;
+	private String scopeParam;
+	private ScopeBy scopeBy;
+	private Map<String,List<String>> scopeValues;
+	private Map<String,String> label;
+	private Integer tooltipMax;
+	private String href;
+	private String stateFilterColumn;
+	private List<String> refreshes;
+	private Placement placement;
+	private String table;
+	private Tone tone;
+	private List<VisibilityRule> visibleWhen;
+
+	private BadgeDef() {}
+
+	/**
+	 * Creates a badge with the given id.
+	 *
+	 * @param id This badge's own id, unique across every Java- and FTL-declared badge on the page (C3 E-B15).
+	 * 	Must not be <jk>null</jk> or blank.
+	 * @return A new {@link BadgeDef}.
+	 * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
+	 */
+	public static BadgeDef create(String id) {
+		if (id == null || id.isBlank())
+			throw iaex("BadgeDef id must not be null or blank.");
+		var b = new BadgeDef();
+		b.id = id;
+		return b;
+	}
+
+	/**
+	 * Sets the same-origin endpoint this badge polls.
+	 *
+	 * @param value The endpoint path.  Checked for presence, not shape, by {@link #validate()} (E-61).
+	 * @return This object.
+	 */
+	public BadgeDef src(String value) {
+		src = value;
+		return this;
+	}
+
+	/**
+	 * Sets extra request parameters merged into every poll (ahead of the scope params).
+	 *
+	 * @param value The parameters.
+	 * @return This object.
+	 */
+	public BadgeDef params(Map<String,Object> value) {
+		params = value;
+		return this;
+	}
+
+	/**
+	 * Sets the poll interval.
+	 *
+	 * @param intervalMs The desired interval, in milliseconds.  Checked against {@link #MIN_REFRESH_MS} by
+	 * 	{@link #validate()} (E-62) &mdash; unlike {@link RegionDef#refreshMs(long)}, this is rejected, not clamped.
+	 * @return This object.
+	 */
+	public BadgeDef refreshMs(long intervalMs) {
+		refreshMs = intervalMs;
+		return this;
+	}
+
+	/**
+	 * Sets this badge's scope (§4.3): the request param to key the poll on, how to resolve its value, and
+	 * (optionally) the authoritative value map.
+	 *
+	 * @param param The query parameter name sent with the poll.  Must not be <jk>null</jk> or blank.
+	 * @param by {@link ScopeBy#NAV} or {@link ScopeBy#VIEW}.
+	 * @param values The authoritative value map, keyed by nav-leaf or view-tab id, or <jk>null</jk> for an
+	 * 	unscoped badge that still carries a {@code by}.
+	 * @return This object.
+	 */
+	public BadgeDef scope(String param, ScopeBy by, Map<String,List<String>> values) {
+		scopeParam = param;
+		scopeBy = by;
+		scopeValues = values;
+		return this;
+	}
+
+	/**
+	 * Sets this badge's label templates (§4.4).
+	 *
+	 * @param value A map with any of the keys {@code one}, {@code other}, {@code mine}, each a {@code {total}}/
+	 * 	{@code {mine}} template.  Unset keys fall back to the stock IRS-equivalent defaults in {@code juneau-badges.js}.
+	 * @return This object.
+	 */
+	public BadgeDef label(Map<String,String> value) {
+		label = value;
+		return this;
+	}
+
+	/**
+	 * Sets the tooltip popover's item cap (§4.5).
+	 *
+	 * @param value The cap, 0-50.  Not range-checked here; schema `$defs/badge.tooltipMax` enforces it for
+	 * 	FTL-declared badges, and an out-of-range Java value is simply clamped by {@code juneau-badges.js} at
+	 * 	render time.
+	 * @return This object.
+	 */
+	public BadgeDef tooltipMax(int value) {
+		tooltipMax = value;
+		return this;
+	}
+
+	/**
+	 * Sets the click-target URL (§4.6).
+	 *
+	 * @param value The destination.  A same-origin path is strongly recommended; the C1 URL safety check applies
+	 * 	client-side regardless (an unsafe {@code href} is dropped with E-JS-9).
+	 * @return This object.
+	 */
+	public BadgeDef href(String value) {
+		href = value;
+		return this;
+	}
+
+	/**
+	 * Sets the column {@code href}'s destination page filters on, via {@code JuneauViews.urlState.encode(...)}
+	 * (§4.6).  Requires a non-empty {@link #scope} to have any effect.
+	 *
+	 * @param column The destination table's filter column name.
+	 * @return This object.
+	 */
+	public BadgeDef stateFilter(String column) {
+		stateFilterColumn = column;
+		return this;
+	}
+
+	/**
+	 * Sets the card ids to {@code JuneauViews.tables.reload(el, {resetPaging: false})} when a previously-seen
+	 * item id drains from the badge's response (§4.1 step 6).
+	 *
+	 * @param value The card ids, in reload order.
+	 * @return This object.
+	 */
+	public BadgeDef refreshes(String...value) {
+		refreshes = l(value);
+		return this;
+	}
+
+	/**
+	 * Sets where this badge renders (§4.7).
+	 *
+	 * @param value {@link Placement#HEADER} (default) or {@link Placement#TOOLBAR}.
+	 * @return This object.
+	 */
+	public BadgeDef placement(Placement value) {
+		placement = value;
+		return this;
+	}
+
+	/**
+	 * Sets the card id this badge is anchored to.  Required when {@link #placement} is {@link Placement#TOOLBAR}
+	 * (E-66) and when {@link #scope}'s {@code by} is {@link ScopeBy#VIEW} (R-7, uncoded).
+	 *
+	 * @param value The card id.
+	 * @return This object.
+	 */
+	public BadgeDef table(String value) {
+		table = value;
+		return this;
+	}
+
+	/**
+	 * Sets this badge's tone (§4.11: Juneau's own tokens, not IRS's gold styling).
+	 *
+	 * @param value {@link Tone#INFO}, {@link Tone#WARNING} (default) or {@link Tone#DANGER}.
+	 * @return This object.
+	 */
+	public BadgeDef tone(Tone value) {
+		tone = value;
+		return this;
+	}
+
+	/**
+	 * Sets the dynamic-visibility rules gating this badge (§8.2); all must match (AND).
+	 *
+	 * @param value The rules, or none for an always-visible badge.
+	 * @return This object.
+	 */
+	public BadgeDef visibleWhen(VisibilityRule...value) {
+		visibleWhen = l(value);
+		return this;
+	}
+
+	/**
+	 * Validates this definition.
+	 *
+	 * <p>
+	 * Enforces E-61 (no {@link #src}), E-62 ({@link #refreshMs} below {@link #MIN_REFRESH_MS}), E-66
+	 * ({@link Placement#TOOLBAR} without {@link #table}), and R-7 (uncoded: {@link ScopeBy#VIEW} without
+	 * {@link #table}).  R-6's "table/refreshes name a real card id" and R-8's "scope.values key is a real nav
+	 * node id" need the rest of the page and run in {@code PageCapture} at {@code </...@console>} instead (§4.10).
+	 *
+	 * @throws IllegalArgumentException If this definition is not well-formed.
+	 */
+	public void validate() {
+		if (src == null || src.isBlank())
+			throw iaex("<@badge id='%s'> requires src=.", id);
+		if (refreshMs != null && refreshMs < MIN_REFRESH_MS)
+			throw iaex("'%s' refreshMs='%s' is below the minimum '%s'.", id, refreshMs, MIN_REFRESH_MS);
+		if (placement == Placement.TOOLBAR && (table == null || table.isBlank()))
+			throw iaex("<@badge id='%s'> placement='toolbar' requires table=.", id);
+		if (scopeBy == ScopeBy.VIEW && (table == null || table.isBlank()))
+			throw iaex("BadgeDef '%s' scope.by='view' requires table=.", id);
+	}
+
+	/**
+	 * Serializes this definition to the compact JSON the {@code header.badges[]} contract carries (§4.8), hand-built
+	 * (rather than reflected) so the key set and order match the schema exactly.
+	 *
+	 * @return An ordered {@link Map} ready for {@code Json.of(...)}.
+	 */
+	public Map<String,Object> toContractMap() {
+		var m = new LinkedHashMap<String,Object>();
+		m.put("id", id);
+		if (src != null)
+			m.put("src", src);
+		if (params != null && !params.isEmpty())
+			m.put("params", params);
+		if (refreshMs != null)
+			m.put("refreshMs", refreshMs);
+		if (scopeParam != null) {
+			var scope = new LinkedHashMap<String,Object>();
+			scope.put("param", scopeParam);
+			scope.put("by", (scopeBy == null ? ScopeBy.NAV : scopeBy).wire());
+			if (scopeValues != null && !scopeValues.isEmpty())
+				scope.put("values", scopeValues);
+			m.put("scope", scope);
+		}
+		if (label != null && !label.isEmpty())
+			m.put("label", label);
+		if (tooltipMax != null)
+			m.put("tooltipMax", tooltipMax);
+		if (href != null)
+			m.put("href", href);
+		if (stateFilterColumn != null)
+			m.put("stateFilter", Map.of("column", stateFilterColumn));
+		if (refreshes != null && !refreshes.isEmpty())
+			m.put("refreshes", refreshes);
+		if (placement != null)
+			m.put("placement", placement.wire());
+		if (table != null)
+			m.put("table", table);
+		if (tone != null)
+			m.put("tone", tone.wire());
+		if (visibleWhen != null && !visibleWhen.isEmpty())
+			m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
+		return m;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
index 4ebc63ad72..52dbd53858 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ConsoleOutputChecks.java
@@ -18,6 +18,8 @@ package org.apache.juneau.rest.server.views;
 
 import java.util.regex.*;
 
+import org.apache.juneau.rest.server.runreport.*;
+
 /**
  * Shared grammars for console-output data: colours, line hrefs, image sources, icon names, tokens.
  *
@@ -29,7 +31,6 @@ final class ConsoleOutputChecks {
 
 	static final Pattern HEX_COLOR = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
 	static final Pattern RGB_COLOR = Pattern.compile("^rgb\\(\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*,\\s*(\\d{1,3})\\s*\\)$");
-	static final Pattern FRAGMENT_HREF = Pattern.compile("^#[A-Za-z0-9._:~-]{0,128}$");
 	static final Pattern ICON_NAME = Pattern.compile("^[a-z][A-Za-z0-9.-]{0,63}$");
 	static final Pattern TOKEN = Pattern.compile("^[A-Za-z0-9._~-]{1,128}$");
 	static final Pattern LOG_ID = Pattern.compile("^[A-Za-z0-9_-]{1,128}$");
@@ -58,12 +59,7 @@ final class ConsoleOutputChecks {
 	 * @return <jk>true</jk> if the href is safe.
 	 */
 	static boolean isSafeLineHref(String s) {
-		if (s == null)
-			return false;
-		var t = stripTabCrLf(s);
-		if (t.startsWith("#"))
-			return FRAGMENT_HREF.matcher(t).matches();
-		return isSafePath(t);
+		return RunViewChecks.isSafeLineHref(s);
 	}
 
 	static boolean isSafeLineImageSrc(String s) {
@@ -91,9 +87,7 @@ final class ConsoleOutputChecks {
 
 	/** Truncates a value for an error message: 64 chars plus an ellipsis. */
 	static String clip(String s) {
-		if (s == null)
-			return "null";
-		return s.length() > 64 ? s.substring(0, 64) + "…" : s;
+		return RunViewChecks.clip(s);
 	}
 
 	private static String stripTabCrLf(String s) {
@@ -101,6 +95,6 @@ final class ConsoleOutputChecks {
 	}
 
 	private static boolean isSafePath(String t) {
-		return RegionDef.isSafeDetailEndpoint(t.replace('\\', '/'));
+		return RunViewChecks.isSameOriginPath(t.replace('\\', '/'));
 	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
index f76d0efb6d..785b58fae6 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FileRunViewSource.java
@@ -29,6 +29,7 @@ import java.util.regex.*;
 
 import org.apache.juneau.marshall.json.JsonSerializer;
 import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.server.runreport.*;
 
 /**
  * A {@link RunViewSource} over one growing JSON Lines file, one event per line, paged through a
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
index 2fe3f3c476..b53717fe53 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RegionDef.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import java.util.*;
 
 import org.apache.juneau.commons.http.*;
+import org.apache.juneau.rest.server.runreport.RunViewChecks;
 
 /**
  * A page "region": a named container whose contents a client-side populator fills in, chosen by name via
@@ -306,6 +307,23 @@ public class RegionDef {
 		return this;
 	}
 
+	/**
+	 * The rules that must all match for the whole region to render.  <jk>null</jk> or empty means always visible.
+	 * See {@link RowAction#visibleWhen} for the shared semantics and evaluation-map convention.
+	 */
+	public List<VisibilityRule> visibleWhen;
+
+	/**
+	 * Hides this region unless every rule matches.
+	 *
+	 * @param rules The rules; all must match.  Replaces any rules set by an earlier call.
+	 * @return This object.
+	 */
+	public RegionDef visibleWhen(VisibilityRule...rules) {
+		visibleWhen = l(rules);
+		return this;
+	}
+
 	/**
 	 * Sets the field catalog.
 	 *
@@ -393,21 +411,7 @@ public class RegionDef {
 	 * @return <jk>true</jk> if the string is a same-origin path template.
 	 */
 	public static boolean isSafeDetailEndpoint(String endpoint) {
-		if (endpoint == null || endpoint.isBlank())
-			return false;
-		if (endpoint.contains("://"))
-			return false;
-		if (endpoint.startsWith("//"))
-			return false;
-		var colon = endpoint.indexOf(':');
-		var slash = endpoint.indexOf('/');
-		if (colon >= 0 && (slash < 0 || colon < slash))
-			return false;
-		for (var seg : endpoint.split("/", -1)) {
-			if (eq(seg, ".."))
-				return false;
-		}
-		return true;
+		return RunViewChecks.isSameOriginPath(endpoint);
 	}
 
 	private void validateDataUrlAndParams() {
@@ -503,6 +507,8 @@ public class RegionDef {
 		if (fields != null && !fields.isEmpty()) {
 			m.put("fields", fields.stream().map(Field::toContractMap).toList());
 		}
+		if (visibleWhen != null && !visibleWhen.isEmpty())
+			m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
 		return m;
 	}
 
@@ -636,6 +642,23 @@ public class RegionDef {
 			return this;
 		}
 
+		/**
+		 * The rules that must all match for this one detail field to render.  <jk>null</jk> or empty means always
+		 * visible.  See {@link RowAction#visibleWhen} for the shared semantics.
+		 */
+		public List<VisibilityRule> visibleWhen;
+
+		/**
+		 * Hides this field unless every rule matches.
+		 *
+		 * @param rules The rules; all must match.  Replaces any rules set by an earlier call.
+		 * @return This object.
+		 */
+		public Field visibleWhen(VisibilityRule...rules) {
+			visibleWhen = l(rules);
+			return this;
+		}
+
 		/**
 		 * Fail-closed validation of this one entry: a non-blank {@link #data}.
 		 *
@@ -668,6 +691,8 @@ public class RegionDef {
 				m.put("span", "full");
 			if (actions != null && !actions.isEmpty())
 				m.put("actions", actions);
+			if (visibleWhen != null && !visibleWhen.isEmpty())
+				m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
 			return m;
 		}
 	}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
index 7941bc383c..aecd624ccf 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
@@ -59,6 +59,14 @@ import org.apache.juneau.rest.server.widgets.Op;
  * 		<td>What the runtime does with a successful result (see {@link OnSuccess}).</td></tr>
  * 	<tr><td>{@code enabledWhen}</td><td>{@code [{field,op,value?,reason}, ...]}</td>
  * 		<td>Row-state disable-with-reason rules (see {@link #enabledWhen(String,Op,Object,String)}).</td></tr>
+ * 	<tr><td>{@code visibleWhen}</td><td>{@code [{field,op,value?}, ...]}</td>
+ * 		<td>Row-state visibility rules (see {@link #visibleWhen(VisibilityRule...)}).</td></tr>
+ * 	<tr><td>{@code tone}</td><td>{@code default}|{@code danger}</td>
+ * 		<td>Styling of the confirmation (see {@link Tone}); {@code danger} focuses Cancel first.</td></tr>
+ * 	<tr><td>{@code confirmLabel}</td><td>string</td>
+ * 		<td>Text of the confirm button; defaults to the action's {@code label}.</td></tr>
+ * 	<tr><td>{@code confirmRenderer}</td><td>string</td>
+ * 		<td>Name of a confirm renderer registered with the client runtime; absent means the built-in modal.</td></tr>
  * </table>
  *
  * <p>
@@ -89,13 +97,14 @@ import org.apache.juneau.rest.server.widgets.Op;
  *
  * <h5 class='section'>Example:</h5>
  * <p class='bjava'>
- * 	RowAction <jv>ack</jv> = RowAction.<jsm>create</jsm>(<js>"ack"</js>)
- * 		.label(<js>"Acknowledge"</js>)
- * 		.endpoint(<js>"servlet:/incidents/{id}/ack"</js>)
- * 		.method(RowAction.Method.<jsf>POST</jsf>)
- * 		.confirm(<js>"Acknowledge this incident?"</js>)
- * 		.present(RowAction.Present.<jsf>DIALOG</jsf>)
- * 		.onSuccess(RowAction.OnSuccess.<jsf>MERGE_ROW</jsf>);
+ * 	RowAction <jv>delete</jv> = RowAction.<jsm>create</jsm>(<js>"delete"</js>)
+ * 		.label(<js>"Delete"</js>)
+ * 		.endpoint(<js>"servlet:/incidents/{id}"</js>)
+ * 		.method(RowAction.Method.<jsf>DELETE</jsf>)
+ * 		.confirm(<js>"Delete incident {id}?"</js>)
+ * 		.confirmLabel(<js>"Delete forever"</js>)
+ * 		.tone(RowAction.Tone.<jsf>DANGER</jsf>)
+ * 		.visibleWhen(VisibilityRule.<jsm>when</jsm>(<js>"locked"</js>).ne(<jk>true</jk>));
  * </p>
  *
  * <h5 class='section'>See Also:</h5>
@@ -105,7 +114,7 @@ import org.apache.juneau.rest.server.widgets.Op;
  *
  * @since 10.0.0
  */
-@BeanType(properties="id,label,icon,endpoint,method,confirm,confirmTitle,bulkMode,form,present,onSuccess,enabledWhen")
+@BeanType(properties="id,label,icon,endpoint,method,confirm,confirmTitle,bulkMode,form,present,onSuccess,enabledWhen,visibleWhen,tone,confirmLabel,confirmRenderer")
 @SuppressWarnings({
 	"java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
 })
@@ -144,6 +153,37 @@ public class RowAction {
 		}
 	}
 
+	/**
+	 * The visual tone of a row action's confirmation.
+	 *
+	 * <p>
+	 * Each constant carries the lowercase wire token emitted for the {@code tone} field.  {@link #DANGER} styles the
+	 * confirmation as destructive and focuses its Cancel button first.
+	 */
+	public enum Tone {
+
+		/** The ordinary confirmation styling. */
+		DEFAULT("default"),
+
+		/** A destructive confirmation: danger styling, Cancel focused first. */
+		DANGER("danger");
+
+		private final String wire;
+
+		Tone(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this tone.
+		 *
+		 * @return The wire token (e.g. {@code "danger"}).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
 	/**
 	 * How a row action's form/confirmation is presented.
 	 *
@@ -288,6 +328,28 @@ public class RowAction {
 	 */
 	public List<RowActionEnabledRule> enabledWhen;
 
+	/**
+	 * The rules that must all match for this action to render at all.  <jk>null</jk> or empty means always visible.
+	 *
+	 * <p>
+	 * Unlike {@link #enabledWhen}, which disables but never hides, a row whose data does not satisfy every rule
+	 * here has this action hidden entirely (re-evaluated on every draw).  Prefer {@code enabledWhen}, which tells
+	 * the user why an action is unavailable, and hide only when the action is irrelevant to the row.  Still
+	 * presentation only &mdash; the endpoint must authorize independently.  The evaluation map is the row, plus the
+	 * page's {@code facts} under a {@code facts.} prefix, so a rule can test either {@code "status"} or
+	 * {@code "facts.viewer.roles"}.
+	 */
+	public List<VisibilityRule> visibleWhen;
+
+	/** Optional confirmation tone wire token (see {@link Tone#wire()}). */
+	public String tone;
+
+	/** Optional text of the confirm button; <jk>null</jk> means the action's {@link #label}. */
+	public String confirmLabel;
+
+	/** Optional name of a confirm renderer registered with the client runtime; <jk>null</jk> means the built-in modal. */
+	public String confirmRenderer;
+
 	/**
 	 * Starts a new {@link RowAction} with the specified stable action id.
 	 *
@@ -532,4 +594,52 @@ public class RowAction {
 		enabledWhen.add(rule);
 		return this;
 	}
+
+	/**
+	 * Hides this action, per row, unless every rule matches (see {@link #visibleWhen} for the semantics).
+	 *
+	 * @param rules The rules; all must match.  Replaces any rules set by an earlier call.
+	 * @return This object.
+	 */
+	public RowAction visibleWhen(VisibilityRule...rules) {
+		visibleWhen = l(rules);
+		return this;
+	}
+
+	/**
+	 * Sets the confirmation tone.
+	 *
+	 * @param value The tone.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public RowAction tone(Tone value) {
+		tone = value.wire();
+		return this;
+	}
+
+	/**
+	 * Sets the text of the confirm button.
+	 *
+	 * @param value The label.  Can be <jk>null</jk> to fall back to {@link #label}.
+	 * @return This object.
+	 */
+	public RowAction confirmLabel(String value) {
+		confirmLabel = value;
+		return this;
+	}
+
+	/**
+	 * Sets the name of the confirm renderer to use for this action's confirmation.
+	 *
+	 * <p>
+	 * The name must be registered on the client with {@code JuneauViews.rowActions.registerConfirmRenderer}; an
+	 * unregistered name refuses the confirmation.
+	 *
+	 * @param value The renderer name.  Can be <jk>null</jk> for the built-in modal.
+	 * @return This object.
+	 */
+	public RowAction confirmRenderer(String value) {
+		confirmRenderer = value;
+		return this;
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
index 1bd34ab74d..5c11f6a3d6 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowActionEnabledRule.java
@@ -81,6 +81,8 @@ public class RowActionEnabledRule {
 	public static RowActionEnabledRule of(String field, Op op, Object value, String reason) {
 		if (field == null || field.isBlank())
 			throw iaex("RowActionEnabledRule field must not be null or blank.");
+		if (op != null && op.isCollectionOp())
+			throw iaex("RowActionEnabledRule op '%s' is a visibility-rule operator; use eq or ne.", op.wire());
 		if (op == null || ! op.requiresValue())
 			throw iaex("RowActionEnabledRule op '%s' does not take a value; use the (field, op, reason) form.",
 				op == null ? "null" : op.wire());
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
index 0c51526189..cac29a20f2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewDef.java
@@ -20,6 +20,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import static org.apache.juneau.rest.server.views.ConsoleOutputChecks.clip;
 
 import java.util.*;
+import org.apache.juneau.rest.server.runreport.*;
 
 /**
  * The author-facing declaration of a run-view region, compiled to a {@link RegionDef}.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
index a818aab792..588541c7ff 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewEndpoints.java
@@ -24,6 +24,7 @@ import org.apache.juneau.http.response.Gone;
 import org.apache.juneau.http.response.NotFound;
 import org.apache.juneau.marshall.json.*;
 import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
 
 /**
  * Static helpers that implement the run-view events endpoint.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
index 7e4551a52a..60755cce22 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewLog.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.regex.*;
+import org.apache.juneau.rest.server.runreport.*;
 
 /**
  * A thread-safe, in-memory {@link RunViewSource} that a producer appends to directly.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
index 3c3af2a5a5..77e6a39e17 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RunViewPage.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
 import java.util.*;
 
 import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.rest.server.runreport.*;
 
 /**
  * One page of a run-view event stream: the envelope the events endpoint returns and the client polls.
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index 7ba2f03006..8189ca8b19 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -350,26 +350,9 @@ public class ViewsMixin {
 	 * catalogs emit this on the lifted {@code view} object.
 	 *
 	 * <p>
-	 * Templates should normally <b>omit</b> {@code view.contractVersion}: when a pre-built envelope leaves it out, the
-	 * framework injects this value, so a contract bump never touches adopter templates. A template that pins a
-	 * different value logs a {@code WARNING} naming the template, card and both versions, and fails the render in dev
-	 * mode and tests (and is flagged statically by {@code ConsoleTemplateValidator}'s {@code view-contract-version}
-	 * rule). Pin a version only to deliberately test the mismatch path.
-	 *
-	 * <h5 class='section'>Example:</h5>
-	 * <p class='bcode'>
-	 * 	&lt;@card type="datatables" id="orders"&gt;
-	 * 	{
-	 * 	    layout: 'wide',
-	 * 	    view: {
-	 * 	        <jc>// No contractVersion here: the runtime's "{@value #CONTRACT_VERSION}" is injected.</jc>
-	 * 	        id: 'orders',
-	 * 	        dataUrl: '/orders/data',
-	 * 	        columns: [ { data: 'name', title: 'Name' } ]
-	 * 	    }
-	 * 	}
-	 * 	&lt;/@card&gt;
-	 * </p>
+	 * Templates never author {@code view.contractVersion}: the framework stamps this value when it lifts the catalog,
+	 * so a contract bump never touches adopter templates. A pre-built envelope body is rejected at render time, and
+	 * {@code ConsoleTemplateValidator}'s {@code view-contract-version} rule flags a literal pin statically.
 	 */
 	public static final String CONTRACT_VERSION = "5";
 
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java
new file mode 100644
index 0000000000..f7f3d201b6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/VisibilityRule.java
@@ -0,0 +1,207 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.rest.server.widgets.Op;
+
+/**
+ * A presentation-only visibility rule: the element is shown only when every rule in its list matches.
+ *
+ * <p>
+ * Rules hide UI; they never authorize.  The endpoint behind a hidden action must still check the caller.
+ * Use {@code visible(false)} instead when the element must not reach the client at all.
+ *
+ * <p>
+ * The evaluation map depends on the element's level.  A row action, detail field or region sees the row plus the
+ * page's {@code facts} under a {@code facts.} prefix; a card, badge or ribbon item sees the page's top-level
+ * {@code facts} alone.  A field absent from the map fails closed for every operator except {@code absent}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	RowAction.<jsm>create</jsm>(<js>"escalate"</js>)
+ * 		.visibleWhen(
+ * 			VisibilityRule.<jsm>when</jsm>(<js>"status"</js>).in(<js>"open"</js>, <js>"acked"</js>),
+ * 			VisibilityRule.<jsm>when</jsm>(<js>"facts.viewer.roles"</js>).contains(<js>"oncall"</js>));
+ * </p>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="field,op,value")
+public final class VisibilityRule {
+
+	private static final Object MISSING = new Object();
+	private static final Set<String> KNOWN_OPS = Set.of("eq", "ne", "present", "absent", "in", "contains");
+
+	/** The field this rule tests, a dotted path into the evaluation map (e.g. {@code "facts.viewer.roles"}). */
+	public String field;
+
+	/** The wire token of the comparison operator (see {@link Op#wire()}). */
+	public String op;
+
+	/** The comparison value for {@code eq}/{@code ne}/{@code in}/{@code contains}; omitted for {@code present}/{@code absent}. */
+	public Object value;
+
+	private VisibilityRule() {}
+
+	/**
+	 * Starts a rule for the given field.
+	 *
+	 * @param field The dotted-path field to test.  Must not be <jk>null</jk> or blank.
+	 * @return A {@link Builder} to pick the operator.
+	 * @throws IllegalArgumentException If {@code field} is <jk>null</jk> or blank.
+	 */
+	public static Builder when(String field) {
+		if (field == null || field.isBlank())
+			throw iaex("VisibilityRule field must not be null or blank.");
+		return new Builder(field);
+	}
+
+	/**
+	 * Evaluates a list of rules against an evaluation map; every rule must match (AND).
+	 *
+	 * <p>
+	 * A <jk>null</jk> or empty list always matches.  A field absent from {@code map} never satisfies
+	 * {@code eq}/{@code ne}/{@code in}/{@code contains}/{@code present}; only {@code absent} treats a missing field
+	 * as a match.  Mirrors the JS {@code JuneauViews.rules.test} evaluator exactly.
+	 *
+	 * @param rules The rules to test, or <jk>null</jk>.
+	 * @param map The evaluation map.
+	 * @return <jk>true</jk> when every rule matches.
+	 * @throws IllegalArgumentException If a rule's {@code op} is not one of the known tokens.
+	 */
+	public static boolean test(List<VisibilityRule> rules, Map<String,Object> map) {
+		if (rules == null || rules.isEmpty())
+			return true;
+		for (var r : rules)
+			if (! r.matches(map))
+				return false;
+		return true;
+	}
+
+	/**
+	 * Serializes this rule to its wire shape.
+	 *
+	 * @return An ordered {@link Map} of {@code field}, {@code op} and (when set) {@code value}.
+	 */
+	public Map<String,Object> toMap() {
+		var m = new LinkedHashMap<String,Object>();
+		m.put("field", field);
+		m.put("op", op);
+		if (value != null)
+			m.put("value", value);
+		return m;
+	}
+
+	static List<Map<String,Object>> toMaps(List<VisibilityRule> rules) {
+		return rules.stream().map(VisibilityRule::toMap).toList();
+	}
+
+	private boolean matches(Map<String,Object> map) {
+		if (! KNOWN_OPS.contains(op))
+			throw iaex("VisibilityRule op '%s' is not one of '%s'.", op, String.join(", ", new TreeSet<>(KNOWN_OPS)));
+		var actual = resolve(map, field);
+		var missing = actual == MISSING;
+		return switch (op) {
+			case "eq" -> ! missing && Objects.equals(actual, value);
+			case "ne" -> ! missing && ! Objects.equals(actual, value);
+			case "present" -> ! missing && actual != null && ! (actual instanceof String s && s.isBlank());
+			case "absent" -> missing || actual == null || (actual instanceof String s && s.isBlank());
+			case "in" -> ! missing && value instanceof Collection<?> c && c.contains(actual);
+			default -> ! missing && actual instanceof Collection<?> c && c.contains(value);
+		};
+	}
+
+	private static Object resolve(Map<?,?> map, String dottedPath) {
+		Object cur = map;
+		for (var part : dottedPath.split("\\."))
+			if (cur instanceof Map<?,?> m && m.containsKey(part))
+				cur = m.get(part);
+			else
+				return MISSING;
+		return cur;
+	}
+
+	/** Fluent operator picker returned by {@link VisibilityRule#when(String)}. */
+	public static final class Builder {
+		private final String field;
+
+		private Builder(String field) {
+			this.field = field;
+		}
+
+		/**
+		 * Matches when the field equals the value.
+		 *
+		 * @param value The comparison value.  Must not be <jk>null</jk>.
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule eq(Object value) { return of(Op.EQ, value); }
+
+		/**
+		 * Matches when the field is present and does not equal the value.
+		 *
+		 * @param value The comparison value.  Must not be <jk>null</jk>.
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule ne(Object value) { return of(Op.NE, value); }
+
+		/**
+		 * Matches when the field is non-null and non-blank.
+		 *
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule present() { return of(Op.PRESENT, null); }
+
+		/**
+		 * Matches when the field is missing, null or blank.
+		 *
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule absent() { return of(Op.ABSENT, null); }
+
+		/**
+		 * Matches when the field equals one of the values.
+		 *
+		 * @param values The candidate values.
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule in(Object...values) { return of(Op.IN, List.of(values)); }
+
+		/**
+		 * Matches when the field (itself a list) contains the value.
+		 *
+		 * @param value The value the list must contain.  Must not be <jk>null</jk>.
+		 * @return A new {@link VisibilityRule}.
+		 */
+		public VisibilityRule contains(Object value) { return of(Op.CONTAINS, value); }
+
+		private VisibilityRule of(Op op, Object value) {
+			if (op.requiresValue() && value == null)
+				throw iaex("VisibilityRule op '%s' requires a non-null value.", op.wire());
+			var r = new VisibilityRule();
+			r.field = field;
+			r.op = op.wire();
+			r.value = value;
+			return r;
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java
deleted file mode 100644
index 7156ca70f3..0000000000
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/report/package-info.java
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * Readers that turn test-report files into run-view events.
- *
- * <p>
- * Each {@link org.apache.juneau.rest.server.views.report.ReportReader} reads one report format (Surefire and JUnit
- * XML, Jest JSON, Playwright JSON) into a {@link org.apache.juneau.rest.server.views.report.ReportResult}, whose
- * {@code toEvents(step)} yields the {@code replace}, {@code test} and {@code note} events a producer appends to a
- * run view when a step ends.  A reader never throws for bad input: a missing, oversize, malformed or truncated file
- * yields the tests that could be recovered plus a warning.
- *
- * <p>
- * Use {@link org.apache.juneau.rest.server.views.report.ReportReaders#forKind(java.lang.String)} to pick a reader by
- * its report kind.
- */
-package org.apache.juneau.rest.server.views.report;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
index afcbc337b2..06bf2a9e69 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-helpers.js
@@ -802,6 +802,8 @@
 
 		for (const fieldsEntry of fields) {
 			const field = fieldsEntry;
+			// visibleWhen: tested against the values map plus the page facts under `facts`; presentation only.
+			if (field.visibleWhen && typeof NS.rules?.testRow === "function" && !NS.rules.testRow(field.visibleWhen, values)) continue;
 
 			const item = document.createElement("div");
 			item.className = "juneau-view-detail-field" + (field.span ? " juneau-view-detail-field-span-" + field.span : "");
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
index 6b3e5b278e..7035bac40b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-regions.js
@@ -1232,15 +1232,18 @@
 				+ REGION_CONTRACT_VERSION + "'); it is read as identity-only.");
 			return empty;
 		}
+		const declared = {
+			dataUrl: body.dataUrl != null ? String(body.dataUrl) : null,
+			renderer: body.renderer != null ? String(body.renderer) : null,
+			lazy: typeof body.lazy === "boolean" ? body.lazy : defaultLazyFor(type),
+			refreshMs: typeof body.refreshMs === "number" ? clampRefreshMs(body.refreshMs) : null,
+			fields: Array.isArray(body.fields) ? body.fields : null,
+			titleFields: Array.isArray(body.titleFields) ? body.titleFields : null
+		};
+		// Present only when the region declares one, so a region without it keeps its exact prior shape.
+		if (Array.isArray(body.visibleWhen) && body.visibleWhen.length) declared.visibleWhen = body.visibleWhen;
 		return {
-			declared: {
-				dataUrl: body.dataUrl != null ? String(body.dataUrl) : null,
-				renderer: body.renderer != null ? String(body.renderer) : null,
-				lazy: typeof body.lazy === "boolean" ? body.lazy : defaultLazyFor(type),
-				refreshMs: typeof body.refreshMs === "number" ? clampRefreshMs(body.refreshMs) : null,
-				fields: Array.isArray(body.fields) ? body.fields : null,
-				titleFields: Array.isArray(body.titleFields) ? body.titleFields : null
-			},
+			declared: declared,
 			params: (body.params && typeof body.params === "object" && !Array.isArray(body.params)) ? body.params : {}
 		};
 	}
@@ -1366,6 +1369,10 @@
 		liveRegions.push(region);
 		el.setAttribute(REGION_STATE_ATTR, "idle");
 		armBarrier();
+		if (hiddenByVisibleWhen(region)) {
+			el.hidden = true;
+			el.setAttribute("data-juneau-region-hidden", "rule");
+		}
 		if (isRegionHidden(el)) {
 			region.deferred = true;
 			return region;
@@ -1375,6 +1382,18 @@
 		return region;
 	}
 
+	/**
+	 * Whether the region's {@code visibleWhen} rules say it should not show.  The evaluation map is the owning row
+	 * (when the region sits in a row-detail panel) plus the page facts under {@code facts}.  A hidden region takes the
+	 * existing hidden path: it is enrolled but deferred, and never populates.  Presentation only.
+	 */
+	function hiddenByVisibleWhen(region) {
+		const rules = region.declared.visibleWhen;
+		if (!rules || typeof NS.rules?.testRow !== "function") return false;
+		const row = NS.rowActions?.contextOf?.(region.el)?.row;
+		return !NS.rules.testRow(rules, row);
+	}
+
 	/**
 	 * The baseline refusal.  A blank container with no error is the worst failure this runtime has, and a silently
 	 * signal-less region is a subtler version of it - so every region gets the error state, and the PAGE gets exactly
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
index feb9749de4..0608270451 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
@@ -453,6 +453,24 @@
 		"class": function () { return "pill-cell juneau-cell-wrap"; }
 	});
 
+	// `edit:<actionId>`: the cell shows the value and a pencil button.  juneau-views.js owns the behavior: it gates the
+	// pencil on the action's visibleWhen/enabledWhen and, on click, swaps the cell to an input that commits through the
+	// action.  meta: `action` (or the `:<actionId>` sugar), `input` (`text` | `number` | `select`), `options` for select.
+	registerRenderer("edit", {
+		display: function (cellData, rowData, meta) {
+			const action = meta?.action ?? meta?.field;
+			const value = cellData == null ? "" : String(cellData);
+			if (!action) return escHtml(value);
+			const input = meta?.input === "number" || meta?.input === "select" ? meta.input : "text";
+			const options = meta?.options == null ? "" : (Array.isArray(meta.options) ? JSON.stringify(meta.options) : String(meta.options));
+			return '<span class="juneau-edit-cell"><span class="juneau-edit-value">' + escHtml(value) + "</span>"
+				+ '<button type="button" class="juneau-edit-pencil" aria-label="Edit" data-juneau-edit="' + escAttr(action) + '"'
+				+ ' data-juneau-edit-col="' + escAttr(meta?.column ?? "") + '" data-juneau-edit-input="' + input + '"'
+				+ (options ? ' data-juneau-edit-options="' + escAttr(options) + '"' : "") + "></button></span>";
+		},
+		"class": function () { return "juneau-cell-wrap"; }
+	});
+
 	// The fill-sink variant of `pill`, registered straight into the frozen set rather than snapshotted from the
 	// registry above: a sink pill is unconditionally DISPLAY-ONLY.  A fill sink has no rowActions in scope, so it can
 	// never emit `role="button"`/`tabindex`/`data-juneau-action` even if an author smuggles a `meta.action` past the
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
index 6e4ce93a0d..0ac82d5bb2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
@@ -19,6 +19,7 @@
  * juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit.
  *
  * Builds the toolbar from viewDef.ribbon: export (feature-detected copy/csv/print via DataTables Buttons, with
+ * a hard `buttons` entry whose JSZip/pdfMake is missing rendered disabled and logged as E-JS-69, never dropped, and
  * excel/pdf lit up only when JSZip/pdfMake are present), refresh, pausePolling, collapseAll,
  * dialog (a row-less, ribbon-hosted dialog, opened through juneau-views.js's ribbon-catalog resolver),
  * option/optionGroup server-query toggles (with persisted state), and divider.
@@ -50,6 +51,13 @@
 
 	const NS = window.JuneauViews = window.JuneauViews || {};
 
+	/** One-line `%s`-replacer for this file's own error strings (each script keeps its own private copy). */
+	function fmt(msg) {
+		const args = Array.prototype.slice.call(arguments, 1);
+		let i = 0;
+		return String(msg).replace(/%s/g, function () { return i < args.length ? String(args[i++]) : "%s"; });
+	}
+
 	// ==================================================================================================================
 	// PURE LOGIC LAYER  (no DOM, no jQuery, no DataTables)
 	// ==================================================================================================================
@@ -263,20 +271,39 @@
 	}
 
 	/**
-	 * Given an `export` action and detected features, returns the button ids that should actually be offered.  With no
-	 * Buttons extension the result is empty (graceful degrade); optional excel/pdf are included only when their extra
-	 * dep is present, otherwise omitted.
+	 * Button id -> `features` key of the extra dependency it needs, for ids a caller may put in the HARD `buttons` list.
+	 * `copy`/`csv`/`print`/`collapse` need nothing beyond the Buttons extension itself, so they have no entry here.
+	 */
+	const EXPORT_BUTTON_DEPS = { excel: "jszip", pdf: "pdfmake" };
+
+	/** Message (and E-JS-69 log text) for a hard export button whose extra library is absent. */
+	const EXPORT_MISSING_MSG = "ribbon export '%s' needs '%s'; load it before juneau-views.js or list the button in optional.";
+
+	/** Human-readable library name for a `features` key, for the disabled-button tip and the E-JS-69 log. */
+	const EXPORT_LIB_DISPLAY_NAME = { jszip: "JSZip", pdfmake: "pdfMake" };
+
+	/**
+	 * Given an `export` action and detected features, returns `{ok, missing}`.  `ok` is the button ids to render and
+	 * register with DataTables Buttons.  `missing` is `{id, needs}` for every id in the HARD `buttons` list whose
+	 * required extra dependency is absent: those are not silently dropped, the caller renders them disabled instead.
+	 * An id in the opt-in `optional` list whose dependency is absent is simply omitted (that list exists to opt out of
+	 * a hard requirement).  With no Buttons extension at all both arrays are empty (graceful degrade).
 	 */
 	function resolveExportButtons(action, features) {
-		const out = [];
-		if (!features?.buttons) return out;   // degrade gracefully - no export cluster at all
-		(action.buttons || []).forEach(function (b) { out.push(b); });
-		(action.optional || []).forEach(function (b) {
+		const ok = [];
+		const missing = [];
+		if (!features?.buttons) return { ok: ok, missing: missing };   // degrade gracefully - no export cluster at all
+		for (const b of (action.buttons || [])) {
+			const needs = EXPORT_BUTTON_DEPS[b];
+			if (needs && !features[needs]) missing.push({ id: b, needs: needs });
+			else ok.push(b);
+		}
+		for (const b of (action.optional || [])) {
 			// Both branches merely gate on their own extra dep being present - combined rather than duplicated.
-			if ((b === "excel" && features.jszip) || (b === "pdf" && features.pdfmake)) out.push(b);
-			// else: dep absent - omit/grey (feature-detected)
-		});
-		return out;
+			if ((b === "excel" && features.jszip) || (b === "pdf" && features.pdfmake)) ok.push(b);
+			// else: dep absent - omitted, not "missing" (opt-in degrade)
+		}
+		return { ok: ok, missing: missing };
 	}
 
 	/** localStorage key for a persisted ribbon toggle (VIEW_META §6.8: juneau.view.<viewId>.ribbon.<optionId>). */
@@ -452,8 +479,19 @@
 	// few lines and several are pinned verbatim by the wiring canary tests below `functionBody(body, "function
 	// buildRibbon(")`, so splitting them into further helpers would reduce test/code locality without reducing
 	// real complexity.
+	/**
+	 * Whether a ribbon item is shown: one with no {@code visibleWhen} always is.  A rule list is tested against the
+	 * page's top-level facts alone (a ribbon item has no row).  Presentation only; a page that somehow loaded this
+	 * runtime without the rules runtime shows the item rather than hiding it.
+	 */
+	function ribbonItemVisible(a) {
+		if (a?.visibleWhen == null) return true;
+		const rules = NS.rules;
+		return typeof rules?.test !== "function" || rules.test(a.visibleWhen, rules.facts());
+	}
+
 	function buildRibbon(viewDef, ctx) {
-		const actions = normalizeRibbon(viewDef.ribbon || []);
+		const actions = normalizeRibbon(viewDef.ribbon || []).filter(ribbonItemVisible);
 		if (!actions.length) return null;
 
 		const $ = window.jQuery;
@@ -487,7 +525,17 @@
 				return;
 			}
 			if (a.type === "export") {
-				const ids = resolveExportButtons(a, features);
+				const resolved = resolveExportButtons(a, features);
+				const ids = resolved.ok;
+				const exportGroupId = a.group ?? "__ungrouped";
+				// A button in the HARD `buttons` list whose library is absent renders DISABLED with a tip naming the
+				// library; the first miss on this ctx is logged once as E-JS-69.
+				if (resolved.missing.length && !ctx.__exportLibWarned) {
+					ctx.__exportLibWarned = true;
+					const first = resolved.missing[0];
+					console.error("[juneau-ribbon] E-JS-69: " + fmt(EXPORT_MISSING_MSG, first.id, EXPORT_LIB_DISPLAY_NAME[first.needs] || first.needs));
+				}
+				let registered = false;
 				if (ids.length && ctx.dataTable && $?.fn?.dataTable?.Buttons) {
 					try {
 						// Still registers/feature-gates each button with DataTables Buttons - but renders our own
@@ -497,14 +545,26 @@
 							buttons: ids,
 							exportOptions: { columns: ":visible" }
 						});
-						const exportGroupId = a.group ?? "__ungrouped";
-						for (const id of ids) {
-							place(button(id, resolveButtonIcon(null, id), function () {
-								ctx.dataTable.button(id).trigger();
-							}, a.appearance), exportGroupId);
-						}
+						registered = true;
 					} catch (e) { /* Buttons present but init failed - degrade silently */ } // NOSONAR javascript:S2486 -- documented best-effort degrade
 				}
+				// Declared order is kept: a missing-library button sits where it was listed, disabled.
+				const declared = (a.buttons || []).concat(a.optional || []);
+				const rendered = (registered ? ids : []).concat(resolved.missing.map(function (m) { return m.id; }));
+				rendered.sort(function (x, y) { return declared.indexOf(x) - declared.indexOf(y); });
+				for (const id of rendered) {
+					const miss = resolved.missing.find(function (m) { return m.id === id; });
+					if (miss) {
+						const dead = button(id, resolveButtonIcon(null, id), function () { /* disabled: nothing to run */ }, a.appearance);
+						dead.disabled = true;
+						stampChromeTip(dead, fmt(EXPORT_MISSING_MSG, id, EXPORT_LIB_DISPLAY_NAME[miss.needs] || miss.needs));
+						place(dead, exportGroupId);
+					} else {
+						place(button(id, resolveButtonIcon(null, id), function () {
+							ctx.dataTable.button(id).trigger();
+						}, a.appearance), exportGroupId);
+					}
+				}
 				return;
 			}
 			if (a.type === "refresh") {
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
index 81799d85e1..6a5ed1e680 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-run-view.js
@@ -34,6 +34,8 @@
 	const TEST_STATUSES = ["pass", "fail", "skip", "error"], LEVELS = ["info", "warn", "error"], DONE_STATUSES = ["ok", "fail", "cancelled"];
 	const KINDS = ["step", "end", "suite", "test", "replace", "note", "done"];
 	const FRAGMENT_HREF = /^#[A-Za-z0-9._:~-]{0,128}$/;
+	// A re-run of a step is "<id>.2", "<id>.3", ... titled "<title> (attempt 2)", ...; the first attempt keeps the plain id.
+	const ATTEMPT_ID = /^(.+)\.([2-9]|[1-9][0-9]+)$/, ATTEMPT_TITLE = /^(.*) \(attempt ([2-9]|[1-9][0-9]+)\)$/;
 
 	// @section:checks
 
@@ -387,6 +389,38 @@
 		return out;
 	}
 
+	/**
+	 * Groups the re-runs of a step.  An attempt is a step whose id is "<base>.<n>" (n >= 2) for an existing step "<base>",
+	 * or, failing that, whose title is "<title> (attempt <n>)" for an existing step titled "<title>".  Returns an array of
+	 * {base, latest, earlier} (stream order, the last attempt being the latest) for every step that has at least one
+	 * earlier attempt.
+	 */
+	function attemptGroups(model) {
+		const byBase = new Map();
+		const byTitle = new Map();
+		for (const st of model.steps)
+			if (!byTitle.has(st.title)) byTitle.set(st.title, st);
+		for (const st of model.steps) {
+			let base = null;
+			const mi = ATTEMPT_ID.exec(st.id);
+			if (mi && model.stepIndex.has(mi[1])) {
+				base = mi[1];
+			} else {
+				const mt = ATTEMPT_TITLE.exec(st.title);
+				const first = mt ? byTitle.get(mt[1]) : null;
+				if (first && first !== st) base = first.id;
+			}
+			if (base !== null) {
+				let g = byBase.get(base);
+				if (!g) { g = [model.stepIndex.get(base)]; byBase.set(base, g); }
+				g.push(st);
+			}
+		}
+		const out = [];
+		byBase.forEach(function (g, base) { out.push({ base: base, latest: g[g.length - 1], earlier: g.slice(0, g.length - 1) }); });
+		return out;
+	}
+
 	/**
 	 * The derived view of a model: run status and headline, each step's shown state (open steps close to fail/skip once a
 	 * done exists), exact per-framework counts and the failures list (step order, then suite order, then arrival order).
@@ -433,7 +467,8 @@
 			}
 		}
 		const counts = model.fwOrder.filter(function (fw) { return byFw.has(fw); }).map(function (fw) { return byFw.get(fw); });
-		return { status: status, headline: headline, glyph: glyph, steps: shown, counts: counts, failures: failures };
+		return { status: status, headline: headline, glyph: glyph, steps: shown, counts: counts, failures: failures,
+			attempts: attemptGroups(model) };
 	}
 
 	// @section:render
@@ -729,6 +764,63 @@
 		st.dirty = false;
 	}
 
+	/**
+	 * Nests the earlier attempts of a re-run step under its latest attempt, behind an "N earlier attempts" toggle that is
+	 * collapsed by default.  A run with no re-run steps is untouched.
+	 */
+	function renderAttempts(inst, d) {
+		const latestIds = new Set();
+		inst.attemptOf = new Map();
+		for (const g of d.attempts) {
+			const rec = inst.recs.get(g.latest.id);
+			if (!rec) continue;
+			latestIds.add(g.latest.id);
+			const open = inst.attemptsOpen.has(g.base);
+			if (!rec.attemptsBtn) {
+				const btn = mk("button", "juneau-rv-attempts-toggle");
+				attr(btn, "type", "button");
+				attr(btn, "data-juneau-rv-act", "toggle-attempts");
+				rec.attemptsChev = mk("span", "juneau-rv-attempts-chevron");
+				rec.attemptsLabel = mk("span", "juneau-rv-attempts-label");
+				attr(rec.attemptsChev, "aria-hidden", "true");
+				btn.appendChild(rec.attemptsChev);
+				btn.appendChild(rec.attemptsLabel);
+				rec.attemptsBtn = btn;
+				rec.attemptsEl = mk("ul", "juneau-rv-attempts");
+				rec.li.insertBefore(btn, rec.suitesEl);
+				rec.li.insertBefore(rec.attemptsEl, rec.suitesEl);
+			}
+			const n = g.earlier.length;
+			attr(rec.attemptsBtn, "data-juneau-rv-key", g.base);
+			attr(rec.attemptsBtn, "aria-expanded", open ? "true" : "false");
+			rec.attemptsChev.textContent = open ? "â–¾" : "â–¸";
+			rec.attemptsLabel.textContent = n + (n === 1 ? " earlier attempt" : " earlier attempts");
+			rec.attemptsEl.hidden = !open;
+			g.earlier.forEach(function (st, i) {
+				inst.attemptOf.set(st.id, g.base);
+				const r = inst.recs.get(st.id);
+				if (!r) return;
+				attr(r.li, "data-juneau-rv-attempt", "earlier");
+				if (r.attemptsBtn) {
+					r.attemptsBtn.remove();
+					r.attemptsEl.remove();
+					r.attemptsBtn = null;
+					r.attemptsEl = null;
+				}
+				if (rec.attemptsEl.childNodes[i] !== r.li)
+					rec.attemptsEl.appendChild(r.li);
+			});
+		}
+		inst.recs.forEach(function (rec, id) {
+			if (rec.attemptsBtn && !latestIds.has(id)) {
+				rec.attemptsBtn.remove();
+				rec.attemptsEl.remove();
+				rec.attemptsBtn = null;
+				rec.attemptsEl = null;
+			}
+		});
+	}
+
 	function failureText(f) {
 		if (f.test === null)
 			return f.suite.suite + ": " + f.suite.fail + " failed";
@@ -793,6 +885,7 @@
 		for (const x of d.steps)
 			if (x.step.dirty || closing || !inst.recs.has(x.step.id))
 				renderStep(inst, x.step, x.shown);
+		renderAttempts(inst, d);
 		if (m.runDirty) {
 			renderNotes(inst.runNotesEl, m.runNotes);
 			m.runDirty = false;
@@ -948,6 +1041,8 @@
 
 	function revealFailure(inst, info, withTrace) {
 		const ek = ekeyOf(info.step, info.suite);
+		const base = inst.attemptOf.get(info.step.id);
+		if (base !== undefined) inst.attemptsOpen.add(base);
 		inst.expanded.set(ek, true);
 		if (withTrace && info.test) inst.trace.set(ek, info.test);
 		info.step.dirty = true;
@@ -978,6 +1073,11 @@
 				return;
 			}
 			const key = act.getAttribute("data-juneau-rv-key");
+			if (a === "toggle-attempts") {
+				if (inst.attemptsOpen.has(key)) inst.attemptsOpen.delete(key); else inst.attemptsOpen.add(key);
+				renderNow(inst);
+				return;
+			}
 			const stepId = a === "toggle-clean" ? key : key.slice(0, key.indexOf("/"));
 			const st = inst.model.stepIndex.get(stepId);
 			const ek = a === "toggle-clean" ? key + "/*clean" : key;
@@ -1088,6 +1188,8 @@
 		inst.recs = new Map();
 		inst.lastDone = null;
 		inst.trace = new Map();
+		inst.attemptsOpen = new Set();
+		inst.attemptOf = new Map();
 		inst.stepsEl.replaceChildren();
 		inst.runNotesEl.replaceChildren();
 		hideTip(inst);
@@ -1122,7 +1224,7 @@
 		const o = checkOptions(el, options);
 		const inst = {
 			id: o.id, host: el, compact: o.compact, rawHref: o.rawHref, emit: o.emit, model: newModel(), recs: new Map(),
-			expanded: new Map(), trace: new Map(), warned: new Set(), listeners: [], timer: null, renders: 0, destroyed: false,
+			expanded: new Map(), trace: new Map(), attemptsOpen: new Set(), attemptOf: new Map(), warned: new Set(), listeners: [], timer: null, renders: 0, destroyed: false,
 			lastDone: null, lastStatus: "empty", errored: false, tipFor: null, stopPoll: null, api: null, banner: null, connEl: null
 		};
 		const root = mk("section", "juneau-rv" + (o.compact ? " juneau-rv-compact" : ""));
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
index 8493658892..5998cf574a 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-urlstate.js
@@ -19,7 +19,7 @@
  * juneau-urlstate.js - the shareable "Copy link" URL-state codec (design section 6.3).
  *
  * A single, dependency-free codec for the ONE opaque query parameter named `state`, whose value uses the Juneau
- * directives tab(...), filter(...), and sort(...) and NOTHING else.  It attaches to window.JuneauViews.urlState
+ * directives tab(...), filter(...), and sort(...) plus any directive a page registers via registerDirective.  It attaches to window.JuneauViews.urlState
  * (mirroring the other dependency-free pre-views globals: the icon registry, the column-search engine, and the
  * page-state store), so it loads as a plain <script> BEFORE juneau-views.js and juneau-config.js, which consume it.
  *
@@ -46,6 +46,135 @@
 
 	function isBlank(v) { return v == null || String(v) === ''; }
 
+	// -------------------------------------------------------------------------------------------------------------
+	// Directive registry: tab/filter/sort are built in; a page may add more (the built-ins `window` and `ids` below).
+	// -------------------------------------------------------------------------------------------------------------
+
+	const RESERVED_DIRECTIVES = { tab: true, filter: true, sort: true };
+	const DIRECTIVE_NAME_RE = /^[a-z][a-zA-Z0-9]{0,31}$/;
+	const registry = Object.create(null);
+	const directiveOrder = []; // registration order, so encodeState appends ext directives deterministically
+
+	/**
+	 * Registers a `?state=` directive beyond the built-in tab/filter/sort.
+	 *
+	 * <p>
+	 * `codec` is `{ parse(body) -> value, serialize(value) -> body, isEmpty?(value), toParams?(value) -> object,
+	 * apply?(value, table) }`.  `parse` and `serialize` must be inverses; build bodies with {@link encodeArg} so a value
+	 * carrying the grammar's own separators round-trips.  A reserved, malformed, or already-registered name logs
+	 * E-JS-67 and the directive simply does not exist.
+	 *
+	 * @example
+	 *   NS.urlState.registerDirective('view', { parse: String, serialize: String });
+	 */
+	function registerDirective(name, codec) {
+		if (RESERVED_DIRECTIVES[name] || ! DIRECTIVE_NAME_RE.test(String(name)) || registry[name]
+			|| ! codec || typeof codec.parse !== 'function' || typeof codec.serialize !== 'function') {
+			console.error('[juneau-urlstate] E-JS-67: url-state directive \'' + name + '\' is reserved, malformed, or already registered');
+			return false;
+		}
+		registry[name] = { codec: codec };
+		directiveOrder.push(name);
+		return true;
+	}
+
+	/** Whether a directive is registered under `name` (built-in tab/filter/sort are not registry entries). */
+	function hasDirective(name) { return !! registry[name]; }
+
+	/** The registered codec for `name`, or null. */
+	function directiveCodec(name) { return registry[name] ? registry[name].codec : null; }
+
+	/** The registered directive names, in registration order. */
+	function directiveNames() { return directiveOrder.slice(); }
+
+	/** Percent-encodes the characters the directive grammar is sensitive to, so a codec body always parses. */
+	function encodeArg(s) {
+		return String(s == null ? '' : s).replace(/[;(),=%]/g, function (c) { return '%' + c.charCodeAt(0).toString(16).toUpperCase(); });
+	}
+
+	/** Decodes an {@link encodeArg} value; a malformed %-sequence is kept as-is. */
+	function decodeArg(s) {
+		try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } // NOSONAR javascript:S2486 -- a malformed %-sequence keeps the raw text
+	}
+
+	/** Whether a registered directive's value is empty (so it is omitted from the encoded state). */
+	function extValueEmpty(codec, value) {
+		return codec.isEmpty ? !! codec.isEmpty(value) : (value == null || value === '');
+	}
+
+	/** Built-in `window` directive: body `start=<iso>;end=<iso>`, either half optional; `params` renames the request params. */
+	function windowDirectiveCodec(cfg) {
+		const params = (cfg && cfg.params) || {};
+		const startParam = params.start || 'start';
+		const endParam = params.end || 'end';
+		return {
+			parse: function (body) {
+				const out = {};
+				for (const part of String(body || '').split(';')) {
+					const eq = part.indexOf('=');
+					if (eq <= 0) continue;
+					const k = part.substring(0, eq);
+					if (k === 'start' || k === 'end') out[k] = decodeArg(part.substring(eq + 1));
+				}
+				return out;
+			},
+			serialize: function (v) {
+				const out = [];
+				if (v?.start) out.push('start=' + encodeArg(v.start));
+				if (v?.end) out.push('end=' + encodeArg(v.end));
+				return out.join(';');
+			},
+			isEmpty: function (v) { return ! v || (! v.start && ! v.end); },
+			toParams: function (v) {
+				const out = {};
+				if (v?.start) out[startParam] = v.start;
+				if (v?.end) out[endParam] = v.end;
+				return out;
+			}
+		};
+	}
+
+	/** Built-in `ids` directive: body `a,b,c`; trims, drops blanks, dedupes, and caps at `max` (default 200). */
+	function idsDirectiveCodec(cfg) {
+		const param = cfg?.param || 'ids';
+		const max = cfg?.max || 200;
+		function normalize(raw) {
+			const seen = Object.create(null);
+			const ids = [];
+			for (const part of String(raw || '').split(',')) {
+				const v = decodeArg(part).trim();
+				if (v === '') continue;
+				if (seen[v] || ids.length >= max) continue;
+				seen[v] = true;
+				ids.push(v);
+			}
+			return { ids: ids, truncated: hasDroppedBeyondCap(raw, ids, max) };
+		}
+		return {
+			parse: function (body) { return normalize(body); },
+			serialize: function (v) { return (v?.ids || []).map(encodeArg).join(','); },
+			isEmpty: function (v) { return ! v?.ids || v.ids.length === 0; },
+			toParams: function (v) {
+				const out = {};
+				out[param] = (v?.ids || []).join(',');
+				return out;
+			},
+			statusMessage: function (v) { return v?.truncated ? ('Showing the first ' + max + ' ids') : null; }
+		};
+	}
+
+	/** Whether any distinct non-blank id in `raw` was left out of `kept` purely because of the cap (dupes do not count). */
+	function hasDroppedBeyondCap(raw, kept, max) {
+		if (kept.length < max) return false;
+		const keptSet = Object.create(null);
+		for (const k of kept) keptSet[k] = true;
+		for (const part of String(raw || '').split(',')) {
+			const v = decodeArg(part).trim();
+			if (v !== '' && ! keptSet[v]) return true;
+		}
+		return false;
+	}
+
 	// -------------------------------------------------------------------------------------------------------------
 	// Codec: {tab, filters:[{column,expr}], sort:{column,dir}} <-> "tab(x);filter(col=expr);sort(col=dir)"
 	// -------------------------------------------------------------------------------------------------------------
@@ -72,6 +201,14 @@
 		const sort = state.sort;
 		if (sort && ! isBlank(sort.column) && ! isBlank(sort.dir))
 			parts.push('sort(' + clauseEscape(String(sort.column)) + '=' + sort.dir + ')');
+		const ext = state.ext || {};
+		for (const extName of directiveOrder) {
+			if (! (extName in ext)) continue;
+			const codec = registry[extName].codec;
+			if (extValueEmpty(codec, ext[extName])) continue;
+			// '%' is doubled so decodeState's one whole-string percent-decode leaves the codec's own %XX escapes intact.
+			parts.push(extName + '(' + String(codec.serialize(ext[extName])).replace(/%/g, '%25') + ')');
+		}
 		return parts.join(';');
 	}
 
@@ -203,7 +340,7 @@
 	 * S4: Discarding until the next top-level `;` after a malformed directive.
 	 */
 	function decodeState(str) { // NOSONAR javascript:S3776 -- URL state parsing; complexity is inherent
-		const state = { tab: null, filters: [], sort: null };
+		const state = { tab: null, filters: [], sort: null, ext: {} };
 		if (str == null) return state;
 		let raw = String(str);
 		if (raw.indexOf('%') >= 0) {
@@ -233,8 +370,15 @@
 				const sortClauses = parseClauses(b);
 				if (sortClauses.length > 0 && sortClauses[0].key !== '')
 					state.sort = { column: sortClauses[0].key, dir: sortClauses[0].value };
+			} else if (registry[n]) {
+				try {
+					state.ext[n] = registry[n].codec.parse(b);
+				} catch (e) {
+					// E-JS-68: the directive is dropped; tab/filter/sort already decoded are kept.
+					console.error('[juneau-urlstate] E-JS-68: url-state directive \'' + n + '\' could not be parsed: ' + (e?.message || e));
+				}
 			}
-			// Any other directive name is deliberately ignored (design section 6.3: tab/filter/sort ONLY).
+			// An unregistered directive name is dropped (design section 6.3, extended: tab/filter/sort plus whatever the page registered).
 		}
 
 		function flushPending() {
@@ -310,9 +454,16 @@
 		return state;
 	}
 
-	/** Whether a decoded state carries no tab, no filters, and no sort (i.e. nothing to restore). */
+	/** Whether a decoded state carries no tab, no filters, and no sort (i.e. nothing to restore); registered directives count too. */
 	function isEmptyState(state) {
-		return ! state || (isBlank(state.tab) && (! state.filters || state.filters.length === 0) && ! state.sort);
+		if (! state) return true;
+		if (! isBlank(state.tab) || (state.filters && state.filters.length > 0) || state.sort) return false;
+		const ext = state.ext || {};
+		for (const name of Object.keys(ext)) {
+			const codec = registry[name]?.codec;
+			if (codec && ! extValueEmpty(codec, ext[name])) return false;
+		}
+		return true;
 	}
 
 	// -------------------------------------------------------------------------------------------------------------
@@ -417,6 +568,23 @@
 		buildShareUrl: buildShareUrl,
 		copy: copy,
 		cleanAddressEnabled: cleanAddressEnabled,
-		resolveOpenState: resolveOpenState
+		resolveOpenState: resolveOpenState,
+		registerDirective: registerDirective,
+		hasDirective: hasDirective,
+		directiveCodec: directiveCodec,
+		directiveNames: directiveNames,
+		encodeArg: encodeArg,
+		decodeArg: decodeArg,
+		builtins: { window: windowDirectiveCodec, ids: idsDirectiveCodec },
+		// Needs a live table, which this DOM-free module never has: delegates to juneau-views.js at call time, so
+		// either script load order works.
+		setDirective: function (tableEl, name, value) {
+			const run = NS.init?.setUrlStateDirective;
+			if (typeof run !== 'function') {
+				console.error('[juneau-urlstate] E-JS-67: setDirective needs juneau-views.js loaded');
+				return false;
+			}
+			return run(tableEl, name, value);
+		}
 	};
 })();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index 86467c9ec0..d87b5594b2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -2871,6 +2871,54 @@ input[type="checkbox"].jc-editable-field-input {
 	box-sizing: border-box;
 }
 
+/*
+ * Inline cell edit (the edit renderer): a value with a pencil that swaps the cell to an input.
+ */
+.juneau-edit-pencil {
+	margin-left: 0.35em;
+	padding: 0;
+	border: 0;
+	background: transparent;
+	cursor: pointer;
+	vertical-align: middle;
+	width: 1em;
+	height: 1em;
+	line-height: 1;
+	opacity: 0.55;
+}
+
+.juneau-edit-pencil::before {
+	content: "\270E";
+}
+
+.juneau-edit-pencil:hover,
+.juneau-edit-pencil:focus-visible {
+	opacity: 1;
+}
+
+.juneau-edit-pencil[disabled],
+.juneau-edit-pencil[aria-disabled="true"] {
+	cursor: not-allowed;
+	opacity: 0.3;
+}
+
+.juneau-edit-editor {
+	display: inline-flex;
+	flex-direction: column;
+	gap: 0.2em;
+}
+
+.juneau-edit-input {
+	font: inherit;
+	max-width: 100%;
+	box-sizing: border-box;
+}
+
+.juneau-edit-error {
+	font-size: 0.85em;
+	color: var(--jc-chrome-danger, currentColor);
+}
+
 .jc-cell-popover {
 	position: fixed;
 	z-index: var(--jc-popover-z);
@@ -3440,6 +3488,36 @@ input[type="checkbox"].jc-editable-field-input {
 	padding: var(--jc-space-1) 0;
 }
 
+.juneau-rv-attempts-toggle {
+	font: inherit;
+	color: var(--jc-tone-info);
+	background: none;
+	border: 0;
+	padding: 0;
+	text-align: left;
+	cursor: pointer;
+}
+
+.juneau-rv-attempts-toggle:hover .juneau-rv-attempts-label {
+	text-decoration: underline;
+}
+
+.juneau-rv-attempts-chevron {
+	display: inline-block;
+	width: 1.2em;
+}
+
+.juneau-rv-attempts {
+	list-style: none;
+	margin: 0;
+	padding: 0 0 0 var(--jc-space-4);
+	opacity: 0.85;
+}
+
+.juneau-rv-attempts[hidden] {
+	display: none;
+}
+
 .juneau-rv-step-head {
 	display: flex;
 	flex-wrap: wrap;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 8d56bf6cc7..c0bd26d2c5 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -209,8 +209,6 @@
 	 * Lift a datatables card's author catalog (`card.table`) into the SLOT_META envelope `paintSlotTable` consumes,
 	 * stamping every contract version (slot, view, bulk, detail, quickStats).
 	 *
-	 * A catalog whose `view` is an object is a pre-built SLOT_META envelope (escape hatch) and passes through unchanged.
-	 *
 	 * @example
 	 * NS.card.liftCatalog('releases', {dataUrl: '/rest/releases/data',
 	 *   columns: [{key: 'version', label: 'Version'}], selection: {rowIdField: 'version'}});
@@ -226,7 +224,6 @@
 	NS.card.liftCatalog = function liftCatalog(cardId, catalog) {
 		if (!catalog || typeof catalog !== "object" || Array.isArray(catalog))
 			throw new Error("datatables card '" + cardId + "': 'table' must be an object");
-		if (catalog.view && typeof catalog.view === "object") return catalog;
 		if (!Array.isArray(catalog.columns) || catalog.columns.length === 0)
 			throw new Error("datatables card '" + cardId + "': 'columns' must be a non-empty array");
 		const hasUrl = typeof catalog.dataUrl === "string" && catalog.dataUrl.trim() !== "";
@@ -1100,6 +1097,7 @@
 		} else {
 			opts.serverSide = false;
 			opts.ajax = { url: viewDef.dataUrl, dataSrc: "" };
+			if (deps?.urlStateParams) opts.ajax.data = function () { return deps.urlStateParams(); };
 		}
 
 		// Nested-table parent scoping.  `deps.nestedScope`, present ONLY for a nested table, merges exactly ONE extra
@@ -1187,7 +1185,7 @@
 			// URL-only params (any `param:`-scoped ribbon option; applyNestedScope wraps this to add the
 			// nested-table parent-scope param) - never a column-scoped one; those go through `data` above.
 			beforeSend: function (jqXHR, settings) {
-				const extra = ribbon ? ribbon.ribbonQueryParams(viewDef, activeState()) : {};
+				const extra = Object.assign({}, ribbon ? ribbon.ribbonQueryParams(viewDef, activeState()) : {}, deps?.urlStateParams ? deps.urlStateParams() : {});
 				settings.url = appendQueryParams(viewDef.dataUrl, extra);
 				// Send the CSRF token with the server-mode data request, resolved per request exactly as the
 				// row-action path does (table attribute first, then the page meta tag); no token, no header.
@@ -3575,6 +3573,77 @@
 		return null;
 	}
 
+	// ==================================================================================================================
+	// VISIBILITY RULES - declarative hide-when-irrelevant, shared by row actions, detail fields, regions, ribbon items
+	// ==================================================================================================================
+	//
+	// A rule is {field, op, value?} with op one of eq/ne/present/absent/in/contains; a list of rules is an AND.  This
+	// is deliberately NOT actionRuleMatches: that evaluator keeps its string-coerced eq and its own fail-closed
+	// `absent` for enabledWhen, and changing it would change every existing disabled-with-reason gate.  Here `field`
+	// is a dotted path, comparison is strict, and a field absent from the evaluation map fails closed for every op
+	// except `absent`.  JuneauViews.rules.test is held to the same corpus as the Java VisibilityRule.test.
+	//
+	// The evaluation map is the row plus the page's facts under `facts` (row actions, detail fields, regions), or the
+	// facts alone (ribbon items).  Hiding is presentation only - the endpoint must still authorize.
+
+	const RULE_FIELD_MISSING = {};
+
+	function ruleValueAt(map, dottedPath) {
+		let cur = map;
+		for (const part of String(dottedPath).split(".")) {
+			if (cur === null || typeof cur !== "object" || !(part in cur)) return RULE_FIELD_MISSING;
+			cur = cur[part];
+		}
+		return cur;
+	}
+
+	function isBlankRuleValue(v) {
+		return v === null || v === undefined || String(v).trim() === "";
+	}
+
+	function visibilityRuleMatches(rule, map) {
+		const actual = ruleValueAt(map, rule?.field);
+		const missing = actual === RULE_FIELD_MISSING;
+		switch (rule?.op) {
+			case "eq": return !missing && actual === rule.value;
+			case "ne": return !missing && actual !== rule.value;
+			case "present": return !missing && !isBlankRuleValue(actual);
+			case "absent": return missing || isBlankRuleValue(actual);
+			case "in": return !missing && Array.isArray(rule.value) && rule.value.includes(actual);
+			case "contains": return !missing && Array.isArray(actual) && actual.includes(rule.value);
+			default:
+				// E-JS-73: an unknown op hides the element (fail closed) but never throws, so the rest of the table still renders.
+				console.error("[juneau-views] E-JS-73: visibleWhen rule on field '" + rule?.field + "' has unknown op '" + rule?.op + "'");
+				return false;
+		}
+	}
+
+	/** True when every rule matches `map`; null/empty matches; a single rule object is a list of one. */
+	function evaluateRules(rules, map) {
+		if (rules === null || rules === undefined) return true;
+		const list = Array.isArray(rules) ? rules : [rules];
+		const scope = map && typeof map === "object" ? map : {};
+		return list.every(function (r) { return visibilityRuleMatches(r, scope); });
+	}
+
+	/** The mounted console page's top-level `facts`, or `{}` when there is no console page or it declares none. */
+	function pageFacts() {
+		let contract = null;
+		try { contract = window.JuneauConsole?.contract?.(); } catch (e) { contract = null; }
+		const facts = contract?.facts;
+		return facts && typeof facts === "object" ? facts : {};
+	}
+
+	/** The evaluation map for a row-level element: the row's own fields plus the page facts under `facts`. */
+	function rowRuleScope(rowData) {
+		return Object.assign({}, rowData && typeof rowData === "object" ? rowData : {}, { facts: pageFacts() });
+	}
+
+	/** Whether `action` is shown for the row; an action with no `visibleWhen` always is. */
+	function isRowActionVisible(action, rowData) {
+		return !action?.visibleWhen?.length || evaluateRules(action.visibleWhen, rowRuleScope(rowData));
+	}
+
 	// ==================================================================================================================
 	// ROWACTION.ENABLEDWHEN - row-state disable-with-reason gate
 	// ==================================================================================================================
@@ -3652,9 +3721,36 @@
 		if (!rowEl || typeof rowEl.querySelectorAll !== "function" || !viewDef?.rowActions?.length) return;
 		for (const pill of rowEl.querySelectorAll("[data-juneau-pill][data-juneau-action]")) {
 			const action = findRowActionById(viewDef, pill.dataset.juneauAction);
+			if (action && !isRowActionVisible(action, rowData)) {
+				pill.hidden = true;
+				pill.setAttribute("aria-hidden", "true");
+				continue;
+			}
 			const failing = firstFailingRowActionRule(action, rowData);
 			if (failing) disableRowActionPill(pill, failing.reason);
 		}
+		// The menu trigger has nothing to open when every action is hidden for this row.
+		const anyVisible = viewDef.rowActions.some(function (a) { return isRowActionVisible(a, rowData); });
+		const trigger = rowEl.querySelector(".juneau-view-action-trigger");
+		if (trigger && !anyVisible) trigger.hidden = true;
+	}
+
+	/**
+	 * Gates the pencil of every `edit` renderer cell on the row: hidden when the action it commits through is not
+	 * visible for this row (`visibleWhen`), disabled with the failing rule's reason when `enabledWhen` fails.  An
+	 * action the view does not declare hides the pencil and is reported once per table draw.
+	 */
+	function applyInlineEditGates(rowEl, rowData, viewDef) {
+		if (!rowEl || typeof rowEl.querySelectorAll !== "function") return;
+		for (const pencil of rowEl.querySelectorAll("[data-juneau-edit]")) {
+			const action = findRowActionById(viewDef, pencil.getAttribute("data-juneau-edit"));
+			if (!action || !isRowActionVisible(action, rowData)) {
+				pencil.hidden = true;
+				continue;
+			}
+			const failing = firstFailingRowActionRule(action, rowData);
+			if (failing) disableRowActionControl(pencil, failing.reason);
+		}
 	}
 
 	/**
@@ -4236,6 +4332,129 @@
 		});
 	}
 
+	// ==================================================================================================================
+	// INLINE CELL EDIT (the `edit` renderer): the cell shows the value and a pencil; the pencil swaps the cell to an
+	// input that commits through the named row action.
+	// ==================================================================================================================
+
+	/** Parses the `options` of a `select` edit cell: an array, or a comma-separated string (render meta values are strings). */
+	function parseEditOptions(raw) {
+		if (Array.isArray(raw)) return raw.map(String);
+		if (typeof raw !== "string" || raw === "") return [];
+		const parsed = parseJsonSafe(raw);
+		if (Array.isArray(parsed)) return parsed.map(String);
+		return raw.split(",").map(function (o) { return o.trim(); }).filter(function (o) { return o !== ""; });
+	}
+
+	function buildEditInput(input, options, current) {
+		let field;
+		if (input === "select" && options.length) {
+			field = document.createElement("select");
+			for (const o of options) {
+				const opt = document.createElement("option");
+				opt.value = o;
+				opt.textContent = o;
+				field.appendChild(opt);
+			}
+			field.value = current;
+		} else {
+			field = document.createElement("input");
+			field.type = input === "number" ? "number" : "text";
+			field.value = current;
+		}
+		field.className = "juneau-edit-input";
+		return field;
+	}
+
+	/** Swaps the pencil's cell into edit mode.  A cell already being edited is left alone. */
+	function startInlineEdit(pencil, table, ctx, viewDef) {
+		const cell = pencil.closest ? pencil.closest("td") : null;
+		const tr = cell?.parentNode;
+		if (!cell || !tr || cell._juneauEditing) return;
+		const action = findRowActionById(viewDef, pencil.getAttribute("data-juneau-edit"));
+		const rowData = rowDataForTr(ctx, tr) || {};
+		// Re-checked at activation, not only at draw, so a stale or bypassed gate can never open the editor.
+		if (!action || !isRowActionVisible(action, rowData) || firstFailingRowActionRule(action, rowData)) return;
+		const column = pencil.getAttribute("data-juneau-edit-col");
+		const current = rowData[column] == null ? "" : String(rowData[column]);
+		const field = buildEditInput(pencil.getAttribute("data-juneau-edit-input"),
+			parseEditOptions(pencil.getAttribute("data-juneau-edit-options")), current);
+		field.setAttribute("aria-label", "Edit " + column);
+		const error = document.createElement("span");
+		error.className = "juneau-edit-error";
+		error.setAttribute("role", "alert");
+		error.hidden = true;
+		const editor = document.createElement("span");
+		editor.className = "juneau-edit-editor";
+		editor.appendChild(field);
+		editor.appendChild(error);
+		const saved = Array.from(cell.childNodes);
+		for (const n of saved) cell.removeChild(n);
+		cell.appendChild(editor);
+		const state = { busy: false, failed: false, done: false };
+		cell._juneauEditing = state;
+
+		function close(newValue) {
+			if (state.done) return;
+			state.done = true;
+			delete cell._juneauEditing;
+			if (editor.parentNode === cell) cell.removeChild(editor);
+			for (const n of saved) cell.appendChild(n);
+			if (newValue != null) {
+				const shown = cell.querySelector(".juneau-edit-value");
+				if (shown) shown.textContent = newValue;
+			}
+			if (typeof pencil.focus === "function") pencil.focus();
+		}
+		function commit() {
+			if (state.busy || state.done) return;
+			const value = field.value;
+			if (value === current) { close(null); return; }
+			state.busy = true;
+			state.failed = false;
+			field.disabled = true;
+			error.hidden = true;
+			const fields = {};
+			fields[column] = value;
+			submitRowActionForOutcome(action, table, tr, ctx, { fields: fields }).then(function (o) {
+				state.busy = false;
+				field.disabled = false;
+				if (o.outcome === "success") {
+					applySuccessBehavior(action, table, tr, ctx, o.result);
+					const redrawn = action.onSuccess === "redraw" || (action.onSuccess === "mergeRow" && o.result?.row != null);
+					if (!redrawn && ctx?.dataTable) reloadTableData(ctx.dataTable, true);   // keep the current page
+					close(value);
+					return;
+				}
+				state.failed = true;
+				error.textContent = o.message || "The change could not be saved.";
+				error.hidden = false;
+				if (typeof field.focus === "function") field.focus();
+			});
+		}
+		field.addEventListener("keydown", function (e) {
+			if (e.key === "Enter") { e.preventDefault(); commit(); }
+			else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); if (!state.busy) close(null); }
+		});
+		// A failed save stays open until the operator retries (Enter) or cancels (Escape); only an untried edit commits on blur.
+		field.addEventListener("blur", function () { if (!state.failed) commit(); });
+		if (typeof field.focus === "function") field.focus();
+	}
+
+	/** ONE delegated listener per table that opens the editor from an `edit` renderer's pencil. */
+	function initInlineEdit(table, ctx, viewDef) {
+		if (!table || typeof table.addEventListener !== "function" || table._juneauInlineEditBound) return;
+		table._juneauInlineEditBound = true;
+		table.addEventListener("click", function (e) {
+			if (!isOwnTableEvent(table, e)) return;   // a nested table owns its own cells
+			const pencil = e.target?.closest ? e.target.closest("[data-juneau-edit]") : null;
+			if (!pencil || pencil.disabled || pencil.hidden) return;
+			e.preventDefault();
+			e.stopPropagation();
+			startInlineEdit(pencil, table, ctx, viewDef);
+		});
+	}
+
 	/**
 	 * ONE delegated click listener on the stable table element.  Survives DataTables redraw.  Escape / outside-click
 	 * dismissal is owned by the shared {@code popupLayerStack} (the popover registers as a light-dismiss layer on open),
@@ -4958,6 +5177,89 @@
 		return { el: el, refresh: refresh };
 	}
 
+	/** Confirm renderers registered by name (see {@link #registerConfirmRenderer}); prototype-less so a name is never inherited. */
+	const confirmRenderers = Object.create(null);
+
+	/** Dialog chromes registered by name (see {@link #registerDialogChrome}). */
+	const dialogChromes = Object.create(null);
+
+	/** Renderer names that always mean the built-in modal: the bulk path's historical "list" token, and "default". */
+	const BUILT_IN_CONFIRM_RENDERERS = { "default": 1, "list": 1 };
+
+	/** The page-default confirm renderer name, set by registering a renderer with {@code {asDefault:true}}. */
+	let defaultConfirmRendererName = null;
+
+	/**
+	 * Registers a named confirm renderer: {@code fn(modal, request)} paints its own UI and returns a boolean (or a
+	 * Promise of one) - true to proceed.  Registering a name twice keeps the first and logs.
+	 */
+	function registerConfirmRenderer(name, fn, opts) {
+		if (typeof fn !== "function" || isBlankToken(name) || Object.hasOwn(BUILT_IN_CONFIRM_RENDERERS, name)) {
+			console.error("[juneau-views] " + fmt("confirm renderer '%s' cannot be registered", name));
+			return;
+		}
+		if (confirmRenderers[name]) { console.error("[juneau-views] " + fmt("confirm renderer '%s' is already registered", name)); return; }
+		confirmRenderers[name] = fn;
+		if (opts?.asDefault) defaultConfirmRendererName = name;
+	}
+
+	/** Registers a named dialog chrome: {@code fn(parts, modal)} may restyle the header/footer parts of a dialog. */
+	function registerDialogChrome(name, fn) {
+		if (typeof fn !== "function" || isBlankToken(name)) {
+			console.error("[juneau-views] " + fmt("dialog chrome '%s' cannot be registered", name));
+			return;
+		}
+		if (dialogChromes[name]) { console.error("[juneau-views] " + fmt("dialog chrome '%s' is already registered", name)); return; }
+		dialogChromes[name] = fn;
+	}
+
+	/** One confirm list entry as text: a string verbatim, a row object by its label field then its id. */
+	function confirmItemLabel(item, request) {
+		if (typeof item === "string") return item;
+		const label = request?.labelField ? item?.[request.labelField] : null;
+		return String(label ?? item?.id ?? item?.[request?.rowIdField] ?? "");
+	}
+
+	/** The renderer-facing description of a confirm request (everything a custom renderer needs to paint it). */
+	function describeConfirm(request) {
+		return {
+			title: request.title || "",
+			body: request.body || null,
+			items: (request.items || []).map(function (it) { return confirmItemLabel(it, request); }),
+			confirmLabel: request.confirmLabel || "Confirm",
+			cancelLabel: request.cancelLabel || "Cancel",
+			tone: request.tone || "default"
+		};
+	}
+
+	/**
+	 * The one confirm entry point (`JuneauViews.dialogs.confirm`): picks the renderer (request, then the action's
+	 * `confirmRenderer`, then the page default, else the built-in modal) and resolves to whether the user confirmed.
+	 * A request carrying a function `action` also has it invoked on confirm, which is the bulk path's contract.
+	 * A renderer name that was never registered logs E-JS-71 and rejects without running anything.
+	 */
+	function confirmPipeline(request) {
+		request = request || {};
+		const act = request.action;
+		const name = request.renderer || (act && typeof act === "object" ? act.confirmRenderer : null) || defaultConfirmRendererName;
+		const builtIn = !name || Object.hasOwn(BUILT_IN_CONFIRM_RENDERERS, name);
+		const render = builtIn ? null : confirmRenderers[name];
+		if (!builtIn && !render) {
+			console.error("[juneau-views] E-JS-71: " + fmt("confirm renderer '%s' is not registered", name));
+			return Promise.reject(new Error("E-JS-71"));
+		}
+		const modal = describeConfirm(request);
+		// The executor runs synchronously, so the built-in modal is on the layer stack by the time confirm() returns.
+		return new Promise(function (resolve) {
+			resolve(render ? render(modal, request) : buildFallbackConfirmModal(Object.assign({}, request, modal), null, null));
+		})
+			.then(function (ok) {
+				if (ok !== true) return false;
+				if (typeof act === "function" && render) act();
+				return true;
+			});
+	}
+
 	/**
 	 * Gates a bulk action behind the mandatory confirm (an action opts out only with `confirm === false`).  Prefers a
 	 * host-provided `JuneauViews.dialogs.confirm(opts)` and otherwise falls back to the built-in modal below.  The rows
@@ -4979,11 +5281,14 @@
 			confirmLabel: action.label || action.id,
 			tone: action.tone || "default",
 			rows: rows,
+			labelField: selectionState.labelField,
+			rowIdField: selectionState.rowIdField,
 			renderer: action.confirmRenderer || "list",
 			action: function () { runBulkActionDeferred(action, table, ctx, selectionState, ids); }
 		};
 		if (window.JuneauViews?.dialogs?.confirm) {
-			window.JuneauViews.dialogs.confirm(opts);
+			const pending = window.JuneauViews.dialogs.confirm(opts);
+			if (typeof pending?.catch === "function") pending.catch(function () { /* already logged by the pipeline */ }); // NOSONAR javascript:S2486 -- a rejected confirm (unregistered renderer) is logged where it is raised
 			return;
 		}
 		buildFallbackConfirmModal(opts, table, selectionState);
@@ -4999,61 +5304,68 @@
 	 * Lists up to 20 selected rows by `labelField` (falling back to the row id) and summarises the remainder.
 	 */
 	function buildFallbackConfirmModal(opts, table, selectionState) { // NOSONAR javascript:S1172 -- table kept for call-site symmetry with the dialogs.confirm path.
-		const backdrop = document.createElement("div");
-		backdrop.className = "juneau-view-dialog-backdrop juneau-view-confirm-modal juneau-view-confirm-modal-" + opts.tone;
-		const dialog = document.createElement("div");
-		dialog.className = "juneau-view-dialog";
-		dialog.setAttribute("role", "dialog");
-		dialog.setAttribute("aria-modal", "true");
-
-		const title = document.createElement("h2");
-		title.className = "juneau-view-dialog-title";
-		title.textContent = opts.title;
-		dialog.appendChild(title);
-
-		if (opts.body) {
-			const body = document.createElement("p");
-			body.textContent = opts.body;
-			dialog.appendChild(body);
-		}
-
-		const cap = 20;
-		const list = document.createElement("ul");
-		list.className = "juneau-view-confirm-list";
-		opts.items.slice(0, cap).forEach(function (row) {
-			const li = document.createElement("li");
-			const label = selectionState.labelField ? row[selectionState.labelField] : null;
-			li.textContent = String(label != null ? label : (row.id ?? row[selectionState.rowIdField]));
-			list.appendChild(li);
-		});
-		if (opts.items.length > cap) {
-			const more = document.createElement("li");
-			more.textContent = "\u2026and " + (opts.items.length - cap) + " more";
-			list.appendChild(more);
-		}
-		dialog.appendChild(list);
+		return new Promise(function (resolve) {
+			let settled = false;
+			function settle(ok) { if (!settled) { settled = true; resolve(ok); } }
+			const backdrop = document.createElement("div");
+			backdrop.className = "juneau-view-dialog-backdrop juneau-view-confirm-modal juneau-view-confirm-modal-" + opts.tone;
+			const dialog = document.createElement("div");
+			dialog.className = "juneau-view-dialog";
+			dialog.setAttribute("role", "dialog");
+			dialog.setAttribute("aria-modal", "true");
+
+			const title = document.createElement("h2");
+			title.className = "juneau-view-dialog-title";
+			title.textContent = opts.title;
+			dialog.appendChild(title);
+
+			if (opts.body) {
+				const body = document.createElement("p");
+				body.textContent = opts.body;
+				dialog.appendChild(body);
+			}
 
-		const actions = document.createElement("div");
-		actions.className = "juneau-view-dialog-actions";
-		const cancelBtn = document.createElement("button");
-		cancelBtn.type = "button";
-		cancelBtn.className = "juneau-view-dialog-cancel";
-		cancelBtn.textContent = "Cancel";
-		cancelBtn.addEventListener("click", function () { popLayer(backdrop); });
-		const confirmBtn = document.createElement("button");
-		confirmBtn.type = "button";
-		confirmBtn.className = "juneau-view-dialog-confirm";
-		confirmBtn.textContent = opts.confirmLabel;
-		confirmBtn.addEventListener("click", function () {
-			popLayer(backdrop);
-			opts.action();
+			const cap = 20;
+			const items = opts.items || [];
+			const list = document.createElement("ul");
+			list.className = "juneau-view-confirm-list";
+			items.slice(0, cap).forEach(function (row) {
+				const li = document.createElement("li");
+				li.textContent = typeof row === "string" ? row : confirmItemLabel(row, selectionState || opts);
+				list.appendChild(li);
+			});
+			if (items.length > cap) {
+				const more = document.createElement("li");
+				more.textContent = "\u2026and " + (items.length - cap) + " more";
+				list.appendChild(more);
+			}
+			if (items.length) dialog.appendChild(list);
+
+			const actions = document.createElement("div");
+			actions.className = "juneau-view-dialog-actions";
+			const cancelBtn = document.createElement("button");
+			cancelBtn.type = "button";
+			cancelBtn.className = "juneau-view-dialog-cancel";
+			cancelBtn.textContent = opts.cancelLabel || "Cancel";
+			cancelBtn.addEventListener("click", function () { settle(false); popLayer(backdrop); });
+			const confirmBtn = document.createElement("button");
+			confirmBtn.type = "button";
+			confirmBtn.className = "juneau-view-dialog-confirm" + (opts.tone === "danger" ? " juneau-view-dialog-confirm-danger" : "");
+			confirmBtn.textContent = opts.confirmLabel;
+			confirmBtn.addEventListener("click", function () {
+				settle(true);
+				popLayer(backdrop);
+				if (typeof opts.action === "function") opts.action();
+			});
+			actions.appendChild(cancelBtn);
+			actions.appendChild(confirmBtn);
+			dialog.appendChild(actions);
+
+			backdrop.appendChild(dialog);
+			// Cancel precedes Confirm in tab order, so it is the first focus target; a danger confirm makes that explicit.
+			pushLayer(backdrop, { kind: "dialog", trapFocus: true, lightDismiss: false, detachOnPop: true, onDismiss: function () { settle(false); } });
+			if (opts.tone === "danger" && typeof cancelBtn.focus === "function") cancelBtn.focus();
 		});
-		actions.appendChild(cancelBtn);
-		actions.appendChild(confirmBtn);
-		dialog.appendChild(actions);
-
-		backdrop.appendChild(dialog);
-		pushLayer(backdrop, { kind: "dialog", trapFocus: true, lightDismiss: false, detachOnPop: true, onDismiss: null });
 	}
 
 	/** Max per-row bulk writes in flight at once - enough to be quick, few enough not to flood a shared backend. */
@@ -5268,6 +5580,39 @@
 			});
 	}
 
+	/**
+	 * Like {@link submitRowAction}, but resolves to the outcome instead of painting it into the row: `{outcome, message,
+	 * result}` where `outcome` is a normalized outcome token ("success", "failure", "refusal", "unknown", ...).  The
+	 * caller owns showing a failure and applying a success (see applySuccessBehavior).  Used by the inline cell editor,
+	 * which reports a failure inside the cell it is editing rather than in the row's action banner.
+	 */
+	function submitRowActionForOutcome(action, table, tr, ctx, extra) {
+		const rowId = tr?.getAttribute ? tr.getAttribute(ROW_ID_ATTR) : null;
+		const req = buildActionRequest(
+			action, resolveCsrfToken(table), resolveCsrfHeaderName(table),
+			Object.assign(rowId == null ? {} : { targetId: rowId }, extra), rowDataForTr(ctx, tr));
+		if (req.refuse) return Promise.resolve({ outcome: "refusal", message: actionRefusalMessage(req.reason) });
+		setRowInFlight(tr, true);
+		return fetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" })
+			.then(function (resp) {
+				setRowInFlight(tr, false);
+				if (!resp.ok) {
+					const boundary = (typeof resp.headers?.get === "function") ? resp.headers.get("X-Loopback-Boundary") : null;
+					return readBodyText(resp).then(function (text) {
+						return { outcome: "refusal", message: transportRefusal(resp.status, boundary, parseJsonSafe(text)).message };
+					});
+				}
+				return readBodyText(resp).then(function (text) {
+					const result = parseActionResult(text);
+					if (!result) return { outcome: "success", result: null };
+					return { outcome: normalizeOutcome(result), message: result.message, result: result };
+				});
+			}, function () {
+				setRowInFlight(tr, false);
+				return { outcome: "unknown", message: REQUEST_UNCONFIRMED_NOTICE };
+			});
+	}
+
 	/**
 	 * Whether `table` currently has a row marked in-flight (design doc §9.1 B5) - see hasInFlightRow above; this is
 	 * the setter half.  Marks the `<tr>` in-flight for an OUTSTANDING SYNCHRONOUS WRITE and disables its action
@@ -5755,6 +6100,42 @@
 	 * and in the ribbon-anchored host when there is not.  A ribbon click otherwise died here, on an open FAILURE,
 	 * before the submit path was ever reached.
 	 */
+	/**
+	 * Fills a single-row `confirm` prompt's `{field}` tokens from the row (plain text, unlike the URL-escaped endpoint
+	 * substitution).  A token whose row value is absent or blank refuses, as it does for `endpoint`.
+	 */
+	function resolveConfirmText(text, rowData) {
+		if (typeof text !== "string" || !ROW_ACTION_TOKEN_RE.test(text)) return { text: text };
+		if (hasBlankSubstitution(text, rowData)) return { refuse: true, reason: "empty-substitution" };
+		return { text: text.replace(new RegExp(ROW_ACTION_TOKEN_RE, "g"), function (m, key) { return String(rowData[key]); }) }; // NOSONAR javascript:S5852 -- ROW_ACTION_TOKEN_RE is linear ([^}] cannot match the closing brace)
+	}
+
+	/**
+	 * The confirm-only (no form URL) dialog.  The prompt is the action's `confirm` text with its `{field}` tokens
+	 * filled, else its name.  An action naming a `confirmRenderer` goes through the confirm pipeline and submits only
+	 * on a true answer; every other action keeps the built-in dialog, carrying `confirmLabel` and `tone`.
+	 */
+	function openConfirmOnlyDialog(action, table, tr, ctx) {
+		const resolved = resolveConfirmText(action.confirm, rowDataForTr(ctx, tr));
+		if (resolved.refuse) {
+			renderActionRefusalFor(tr, action, resolved.reason, ctx);
+			return;
+		}
+		const modal = { title: resolved.text || actionDisplayName(action) };
+		if (isBlankToken(action.confirmRenderer)) {
+			showActionDialog(modal, action, table, tr, ctx);
+			return;
+		}
+		confirmPipeline({
+			title: modal.title,
+			confirmLabel: action.confirmLabel || action.label || "Confirm",
+			tone: action.tone,
+			action: action
+		}).then(function (ok) {
+			if (ok) submitActionDialog(modal, action, table, tr, ctx, null);
+		}).catch(function () { /* already logged by the pipeline; the action does not run */ }); // NOSONAR javascript:S2486 -- the unregistered-renderer rejection is logged where it is raised
+	}
+
 	// NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
 	function openActionDialog(action, table, tr, ctx) {
 		// v1 depth cap (counts dialog-kind layers): a third dialog is a visible refusal inside the current top dialog.
@@ -5763,7 +6144,7 @@
 			// Confirm-only LOCAL path (no form-source URL): unversioned, and never fail-loud on a missing version.
 			// The name read is widened (label, then title, then id) so a ribbon-hosted action - which carries
 			// `title` and no `label` - prompts with its own name rather than its raw id.
-			showActionDialog({ title: action.confirm || actionDisplayName(action) }, action, table, tr, ctx);
+			openConfirmOnlyDialog(action, table, tr, ctx);
 			return;
 		}
 		const rowData = rowDataForTr(ctx, tr);
@@ -6003,7 +6384,11 @@
 		const confirmBtn = document.createElement("button");
 		confirmBtn.type = "button";
 		confirmBtn.className = "juneau-view-dialog-confirm";
-		confirmBtn.textContent = action?.label || "Confirm";
+		confirmBtn.textContent = modal?.confirmLabel || action?.confirmLabel || action?.label || "Confirm";
+		if ((modal?.tone || action?.tone) === "danger") {
+			confirmBtn.classList.add("juneau-view-dialog-confirm-danger");
+			dialog.classList.add("juneau-view-dialog-danger");
+		}
 		actions.appendChild(cancelBtn);
 		actions.appendChild(confirmBtn);
 		dialog.appendChild(actions);
@@ -6110,8 +6495,10 @@
 		// signal to its author today.  (4) A miss in both stays today's fail-closed disabled+marked paint.
 		if (dialogActionIsOpenable(ctx, actionId)) {
 			const target = findRowActionById(ctx?.viewDef, actionId);
-			const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
-			if (failing) disableRowActionControl(control, failing.reason);
+			const rowData = rowDataForTr(ctx, tr);
+			const failing = firstFailingRowActionRule(target, rowData);
+			if (!isRowActionVisible(target, rowData)) control.hidden = true;   // visibleWhen: irrelevant for this row
+			else if (failing) disableRowActionControl(control, failing.reason);
 		} else if (rowActionIdExists(ctx, actionId) || ! childActionById(childCatalog, actionId)) {
 			control.disabled = true;
 			control.setAttribute("aria-disabled", "true");
@@ -6228,7 +6615,9 @@
 		let target = null;
 		const catalog = (ctx?.viewDef?.rowActions) || [];
 		for (const c of catalog) if (c?.id === actionId) { target = c; break; }
-		const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
+		const rowData = rowDataForTr(ctx, tr);
+		if (!isRowActionVisible(target, rowData)) { renderDialogActionRefusal(actionId); return; }
+		const failing = firstFailingRowActionRule(target, rowData);
 		if (failing) { renderDialogActionRefusal(actionId, failing.reason); return; }
 		openActionDialog(target, table, tr, ctx);
 	}
@@ -6553,6 +6942,115 @@
 		return out;
 	}
 
+	/** Hands a registered dialog chrome the header/footer parts of a freshly built dialog; a throwing chrome is logged, not fatal. */
+	function applyDialogChrome(modal, ui) {
+		const parts = {
+			dialog: ui.dialog,
+			header: ui.dialog.querySelector(".juneau-view-dialog-header"),
+			actions: ui.dialog.querySelector(".juneau-view-dialog-actions"),
+			confirmBtn: ui.confirmBtn,
+			cancelBtn: ui.cancelBtn,
+			dismissBtn: ui.dismissBtn
+		};
+		try { dialogChromes[modal.chrome](parts, modal); }
+		catch (e) { console.error("[juneau-views] " + fmt("dialog chrome '%s' threw: %s", modal.chrome, e?.message ?? e)); } // NOSONAR javascript:S2486 -- a throwing chrome must not stop the dialog opening
+	}
+
+	/** Paints one wizard step's display fields into `host` (textContent only). */
+	function paintStepFields(host, step) {
+		const dl = document.createElement("dl");
+		dl.className = "juneau-view-dialog-fields";
+		(step.fields || []).forEach(function (f) {
+			const dt = document.createElement("dt");
+			dt.textContent = (f?.label != null) ? String(f.label) : "";
+			const dd = document.createElement("dd");
+			buildModalFieldValueNode(dd, f);
+			dl.appendChild(dt);
+			dl.appendChild(dd);
+		});
+		host.appendChild(dl);
+	}
+
+	/**
+	 * Turns the dialog `ui` into the scripting handle (`dialogs.open`'s return value) and returns the private state the
+	 * confirm wiring needs.  Steps (`modal.steps`) render one at a time with Back and Next; the last step's button is
+	 * the dialog's own confirm.  A script drives dynamic flows through `setStep` / `setConfirmLabel` / `onConfirm`.
+	 */
+	// NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
+	function wireDialogHandle(ui, modal, action, table, tr, ctx) { // NOSONAR javascript:S107 -- dialog-build context threaded through unchanged
+		const steps = Array.isArray(modal?.steps) ? modal.steps.filter(function (st) { return st && st.id != null; }) : [];
+		const finalLabel = ui.confirmBtn.textContent;
+		const state = { confirmHandler: null, cancelHandler: null, submitted: false, proceed: null };
+		let stepIndex = steps.length ? 0 : -1;
+		let freeStep = null;
+		let closedResolve = null;
+		const closed = new Promise(function (resolve) { closedResolve = resolve; });
+		let backBtn = null;
+		if (steps.length) {
+			backBtn = document.createElement("button");
+			backBtn.type = "button";
+			backBtn.className = "juneau-view-dialog-back";
+			backBtn.textContent = "Back";
+			backBtn.hidden = true;
+			ui.cancelBtn.parentNode.insertBefore(backBtn, ui.confirmBtn);
+			backBtn.addEventListener("click", function () { if (stepIndex > 0) { stepIndex--; renderStep(); } });
+		}
+		let bodyEl = null;
+
+		function body() {
+			if (!bodyEl) {
+				bodyEl = document.createElement("div");
+				bodyEl.className = "juneau-view-dialog-body";
+				ui.dialog.insertBefore(bodyEl, ui.cancelBtn.parentNode);
+			}
+			return bodyEl;
+		}
+
+		function renderStep() {
+			const st = steps[stepIndex];
+			const host = body();
+			while (host.firstChild) host.removeChild(host.firstChild);
+			paintStepFields(host, st);
+			const titleEl = ui.dialog.querySelector(".juneau-view-dialog-title");
+			if (titleEl && st.title != null) titleEl.textContent = String(st.title);
+			const last = stepIndex === steps.length - 1;
+			ui.confirmBtn.textContent = st.confirmLabel != null ? String(st.confirmLabel) : (last ? finalLabel : "Next");
+			backBtn.hidden = stepIndex === 0;
+		}
+
+		if (steps.length) renderStep();
+
+		state.nextStep = function () {
+			if (stepIndex < 0 || stepIndex >= steps.length - 1) return null;
+			return function () { stepIndex++; renderStep(); };
+		};
+		state.dismissed = function () {
+			closedResolve(state.submitted ? "submitted" : "cancelled");
+			if (!state.submitted && state.cancelHandler) state.cancelHandler();
+		};
+
+		ui.fields = function () { return collectDialogFormFields(ui.dialog); };
+		ui.body = body;
+		ui.step = function () { return stepIndex >= 0 ? steps[stepIndex].id : freeStep; };
+		ui.setStep = function (id) {
+			const i = steps.findIndex(function (st) { return st.id === id; });
+			if (i >= 0) { stepIndex = i; renderStep(); return; }
+			if (steps.length) { console.error("[juneau-views] " + fmt("dialog step '%s' is not defined", id)); return; }
+			freeStep = id;
+		};
+		ui.setConfirmLabel = function (label) { ui.confirmBtn.textContent = label == null ? "" : String(label); };
+		ui.setCancelLabel = function (label) { ui.cancelBtn.textContent = label == null ? "" : String(label); };
+		ui.onConfirm = function (fn) { state.confirmHandler = typeof fn === "function" ? fn : null; };
+		ui.onCancel = function (fn) { state.cancelHandler = typeof fn === "function" ? fn : null; };
+		ui.submit = function (extra) {
+			state.proceed(extra?.fields ?? collectDialogFormFields(ui.dialog));
+			return Promise.resolve({});
+		};
+		ui.close = function () { popLayer(ui.backdrop); };   // pops this dialog's layer (and anything stacked above it), never a sibling below
+		ui.closed = closed;
+		return state;
+	}
+
 	/**
 	 * Shows a dialog overlay for an action as a modal layer on the shared {@code popupLayerStack} and wires its confirm
 	 * (validate -> submit) / cancel (dismiss) buttons.  The backdrop is pushed as a {@code kind:"dialog"} focus-trapping
@@ -6563,15 +7061,19 @@
 	 */
 	// NOSONAR javascript:S3776 -- encodes a views/widgets state machine; complexity is inherent.
 	function showActionDialog(modal, action, table, tr, ctx) {
+		if (modal?.chrome && !dialogChromes[modal.chrome]) {
+			console.error("[juneau-views] E-JS-72: " + fmt("dialog chrome '%s' is not registered", modal.chrome));
+			return null;
+		}
 		if (dialogLayerCount() >= MAX_DIALOG_DEPTH) { renderDialogDepthRefusal(); return null; }
 		const seq = ++dialogSeq;
 		const ui = buildDialogOverlay(modal, action, table, tr, ctx, seq);
+		if (modal?.chrome) applyDialogChrome(modal, ui);
+		const state = wireDialogHandle(ui, modal, action, table, tr, ctx);
 		function close() { popLayer(ui.backdrop); }
 		ui.cancelBtn.addEventListener("click", close);
 		if (ui.dismissBtn) ui.dismissBtn.addEventListener("click", close);
-		ui.confirmBtn.addEventListener("click", function () {
-			if (! validateDialogForm(ui.dialog, true)) return;   // fail-loud client validation before the submit
-			const fields = collectDialogFormFields(ui.dialog);
+		function proceed(fields) {
 			// A modal that opted in to hosting its own result KEEPS ITS LAYER and registers a hold instead of
 			// closing; the settle then decides that layer's fate from the outcome.  Registration happens BEFORE the
 			// submit because the submit's client-refusal terminal can settle synchronously.  The hold is passed
@@ -6579,10 +7081,23 @@
 			// J0512's targetId precedence and its tripwire keep covering the only submit path there is.
 			// No ctx means no hold registry AND no dialog stack to check liveness against, so it closes as today -
 			// the fail-safe direction: today's behavior, not a dialog nothing can ever settle.
+			state.submitted = true;
 			if (modal?.keepOpenOnSubmit && ctx) beginDialogResultHold(ui, action, modal, ctx);
 			else close();
 			submitActionDialog(modal, action, table, tr, ctx, fields);
+		}
+		ui.confirmBtn.addEventListener("click", function () {
+			if (! validateDialogForm(ui.dialog, true)) return;   // fail-loud client validation before the submit
+			const fields = collectDialogFormFields(ui.dialog);
+			const advance = state.nextStep();   // null when there is nothing to advance to (no steps, or on the last)
+			function go() { if (advance) advance(); else proceed(fields); }
+			if (! state.confirmHandler) { go(); return; }
+			// onConfirm decides: exactly `true` (or a Promise of it) continues, anything else keeps the dialog as-is.
+			const verdict = state.confirmHandler(fields);
+			if (typeof verdict?.then === "function") verdict.then(function (ok) { if (ok === true) go(); });
+			else if (verdict === true) go();
 		});
+		state.proceed = proceed;
 		if (ctx) {
 			if (! ctx._dialogStack) ctx._dialogStack = [];
 			ctx._dialogStack.push(ui.backdrop);
@@ -6598,8 +7113,10 @@
 					ctx._actionDialog = ctx._dialogStack.at(-1) ?? null;
 				}
 				notifyPollPausedChange(ctx);
+				state.dismissed();
 			}
 		});
+		if ((modal?.tone || action?.tone) === "danger" && typeof ui.cancelBtn.focus === "function") ui.cancelBtn.focus();
 		notifyPollPausedChange(ctx);
 		// Enhance-on-insert for a dialog-hosted bar slot: MUST run after pushLayer has portalled the dialog into the
 		// document (initAll() scans document-wide), reusing the SAME chrome entry + shared wired marker the
@@ -7126,6 +7643,7 @@
 		menu.dataset.testid = "action-menu";
 		const rowData = rowDataForTr(ctx, tr);
 		(viewDef.rowActions || []).forEach(function (action) {
+			if (!isRowActionVisible(action, rowData)) return;   // hidden for this row by visibleWhen
 			const li = document.createElement("li");
 			li.setAttribute("role", "none");
 			const item = document.createElement("button");
@@ -7140,7 +7658,8 @@
 			item.addEventListener("click", function () {
 				// Fresh, fail-closed re-check: a gated-and-failing action must never fire, even if its disabled
 				// state was somehow bypassed or the menu is stale.
-				if (firstFailingRowActionRule(action, rowDataForTr(ctx, tr))) return;
+				const freshRow = rowDataForTr(ctx, tr);
+				if (!isRowActionVisible(action, freshRow) || firstFailingRowActionRule(action, freshRow)) return;
 				closeRowActionMenus(table);
 				// A present=dialog action opens the modal (confirmation + optional form) before its submit;
 				// everything else is the direct fail-closed submit.
@@ -7194,7 +7713,9 @@
 		const tr = pill.closest ? pill.closest("tr") : null;
 		if (!tr) return;
 		if (tr.dataset?.juneauInflight) return;   // in-flight guard (no double submit)
-		if (firstFailingRowActionRule(action, rowDataForTr(ctx, tr))) return;
+		const rowData = rowDataForTr(ctx, tr);
+		if (!isRowActionVisible(action, rowData)) return;   // a hidden action never fires, even if the pill was un-hidden
+		if (firstFailingRowActionRule(action, rowData)) return;
 		if (isDialogAction(action)) openActionDialog(action, table, tr, ctx);
 		else submitRowAction(action, table, tr, ctx);
 	}
@@ -7408,6 +7929,7 @@
 			const field = ctx.selectionState?.rowIdField ?? null;
 			stampRowId(rowEl, rowData, field);
 			applyRowActionPillGates(rowEl, rowData, viewDef);
+			applyInlineEditGates(rowEl, rowData, viewDef);
 		};
 		opts.order = resolveOrder(viewDef, opts.columns);
 	}
@@ -7737,7 +8259,78 @@
 					sort = { column: String(def.data), dir: String(dir) };
 			}
 		}
-		return { tab: readLiveShareTab(), filters: filters, sort: sort };
+		return { tab: readLiveShareTab(), filters: filters, sort: sort, ext: Object.assign({}, ctx?.urlExt) };
+	}
+
+	/**
+	 * Registers the built-in directives a view's {@code urlState} config names and seeds {@code ctx.urlExt} from the
+	 * address bar, so the FIRST data request already carries the deep-linked params. Primary table only; idempotent.
+	 */
+	function prepareUrlStateDirectives(table, viewDef, ctx) {
+		const U = NS.urlState;
+		if (!U || typeof U.registerDirective !== "function" || ctx._urlDirectivesPrepared) return;
+		if (!viewDef?.urlState || !isShareablePrimaryTable(table, viewDef)) return;
+		ctx._urlDirectivesPrepared = true;
+		for (const name of Object.keys(viewDef.urlState)) {
+			const factory = U.builtins?.[name];
+			if (typeof factory !== "function" || U.hasDirective(name)) continue;
+			U.registerDirective(name, factory(viewDef.urlState[name] || {}));
+		}
+		const fromUrl = typeof U.readFromSearch === "function" ? U.readFromSearch(window.location.search) : null;
+		ctx.urlExt = Object.assign({}, fromUrl?.ext);
+		announceUrlStateStatus(table, ctx);
+	}
+
+	/** Merges every registered directive's {@code toParams} over the live {@code ctx.urlExt}. */
+	function urlStateRequestParams(ctx) {
+		const out = {};
+		const U = NS.urlState;
+		for (const name of Object.keys(ctx?.urlExt || {})) {
+			const codec = U?.directiveCodec?.(name);
+			if (typeof codec?.toParams === "function") Object.assign(out, codec.toParams(ctx.urlExt[name]));
+		}
+		return out;
+	}
+
+	/** Announces a directive's {@code statusMessage} (e.g. the {@code ids} cap) through the table's live region. */
+	function announceUrlStateStatus(table, ctx) {
+		const U = NS.urlState;
+		for (const name of Object.keys(ctx?.urlExt || {})) {
+			const msg = U?.directiveCodec?.(name)?.statusMessage?.(ctx.urlExt[name]);
+			if (msg) announce(table, msg);
+		}
+	}
+
+	/** Runs each registered directive's {@code apply(value, table)} against the current {@code ctx.urlExt}. */
+	function applyUrlStateDirectives(table, ctx) {
+		const U = NS.urlState;
+		for (const name of Object.keys(ctx?.urlExt || {})) {
+			const codec = U?.directiveCodec?.(name);
+			if (typeof codec?.apply !== "function") continue;
+			try { codec.apply(ctx.urlExt[name], table); } catch (e) { error("[juneau-views] E-JS-68: url-state directive '" + name + "' apply failed: " + (e?.message || e)); }
+		}
+	}
+
+	/**
+	 * Sets (or, with a null/empty value, clears) one registered {@code ?state=} directive on a live table: updates the
+	 * address bar (replaceState, honoring clean-address) and refetches with the new request params.
+	 *
+	 * @example
+	 *   NS.urlState.setDirective(tableEl, "window", { start: "2026-10-01T00:00:00Z", end: "2026-10-02T00:00:00Z" });
+	 */
+	function setUrlStateDirective(tableEl, name, value) {
+		const ctx = tableEl?.__juneauCtx;
+		const U = NS.urlState;
+		if (!ctx || !U?.hasDirective?.(name)) {
+			error("[juneau-views] E-JS-67: url-state directive '" + name + "' is not registered for this table");
+			return false;
+		}
+		ctx.urlExt = ctx.urlExt || {};
+		if (value == null) delete ctx.urlExt[name]; else ctx.urlExt[name] = value;
+		syncShareableUrlState(tableEl, ctx);
+		announceUrlStateStatus(tableEl, ctx);
+		if (ctx.dataTable) reloadTableData(ctx.dataTable);
+		return true;
 	}
 
 	/**
@@ -7930,6 +8523,17 @@
 		}
 		// Reflect the post-open state once (skipped under clean-address).
 		syncShareableUrlState(table, ctx);
+		applyUrlStateDirectives(table, ctx);
+		// Back / Forward: re-read the registered directives from the address bar, re-apply them, and refetch.
+		if (U.directiveNames?.().length && typeof window.addEventListener === "function") {
+			window.addEventListener("popstate", function () {
+				const st = U.readFromSearch(window.location.search);
+				ctx.urlExt = Object.assign({}, st?.ext);
+				announceUrlStateStatus(table, ctx);
+				applyUrlStateDirectives(table, ctx);
+				if (ctx.dataTable) reloadTableData(ctx.dataTable);
+			});
+		}
 	}
 
 	/**
@@ -7996,8 +8600,11 @@
 			// buildOptions merges no parent-scope parameter.
 			nestedScope: ctx.nestedScope,
 			// The view table, so a server-mode data request can carry the CSRF token (read per request).
-			table: table
+			table: table,
+			// Request params contributed by registered ?state= directives (window / ids), read at REQUEST time.
+			urlStateParams: function () { return urlStateRequestParams(ctx); }
 		};
+		prepareUrlStateDirectives(table, viewDef, ctx);
 
 		const opts = buildOptions(viewDef, deps);
 		assembleFullColumnArray(opts, viewDef, ctx);
@@ -8250,6 +8857,7 @@
 		if (findRowDetailTemplate(table))
 			initDetailsExpander(table, ctx, viewDef);
 		initCellPopover(table, ctx, viewDef);
+		initInlineEdit(table, ctx, viewDef);
 		if (viewDef.rowActions?.length)
 			initRowActions(table, viewDef, ctx);
 		if (selectionState)
@@ -8399,6 +9007,7 @@
 	function initTableWidgets(table, ctx, viewDef) {
 		if (findRowDetailTemplate(table)) initDetailsExpander(table, ctx, viewDef);
 		initCellPopover(table, ctx, viewDef);
+		initInlineEdit(table, ctx, viewDef);
 		if (viewDef.rowActions?.length) initRowActions(table, viewDef, ctx);
 	}
 
@@ -9243,6 +9852,10 @@
 		mergeMeta: mergeMeta,
 		buildOptions: buildOptions,
 		initAll: initAll,
+		// ?state= directive hook behind NS.urlState.setDirective (the codec module has no live table).
+		setUrlStateDirective: setUrlStateDirective,
+		urlStateRequestParams: urlStateRequestParams,
+		prepareUrlStateDirectives: prepareUrlStateDirectives,
 		// Previously private - exposed so a host (HTML-slot `regions.mount`, or any lazy panel) can init one
 		// specific view's table on demand.  Always returns a thenable; overlapping calls coalesce on the in-flight
 		// promise (data-juneau-init-pending).  Already idempotent (isDataTable guard), so re-entry after the
@@ -9368,6 +9981,8 @@
 		ownRowsWithId: ownRowsWithId,
 		fillCellPopover: fillCellPopover,
 		initCellPopover: initCellPopover,
+		initInlineEdit: initInlineEdit,
+		applyInlineEditGates: applyInlineEditGates,
 		closeCellPopover: closeCellPopover,
 		appendPopoverTrigger: appendPopoverTrigger,
 		// Row actions + fail-closed CSRF submit - exposed for manual verification and the fail-closed canary.
@@ -9496,6 +10111,147 @@
 		renderAsyncStatus: renderAsyncStatus
 	};
 
+	// ==================================================================================================================
+	// SANCTIONED HOOKS - thin wrappers over the internals above.  Every namespace here is frozen; nothing outside one
+	// of these namespaces (or NS.init, which is deprecated) is public API.
+	// ==================================================================================================================
+
+	/** Substitutes each {@code %s} in {@code msg} with the next argument. */
+	function fmt(msg, ...args) {
+		let i = 0;
+		return String(msg).replaceAll("%s", () => String(args[i++]));
+	}
+
+	/**
+	 * Resolves the row context (table, tr, row, viewDef) for any element inside a view table row or its row-detail
+	 * panel.  Walks up via owningViewTable first; when that misses (the element lives inside a portalled row-detail
+	 * panel), falls back to the panel's _juneauParentTr back-reference.
+	 */
+	function hookContextOf(el) {
+		if (!el) return null;
+		let table = owningViewTable(el);
+		let tr = null;
+		if (table) {
+			tr = typeof el.closest === "function" ? el.closest("tbody tr") : null;
+		} else {
+			const panel = typeof el.closest === "function" ? el.closest("[data-juneau-row-detail]") : null;
+			tr = panel?._juneauParentTr || null;
+			table = tr ? owningViewTable(tr) : null;
+		}
+		if (!table || !tr) return null;
+		const ctx = table.__juneauCtx;
+		if (!ctx) return null;
+		return { table: table, tr: tr, row: rowDataForTr(ctx, tr) || {}, viewDef: ctx.viewDef || {} };
+	}
+
+	/** The table-handle shape returned by {@code tables.ready} / {@code tables.handle}. */
+	function hookTableHandleFor(el) {
+		const ctx = el.__juneauCtx;
+		return {
+			element: el,
+			dataTable: ctx.dataTable,
+			viewDef: ctx.viewDef,
+			contextOf: function (tr) { return { table: el, tr: tr, row: rowDataForTr(ctx, tr) || {}, viewDef: ctx.viewDef }; }
+		};
+	}
+
+	/** The element a CSRF lookup should read: the owning view table of {@code el}, else {@code el} itself. */
+	function hookCsrfTable(el) {
+		return el ? (owningViewTable(el) || el) : null;
+	}
+
+	NS.rowActions = Object.freeze({
+		contextOf: hookContextOf,
+		find: function (viewDefOrEl, actionId) {
+			const viewDef = viewDefOrEl?.rowActions ? viewDefOrEl : (hookContextOf(viewDefOrEl) || {}).viewDef;
+			return findRowActionById(viewDef, actionId);
+		},
+		isDialog: isDialogAction,
+		run: function (el, actionId, opts) {
+			const rc = hookContextOf(el);
+			if (!rc) {
+				console.error("[juneau-views] " + fmt("rowActions.run: element is not inside a view table row"));
+				return Promise.reject(new Error("E-JS-74"));
+			}
+			const action = findRowActionById(rc.viewDef, actionId);
+			if (!action) {
+				console.error("[juneau-views] " + fmt("row action '%s' not found on view '%s'", actionId, rc.viewDef.id || ""));
+				return Promise.reject(new Error("E-JS-70"));
+			}
+			const ctx = rc.table.__juneauCtx;
+			if (isDialogAction(action)) return Promise.resolve(openActionDialog(action, rc.table, rc.tr, ctx));
+			return Promise.resolve(submitRowAction(action, rc.table, rc.tr, ctx, opts?.extra || {}));
+		},
+		open: function (action, rc) { return openActionDialog(action, rc.table, rc.tr, rc.table.__juneauCtx); },
+		submit: function (action, rc, extra) { return submitRowAction(action, rc.table, rc.tr, rc.table.__juneauCtx, extra || {}); },
+		submitForOutcome: function (action, rc, extra) { return submitRowActionForOutcome(action, rc.table, rc.tr, rc.table.__juneauCtx, extra || {}); },
+		registerConfirmRenderer: registerConfirmRenderer
+	});
+
+	NS.dialogs = Object.freeze({
+		// opts.action (a row action, or the id of one on the row's view) is what a confirming dialog submits; without it
+		// a confirm has nothing to send and the dialog is a pure prompt.
+		open: function (modal, rc, opts) {
+			const a = opts?.action;
+			const action = typeof a === "string" ? findRowActionById(rc.viewDef, a) : (a ?? null);
+			return showActionDialog(modal, action, rc.table, rc.tr, rc.table.__juneauCtx);
+		},
+		confirm: confirmPipeline,
+		registerChrome: registerDialogChrome,
+		depth: dialogLayerCount
+	});
+
+	NS.probeSelection = Object.freeze({
+		enhance: enhanceProbeGroup,
+		initAll: function (root) {
+			for (const g of ownNodes(root || document, null, "[data-juneau-probe-group]")) enhanceProbeGroup(g);
+		}
+	});
+
+	NS.tables = Object.freeze({
+		columnIndex: function (tableOrDt, dataKey) {
+			const dt = typeof tableOrDt?.row === "function" ? tableOrDt : tableOrDt?.__juneauCtx?.dataTable;
+			return liveDtIndex(dataKey, dt ? dt.columns().header().toArray() : null);
+		},
+		ready: function (el) {
+			return new Promise(function (resolve) {
+				(function poll() {
+					if (el.__juneauCtx?.dataTable) resolve(hookTableHandleFor(el));
+					else setTimeout(poll, 20);
+				})();
+			});
+		},
+		handle: function (el) { return el?.__juneauCtx ? hookTableHandleFor(el) : null; },
+		reload: function (el, opts) {
+			const dt = el?.__juneauCtx?.dataTable;
+			if (dt) dt.ajax.reload(null, opts?.resetPaging === true);
+		}
+	});
+
+	NS.status = Object.freeze({ render: renderAsyncStatus });
+	NS.csrf = Object.freeze({
+		token: function (el) { const t = hookCsrfTable(el); return t ? resolveCsrfToken(t) : null; },
+		headerName: function (el) { const t = hookCsrfTable(el); return t ? resolveCsrfHeaderName(t) : DEFAULT_CSRF_HEADER; },
+		headers: function (el) {
+			const t = hookCsrfTable(el);
+			const token = t ? resolveCsrfToken(t) : null;
+			if (isBlankToken(token)) return {};
+			return { [resolveCsrfHeaderName(t)]: token };
+		}
+	});
+	NS.rules = Object.freeze({
+		test: evaluateRules,
+		facts: pageFacts,
+		/** Tests `rules` against `row` merged with the page facts under `facts` (the row-level evaluation map). */
+		testRow: function (rules, row) { return evaluateRules(rules, rowRuleScope(row)); }
+	});
+	NS.details = Object.freeze({ fillSlots: fillDetailSlots });
+	NS.html = Object.freeze({
+		// NS._render (juneau-renders.js) is a sibling <script>; never assume load order.
+		esc: function (s) { return typeof NS._render?.escHtml === "function" ? NS._render.escHtml(s) : String(s ?? ""); },
+		escAttr: viewEscAttr
+	});
+
 	initCursorTooltip();
 	if (document.readyState === "loading") {
 		document.addEventListener("DOMContentLoaded", initAll);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.java
new file mode 100644
index 0000000000..6e833225cb
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BadgeDef_Test.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
+ *
+ *      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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.junit.jupiter.api.*;
+
+class BadgeDef_Test {
+
+	@Test void a01_minimal() {
+		var b = BadgeDef.create("pending").src("/rest/changes/pending");
+		b.validate();
+		var m = b.toContractMap();
+		assertEquals("pending", m.get("id"));
+		assertEquals("/rest/changes/pending", m.get("src"));
+		assertFalse(m.containsKey("placement"));
+		assertFalse(m.containsKey("tone"));
+	}
+
+	@Test void a02_fullExample_matchesJavadoc() {
+		var b = BadgeDef.create("pending")
+			.src("/rest/changes/pending")
+			.scope("beanType", BadgeDef.ScopeBy.NAV, Map.of("suspensions", List.of("Suspension")))
+			.href("/ui/changes")
+			.stateFilter("beanType")
+			.refreshes("rules-table")
+			.refreshMs(60_000);
+		b.validate();
+		var m = b.toContractMap();
+		assertEquals("/ui/changes", m.get("href"));
+		assertEquals(60_000L, m.get("refreshMs"));
+		assertEquals(List.of("rules-table"), m.get("refreshes"));
+		@SuppressWarnings("unchecked")
+		var scope = (Map<String,Object>) m.get("scope");
+		assertEquals("beanType", scope.get("param"));
+		assertEquals("nav", scope.get("by"));
+		@SuppressWarnings("unchecked")
+		var stateFilter = (Map<String,Object>) m.get("stateFilter");
+		assertEquals("beanType", stateFilter.get("column"));
+	}
+
+	@Test void a03_idRequired() {
+		var e = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create(null));
+		assertTrue(e.getMessage().contains("id must not be null or blank"), e.getMessage());
+		var e2 = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create(" "));
+		assertTrue(e2.getMessage().contains("id must not be null or blank"), e2.getMessage());
+	}
+
+	@Test void a04_e61_srcRequired() {
+		var e = assertThrows(IllegalArgumentException.class, () -> BadgeDef.create("pending").validate());
+		assertEquals("<@badge id='pending'> requires src=.", e.getMessage());
+	}
+
+	@Test void a05_e62_refreshMsFloor() {
+		var b = BadgeDef.create("pending").src("/x").refreshMs(1000);
+		var e = assertThrows(IllegalArgumentException.class, b::validate);
+		assertEquals("'pending' refreshMs='1000' is below the minimum '5000'.", e.getMessage());
+	}
+
+	@Test void a06_refreshMsAtFloorIsFine() {
+		BadgeDef.create("pending").src("/x").refreshMs(5000).validate();
+	}
+
+	@Test void a07_e66_toolbarRequiresTable() {
+		var b = BadgeDef.create("pending").src("/x").placement(BadgeDef.Placement.TOOLBAR);
+		var e = assertThrows(IllegalArgumentException.class, b::validate);
+		assertEquals("<@badge id='pending'> placement='toolbar' requires table=.", e.getMessage());
+	}
+
+	@Test void a08_toolbarWithTableIsFine() {
+		BadgeDef.create("pending").src("/x").placement(BadgeDef.Placement.TOOLBAR).table("rules-table").validate();
+	}
+
+	@Test void a09_r7_scopeByViewRequiresTable_uncoded() {
+		var b = BadgeDef.create("pending").src("/x").scope("beanType", BadgeDef.ScopeBy.VIEW, null);
+		var e = assertThrows(IllegalArgumentException.class, b::validate);
+		assertTrue(e.getMessage().contains("scope.by='view' requires table"), e.getMessage());
+	}
+
+	@Test void a10_scopeByViewWithTableIsFine() {
+		BadgeDef.create("pending").src("/x").table("rules-table")
+			.scope("beanType", BadgeDef.ScopeBy.VIEW, null).validate();
+	}
+
+	@Test void a11_labelToneTooltipMax_roundTrip() {
+		var m = BadgeDef.create("pending").src("/x")
+			.label(Map.of("one", "{total} item", "other", "{total} items"))
+			.tone(BadgeDef.Tone.DANGER)
+			.tooltipMax(5)
+			.toContractMap();
+		@SuppressWarnings("unchecked")
+		var label = (Map<String,Object>) m.get("label");
+		assertEquals("{total} item", label.get("one"));
+		assertEquals("danger", m.get("tone"));
+		assertEquals(5, m.get("tooltipMax"));
+	}
+
+	@Test void a12_visibleWhen_roundTrip() {
+		var m = BadgeDef.create("pending").src("/x")
+			.visibleWhen(VisibilityRule.when("facts.viewer.roles").contains("oncall"))
+			.toContractMap();
+		@SuppressWarnings("unchecked")
+		var vw = (List<Map<String,Object>>) m.get("visibleWhen");
+		assertEquals(1, vw.size());
+		assertEquals("facts.viewer.roles", vw.get(0).get("field"));
+		assertEquals("contains", vw.get(0).get("op"));
+		assertEquals("oncall", vw.get(0).get("value"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.java
new file mode 100644
index 0000000000..14cc4d370b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfirmRenderer_BrowserTest.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
+ *
+ *      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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the confirm pipeline as a user would experience it: a
+ * registered renderer's own buttons decide whether the row action's write is sent, and a danger confirm focuses Cancel first.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  The prober ({@code confirm-renderer.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=ConfirmRenderer_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ConfirmRenderer_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("confirm-renderer.html.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("confirm-renderer.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("confirm-renderer-stdout.json");
+		var stderr = dir.resolve("confirm-renderer-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_runtimeLoadedWithoutScriptErrors() {
+		assertEquals(Boolean.TRUE, report.get("hasRun"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+	}
+
+	@Test void b01_customRendererPaintsAndConfirmSendsOneWrite() {
+		assertEquals("Delete Widget?", report.get("styledText"), () -> report.toString());
+		assertEquals("Delete it", report.get("styledOkLabel"), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("styledVisible"), () -> report.toString());
+		assertEquals(0, ((Number)report.get("fetchesBeforeClick")).intValue(), () -> report.toString());
+		assertEquals(1, ((Number)report.get("confirmFetches")).intValue(), () -> report.toString());
+		assertEquals("DELETE", report.get("confirmMethod"), () -> report.toString());
+		assertEquals("/x/7", report.get("confirmUrl"), () -> report.toString());
+	}
+
+	@Test void b02_cancelSendsNothing() {
+		assertEquals(1, ((Number)report.get("fetchesAfterCancel")).intValue(), () -> report.toString());
+	}
+
+	@Test void c01_dangerConfirmFocusesCancelFirst() {
+		assertEquals(Boolean.TRUE, report.get("dangerCancelFocused"), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("builtInResolved"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java
new file mode 100644
index 0000000000..e9743539c0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleBadges_Test.java
@@ -0,0 +1,121 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * The count-badge runtime {@code juneau-badges.js}: labels, hide-at-zero, error handling, scope, visibility, drain
+ * refresh, tooltip and click navigation.  Lives in this module because it owns the Node harness plumbing and the
+ * DOM shims; runs when node is on PATH.
+ */
+class ConsoleBadges_Test extends TestBase {
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("badges.cjs", ConsoleChromeMixin.BADGES_JS_RESOURCE);
+		assumeTrue(r != null, "node (or badges.cjs) not available - JS layer skipped");
+		return r;
+	}
+
+	@Test void a01_labelsAndAria() {
+		var r = report();
+		assertEquals("3 changes pending", r.get("plainLabel"));
+		assertEquals("1 change pending", r.get("singularLabel"));
+		assertEquals("2 changes pending (1 yours)", r.get("mineLabel"));
+		assertEquals("2 open", r.get("customLabel"));
+		assertEquals("status", r.get("role"));
+		assertEquals("polite", r.get("ariaLive"));
+		assertEquals("jc-header-badges", r.get("inHeaderBadges"));
+	}
+
+	@Test void a02_toneDefaultsToWarningAndDangerPaintsAsError() {
+		var r = report();
+		assertEquals("warning", r.get("defaultTone"));
+		assertEquals("error", r.get("dangerTone"));
+	}
+
+	@Test void a03_zeroHidesAndGrowthPulses() {
+		var r = report();
+		assertEquals(true, r.get("zeroHidden"));
+		assertEquals(true, r.get("reshown"));
+		assertEquals(true, r.get("pulsed"));
+	}
+
+	@Test void a04_forbiddenRemovesBadgeAndStopsPolling() {
+		var r = report();
+		assertEquals(true, r.get("removedOn403"));
+		assertEquals(1, ((Number)r.get("e65Count")).intValue());
+		assertEquals(true, r.get("noRescheduleOn403"));
+		assertEquals(1, ((Number)r.get("fetchCountAfter403")).intValue());
+	}
+
+	@Test void a05_badResponseGoesStaleAndBacksOff() {
+		var r = report();
+		assertEquals(true, r.get("stale"));
+		assertEquals(true, r.get("e66"));
+		assertEquals(true, r.get("e65OnNetworkError"));
+		assertEquals(List.of(10000, 20000), ((List<?>)r.get("backoff")).stream().map(o -> ((Number)o).intValue()).toList());
+	}
+
+	@Test void a06_refreshMsBelowFloorIsClamped() {
+		assertEquals(5000, ((Number)report().get("clampedInterval")).intValue());
+	}
+
+	@Test void a07_visibleWhenGatesMountAndFetch() {
+		var r = report();
+		assertEquals(true, r.get("visibleWhenShown"));
+		assertEquals(true, r.get("visibleWhenHiddenNotMounted"));
+		assertEquals(List.of("/yes"), r.get("visibleWhenFetched"));
+	}
+
+	@Test void a08_scopeResolvesFromNavLeaf() {
+		var r = report();
+		assertEquals("/p?a=b%20c&beanType=Suspension,Hold", r.get("scopedUrl"));
+		assertEquals(true, r.get("unscopedPageNoFetch"));
+		assertEquals(true, r.get("unscopedPageReschedules"));
+	}
+
+	@Test void a09_drainRefreshesLinkedCardsOnlyWhenAnItemLeaves() {
+		var r = report();
+		assertEquals(0, ((Number)r.get("reloadedOnGrowth")).intValue());
+		assertEquals(List.of("rules-table"), r.get("reloadedOnDrain"));
+	}
+
+	@Test void a10_tooltipHonorsCapAndCloses() {
+		var r = report();
+		assertEquals(true, r.get("tooltipOpen"));
+		assertEquals("tooltip", r.get("tooltipRole"));
+		assertEquals(3, ((Number)r.get("tooltipItems")).intValue());
+		assertEquals("and 1 more", r.get("tooltipMore"));
+		assertEquals(true, r.get("describedBy"));
+		assertEquals(true, r.get("escapeCloses"));
+		assertEquals(true, r.get("blurCloses"));
+	}
+
+	@Test void a11_clickNavigation() {
+		var r = report();
+		assertEquals("/ui/changes", r.get("plainNav"));
+		assertEquals("/ui/changes?state=filter(beanType%3D%24eq(Suspension))", r.get("filteredNav"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
index cdf8309ced..51a9165d6b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleCards_Shell_Test.java
@@ -235,8 +235,8 @@ class ConsoleCards_Shell_Test extends TestBase {
 
 	@Test void b02_csrfHeaderOnNonGetOnly() {
 		var calls = list(context("csrf").get("calls"));
-		assertEquals(Map.of("url", "/get", "method", "GET", "headers", Map.of()), calls.get(0));
-		assertEquals(Map.of("url", "/post", "method", "POST", "headers", Map.of("X-My-Csrf", "tok-123")), calls.get(1));
+		assertEquals(Map.of("url", "/get", "method", "GET", "headers", Map.of("Accept", "application/json")), calls.get(0));
+		assertEquals(Map.of("url", "/post", "method", "POST", "headers", Map.of("Accept", "application/json", "X-My-Csrf", "tok-123")), calls.get(1));
 	}
 
 	@Test void b03_errorMessagePrecedence() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java
new file mode 100644
index 0000000000..de0962e429
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConsoleVisibility_Test.java
@@ -0,0 +1,63 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Card-level {@code visibleWhen} in {@code juneau-console.js}: the shell hides a card whose rules do not match the
+ * page's top-level {@code facts}, proven against the same corpus as the Java and views evaluators.  Runs when node is
+ * on PATH.
+ */
+class ConsoleVisibility_Test extends TestBase {
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("console-visibility.cjs", ConsoleChromeMixin.CONSOLE_JS_RESOURCE);
+		assumeTrue(r != null, "node (or console-visibility.cjs) not available - JS layer skipped");
+		return r;
+	}
+
+	@Test void a01_everyCorpusCaseAgreesWithJava() {
+		var cases = (List<?>)report().get("cases");
+		assertFalse(cases.isEmpty());
+		for (var o : cases) {
+			var m = (Map<?,?>)o;
+			assertEquals(m.get("expected"), m.get("actual"), String.valueOf(m.get("name")));
+		}
+	}
+
+	@Test void a02_noFactsFailsClosedExceptForCardsWithNoRule() {
+		assertEquals(List.of("plain"), report().get("noFactsMounted"));
+	}
+
+	@Test void a03_singleRuleObjectIsAListOfOne() {
+		assertEquals(List.of("single"), report().get("singleObjectMounted"));
+	}
+
+	@Test void a04_unknownOpHidesTheCardAndLogs() {
+		var r = report();
+		assertEquals(List.of(), r.get("unknownOpMounted"));
+		assertEquals(true, r.get("unknownOpLogged"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java
new file mode 100644
index 0000000000..2fb0fe41f0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DialogWizard_BrowserTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and drives a two-step "Review changes" then "Apply" dialog through
+ * the {@code DialogHandle}: the visible button text changes, and exactly one write is sent, at the end.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  The prober ({@code dialog-wizard.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=DialogWizard_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class DialogWizard_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("dialog-wizard.html.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("dialog-wizard.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("dialog-wizard-stdout.json");
+		var stderr = dir.resolve("dialog-wizard-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_runtimeLoadedWithoutScriptErrors() {
+		assertEquals(Boolean.TRUE, report.get("hasOpen"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+	}
+
+	@Test void b01_firstStepIsRelabeledAndNothingIsSentOnNext() {
+		assertEquals("Review changes", report.get("firstLabel"), () -> report.toString());
+		assertEquals("edit", report.get("firstStep"), () -> report.toString());
+		assertEquals("review", report.get("reviewStep"), () -> report.toString());
+		assertEquals("Apply 1 change", report.get("reviewLabel"), () -> report.toString());
+		assertEquals("Review", report.get("reviewTitle"), () -> report.toString());
+		assertEquals(0, ((Number)report.get("fetchesAtReview")).intValue(), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("openAtReview"), () -> report.toString());
+	}
+
+	@Test void b02_finalConfirmSendsExactlyOneWrite() {
+		assertEquals("submitted", report.get("closedResult"), () -> report.toString());
+		assertEquals(1, ((Number)report.get("fetchesAtEnd")).intValue(), () -> report.toString());
+		assertEquals("POST", report.get("method"), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("dialogGone"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java
new file mode 100644
index 0000000000..739370a5fc
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ExportLoud_BrowserTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the ribbon "export loud" contract: with no JSZip
+ * loaded, a hard-listed {@code excel} button renders visibly disabled with a tip naming JSZip, while {@code copy}
+ * stays enabled and fires.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  The prober ({@code export-loud.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=ExportLoud_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ExportLoud_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("export-loud.html.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.RIBBON_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("export-loud.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("export-loud-stdout.json");
+		var stderr = dir.resolve("export-loud-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_runtimeLoadedWithoutScriptErrors() {
+		assertEquals(Boolean.TRUE, report.get("hasBuild"), () -> report.toString());
+		// The missing library is reported loudly (E-JS-69) on purpose; anything else logged as an error is a real failure.
+		var failures = ((List<?>)report.get("jsFailures")).stream().map(String::valueOf).toList();
+		assertEquals(List.of(), failures.stream().filter(f -> ! f.contains("E-JS-69")).toList(), () -> report.toString());
+		assertEquals(1, failures.stream().filter(f -> f.contains("E-JS-69") && f.contains("JSZip")).count(), () -> report.toString());
+	}
+
+	@Test void b01_missingLibraryRendersADisabledVisibleButtonWithATip() {
+		var buttons = (List<?>)report.get("buttons");
+		assertEquals(2, buttons.size(), () -> report.toString());
+		// Declared order is kept: copy first, then the disabled excel.
+		assertEquals(Boolean.FALSE, ((Map<?,?>)buttons.get(0)).get("disabled"), () -> report.toString());
+		assertEquals(Boolean.TRUE, ((Map<?,?>)buttons.get(1)).get("disabled"), () -> report.toString());
+		var excel = buttons.stream().map(b -> (Map<?,?>)b).filter(b -> String.valueOf(b.get("tip")).contains("JSZip")).findFirst().orElseThrow();
+		var copy = buttons.stream().map(b -> (Map<?,?>)b).filter(b -> b != excel).findFirst().orElseThrow();
+		assertEquals(Boolean.FALSE, copy.get("disabled"), () -> report.toString());
+		assertEquals(Boolean.TRUE, excel.get("disabled"), () -> report.toString());
+		assertEquals(Boolean.TRUE, excel.get("visible"), () -> report.toString());
+		assertTrue(((String)excel.get("tip")).contains("JSZip"), () -> report.toString());
+	}
+
+	@Test void b02_onlyTheEnabledButtonFires() {
+		assertEquals(List.of("copy"), report.get("triggered"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
index 6db4c8cfa4..85b4c25ba2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/FileRunViewSource_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
 import java.util.concurrent.atomic.*;
 
 import org.apache.juneau.*;
+import org.apache.juneau.rest.server.runreport.*;
 import org.apache.juneau.rest.server.views.RunViewSource.*;
 import org.junit.jupiter.api.*;
 import org.junit.jupiter.api.io.*;
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java
new file mode 100644
index 0000000000..f0cc81a8c2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/NodeHarness.java
@@ -0,0 +1,133 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Generic Node plumbing for the plain-Node behavioral harnesses under {@code src/test/js}.
+ *
+ * <p>
+ * Each harness is run as {@code node <harness> <asset-file>...}, where every asset is a classpath resource copied
+ * to a temp file, and must print exactly one JSON object to stdout.  Reports are cached per harness-and-assets.
+ * A machine without Node (or without the harness on any known path) yields {@code null}, which callers turn into a
+ * skip.
+ */
+final class NodeHarness {
+
+	private static final Map<String,Map<?,?>> CACHE = new ConcurrentHashMap<>();
+	private static final Set<String> UNAVAILABLE = ConcurrentHashMap.newKeySet();
+
+	private NodeHarness() {}
+
+	/**
+	 * Runs {@code harnessName} against the given classpath resources (resolved relative to {@link ViewsMixin}) and
+	 * returns its parsed report, or {@code null} when it cannot run on this machine.
+	 */
+	static Map<?,?> report(String harnessName, String...resources) {
+		var key = harnessName + Arrays.toString(resources);
+		if (UNAVAILABLE.contains(key))
+			return null;
+		var cached = CACHE.get(key);
+		if (cached != null)
+			return cached;
+		try {
+			var harness = nodeAvailable() ? locate(harnessName) : null;
+			if (harness == null) {
+				UNAVAILABLE.add(key);
+				return null;
+			}
+			var report = Json.to(run(harness, harnessName, resources), Map.class);
+			CACHE.put(key, report);
+			return report;
+		} catch (Exception e) {
+			throw new AssertionError("could not run " + harnessName + ": " + e, e);
+		}
+	}
+
+	private static String run(Path harness, String harnessName, String[] resources) throws Exception {
+		var temps = new ArrayList<Path>();
+		var stdout = Files.createTempFile("node-harness-stdout-", ".json");
+		var stderr = Files.createTempFile("node-harness-stderr-", ".txt");
+		try {
+			var args = new ArrayList<String>(List.of("node", harness.toString()));
+			for (var r : resources) {
+				var f = Files.createTempFile("node-harness-asset-", ".js");
+				temps.add(f);
+				try (var in = ViewsMixin.class.getResourceAsStream(r)) {
+					assertNotNull(in, () -> "missing classpath resource: " + r);
+					Files.writeString(f, new String(in.readAllBytes(), UTF_8), UTF_8);
+				}
+				args.add(f.toString());
+			}
+			var p = new ProcessBuilder(args).redirectOutput(stdout.toFile()).redirectError(stderr.toFile()).start();
+			if (!p.waitFor(60, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				fail(harnessName + " did not finish within 60s; stderr:\n" + quietRead(stderr));
+			}
+			if (p.exitValue() != 0)
+				fail(harnessName + " exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr) + "\nstdout:\n" + quietRead(stdout));
+			return Files.readString(stdout, UTF_8);
+		} finally {
+			for (var f : temps)
+				Files.deleteIfExists(f);
+			Files.deleteIfExists(stdout);
+			Files.deleteIfExists(stderr);
+		}
+	}
+
+	private static boolean nodeAvailable() {
+		try {
+			var p = new ProcessBuilder("node", "--version").redirectErrorStream(true).start();
+			if (!p.waitFor(5, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				return false;
+			}
+			return p.exitValue() == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	private static Path locate(String harnessName) {
+		var basedir = System.getProperty("basedir");
+		if (basedir != null) {
+			var p = Path.of(basedir, "src/test/js/" + harnessName);
+			if (Files.isRegularFile(p))
+				return p.toAbsolutePath().normalize();
+		}
+		for (var rel : List.of("src/test/js/" + harnessName, "juneau-rest/juneau-rest-server-views/src/test/js/" + harnessName)) {
+			var p = Path.of(rel);
+			if (Files.isRegularFile(p))
+				return p.toAbsolutePath().normalize();
+		}
+		return null;
+	}
+
+	private static String quietRead(Path p) {
+		try { return Files.readString(p, UTF_8); }
+		catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java
new file mode 100644
index 0000000000..0b7fdf6cab
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PendingBadge_BrowserTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.console.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served {@code juneau-badges.js} (and {@code chrome.css}) in headless Chromium: a count badge renders
+ * visibly in the header with its label, hovering lists the items, Escape closes the list, clicking navigates, and a
+ * 403 removes the badge.  Lives in this module for the same reason as {@code ConsoleBadges_Test}.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  The prober ({@code badges.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=PendingBadge_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class PendingBadge_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("badges.html.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><style>\n"
+			+ resource("/org/apache/juneau/console/chrome.css")
+			+ "\n</style></head><body><div class=\"jc-header-actions\"></div>\n<script>\n"
+			+ resource(ConsoleChromeMixin.BADGES_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("pending-badge.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("pending-badge-stdout.json");
+		var stderr = dir.resolve("pending-badge-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_scriptLoadsWithoutErrors() {
+		assertEquals(Boolean.TRUE, report.get("hasMount"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+	}
+
+	@Test void b01_badgeRendersVisiblyInTheHeader() {
+		assertEquals("3 changes pending (1 yours)", report.get("label"), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("visible"), () -> report.toString());
+		assertEquals("jc-header-badges", report.get("hostClass"), () -> report.toString());
+	}
+
+	@Test void b02_hoverListsItemsAndEscapeCloses() {
+		assertEquals(2, ((Number)report.get("popoverItems")).intValue(), () -> report.toString());
+		assertEquals(Boolean.TRUE, report.get("popoverClosedOnEscape"), () -> report.toString());
+	}
+
+	@Test void b03_clickNavigates() {
+		assertEquals("#changes", report.get("hash"), () -> report.toString());
+	}
+
+	@Test void b04_forbiddenRemovesTheBadge() {
+		assertEquals(Boolean.TRUE, report.get("deniedRemoved"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java
new file mode 100644
index 0000000000..569b4e1318
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RelatedList_BrowserTest.java
@@ -0,0 +1,324 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.beanquery.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.datatables.*;
+import org.apache.juneau.rest.server.datatables.adapter.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+import com.sun.net.httpserver.*;
+
+/**
+ * A nested related list (a {@code ViewTable} in a row's detail panel) in a real browser against a real JDK
+ * {@link HttpServer}: it pages, sorts and runs a row action, each as a request the server actually received.
+ *
+ * <p>
+ * Row 1 owns 30 events, so the nested table has a second page.  The prober pages forward, sorts the {@code what}
+ * column descending, and runs the {@code ack} row action from the nested row's menu; the server records each
+ * request and the assertions read those records, not just the DOM.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does: {@code mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test}.
+ */
+@EnabledIfSystemProperty(named=RelatedList_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RelatedList_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	/** A root-table row; a public static class with getters so the bean-query context infers its columns. */
+	public static class Row {
+		private final int id;
+		private final String name;
+
+		Row(int id, String name) {
+			this.id = id;
+			this.name = name;
+		}
+
+		public int getId() { return id; }
+		public String getName() { return name; }
+	}
+
+	/** A nested-table row, scoped to its parent root row by {@code parentId}. */
+	public static class Event {
+		private final int parentId;
+		private final String what;
+
+		Event(int parentId, String what) {
+			this.parentId = parentId;
+			this.what = what;
+		}
+
+		public int getParentId() { return parentId; }
+		public String getWhat() { return what; }
+	}
+
+	private static List<Row> rootRows() {
+		return List.of(new Row(1, "Row 01"), new Row(2, "Row 02"));
+	}
+
+	private static List<Event> allEvents() {
+		var l = new ArrayList<Event>();
+		for (var i = 1; i <= 30; i++)
+			l.add(new Event(1, String.format("evt-%02d", i)));
+		l.add(new Event(2, "detail for 2"));
+		return l;
+	}
+
+	/** Every {@code /nested} request's raw query string, in arrival order; proves the scope param is re-derived per expand. */
+	private final List<String> nestedQueries = new CopyOnWriteArrayList<>();
+
+	/** Every {@code /nested} request body (the DataTables paging/ordering envelope), in arrival order. */
+	private final List<String> nestedBodies = new CopyOnWriteArrayList<>();
+
+	/** Every {@code /ack} request: method, CSRF header and body. */
+	private final List<String> acks = new CopyOnWriteArrayList<>();
+
+	private HttpServer server;
+	private int port;
+
+	@BeforeEach
+	void startServer() throws IOException {
+		server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+		port = server.getAddress().getPort();
+		var rootContext = InMemoryBeanQueryContext.create(Row.class).columns("id", "name").build();
+		var eventContext = InMemoryBeanQueryContext.create(Event.class).columns("parentId", "what").build();
+
+		server.createContext("/query", ex -> {
+			try {
+				if (neq(ex.getRequestMethod(), "POST")) {
+					ex.sendResponseHeaders(405, -1);
+					return;
+				}
+				String body;
+				try (var in = ex.getRequestBody()) {
+					body = new String(in.readAllBytes(), UTF_8);
+				}
+				String json;
+				try (var s = rootContext.getSession(rootRows())) {
+					json = Json.of(DataTablesQuery.run(Json.to(body, DataTablesRequest.class), s));
+				}
+				writeJson(ex, json);
+			} catch (Exception e) {
+				sendFailure(ex, e);
+			}
+		});
+		// A distinct "/query/" context (longest-prefix match) serves the row-detail GET envelope at /query/{id};
+		// an empty-fields 200 is all expandDetailRow (juneau-views.js) requires to proceed to the nested table.
+		server.createContext("/query/", ex -> writeJson(ex, "{\"contractVersion\":\"1\",\"fields\":{}}"));
+		server.createContext("/nested", ex -> {
+			try {
+				if (neq(ex.getRequestMethod(), "POST")) {
+					ex.sendResponseHeaders(405, -1);
+					return;
+				}
+				var query = ex.getRequestURI().getRawQuery();
+				nestedQueries.add(String.valueOf(query));
+				var parentId = queryParam(query, "parentId");
+				var scoped = allEvents().stream().filter(e -> String.valueOf(e.getParentId()).equals(parentId)).toList();
+				String body;
+				try (var in = ex.getRequestBody()) {
+					body = new String(in.readAllBytes(), UTF_8);
+				}
+				nestedBodies.add(body);
+				String json;
+				try (var s = eventContext.getSession(scoped)) {
+					json = Json.of(DataTablesQuery.run(Json.to(body, DataTablesRequest.class), s));
+				}
+				writeJson(ex, json);
+			} catch (Exception e) {
+				sendFailure(ex, e);
+			}
+		});
+		server.createContext("/ack/", ex -> {
+			try (var in = ex.getRequestBody()) {
+				acks.add(ex.getRequestMethod() + " " + ex.getRequestURI().getPath() + " csrf="
+					+ ex.getRequestHeaders().getFirst("X-CSRF-Token") + " body=" + new String(in.readAllBytes(), UTF_8));
+			}
+			writeJson(ex, "{\"outcome\":\"success\",\"message\":\"Acknowledged\"}");
+		});
+		server.createContext("/", ex -> {
+			var bytes = fixturePage().getBytes(UTF_8);
+			ex.getResponseHeaders().add("Content-Type", "text/html;charset=utf-8");
+			ex.sendResponseHeaders(200, bytes.length);
+			try (var os = ex.getResponseBody()) {
+				os.write(bytes);
+			}
+		});
+		server.start();
+	}
+
+	@AfterEach
+	void stopServer() {
+		server.stop(0);
+	}
+
+	private static String queryParam(String query, String name) {
+		if (query == null)
+			return null;
+		for (var pair : query.split("&")) {
+			var i = pair.indexOf('=');
+			if (i >= 0 && URLDecoder.decode(pair.substring(0, i), UTF_8).equals(name))
+				return URLDecoder.decode(pair.substring(i + 1), UTF_8);
+		}
+		return null;
+	}
+
+	/**
+	 * Answers a handler failure with a 500 carrying the stack trace, instead of throwing (which makes the JDK server
+	 * drop the connection with no response) - so the prober's network diagnostics show why a draw failed.
+	 */
+	private static void sendFailure(HttpExchange ex, Exception e) throws IOException {
+		var sw = new StringWriter();
+		e.printStackTrace(new PrintWriter(sw));
+		System.err.println(sw);
+		var bytes = sw.toString().getBytes(UTF_8);
+		ex.getResponseHeaders().add("Content-Type", "text/plain;charset=utf-8");
+		ex.sendResponseHeaders(500, bytes.length);
+		try (var os = ex.getResponseBody()) {
+			os.write(bytes);
+		}
+	}
+
+	private static void writeJson(HttpExchange ex, String json) throws IOException {
+		var bytes = json.getBytes(UTF_8);
+		ex.getResponseHeaders().add("Content-Type", "application/json");
+		ex.sendResponseHeaders(200, bytes.length);
+		try (var os = ex.getResponseBody()) {
+			os.write(bytes);
+		}
+	}
+
+	private static String resource(Class<?> anchor, String path) throws IOException {
+		try (var in = anchor.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path + " (relative to " + anchor.getName() + ")");
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	private static String fixturePage() throws IOException {
+		var nodeModules = Path.of(requiredProperty("juneau.jsTests.dir")).resolve("node_modules");
+		var jquery = Files.readString(nodeModules.resolve("jquery/dist/jquery.min.js"));
+		var dataTablesJs = Files.readString(nodeModules.resolve("datatables.net/js/dataTables.min.js"));
+		var datatablesGlue = resource(DataTablesMixin.class, "juneau-datatables.js");
+		var viewsJs = resource(ViewsMixin.class, ViewsMixin.VIEWS_JS_RESOURCE);
+
+		var rootMeta = "{\"contractVersion\":\"5\",\"id\":\"root\",\"dataMode\":\"server\",\"dataUrl\":\"/query\","
+			+ "\"columns\":[{\"data\":\"id\",\"title\":\"ID\"},{\"data\":\"name\",\"title\":\"Name\"}]}";
+		var nestedMeta = "{\"contractVersion\":\"5\",\"id\":\"events\",\"dataMode\":\"server\",\"dataUrl\":\"/nested\","
+			+ "\"columns\":[{\"data\":\"what\",\"title\":\"What\"}],"
+			+ "\"rowActions\":[{\"id\":\"ack\",\"label\":\"Acknowledge\",\"endpoint\":\"/ack/{what}\",\"method\":\"POST\","
+			+ "\"onSuccess\":\"redraw\"}]}";
+
+		// The nested sidecar carries type="application/json" so the browser never executes it as script once the
+		// row-detail template is cloned into the live document.
+		return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n"
+			+ "<table data-juneau-view=\"root\" id=\"root\" data-juneau-csrf=\"tok-9\"></table>\n"
+			+ "<script type=\"application/json\" id=\"juneau-view:root\">" + rootMeta + "</script>\n"
+			+ "<template data-juneau-row-detail=\"1\" data-juneau-detail-url=\"/query/{id}\">\n"
+			+ "  <div class=\"juneau-view-detail-nested\" data-juneau-nested=\"1\" data-juneau-nested-contract=\"2\""
+			+ " data-juneau-nested-scope-param=\"parentId\">\n"
+			+ "    <table data-juneau-view=\"events\" data-juneau-csrf=\"tok-9\"></table>\n"
+			+ "    <script type=\"application/json\" data-juneau-nested-meta=\"events\">" + nestedMeta + "</script>\n"
+			+ "  </div>\n"
+			+ "</template>\n"
+			+ "<script>\n" + jquery + "\n</script>\n"
+			+ "<script>\n" + dataTablesJs + "\n</script>\n"
+			+ "<script>\n" + datatablesGlue + "\n</script>\n"
+			+ "<script>\n" + resource(ViewsMixin.class, ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n"
+			+ "<script>\n" + viewsJs + "\n</script>\n"
+			+ "</body></html>";
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	private Map<?,?> runHarness() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("related-list.cjs");
+		var baseUrl = "http://127.0.0.1:" + port + "/";
+
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), baseUrl);
+		var stdout = dir.resolve("related-list-stdout.json");
+		var stderr = dir.resolve("related-list-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (! p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Json.to(Files.readString(stdout), Map.class);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_nestedListPagesToTheSecondPage() throws Exception {
+		var report = (Map<?,?>)runHarness().get("paging");
+		assertEquals("evt-01", report.get("firstPageFirstRow"), () -> report.toString());
+		assertNotEquals(report.get("firstPageFirstRow"), report.get("secondPageFirstRow"), () -> report.toString());
+		assertTrue(nestedBodies.stream().anyMatch(b -> ! b.contains("\"start\":0")), () -> "no paged request in " + nestedBodies);
+	}
+
+	@Test void a02_nestedListSortsOnTheServer() throws Exception {
+		var report = (Map<?,?>)runHarness().get("sorting");
+		assertEquals("evt-30", report.get("firstRowDescending"), () -> report.toString());
+		assertTrue(nestedBodies.stream().anyMatch(b -> b.contains("\"desc\"")), () -> "no descending order in " + nestedBodies);
+	}
+
+	@Test void a03_nestedRowActionRunsAgainstTheServer() throws Exception {
+		var report = (Map<?,?>)runHarness().get("action");
+		assertEquals(Boolean.TRUE, report.get("menuOpened"), () -> report.toString());
+		assertEquals(1, acks.size(), () -> acks.toString());
+		assertTrue(acks.get(0).startsWith("POST /ack/evt-"), acks.get(0));
+		assertTrue(acks.get(0).contains("csrf=tok-9"), acks.get(0));
+	}
+
+	@Test void a04_noScriptErrors() throws Exception {
+		assertEquals(List.of(), runHarness().get("jsFailures"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java
new file mode 100644
index 0000000000..d7f4035a0c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowAction_Tone_Test.java
@@ -0,0 +1,67 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/** Unit tests for {@link RowAction}'s {@code tone}, {@code confirmLabel} and {@code confirmRenderer} fields. */
+class RowAction_Tone_Test extends TestBase {
+
+	@Test void a01_unsetFieldsAreNull() {
+		var a = RowAction.create("x");
+		assertNull(a.tone);
+		assertNull(a.confirmLabel);
+		assertNull(a.confirmRenderer);
+	}
+
+	@Test void a02_dangerWireToken() {
+		assertEquals("danger", RowAction.Tone.DANGER.wire());
+		assertEquals("danger", RowAction.create("x").tone(RowAction.Tone.DANGER).tone);
+	}
+
+	@Test void a03_defaultWireToken() {
+		assertEquals("default", RowAction.Tone.DEFAULT.wire());
+		assertEquals("default", RowAction.create("x").tone(RowAction.Tone.DEFAULT).tone);
+	}
+
+	@Test void a04_confirmLabelSetVerbatim() {
+		assertEquals("Delete forever", RowAction.create("x").confirmLabel("Delete forever").confirmLabel);
+	}
+
+	@Test void a05_confirmRendererSetVerbatim() {
+		assertEquals("styled", RowAction.create("x").confirmRenderer("styled").confirmRenderer);
+	}
+
+	@Test void a06_fluentChainReturnsSameInstance() {
+		var a = RowAction.create("x");
+		assertSame(a, a.tone(RowAction.Tone.DANGER).confirmLabel("Go").confirmRenderer("r"));
+	}
+
+	@Test void a07_serializesOnlyWhenSet() {
+		var plain = Json5.of(RowAction.create("x"));
+		assertFalse(plain.contains("tone"), plain);
+		assertFalse(plain.contains("confirmLabel"), plain);
+		var json = Json5.of(RowAction.create("x").tone(RowAction.Tone.DANGER).confirmLabel("Go").confirmRenderer("r"));
+		assertTrue(json.contains("tone:'danger'"), json);
+		assertTrue(json.contains("confirmLabel:'Go'"), json);
+		assertTrue(json.contains("confirmRenderer:'r'"), json);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
index 2cac977789..22de853621 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewChecks_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
 
 import org.apache.juneau.*;
 import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.runreport.*;
 import org.junit.jupiter.api.*;
 
 class RunViewChecks_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
index 52fcde85a9..19a343b7ce 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewLog_Test.java
@@ -19,13 +19,16 @@ package org.apache.juneau.rest.server.views;
 import static org.apache.juneau.BasicTestUtils.*;
 import static org.junit.jupiter.api.Assertions.*;
 
+import java.nio.file.*;
 import java.util.*;
 import java.util.concurrent.*;
 import java.util.stream.*;
 
 import org.apache.juneau.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
 import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
 
 class RunViewLog_Test extends TestBase {
 
@@ -153,4 +156,15 @@ class RunViewLog_Test extends TestBase {
 		pool.shutdown();
 		assertEquals(LongStream.rangeClosed(1, 4000).boxed().toList(), seqs(log.page(null, 10_000)));
 	}
+
+	@Test void z01_reportResultFeedsTheLog(@TempDir Path d) throws Exception {
+		Files.writeString(d.resolve("TEST-x.xml"), "<testsuite name=\"com.example.X\"><testcase name=\"ok\" classname=\"com.example.X\"/>"
+			+ "<testcase name=\"bad\" classname=\"com.example.X\"><failure message=\"no\">trace</failure></testcase></testsuite>");
+		var log = RunViewLog.create();
+		ReportReaders.forKind("surefire").read(d).toEvents("tests").forEach(log::append);
+		var page = log.page(null, 100);
+		assertEquals("replace", page.events().get(0).toContractMap().get("ev"));
+		assertEquals(3, page.events().size());
+		assertDoesNotThrow(() -> page.validate());
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
index b878dca819..80516d4144 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewMixin_Test.java
@@ -26,8 +26,9 @@ import org.apache.juneau.*;
 import org.apache.juneau.marshall.marshaller.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
 import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
 import org.junit.jupiter.api.*;
 
 class RunViewMixin_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
index 13c848581e..86b98540c2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunViewPage_Test.java
@@ -22,7 +22,8 @@ import static org.junit.jupiter.api.Assertions.*;
 import java.util.*;
 
 import org.apache.juneau.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
 import org.junit.jupiter.api.*;
 
 class RunViewPage_Test extends TestBase {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
index 3546dc0908..277edac1ce 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RunView_BrowserTest.java
@@ -30,8 +30,9 @@ import org.apache.juneau.*;
 import org.apache.juneau.marshall.marshaller.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.runreport.*;
 import org.apache.juneau.rest.server.servlet.*;
-import org.apache.juneau.rest.server.views.RunEvent.*;
+import org.apache.juneau.rest.server.runreport.RunEvent.*;
 import org.junit.jupiter.api.*;
 import org.junit.jupiter.api.condition.*;
 
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.java
new file mode 100644
index 0000000000..b1b82453a8
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/UrlStateDirectives_BrowserTest.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
+ *
+ *      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.juneau.rest.server.views;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Runs the REAL served scripts in headless Chromium and asserts the {@code ?state=} directives against the real address
+ * bar and history: a deep link seeds the request params, {@code setDirective} rewrites the bar and the params, and
+ * browser Back restores the prior ids.
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests} Maven
+ * profile does.  The prober ({@code url-state-directives.html.cjs}) is resolved by name from the profile's {@code juneau.jsTests.harnessDir}.
+ */
+@EnabledIfSystemProperty(named=UrlStateDirectives_BrowserTest.GATE, matches="true",
+	disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class UrlStateDirectives_BrowserTest extends TestBase {
+
+	/** System property the {@code js-tests} profile sets to enable this class. */
+	static final String GATE = "juneau.jsTests";
+
+	private static Map<?,?> report;
+
+	private static String resource(String path) throws IOException {
+		try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+			assertNotNull(in, () -> "missing classpath resource: " + path);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	@BeforeAll
+	static void probe() throws Exception {
+		var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+		var harness = Path.of(requiredProperty("juneau.jsTests.harnessDir")).resolve("url-state-directives.html.cjs");
+
+		var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+			+ resource(ViewsMixin.RENDERS_JS_RESOURCE) + "\n</script>\n<script>\n"
+			+ resource(ViewsMixin.VIEWS_JS_RESOURCE) + "\n</script>\n<script>\n" + resource(ViewsMixin.URLSTATE_JS_RESOURCE)
+			+ "\n</script></body></html>";
+		var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("url-state-directives.html");
+		Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+		report = Json.to(run(dir, harness, fixtureFile), Map.class);
+	}
+
+	private static String requiredProperty(String name) {
+		var v = System.getProperty(name);
+		assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+		return v;
+	}
+
+	/** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+	private static String run(Path dir, Path harness, Path fixture) throws Exception {
+		var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+		var stdout = dir.resolve("url-state-directives-stdout.json");
+		var stderr = dir.resolve("url-state-directives-stderr.txt");
+		var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+		pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+		pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+		var p = pb.start();
+		if (!p.waitFor(3, TimeUnit.MINUTES)) {
+			p.destroyForcibly();
+			fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+		}
+		assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+		return Files.readString(stdout);
+	}
+
+	private static String quietRead(Path p) {
+		try {
+			return Files.readString(p);
+		} catch (IOException e) {
+			return "<unreadable: " + e + ">";
+		}
+	}
+
+	@Test void a01_runtimeLoadedWithoutScriptErrors() {
+		assertEquals(Boolean.TRUE, report.get("hasSet"), () -> report.toString());
+		assertEquals(List.of(), report.get("jsFailures"), () -> report.toString());
+	}
+
+	@Test void b01_deepLinkSeedsTheRequestParams() {
+		assertEquals("a,b", ((Map<?,?>)report.get("seededParams")).get("ids"), () -> report.toString());
+	}
+
+	@Test void b02_setDirectiveRewritesBarAndParamsAndRefetches() {
+		var address = (String)report.get("address");
+		assertTrue(address.contains("window(start=2026-10-01T00:00:00Z;end=2026-10-02T00:00:00Z)"), address);
+		var params = (Map<?,?>)report.get("paramsAfterSet");
+		assertEquals("2026-10-01T00:00:00Z", params.get("from"), () -> report.toString());
+		assertEquals("2026-10-02T00:00:00Z", params.get("to"), () -> report.toString());
+		assertEquals(1, ((Number)report.get("reloadsAfterSet")).intValue(), () -> report.toString());
+	}
+
+	@Test void b03_backRestoresThePriorIds() {
+		assertEquals("x,y", report.get("afterPushIds"), () -> report.toString());
+		assertEquals("a,b", report.get("idsAfterBack"), () -> report.toString());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java
new file mode 100644
index 0000000000..89089e6231
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Confirm_Test.java
@@ -0,0 +1,107 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for {@code JuneauViews.dialogs.confirm} and its renderer registry. */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Confirm_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("confirm.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(r != null, "node (or confirm.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void a01_sourceExposesConfirmSurface() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("confirm: confirmPipeline"), "dialogs.confirm");
+		assertTrue(body.contains("registerConfirmRenderer: registerConfirmRenderer"), "rowActions.registerConfirmRenderer");
+		assertTrue(body.contains("registerChrome: registerDialogChrome"), "dialogs.registerChrome");
+		assertTrue(body.contains("E-JS-71"), "unregistered renderer code");
+	}
+
+	@Test void b01_registeredRendererDecidesTheAnswer() {
+		var r = report();
+		assertEquals(true, r.get("surface"));
+		assertEquals(true, r.get("stubOk"));
+		assertEquals(false, r.get("stubCancel"));
+		assertEquals(true, r.get("duplicateKeptFirst"));
+		assertEquals(true, r.get("actionObjectRenderer"));
+	}
+
+	@Test void b02_rendererReceivesTheDescribedRequest() {
+		var saw = (List<?>)report().get("rendererSaw");
+		var first = (Map<?,?>)saw.get(0);
+		assertEquals("danger", first.get("tone"));
+		assertEquals("Go", first.get("confirmLabel"));
+		assertEquals(2, ((Number)first.get("items")).intValue());
+		assertEquals(true, first.get("sameRequest"));
+		var second = (Map<?,?>)saw.get(1);
+		assertEquals("default", second.get("tone"));
+		assertEquals("Confirm", second.get("confirmLabel"));
+	}
+
+	@Test void b03_unregisteredRendererRejectsWithE_JS_71() {
+		assertEquals("E-JS-71", report().get("unregistered"));
+	}
+
+	@Test void b04_builtInModalCapsListAndStylesDanger() {
+		var b = (Map<?,?>)report().get("builtIn");
+		assertEquals(true, b.get("shown"));
+		assertEquals(21, ((Number)b.get("listItems")).intValue());
+		assertEquals("\u2026and 5 more", b.get("lastItem"));
+		assertEquals("Delete", b.get("confirmText"));
+		assertEquals(true, b.get("dangerClass"));
+		assertEquals(true, b.get("cancelFocused"));
+		assertEquals(1, ((Number)b.get("depthWhileOpen")).intValue());
+	}
+
+	@Test void b05_builtInConfirmResolvesAndRunsActionOnlyOnConfirm() {
+		var b = (Map<?,?>)report().get("builtIn");
+		assertEquals(true, b.get("resolved"));
+		assertEquals(1, ((Number)b.get("actionRuns")).intValue());
+		assertEquals(true, b.get("goneAfter"));
+		assertEquals(false, b.get("cancelResolved"));
+		assertEquals(1, ((Number)b.get("actionRunsAfterCancel")).intValue());
+		assertEquals(true, report().get("defaultTonePlain"));
+		assertEquals(0, ((Number)report().get("depthAfter")).intValue());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java
new file mode 100644
index 0000000000..c29c6f56a2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogHandle_Test.java
@@ -0,0 +1,124 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for the {@code DialogHandle} returned by {@code dialogs.open}. */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_DialogHandle_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("dialog-handle.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(r != null, "node (or dialog-handle.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void a01_sourceExposesHandleAndChromeRegistry() throws Exception {
+		var body = viewsJs();
+		assertTrue(body.contains("ui.setConfirmLabel"), "setConfirmLabel");
+		assertTrue(body.contains("ui.onConfirm"), "onConfirm");
+		assertTrue(body.contains("E-JS-72"), "unregistered chrome code");
+		assertTrue(body.contains("depth: dialogLayerCount"), "dialogs.depth");
+	}
+
+	@Test void b01_handleSurface() {
+		assertEquals(true, report().get("handleSurface"));
+	}
+
+	@Test void b02_stepsAdvanceRetreatAndSubmitOnTheLast() {
+		var w = (Map<?,?>)report().get("wizard");
+		assertEquals("edit", w.get("step0"));
+		assertEquals("Next", w.get("label0"));
+		assertEquals(true, w.get("backHidden0"));
+		assertEquals("review", w.get("step1"));
+		assertEquals("Apply", w.get("label1"));
+		assertEquals("Review", w.get("title1"));
+		assertEquals(true, w.get("backShown1"));
+		assertEquals(1, ((Number)w.get("openAfterNext")).intValue());
+		assertEquals("edit", w.get("stepBack"));
+		assertEquals("edit", w.get("stepAfterUnknown"));
+		assertEquals("review", w.get("stepAfterSet"));
+		assertEquals("submitted", w.get("closed"));
+		assertEquals(0, ((Number)w.get("openAfterSubmit")).intValue());
+	}
+
+	@Test void b03_relabelingAndOnConfirmGating() {
+		var g = (Map<?,?>)report().get("gate");
+		assertEquals("Review changes", g.get("confirmLabel"));
+		assertEquals("Back out", g.get("cancelLabel"));
+		assertEquals(true, g.get("fieldsIsObject"));
+		assertEquals(1, ((Number)g.get("openAfterFalse")).intValue());
+		assertEquals(1, ((Number)g.get("openAfterPromiseFalse")).intValue());
+		assertEquals(1, ((Number)g.get("openAfterTruthyNotTrue")).intValue());
+		assertEquals("submitted", g.get("closed"));
+		assertEquals(4, ((Number)g.get("calls")).intValue());
+		assertEquals(0, ((Number)g.get("openAfterTrue")).intValue());
+	}
+
+	@Test void b04_closePopsOnlyItsOwnLayerAndDepthCapHolds() {
+		var c2 = (Map<?,?>)report().get("layers");
+		assertEquals(2, ((Number)c2.get("depthTwo")).intValue());
+		assertEquals(true, c2.get("thirdRefused"));
+		assertEquals(1, ((Number)c2.get("depthAfterInnerClose")).intValue());
+		assertEquals(1, ((Number)c2.get("outerStillOpen")).intValue());
+		assertEquals("cancelled", c2.get("innerClosed"));
+		assertEquals("cancelled", c2.get("outerClosed"));
+		assertEquals(1, ((Number)c2.get("onCancelRan")).intValue());
+		assertEquals(0, ((Number)c2.get("depthEnd")).intValue());
+	}
+
+	@Test void b05_chromeRegistry() {
+		var ch = (Map<?,?>)report().get("chrome");
+		assertEquals(true, ch.get("unknownReturnsNull"));
+		assertEquals(true, ch.get("depthUnchangedByUnknown"));
+		assertEquals(true, ch.get("opened"));
+		var seen = (List<?>)ch.get("seen");
+		assertEquals(1, seen.size());
+		assertEquals(List.of("actions", "cancelBtn", "confirmBtn", "dialog", "dismissBtn", "header"), ((Map<?,?>)seen.get(0)).get("keys"));
+	}
+
+	@Test void b06_openWithActionSubmitsOnlyOnTheLastStep() {
+		var sub = (Map<?,?>)report().get("submit");
+		assertEquals(0, ((Number)sub.get("afterNext")).intValue());
+		assertEquals(1, ((Number)sub.get("total")).intValue());
+		assertEquals("/x/apply", sub.get("url"));
+		assertEquals("apply", sub.get("action"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java
new file mode 100644
index 0000000000..77e3b2ad74
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ExportDetect_Test.java
@@ -0,0 +1,83 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for the ribbon "export loud" contract (disabled button, one logged E-JS-69). */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_ExportDetect_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("export-detect.cjs", ViewsMixin.RIBBON_JS_RESOURCE);
+		assumeTrue(r != null, "node (or export-detect.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void a01_sourceDeclaresTheDependencyTableAndTheCode() throws Exception {
+		var body = c.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("EXPORT_BUTTON_DEPS"), "dependency table");
+		assertTrue(body.contains("E-JS-69"), "code");
+		assertTrue(body.contains("return { ok: ok, missing: missing }"), "result shape");
+	}
+
+	@Test void b01_okAndMissingAreSplitByDependency() {
+		var r = report();
+		assertEquals(Map.of("ok", List.of(), "missing", List.of()), r.get("noButtonsExtension"));
+		assertEquals(Map.of("ok", List.of("copy", "csv"), "missing", List.of()), r.get("plainButtons"));
+		assertEquals(Map.of("ok", List.of("copy"), "missing", List.of(Map.of("id", "excel", "needs", "jszip"))), r.get("hardExcelNoJszip"));
+		assertEquals(Map.of("ok", List.of("pdf"), "missing", List.of()), r.get("hardPdfWithPdfmake"));
+	}
+
+	@Test void b02_optionalEntriesStayASilentOptIn() {
+		var r = report();
+		assertEquals(Map.of("ok", List.of(), "missing", List.of()), r.get("optionalExcelNoJszip"));
+		assertEquals(Map.of("ok", List.of("excel"), "missing", List.of()), r.get("optionalExcelWithJszip"));
+	}
+
+	@Test void c01_missingButtonRendersDisabledWithATipNamingTheLibrary() {
+		var r = report();
+		assertEquals(2, ((Number)r.get("disabledButtonCount")).intValue());
+		assertTrue(((String)r.get("disabledButtonTip")).contains("JSZip"), () -> String.valueOf(r.get("disabledButtonTip")));
+		assertNull(r.get("disabledTitleAttr"));
+	}
+
+	@Test void c02_e_js_69_isLoggedOncePerTable() {
+		var r = report();
+		assertEquals(1, ((Number)r.get("errorCountAcrossBothActions")).intValue());
+		assertEquals(true, r.get("ctxFlagSet"));
+		assertTrue(((String)r.get("errorText")).contains("ribbon export 'excel' needs 'JSZip'"), () -> String.valueOf(r.get("errorText")));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.java
new file mode 100644
index 0000000000..922e95aa73
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Hooks_Test.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
+ *
+ *      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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for the sanctioned hook namespaces
+ * ({@code JuneauViews.rowActions/dialogs/probeSelection/tables/status/csrf/details/html}), plus a Node behavioral
+ * proof (via {@code hooks-api.cjs}) that every namespace is frozen and exposes the right member shapes.
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_Hooks_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// Source-shape (always-on, no node needed)
+	// -----------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_everyNamespaceIsFrozenAndPlacedAfterNsInit() throws Exception {
+		var body = viewsJs();
+		var anchor = body.indexOf("renderAsyncStatus: renderAsyncStatus");
+		assertTrue(anchor >= 0, "NS.init closing anchor not found");
+		for (var ns : List.of("NS.rowActions = Object.freeze(", "NS.dialogs = Object.freeze(",
+				"NS.probeSelection = Object.freeze(", "NS.tables = Object.freeze(", "NS.status = Object.freeze(",
+				"NS.csrf = Object.freeze(", "NS.details = Object.freeze(", "NS.html = Object.freeze(")) {
+			assertTrue(body.indexOf(ns) > anchor, () -> ns + " not found after the NS.init anchor");
+		}
+	}
+
+	@Test void a02_rowActionsRunResolvesContextBeforeDispatching() throws Exception {
+		var body = viewsJs();
+		var start = body.indexOf("NS.rowActions = Object.freeze(");
+		var fragment = body.substring(start, body.indexOf("NS.dialogs = Object.freeze(", start));
+		assertTrue(fragment.contains("hookContextOf"), fragment);
+		assertTrue(fragment.contains("isDialogAction"), fragment);
+		assertTrue(fragment.contains("E-JS-74"), fragment);
+		assertTrue(fragment.contains("E-JS-70"), fragment);
+	}
+
+	// -----------------------------------------------------------------------------------------------------------------
+	// Node behavioral harness (hooks-api.cjs) - runs when node is on PATH.
+	// -----------------------------------------------------------------------------------------------------------------
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("hooks-api.cjs", ViewsMixin.VIEWS_JS_RESOURCE, ViewsMixin.RENDERS_JS_RESOURCE);
+		assumeTrue(r != null, "node not available or hooks-api.cjs not found - behavioral layer skipped");
+		return r;
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Object> ns(String name) {
+		return (Map<String,Object>)report().get(name);
+	}
+
+	@Test void b01_everyNamespaceExistsAndIsFrozen() {
+		for (var name : List.of("rowActions", "dialogs", "probeSelection", "tables", "status", "csrf", "details", "html")) {
+			assertEquals(Boolean.TRUE, ns(name).get("exists"), name);
+			assertEquals(Boolean.TRUE, ns(name).get("frozen"), name);
+		}
+	}
+
+	@Test void b02_rowActionsHasTheFullMemberSet() {
+		var members = (Map<?,?>)ns("rowActions").get("members");
+		for (var m : List.of("contextOf", "find", "isDialog", "run", "open", "submit"))
+			assertEquals("function", members.get(m), m);
+	}
+
+	@Test void b03_htmlEscDelegatesToRendersJs() {
+		assertEquals("&lt;a&gt;&amp;&quot;&#39;", report().get("htmlEscOutput"));
+	}
+
+	@Test void b04_csrfHeaders() {
+		var r = report();
+		assertEquals(Map.of("X-Csrf-Token", "tok"), r.get("csrfHeadersWithToken"));
+		assertEquals(Map.of("X-Mine", "tok"), r.get("csrfHeadersCustomName"));
+		assertEquals(Map.of(), r.get("csrfHeadersBlank"));
+		assertEquals(Map.of(), r.get("csrfHeadersNoEl"));
+		assertEquals("tok", r.get("csrfToken"));
+	}
+
+	@Test void b05_tablesHandleAndReload() {
+		var r = report();
+		assertEquals("v", r.get("handleViewId"));
+		assertEquals(Map.of("id", 7), r.get("handleRow"));
+		assertEquals(Boolean.TRUE, r.get("handleNull"));
+		assertEquals(Arrays.asList(null, false), r.get("reloadDefault"));
+		assertEquals(Arrays.asList(null, true), r.get("reloadReset"));
+		assertEquals(Boolean.TRUE, r.get("contextOfNull"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java
new file mode 100644
index 0000000000..7d43a75ce0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_InlineEdit_Test.java
@@ -0,0 +1,122 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/** Node behavioral checks for the {@code edit} renderer and the inline cell editor it drives. */
+class ViewsJs_InlineEdit_Test extends TestBase {
+
+	private static Map<?,?> report;
+
+	private static Map<?,?> report() {
+		if (report == null)
+			report = NodeHarness.report("edit-renderer.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(report != null, "node (or edit-renderer.cjs) not available - behavioral layer skipped");
+		return report;
+	}
+
+	private static Map<?,?> map(String key) {
+		return (Map<?,?>)report().get(key);
+	}
+
+	@Test void a01_rendererMarkupIsEscapedAndCarriesTheWiring() {
+		assertEquals(Boolean.TRUE, report().get("registered"));
+		for (var e : map("markup").entrySet())
+			assertEquals(Boolean.TRUE, e.getValue(), () -> "markup." + e.getKey());
+		assertEquals(Boolean.TRUE, report().get("noActionPlain"));
+		assertEquals(Boolean.TRUE, report().get("hostileActionEscaped"));
+		assertEquals(Boolean.TRUE, report().get("wrapClass"));
+	}
+
+	@Test void b01_visibleWhenHidesTheMissingActionAndTheFailingRow() {
+		assertEquals(Boolean.TRUE, report().get("unknownActionHides"));
+		assertEquals(Boolean.TRUE, report().get("visibleWhenHides"));
+	}
+
+	@Test void b02_enabledWhenDisablesWithTheReasonAndNeverOpens() {
+		var d = map("enabledWhenDisables");
+		assertEquals(Boolean.TRUE, d.get("disabled"));
+		assertEquals("Closed rows cannot be reassigned", d.get("title"));
+		assertEquals(Boolean.TRUE, report().get("disabledNoEditor"));
+	}
+
+	@Test void c01_enterCommitsTheEditedColumnThroughTheAction() {
+		assertEquals(Boolean.TRUE, report().get("opened"));
+		assertEquals("old", report().get("prefill"));
+		var c = map("commit");
+		assertEquals(1, ((Number)c.get("fetches")).intValue());
+		assertEquals("/x/7", c.get("url"));
+		assertEquals("assign", c.get("action"));
+		assertEquals("7", c.get("targetId"));
+		assertEquals(Map.of("owner", "new"), c.get("fields"));
+		assertEquals(Boolean.TRUE, report().get("closedAfterSuccess"));
+		assertEquals("new", report().get("shownValue"));
+		assertEquals(1, ((Number)report().get("reloadedAfterSuccess")).intValue());
+	}
+
+	@Test void c02_escapeAndUnchangedValueSendNothing() {
+		var e = map("escape");
+		assertEquals(0, ((Number)e.get("fetches")).intValue());
+		assertEquals(Boolean.TRUE, e.get("closed"));
+		assertEquals("old", e.get("restored"));
+		assertEquals(Boolean.TRUE, e.get("pencilBack"));
+		var u = map("unchanged");
+		assertEquals(0, ((Number)u.get("fetches")).intValue());
+		assertEquals(Boolean.TRUE, u.get("closed"));
+	}
+
+	@Test void c03_blurCommits() {
+		var b = map("blurCommit");
+		assertEquals(1, ((Number)b.get("fetches")).intValue());
+		assertEquals(Map.of("owner", "blurred"), b.get("fields"));
+	}
+
+	@Test void d01_failureKeepsTheEditorOpenWithTheErrorInline() {
+		var f = map("failure");
+		assertEquals(Boolean.TRUE, f.get("stillOpen"));
+		assertEquals("Not allowed", f.get("error"));
+		assertEquals(Boolean.TRUE, f.get("errorVisible"));
+		assertEquals("alert", f.get("role"));
+		assertEquals(Boolean.TRUE, f.get("enabled"));
+		assertEquals(0, ((Number)f.get("reloads")).intValue());
+		assertEquals(1, ((Number)report().get("failureBlurNoResubmit")).intValue(), "blur after a failure must not resubmit");
+	}
+
+	@Test void d02_transportRefusalAlsoSurfacesInline() {
+		var r = map("refusal");
+		assertEquals(Boolean.TRUE, r.get("stillOpen"));
+		assertEquals(Boolean.TRUE, r.get("hasMessage"));
+	}
+
+	@Test void e01_selectInputListsTheOptionsAndPrefills() {
+		var s = map("select");
+		assertEquals("SELECT", s.get("tag"));
+		assertEquals(List.of("x", "y", "z"), s.get("options"));
+		assertEquals("y", s.get("value"));
+	}
+
+	@Test void e02_aNestedTablesPencilIsNotTheParentsToHandle() {
+		assertEquals(Boolean.TRUE, report().get("nestedIgnored"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java
new file mode 100644
index 0000000000..277e7e3525
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActionsRun_Test.java
@@ -0,0 +1,92 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/** Source-shape and Node behavioral checks for {@code rowActions.run} on a confirm-only action with a confirm renderer. */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_RowActionsRun_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String viewsJs() throws Exception {
+		return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("row-actions-run.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(r != null, "node (or row-actions-run.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void a01_confirmOnlyPathRoutesThroughThePipeline() throws Exception {
+		var body = viewsJs();
+		var fn = body.substring(body.indexOf("function openConfirmOnlyDialog("));
+		fn = fn.substring(0, fn.indexOf("\n\t}\n"));
+		assertTrue(fn.indexOf("confirmPipeline(") > 0, fn);
+		assertTrue(fn.indexOf("confirmPipeline(") < fn.indexOf("submitActionDialog("), "confirm must precede the submit");
+	}
+
+	@Test void b01_confirmedActionSendsExactlyOneWrite() {
+		var c2 = (Map<?,?>)report().get("confirmed");
+		assertEquals(1, ((Number)c2.get("fetches")).intValue());
+		assertEquals("/x/7", c2.get("url"));
+		assertEquals("DELETE", c2.get("method"));
+		assertEquals("delete", c2.get("action"));
+		assertEquals("7", c2.get("targetId"));
+	}
+
+	@Test void b02_cancelledActionSendsNothing() {
+		assertEquals(1, ((Number)((Map<?,?>)report().get("cancelled")).get("fetches")).intValue());
+	}
+
+	@Test void b03_rendererSeesFilledTitleLabelAndTone() {
+		var asked = (List<?>)report().get("asked");
+		var first = (Map<?,?>)asked.get(0);
+		assertEquals("Delete Widget?", first.get("title"));
+		assertEquals("Delete it", first.get("confirmLabel"));
+		assertEquals("danger", first.get("tone"));
+	}
+
+	@Test void b04_emptyConfirmTokenRefusesBeforeAnythingShows() {
+		var e = (Map<?,?>)report().get("emptyToken");
+		assertEquals(0, ((Number)e.get("asked")).intValue());
+		assertEquals(1, ((Number)e.get("fetches")).intValue());
+	}
+
+	@Test void b05_unknownActionAndOutsideRowReject() {
+		assertEquals("E-JS-70", report().get("unknownAction"));
+		assertEquals("E-JS-74", report().get("outsideRow"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java
new file mode 100644
index 0000000000..e6e1aad772
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Rules_Test.java
@@ -0,0 +1,76 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Cross-language parity proof: {@code rules.cjs} runs the shared {@code visibility-corpus.json} through
+ * {@code JuneauViews.rules.test} (the Java side is {@link VisibilityRule_Corpus_Test}), then checks the
+ * facts-aware row scope.  Runs when node is on PATH.
+ */
+class ViewsJs_Rules_Test extends TestBase {
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("rules.cjs", ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(r != null, "node (or rules.cjs) not available - JS/Java parity layer skipped");
+		return r;
+	}
+
+	@Test void a01_everyCorpusCaseAgreesWithJava() {
+		var cases = (List<?>)report().get("cases");
+		assertFalse(cases.isEmpty());
+		for (var o : cases) {
+			var m = (Map<?,?>)o;
+			assertEquals(m.get("expected"), m.get("actual"), String.valueOf(m.get("name")));
+		}
+	}
+
+	@Test void a02_namespaceIsFrozen() {
+		assertEquals(true, report().get("frozen"));
+	}
+
+	@Test void a03_rowScopeMergesRowAndFacts() {
+		var r = report();
+		assertEquals("{\"viewer\":{\"roles\":[\"admin\"]}}", r.get("factsRead"));
+		assertEquals(true, r.get("rowScopeSeesFacts"));
+		assertEquals(true, r.get("rowScopeSeesRow"));
+		assertEquals(false, r.get("rowScopeBoth"));
+	}
+
+	@Test void a04_singleRuleObjectIsAListOfOne() {
+		assertEquals(true, report().get("singleRuleObject"));
+	}
+
+	@Test void a05_noConsolePageMeansEmptyFactsAndFailsClosed() {
+		var r = report();
+		assertEquals("{}", r.get("noContractFacts"));
+		assertEquals(false, r.get("noContractFailsClosed"));
+	}
+
+	@Test void a06_unknownOpHidesAndLogsE73() {
+		var r = report();
+		assertEquals(false, r.get("unknownOp"));
+		assertEquals(true, r.get("unknownOpLogged"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
index 5ac8e8ca54..b2a1ad6878 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RunView_Test.java
@@ -56,7 +56,9 @@ class ViewsJs_RunView_Test extends TestBase {
 		"tip03_stepExitTooltip", "cap01_domBounds", "cap02_collapsedBuildsNoBlocks",
 		"inc01_untouchedStepKeepsItsNode", "inc02_rowRebuiltOnlyWhenSuiteChanged", "cmp01_compactMode",
 		"gold01_fullGolden", "gold02_compactGolden", "gold03_replaceFlow",
-		"gold04_idempotentAppend"
+		"gold04_idempotentAppend",
+		"att01_groupingByIdAndTitle", "att02_collapsedByDefaultWithToggle", "att03_newAttemptRegroupsAndKeepsExpansion",
+		"att04_noAttemptsNoToggle", "att05_failureLinkOpensEarlierAttempt", "att06_resetClearsAttemptState"
 	));
 
 	static Map<?,?> report(String harness) {
@@ -133,6 +135,12 @@ class ViewsJs_RunView_Test extends TestBase {
 	@Test void n02_compactGolden() { assertAllTrue(r(), "gold02_compactGolden"); }
 	@Test void n03_replaceFlow() { assertAllTrue(r(), "gold03_replaceFlow"); }
 	@Test void n04_idempotentAppend() { assertAllTrue(r(), "gold04_idempotentAppend"); }
+	@Test void o01_groupingByIdAndTitle() { assertAllTrue(r(), "att01_groupingByIdAndTitle"); }
+	@Test void o02_collapsedByDefaultWithToggle() { assertAllTrue(r(), "att02_collapsedByDefaultWithToggle"); }
+	@Test void o03_newAttemptRegroupsAndKeepsExpansion() { assertAllTrue(r(), "att03_newAttemptRegroupsAndKeepsExpansion"); }
+	@Test void o04_noAttemptsNoToggle() { assertAllTrue(r(), "att04_noAttemptsNoToggle"); }
+	@Test void o05_failureLinkOpensEarlierAttempt() { assertAllTrue(r(), "att05_failureLinkOpensEarlierAttempt"); }
+	@Test void o06_resetClearsAttemptState() { assertAllTrue(r(), "att06_resetClearsAttemptState"); }
 
 	@Test void z01_everyCasePassesAndNoneIsMissing() {
 		var r = r();
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java
new file mode 100644
index 0000000000..b0357b8e24
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_UrlStateDirectives_Test.java
@@ -0,0 +1,129 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/** Node behavioral checks for the {@code ?state=} directive registry, the {@code window}/{@code ids} built-ins, and their live wire. */
+class ViewsJs_UrlStateDirectives_Test extends TestBase {
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("urlstate-directives.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE, ViewsMixin.URLSTATE_JS_RESOURCE);
+		assumeTrue(r != null, "node (or urlstate-directives.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void d01_registerDirectiveRejectsReservedAndMalformedNames() {
+		var r = report();
+		assertEquals(true, r.get("registerReserved"));
+		assertEquals(true, r.get("registerBadName"));
+		assertEquals(true, r.get("registerOk"));
+	}
+
+	@Test void d02_registerDirectiveRejectsDuplicateNames() {
+		assertEquals(true, report().get("registerDuplicate"));
+	}
+
+	@Test void d03_registeredDirectiveRoundTripsThroughExt() {
+		var r = report();
+		assertEquals("abc", r.get("extRoundTrip"));
+		assertEquals("probe(abc)", r.get("encoded"));
+	}
+
+	@Test void d04_unregisteredDirectiveStillDropped() {
+		assertNull(report().get("stillDroppedUnregistered"));
+	}
+
+	@Test void d05_isEmptyStateChecksExt() {
+		var r = report();
+		assertEquals(false, r.get("notEmptyBecauseOfExtOnly"));
+		assertEquals(true, r.get("emptyWithNoExt"));
+		assertEquals(true, r.get("emptyWithBlankExt"));
+	}
+
+	@Test void d06_codecThrowOnDecodeIsEJS68AndDirectiveDropped() {
+		var r = report();
+		assertEquals("E-JS-68", r.get("throwingCodecLogged"));
+		assertNull(r.get("throwingCodecValue"));
+		assertEquals("a", r.get("throwingCodecKeepsTab"));
+	}
+
+	@Test void d07_encodeArgPercentEncodesTheReservedChars() {
+		assertEquals("a%3Bb%28c%29d%2Ce%3Df%25", report().get("encodeArgSample"));
+	}
+
+	@Test void d08_windowBuiltin_roundTripsStartAndEnd() {
+		var r = report();
+		assertEquals("2026-10-01T00:00:00Z", r.get("windowStart"));
+		assertEquals("2026-10-02T00:00:00Z", r.get("windowEnd"));
+	}
+
+	@Test void d09_windowBuiltin_toParamsRenamesByConfig() {
+		var r = report();
+		assertEquals("2026-10-01T00:00:00Z", r.get("windowParamFrom"));
+		assertEquals("2026-10-02T00:00:00Z", r.get("windowParamTo"));
+		assertEquals("{\"from\":\"x\"}", r.get("windowOpenEnded"));
+	}
+
+	@Test void d10_idsBuiltin_trimsDedupesAndCaps() {
+		var r = report();
+		assertEquals("a,b", r.get("idsNormalized"));
+		assertEquals(true, r.get("idsCappedAtMax"));
+		assertEquals("Showing the first 2 ids", r.get("idsCapMessage"));
+		assertNull(r.get("idsNoMessageWhenNotCapped"));
+		assertEquals("{\"idList\":\"a,b\"}", r.get("idsParam"));
+	}
+
+	@Test void d11_idsBuiltin_parseThenSerializeIsIdempotent() {
+		var r = report();
+		assertEquals(true, r.get("idsIdempotent"));
+		assertEquals(true, r.get("idsSpecialCharsRoundTrip"));
+	}
+
+	@Test void e01_liveWire_seedsRequestParamsFromTheAddressBar() {
+		var r = report();
+		assertEquals("[\"p\",\"q\"]", r.get("seededFromUrl"));
+		assertEquals("{\"idList\":\"p,q\"}", r.get("requestParamsSeeded"));
+		assertEquals(1, ((Number)r.get("popstateListener")).intValue());
+	}
+
+	@Test void e02_liveWire_setDirectiveUpdatesBarParamsAndRefetches() {
+		var r = report();
+		assertEquals(true, r.get("setDirectiveOk"));
+		assertEquals(1, ((Number)r.get("setDirectiveReloaded")).intValue());
+		var bar = (String)r.get("addressBar");
+		assertTrue(bar.contains("window(start=2026-10-01T00:00:00Z;end=2026-10-02T00:00:00Z)"), bar);
+		assertTrue(bar.contains("ids(p,q)"), bar);
+		var params = (String)r.get("requestParamsAfterSet");
+		assertTrue(params.contains("\"from\":\"2026-10-01T00:00:00Z\"") && params.contains("\"to\":\"2026-10-02T00:00:00Z\""), params);
+		assertEquals("{\"idList\":\"p,q\"}", r.get("requestParamsAfterClear"));
+		assertFalse(((String)r.get("addressBarAfterClear")).contains("window("));
+		assertEquals(true, r.get("setDirectiveUnknown"));
+	}
+
+	@Test void e03_liveWire_popstateReappliesAndRefetches() {
+		var r = report();
+		assertEquals("[\"z\"]", r.get("popstateIds"));
+		assertEquals(1, ((Number)r.get("popstateReloaded")).intValue());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java
new file mode 100644
index 0000000000..43e272e7ef
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_VisibilityWiring_Test.java
@@ -0,0 +1,92 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Where {@code visibleWhen} takes effect: a Node behavioral proof (via {@code visibility-wiring.cjs}) for row actions,
+ * plus always-on source-shape checks for the ribbon, field grid and region paths.
+ */
+@SuppressWarnings({
+	"resource" // Closeable test fixture held in a static field; lifecycle managed by the test/framework, not a real leak.
+})
+class ViewsJs_VisibilityWiring_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	private static String asset(String path) throws Exception {
+		return c.get(path).run().assertStatus(200).getContent().asString();
+	}
+
+	private static Map<?,?> report() {
+		var r = NodeHarness.report("visibility-wiring.cjs", ViewsMixin.RENDERS_JS_RESOURCE, ViewsMixin.VIEWS_JS_RESOURCE);
+		assumeTrue(r != null, "node (or visibility-wiring.cjs) not available - behavioral layer skipped");
+		return r;
+	}
+
+	@Test void a01_menuOmitsActionsHiddenForTheRow() {
+		var r = report();
+		assertEquals(List.of("ack", "page", "view"), r.get("menuOpenRow"));
+		assertEquals(List.of("page", "view"), r.get("menuClosedRow"));
+	}
+
+	@Test void a02_pillAndTriggerHiddenAtDrawTime() {
+		var r = report();
+		assertEquals(true, r.get("pillHidden"));
+		assertEquals(true, r.get("pillShown"));
+		assertEquals(true, r.get("triggerHiddenWhenNothingLeft"));
+		assertEquals(true, r.get("triggerShownWhenSomethingLeft"));
+	}
+
+	@Test void a03_hiddenActionNeverFires() {
+		var r = report();
+		assertEquals(false, r.get("hiddenActionFired"));
+		assertEquals(true, r.get("visibleActionFired"));
+	}
+
+	@Test void b01_ribbonFiltersItemsByFacts() throws Exception {
+		var body = asset(ViewsMixin.RIBBON_JS_PATH);
+		assertTrue(body.contains("ribbonItemVisible"), body.length() + " bytes");
+		assertTrue(body.contains(".filter(ribbonItemVisible)"));
+	}
+
+	@Test void b02_fieldGridSkipsHiddenFields() throws Exception {
+		var body = asset(ViewsMixin.HELPERS_JS_PATH);
+		assertTrue(body.contains("NS.rules.testRow(field.visibleWhen, values)"));
+	}
+
+	@Test void b03_hiddenRegionTakesTheExistingDeferredPath() throws Exception {
+		var body = asset(ViewsMixin.REGIONS_JS_PATH);
+		assertTrue(body.contains("hiddenByVisibleWhen(region)"));
+		assertTrue(body.contains("data-juneau-region-hidden"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java
new file mode 100644
index 0000000000..0098611766
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Corpus_Test.java
@@ -0,0 +1,70 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Runs {@code visibility-corpus.json} (shared with {@code rules.cjs}) through {@link VisibilityRule#test}, so the
+ * Java and JS evaluators are proven to agree on every case from one corpus.
+ */
+class VisibilityRule_Corpus_Test extends TestBase {
+
+	static Path locateCorpus() {
+		for (var rel : List.of(
+			"src/test/js/visibility-corpus.json",
+			"juneau-rest/juneau-rest-server-views/src/test/js/visibility-corpus.json")) {
+			var p = Path.of(rel);
+			if (Files.isRegularFile(p))
+				return p;
+		}
+		throw new IllegalStateException("visibility-corpus.json not found");
+	}
+
+	@SuppressWarnings("unchecked")
+	@Test void a01_everyCorpusCaseMatchesItsExpectation() throws IOException {
+		var cases = (List<Map<String,Object>>)Json.to(Files.readString(locateCorpus()), List.class);
+		assertFalse(cases.isEmpty());
+		for (var c : cases) {
+			var rules = ((List<Map<String,Object>>)c.get("rules")).stream().map(VisibilityRule_Corpus_Test::toRule).toList();
+			var facts = (Map<String,Object>)c.get("facts");
+			assertEquals(c.get("expected"), VisibilityRule.test(rules, facts), (String)c.get("name"));
+		}
+	}
+
+	private static VisibilityRule toRule(Map<String,Object> m) {
+		var b = VisibilityRule.when((String)m.get("field"));
+		var op = (String)m.get("op");
+		return switch (op) {
+			case "eq" -> b.eq(m.get("value"));
+			case "ne" -> b.ne(m.get("value"));
+			case "present" -> b.present();
+			case "absent" -> b.absent();
+			case "in" -> b.in(((List<?>)m.get("value")).toArray());
+			case "contains" -> b.contains(m.get("value"));
+			default -> throw new IllegalArgumentException("unknown corpus op '" + op + "'");
+		};
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java
new file mode 100644
index 0000000000..3865ed2194
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/VisibilityRule_Test.java
@@ -0,0 +1,123 @@
+/*
+ * 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.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/** Unit tests for the {@link VisibilityRule} builder, its {@code test(rules, map)} evaluator, and its wire shape. */
+class VisibilityRule_Test extends TestBase {
+
+	@Test void a01_eqSerializesFieldOpValue() {
+		var r = VisibilityRule.when("status").eq("open");
+		assertEquals("status", r.field);
+		assertEquals("eq", r.op);
+		assertEquals("open", r.value);
+	}
+
+	@Test void a02_presentAndAbsentOmitValue() {
+		assertNull(VisibilityRule.when("name").present().value);
+		assertNull(VisibilityRule.when("name").absent().value);
+	}
+
+	@Test void a03_inWrapsVarargsAsAList() {
+		assertEquals(List.of("open", "acked"), VisibilityRule.when("status").in("open", "acked").value);
+	}
+
+	@Test void a04_whenRejectsBlankField() {
+		assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when(" "));
+		assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when(null));
+	}
+
+	@Test void a05_eqRejectsNullValue() {
+		assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when("status").eq(null));
+		assertThrows(IllegalArgumentException.class, () -> VisibilityRule.when("status").contains(null));
+	}
+
+	@Test void a06_testMatchesAndRule() {
+		var rules = List.of(VisibilityRule.when("status").eq("open"), VisibilityRule.when("priority").in("p1", "p2"));
+		assertTrue(VisibilityRule.test(rules, Map.of("status", "open", "priority", "p1")));
+		assertFalse(VisibilityRule.test(rules, Map.of("status", "open", "priority", "p3")));
+	}
+
+	@Test void a07_testFailsClosedOnMissingField() {
+		assertFalse(VisibilityRule.test(List.of(VisibilityRule.when("status").ne("open")), Map.of()));
+	}
+
+	@Test void a08_absentMatchesMissingAndNull() {
+		var rules = List.of(VisibilityRule.when("name").absent());
+		assertTrue(VisibilityRule.test(rules, Map.of()));
+		var m = new HashMap<String,Object>();
+		m.put("name", null);
+		assertTrue(VisibilityRule.test(rules, m));
+		assertFalse(VisibilityRule.test(rules, Map.of("name", "a")));
+	}
+
+	@Test void a09_dottedPathResolvesNestedFacts() {
+		var rules = List.of(VisibilityRule.when("facts.viewer.roles").contains("admin"));
+		assertTrue(VisibilityRule.test(rules, Map.of("facts", Map.of("viewer", Map.of("roles", List.of("admin"))))));
+	}
+
+	@Test void a10_emptyRuleListAlwaysMatches() {
+		assertTrue(VisibilityRule.test(List.of(), Map.of()));
+		assertTrue(VisibilityRule.test(null, Map.of()));
+	}
+
+	@Test void a11_unknownOpThrows() {
+		var r = VisibilityRule.when("status").eq("open");
+		r.op = "bogus";
+		var ex = assertThrows(IllegalArgumentException.class, () -> VisibilityRule.test(List.of(r), Map.of("status", "open")));
+		assertTrue(ex.getMessage().contains("'bogus'"), ex.getMessage());
+	}
+
+	@Test void a12_toMapOmitsValueWhenAbsent() {
+		assertEquals(Map.of("field", "n", "op", "present"), VisibilityRule.when("n").present().toMap());
+		assertEquals(Map.of("field", "s", "op", "in", "value", List.of("a")), VisibilityRule.when("s").in("a").toMap());
+	}
+
+	@Test void b01_rowActionSerializesVisibleWhen() {
+		var a = RowAction.create("escalate").visibleWhen(VisibilityRule.when("status").eq("open"));
+		var json = Json.of(a);
+		assertTrue(json.contains("visibleWhen"), json);
+		assertTrue(json.contains("\"op\":\"eq\""), json);
+	}
+
+	@Test void b02_rowActionOmitsVisibleWhenWhenUnset() {
+		assertFalse(Json.of(RowAction.create("x")).contains("visibleWhen"));
+	}
+
+	@Test void b03_regionAndFieldCarryVisibleWhenInContract() {
+		var rule = VisibilityRule.when("facts.admin").eq(true);
+		var region = RegionDef.create("r").visibleWhen(rule)
+			.fields(RegionDef.Field.of("a").visibleWhen(rule), RegionDef.Field.of("b"));
+		var m = region.toContractMap();
+		assertEquals(List.of(rule.toMap()), m.get("visibleWhen"));
+		var fields = (List<?>)m.get("fields");
+		assertEquals(List.of(rule.toMap()), ((Map<?,?>)fields.get(0)).get("visibleWhen"));
+		assertFalse(((Map<?,?>)fields.get(1)).containsKey("visibleWhen"));
+	}
+
+	@Test void b04_enabledWhenRejectsCollectionOps() {
+		assertThrows(IllegalArgumentException.class,
+			() -> org.apache.juneau.rest.server.views.RowActionEnabledRule.of("s", org.apache.juneau.rest.server.widgets.Op.IN, "x", "r"));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs
new file mode 100644
index 0000000000..70a96168f3
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/badges.cjs
@@ -0,0 +1,250 @@
+/*
+ * 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.
+ */
+
+/*
+ * badges.cjs - drives juneau-badges.js against a scripted fetch and a captured timer queue: label templates,
+ * hide-at-zero and pulse, 401/403 removal, stale + backoff, visibleWhen gating, scope resolution, drain-triggered
+ * card refresh, tooltip open/close, and click navigation.  Prints one JSON report.
+ *
+ *   Usage:  node badges.cjs <juneau-badges.js>
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+const { makeConsoleEnv } = require(path.join(__dirname, 'console-dom-shim.cjs'));
+
+const badgesPath = process.argv[2];
+if (!badgesPath) {
+	console.error('usage: node badges.cjs <juneau-badges.js>');
+	process.exit(2);
+}
+const src = fs.readFileSync(badgesPath, 'utf8');
+
+/** Builds a fresh page + script instance.  `responses` is a queue of {status, body|throws} consumed per fetch. */
+function setup(responses, opts) {
+	opts = opts || {};
+	const env = makeConsoleEnv(opts.location ? { location: opts.location } : undefined);
+	const doc = env.document;
+	const win = env.window;
+	win.window = win;
+	win.document = doc;
+	const errors = [], urls = [], assigned = [], timers = [], reloaded = [];
+	const header = doc.createElement('div');
+	header.className = 'jc-header-actions';
+	doc.body.appendChild(header);
+	win.location.assign = u => assigned.push(u);
+	if (opts.contract) {
+		win.JuneauConsole = {
+			contract: () => opts.contract,
+			refreshCard: id => { reloaded.push(id); return Promise.resolve(); }
+		};
+	}
+	if (opts.urlState) win.JuneauViews = { urlState: opts.urlState };
+	const sandbox = {
+		window: win, document: doc,
+		console: { error: m => errors.push(String(m)), log: () => {}, warn: () => {} },
+		URLSearchParams, Proxy, Promise, JSON, Object, Array, Error, String, Math, Set, Map,
+		encodeURIComponent,
+		fetch: u => {
+			urls.push(u);
+			const r = responses.shift();
+			if (!r) return Promise.reject(new Error('no scripted response'));
+			if (r.throws) return Promise.reject(new Error(r.throws));
+			return Promise.resolve({ status: r.status || 200, json: () => Promise.resolve(r.body) });
+		},
+		setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
+		clearTimeout: () => {}
+	};
+	vm.runInNewContext(src, sandbox, { filename: 'juneau-badges.js' });
+	return { env, doc, win, NS: win.JuneauConsoleBadges, errors, urls, assigned, timers, reloaded, header };
+}
+
+const flush = () => new Promise(r => setTimeout(r, 0));
+const badge = (doc, id) => doc.querySelector('[data-juneau-badge-id=' + id + ']');
+
+async function tick(h) {
+	const t = h.timers.pop();
+	t.fn();
+	await flush();
+	return t.ms;
+}
+
+(async () => {
+	const report = {};
+
+	// (a)-(c) labels, role/aria-live, singular, mine suffix
+	{
+		const h = setup([{ body: { total: 3 } }, { body: { total: 1 } }, { body: { total: 2, mine: 1 } }]);
+		h.NS.mount([{ id: 'pending', src: '/p' }]);
+		await flush();
+		const b = badge(h.doc, 'pending');
+		report.plainLabel = b.textContent;
+		report.role = b.getAttribute('role');
+		report.ariaLive = b.getAttribute('aria-live');
+		report.inHeaderBadges = b.parentNode.className;
+		report.defaultTone = b.getAttribute('data-juneau-badge-tone');
+		await tick(h);
+		report.singularLabel = b.textContent;
+		await tick(h);
+		report.mineLabel = b.textContent;
+		report.noPulseOnDecreaseOrFirst = true;
+	}
+
+	// custom label templates, tone mapping
+	{
+		const h = setup([{ body: { total: 2 } }]);
+		h.NS.mount([{ id: 'x', src: '/p', tone: 'danger', label: { other: '{total} open' } }]);
+		await flush();
+		const b = badge(h.doc, 'x');
+		report.customLabel = b.textContent;
+		report.dangerTone = b.getAttribute('data-juneau-badge-tone');
+	}
+
+	// (d) zero hides, then >0 un-hides and pulses
+	{
+		const h = setup([{ body: { total: 0 } }, { body: { total: 2 } }]);
+		h.NS.mount([{ id: 'z', src: '/p' }]);
+		await flush();
+		const b = badge(h.doc, 'z');
+		report.zeroHidden = b.hidden === true;
+		await tick(h);
+		report.reshown = b.hidden === false;
+		report.pulsed = b.classList.contains('jc-count-badge--pulse');
+	}
+
+	// (e) 403 removes the badge, logs E-JS-65 once, stops polling
+	{
+		const h = setup([{ status: 403, body: {} }]);
+		h.NS.mount([{ id: 'f', src: '/p' }]);
+		await flush();
+		report.removedOn403 = badge(h.doc, 'f') === null;
+		report.e65Count = h.errors.filter(e => e.includes('E-JS-65')).length;
+		report.noRescheduleOn403 = h.timers.length === 0;
+		report.fetchCountAfter403 = h.urls.length;
+	}
+
+	// (f) no numeric total: stale + E-JS-66 + backoff doubles
+	{
+		const h = setup([{ body: { total: 'x' } }, { body: {} }, { throws: 'down' }]);
+		h.NS.mount([{ id: 's', src: '/p', refreshMs: 10000 }]);
+		await flush();
+		const b = badge(h.doc, 's');
+		report.stale = b.classList.contains('jc-count-badge--stale');
+		report.e66 = h.errors.some(e => e.includes('E-JS-66'));
+		const d1 = await tick(h);
+		const d2 = await tick(h);
+		report.backoff = [d1, d2];
+		report.e65OnNetworkError = h.errors.some(e => e.includes('E-JS-65') && e.includes('down'));
+	}
+
+	// refreshMs below the floor is clamped when scheduling
+	{
+		const h = setup([{ body: { total: 1 } }]);
+		h.NS.mount([{ id: 'c', src: '/p', refreshMs: 100 }]);
+		await flush();
+		report.clampedInterval = h.timers[h.timers.length - 1].ms;
+	}
+
+	// visibleWhen: gated by facts, never fetched when hidden
+	{
+		const contract = { activeNav: ['a'], facts: { viewer: { roles: ['oncall'] } } };
+		const h = setup([{ body: { total: 1 } }], { contract });
+		h.NS.mount([
+			{ id: 'yes', src: '/yes', visibleWhen: [{ field: 'viewer.roles', op: 'contains', value: 'oncall' }] },
+			{ id: 'no', src: '/no', visibleWhen: { field: 'viewer.roles', op: 'contains', value: 'admin' } }
+		]);
+		await flush();
+		report.visibleWhenShown = badge(h.doc, 'yes') !== null;
+		report.visibleWhenHiddenNotMounted = badge(h.doc, 'no') === null;
+		report.visibleWhenFetched = h.urls;
+	}
+
+	// scope: nav leaf resolves values onto the request; no match hides and keeps polling
+	{
+		const contract = { activeNav: ['rules', 'suspensions'], facts: {} };
+		const h = setup([{ body: { total: 2 } }], { contract });
+		h.NS.mount([{ id: 'sc', src: '/p', params: { a: 'b c' }, scope: { param: 'beanType', by: 'nav', values: { suspensions: ['Suspension', 'Hold'] } } }]);
+		await flush();
+		report.scopedUrl = h.urls[0];
+		const h2 = setup([], { contract: { activeNav: ['other'], facts: {} } });
+		h2.NS.mount([{ id: 'sc2', src: '/p', scope: { param: 'beanType', values: { suspensions: ['Suspension'] } } }]);
+		await flush();
+		report.unscopedPageNoFetch = h2.urls.length === 0;
+		report.unscopedPageReschedules = h2.timers.length === 1;
+	}
+
+	// drain detection refreshes linked cards, only on an item leaving
+	{
+		const contract = { activeNav: [], facts: {} };
+		const h = setup([
+			{ body: { total: 2, items: [{ id: '1' }, { id: '2' }] } },
+			{ body: { total: 3, items: [{ id: '1' }, { id: '2' }, { id: '3' }] } },
+			{ body: { total: 2, items: [{ id: '2' }, { id: '3' }] } }
+		], { contract });
+		h.NS.mount([{ id: 'd', src: '/p', refreshes: ['rules-table'] }]);
+		await flush();
+		await tick(h);
+		report.reloadedOnGrowth = h.reloaded.length;
+		await tick(h);
+		report.reloadedOnDrain = h.reloaded.slice();
+	}
+
+	// tooltip: opens on focus with the item cap, Escape and blur close it
+	{
+		const items = [];
+		for (let i = 1; i <= 4; i++) items.push({ id: String(i), label: 'item ' + i, href: '/i/' + i });
+		const h = setup([{ body: { total: 4, items } }]);
+		h.NS.mount([{ id: 't', src: '/p', tooltipMax: 3 }]);
+		await flush();
+		const b = badge(h.doc, 't');
+		b.dispatch('focus');
+		const pop = h.doc.querySelector('.jc-count-badge-popover');
+		report.tooltipOpen = pop !== null;
+		report.tooltipRole = pop && pop.getAttribute('role');
+		report.tooltipItems = pop ? pop.querySelectorAll('.jc-count-badge-popover-item').length : -1;
+		report.tooltipMore = pop ? pop.querySelector('.jc-count-badge-popover-more').textContent : null;
+		report.describedBy = b.getAttribute('aria-describedby') === (pop && pop.id);
+		h.env.dispatchDocument('keydown', { key: 'Escape' });
+		report.escapeCloses = h.doc.querySelector('.jc-count-badge-popover') === null;
+		b.dispatch('mouseenter');
+		const reopened = h.doc.querySelector('.jc-count-badge-popover') !== null;
+		b.dispatch('blur');
+		report.blurCloses = reopened && h.doc.querySelector('.jc-count-badge-popover') === null;
+	}
+
+	// (g)/(h) click navigation
+	{
+		const encoded = [];
+		const urlState = { encode: s => { encoded.push(s); return 'filter(beanType=' + s.filters[0].expr + ')'; } };
+		const contract = { activeNav: ['suspensions'], facts: {} };
+		const h = setup([{ body: { total: 1 } }, { body: { total: 1 } }], { contract, urlState });
+		h.NS.mount([
+			{ id: 'plain', src: '/p', href: '/ui/changes' },
+			{ id: 'filt', src: '/p', href: '/ui/changes', stateFilter: { column: 'beanType' }, scope: { param: 'beanType', values: { suspensions: ['Suspension'] } } }
+		]);
+		await flush();
+		badge(h.doc, 'plain').dispatch('click');
+		badge(h.doc, 'filt').dispatch('click');
+		report.plainNav = h.assigned[0];
+		report.filteredNav = h.assigned[1];
+		report.encodeArg = encoded[0] && encoded[0].filters[0];
+	}
+
+	console.log(JSON.stringify(report));
+})().catch(e => { console.error(e && e.stack || e); process.exit(1); });
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs
new file mode 100644
index 0000000000..fdbfe84a9d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/badges.html.cjs
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+/*
+ * badges.html.cjs - real-browser prober for the count badge: label and visibility in the header, the hover popover,
+ * click navigation (to a same-page hash, since location.assign is not stubbable in a real browser), and removal on 403.
+ *
+ * Never runs in a default build; it is driven by PendingBadge_BrowserTest, which only runs under `mvn -Pjs-tests`.
+ *
+ *   Usage:  node badges.html.cjs <page.html>
+ *
+ * This script only OBSERVES and prints ONE JSON object; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const PROBE = async function () {
+	const NS = window.JuneauConsoleBadges;
+	const out = { hasMount: typeof NS?.mount === 'function' };
+	if (!out.hasMount) return out;
+	const bodies = {
+		'/badges/pending': { status: 200, body: { total: 3, mine: 1, items: [{ id: '1', label: 'Rule one' }, { id: '2', label: 'Rule two' }] } },
+		'/badges/denied': { status: 403, body: {} }
+	};
+	window.fetch = function (url) {
+		const r = bodies[String(url).split('?')[0]] || { status: 404, body: {} };
+		return Promise.resolve({ status: r.status, json: function () { return Promise.resolve(r.body); } });
+	};
+	NS.mount([
+		{ id: 'pending', src: '/badges/pending', href: '#changes', refreshMs: 60000 },
+		{ id: 'denied', src: '/badges/denied' }
+	]);
+	await new Promise(function (r) { setTimeout(r, 100); });
+	const b = document.querySelector('[data-juneau-badge-id=pending]');
+	if (b) {
+		const r = b.getBoundingClientRect();
+		out.label = b.textContent;
+		out.visible = r.width > 0 && r.height > 0;
+		out.hostClass = b.parentNode.className;
+		b.dispatchEvent(new MouseEvent('mouseenter'));
+		const pop = document.querySelector('.jc-count-badge-popover');
+		out.popoverItems = pop ? pop.querySelectorAll('.jc-count-badge-popover-item').length : -1;
+		document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+		out.popoverClosedOnEscape = document.querySelector('.jc-count-badge-popover') === null;
+		b.click();
+		out.hash = location.hash;
+	}
+	out.deniedRemoved = document.querySelector('[data-juneau-badge-id=denied]') === null;
+	NS.unmountAll();
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node badges.html.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		await page.goto('file://' + path.resolve(fixture));
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(error => {
+	process.stderr.write(String(error?.stack || error) + '\n');
+	process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs
new file mode 100644
index 0000000000..5994486e76
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/confirm-renderer.html.cjs
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+/*
+ * confirm-renderer.html.cjs - real-browser prober for the confirm pipeline: a registered renderer paints its own
+ * modal and its real Confirm/Cancel buttons decide whether the row action's write is sent, and a danger confirm in the
+ * built-in modal focuses Cancel first.
+ *
+ * Never runs in a default build; it is driven by ConfirmRenderer_BrowserTest, which only runs under `mvn -Pjs-tests`.
+ *
+ *   Usage:  node confirm-renderer.html.cjs <page.html>
+ *
+ * This script only OBSERVES and prints ONE JSON object; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const PROBE = async function () {
+	const NS = window.JuneauViews;
+	const out = { hasRun: typeof NS?.rowActions?.run === 'function' };
+	if (!out.hasRun) return out;
+
+	const fetchCalls = [];
+	window.fetch = function (url, opts) { fetchCalls.push({ url: url, opts: opts }); return Promise.resolve({ ok: true, status: 200 }); };
+
+	// NOSONAR javascript:S7721 -- must stay nested: page.evaluate(PROBE) ships only PROBE's own source into the browser context.
+	const flush = function () { return new Promise(function (r) { setTimeout(r, 20); }); }; // NOSONAR javascript:S7721 -- must stay nested: page.evaluate(PROBE) serializes only PROBE's own source
+
+	// A renderer that paints its own styled modal; its real buttons settle the confirm.
+	NS.rowActions.registerConfirmRenderer('styled', function (modal) {
+		return new Promise(function (resolve) {
+			const box = document.createElement('div');
+			box.id = 'styled-modal';
+			box.style.cssText = 'position:fixed;top:20px;left:20px;width:240px;height:80px;background:#eee;z-index:5000';
+			box.textContent = modal.title;
+			const ok = document.createElement('button');
+			ok.id = 'styled-ok';
+			ok.textContent = modal.confirmLabel;
+			const cancel = document.createElement('button');
+			cancel.id = 'styled-cancel';
+			cancel.textContent = modal.cancelLabel;
+			function done(v) { box.remove(); resolve(v); }
+			ok.addEventListener('click', function () { done(true); });
+			cancel.addEventListener('click', function () { done(false); });
+			box.appendChild(ok);
+			box.appendChild(cancel);
+			document.body.appendChild(box);
+		});
+	});
+
+	const action = { id: 'delete', label: 'Delete', endpoint: '/x/{id}', method: 'DELETE', present: 'dialog',
+		confirm: 'Delete {name}?', confirmLabel: 'Delete it', tone: 'danger', confirmRenderer: 'styled' };
+	const table = document.createElement('table');
+	table.dataset.juneauView = 'v';
+	table.dataset.juneauCsrf = 'tok-1';
+	const tbody = document.createElement('tbody');
+	const tr = document.createElement('tr');
+	tr.dataset.juneauRowId = '7';
+	const td = document.createElement('td');
+	const btn = document.createElement('button');
+	btn.textContent = 'go';
+	td.appendChild(btn);
+	tr.appendChild(td);
+	tbody.appendChild(tr);
+	table.appendChild(tbody);
+	document.body.appendChild(table);
+	table.__juneauCtx = { viewDef: { id: 'v', rowActions: [action] }, dataTable: { row: function () { return { data: function () { return { id: 7, name: 'Widget' }; } }; } } };
+
+	NS.rowActions.run(btn, 'delete');
+	await flush();
+	const modal = document.getElementById('styled-modal');
+	out.styledText = modal?.firstChild?.textContent ?? null;
+	out.styledOkLabel = document.getElementById('styled-ok')?.textContent ?? null;
+	const r = modal ? modal.getBoundingClientRect() : null;
+	out.styledVisible = !!r && r.width > 0 && r.height > 0;
+	out.fetchesBeforeClick = fetchCalls.length;
+	document.getElementById('styled-ok').click();
+	await flush();
+	out.confirmFetches = fetchCalls.length;
+	out.confirmMethod = fetchCalls[0]?.opts.method ?? null;
+	out.confirmUrl = fetchCalls[0]?.url ?? null;
+
+	NS.rowActions.run(btn, 'delete');
+	await flush();
+	document.getElementById('styled-cancel').click();
+	await flush();
+	out.fetchesAfterCancel = fetchCalls.length;
+
+	// The built-in modal: a danger confirm focuses Cancel first.
+	const pending = NS.dialogs.confirm({ title: 'Really?', tone: 'danger', confirmLabel: 'Do it' });
+	await flush();
+	out.dangerCancelFocused = document.activeElement?.classList.contains('juneau-view-dialog-cancel') === true;
+	document.querySelector('.juneau-view-dialog-confirm').click();
+	out.builtInResolved = await pending;
+	return out;
+};
+
+(async () => {
+	const [fixture] = process.argv.slice(2);
+	if (!fixture) {
+		process.stderr.write('usage: node confirm-renderer.html.cjs <page.html>\n');
+		process.exit(2);
+	}
+	if (!fs.existsSync(fixture))
+		throw new Error('fixture not found: ' + fixture);
+	const browser = await chromium.launch();
+	try {
+		const page = await browser.newPage();
+		const failures = [];
+		page.on('pageerror', e => failures.push(String(e)));
+		page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+		await page.goto('file://' + path.resolve(fixture));
+		await page.evaluate(() => new Promise(requestAnimationFrame));
+		const report = await page.evaluate(PROBE);
+		report.jsFailures = failures.slice();
+		process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+	} finally {
+		await browser.close();
+	}
+})().catch(error => {
+	process.stderr.write(String(error?.stack || error) + '\n');
+	process.exit(1);
+});
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs
new file mode 100644
index 0000000000..7207fdcd08
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/confirm.cjs
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+/*
+ * confirm.cjs - always-on Node harness for JuneauViews.dialogs.confirm: a registered renderer decides the answer,
+ * an unregistered renderer name rejects with E-JS-71, and the built-in modal lists/caps items, carries confirmLabel
+ * and tone, focuses Cancel first for a danger confirm, runs a function `action` only on confirm, and resolves false on
+ * cancel.  Every assertion lives in the Java test.
+ *
+ *   Usage: node confirm.cjs <juneau-renders.js> <juneau-views.js>
+ */
+'use strict';
+
... 2793 lines suppressed ...