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 <[email protected]>
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);

Reply via email to