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/08/18 18:31:32 UTC

(juneau) 04/08: TODO-429/431: Fix sub-tab panel visibility and eager DataTable init in juneau-pages.js; adopt escapeForScript

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

commit b7f14752c9035b40481e588ab63f2fd249f3b79b
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 13:34:31 2026 -0400

    TODO-429/431: Fix sub-tab panel visibility and eager DataTable init in juneau-pages.js; adopt escapeForScript
    
    Two real client-runtime defects found by the browser harness that is added in
    a following commit:
    
    - panelMatches() demanded an exact data-panel-subtab match, so a sub-tabbed
      tab's outer .jc-panel (which carries only data-panel-tab) never got
      .jc-active, blanking every sub-tabbed tab. Fixed with an explicit "missing
      attribute = tab-agnostic, always matches" branch; the contract is now
      spelled out in both juneau-pages.js and PageTable's javadoc.
    - activatePanelViews() claimed every table[data-juneau-view] in its subtree
      including hidden descendant sub-panels, firing every sub-tab's ajax draw and
      sizing columns while display:none -- the exact mis-sizing lazy init exists to
      prevent.
    
    Also (TODO-431, framework half): PageTable and ViewTable each carried their own
    private escapeForScript for their JSON sidecars; both are deleted in favor of
    the new, 19-assertion-covered StringUtils.escapeForScript.
    HtmlBuilder_RawText_Test's contract-doc comment is repointed at the new
    location. TODO-431's consumer-side sweep (release-manager's new-release.ftlh)
    and its Phase 3 build-time guard are still open; not this commit.
---
 .../bean/html5/HtmlBuilder_RawText_Test.java       |   4 +-
 .../apache/juneau/commons/utils/StringUtils.java   |  78 +++++++
 .../juneau/commons/utils/StringUtils_Test.java     |  71 ++++++
 .../apache/juneau/rest/server/views/PageTable.java |  86 +++++++-
 .../apache/juneau/rest/server/views/ViewTable.java |  31 +--
 .../org/apache/juneau/views/juneau-pages.js        |  37 +++-
 .../rest/server/views/JuneauPagesJs_Test.java      |  24 +-
 .../rest/server/views/PageTable_Emit_Test.java     |  10 +-
 .../views/PageTable_SubtabPanelContract_Test.java  | 243 +++++++++++++++++++++
 .../rest/server/views/ViewsJs_PageSeam_Test.java   |  13 +-
 .../rest/server/views/ViewsMixin_Serving_Test.java |   9 +-
 11 files changed, 542 insertions(+), 64 deletions(-)

diff --git a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
index 06524220d0..816f0436d3 100644
--- a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
@@ -65,8 +65,8 @@ class HtmlBuilder_RawText_Test extends TestBase {
 	}
 
 	// SECURITY: RAWTEXT/rawText is verbatim by contract -- it does NOT silently neutralize a '</script>'
-	// end-tag sequence in the body.  Safety is the caller's responsibility (escape '<' -> \u003c), exactly as
-	// juneau-rest-server-views' ViewTable.escapeForScript does.  This test documents that contract.
+	// end-tag sequence in the body.  Safety is the caller's responsibility; callers embedding a JSON payload should
+	// use StringUtils.escapeForScript(String) rather than hand-rolling it.  This test documents that contract.
 	@Test void c01_scriptEndTagIsCallerResponsibility() throws Exception {
 		var s = HtmlSerializer.DEFAULT_SQ;
 
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
index fc49277272..11ad9f9e4f 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/StringUtils.java
@@ -1617,10 +1617,88 @@ public class StringUtils {
 		return sb.toString();
 	}
 
+	/**
+	 * Escapes a serialized-JSON string so it can be safely embedded as the raw-text content of an HTML
+	 * {@code <script>} element.
+	 *
+	 * <p>
+	 * This is the escaper for a JSON <i>sidecar</i> &mdash; a payload handed to the browser as the content of a
+	 * {@code <script type="application/json" id="...">} element, or assigned to a variable inside a plain inline
+	 * {@code <script>}.  It substitutes only characters whose JSON encoding is unambiguous, so the result stays
+	 * valid, round-trippable JSON that parses back to the original value:
+	 *
+	 * <ul class='spaced-list'>
+	 * 	<li>{@code <} &rarr; {@code \u005Cu003c}
+	 * 	<li>{@code U+2028} (LINE SEPARATOR) &rarr; {@code \u005Cu2028}
+	 * 	<li>{@code U+2029} (PARAGRAPH SEPARATOR) &rarr; {@code \u005Cu2029}
+	 * </ul>
+	 *
+	 * <h5 class='section'>Injection vectors this defends against (do not "simplify" these away):</h5>
+	 * <ul class='spaced-list'>
+	 * 	<li><b>{@code </script>} break-out.</b>  A {@code <script>} element's content is <i>raw text</i>: the HTML
+	 * 		parser scans it only for an end tag, which it matches ASCII-case-insensitively as {@code </script}
+	 * 		followed by whitespace, {@code /}, or {@code >}.  A payload containing that sequence closes the element
+	 * 		early and everything after it becomes live markup.  Escaping {@code <} neutralizes it at the source.  A
+	 * 		targeted {@code replace("</script>", ...)} does <b>not</b>: it misses {@code </SCRIPT>},
+	 * 		{@code </script foo>}, and {@code </script/}.
+	 * 	<li><b>{@code <!--} / {@code <script} double-escape confusion.</b>  Inside script content, {@code <!--}
+	 * 		switches the tokenizer to its "script data escaped" state, and a subsequent {@code <script} switches it
+	 * 		to "script data double escaped" &mdash; a state in which the element's own real {@code </script>} no
+	 * 		longer closes it, so the rest of the document is swallowed as script data and the page's structure is
+	 * 		attacker-controlled.  Escaping {@code </} alone does <b>not</b> stop this; escaping {@code <} does.
+	 * 	<li><b>{@code U+2028}/{@code U+2029} string-literal break-out.</b>  Both are legal raw characters inside a
+	 * 		JSON string but were JavaScript <i>line terminators</i> before ES2019, so a payload embedded in a
+	 * 		JavaScript expression context could terminate its string literal mid-value.  Escaped unconditionally so
+	 * 		one helper is correct for both the {@code type="application/json"} and the inline-expression form.
+	 * </ul>
+	 *
+	 * <h5 class='section'>Why <c>&amp;</c> and {@code >} are deliberately NOT escaped:</h5>
+	 * <p>
+	 * Raw-text content is not entity-decoded by the browser, so HTML character references buy no safety here and
+	 * {@link #escapeHtml(String)} is the <b>wrong</b> tool for this context &mdash; it would rewrite <c>&amp;</c>
+	 * to <c>&amp;amp;</c> and corrupt the payload the moment {@code JSON.parse} reads it back verbatim.  For the
+	 * same reason {@code >} cannot terminate anything on its own once {@code <} is gone.
+	 *
+	 * <h5 class='section'>Why the substitutions are safe:</h5>
+	 * <p>
+	 * In valid JSON, {@code <}, {@code U+2028}, and {@code U+2029} can only occur inside a string literal (JSON's
+	 * inter-token whitespace is limited to space, tab, CR, and LF), and inside a string literal a backslash is
+	 * always itself escaped as {@code \u005C\u005C}.  The inserted backslash therefore always begins a fresh
+	 * escape sequence rather than extending a preceding one.
+	 *
+	 * <h5 class='section'>Example:</h5>
+	 * <p class='bjava'>
+	 * 	<jc>// Serialize, escape, then insert as VERBATIM raw script content (never entity-encoded).</jc>
+	 * 	String <jv>json</jv> = <jsm>escapeForScript</jsm>(Json.<jsm>of</jsm>(<jv>meta</jv>));
+	 * 	<jc>// {"label":"x\u005Cu003c/script&gt;"} - parses back to the original "x&lt;/script&gt;"</jc>
+	 * </p>
+	 *
+	 * @param json The <b>already-serialized JSON</b> to escape.  Must be JSON: on arbitrary text these
+	 * 	substitutions are not escape sequences and would alter the value.  Can be <jk>null</jk> (returns
+	 * 	<jk>null</jk>).
+	 * @return The break-out-safe JSON, or <jk>null</jk> if input is <jk>null</jk>.
+	 * @see #escapeHtml(String)
+	 */
+	public static String escapeForScript(String json) {
+		if (json == null)
+			return null;
+		// The search terms are the RAW characters (a Java source \\u escape is pre-processed to the single character);
+		// the replacements are their 6-character JSON escape sequences.
+		return json
+			.replace("<", "\\u003c")
+			.replace("\u2028", "\\u2028")
+			.replace("\u2029", "\\u2029");
+	}
+
 	/**
 	 * Escapes HTML entities in a string.
 	 *
 	 * <p>
+	 * Use this for text interpolated into HTML <i>element content or an attribute value</i>.  It is <b>not</b> the
+	 * right escaper for the raw-text content of a {@code <script>} element &mdash; see
+	 * {@link #escapeForScript(String)} for that.
+	 *
+	 * <p>
 	 * Escapes the following characters:
 	 * <ul>
 	 *   <li><js>'&amp;'</js> → <js>"&amp;amp;"</js></li>
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
index 25700afafb..4fc5fb4c5b 100755
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
@@ -1069,6 +1069,77 @@ class StringUtils_Test extends TestBase {
 		assertEquals("Test\\u0100Test", escapeForJava("Test\u0100Test")); // Latin capital A with macron
 	}
 
+	//====================================================================================================
+	// escapeForScript(String)
+	//====================================================================================================
+
+	/** The 6-character JSON escape for {@code '<'}.  Not a Java unicode escape - the leading {@code \\} is literal. */
+	private static final String JSON_LT = "\\u003c";
+
+	/** U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR, as raw single characters. */
+	private static final String RAW_LS = String.valueOf((char)0x2028);
+	private static final String RAW_PS = String.valueOf((char)0x2029);
+
+	@Test
+	void a042b_escapeForScript_basics() {
+		assertNull(escapeForScript(null));
+		assertEquals("", escapeForScript(""));
+		// Nothing dangerous -> byte-identical passthrough (never entity-encodes: '&' and '>' must survive intact,
+		// since <script> raw text is not entity-decoded by the browser).
+		assertEquals("{\"a\":\"b & c > d\"}", escapeForScript("{\"a\":\"b & c > d\"}"));
+	}
+
+	@Test
+	void a042b1_escapeForScript_neutralizesScriptEndTagBreakout() {
+		// The primary vector: an end tag inside the payload would close the <script> element early.
+		assertEquals("{\"v\":\"" + JSON_LT + "/script>\"}", escapeForScript("{\"v\":\"</script>\"}"));
+	}
+
+	@Test
+	void a042b2_escapeForScript_neutralizesEveryEndTagSpelling() {
+		// Proves this is NOT a `replace("</script>", ...)` substring guard: the HTML parser matches `</script`
+		// case-insensitively followed by whitespace, '/', or '>', so all of these terminate the element.
+		for (var s : list("</script>", "</SCRIPT>", "</ScRiPt>", "</script >", "</script\t>", "</script/", "</script"))
+			assertFalse(escapeForScript("{\"v\":\"" + s + "\"}").contains("<"), s);
+	}
+
+	@Test
+	void a042b3_escapeForScript_neutralizesDoubleEscapeConfusion() {
+		// `<!--` puts the tokenizer in "script data escaped" state and a following `<script` in "script data double
+		// escaped", where the element's own real </script> no longer closes it - so escaping only `</` is not enough.
+		var a = escapeForScript("{\"v\":\"<!-- <script>alert(1)</script>\"}");
+		assertFalse(a.contains("<"), a);
+		assertEquals("{\"v\":\"" + JSON_LT + "!-- " + JSON_LT + "script>alert(1)" + JSON_LT + "/script>\"}", a);
+	}
+
+	@Test
+	void a042b4_escapeForScript_neutralizesLineAndParagraphSeparators() {
+		// Legal raw characters in a JSON string, but JavaScript line terminators before ES2019 - they would break out
+		// of a string literal when the payload is embedded in a JS expression context.
+		assertEquals("{\"v\":\"a\\u2028b\"}", escapeForScript("{\"v\":\"a" + RAW_LS + "b\"}"));
+		assertEquals("{\"v\":\"a\\u2029b\"}", escapeForScript("{\"v\":\"a" + RAW_PS + "b\"}"));
+		var a = escapeForScript("{\"v\":\"" + RAW_LS + RAW_PS + "\"}");
+		assertFalse(a.contains(RAW_LS), a);
+		assertFalse(a.contains(RAW_PS), a);
+	}
+
+	@Test
+	void a042b5_escapeForScript_insertedBackslashNeverExtendsAPrecedingEscape() {
+		// A '<' in valid JSON can only sit inside a string literal, where a literal backslash is always already
+		// doubled - so the inserted '\' starts a fresh escape rather than turning the preceding '\\' into '\\\'.
+		// Input JSON below carries the value `a\<b` (backslash, then '<').
+		assertEquals("{\"v\":\"a\\\\" + JSON_LT + "b\"}", escapeForScript("{\"v\":\"a\\\\<b\"}"));
+	}
+
+	@Test
+	void a042b6_escapeForScript_outputCarriesNoBreakoutCharacterAtAll() {
+		// The invariant a future "simplification" must not break: no raw '<', U+2028, or U+2029 survives.
+		var a = escapeForScript("{\"v\":\"</script><!--<script>" + RAW_LS + RAW_PS + "\"}");
+		assertFalse(a.contains("<"), a);
+		assertFalse(a.contains(RAW_LS), a);
+		assertFalse(a.contains(RAW_PS), a);
+	}
+
 	//====================================================================================================
 	// escapeHtml(String)
 	//====================================================================================================
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
index 42cccce3ac..ff2e756442 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -17,11 +17,13 @@
 package org.apache.juneau.rest.server.views;
 
 import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
 
 import java.util.*;
 
 import org.apache.juneau.bean.html5.*;
 import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.*;
 import org.apache.juneau.marshall.marshaller.*;
 
@@ -33,12 +35,31 @@ import org.apache.juneau.marshall.marshaller.*;
  *
  * <p>
  * Mirrors the sibling {@link ViewTable} emitter pattern exactly, including its escaping contract (see that class's
- * javadoc): PAGE_META is serialized with the repo's canonical compact JSON marshaller, has every {@code <} escaped
- * to its JSON unicode escape ({@link ViewTable#escapeForScript(String)} &mdash; reused verbatim, not re-implemented,
- * so the two sidecars can never drift in how they neutralize a {@code </script>} break-out), and is inserted as
+ * javadoc): PAGE_META is serialized with the repo's canonical compact JSON marshaller, is passed through
+ * {@link StringUtils#escapeForScript(String)} (the same shared escaper {@link ViewTable} uses, so the two sidecars
+ * can never drift in how they neutralize a {@code </script>} break-out), and is inserted as
  * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) raw content} so the returned bean stays
  * re-serializable.
  *
+ * <h5 class='section'>Panel markup contract (shared with {@code juneau-pages.js} &mdash; both sides MUST agree):</h5>
+ * <p>
+ * Panel visibility is a two-attribute, hierarchically-narrowing contract:
+ * <ul class='spaced-list'>
+ * 	<li>{@link #PANEL_TAB_ATTR} scopes a panel to one top-level tab and is emitted on <b>every</b> panel.
+ * 	<li>{@link #PANEL_SUBTAB_ATTR} is <b>optional</b> and only <i>narrows</i> a panel further, to one specific
+ * 		sub-tab.  Omitting it means the panel is sub-tab-<b>agnostic</b>: it is shown whenever its tab is active,
+ * 		whichever sub-tab that tab resolved to.
+ * </ul>
+ * <p>
+ * A leaf tab (one declaring {@link Tab#view}) therefore emits a single sub-tab-agnostic {@link #PANEL_CLASS} panel.
+ * A tab declaring {@link Tab#subtabs} emits <b>two nested levels</b>: an outer {@link #PANEL_CLASS} panel carrying
+ * only {@link #PANEL_TAB_ATTR} (it wraps the sub-tab bar and must stay visible for <i>all</i> of its sub-tabs, so it
+ * deliberately carries no {@link #PANEL_SUBTAB_ATTR} &mdash; a static attribute could only ever name one of them),
+ * and inside it one {@link #SUBPANEL_CLASS} per sub-tab carrying <b>both</b> attributes.  Because
+ * {@code juneau-views.css} hides both panel classes until the runtime adds {@code .jc-active}, the runtime's
+ * matching rule must treat a missing {@link #PANEL_SUBTAB_ATTR} as "any sub-tab"; requiring an exact match instead
+ * leaves the outer panel {@code display:none} and blanks the whole tab, sub-tab bar and active sub-panel included.
+ *
  * <h5 class='section'>No module dependency, class-based chrome only (Decision 1(A)):</h5>
  * <p>
  * The emitted shell carries only the neutral {@code .jc-*} classes ({@link #PAGE_CLASS}, {@link #TAB_CLASS},
@@ -97,6 +118,48 @@ public class PageTable {
 	/** Class on a sub-tab panel (nested inside a tab's panel). */
 	public static final String SUBPANEL_CLASS = "jc-subpanel";
 
+	/**
+	 * Attribute scoping a panel to one top-level tab; emitted on <b>every</b> panel.
+	 *
+	 * <p>
+	 * Published alongside the {@code .jc-*} class constants because it is the load-bearing half of the panel markup
+	 * contract documented in this class's javadoc: {@code juneau-pages.js}'s {@code panelMatches} reads this exact
+	 * attribute name and cannot import a Java constant, so renaming it here without mirroring it there silently
+	 * blanks every panel.  {@code PageTable_SubtabPanelContract_Test} asserts the two spellings still agree.
+	 */
+	public static final String PANEL_TAB_ATTR = "data-panel-tab";
+
+	/**
+	 * Attribute narrowing a panel to one specific sub-tab; <b>optional</b>, and its <i>absence</i> is meaningful.
+	 *
+	 * <p>
+	 * A panel omitting it is sub-tab-agnostic (shown for whichever sub-tab its tab resolved to) &mdash; the rule a
+	 * sub-tabbed tab's outer panel depends on to render at all.  Same cross-artifact caveat as
+	 * {@link #PANEL_TAB_ATTR}.
+	 */
+	public static final String PANEL_SUBTAB_ATTR = "data-panel-subtab";
+
+	/**
+	 * Attribute carrying a top-level tab-bar link's tab id, compared by the runtime to decide which tab reads as
+	 * selected.  Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+	 */
+	public static final String TAB_ID_ATTR = "data-tab-id";
+
+	/**
+	 * Attribute carrying a sub-tab-bar link's sub-tab id.  Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+	 */
+	public static final String SUBTAB_ID_ATTR = "data-subtab-id";
+
+	/**
+	 * Attribute carrying the id of the tab a sub-tab link belongs to.
+	 *
+	 * <p>
+	 * Sub-tab ids are only required to be unique <i>within</i> their tab, so the runtime pairs this with
+	 * {@link #SUBTAB_ID_ATTR} before marking a sub-tab selected; matching on the sub-tab id alone would light up a
+	 * same-named sub-tab under a different tab.  Same cross-artifact caveat as {@link #PANEL_TAB_ATTR}.
+	 */
+	public static final String PARENT_TAB_ATTR = "data-parent-tab";
+
 	private PageTable() {}
 
 	/**
@@ -128,7 +191,7 @@ public class PageTable {
 			tabBarChildren.add(
 				a(hashHref(id, t.id, null), t.label == null ? t.id : t.label)
 					.class_(TAB_CLASS)
-					.attr("data-tab-id", t.id));
+					.attr(TAB_ID_ATTR, t.id));
 		var tabBar = nav(tabBarChildren.toArray()).class_(TAB_BAR_CLASS).attr("role", "tablist");
 
 		var panelsChildren = new ArrayList<>();
@@ -136,7 +199,7 @@ public class PageTable {
 			panelsChildren.add(buildTabPanel(ctx, id, t));
 		var panels = div(panelsChildren.toArray()).class_("jc-panels");
 
-		var json = ViewTable.escapeForScript(Json.of(buildMeta(pageDef)));
+		var json = escapeForScript(Json.of(buildMeta(pageDef)));
 		var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
 
 		return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
@@ -146,7 +209,7 @@ public class PageTable {
 	private static Div buildTabPanel(MarshallingContext ctx, String pageId, Tab t) {
 		if (t.view != null) {
 			var body = ViewTable.of(ctx, t.view, null);
-			return div(body).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+			return div(body).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
 		}
 
 		var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
@@ -156,19 +219,22 @@ public class PageTable {
 			subtabBarChildren.add(
 				a(hashHref(pageId, t.id, s.id), s.label == null ? s.id : s.label)
 					.class_(SUBTAB_CLASS)
-					.attr("data-subtab-id", s.id)
-					.attr("data-parent-tab", t.id));
+					.attr(SUBTAB_ID_ATTR, s.id)
+					.attr(PARENT_TAB_ATTR, t.id));
 		var subtabBar = nav(subtabBarChildren.toArray()).class_(SUBTAB_BAR_CLASS).attr("role", "tablist");
 
 		var subpanelsChildren = new ArrayList<>();
 		for (var s : subtabs) {
 			var body = ViewTable.of(ctx, s.view, null);
 			subpanelsChildren.add(
-				div(body).class_(SUBPANEL_CLASS).attr("data-panel-tab", t.id).attr("data-panel-subtab", s.id));
+				div(body).class_(SUBPANEL_CLASS).attr(PANEL_TAB_ATTR, t.id).attr(PANEL_SUBTAB_ATTR, s.id));
 		}
 		var subpanels = div(subpanelsChildren.toArray()).class_("jc-subpanels");
 
-		return div(subtabBar, subpanels).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+		// Tab-scoped only, on purpose: this panel wraps the sub-tab bar and must be visible for EVERY sub-tab, so it
+		// stays sub-tab-agnostic (see the panel markup contract in this class's javadoc).  Do not add
+		// data-panel-subtab here - it would pin the whole tab to a single sub-tab and blank it for the others.
+		return div(subtabBar, subpanels).class_(PANEL_CLASS).attr(PANEL_TAB_ATTR, t.id);
 	}
 
 	/** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 60283db7f4..10b7ccc684 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -17,10 +17,12 @@
 package org.apache.juneau.rest.server.views;
 
 import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.apache.juneau.commons.utils.StringUtils.escapeForScript;
 
 import java.util.*;
 
 import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.*;
 import org.apache.juneau.marshall.marshaller.*;
 
@@ -50,10 +52,12 @@ import org.apache.juneau.marshall.marshaller.*;
  * The VIEW_META JSON is emitted as the text content of a {@code <script type="application/json">} element.  Per the
  * HTML spec such content is <b>raw text</b> (HTML entities are NOT decoded inside it) and must not contain the
  * substring {@code </} (nor {@code <!--}), or it would prematurely terminate the element.  This emitter therefore
- * escapes every {@code <} in the serialized JSON to its JSON unicode escape {@code \u003c} <b>before</b> insertion,
- * which neutralizes {@code </script>}, {@code <script}, and {@code <!--} break-outs while keeping the payload valid,
- * round-trippable JSON.  The JSON is inserted as verbatim raw content (via
- * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) rawText}) so Juneau's normal XML/HTML text
+ * hands the serialized JSON to {@link StringUtils#escapeForScript(String)} <b>before</b> insertion, which
+ * neutralizes {@code </script>},
+ * {@code <script}, and {@code <!--} break-outs while keeping the payload valid, round-trippable JSON.  That method
+ * is the single, shared, publicly reusable implementation &mdash; see its javadoc for the exact vectors covered, and
+ * reuse it rather than hand-rolling an escaper for your own sidecar.  The JSON is inserted as verbatim raw content
+ * (via {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) rawText}) so Juneau's normal XML/HTML text
  * entity-encoding does not corrupt the {@code application/json} payload (it would otherwise turn {@code &}/{@code >}
  * into {@code &amp;}/{@code &gt;}, which browsers do NOT decode inside a raw-text {@code <script>}).  Because
  * {@code rawText} is backed by a {@code String} (not a one-shot {@link java.io.Reader}), the returned bean is fully
@@ -137,30 +141,13 @@ public class ViewTable {
 
 		var table = table(tableChildren.toArray()).id(id).attr(MARKER_ATTR, id);
 
-		// Sidecar: serialize the VIEW_META, escape '<' -> \u003c, then insert as RAW content (see class javadoc).
+		// Sidecar: serialize the VIEW_META, neutralize script break-outs, then insert as RAW content (class javadoc).
 		var json = escapeForScript(Json.of(viewDef));
 		var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
 
 		return div(table, sidecar);
 	}
 
-	/**
-	 * Escapes every {@code <} in a serialized-JSON string to its JSON unicode escape {@code \u003c} so the JSON can be
-	 * safely embedded as raw-text {@code <script>} content (design doc §6.1).
-	 *
-	 * <p>
-	 * A blanket {@code <} &rarr; {@code \u003c} substitution is safe: {@code <} never appears inside a JSON escape
-	 * sequence, and {@code \u003c} is the valid JSON encoding of {@code <}, so the result stays parseable JSON that
-	 * decodes back to the original value.  Neutralizing {@code <} alone covers every HTML break-out sequence for a
-	 * raw-text element ({@code </script}, {@code <script}, {@code <!--}).
-	 *
-	 * @param json The serialized JSON.  Must not be <jk>null</jk>.
-	 * @return The break-out-safe JSON.
-	 */
-	static String escapeForScript(String json) {
-		return json.replace("<", "\\u003c");
-	}
-
 	/** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
 	private static Object value(MarshallingContext ctx, Object row, String key) {
 		if (row instanceof java.util.Map<?,?> m)
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
index c2c341c6e9..d94cbc1abe 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -133,17 +133,46 @@
 		root.insertBefore(b, root.firstChild);
 	}
 
-	/** Whether `panel`'s data-panel-tab/data-panel-subtab attributes match the resolved active (tabId, subtabId). */
+	// Every node whose visibility this runtime owns: a top-level tab panel, or a sub-tab panel nested inside one.
+	var PANEL_SELECTOR = ".jc-panel, .jc-subpanel";
+
+	/*
+	 * Whether `panel` should be visible for the resolved active (tabId, subtabId).
+	 *
+	 * PANEL MARKUP CONTRACT (produced by the PageTable emitter, honored here - the two MUST agree):
+	 *   - `data-panel-tab` scopes a panel to one top-level tab, and is present on EVERY panel.
+	 *   - `data-panel-subtab` is OPTIONAL and only NARROWS a panel further, to one specific sub-tab.
+	 *   - A panel that omits `data-panel-subtab` is therefore sub-tab-AGNOSTIC: it is shown whenever its tab is
+	 *     active, whichever sub-tab that tab resolved to.
+	 *
+	 * That last rule is what makes a sub-tabbed tab render at all.  PageTable emits TWO nested levels for such a
+	 * tab: an outer `.jc-panel` (tab-scoped only) wrapping the sub-tab bar plus one `.jc-subpanel` per sub-tab
+	 * (tab- AND sub-tab-scoped).  Because juneau-views.css hides `.jc-panel` until it carries `.jc-active`,
+	 * demanding an exact `data-panel-subtab` match here would leave that outer panel `display:none` - hiding the
+	 * sub-tab bar and the active sub-panel nested inside it, i.e. rendering the entire tab blank.  The outer panel
+	 * cannot fix this from the emitter side either: it must be visible for EVERY one of its sub-tabs, and a static
+	 * attribute can only name one of them.
+	 */
 	function panelMatches(panel, tabId, subtabId) {
 		if (panel.getAttribute("data-panel-tab") !== tabId) return false;
 		var panelSubtabId = panel.getAttribute("data-panel-subtab");
-		return subtabId != null ? panelSubtabId === subtabId : !panelSubtabId;
+		if (!panelSubtabId) return true;   // sub-tab-agnostic panel: tab match is sufficient
+		return panelSubtabId === subtabId;
 	}
 
-	/** Lazily inits (or, if already a DataTable, columns.adjust()s) every view table inside a just-shown panel. */
+	/*
+	 * Lazily inits (or, if already a DataTable, columns.adjust()s) the view tables this panel OWNS.
+	 *
+	 * Tables sitting inside a DESCENDANT panel are skipped - a sub-tabbed tab's outer panel contains one
+	 * `.jc-subpanel` per sub-tab, and those tables belong to their own sub-panel, which inits them when IT is
+	 * activated.  Claiming them here would defeat lazy init (every sub-tab's ajax draw would fire on page load)
+	 * and would size their columns while they are still `display:none`, the exact mis-sizing lazy init exists to
+	 * avoid.
+	 */
 	function activatePanelViews(panel) {
 		var tables = panel.querySelectorAll("table[data-juneau-view]");
 		Array.prototype.forEach.call(tables, function (t) {
+			if (t.closest(PANEL_SELECTOR) !== panel) return;
 			var $ = window.jQuery;
 			if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.isDataTable(t)) {
 				$(t).DataTable().columns.adjust();
@@ -171,7 +200,7 @@
 			el.classList.toggle("jc-subtab-active", active);
 		});
 
-		var panels = root.querySelectorAll(".jc-panel, .jc-subpanel");
+		var panels = root.querySelectorAll(PANEL_SELECTOR);
 		Array.prototype.forEach.call(panels, function (p) {
 			var active = panelMatches(p, tabId, subtabId);
 			p.classList.toggle("jc-active", active);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
index 0a5da6d02c..1eecde1098 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
@@ -27,15 +27,14 @@ import org.junit.jupiter.api.*;
  * {@code juneau-pages.js} pure hash-routing logic + DOM binding shim tests (TODO-399 Phase C, Tasks 7-8).
  *
  * <p>
- * <b>Test-approach resolution (⚠️ UNVERIFIED in the plan):</b> this module has no JS-execution (jsdom/Option-B)
- * harness &mdash; confirmed while implementing Task 6 by inspecting {@code ViewsMixin_Serving_Test}, whose {@code
- * e03}/{@code g04} tests are Option-A-only (served-script content-substring assertions, with an explicit code
- * comment noting true JS-execution verification "awaits the deferred Option-B (jsdom) harness").  Per that
- * established convention, these tests extract each pure function's source body from the served script and assert
- * on its control-flow markers, mirroring {@code g04_viewsJs_columnDefsSetDefaultContentForUndefinedSafety}'s
- * function-body-extraction style.  The functions are still written as a DOM-free pure layer (see the class-header
- * comment in {@code juneau-pages.js}), so a future Option-B harness can execute them directly with zero
- * refactoring &mdash; only the verification depth of these particular tests would change.
+ * <b>Scope:</b> these tests extract each pure function's source body from the served script and assert on its
+ * control-flow markers.  They are cheap, always-on tripwires that need no Node, and they are deliberately <i>not</i>
+ * the proof that the runtime works: source shape cannot distinguish a working page from a blank one.  That proof
+ * lives in {@link PagePanelVisibility_BrowserTest}, which executes this script in a real browser and asserts on
+ * rendered visibility &mdash; opt-in, behind the module's {@code js-tests} Maven profile.
+ * <p>
+ * The functions remain written as a DOM-free pure layer (see the class-header comment in {@code juneau-pages.js}),
+ * so the harness could also drive them directly should these particular assertions ever need that depth.
  */
 class JuneauPagesJs_Test extends TestBase {
 
@@ -129,9 +128,10 @@ class JuneauPagesJs_Test extends TestBase {
 		// (getAttribute(...) === ...), never interpolated into a querySelector(...) string or innerHTML.
 		var body = pagesJs();
 		var fn = functionBody(body, "showActive");
-		assertTrue(fn.contains("getAttribute(\"data-tab-id\") === tabId"), fn);
-		assertTrue(fn.contains("getAttribute(\"data-subtab-id\") === subtabId"), fn);
-		assertFalse(fn.contains("querySelector(\"[data-tab-id=\""), fn);
+		// Spelled from the emitter's constants, so these double as part of the name-correspondence pin.
+		assertTrue(fn.contains("getAttribute(\"" + PageTable.TAB_ID_ATTR + "\") === tabId"), fn);
+		assertTrue(fn.contains("getAttribute(\"" + PageTable.SUBTAB_ID_ATTR + "\") === subtabId"), fn);
+		assertFalse(fn.contains("querySelector(\"[" + PageTable.TAB_ID_ATTR + "=\""), fn);
 		assertFalse(fn.contains("innerHTML"), fn);
 	}
 
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
index 8a93248bf4..7a0a36b4a7 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
@@ -89,18 +89,18 @@ class PageTable_Emit_Test extends TestBase {
 
 	@Test void a02_emitsOneTabBarEntryPerTab() {
 		var html = Html.of(PageTable.of(leafPage()));
-		assertTrue(html.contains("data-tab-id=\"releases\""), html);
-		assertTrue(html.contains("data-tab-id=\"users\""), html);
+		assertTrue(html.contains(PageTable.TAB_ID_ATTR + "=\"releases\""), html);
+		assertTrue(html.contains(PageTable.TAB_ID_ATTR + "=\"users\""), html);
 		assertTrue(html.contains(PageTable.TAB_CLASS), html);
 	}
 
 	@Test void a03_emitsSubTabBarOnlyForTabsWithSubtabs() {
 		var html = Html.of(PageTable.of(pageWithSubtabs()));
-		assertTrue(html.contains("data-subtab-id=\"packages\""), html);
-		assertTrue(html.contains("data-subtab-id=\"bundles\""), html);
+		assertTrue(html.contains(PageTable.SUBTAB_ID_ATTR + "=\"packages\""), html);
+		assertTrue(html.contains(PageTable.SUBTAB_ID_ATTR + "=\"bundles\""), html);
 		assertTrue(html.contains(PageTable.SUBTAB_CLASS), html);
 		// The leaf "releases" tab has no subtabs -> no subtab-bar markers referencing it.
-		assertFalse(html.contains("data-parent-tab=\"releases\""), html);
+		assertFalse(html.contains(PageTable.PARENT_TAB_ATTR + "=\"releases\""), html);
 	}
 
 	@Test void a04_eachViewGetsItsOwnMarkerTableAndSidecar() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
new file mode 100644
index 0000000000..63b6fe3031
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
@@ -0,0 +1,243 @@
+/*
+ * 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.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Regression barrier for the panel-visibility contract that decides whether a <b>sub-tabbed</b> tab renders at all.
+ *
+ * <p>
+ * A tab declaring {@link Tab#subtabs} is emitted as two nested levels: an outer {@link PageTable#PANEL_CLASS} panel
+ * (tab-scoped only) wrapping the sub-tab bar plus one {@link PageTable#SUBPANEL_CLASS} per sub-tab (tab- <i>and</i>
+ * sub-tab-scoped).  Three independently-editable artifacts have to agree for that to be visible, and a mismatch
+ * between any two of them renders the entire tab <b>blank</b> while leaving leaf tabs working perfectly &mdash; which
+ * is precisely how such a mismatch escapes a leaf-tab-only test suite:
+ * <ol class='spaced-list'>
+ * 	<li><b>{@code juneau-views.css}</b> hides {@code .jc-panel}/{@code .jc-subpanel} until {@code .jc-active} is
+ * 		added, so the outer panel <i>must</i> receive {@code .jc-active} or its descendants stay invisible no matter
+ * 		what classes they carry.
+ * 	<li><b>{@link PageTable}</b> deliberately emits the outer panel with {@link PageTable#PANEL_TAB_ATTR} and
+ * 		<i>no</i> {@link PageTable#PANEL_SUBTAB_ATTR} (it must be visible for every one of its sub-tabs; a static
+ * 		attribute could only name one).
+ * 	<li><b>{@code juneau-pages.js}</b>'s {@code panelMatches} must therefore treat a missing
+ * 		{@link PageTable#PANEL_SUBTAB_ATTR} as "any sub-tab" rather than demanding an exact match.
+ * </ol>
+ *
+ * <h5 class='section'>Division of labour with the browser harness:</h5>
+ * <p>
+ * Points 1 and 2 are asserted here, directly against the served CSS and the emitted markup, and need nothing but a
+ * JVM.  Point 3 is a claim about <i>runtime behavior</i>, so it is proven by executing the runtime in a real browser
+ * in {@link PagePanelVisibility_BrowserTest} rather than by pattern-matching the script's source &mdash; a substring
+ * assertion can tell you the code still <i>looks</i> right while the page renders blank.
+ * <p>
+ * What this class keeps for the runtime side is the one thing a behavioural test cannot cover: the attribute and
+ * class <b>names</b> are shared across a Java/JavaScript boundary that no compiler checks, and the browser harness is
+ * opt-in, so a rename that never reached {@code juneau-pages.js} would sail through a default build and blank every
+ * panel at runtime.  Section {@code c} pins those spellings so that mismatch fails a build instead.
+ *
+ * <h5 class='section'>Which names are constants, and which are not:</h5>
+ * <p>
+ * Every name the <i>emitter</i> writes and the runtime reads is a {@link PageTable} constant, because Java is one of
+ * the two parties and a constant is how one party states the name once.  The active-state classes
+ * ({@code .jc-tab-active}, {@code .jc-subtab-active}, {@code .jc-active}) deliberately are <b>not</b>: the emitter
+ * never writes them, so a public Java constant for them would publish API surface for a string this module's Java
+ * never produces.  They are still a two-artifact contract &mdash; {@code juneau-pages.js} sets them and
+ * {@code juneau-views.css} styles them &mdash; so they get the same protection a different way, in {@code c03}.
+ */
+class PageTable_SubtabPanelContract_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(WithMixin.class);
+
+	public static class Release {
+		public String name;
+	}
+
+	private static ViewDef view(String id) {
+		return ViewDef.create(id)
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/" + id + "/data")
+			.columns(Column.of("name").title("Name"))
+			.build();
+	}
+
+	/** A page mixing a leaf tab with a sub-tabbed tab - the leaf tab is the case that never broke. */
+	private static PageDef pageWithSubtabs() {
+		return PageDef.create("admin")
+			.tabs(
+				Tab.create("releases", "Releases").view(view("releases")),
+				Tab.create("catalog", "Catalog").subtabs(
+					Subtab.create("packages", "Packages").view(view("packages")),
+					Subtab.create("bundles", "Bundles").view(view("bundles"))))
+			.build();
+	}
+
+	/** Every {@code <div ...>} start tag in the markup, so a panel's OWN attributes can be inspected in isolation. */
+	private static List<String> divTags(String html) {
+		var l = new ArrayList<String>();
+		var i = 0;
+		while ((i = html.indexOf("<div", i)) >= 0) {
+			var end = html.indexOf('>', i);
+			assertTrue(end >= 0, () -> "unterminated <div in:\n" + html);
+			l.add(html.substring(i, end + 1));
+			i = end + 1;
+		}
+		return l;
+	}
+
+	/** The single {@code <div>} start tag containing all of {@code required}; fails when not exactly one matches. */
+	private static String theDivTag(String html, String...required) {
+		var matches = divTags(html).stream().filter(x -> Arrays.stream(required).allMatch(x::contains)).toList();
+		assertEquals(1, matches.size(),
+			() -> "expected exactly one <div> tag containing " + Arrays.toString(required) + ", found " + matches + " in:\n" + html);
+		return matches.get(0);
+	}
+
+	/** {@code data-panel-tab="<id>"} as it appears in the markup, spelled from the emitter's own constant. */
+	private static String tabAttr(String id) {
+		return PageTable.PANEL_TAB_ATTR + "=\"" + id + "\"";
+	}
+
+	/** {@code data-panel-subtab="<id>"}, likewise. */
+	private static String subtabAttr(String id) {
+		return PageTable.PANEL_SUBTAB_ATTR + "=\"" + id + "\"";
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a: the emitted markup side of the contract
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_subtabbedTabOuterPanelIsTabScopedOnly() {
+		// The load-bearing emitter invariant: the outer panel wrapping a sub-tab bar must NOT be pinned to one
+		// sub-tab, because it has to stay visible for all of them.
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("catalog"));
+		assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR),
+			() -> "the outer .jc-panel of a sub-tabbed tab must stay sub-tab-agnostic (adding data-panel-subtab pins the whole tab to one sub-tab and blanks it for the others): " + tag);
+	}
+
+	@Test void a02_eachSubpanelIsTabAndSubtabScoped() {
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		for (var id : List.of("packages", "bundles")) {
+			var tag = theDivTag(html, PageTable.SUBPANEL_CLASS, subtabAttr(id));
+			assertTrue(tag.contains(tabAttr("catalog")),
+				() -> "a .jc-subpanel must also carry its parent tab id, so it can never activate under another tab: " + tag);
+		}
+	}
+
+	@Test void a03_leafTabPanelIsAlsoSubtabAgnostic() {
+		// Leaf and sub-tabbed outer panels share one rule ("no data-panel-subtab means any sub-tab"), which is why a
+		// leaf-tab-only suite exercises the rule but never the sub-tabbed case.
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		var tag = theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("releases"));
+		assertFalse(tag.contains(PageTable.PANEL_SUBTAB_ATTR), tag);
+	}
+
+	@Test void a04_subpanelsAreNestedInsideTheirTabsOuterPanel() {
+		// Nesting is what makes the outer panel's visibility a precondition for the sub-panels' visibility.
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		var outer = html.indexOf(theDivTag(html, PageTable.PANEL_CLASS + "\"", tabAttr("catalog")));
+		var sub = html.indexOf(theDivTag(html, PageTable.SUBPANEL_CLASS, subtabAttr("packages")));
+		assertTrue(outer >= 0 && sub > outer, () -> "expected .jc-subpanel nested after its outer .jc-panel:\n" + html);
+	}
+
+	@Test void a05_pageMetaGivesTheSubtabbedTabANonEmptySubtabList() {
+		// This is what makes resolveInitial(...) hand showActive(...) a NON-NULL subtabId for this tab - the
+		// precondition under which an exact-match panel rule would blank the outer panel.
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		var open = html.indexOf("id=\"" + PageTable.SIDECAR_ID_PREFIX + "admin\"");
+		assertTrue(open >= 0, () -> "PAGE_META sidecar not found:\n" + html);
+		var start = html.indexOf('>', open) + 1;
+		var meta = Json.to(html.substring(start, html.indexOf("</script>", start)), Map.class);
+		var catalog = (Map<?,?>) ((List<?>) meta.get("tabs")).get(1);
+		assertEquals("catalog", catalog.get("id"));
+		assertEquals(2, ((List<?>) catalog.get("subtabs")).size());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b: the CSS side of the contract
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_panelsAreHiddenUntilTheRuntimeAddsJcActive() throws Exception {
+		// Why the outer panel's .jc-active matters: without it the whole subtree - sub-tab bar included - is
+		// display:none, regardless of any class the runtime put on the sub-panel inside it.
+		var css = c.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString().replaceAll("\\s+", " ");
+		assertTrue(css.contains(".jc-panel, .jc-subpanel { display: none; }"), css);
+		assertTrue(css.contains(".jc-panel.jc-active, .jc-subpanel.jc-active { display: block; }"), css);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c: the Java-to-JavaScript name correspondence  (the half no compiler and no behavioural test can check)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_theRuntimeReadsEveryAttributeNameTheEmitterWrites() throws Exception {
+		// Built from the constants on purpose: rename any of these without mirroring the new spelling in
+		// juneau-pages.js and this fails, instead of the runtime silently failing to recognize what it is handed -
+		// which for a panel attribute means display:none forever, and for a tab-bar attribute means a tab that never
+		// reads as selected.
+		var js = pagesJs();
+		for (var attr : List.of(PageTable.PANEL_TAB_ATTR, PageTable.PANEL_SUBTAB_ATTR, PageTable.TAB_ID_ATTR, PageTable.SUBTAB_ID_ATTR, PageTable.PARENT_TAB_ATTR))
+			assertTrue(js.contains("getAttribute(\"" + attr + "\")"),
+				() -> "juneau-pages.js does not read '" + attr + "' - the emitter and the runtime have drifted apart, and an attribute the runtime cannot recognize is one it silently ignores:\n" + js);
+	}
+
+	@Test void c02_theRuntimeSelectsTheSamePanelClassNamesTheEmitterWrites() throws Exception {
+		// Same irreducible duplication, other half: the runtime's PANEL_SELECTOR names both panel levels literally.
+		var js = pagesJs();
+		assertTrue(js.contains("\"." + PageTable.PANEL_CLASS + ", ." + PageTable.SUBPANEL_CLASS + "\""),
+			() -> "juneau-pages.js must select both emitted panel levels; dropping either one re-hides it:\n" + js);
+	}
+
+	@Test void c03_theStylesheetStylesTheExactActiveClassNamesTheRuntimeToggles() throws Exception {
+		// The active-state class names are the one part of this contract with no Java side at all: the emitter never
+		// writes them, the runtime adds them and the stylesheet reacts to them.  So there is no constant to build
+		// these from - the pin is this test naming each one once and requiring both artifacts to agree, which is
+		// exactly the drift a rename in either file would otherwise cause (a class nothing styles, or a style
+		// nothing sets - both render as a page that quietly stops responding to clicks).
+		var js = pagesJs();
+		var css = viewsCss();
+		for (var cls : List.of("jc-tab-active", "jc-subtab-active", "jc-active")) {
+			assertTrue(js.contains("classList.toggle(\"" + cls + "\""),
+				() -> "juneau-pages.js no longer toggles '" + cls + "', but juneau-views.css still styles it:\n" + js);
+			assertTrue(css.contains("." + cls),
+				() -> "juneau-views.css does not style '." + cls + "', so the class juneau-pages.js sets has no visible effect:\n" + css);
+		}
+	}
+
+	private static String pagesJs() throws Exception {
+		return c.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	private static String viewsCss() throws Exception {
+		return c.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
index 15e50808ce..1fe880c6c2 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
@@ -27,11 +27,14 @@ import org.junit.jupiter.api.*;
  * Lazy-init seam test for {@code juneau-views.js} (TODO-399 Phase C, Task 6, design doc §"Client page runtime").
  *
  * <p>
- * There is no JS-execution (jsdom/Option-B) harness in this module yet (confirmed by inspecting
- * {@code ViewsMixin_Serving_Test}, which is Option-A-only: {@code e03}/{@code g04} assert on served-script content
- * substrings, not on executed behavior) &mdash; so, per that established convention, this test asserts on the
- * served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace (previously
- * private), and {@code initAll} must skip tables scoped under a {@code [data-juneau-page]} shell.
+ * Asserts on the served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace
+ * (previously private), and {@code initAll} must skip tables scoped under a {@code [data-juneau-page]} shell.
+ * <p>
+ * The <i>consequences</i> of this seam &mdash; that a panel lazy-inits exactly the view tables it owns, and that a
+ * sub-tabbed tab's outer panel does not claim its sub-panels' tables &mdash; are verified behaviourally in
+ * {@link PagePanelVisibility_BrowserTest}, which stubs {@code NS.init.initTable} in a real browser and records which
+ * views it is called for.  These text assertions remain as always-on tripwires for the seam's <i>shape</i>, since
+ * that harness is opt-in.
  */
 class ViewsJs_PageSeam_Test extends TestBase {
 
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 4b9cf80702..c9f59c647b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -270,8 +270,9 @@ class ViewsMixin_Serving_Test extends TestBase {
 		// Regression: the `tag` renderer must mirror console-ui's Tag#normalize/TagHtmlRender token algorithm
 		// (lowercase both <domain> and <value> into the `.tag.<domain>.<value>` CSS token) so themed chrome.css
 		// rules (e.g. `.tag.status.released`) match - a raw "RELEASED" cell must no longer render as an
-		// unthemed neutral chip.  Option-A (content substring) coverage; true JS-execution verification of the
-		// exact lowercased/hyphenated output awaits the deferred Option-B (jsdom) harness.
+		// unthemed neutral chip.  Content-substring coverage only: the module's browser harness
+		// (PagePanelVisibility_BrowserTest) covers the page runtime, not the renderer registry, so executing this
+		// renderer to check its exact lowercased/hyphenated output would need a second fixture there.
 		var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
 		assertTrue(body.contains("normalizeTagToken("), body);
 		var tagRendererStart = body.indexOf("registerRenderer(\"tag\"");
@@ -375,8 +376,8 @@ class ViewsMixin_Serving_Test extends TestBase {
 		// Regression: a nullable column's value is OMITTED (not null) by the server's JSON serializer, so
 		// DataTables' data accessor sees `undefined` and throws "Requested unknown parameter" (datatables.net/tn/4)
 		// before any renderer runs.  Every generated column def must set defaultContent so DataTables substitutes
-		// that value instead of warning - Option-A (content substring) coverage; true JS-execution verification of
-		// the undefined-safe behavior awaits the deferred Option-B (jsdom) harness.
+		// that value instead of warning - content-substring coverage only: proving the undefined-safe behavior would
+		// mean booting DataTables itself, which the module's browser harness deliberately stubs out rather than loads.
 		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
 		var buildColumnDefStart = body.indexOf("function buildColumnDef(");
 		assertTrue(buildColumnDefStart >= 0, () -> "buildColumnDef not found:\n" + body);