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:29 UTC

(juneau) 01/08: Add juneau-rest-server-views module and raw-text sidecar serialization

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 4b56621c3e727cd542b6152faf06c2c288981333
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 18 09:07:30 2026 -0400

    Add juneau-rest-server-views module and raw-text sidecar serialization
    
    Introduces juneau-rest-server-views, a declarative rich-DataTables view
    toolkit (ViewDef/Column/RibbonAction/RowClassRule, ViewTable/PageTable
    emitters, ViewsMixin asset serving, and the juneau-views client runtime),
    registered in the juneau-rest reactor and the BOM.
    
    The raw-text serialization support is committed alongside it rather than
    separately because ViewTable and PageTable emit their VIEW_META/PAGE_META
    sidecars via HtmlBuilder.rawText(String); without RawText/RawContent and
    the XML/HTML session changes the module does not compile.
    
    NOTICE gains the Material Symbols Outlined attribution for the icon glyphs
    bundled in juneau-icons.js.
---
 NOTICE                                             |   4 +
 .../org/apache/juneau/bean/html5/HtmlBuilder.java  |  19 +
 .../java/org/apache/juneau/bean/html5/RawText.java |  69 +++
 .../bean/html5/HtmlBuilder_RawText_Test.java       |  93 +++
 .../bean/html5/HtmlElementContainer_Nil_Test.java  |  50 ++
 juneau-bom/pom.xml                                 |   1 +
 .../marshall/html/HtmlSerializerSession.java       |   8 +-
 .../org/apache/juneau/marshall/xml/RawContent.java |  47 ++
 .../apache/juneau/marshall/xml/XmlBeanMeta.java    |   3 +-
 .../org/apache/juneau/marshall/xml/XmlFormat.java  |  25 +
 .../juneau/marshall/xml/XmlParserSession.java      |   7 +-
 .../juneau/marshall/xml/XmlSerializerSession.java  |  30 +-
 .../juneau/marshall/xml/Xml_RawText_Test.java      | 135 +++++
 juneau-rest/juneau-rest-server-views/pom.xml       | 122 ++++
 .../apache/juneau/rest/server/views/Column.java    | 170 ++++++
 .../apache/juneau/rest/server/views/PageDef.java   | 157 +++++
 .../apache/juneau/rest/server/views/PageTable.java | 246 ++++++++
 .../apache/juneau/rest/server/views/Render.java    |  98 ++++
 .../juneau/rest/server/views/RibbonAction.java     | 451 ++++++++++++++
 .../juneau/rest/server/views/RowClassRule.java     | 140 +++++
 .../apache/juneau/rest/server/views/Subtab.java    |  78 +++
 .../org/apache/juneau/rest/server/views/Tab.java   | 118 ++++
 .../apache/juneau/rest/server/views/ViewDef.java   | 359 ++++++++++++
 .../apache/juneau/rest/server/views/ViewTable.java | 170 ++++++
 .../juneau/rest/server/views/ViewsMixin.java       | 304 ++++++++++
 .../juneau/rest/server/views/package-info.java     |  43 ++
 .../org/apache/juneau/views/juneau-icons.js        |  71 +++
 .../org/apache/juneau/views/juneau-pages.js        | 236 ++++++++
 .../org/apache/juneau/views/juneau-renders.js      | 240 ++++++++
 .../org/apache/juneau/views/juneau-ribbon.js       | 354 +++++++++++
 .../org/apache/juneau/views/juneau-views.css       | 359 ++++++++++++
 .../org/apache/juneau/views/juneau-views.js        | 651 +++++++++++++++++++++
 .../rest/server/views/JuneauPagesJs_Test.java      | 166 ++++++
 .../rest/server/views/PageDef_Contract_Test.java   | 165 ++++++
 .../rest/server/views/PageDef_Validation_Test.java | 160 +++++
 .../rest/server/views/PageServerWiring_Test.java   | 139 +++++
 .../rest/server/views/PageTable_Emit_Test.java     | 208 +++++++
 .../rest/server/views/PagingPill_Wiring_Test.java  | 302 ++++++++++
 .../server/views/RibbonIcons_Resolution_Test.java  |  72 +++
 .../rest/server/views/ViewMeta_Contract_Test.java  | 342 +++++++++++
 .../rest/server/views/ViewServerWiring_Test.java   | 280 +++++++++
 .../rest/server/views/ViewTable_Emit_Test.java     | 208 +++++++
 .../rest/server/views/ViewsJs_PageSeam_Test.java   |  64 ++
 .../rest/server/views/ViewsMixin_Pages_Test.java   |  76 +++
 .../rest/server/views/ViewsMixin_Serving_Test.java | 530 +++++++++++++++++
 juneau-rest/pom.xml                                |   1 +
 46 files changed, 7559 insertions(+), 12 deletions(-)

diff --git a/NOTICE b/NOTICE
index 7614817a41..f0d0d61a2f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -3,3 +3,7 @@ Copyright 2016-2025 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
+
+This product bundles icon glyphs from the Material Symbols Outlined icon set
+(https://github.com/google/material-design-icons), licensed under the
+Apache License, Version 2.0.
diff --git a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
index 86d068690e..2b3e909f9f 100644
--- a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
+++ b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/HtmlBuilder.java
@@ -1684,6 +1684,25 @@ public class HtmlBuilder {
 		return new Samp(children);
 	}
 
+	/**
+	 * Creates a re-serializable, {@link String}-backed {@link RawText} raw-content wrapper.
+	 *
+	 * <p>
+	 * The value is serialized <b>verbatim</b> (no XML entity escaping, no <c>_x####_</c> whitespace encoding, no
+	 * wrapping element) by both the XML and HTML serializers, mirroring the one-shot {@link java.io.Reader} raw path but
+	 * usable any number of times.  Typically used to embed pre-built script/style bodies:
+	 *
+	 * <p class='bjava'>
+	 * 	Script <jv>s</jv> = <jsm>script</jsm>(<js>"application/json"</js>).text(<jsm>rawText</jsm>(<jv>escapedJson</jv>));
+	 * </p>
+	 *
+	 * @param value The raw, verbatim text content. Can be <jk>null</jk> (nothing is emitted).
+	 * @return The new {@link RawText} wrapper.
+	 */
+	public static final RawText rawText(String value) {
+		return new RawText(value);
+	}
+
 	/**
 	 * Creates an empty {@link Script} element.
 	 *
diff --git a/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java
new file mode 100644
index 0000000000..b13b1c1fed
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/main/java/org/apache/juneau/bean/html5/RawText.java
@@ -0,0 +1,69 @@
+/*
+ * 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.bean.html5;
+
+import org.apache.juneau.marshall.xml.*;
+
+/**
+ * A re-serializable, {@link String}-backed raw-content wrapper that the XML and HTML serializers emit <b>verbatim</b>.
+ *
+ * <p>
+ * This is the reusable analogue of handing the serializer a one-shot {@link java.io.Reader}: the value is emitted
+ * without any XML entity escaping or <c>_x####_</c> whitespace encoding, and without a wrapping element &mdash; but
+ * because it is backed by a {@code String} it can be serialized any number of times.  This makes a bean carrying a
+ * {@code RawText} value survive a serialize&rarr;object&rarr;serialize cycle (for the serialize direction), unlike a
+ * consumed {@code Reader}.
+ *
+ * <p>
+ * Typical use is embedding pre-built script/style bodies into a {@link Script}/{@link Style} element:
+ *
+ * <h5 class='figure'>Example:</h5>
+ * <p class='bjava'>
+ * 	<jc>// Browser-safe raw JSON sidecar ('&lt;' pre-escaped to \\u003c by the caller).</jc>
+ * 	Script <jv>s</jv> = <jsm>script</jsm>(<js>"application/json"</js>).text(<jsm>rawText</jsm>(<jv>escapedJson</jv>));
+ * </p>
+ *
+ * <p>
+ * <b>Safety:</b> content is emitted verbatim.  Because Juneau parses via a StAX
+ * {@link javax.xml.stream.XMLStreamReader}, a value containing a raw <js>'&lt;'</js> (e.g. a literal
+ * <c>&lt;/script&gt;</c> sequence) or a bare <js>'&amp;'</js> is browser-correct on serialize but will NOT round-trip
+ * back through Juneau's parser.  Callers embedding markup-bearing content are responsible for escaping <js>'&lt;'</js>
+ * to the JS/CSS unicode escape <js>"\\u003c"</js> before wrapping.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jm'>{@link HtmlBuilder#rawText(String)}
+ * 	<li class='jic'>{@link RawContent}
+ * </ul>
+ */
+public class RawText implements RawContent {
+
+	private final String text;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param text The raw, verbatim text content.  Can be <jk>null</jk> (nothing is emitted).
+	 */
+	public RawText(String text) {
+		this.text = text;
+	}
+
+	@Override /* Overridden from RawContent */
+	public String toString() {
+		return text;
+	}
+}
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
new file mode 100644
index 0000000000..06524220d0
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlBuilder_RawText_Test.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.bean.html5;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.html.*;
+import org.apache.juneau.marshall.xml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the re-serializable, String-backed {@link HtmlBuilder#rawText(String)} affordance.
+ *
+ * <p>
+ * Unlike a one-shot {@code Reader}, a {@link RawText} is backed by a reusable {@code String} and therefore survives
+ * repeated serialization.  Its content is emitted <b>verbatim</b> (no entity/whitespace encoding), mirroring the
+ * existing raw {@code Reader}/{@code InputStream} path.
+ */
+class HtmlBuilder_RawText_Test extends TestBase {
+
+	// A rawText body containing '<' and '&' is emitted verbatim, AND the element re-serializes identically
+	// (proves it is not one-shot like a Reader).
+	@Test void a01_reSerializable() throws Exception {
+		var s = HtmlSerializer.DEFAULT_SQ;
+		var e = script().text(rawText("a<b & c"));
+		var r1 = s.write(e);
+		var r2 = s.write(e);
+		assertEquals("<script>a<b & c</script>", r1);
+		assertEquals(r1, r2);
+	}
+
+	// rawText(s) produces output identical to the established one-shot Reader raw path for the same input.
+	@Test void a02_vsReaderParity() throws Exception {
+		var s = HtmlSerializer.DEFAULT_SQ;
+		var body = "if (a < b) { alert('x & y'); }";
+		var rawTextOut = s.write(script().text(rawText(body)));
+		var readerOut = s.write(script().text(new StringReader(body)));
+		assertEquals(readerOut, rawTextOut);
+		assertEquals("<script>if (a < b) { alert('x & y'); }</script>", rawTextOut);
+	}
+
+	// Also emits verbatim through the XML serializer (RawText is recognized by both).
+	@Test void a03_xmlSerializerVerbatim() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var r = s.write(script().text(rawText("x < y & z")));
+		assertEquals("<script>x < y & z</script>", r);
+	}
+
+	// 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.
+	@Test void c01_scriptEndTagIsCallerResponsibility() throws Exception {
+		var s = HtmlSerializer.DEFAULT_SQ;
+
+		// Verbatim: a literal </script> in the body is emitted as-is (would prematurely close the element in a browser).
+		var unsafe = s.write(script().text(rawText("var x = '</script>';")));
+		assertEquals("<script>var x = '</script>';</script>", unsafe);
+		assertEquals(2, countOccurrences(unsafe, "</script>"));
+
+		// Caller neutralizes by escaping '<' -> \u003c (valid JS); the only real </script> is the true close tag.
+		var safe = s.write(script().text(rawText("var x = '\\u003c/script>';")));
+		assertEquals("<script>var x = '\\u003c/script>';</script>", safe);
+		assertEquals(1, countOccurrences(safe, "</script>"));
+	}
+
+	private static int countOccurrences(String haystack, String needle) {
+		var count = 0;
+		var i = 0;
+		while ((i = haystack.indexOf(needle, i)) != -1) {
+			count++;
+			i += needle.length();
+		}
+		return count;
+	}
+}
diff --git a/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java
new file mode 100644
index 0000000000..725878b30e
--- /dev/null
+++ b/juneau-bean/juneau-bean-html5/src/test/java/org/apache/juneau/bean/html5/HtmlElementContainer_Nil_Test.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      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.bean.html5;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.html.*;
+import org.apache.juneau.marshall.xml.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that empty HTML container/mixed elements do not serialize a stray <c>nil='true'</c> attribute
+ * when using serializers that add JSON type tags (the default).
+ */
+class HtmlElementContainer_Nil_Test extends TestBase {
+
+	@Test void a01_emptyContainer_html_noNil() {
+		assertEquals("<table></table>", HtmlSerializer.DEFAULT_SQ.toString(table()));
+	}
+
+	@Test void a02_emptyContainer_xml_noNil() {
+		assertEquals("<table></table>", XmlSerializer.DEFAULT_SQ.toString(table()));
+	}
+
+	@Test void a03_nestedEmptyContainers_html_noNil() {
+		assertEquals("<table><thead></thead></table>", HtmlSerializer.DEFAULT_SQ.toString(table().child(thead())));
+	}
+
+	@Test void a04_emptyContainers_variety_html_noNil() {
+		assertEquals("<tbody></tbody>", HtmlSerializer.DEFAULT_SQ.toString(tbody()));
+		assertEquals("<tr></tr>", HtmlSerializer.DEFAULT_SQ.toString(tr()));
+		assertEquals("<ul></ul>", HtmlSerializer.DEFAULT_SQ.toString(ul()));
+	}
+}
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 760628be8d..64e3373b2b 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -62,6 +62,7 @@
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-tracing-otel</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-management-logging</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-datatables</artifactId><version>${project.version}</version></dependency>
+			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker</artifactId><version>${project.version}</version></dependency>
 			<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker-datatables</artifactId><version>${project.version}</version></dependency>
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
index bac1a8e089..1f69b46634 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/html/HtmlSerializerSession.java
@@ -920,14 +920,16 @@ public class HtmlSerializerSession extends XmlSerializerSession {
 					sType = getClassMetaForObject(o);
 			}
 
-			// Handle the case where we're serializing a raw stream.
-			if (sType.isReader() || sType.isInputStream()) {
+			// Handle the case where we're serializing a raw stream or re-serializable raw content.
+			if (sType.isReader() || sType.isInputStream() || o instanceof RawContent) {
 				pop();
 				indent -= xIndent;
 				if (sType.isReader())
 					pipe((Reader)o, out, SerializerSession::handleThrown);
-				else
+				else if (sType.isInputStream())
 					pipe((InputStream)o, out, SerializerSession::handleThrown);
+				else
+					out.append(o.toString());
 				return ContentResult.CR_MIXED;
 			}
 
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java
new file mode 100644
index 0000000000..5f4d0ab827
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/RawContent.java
@@ -0,0 +1,47 @@
+/*
+ * 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.marshall.xml;
+
+/**
+ * Marker interface for objects whose {@link #toString()} value should be emitted <b>verbatim</b> as raw element
+ * content by the XML and HTML serializers.
+ *
+ * <p>
+ * This is the re-serializable analogue of the raw {@link java.io.Reader}/{@link java.io.InputStream} path: the value is
+ * appended as-is (no XML entity escaping, no <c>_x####_</c> whitespace/invalid-char encoding, no wrapping element), but
+ * because it is backed by a reusable value (typically a {@code String}) it survives repeated serialization &mdash;
+ * unlike a one-shot {@code Reader} which is consumed on first use.
+ *
+ * <p>
+ * Implementations are responsible for producing content that is safe for the target syntax.  Notably, because Juneau
+ * parses via a StAX {@link javax.xml.stream.XMLStreamReader}, content containing a raw <js>'&lt;'</js> or a bare
+ * <js>'&amp;'</js> will serialize correctly (browser-safe) but will NOT round-trip back through Juneau's parser.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jf'>{@link XmlFormat#RAWTEXT}
+ * </ul>
+ */
+public interface RawContent {
+
+	/**
+	 * Returns the raw, verbatim character content to emit.
+	 *
+	 * @return The raw content.  May be <jk>null</jk> (nothing is emitted).
+	 */
+	@Override
+	String toString();
+}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
index 908f17d861..a7f3084102 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlBeanMeta.java
@@ -87,7 +87,7 @@ public class XmlBeanMeta extends ExtendedBeanMeta {
 					if (! pcm.isMapOrBean())
 						throw brex(c, "Invalid type for ATTRS property.  Only properties of type Map and bean can be used.");
 					attrsProperty = p;
-				} else if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT)) {
+				} else if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT)) {
 					if (xf.isOneOf(FREEFORM, MIXED, MIXED_PWS) && ! pcm.isCollectionOrArray())
 						throw brex(c, "Invalid type for %s property.  Only properties of type Collection and array can be used.", xf);
 					if (nn(contentProperty)) {
@@ -173,6 +173,7 @@ public class XmlBeanMeta extends ExtendedBeanMeta {
 	 * 	<li>{@link XmlFormat#TEXT}
 	 * 	<li>{@link XmlFormat#TEXT_PWS}
 	 * 	<li>{@link XmlFormat#XMLTEXT}
+	 * 	<li>{@link XmlFormat#RAWTEXT}
 	 * 	<li>{@link XmlFormat#VOID}
 	 * 	<li><jk>null</jk>
 	 * </ul>
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
index d918d709bd..bb4bed3426 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlFormat.java
@@ -144,6 +144,31 @@ public enum XmlFormat {
 	 */
 	XMLTEXT,
 
+	/**
+	 * Render property value as the raw, verbatim text content of the element.
+	 *
+	 * <p>
+	 * Similar to {@link #TEXT_PWS} (single, whitespace-preserving value), but the value is emitted with NO XML entity
+	 * escaping (no <c>&amp;amp;</c>/<c>&amp;lt;</c>/<c>&amp;gt;</c> substitution) and NO <c>_x####_</c>
+	 * whitespace/invalid-char encoding.
+	 * This matches HTML5 "raw text" element semantics (<xt>&lt;script&gt;</xt>/<xt>&lt;style&gt;</xt>) where browsers do
+	 * not decode character references.
+	 *
+	 * <p>
+	 * Unlike {@link #XMLTEXT}, the content is NOT re-parsed as XML on parse; it is read back verbatim as a text node
+	 * (no <c>_x####_</c> decoding, no trimming).
+	 *
+	 * <p>
+	 * Only applicable for scalar (non-collection) bean properties.
+	 *
+	 * <p>
+	 * <b>Round-trip caveat:</b> because Juneau parses via a StAX {@link javax.xml.stream.XMLStreamReader}, a value
+	 * containing a raw <js>'&lt;'</js> or a bare <js>'&amp;'</js> will NOT be parseable back.
+	 * Callers embedding markup-bearing content are responsible for escaping <js>'&lt;'</js> (e.g. to the JS/CSS unicode
+	 * escape <js>"\\u003c"</js>) before use, exactly as required for browser-safe raw-text content.
+	 */
+	RAWTEXT,
+
 	/**
 	 * Prevents collections and arrays from being enclosed in <xt>&lt;array&gt;</xt> elements.
 	 *
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
index 3530d13668..715653d2c2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlParserSession.java
@@ -389,7 +389,7 @@ public class XmlParserSession extends ReaderParserSession implements RecordReada
 
 		var cp = xmlMeta.getContentProperty();
 		var cpf = xmlMeta.getContentFormat();
-		var trim = cp == null || ! cpf.isOneOf(MIXED_PWS, TEXT_PWS);
+		var trim = cp == null || ! cpf.isOneOf(MIXED_PWS, TEXT_PWS, RAWTEXT);
 		ClassMeta<?> cpcm = (cp == null ? object() : (ClassMeta<?>) cp.getBeanInfo());
 		StringBuilder sb = null;
 		var breg = cp == null ? null : cp.getBeanRegistry();
@@ -410,6 +410,11 @@ public class XmlParserSession extends ReaderParserSession implements RecordReada
 					} else {
 						cp.set(m, null, getText(r, trim));
 					}
+				} else if (nn(cp) && cpf == RAWTEXT) {
+					// Verbatim: no trim, no _x####_ decoding (mirrors RAWTEXT serialize which bypasses encoding).
+					if (sb == null)
+						sb = getStringBuilder();
+					sb.append(r.getText());
 				} else if (cpf != FREEFORM) {
 					var s = getText(r, trim);
 					if (nn(s)) {
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
index ec8b1371fe..bdcd72fda2 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/xml/XmlSerializerSession.java
@@ -383,6 +383,15 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 		return xcm.getFormat() == XMLTEXT;
 	}
 
+	private boolean isRawText(XmlFormat format, ClassMeta<?> sType) {
+		if (format == RAWTEXT)
+			return true;
+		var xcm = getXmlClassMeta(sType);
+		if (xcm == null)
+			return false;
+		return xcm.getFormat() == RAWTEXT;
+	}
+
 	private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) {
 		var key = p.getName();
 		var value = p.getValue();
@@ -470,7 +479,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 					contentType = (ClassMeta<?>) p.getBeanInfo();
 					hasContent = true;
 					cf = xbm.getContentFormat();
-					if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT))
+					if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT))
 						isMixedOrText = true;
 					if (cf.isOneOf(MIXED_PWS, TEXT_PWS))
 						preserveWhitespace = true;
@@ -535,7 +544,11 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 				writeAnything(out, content, contentType, null, null, null, false, cf, isMixedOrText, preserveWhitespace, null);
 			}
 		} else {
-			if (isAddJsonTags())
+			// A null FREEFORM content property represents an empty container element (e.g. an empty HTML <table> or
+			// <thead>), which must render as an empty element rather than an explicit null.  Emitting nil='true' here
+			// is surprising for such containers.  Other content formats (TEXT/TEXT_PWS/XMLTEXT for null-vs-empty-string
+			// round-tripping, and MIXED/MIXED_PWS) retain the nil marker to preserve existing behavior.
+			if (isAddJsonTags() && xbm.getContentFormat() != FREEFORM)
 				out.attr("nil", "true");
 			out.w('>').nlIf(! isMixedOrText, indent);
 		}
@@ -926,7 +939,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 			o = null;
 
 		boolean isCollapsed = false;		// If 'true', this is a collection and we're not rendering the outer element.
-		boolean isRaw = (sType.isReader() || sType.isInputStream()) && nn(o);
+		boolean isRaw = (sType.isReader() || sType.isInputStream() || o instanceof RawContent) && nn(o);
 
 		// Get the JSON type string.
 		if (o == null) {
@@ -947,7 +960,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 			type = STRING;
 		}
 
-		if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN))
+		if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN))
 			isCollapsed = true;
 
 		// Is there a name associated with this bean?
@@ -1032,7 +1045,10 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 
 		// Render the tag contents.
 		if (nn(o)) {
-			if (sType.isBean()) {
+			if (o instanceof RawContent) {
+				// Re-serializable, String-backed raw content: emit verbatim (no entity/whitespace encoding), like the Reader path.
+				out.append(o.toString());
+			} else if (sType.isBean()) {
 				rc = writeBeanMap(out, toBeanMap(o), elementNamespace, isCollapsed, isMixedOrText);
 			} else if (sType.isMap() || (nn(wType) && wType.isMap())) {
 				if (o instanceof BeanMap o2)
@@ -1049,7 +1065,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 				// Must come before isCharSequence/isChar: @Uri-annotated String properties (where sType is String but pMeta.isUri() is true) need to route here for URI text emission.
 				out.textUri(o);
 			} else if (sType.isCharSequence() || sType.isChar()) {
-				if (isXmlText(format, sType))
+				if (isXmlText(format, sType) || isRawText(format, sType))
 					out.append(o);
 				else
 					out.text(o, preserveWhitespace);
@@ -1076,7 +1092,7 @@ public class XmlSerializerSession extends WriterSerializerSession implements Rec
 			} else if (sType.isInputStream()) {
 				pipe((InputStream)o, out, SerializerSession::handleThrown);
 			} else {
-				if (isXmlText(format, sType))
+				if (isXmlText(format, sType) || isRawText(format, sType))
 					out.append(toString(o));
 				else
 					out.text(toString(o));
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java
new file mode 100644
index 0000000000..175ee42dee
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/xml/Xml_RawText_Test.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      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.marshall.xml;
+
+import static org.apache.juneau.TestUtils.*;
+import static org.apache.juneau.marshall.xml.XmlFormat.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.parser.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the {@link XmlFormat#RAWTEXT} content format.
+ *
+ * <p>
+ * RAWTEXT emits a scalar String content property <b>verbatim</b> (no {@code &amp;}/{@code &lt;}/{@code &gt;} entity
+ * escaping and no {@code _x####_} whitespace/invalid-char encoding), mirroring HTML5 raw-text element semantics
+ * ({@code <script>}/{@code <style>}).  On the parse side it is read back verbatim (no decode, no trim).
+ */
+class Xml_RawText_Test extends TestBase {
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Serialize
+	//-----------------------------------------------------------------------------------------------------------------
+
+	// A body containing raw '<', '>' and '&' must be emitted VERBATIM (no entity escaping).
+	@Test void a01_serializeVerbatim() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "if (a < b) { return a & b > c; }";
+		assertEquals("<A f1='f1'>if (a < b) { return a & b > c; }</A>", s.write(t));
+	}
+
+	// Whitespace (newlines/tabs) must be emitted literally (contrast with TEXT_PWS's &#x000a;/&#x0009;/_x0020_).
+	@Test void a02_serializeWhitespaceVerbatim() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "\n\tx();\n";
+		assertEquals("<A f1='f1'>\n\tx();\n</A>", s.write(t));
+	}
+
+	// A literal _x####_ sequence must NOT be encoded on serialize (verbatim).
+	@Test void a03_serializeNoUnderscoreEncoding() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "  _x0041_ leading";
+		assertEquals("<A f1='f1'>  _x0041_ leading</A>", s.write(t));
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Parse (round-trip) of StAX-safe content
+	//-----------------------------------------------------------------------------------------------------------------
+
+	// StAX-safe content (no raw '<' or bare '&') round-trips verbatim (no decode, no trim).
+	@Test void b01_roundTripSafeContent() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var p = XmlParser.DEFAULT;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "var x = 1;\n";
+		var r = s.write(t);
+		var t2 = p.read(r, A.class);
+		assertEquals(t.f2, t2.f2);
+		assertEquals(json(t), json(t2));
+	}
+
+	// Caller-escaped markup (ViewTable-style '<' -> \u003c) round-trips byte-identical.
+	@Test void b02_roundTripEscapedMarkup() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var p = XmlParser.DEFAULT;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "{\"a\":\"\\u003cb\\u003e\"}";
+		var r = s.write(t);
+		var t2 = p.read(r, A.class);
+		assertEquals(t.f2, t2.f2);
+	}
+
+	// A literal _x0041_ in RAWTEXT content must NOT be decoded on parse (stays verbatim).
+	@Test void b03_roundTripNoUnderscoreDecode() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var p = XmlParser.DEFAULT;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "value _x0041_ here";
+		var r = s.write(t);
+		var t2 = p.read(r, A.class);
+		assertEquals("value _x0041_ here", t2.f2);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// LIMITATION: verbatim raw '<' is not round-trippable through Juneau's StAX parser.
+	//-----------------------------------------------------------------------------------------------------------------
+
+	// This test intentionally documents (and locks in) the constraint from the design: RAWTEXT emits raw '<'
+	// verbatim (browser-correct), but Juneau parses via a StAX XMLStreamReader, so the resulting non-well-formed
+	// XML CANNOT be parsed back.  Callers embedding markup must escape '<' -> \u003c (see b02).
+	@Test void b04_rawAngleBracketNotParseable() throws Exception {
+		var s = XmlSerializer.DEFAULT_SQ;
+		var p = XmlParser.DEFAULT;
+		var t = new A();
+		t.f1 = "f1";
+		t.f2 = "if (a < b) {}";
+		var r = s.write(t);
+		// The serialized output is verbatim (browser-correct)...
+		assertEquals("<A f1='f1'>if (a < b) {}</A>", r);
+		// ...but is not well-formed XML, so parsing it back fails.
+		assertThrows(ParseException.class, () -> p.read(r, A.class));
+	}
+
+	@Marshalled(typeName="A")
+	public static class A {
+		@Xml(format=ATTR) public String f1;
+		@Xml(format=RAWTEXT) public String f2;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
new file mode 100644
index 0000000000..e6ac1a3e76
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+	<modelVersion>4.0.0</modelVersion>
+
+	<parent>
+		<groupId>org.apache.juneau</groupId>
+		<artifactId>juneau-rest</artifactId>
+		<version>10.0.0-SNAPSHOT</version>
+	</parent>
+
+	<artifactId>juneau-rest-server-views</artifactId>
+	<name>Apache Juneau REST Server Views</name>
+	<description>Apache Juneau REST Server - Rich DataTables view toolkit: declarative VIEW_META view definitions + client runtime (opt-in)</description>
+	<packaging>bundle</packaging>
+
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+	</properties>
+
+	<dependencies>
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-server-datatables</artifactId>
+			<version>${project.version}</version>
+		</dependency>
+
+		<!-- Test scope -->
+		<dependency>
+			<groupId>org.junit.jupiter</groupId>
+			<artifactId>junit-jupiter</artifactId>
+			<version>${junit.version}</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-test-utils</artifactId>
+			<version>${project.version}</version>
+			<scope>test</scope>
+		</dependency>
+		<dependency>
+			<groupId>org.apache.juneau</groupId>
+			<artifactId>juneau-rest-mock</artifactId>
+			<version>${project.version}</version>
+			<scope>test</scope>
+		</dependency>
+	</dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-source-plugin</artifactId>
+				<executions>
+					<execution>
+						<id>attach-sources</id>
+						<phase>verify</phase>
+						<goals>
+							<goal>jar-no-fork</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.felix</groupId>
+				<artifactId>maven-bundle-plugin</artifactId>
+				<extensions>true</extensions>
+				<configuration>
+					<supportIncrementalBuild>true</supportIncrementalBuild>
+				</configuration>
+				<executions>
+					<execution>
+						<id>bundle-manifest</id>
+						<phase>process-classes</phase>
+						<goals>
+							<goal>manifest</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-jar-plugin</artifactId>
+			</plugin>
+			<plugin>
+				<groupId>org.jacoco</groupId>
+				<artifactId>jacoco-maven-plugin</artifactId>
+				<executions>
+					<execution>
+						<id>default-prepare-agent</id>
+						<goals>
+							<goal>prepare-agent</goal>
+						</goals>
+					</execution>
+					<execution>
+						<id>default-report</id>
+						<phase>prepare-package</phase>
+						<goals>
+							<goal>report</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
+		</plugins>
+	</build>
+</project>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
new file mode 100644
index 0000000000..eb28fa822d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
@@ -0,0 +1,170 @@
+/*
+ * 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 org.apache.juneau.commons.bean.*;
+
+/**
+ * A single column descriptor in the {@code VIEW_META} wire contract (design doc §6.4).
+ *
+ * <p>
+ * Mirrors the DataTables column shape (the DataTables-native terms {@code orderable}/{@code searchable}/
+ * {@code className} are kept) and adds a named-renderer reference ({@link #render}) plus a declarative
+ * {@code {property}} URL template ({@link #href}).  Optional fields ({@code name}, {@code href}, {@code className})
+ * are omitted from the wire when unset.
+ *
+ * <p>
+ * The reserved catalog/View-Settings fields ({@code format}, {@code description}, {@code pinned},
+ * {@code defaultVisible}) are <b>not</b> exposed by this MVP builder and are therefore omitted from the serialized
+ * contract (design doc §6.4 reserved stubs).
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * 	<li class='jc'>{@link Render}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="data,name,title,orderable,searchable,render,href,className")
+public class Column {
+
+	/** The bean-property / JSON key this column reads from each row. */
+	public String data;
+
+	/** Optional stable DataTables column {@code name}; defaults to {@link #data} when omitted. */
+	public String name;
+
+	/** The header text. */
+	public String title;
+
+	/** Whether the column is orderable (DataTables-native term).  Defaults to <jk>true</jk>. */
+	public boolean orderable = true;
+
+	/** Whether the column is searchable.  Defaults to <jk>true</jk>. */
+	public boolean searchable = true;
+
+	/** The named cell-renderer reference. */
+	public Render render;
+
+	/** Optional declarative <c>{property}</c> URL template consumed by the {@code linked} renderer. */
+	public String href;
+
+	/** Optional static CSS class on the cell (DataTables-native {@code className}). */
+	public String className;
+
+	/**
+	 * Creates a column bound to the specified data key, with {@code orderable}/{@code searchable} defaulting to
+	 * <jk>true</jk>.
+	 *
+	 * @param data The bean-property / JSON key.  Must not be <jk>null</jk>.
+	 * @return A new {@link Column}.
+	 */
+	public static Column of(String data) {
+		var c = new Column();
+		c.data = data;
+		return c;
+	}
+
+	/**
+	 * Sets the stable DataTables column name.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public Column name(String value) {
+		name = value;
+		return this;
+	}
+
+	/**
+	 * Sets the header text.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public Column title(String value) {
+		title = value;
+		return this;
+	}
+
+	/**
+	 * Sets whether the column is orderable.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public Column orderable(boolean value) {
+		orderable = value;
+		return this;
+	}
+
+	/**
+	 * Sets whether the column is searchable.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public Column searchable(boolean value) {
+		searchable = value;
+		return this;
+	}
+
+	/**
+	 * Sets the named cell-renderer using the <c>"id:field"</c> string sugar (see {@link Render#parse(String)}).
+	 *
+	 * @param value The render-id string.  E.g. <c>"tag:status"</c> or <c>"date"</c>.
+	 * @return This object.
+	 */
+	public Column render(String value) {
+		render = Render.parse(value);
+		return this;
+	}
+
+	/**
+	 * Sets the named cell-renderer to a pre-built {@link Render} (canonical form).
+	 *
+	 * @param value The renderer reference.
+	 * @return This object.
+	 */
+	public Column render(Render value) {
+		render = value;
+		return this;
+	}
+
+	/**
+	 * Sets the declarative <c>{property}</c> URL template.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public Column href(String value) {
+		href = value;
+		return this;
+	}
+
+	/**
+	 * Sets the static CSS class on the cell.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public Column className(String value) {
+		className = value;
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
new file mode 100644
index 0000000000..b923d7a255
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * The top-level, declarative "tabs / sub-tabs page" definition (TODO-399 Phase C, design doc §"Bean model") &mdash;
+ * composes multiple existing {@link ViewDef} views into one multi-tab / sub-tab admin page.
+ *
+ * <p>
+ * A {@link PageDef} is an ordinary Juneau bean built via a small fluent builder ({@link #create(String)} + chained
+ * setters + {@link #build()}), mirroring {@link ViewDef}'s builder ergonomics exactly.  It holds an ordered list of
+ * {@link Tab}; each {@link Tab} holds either a single child {@link ViewDef} reference or an ordered list of
+ * {@link Subtab}, each of which references one {@link ViewDef}.
+ *
+ * <p>
+ * The page does <b>not</b> wrap/absorb a child view's configuration &mdash; {@link PageTable} renders each
+ * referenced view exactly as {@code ViewTable.of(...)} already does (marker table + VIEW_META sidecar) and only
+ * controls which panel is visible.  {@link #contractVersion} reuses {@link ViewDef#CONTRACT_VERSION} as the single
+ * source of truth, mirroring {@code ViewsMixin.CONTRACT_VERSION}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	PageDef <jv>page</jv> = PageDef.<jsm>create</jsm>(<js>"admin"</js>)
+ * 		.title(<js>"Admin"</js>)
+ * 		.tabs(
+ * 			Tab.<jsm>create</jsm>(<js>"releases"</js>, <js>"Releases"</js>).view(releasesView),
+ * 			Tab.<jsm>create</jsm>(<js>"catalog"</js>, <js>"Catalog"</js>).subtabs(
+ * 				Subtab.<jsm>create</jsm>(<js>"packages"</js>, <js>"Packages"</js>).view(packagesView),
+ * 				Subtab.<jsm>create</jsm>(<js>"bundles"</js>, <js>"Bundles"</js>).view(bundlesView)))
+ * 		.build();
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link Tab}
+ * 	<li class='jc'>{@link Subtab}
+ * 	<li class='jc'>{@link PageTable}
+ * 	<li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,id,title,tabs")
+public class PageDef {
+
+	/** The frozen contract version, reusing {@link ViewDef#CONTRACT_VERSION} as the single source of truth. */
+	public static final String CONTRACT_VERSION = ViewDef.CONTRACT_VERSION;
+
+	/** The frozen contract-version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+	public String contractVersion = CONTRACT_VERSION;
+
+	/** The stable page id (the first hash segment, {@code #<id>/tabId[/subtabId]}). */
+	public String id;
+
+	/** Optional page title. */
+	public String title;
+
+	/** The ordered top-level tabs. */
+	public List<Tab> tabs;
+
+	/**
+	 * Starts a new {@link PageDef} builder with the specified stable page id.
+	 *
+	 * @param id The stable page id.  Must not be <jk>null</jk> or blank.
+	 * @return A new mutable {@link PageDef} to chain builder calls on.
+	 */
+	public static PageDef create(String id) {
+		if (id == null || id.isBlank())
+			throw iaex("PageDef id must not be null or blank.");
+		var p = new PageDef();
+		p.id = id;
+		return p;
+	}
+
+	/**
+	 * Sets the page title.
+	 *
+	 * @param value The new value.  Can be <jk>null</jk> to unset.
+	 * @return This object.
+	 */
+	public PageDef title(String value) {
+		title = value;
+		return this;
+	}
+
+	/**
+	 * Sets the ordered top-level tabs.
+	 *
+	 * @param value The tabs, in display order.
+	 * @return This object.
+	 */
+	public PageDef tabs(Tab...value) {
+		tabs = new ArrayList<>(Arrays.asList(value));
+		return this;
+	}
+
+	/**
+	 * Finalizes the builder, validating the composed tab tree, and returns the wire-ready {@link PageDef}.
+	 *
+	 * <p>
+	 * Validation (design doc §"Bean model"): at least one tab must be declared; every {@link Tab} must declare
+	 * exactly one of {@link Tab#view}/{@link Tab#subtabs} (delegated to {@link Tab#validate()}, which also checks
+	 * sub-tab id uniqueness <i>within</i> that tab); every {@link Tab#id} must be unique across the page; and every
+	 * referenced {@link ViewDef#id} (from a leaf tab's view or any subtab's view) must be unique across the whole
+	 * page, so hash routing and sidecar lookup stay unambiguous.
+	 *
+	 * @return This object.
+	 * @throws IllegalArgumentException On any validation rule violation.
+	 */
+	public PageDef build() {
+		validate();
+		return this;
+	}
+
+	private void validate() {
+		if (tabs == null || tabs.isEmpty())
+			throw iaex("PageDef '%s' must declare at least one tab.", id);
+		var tabIds = new HashSet<String>();
+		var viewIds = new HashSet<String>();
+		for (var t : tabs) {
+			t.validate();
+			if (!tabIds.add(t.id))
+				throw iaex("PageDef '%s': duplicate tab id '%s'.", id, t.id);
+			if (t.view != null) {
+				addViewId(viewIds, t.view.id);
+			} else {
+				for (var s : t.subtabs)
+					addViewId(viewIds, s.view.id);
+			}
+		}
+	}
+
+	private void addViewId(Set<String> viewIds, String viewId) {
+		if (!viewIds.add(viewId))
+			throw iaex("PageDef '%s': duplicate referenced ViewDef id '%s'.", id, viewId);
+	}
+}
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
new file mode 100644
index 0000000000..42cccce3ac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Builds the HTML delivery shell for a {@link PageDef} &mdash; the self-contained, class-based tab/sub-tab shell
+ * (TODO-399 Phase C, design doc §"Client page runtime" + Decision 1(A)) wrapping one independent
+ * {@code data-juneau-view} table per referenced {@link ViewDef}, plus the {@code <script type="application/json">}
+ * PAGE_META sidecar the {@code juneau-pages.js} runtime consumes for hash routing.
+ *
+ * <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
+ * {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String) raw content} so the returned bean stays
+ * re-serializable.
+ *
+ * <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},
+ * {@link #SUBTAB_CLASS}, etc.) &mdash; no inline {@code style="..."} and no {@code url(...)}.  This module takes no
+ * dependency on {@code juneau-rest-server-console-ui}; TODO-361's {@code chrome.css} may theme the same classes when
+ * present, mirroring the already-agreed {@code .tag.<domain>.<value>} chip reconciliation.
+ *
+ * <h5 class='section'>Child views stay independent (no wrapping/absorption):</h5>
+ * <p>
+ * Each referenced {@link ViewDef} is rendered by delegating straight to {@link ViewTable#of(MarshallingContext,
+ * ViewDef, Collection) ViewTable.of(ctx, view, null)} &mdash; the exact same code path a standalone
+ * {@code ViewTable.of(view)} call uses &mdash; so a child view's marker table + VIEW_META sidecar is byte-for-byte
+ * identical whether reached standalone or through a page.  This page emitter only wraps that untouched output in a
+ * tab-bar/panel shell and controls which panel is visible; it never re-derives or mutates the child's contract.
+ *
+ * <h5 class='section'>PAGE_META shape:</h5>
+ * <p>
+ * A light-weight tab tree (id/label/subtabs only &mdash; no embedded {@code ViewDef} content, since the page
+ * runtime's lazy-init binding layer locates a panel's {@code table[data-juneau-view]} elements directly via the DOM
+ * rather than via PAGE_META): <c>{contractVersion, id, tabs:[{id, label, subtabs?:[{id, label}]}]}</c>.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link PageDef}
+ * 	<li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class PageTable {
+
+	/** Marker attribute the {@code juneau-pages.js} runtime looks for to auto-initialize a page shell. */
+	public static final String MARKER_ATTR = "data-juneau-page";
+
+	/** Prefix of the PAGE_META sidecar {@code <script>} element id: {@code juneau-page:<pageId>}. */
+	public static final String SIDECAR_ID_PREFIX = "juneau-page:";
+
+	/** Class on the page shell root. */
+	public static final String PAGE_CLASS = "jc-page";
+
+	/** Class on the top-level tab-bar container. */
+	public static final String TAB_BAR_CLASS = "jc-tab-bar";
+
+	/** Class on a single top-level tab-bar link. */
+	public static final String TAB_CLASS = "jc-tab";
+
+	/** Class on a sub-tab-bar container (nested inside a tab's panel). */
+	public static final String SUBTAB_BAR_CLASS = "jc-subtab-bar";
+
+	/** Class on a single sub-tab-bar link. */
+	public static final String SUBTAB_CLASS = "jc-subtab";
+
+	/** Class on a top-level tab panel. */
+	public static final String PANEL_CLASS = "jc-panel";
+
+	/** Class on a sub-tab panel (nested inside a tab's panel). */
+	public static final String SUBPANEL_CLASS = "jc-subpanel";
+
+	private PageTable() {}
+
+	/**
+	 * Builds the page shell for a built {@link PageDef}, using the default marshalling context.
+	 *
+	 * @param pageDef The built page definition.  Must not be <jk>null</jk>.
+	 * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell, tab/sub-tab bars, one panel per
+	 * 	referenced view, and the PAGE_META sidecar.
+	 */
+	public static Div of(PageDef pageDef) {
+		return of(MarshallingContext.DEFAULT, pageDef);
+	}
+
+	/**
+	 * Builds the page shell using the specified marshalling context for the wrapped child views' cell reads.
+	 *
+	 * @param ctx The marshalling context passed through to each child {@code ViewTable.of(...)} call.  Must not be
+	 * 	<jk>null</jk>.
+	 * @param pageDef The built page definition.  Must not be <jk>null</jk>.
+	 * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell, tab/sub-tab bars, one panel per
+	 * 	referenced view, and the PAGE_META sidecar.
+	 */
+	public static Div of(MarshallingContext ctx, PageDef pageDef) {
+		var id = pageDef.id;
+		var tabs = pageDef.tabs == null ? List.<Tab>of() : pageDef.tabs;
+
+		var tabBarChildren = new ArrayList<>();
+		for (var t : tabs)
+			tabBarChildren.add(
+				a(hashHref(id, t.id, null), t.label == null ? t.id : t.label)
+					.class_(TAB_CLASS)
+					.attr("data-tab-id", t.id));
+		var tabBar = nav(tabBarChildren.toArray()).class_(TAB_BAR_CLASS).attr("role", "tablist");
+
+		var panelsChildren = new ArrayList<>();
+		for (var t : tabs)
+			panelsChildren.add(buildTabPanel(ctx, id, t));
+		var panels = div(panelsChildren.toArray()).class_("jc-panels");
+
+		var json = ViewTable.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);
+	}
+
+	/** Builds one top-level tab's panel: either a leaf view panel, or a sub-tab bar + one sub-panel per subtab. */
+	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);
+		}
+
+		var subtabs = t.subtabs == null ? List.<Subtab>of() : t.subtabs;
+
+		var subtabBarChildren = new ArrayList<>();
+		for (var s : subtabs)
+			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));
+		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));
+		}
+		var subpanels = div(subpanelsChildren.toArray()).class_("jc-subpanels");
+
+		return div(subtabBar, subpanels).class_(PANEL_CLASS).attr("data-panel-tab", t.id);
+	}
+
+	/** Builds the deep-linkable hash href: {@code #pageId/tabId} or {@code #pageId/tabId/subtabId}. */
+	private static String hashHref(String pageId, String tabId, String subtabId) {
+		var sb = new StringBuilder("#").append(pageId).append('/').append(tabId);
+		if (subtabId != null)
+			sb.append('/').append(subtabId);
+		return sb.toString();
+	}
+
+	/** Projects a {@link PageDef} down to the light-weight PAGE_META tab tree (no embedded ViewDef content). */
+	private static PageMeta buildMeta(PageDef pageDef) {
+		var meta = new PageMeta();
+		meta.contractVersion = pageDef.contractVersion;
+		meta.id = pageDef.id;
+		var tabMetas = new ArrayList<TabMeta>();
+		for (var t : (pageDef.tabs == null ? List.<Tab>of() : pageDef.tabs)) {
+			var tm = new TabMeta();
+			tm.id = t.id;
+			tm.label = t.label;
+			if (t.subtabs != null) {
+				var subMetas = new ArrayList<SubtabMeta>();
+				for (var s : t.subtabs) {
+					var sm = new SubtabMeta();
+					sm.id = s.id;
+					sm.label = s.label;
+					subMetas.add(sm);
+				}
+				tm.subtabs = subMetas;
+			}
+			tabMetas.add(tm);
+		}
+		meta.tabs = tabMetas;
+		return meta;
+	}
+
+	/** The PAGE_META root: {@code {contractVersion, id, tabs}}. */
+	@BeanType(properties="contractVersion,id,tabs")
+	public static class PageMeta {
+
+		/** The frozen contract-version discriminator. */
+		public String contractVersion;
+
+		/** The page id. */
+		public String id;
+
+		/** The tab tree. */
+		public List<TabMeta> tabs;
+	}
+
+	/** One PAGE_META tab entry: {@code {id, label, subtabs?}}. */
+	@BeanType(properties="id,label,subtabs")
+	public static class TabMeta {
+
+		/** The tab id. */
+		public String id;
+
+		/** The tab label. */
+		public String label;
+
+		/** The sub-tab entries; omitted for a leaf tab. */
+		public List<SubtabMeta> subtabs;
+	}
+
+	/** One PAGE_META sub-tab entry: {@code {id, label}}. */
+	@BeanType(properties="id,label")
+	public static class SubtabMeta {
+
+		/** The sub-tab id. */
+		public String id;
+
+		/** The sub-tab label. */
+		public String label;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
new file mode 100644
index 0000000000..be36683ba6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A named cell-renderer reference in the {@code VIEW_META} wire contract (design doc §6.6).
+ *
+ * <p>
+ * Serializes to the canonical object form <c>{"id":"tag","meta":{"field":"status"}}</c>.  The client
+ * {@code juneau-renders.js} registry looks up {@link #id} and invokes the matching renderer, passing the
+ * {@link #meta} map through as per-column context.
+ *
+ * <p>
+ * A compact string sugar is supported via {@link #parse(String)}: everything after the <b>first</b> colon becomes
+ * <c>meta.field</c> &mdash; e.g. <c>"tag:status"</c> &rarr; <c>{id:"tag", meta:{field:"status"}}</c> and
+ * <c>"date"</c> &rarr; <c>{id:"date"}</c>.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link Column}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,meta")
+public class Render {
+
+	/** The renderer id (registry key in {@code juneau-renders.js}). */
+	public String id;
+
+	/** Optional per-column renderer metadata.  Omitted from the wire when absent. */
+	public Map<String,String> meta;
+
+	/**
+	 * Creates a renderer reference with the specified id and no metadata.
+	 *
+	 * @param id The renderer id.  Must not be <jk>null</jk>.
+	 * @return A new {@link Render}.
+	 */
+	public static Render of(String id) {
+		var r = new Render();
+		r.id = id;
+		return r;
+	}
+
+	/**
+	 * Parses a render-id string into a {@link Render}, applying the <c>"id:field"</c> string sugar.
+	 *
+	 * <p>
+	 * Everything after the <b>first</b> colon becomes <c>meta.field</c>; a bare id (no colon) yields a
+	 * metadata-free renderer.
+	 *
+	 * @param s The render-id string.  Must not be <jk>null</jk> or blank.
+	 * @return A new {@link Render}.
+	 */
+	public static Render parse(String s) {
+		if (s == null || s.isBlank())
+			throw iaex("Render id must not be null or blank.");
+		var i = s.indexOf(':');
+		if (i < 0)
+			return of(s);
+		return of(s.substring(0, i)).meta("field", s.substring(i + 1));
+	}
+
+	/**
+	 * Adds a metadata key/value pair to this renderer reference.
+	 *
+	 * @param key The metadata key.  Must not be <jk>null</jk>.
+	 * @param value The metadata value.
+	 * @return This object.
+	 */
+	public Render meta(String key, String value) {
+		if (meta == null)
+			meta = new LinkedHashMap<>();
+		meta.put(key, value);
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
new file mode 100644
index 0000000000..3ff18d7014
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
@@ -0,0 +1,451 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A ribbon/toolbar action descriptor in the {@code VIEW_META} wire contract (design doc §6.5).
+ *
+ * <p>
+ * A single bean discriminated by {@link #type}; only the fields relevant to a given action type are populated, and
+ * the serializer omits the rest (null-valued properties are dropped).  Build actions via the static factory methods
+ * ({@link #export(String...)}, {@link #refresh()}, {@link #columnSearchToggle()}, {@link #option(String)},
+ * {@link #optionGroup(String)}, {@link #divider()}).
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="type,buttons,optional,id,title,group,column,value,param,persist,symbol,color,deselectable,options")
+public class RibbonAction {
+
+	/** The discriminator: {@code export}/{@code refresh}/{@code columnSearchToggle}/{@code option}/{@code optionGroup}/{@code divider}. */
+	public String type;
+
+	/** For {@code export}: the always-on button ids (e.g. {@code ["copy","csv"]}). */
+	public List<String> buttons;
+
+	/** For {@code export}: feature-detected optional button ids (e.g. {@code ["excel","pdf"]}). */
+	public List<String> optional;
+
+	/** The action id (for {@code option}/{@code optionGroup}). */
+	public String id;
+
+	/** The display title. */
+	public String title;
+
+	/**
+	 * An opaque grouping key (visual-parity design doc §4.A).  Adjacent actions sharing the same non-<jk>null</jk>
+	 * {@code group} value render as ONE segmented ribbon cluster (shared borders, rounded only on the outer ends) -
+	 * see {@code juneau-ribbon.js}'s {@code buildRibbon(...)}.  An {@code export} action's own resolved buttons are
+	 * always clustered together even when {@code group} is unset (one action, one visual cluster); this field
+	 * exists so UNRELATED, adjacent actions (e.g. a {@code columnSearchToggle} and a column-scoped {@code option})
+	 * can be clustered into one ribbon too.  A {@code divider} always breaks an open cluster.
+	 */
+	public String group;
+
+	/** For a column-scoped {@code option}: the target column key. */
+	public String column;
+
+	/** For an {@code option}: the value contributed to the query when active. */
+	public String value;
+
+	/** For a custom (non-column-scoped) {@code option}: the request param name the endpoint must parse. */
+	public String param;
+
+	/** Whether the {@code option} toggle state is persisted to {@code localStorage}. */
+	public Boolean persist;
+
+	/** Optional glyph/symbol hint. */
+	public String symbol;
+
+	/** Optional color hint. */
+	public String color;
+
+	/** For {@code optionGroup}: whether the select-one cluster can be fully deselected. */
+	public Boolean deselectable;
+
+	/** For {@code optionGroup}: the member options. */
+	public List<Opt> options;
+
+	/**
+	 * A member option of an {@code optionGroup} (design doc §6.5).
+	 *
+	 * @since 10.0.0
+	 */
+	@BeanType(properties="id,title,column,value,param,symbol,color")
+	public static class Opt {
+
+		/** The option id. */
+		public String id;
+
+		/** The display title. */
+		public String title;
+
+		/** The target column key (column-scoped option). */
+		public String column;
+
+		/** The value contributed to the query when active. */
+		public String value;
+
+		/** The custom request param name (non-column-scoped option). */
+		public String param;
+
+		/** Optional glyph/symbol hint. */
+		public String symbol;
+
+		/** Optional color hint. */
+		public String color;
+
+		/**
+		 * Creates an option with the specified id.
+		 *
+		 * @param id The option id.  Must not be <jk>null</jk>.
+		 * @return A new {@link Opt}.
+		 */
+		public static Opt of(String id) {
+			var o = new Opt();
+			o.id = id;
+			return o;
+		}
+
+		/**
+		 * Sets the display title.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt title(String value) {
+			title = value;
+			return this;
+		}
+
+		/**
+		 * Sets the target column key.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt column(String value) {
+			column = value;
+			return this;
+		}
+
+		/**
+		 * Sets the contributed value.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt value(String value) {
+			this.value = value;
+			return this;
+		}
+
+		/**
+		 * Sets the custom request param name.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt param(String value) {
+			param = value;
+			return this;
+		}
+
+		/**
+		 * Sets the color hint.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt color(String value) {
+			color = value;
+			return this;
+		}
+
+		/**
+		 * Sets the symbol hint.
+		 *
+		 * @param value The new value.
+		 * @return This object.
+		 */
+		public Opt symbol(String value) {
+			symbol = value;
+			return this;
+		}
+	}
+
+	/**
+	 * Creates an {@code export} action with the specified always-on button ids.
+	 *
+	 * @param buttons The always-on button ids (MVP: {@code "copy"}, {@code "csv"}).
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction export(String...buttons) {
+		var a = new RibbonAction();
+		a.type = "export";
+		a.buttons = new ArrayList<>(Arrays.asList(buttons));
+		return a;
+	}
+
+	/**
+	 * Sets the feature-detected optional export button ids (e.g. {@code "excel"}, {@code "pdf"}).
+	 *
+	 * @param optional The optional button ids.
+	 * @return This object.
+	 */
+	public RibbonAction optional(String...optional) {
+		this.optional = new ArrayList<>(Arrays.asList(optional));
+		return this;
+	}
+
+	/**
+	 * Creates a {@code refresh} action (re-draw / reload the table).
+	 *
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction refresh() {
+		var a = new RibbonAction();
+		a.type = "refresh";
+		return a;
+	}
+
+	/**
+	 * Creates a {@code columnSearchToggle} action (show/hide per-column search inputs).
+	 *
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction columnSearchToggle() {
+		var a = new RibbonAction();
+		a.type = "columnSearchToggle";
+		return a;
+	}
+
+	/**
+	 * Creates an {@code option} action (a server-query toggle) with the specified id.
+	 *
+	 * @param id The option id.  Must not be <jk>null</jk>.
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction option(String id) {
+		var a = new RibbonAction();
+		a.type = "option";
+		a.id = id;
+		return a;
+	}
+
+	/**
+	 * Creates an {@code optionGroup} action (a select-one cluster) with the specified id.
+	 *
+	 * @param id The group id.  Must not be <jk>null</jk>.
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction optionGroup(String id) {
+		var a = new RibbonAction();
+		a.type = "optionGroup";
+		a.id = id;
+		return a;
+	}
+
+	/**
+	 * Creates a {@code divider} action (a visual separator).
+	 *
+	 * @return A new {@link RibbonAction}.
+	 */
+	public static RibbonAction divider() {
+		var a = new RibbonAction();
+		a.type = "divider";
+		return a;
+	}
+
+	/**
+	 * Sets the display title.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction title(String value) {
+		title = value;
+		return this;
+	}
+
+	/**
+	 * Sets the grouping key (visual-parity design doc §4.A) - adjacent actions sharing the same key render as one
+	 * segmented ribbon cluster.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction group(String value) {
+		group = value;
+		return this;
+	}
+
+	/**
+	 * Sets the target column key (column-scoped option).
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction column(String value) {
+		column = value;
+		return this;
+	}
+
+	/**
+	 * Sets the value contributed to the query when the option is active.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction value(String value) {
+		this.value = value;
+		return this;
+	}
+
+	/**
+	 * Sets the custom request param name (non-column-scoped option).
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction param(String value) {
+		param = value;
+		return this;
+	}
+
+	/**
+	 * Sets whether the option toggle state is persisted to {@code localStorage}.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction persist(boolean value) {
+		persist = value;
+		return this;
+	}
+
+	/**
+	 * Sets the glyph/symbol hint.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction symbol(String value) {
+		symbol = value;
+		return this;
+	}
+
+	/**
+	 * Sets the color hint.
+	 *
+	 * @param value The new value.
+	 * @return This object.
+	 */
+	public RibbonAction color(String value) {
+		color = value;
+		return this;
+	}
+
+	/**
+	 * Marks an {@code optionGroup} as fully deselectable.
+	 *
+	 * @return This object.
+	 */
+	public RibbonAction deselectable() {
+		deselectable = true;
+		return this;
+	}
+
+	/**
+	 * Sets the member options of an {@code optionGroup}.
+	 *
+	 * @param options The member options.
+	 * @return This object.
+	 */
+	public RibbonAction options(Opt...options) {
+		this.options = new ArrayList<>(Arrays.asList(options));
+		return this;
+	}
+
+	/**
+	 * Maps a view's ribbon <b>option</b>/<b>optionGroup</b> toggles to the DataTables request parameters they
+	 * contribute <b>when active</b> (design doc §6.5, Task B.5).
+	 *
+	 * <p>
+	 * This is the pure, DOM-free counterpart of the {@code juneau-ribbon.js} contribution logic (so the two
+	 * implementations share one testable fixture and can't drift), and covers two option shapes:
+	 * <ul class='spaced-list'>
+	 * 	<li><b>Column-scoped</b> ({@code option(...).column(key).value(v)}): resolves {@code key} to its column
+	 * 		<i>index</i> in {@link ViewDef#columns} and contributes the DataTables per-column search param
+	 * 		<c>columns[&lt;index&gt;][search][value]=v</c> &mdash; consumed by {@code DataTablesQueryProtocol}.
+	 * 	<li><b>Custom-param</b> ({@code option(...).param(name).value(v)}): contributes <c>name=v</c> verbatim.  This is
+	 * 		<b>the endpoint's responsibility to parse</b> &mdash; {@code DataTablesQueryProtocol} does <b>not</b> read
+	 * 		custom (non-{@code columns[...]}/{@code search}/{@code order}/{@code start}/{@code length}) params.
+	 * </ul>
+	 *
+	 * <p>
+	 * An option carrying no {@code value} (or neither a {@code column} nor a {@code param}) contributes nothing;
+	 * {@code refresh}/{@code columnSearchToggle}/{@code divider}/{@code export} actions are not query-contributing and
+	 * are skipped.  {@code optionGroup} member options are mapped uniformly with top-level options.
+	 *
+	 * @param viewDef The built view whose ribbon + columns are read.  Must not be <jk>null</jk>.
+	 * @return An insertion-ordered map of contributed request param name &rarr; value (possibly empty).
+	 * @throws IllegalArgumentException If a column-scoped option references a column not present in {@code viewDef}.
+	 */
+	public static Map<String,String> toQueryParams(ViewDef viewDef) {
+		var out = new LinkedHashMap<String,String>();
+		if (viewDef.ribbon != null)
+			for (var a : viewDef.ribbon) {
+				if ("option".equals(a.type))
+					addOptionParam(out, viewDef, a.column, a.param, a.value);
+				else if ("optionGroup".equals(a.type) && a.options != null)
+					for (var o : a.options)
+						addOptionParam(out, viewDef, o.column, o.param, o.value);
+			}
+		return out;
+	}
+
+	/** Adds one option's contributed param (column-scoped search value, else a custom param); a valueless option is a no-op. */
+	private static void addOptionParam(Map<String,String> out, ViewDef viewDef, String column, String param, String value) {
+		if (value == null)
+			return;
+		if (column != null)
+			out.put("columns[" + columnIndex(viewDef, column) + "][search][value]", value);
+		else if (param != null)
+			out.put(param, value);
+	}
+
+	/** Resolves a column's {@code data} key to its zero-based index in the view's column list. */
+	private static int columnIndex(ViewDef viewDef, String columnData) {
+		var cols = viewDef.columns;
+		if (cols != null)
+			for (var i = 0; i < cols.size(); i++)
+				if (columnData.equals(cols.get(i).data))
+					return i;
+		throw iaex("Ribbon option references unknown column '%s'.", columnData);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java
new file mode 100644
index 0000000000..58ec805053
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowClassRule.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A declarative row-decorator rule in the {@code VIEW_META} wire contract (design doc §6.3).
+ *
+ * <p>
+ * Adds {@link #cssClass a CSS class} to a row when the rule matches, replacing a JS {@code createdRow} lambda.
+ * Serializes to <c>{"field":..., "op":..., "value"?:..., "class":...}</c>.
+ *
+ * <p>
+ * The {@link Op#EQ eq}/{@link Op#NE ne} operators <b>require</b> a {@link #value}; the
+ * {@link Op#PRESENT present}/{@link Op#ABSENT absent} operators test only whether {@code row[field]} is
+ * non-null/non-empty and therefore <b>omit</b> {@code value} from the wire.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="field,op,value,class")
+public class RowClassRule {
+
+	/**
+	 * The comparison operator for a {@link RowClassRule}.
+	 *
+	 * <p>
+	 * Each constant carries its lowercase wire token (the value emitted for the {@code op} field).
+	 */
+	public enum Op {
+
+		/** Row matches when {@code row[field]} equals the rule value. */
+		EQ("eq"),
+
+		/** Row matches when {@code row[field]} does not equal the rule value. */
+		NE("ne"),
+
+		/** Row matches when {@code row[field]} is non-null/non-empty (no value needed). */
+		PRESENT("present"),
+
+		/** Row matches when {@code row[field]} is missing/null (no value needed). */
+		ABSENT("absent");
+
+		private final String wire;
+
+		Op(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this operator.
+		 *
+		 * @return The wire token (e.g. <c>"eq"</c>).
+		 */
+		public String wire() {
+			return wire;
+		}
+
+		/** Whether this operator requires a comparison value ({@code eq}/{@code ne}). */
+		boolean requiresValue() {
+			return this == EQ || this == NE;
+		}
+	}
+
+	/** The row field this rule tests. */
+	public String field;
+
+	/** The wire token of the comparison operator (see {@link Op#wire()}). */
+	public String op;
+
+	/** The comparison value for {@code eq}/{@code ne}; omitted for {@code present}/{@code absent}. */
+	public Object value;
+
+	/** The CSS class to add to a matching row (serialized as the {@code class} key). */
+	@BeanProp(name="class")
+	public String cssClass;
+
+	/**
+	 * Creates a value-based rule for the {@link Op#EQ eq}/{@link Op#NE ne} operators.
+	 *
+	 * @param field The row field to test.  Must not be <jk>null</jk>.
+	 * @param op The operator.  Must be {@link Op#EQ} or {@link Op#NE}.
+	 * @param value The comparison value.  Must not be <jk>null</jk>.
+	 * @param cssClass The CSS class to add to a matching row.  Must not be <jk>null</jk>.
+	 * @return A new {@link RowClassRule}.
+	 * @throws IllegalArgumentException If {@code op} is not value-based or {@code value} is <jk>null</jk>.
+	 */
+	public static RowClassRule of(String field, Op op, Object value, String cssClass) {
+		if (! op.requiresValue())
+			throw iaex("rowClassRule op '%s' does not take a value; use the (field, op, class) form.", op.wire());
+		if (value == null)
+			throw iaex("rowClassRule op '%s' requires a non-null value.", op.wire());
+		var r = new RowClassRule();
+		r.field = field;
+		r.op = op.wire();
+		r.value = value;
+		r.cssClass = cssClass;
+		return r;
+	}
+
+	/**
+	 * Creates a presence-based rule for the {@link Op#PRESENT present}/{@link Op#ABSENT absent} operators.
+	 *
+	 * @param field The row field to test.  Must not be <jk>null</jk>.
+	 * @param op The operator.  Must be {@link Op#PRESENT} or {@link Op#ABSENT}.
+	 * @param cssClass The CSS class to add to a matching row.  Must not be <jk>null</jk>.
+	 * @return A new {@link RowClassRule} with no {@code value}.
+	 * @throws IllegalArgumentException If {@code op} is value-based.
+	 */
+	public static RowClassRule of(String field, Op op, String cssClass) {
+		if (op.requiresValue())
+			throw iaex("rowClassRule op '%s' requires a value; use the (field, op, value, class) form.", op.wire());
+		var r = new RowClassRule();
+		r.field = field;
+		r.op = op.wire();
+		r.cssClass = cssClass;
+		return r;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
new file mode 100644
index 0000000000..e005247ad4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one child {@link ViewDef}
+ * (TODO-399 Phase C, design doc §"Bean model").
+ *
+ * <p>
+ * A {@link Subtab} carries a stable {@code id} (the third hash segment, {@code #pageId/tabId/<subtabId>}) and
+ * {@code label} (the sub-tab bar button text), plus the referenced {@link #view}.  Built via
+ * {@link #create(String, String)} + {@link #view(ViewDef)}, mirroring the Phase B builder ergonomics.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link PageDef}
+ * 	<li class='jc'>{@link Tab}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,view")
+public class Subtab {
+
+	/** The stable sub-tab id (the third hash segment). */
+	public String id;
+
+	/** The sub-tab bar button text. */
+	public String label;
+
+	/** The referenced child view. */
+	public ViewDef view;
+
+	/**
+	 * Starts a new {@link Subtab} builder with the specified stable id and display label.
+	 *
+	 * @param id The stable sub-tab id.  Must not be <jk>null</jk> or blank.
+	 * @param label The sub-tab bar button text.
+	 * @return A new mutable {@link Subtab} to chain builder calls on.
+	 */
+	public static Subtab create(String id, String label) {
+		if (id == null || id.isBlank())
+			throw iaex("Subtab id must not be null or blank.");
+		var s = new Subtab();
+		s.id = id;
+		s.label = label;
+		return s;
+	}
+
+	/**
+	 * Sets the referenced child view.
+	 *
+	 * @param value The built child view.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public Subtab view(ViewDef value) {
+		view = value;
+		return this;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
new file mode 100644
index 0000000000..2c68797f2c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+
+/**
+ * A single top-level tab within a {@link PageDef}, referencing either one child {@link ViewDef} directly or an
+ * ordered list of {@link Subtab} (TODO-399 Phase C, design doc §"Bean model").
+ *
+ * <p>
+ * A {@link Tab} carries a stable {@code id} (the second hash segment, {@code #pageId/<tabId>/...}) and {@code label}
+ * (the tab-bar button text), plus <b>exactly one</b> of {@link #view} or {@link #subtabs} &mdash; enforced by
+ * {@link PageDef#build()}, not by the individual setters, so builder call order stays unconstrained.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link PageDef}
+ * 	<li class='jc'>{@link Subtab}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="id,label,view,subtabs")
+public class Tab {
+
+	/** The stable tab id (the second hash segment), unique across the page. */
+	public String id;
+
+	/** The tab-bar button text. */
+	public String label;
+
+	/** The referenced child view (mutually exclusive with {@link #subtabs}). */
+	public ViewDef view;
+
+	/** The ordered sub-tabs (mutually exclusive with {@link #view}). */
+	public List<Subtab> subtabs;
+
+	/**
+	 * Starts a new {@link Tab} builder with the specified stable id and display label.
+	 *
+	 * @param id The stable tab id.  Must not be <jk>null</jk> or blank.
+	 * @param label The tab-bar button text.
+	 * @return A new mutable {@link Tab} to chain builder calls on.
+	 */
+	public static Tab create(String id, String label) {
+		if (id == null || id.isBlank())
+			throw iaex("Tab id must not be null or blank.");
+		var t = new Tab();
+		t.id = id;
+		t.label = label;
+		return t;
+	}
+
+	/**
+	 * Sets the referenced child view (a leaf tab with no sub-tabs).
+	 *
+	 * @param value The built child view.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public Tab view(ViewDef value) {
+		view = value;
+		return this;
+	}
+
+	/**
+	 * Sets the ordered sub-tabs.
+	 *
+	 * @param value The sub-tabs, in display order.
+	 * @return This object.
+	 */
+	public Tab subtabs(Subtab...value) {
+		subtabs = new ArrayList<>(Arrays.asList(value));
+		return this;
+	}
+
+	/**
+	 * Validates this tab in isolation (design doc §"Bean model" validation rules): exactly one of {@link #view}/
+	 * {@link #subtabs} must be set, and sub-tab ids must be unique <b>within this tab</b> (a sub-tab id may safely
+	 * recur under a different tab &mdash; the hash's {@code tabId} segment already disambiguates the parent before
+	 * {@code subtabId} is ever resolved).
+	 *
+	 * @throws IllegalArgumentException On any rule violation.
+	 */
+	void validate() {
+		var hasView = view != null;
+		var hasSubtabs = subtabs != null && !subtabs.isEmpty();
+		if (hasView == hasSubtabs)
+			throw iaex("Tab '%s' must declare exactly one of view or subtabs.", id);
+		if (hasSubtabs) {
+			var ids = new HashSet<String>();
+			for (var s : subtabs) {
+				if (s.view == null)
+					throw iaex("Subtab '%s' in tab '%s' must declare a view.", s.id, id);
+				if (!ids.add(s.id))
+					throw iaex("Tab '%s': duplicate subtab id '%s'.", id, s.id);
+			}
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
new file mode 100644
index 0000000000..cb04fb2d35
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.bean.*;
+import org.apache.juneau.rest.server.converter.*;
+import org.apache.juneau.rest.server.datatables.*;
+
+/**
+ * The top-level, declarative "rich DataTables view" definition &mdash; the root of the frozen {@code VIEW_META}
+ * JSON wire contract (design doc §6.10).
+ *
+ * <p>
+ * A {@link ViewDef} is an ordinary Juneau bean built via a small fluent builder ({@link #create(String)} + chained
+ * setters + {@link #build()}) and serialized with the repo's canonical compact JSON marshaller.  The
+ * {@code @BeanType(properties=...)} contract pins both the <b>set</b> and the <b>order</b> of emitted keys so the
+ * sidecar the server writes and the {@code juneau-views.js} runtime consumes stay wire-stable.
+ *
+ * <p>
+ * The reserved detail/row-action/catalog fields ({@code details}, {@code rowActions}, {@code catalog}) are
+ * <b>not</b> part of this MVP builder and are therefore omitted from the serialized contract (design doc §6.10
+ * reserved stubs) &mdash; omitted, not emitted as {@code null}.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * 	ViewDef <jv>view</jv> = ViewDef.<jsm>create</jsm>(<js>"releases"</js>)
+ * 		.rowType(Release.<jk>class</jk>)
+ * 		.dataMode(DataMode.<jsf>SERVER</jsf>)
+ * 		.dataUrl(<js>"servlet:/releases/data"</js>)
+ * 		.defaultOrder(<js>"date"</js>, Dir.<jsf>DESC</jsf>)
+ * 		.columns(Column.<jsm>of</jsm>(<js>"name"</js>).title(<js>"Name"</js>))
+ * 		.build();
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link Column}
+ * 	<li class='jc'>{@link RibbonAction}
+ * 	<li class='jc'>{@link RowClassRule}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules")
+public class ViewDef {
+
+	/** The frozen contract version.  Bumped only on a breaking wire change. */
+	public static final String CONTRACT_VERSION = "2";
+
+	/**
+	 * How the table sources its rows.
+	 *
+	 * <p>
+	 * Each constant carries the lowercase wire token emitted for the {@code dataMode} field.
+	 */
+	public enum DataMode {
+
+		/** Server-side processing: DataTables posts draw/search/order params and the endpoint returns a page. */
+		SERVER("server"),
+
+		/** Client-side processing: the full row set is delivered once and DataTables paginates in-browser. */
+		CLIENT("client");
+
+		private final String wire;
+
+		DataMode(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this mode.
+		 *
+		 * @return The wire token (e.g. <c>"server"</c>).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
+	/**
+	 * A sort direction.
+	 *
+	 * <p>
+	 * Each constant carries the lowercase wire token emitted for an order entry's {@code dir} field.
+	 */
+	public enum Dir {
+
+		/** Ascending. */
+		ASC("asc"),
+
+		/** Descending. */
+		DESC("desc");
+
+		private final String wire;
+
+		Dir(String wire) {
+			this.wire = wire;
+		}
+
+		/**
+		 * Returns the lowercase wire token for this direction.
+		 *
+		 * @return The wire token (e.g. <c>"desc"</c>).
+		 */
+		public String wire() {
+			return wire;
+		}
+	}
+
+	/**
+	 * A single default-order entry: which column to sort by and in which direction.
+	 *
+	 * @since 10.0.0
+	 */
+	@BeanType(properties="data,dir")
+	public static class OrderEntry {
+
+		/** The column data key to sort by. */
+		public String data;
+
+		/** The sort direction wire token (see {@link Dir#wire()}). */
+		public String dir;
+
+		/**
+		 * Creates an order entry.
+		 *
+		 * @param data The column data key.  Must not be <jk>null</jk>.
+		 * @param dir The sort direction.  Must not be <jk>null</jk>.
+		 * @return A new {@link OrderEntry}.
+		 */
+		public static OrderEntry of(String data, Dir dir) {
+			var e = new OrderEntry();
+			e.data = data;
+			e.dir = dir.wire();
+			return e;
+		}
+	}
+
+	/** The frozen contract version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
+	public String contractVersion = CONTRACT_VERSION;
+
+	/** The stable view id (also the {@code localStorage} state-key root). */
+	public String id;
+
+	/** Diagnostic: the simple name of the row bean type this view renders. */
+	public String rowType;
+
+	/** How the table sources rows (wire token; see {@link DataMode#wire()}). */
+	public String dataMode;
+
+	/** The URL the client hits for row data. */
+	public String dataUrl;
+
+	/** The initial sort order. */
+	public List<OrderEntry> defaultOrder;
+
+	/** The column descriptors, in display order. */
+	public List<Column> columns;
+
+	/** The ribbon/toolbar actions, in display order. */
+	public List<RibbonAction> ribbon;
+
+	/** The declarative row-decorator rules. */
+	public List<RowClassRule> rowClassRules;
+
+	/**
+	 * The row bean type, retained (non-serialized) so {@link #build()} can auto-seed {@link #columns} from
+	 * {@link DataTablesColumns#of(Class)} when no explicit columns were declared.  Not a wire field.
+	 */
+	private transient Class<?> rowTypeClass;
+
+	/**
+	 * Starts a new {@link ViewDef} builder with the specified stable view id.
+	 *
+	 * @param id The stable view id.  Must not be <jk>null</jk> or blank.
+	 * @return A new mutable {@link ViewDef} to chain builder calls on.
+	 */
+	public static ViewDef create(String id) {
+		if (id == null || id.isBlank())
+			throw iaex("ViewDef id must not be null or blank.");
+		var v = new ViewDef();
+		v.id = id;
+		return v;
+	}
+
+	/**
+	 * Sets the row bean type; its {@link Class#getSimpleName() simple name} drives the {@link #rowType} diagnostic.
+	 *
+	 * @param value The row bean type.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef rowType(Class<?> value) {
+		rowType = value.getSimpleName();
+		rowTypeClass = value;
+		return this;
+	}
+
+	/**
+	 * Sets how the table sources its rows.
+	 *
+	 * @param value The data mode.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef dataMode(DataMode value) {
+		dataMode = value.wire();
+		return this;
+	}
+
+	/**
+	 * Sets the URL the client hits for row data.
+	 *
+	 * @param value The data URL.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef dataUrl(String value) {
+		dataUrl = value;
+		return this;
+	}
+
+	/**
+	 * Sets the initial sort order to a single column/direction entry.
+	 *
+	 * @param data The column data key to sort by.  Must not be <jk>null</jk>.
+	 * @param dir The sort direction.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef defaultOrder(String data, Dir dir) {
+		defaultOrder = new ArrayList<>(List.of(OrderEntry.of(data, dir)));
+		return this;
+	}
+
+	/**
+	 * Sets the column descriptors.
+	 *
+	 * @param value The columns, in display order.
+	 * @return This object.
+	 */
+	public ViewDef columns(Column...value) {
+		columns = new ArrayList<>(Arrays.asList(value));
+		return this;
+	}
+
+	/**
+	 * Sets the ribbon/toolbar actions.
+	 *
+	 * @param value The actions, in display order.
+	 * @return This object.
+	 */
+	public ViewDef ribbon(RibbonAction...value) {
+		ribbon = new ArrayList<>(Arrays.asList(value));
+		return this;
+	}
+
+	/**
+	 * Adds a value-based row-decorator rule ({@link RowClassRule.Op#EQ eq}/{@link RowClassRule.Op#NE ne}).
+	 *
+	 * @param field The row field to test.  Must not be <jk>null</jk>.
+	 * @param op The operator.  Must be {@link RowClassRule.Op#EQ} or {@link RowClassRule.Op#NE}.
+	 * @param value The comparison value.  Must not be <jk>null</jk>.
+	 * @param cssClass The CSS class to add to a matching row.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef rowClassRule(String field, RowClassRule.Op op, Object value, String cssClass) {
+		return addRowClassRule(RowClassRule.of(field, op, value, cssClass));
+	}
+
+	/**
+	 * Adds a presence-based row-decorator rule ({@link RowClassRule.Op#PRESENT present}/{@link RowClassRule.Op#ABSENT absent}).
+	 *
+	 * @param field The row field to test.  Must not be <jk>null</jk>.
+	 * @param op The operator.  Must be {@link RowClassRule.Op#PRESENT} or {@link RowClassRule.Op#ABSENT}.
+	 * @param cssClass The CSS class to add to a matching row.  Must not be <jk>null</jk>.
+	 * @return This object.
+	 */
+	public ViewDef rowClassRule(String field, RowClassRule.Op op, String cssClass) {
+		return addRowClassRule(RowClassRule.of(field, op, cssClass));
+	}
+
+	private ViewDef addRowClassRule(RowClassRule rule) {
+		if (rowClassRules == null)
+			rowClassRules = new ArrayList<>();
+		rowClassRules.add(rule);
+		return this;
+	}
+
+	/**
+	 * Finalizes the builder and returns the wire-ready {@link ViewDef}.
+	 *
+	 * <p>
+	 * When no columns were declared via {@link #columns(Column...)} but a {@link #rowType(Class) row type} was set,
+	 * the column list is auto-seeded from {@link DataTablesColumns#of(Class)} &mdash; one {@link Column} per readable
+	 * bean property, in bean-property order, carrying the DataTables-native {@code data}/{@code title}/
+	 * {@code orderable}/{@code searchable} fields.  Explicitly-declared columns are left untouched.
+	 *
+	 * @return This object.
+	 */
+	public ViewDef build() {
+		if (columns == null && rowTypeClass != null)
+			columns = seedColumns(rowTypeClass);
+		return this;
+	}
+
+	/**
+	 * Server-side wiring helper (design doc §6, Task B.5): builds the {@link QueryableSettings} bean that selects the
+	 * DataTables server-side-processing protocol for this view's data op.
+	 *
+	 * <p>
+	 * Register the returned bean in the data endpoint's resource (a {@code @Bean} factory method) alongside
+	 * {@code @Rest(converters=ProtocolQueryable.class)}; the op then returns its row {@code List} and
+	 * {@link ProtocolQueryable} parses the DataTables request, runs the shared query engine, and wraps the page in a
+	 * {@link DataTablesResults} envelope.  Binding the protocol's {@code rowType} to this view's row type keeps
+	 * array-index (positional) column resolution in sync with the view definition.
+	 *
+	 * <p>
+	 * The settings must be registered <b>at the op/resource level</b> (a mixin can't inject config into a host's other
+	 * endpoints).  Hand-wiring the equivalent
+	 * <c>QueryableSettings.create().protocol(new DataTablesQueryProtocol(rowType)).build()</c> stays fully supported;
+	 * this helper simply keeps the row type in one source of truth with the {@link ViewDef}.
+	 *
+	 * @return A new {@link QueryableSettings} whose protocol is a {@link DataTablesQueryProtocol} bound to this view's
+	 * 	row type.
+	 * @throws IllegalArgumentException If no {@link #rowType(Class) row type} was set on this view.
+	 */
+	public QueryableSettings queryableSettings() {
+		if (rowTypeClass == null)
+			throw iaex("ViewDef.queryableSettings() requires a rowType; call .rowType(...) before building the view.");
+		return QueryableSettings.create().protocol(new DataTablesQueryProtocol(rowTypeClass)).build();
+	}
+
+	/** Decorates the {@link DataTablesColumns#of(Class)} descriptor maps into {@link Column} beans. */
+	private static List<Column> seedColumns(Class<?> rowType) {
+		var out = new ArrayList<Column>();
+		for (var m : DataTablesColumns.of(rowType)) {
+			out.add(Column.of((String) m.get("data"))
+				.title((String) m.get("title"))
+				.orderable(Boolean.TRUE.equals(m.get("orderable")))
+				.searchable(Boolean.TRUE.equals(m.get("searchable"))));
+		}
+		return out;
+	}
+}
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
new file mode 100644
index 0000000000..60283db7f4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.bean.html5.HtmlBuilder.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.html5.*;
+import org.apache.juneau.marshall.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Builds the HTML delivery shell for a {@link ViewDef} &mdash; the {@code data-juneau-view} table plus the
+ * {@code <script type="application/json">} VIEW_META sidecar the {@code juneau-views.js} runtime consumes (design
+ * doc §6.1).
+ *
+ * <p>
+ * Mirrors the sibling {@link org.apache.juneau.rest.server.datatables.DataTablesTable DataTablesTable} pattern, but
+ * emits the <b>distinct</b> {@link #MARKER_ATTR data-juneau-view} marker (never {@code data-juneau-datatable}) so
+ * the VIEW_META path and the plain-DataTables path never collide on the same node.  The returned {@link Div} carries:
+ * <ul class='spaced-list'>
+ * 	<li>a {@code <table>} with a stable {@code id} (the {@code $('#id')} selector) + the {@code data-juneau-view="<id>"}
+ * 		marker and a {@code <thead>} of column titles, and
+ * 	<li>a sibling {@code <script type="application/json" id="juneau-view:<id>">} sidecar carrying the serialized
+ * 		{@link ViewDef}.
+ * </ul>
+ *
+ * <p>
+ * When {@code rows} are supplied (client-side datasets), they are rendered into a {@code <tbody>} up front; in the
+ * server-side dogfood path {@code rows} is omitted and the rows arrive via ajax draws against
+ * {@link ViewDef#dataUrl}.
+ *
+ * <h5 class='section'>Escaping contract (security-critical &mdash; design doc §6.1):</h5>
+ * <p>
+ * 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
+ * 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
+ * re-serializable &mdash; it survives a serialize&rarr;object&rarr;serialize cycle and the full {@code HtmlDoc} page
+ * path.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * 	<li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class ViewTable {
+
+	/** Marker attribute the {@code juneau-views.js} runtime looks for to auto-initialize a view table. */
+	public static final String MARKER_ATTR = "data-juneau-view";
+
+	/** Prefix of the sidecar {@code <script>} element id: {@code juneau-view:<viewId>}. */
+	public static final String SIDECAR_ID_PREFIX = "juneau-view:";
+
+	private ViewTable() {}
+
+	/**
+	 * Builds the view-table shell for a server-side view (no up-front rows), using the default marshalling context.
+	 *
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>} and the JSON sidecar.
+	 */
+	public static Div of(ViewDef viewDef) {
+		return of(MarshallingContext.DEFAULT, viewDef, null);
+	}
+
+	/**
+	 * Builds the view-table shell and renders {@code rows} into the {@code <tbody>} (client-side datasets), using the
+	 * default marshalling context.
+	 *
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps).  Can be <jk>null</jk> (server-side mode) or empty.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+	 * 	JSON sidecar.
+	 */
+	public static Div of(ViewDef viewDef, Collection<?> rows) {
+		return of(MarshallingContext.DEFAULT, viewDef, rows);
+	}
+
+	/**
+	 * Builds the view-table shell using the specified marshalling context for bean-property cell reads.
+	 *
+	 * @param ctx The marshalling context used to read bean-property cell values.  Must not be <jk>null</jk>.
+	 * @param viewDef The built view definition.  Must not be <jk>null</jk>.
+	 * @param rows The rows to render (beans or maps).  Can be <jk>null</jk> (server-side mode) or empty.
+	 * @return A new {@link Div} carrying the {@code <table data-juneau-view>}, an optional {@code <tbody>}, and the
+	 * 	JSON sidecar.
+	 */
+	public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows) {
+		var id = viewDef.id;
+		var cols = viewDef.columns == null ? List.<Column>of() : viewDef.columns;
+
+		// <thead> of column titles (falling back to the data key when no title was set).
+		var headerCells = new ArrayList<>(cols.size());
+		for (var c : cols)
+			headerCells.add(th(c.title == null ? c.data : c.title));
+
+		var tableChildren = new ArrayList<>();
+		tableChildren.add(thead(tr(headerCells.toArray())));
+
+		if (rows != null) {
+			var bodyRows = new ArrayList<>(rows.size());
+			for (var row : rows) {
+				var cells = new ArrayList<>(cols.size());
+				for (var c : cols) {
+					var v = value(ctx, row, c.data);
+					cells.add(td(v == null ? "" : v));
+				}
+				bodyRows.add(tr(cells.toArray()));
+			}
+			tableChildren.add(tbody(bodyRows.toArray()));
+		}
+
+		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).
+		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)
+			return m.get(key);
+		return ctx.toBeanMap(row).get(key);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
new file mode 100644
index 0000000000..79ae6088a4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -0,0 +1,304 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.zip.*;
+
+import org.apache.juneau.commons.utils.*;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.http.resource.*;
+import org.apache.juneau.rest.server.*;
+
+/**
+ * Mixin that serves the first-party rich-view runtime assets &mdash; {@code juneau-views.js},
+ * {@code juneau-ribbon.js}, {@code juneau-renders.js}, {@code juneau-views.css}, and the opt-in
+ * {@code juneau-pages.js} tabs/sub-tabs page runtime (TODO-399 Phase C) &mdash; each at its stable path (design doc
+ * §6.1).
+ *
+ * <p>
+ * Compose into a host resource via {@link Rest#mixins() @Rest(mixins=ViewsMixin.class)}; the asset URLs then become
+ * available alongside the host's own endpoints, so browser pages can load the runtime without the application hosting
+ * it itself.  This mirrors the sibling {@code DataTablesMixin}/{@code ConsoleChromeMixin} serving pattern (those
+ * modules are not dependencies of this one, so they are not linked here).
+ *
+ * <h5 class='section'>What this ships (and what it deliberately does not):</h5>
+ * <p>
+ * This is an Apache project; the <a class="doclink" href="https://datatables.net">DataTables</a> library and jQuery are
+ * <b>not</b> ASF category-A licensed, so <b>they are not bundled here.</b>  Only the thin, first-party, clean-room
+ * runtime is served; the DataTables/jQuery/Buttons libraries stay <b>caller-provided</b> (CDN or self-hosted).  The
+ * base {@code juneau-views.css} {@code .tag} chip is dependency-free (neutral, no colors); {@code console-ui}'s
+ * {@code chrome.css} themes the same {@code .tag.<domain>.<value>} classes when present, but this module takes
+ * <b>no</b> dependency on it.
+ *
+ * <h5 class='section'>Cache-busting + versioned URLs:</h5>
+ * <p>
+ * Each asset is served with a one-day {@code Cache-Control} and referenced from a page's {@code head=} block with a
+ * {@code ?v=<buildVersion>-<hash8>} cache-buster (see {@link #viewAssetUrl(String)}), where {@code hash8} is an
+ * 8-hex-char CRC32 of that asset's own served bytes, computed once and cached (classpath resources never change
+ * within a running JVM). Keying the buster off content rather than {@code buildVersion} alone matters for
+ * {@code -SNAPSHOT} builds: that version string is stable across dev rebuilds, so a version-only buster would keep
+ * serving a browser's stale cached copy after every rebuild - the content hash changes the instant the bytes do,
+ * with no SNAPSHOT special-casing needed. {@code buildVersion} is the framework's
+ * {@link Package#getImplementationVersion() implementation version}, falling back to {@code "dev"} when running from an
+ * unpackaged (IDE/test) classpath.  The served endpoint ignores the query string, so any (or no) {@code ?v=} still
+ * resolves to the same asset.
+ *
+ * <h5 class='section'>Contract-version handshake:</h5>
+ * <p>
+ * {@link #CONTRACT_VERSION} is the single wire-contract discriminator, kept in one source of truth with the value the
+ * model emits ({@link ViewDef#CONTRACT_VERSION}).  {@code juneau-views.js} bakes in the same value so the client can
+ * fail loud when a served {@code VIEW_META} sidecar's {@code contractVersion} differs from the runtime's.
+ *
+ * <h5 class='section'>Mixin-only deployment:</h5>
+ * <p>
+ * The mount paths are pinned at the op level by {@code @RestGet(path=...)} on the serving methods; a class-level
+ * {@code @Rest(paths=...)} would be silently ignored under the mixin pattern.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link ViewDef}
+ * 	<li class='jc'>{@link ViewTable}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+// @formatter:off
+@Rest
+public class ViewsMixin {
+
+	/** The URL path at which the client initializer is served (relative to the host mount). */
+	public static final String VIEWS_JS_PATH = "/juneau-views.js";
+
+	/** The URL path at which the ribbon runtime is served (relative to the host mount). */
+	public static final String RIBBON_JS_PATH = "/juneau-ribbon.js";
+
+	/** The URL path at which the renderer registry is served (relative to the host mount). */
+	public static final String RENDERS_JS_PATH = "/juneau-renders.js";
+
+	/** The URL path at which the base view stylesheet is served (relative to the host mount). */
+	public static final String VIEWS_CSS_PATH = "/juneau-views.css";
+
+	/** The URL path at which the icon registry is served (relative to the host mount). */
+	public static final String ICONS_JS_PATH = "/juneau-icons.js";
+
+	/**
+	 * The URL path at which the opt-in tabs/sub-tabs page runtime is served (relative to the host mount) &mdash;
+	 * TODO-399 Phase C.  A separate, opt-in asset (Decision 2(A)): single-view pages never load it.
+	 */
+	public static final String PAGES_JS_PATH = "/juneau-pages.js";
+
+	/**
+	 * The frozen {@code VIEW_META} contract-version handshake constant, kept in one source of truth with the value the
+	 * model emits ({@link ViewDef#CONTRACT_VERSION}).
+	 */
+	public static final String CONTRACT_VERSION = ViewDef.CONTRACT_VERSION;
+
+	/** Classpath location of the shipped initializer. */
+	static final String VIEWS_JS_RESOURCE = "/org/apache/juneau/views/juneau-views.js";
+
+	/** Classpath location of the shipped ribbon runtime. */
+	static final String RIBBON_JS_RESOURCE = "/org/apache/juneau/views/juneau-ribbon.js";
+
+	/** Classpath location of the shipped renderer registry. */
+	static final String RENDERS_JS_RESOURCE = "/org/apache/juneau/views/juneau-renders.js";
+
+	/** Classpath location of the shipped base stylesheet. */
+	static final String VIEWS_CSS_RESOURCE = "/org/apache/juneau/views/juneau-views.css";
+
+	/** Classpath location of the shipped icon registry. */
+	static final String ICONS_JS_RESOURCE = "/org/apache/juneau/views/juneau-icons.js";
+
+	/** Classpath location of the shipped page runtime (TODO-399 Phase C). */
+	static final String PAGES_JS_RESOURCE = "/org/apache/juneau/views/juneau-pages.js";
+
+	/** Content type emitted for the JavaScript assets. */
+	static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8";
+
+	/** Content type emitted for the stylesheet asset. */
+	static final String CSS_CONTENT_TYPE = "text/css;charset=utf-8";
+
+	/** {@code Cache-Control} header emitted for every asset (1 day). */
+	static final String CACHE_CONTROL = "max-age=86400, public";
+
+	/** Per-resource byte cache, populated on first request (the shipped files never vary). */
+	private static final Map<String,byte[]> CACHE = new ConcurrentHashMap<>();
+
+	/** Per-path content-hash cache, populated on first {@link #viewAssetUrl(String)} call (mirrors {@link #CACHE}). */
+	private static final Map<String,String> HASH_CACHE = new ConcurrentHashMap<>();
+
+	/**
+	 * [GET /juneau-views.js] &mdash; serve the client initializer.
+	 *
+	 * @return The initializer as a JavaScript {@link HttpResource}.
+	 */
+	@RestGet(
+		path=VIEWS_JS_PATH,
+		summary="Juneau rich-view client initializer",
+		description="First-party JavaScript that auto-initializes <table data-juneau-view> elements from their VIEW_META sidecar.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getViewsScript() {
+		return serve(VIEWS_JS_RESOURCE, JS_CONTENT_TYPE);
+	}
+
+	/**
+	 * [GET /juneau-ribbon.js] &mdash; serve the ribbon/toolbar runtime.
+	 *
+	 * @return The ribbon runtime as a JavaScript {@link HttpResource}.
+	 */
+	@RestGet(
+		path=RIBBON_JS_PATH,
+		summary="Juneau rich-view ribbon runtime",
+		description="First-party JavaScript that builds the ribbon/toolbar from a view's VIEW_META ribbon actions.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getRibbonScript() {
+		return serve(RIBBON_JS_RESOURCE, JS_CONTENT_TYPE);
+	}
+
+	/**
+	 * [GET /juneau-renders.js] &mdash; serve the renderer registry.
+	 *
+	 * @return The renderer registry as a JavaScript {@link HttpResource}.
+	 */
+	@RestGet(
+		path=RENDERS_JS_PATH,
+		summary="Juneau rich-view renderer registry",
+		description="First-party, dependency-free JavaScript cell-renderer registry consumed by the view initializer.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getRendersScript() {
+		return serve(RENDERS_JS_RESOURCE, JS_CONTENT_TYPE);
+	}
+
+	/**
+	 * [GET /juneau-views.css] &mdash; serve the base view stylesheet.
+	 *
+	 * @return The base stylesheet as a CSS {@link HttpResource}.
+	 */
+	@RestGet(
+		path=VIEWS_CSS_PATH,
+		summary="Juneau rich-view base stylesheet",
+		description="First-party base '.tag' chip stylesheet (neutral, no colors); console-ui themes the same classes when present.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getViewsStylesheet() {
+		return serve(VIEWS_CSS_RESOURCE, CSS_CONTENT_TYPE);
+	}
+
+	/**
+	 * [GET /juneau-icons.js] &mdash; serve the icon registry.
+	 *
+	 * @return The icon registry as a JavaScript {@link HttpResource}.
+	 */
+	@RestGet(
+		path=ICONS_JS_PATH,
+		summary="Juneau rich-view icon registry",
+		description="First-party, dependency-free JavaScript icon registry (name -> inline-SVG markup) consumed by the ribbon/paging-pill runtime.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getIconsScript() {
+		return serve(ICONS_JS_RESOURCE, JS_CONTENT_TYPE);
+	}
+
+	/**
+	 * [GET /juneau-pages.js] &mdash; serve the opt-in tabs/sub-tabs page runtime (TODO-399 Phase C).
+	 *
+	 * @return The page runtime as a JavaScript {@link HttpResource}.
+	 */
+	@RestGet(
+		path=PAGES_JS_PATH,
+		summary="Juneau rich-view tabs/sub-tabs page runtime",
+		description="First-party, opt-in JavaScript that hash-routes a PageDef's tabs/sub-tabs and lazy-inits each panel's view table on first activation.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public HttpResource getPagesScript() {
+		return serve(PAGES_JS_RESOURCE, JS_CONTENT_TYPE);
+	}
+
+	/**
+	 * Returns the servlet-relative URL for a served asset, carrying a {@code ?v=<buildVersion>-<hash8>} content-
+	 * sensitive cache-buster suitable for a page's {@code head=} block (see the class Javadoc's cache-busting
+	 * section for why the buster is content- rather than purely version-keyed).
+	 *
+	 * @param path One of the asset path constants ({@link #VIEWS_JS_PATH}, {@link #RIBBON_JS_PATH},
+	 * 	{@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH}).
+	 * @return The servlet-relative asset URL with the version+content-hash cache-buster appended.
+	 */
+	public static String viewAssetUrl(String path) {
+		return "servlet:" + path + "?v=" + buildVersion() + "-" + contentHash(path);
+	}
+
+	/** Reads (and caches) the classpath asset and wraps it as a cacheable {@link HttpResource}. */
+	private static HttpResource serve(String resource, String contentType) {
+		var bytes = CACHE.computeIfAbsent(resource, ViewsMixin::load);
+		return HttpResourceBean.of(
+			ByteArrayBody.of(bytes, contentType),
+			list(ContentType.of(contentType), CacheControl.of(CACHE_CONTROL))
+		);
+	}
+
+	/** Reads a shipped classpath asset into a byte array, wrapping the (effectively unreachable) IO failure. */
+	private static byte[] load(String resource) {
+		try (var in = ViewsMixin.class.getResourceAsStream(resource)) {
+			if (in == null)
+				throw new IOException("Classpath resource not found: " + resource);
+			return IoUtils.readBytes(in);
+		} catch (IOException e) {  // HTT: unreachable - the asset ships in the same jar as this class.
+			throw new UncheckedIOException(e);
+		}
+	}
+
+	/**
+	 * Resolves the framework build version for asset cache-busting, falling back to {@code "dev"} when unset (e.g.
+	 * running from an IDE/test classpath rather than a packaged jar).
+	 */
+	private static String buildVersion() {
+		var v = ViewsMixin.class.getPackage().getImplementationVersion();
+		return v == null ? "dev" : v;  // HTT: the non-null branch only fires from a packaged jar manifest - unreachable against unpackaged target/classes.
+	}
+
+	/** Computes (and caches) the given asset path's 8-hex-char content hash from its served bytes. */
+	private static String contentHash(String path) {
+		return HASH_CACHE.computeIfAbsent(path, p -> hash8(CACHE.computeIfAbsent(resourceFor(p), ViewsMixin::load)));
+	}
+
+	/** Maps a public asset path constant to its classpath resource constant (content-hashing only; routing itself is by {@code @RestGet(path=...)}). */
+	private static String resourceFor(String path) {
+		if (VIEWS_JS_PATH.equals(path)) return VIEWS_JS_RESOURCE;
+		if (RIBBON_JS_PATH.equals(path)) return RIBBON_JS_RESOURCE;
+		if (RENDERS_JS_PATH.equals(path)) return RENDERS_JS_RESOURCE;
+		if (VIEWS_CSS_PATH.equals(path)) return VIEWS_CSS_RESOURCE;
+		if (ICONS_JS_PATH.equals(path)) return ICONS_JS_RESOURCE;
+		if (PAGES_JS_PATH.equals(path)) return PAGES_JS_RESOURCE;
+		throw new IllegalArgumentException("Unknown asset path: " + path);
+	}
+
+	/** Formats a CRC32 checksum of {@code bytes} as a zero-padded 8-hex-char content hash. */
+	private static String hash8(byte[] bytes) {
+		var crc = new CRC32();
+		crc.update(bytes);
+		return String.format("%08x", crc.getValue());
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
new file mode 100644
index 0000000000..94833efff6
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+/**
+ * A typed, declarative "rich DataTables view" toolkit that serializes to the frozen {@code VIEW_META} JSON wire
+ * contract consumed by the first-party client runtime.
+ *
+ * <p>
+ * This optional module builds on {@code juneau-rest-server-datatables} (the TODO-355 server-side query protocol):
+ * an app author declares one {@link org.apache.juneau.rest.server.views.ViewDef} &mdash; columns, ribbon actions,
+ * row-decorator rules, and named cell renderers &mdash; as ordinary Juneau beans, and the model serializes to the
+ * {@code VIEW_META} JSON contract that the shipped {@code juneau-views.js}/{@code juneau-ribbon.js}/
+ * {@code juneau-renders.js} runtime consumes to wire up a fully-featured DataTable.
+ *
+ * <p>
+ * The module owns a dependency-free base {@code .tag} chip stylesheet and takes <b>no</b> hard dependency on
+ * {@code juneau-rest-server-console-ui}: the base chip keeps client-rendered views legible standalone, while
+ * console-ui's palette themes the shared {@code .tag.<domain>.<value>} class-name contract when present.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * 	<li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesQueryProtocol}
+ * 	<li class='jc'>{@link org.apache.juneau.rest.server.datatables.DataTablesColumns}
+ * 	<li class='link'><a class="doclink" href="https://datatables.net/manual/server-side">DataTables Server-Side Processing</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.rest.server.views;
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
new file mode 100644
index 0000000000..fe42d7884d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-icons.js - dependency-free icon registry for the Apache Juneau rich-view toolkit (DataTables ribbon
+ * visual-parity pass).
+ *
+ * A pure, library-free registry mapping icon names to inline-SVG markup, mirroring juneau-renders.js's
+ * registerRenderer/resolveRenderer pattern exactly (registerIcon/resolveIcon).  Apps can register additional or
+ * overriding icons at runtime via window.JuneauViews.icons.registerIcon(name, svgMarkup).
+ *
+ * Ships the glyphs this pass's ribbon/paging-ribbon components actually use - each a minimal, single-<path>,
+ * currentColor-filled Material Symbols Outlined glyph, sourced from Google's public google/material-design-icons
+ * repository (Apache License, Version 2.0) - see the repo-root NOTICE file for the attribution paragraph.  This is
+ * a clean-room, spec-from-behavior asset, NOT ported from IRS (design doc §6).
+ */
+(function () {
+	"use strict";
+
+	var NS = window.JuneauViews = window.JuneauViews || {};
+
+	var registry = NS._icons = NS._icons || {};
+
+	/** Registers (or overrides) an icon's inline-SVG markup under `name`. */
+	function registerIcon(name, svgMarkup) {
+		registry[name] = svgMarkup;
+		return registry[name];
+	}
+
+	/** Looks up an icon's markup by name; returns null when unknown (callers fall back to rendering raw text). */
+	function resolveIcon(name) {
+		return Object.prototype.hasOwnProperty.call(registry, name) ? registry[name] : null;
+	}
+
+	NS.icons = {
+		registerIcon: registerIcon,
+		resolveIcon: resolveIcon
+	};
+
+	// Bundled glyphs (Material Symbols Outlined, Apache License 2.0, Google upstream - see NOTICE).
+	registerIcon("content_copy", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M360-240q-33 0-56.5-23.5T280-320v-480q0-33 23.5-56.5T360-880h360q33 0 56.5 23.5T800-800v480q0 33-23.5 56.5T720-240H360Zm0-80h360v-480H360v480ZM200-80q-33 0-56.5-23.5T120-160v-560h80v560h440v80H200Zm160-240v-480 480Z\"/></svg>");
+	registerIcon("csv", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M230-360h120v-60H250v-120h100v-60H230q-17 0-28.5 11.5T190-560v160q0 17 11.5 28.5T230-360Zm156 0h120q17 0 28.5-11.5T546-400v-60q0-17-11.5-31.5T506-506h-60v-34h100v-60H426q-17 0-28.5 11.5T386-560v60q0 17 11.5 30.5T426-456h60v36H386v60Zm264 0h60l70-240h-60l-40 138-40-138h-60l70 240ZM160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm0-80h64 [...]
+	registerIcon("table", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm240-240H200v160h240v-160Zm80 0v160h240v-160H520Zm-80-80v-160H200v160h240Zm80 0h240v-160H520v160ZM200-680h560v-80H200v80Z\"/></svg>");
+	registerIcon("picture_as_pdf", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M360-460h40v-80h40q17 0 28.5-11.5T480-580v-40q0-17-11.5-28.5T440-660h-80v200Zm40-120v-40h40v40h-40Zm120 120h80q17 0 28.5-11.5T640-500v-120q0-17-11.5-28.5T600-660h-80v200Zm40-40v-120h40v120h-40Zm120 40h40v-80h40v-40h-40v-40h40v-40h-80v200ZM320-240q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-5 [...]
+	registerIcon("refresh", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z\"/></svg>");
+	registerIcon("manage_search", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M80-200v-80h400v80H80Zm0-200v-80h200v80H80Zm0-200v-80h200v80H80Zm744 400L670-354q-24 17-52.5 25.5T560-320q-83 0-141.5-58.5T360-520q0-83 58.5-141.5T560-720q83 0 141.5 58.5T760-520q0 29-8.5 57.5T726-410l154 154-56 56ZM560-400q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Z\"/></svg>");
+	registerIcon("unfold_less", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"m356-160-56-56 180-180 180 180-56 56-124-124-124 124Zm124-404L300-744l56-56 124 124 124-124 56 56-180 180Z\"/></svg>");
+	registerIcon("tune", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z\"/></svg>");
+	registerIcon("first_page", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M240-240v-480h80v480h-80Zm440 0L440-480l240-240 56 56-184 184 184 184-56 56Z\"/></svg>");
+	registerIcon("chevron_left", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M560-240 320-480l240-240 56 56-184 184 184 184-56 56Z\"/></svg>");
+	registerIcon("chevron_right", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z\"/></svg>");
+	registerIcon("last_page", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"m280-240-56-56 184-184-184-184 56-56 240 240-240 240Zm360 0v-480h80v480h-80Z\"/></svg>");
+	registerIcon("filter_alt", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M440-160q-17 0-28.5-11.5T400-200v-240L168-736q-15-20-4.5-42t36.5-22h560q26 0 36.5 22t-4.5 42L560-440v240q0 17-11.5 28.5T520-160h-80Zm40-308 198-252H282l198 252Zm0 0Z\"/></svg>");
+	// Unified paging ribbon's page-size menu-button caret (visual-parity follow-up - see juneau-views.js's
+	// buildPageSizeMenu(...)); previously missing from this pass's original 13-glyph set.
+	registerIcon("expand_more", "<svg viewBox=\"0 -960 960 960\" fill=\"currentColor\"><path d=\"M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z\"/></svg>");
+})();
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
new file mode 100644
index 0000000000..c2c341c6e9
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-pages.js - opt-in tabs/sub-tabs page runtime for the Apache Juneau rich-view toolkit (TODO-399 Phase C).
+ *
+ * Separate, opt-in asset (Decision 2, RESOLVED (A)): a page composing multiple ViewDef views into tabs/sub-tabs
+ * loads this file IN ADDITION to juneau-views.js; a plain single-view page never loads it and pays nothing for
+ * hash-routing/lazy-init complexity.
+ *
+ * On DOMContentLoaded it owns init for every [data-juneau-page] shell: it reads the shell's id, finds the matching
+ * <script type="application/json" id="juneau-page:<id>"> PAGE_META sidecar, JSON.parses it, and - like
+ * juneau-views.js's own VIEW_META handshake - performs a FAIL-LOUD contract-version check on PAGE_META's
+ * contractVersion (a DISTINCT check from the per-view VIEW_META handshake, which juneau-views.js's initTable(...)
+ * keeps running unchanged for each panel's view table - this runtime does not bypass or duplicate that one).
+ *
+ * Responsibilities (design doc §"Client page runtime"):
+ *   1. Deep-linkable initial tab: parse location.hash (#<pageId>/<tabId>[/<subtabId>]), falling back to the first
+ *      tab/subtab when absent or unknown.
+ *   2. Hash-driven switching: tab/sub-tab links are plain <a href="#pageId/tabId[/subtabId]"> elements, so a click
+ *      updates location.hash via the browser's native anchor behavior (back/forward + shareable, for free) - this
+ *      runtime only needs a `hashchange` listener to reflect the new hash into the visible panel.
+ *   3. Lazy init on first activation: a panel's table[data-juneau-view] is inited (via juneau-views.js's now-public
+ *      NS.init.initTable, idempotent) the first time its panel becomes visible; on every subsequent show,
+ *      DataTables' `columns.adjust()` is called instead to fix any sizing deferred while the panel was
+ *      `display:none` (DataTables mis-sizes columns initialized inside a hidden container).
+ *
+ * SECURITY: hash-derived tabId/subtabId values are NEVER written into innerHTML/selectors built from them - they
+ * are only compared (===) against existing elements' data-tab-id/data-subtab-id ATTRIBUTE VALUES (read via
+ * getAttribute, not interpolated into a querySelector string), so a crafted location.hash cannot inject markup or
+ * break out of a CSS selector.
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM-free (plain data in, plain data out) so it is unit-checkable
+ * independent of a browser (mirrors the juneau-views.js/juneau-ribbon.js Option-B split); the
+ * "DOM BINDING LAYER" is the thin shim that scans, parses, and wires hashchange + lazy init.
+ */
+(function () {
+	"use strict";
+
+	// Contract-version handshake for PAGE_META: MUST equal PageDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION
+	// (single source of truth on the server, itself reusing ViewDef.CONTRACT_VERSION).  Distinct from - and does
+	// not replace - the per-view VIEW_META handshake juneau-views.js's initTable(...) already performs.
+	var JUNEAU_PAGE_CONTRACT_VERSION = "2";
+
+	var NS = window.JuneauViews = window.JuneauViews || {};
+
+	// ==================================================================================================================
+	// PURE LOGIC LAYER  (no DOM)
+	// ==================================================================================================================
+
+	/**
+	 * Parses a `location.hash`-shaped string into `{pageId, tabId, subtabId}`.  Accepts the hash with or without its
+	 * leading "#".  Returns `null` for an empty/absent hash (caller falls back to the first tab/subtab).  Segments
+	 * beyond the third are ignored; a missing trailing segment yields `null` for that field (never `""`).
+	 */
+	function parseHash(hash) {
+		var h = String(hash || "").replace(/^#/, "");
+		if (!h) return null;
+		var parts = h.split("/");
+		return {
+			pageId: parts[0] || null,
+			tabId: parts[1] || null,
+			subtabId: parts[2] || null
+		};
+	}
+
+	/** Finds the entry in `list` whose `.id === id`, or `null` when absent/list is empty. */
+	function findById(list, id) {
+		if (!list) return null;
+		for (var i = 0; i < list.length; i++)
+			if (list[i].id === id) return list[i];
+		return null;
+	}
+
+	/**
+	 * Resolves the tab/sub-tab that should be active for a given PAGE_META + `location.hash`, falling back to the
+	 * first tab (and, when that tab has sub-tabs, its first sub-tab) whenever the hash is absent, targets a
+	 * different page, or names an id that no longer exists (design doc §"Client page runtime" pt.1).  Returns
+	 * `{tabId, subtabId}` (`subtabId` is `null` for a leaf tab) or `null` when `pageMeta` has no tabs at all.
+	 */
+	function resolveInitial(pageMeta, hash) {
+		var tabs = (pageMeta && pageMeta.tabs) || [];
+		if (!tabs.length) return null;
+
+		var parsed = parseHash(hash);
+		var tab = null;
+		if (parsed && parsed.pageId === pageMeta.id && parsed.tabId)
+			tab = findById(tabs, parsed.tabId);
+		if (!tab) tab = tabs[0];
+
+		var result = { tabId: tab.id, subtabId: null };
+		if (tab.subtabs && tab.subtabs.length) {
+			var sub = (parsed && parsed.subtabId) ? findById(tab.subtabs, parsed.subtabId) : null;
+			if (!sub) sub = tab.subtabs[0];
+			result.subtabId = sub.id;
+		}
+		return result;
+	}
+
+	/** Builds the deep-linkable hash for a (pageId, tabId, subtabId) triple; `subtabId` may be null/omitted. */
+	function hashFor(pageId, tabId, subtabId) {
+		var h = "#" + pageId + "/" + tabId;
+		if (subtabId != null) h += "/" + subtabId;
+		return h;
+	}
+
+	// ==================================================================================================================
+	// DOM BINDING LAYER  (thin shim)
+	// ==================================================================================================================
+
+	function warn(msg) { if (window.console && console.warn) console.warn(msg); }
+	function error(msg) { if (window.console && console.error) console.error(msg); }
+
+	/** Renders the fail-loud, visible banner used on a PAGE_META contract-version mismatch (or a parse failure). */
+	function renderBanner(root, message) {
+		var b = document.createElement("div");
+		b.className = "jc-page-error";
+		b.textContent = message;
+		root.insertBefore(b, root.firstChild);
+	}
+
+	/** Whether `panel`'s data-panel-tab/data-panel-subtab attributes match the resolved active (tabId, subtabId). */
+	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;
+	}
+
+	/** Lazily inits (or, if already a DataTable, columns.adjust()s) every view table inside a just-shown panel. */
+	function activatePanelViews(panel) {
+		var tables = panel.querySelectorAll("table[data-juneau-view]");
+		Array.prototype.forEach.call(tables, function (t) {
+			var $ = window.jQuery;
+			if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.isDataTable(t)) {
+				$(t).DataTable().columns.adjust();
+			} else if (NS.init && NS.init.initTable) {
+				NS.init.initTable(t);
+			} else {
+				warn("Juneau page: juneau-views.js not loaded (or too old to expose initTable); cannot lazy-init view '" +
+					t.getAttribute("data-juneau-view") + "'.");
+			}
+		});
+	}
+
+	/** Applies the resolved (tabId, subtabId) to `root`: toggles active classes, shows/hides panels, lazy-inits. */
+	function showActive(root, tabId, subtabId) {
+		var tabs = root.querySelectorAll(".jc-tab");
+		Array.prototype.forEach.call(tabs, function (el) {
+			el.classList.toggle("jc-tab-active", el.getAttribute("data-tab-id") === tabId);
+		});
+
+		var subtabs = root.querySelectorAll(".jc-subtab");
+		Array.prototype.forEach.call(subtabs, function (el) {
+			var active = subtabId != null &&
+				el.getAttribute("data-subtab-id") === subtabId &&
+				el.getAttribute("data-parent-tab") === tabId;
+			el.classList.toggle("jc-subtab-active", active);
+		});
+
+		var panels = root.querySelectorAll(".jc-panel, .jc-subpanel");
+		Array.prototype.forEach.call(panels, function (p) {
+			var active = panelMatches(p, tabId, subtabId);
+			p.classList.toggle("jc-active", active);
+			if (active) activatePanelViews(p);
+		});
+	}
+
+	function initPage(root) {
+		var id = root.getAttribute("data-juneau-page");
+		var sidecar = document.getElementById("juneau-page:" + id);
+		if (!sidecar) { error("Juneau page '" + id + "': missing PAGE_META sidecar; refusing to init."); return; }
+
+		var pageMeta;
+		try {
+			pageMeta = JSON.parse(sidecar.textContent);
+		} catch (e) {
+			error("Juneau page '" + id + "': malformed PAGE_META sidecar; refusing to init.");
+			renderBanner(root, "Juneau page '" + id + "': malformed configuration.");
+			return;
+		}
+
+		if (pageMeta.contractVersion !== JUNEAU_PAGE_CONTRACT_VERSION) {
+			var m = "Juneau page '" + id + "': contract version mismatch (page='" + pageMeta.contractVersion +
+				"', runtime='" + JUNEAU_PAGE_CONTRACT_VERSION + "'). Refusing to init - reload to clear a stale cached script.";
+			error(m);
+			renderBanner(root, m);
+			return;   // refuse to init rather than silently mis-render
+		}
+
+		function apply() {
+			var active = resolveInitial(pageMeta, window.location.hash);
+			if (active) showActive(root, active.tabId, active.subtabId);
+		}
+
+		window.addEventListener("hashchange", apply);
+		apply();
+	}
+
+	function initAllPages() {
+		var pages = document.querySelectorAll("[data-juneau-page]");
+		Array.prototype.forEach.call(pages, function (p) { initPage(p); });
+	}
+
+	// ==================================================================================================================
+	// PUBLIC API + bootstrap
+	// ==================================================================================================================
+
+	NS.pages = {
+		CONTRACT_VERSION: JUNEAU_PAGE_CONTRACT_VERSION,
+		parseHash: parseHash,
+		findById: findById,
+		resolveInitial: resolveInitial,
+		hashFor: hashFor,
+		initAllPages: initAllPages
+	};
+
+	if (document.readyState === "loading") {
+		document.addEventListener("DOMContentLoaded", initAllPages);
+	} else {
+		initAllPages();
+	}
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
new file mode 100644
index 0000000000..80e6c409dc
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-renders.js - dependency-free cell-renderer registry for the Apache Juneau rich-view toolkit (Task B.6).
+ *
+ * A pure, library-free registry mapping render ids (per the VIEW_META §6.6 grammar) to cell-render functions.  Each
+ * renderer is an object with up to five orthogonal facets DataTables asks a column for:
+ *
+ *     { display(cellData, rowData, meta), sort(...), filter(...), type(...), class(cellData, rowData, meta) }
+ *
+ * In the MVP SERVER data mode only `display` + `class` are live - the server does sort/filter/type via
+ * DataTablesQueryProtocol, so those facets are inert (they become live only in client-side mode).  The registry
+ * contract still defines all four for forward-compatibility.
+ *
+ * Everything in the "PURE LOGIC LAYER" below is a pure (cellData, rowData, meta) -> string/class function with NO
+ * dependency on jQuery, DataTables, or the DOM - so it is unit-checkable (Option B: jsdom + node:test) and
+ * Option-B-portable without pulling in the caller-provided libs.  Generic renderers use only built-in
+ * Intl.DateTimeFormat / Date - NO date library (no moment/dayjs/luxon), bundled or caller-provided.
+ *
+ * The `tag` renderer emits the shared `.tag.<domain>.<value>` class-name contract that console-ui OWNS; this module
+ * ships only the dependency-free base `.tag` chip (juneau-views.css, neutral/no colors) and takes NO dependency on
+ * console-ui - its palette themes the same classes when present.
+ */
+(function () {
+	"use strict";
+
+	var NS = window.JuneauViews = window.JuneauViews || {};
+
+	// ==================================================================================================================
+	// PURE LOGIC LAYER  (no DOM, no jQuery, no DataTables)
+	// ==================================================================================================================
+
+	var registry = NS._renderers = NS._renderers || {};
+
+	/**
+	 * Registers (or overrides) a renderer under `id`.  `def` is `{display?, sort?, filter?, type?, class?}`; a bare
+	 * function is treated as the `display` facet.  Apps extend the registry via window.JuneauViews.registerRenderer.
+	 */
+	function registerRenderer(id, def) {
+		registry[id] = (typeof def === "function") ? { display: def } : (def || {});
+		return registry[id];
+	}
+
+	/** Looks up a renderer by id; returns null when unknown (callers warn + fall back to the raw value). */
+	function resolveRenderer(id) {
+		return Object.prototype.hasOwnProperty.call(registry, id) ? registry[id] : null;
+	}
+
+	/**
+	 * Parses a render reference into the canonical `{id, meta?}` form (VIEW_META §6.6).  Accepts either the already
+	 * canonical object, or the `"id:field"` string sugar where everything after the FIRST colon becomes meta.field.
+	 */
+	function parseRenderId(spec) {
+		if (spec == null) return null;
+		if (typeof spec === "object") return spec;
+		var i = spec.indexOf(":");
+		if (i < 0) return { id: spec };
+		return { id: spec.substring(0, i), meta: { field: spec.substring(i + 1) } };
+	}
+
+	// --- escaping helpers (pure) ---------------------------------------------------------------------------------
+
+	function escHtml(s) {
+		return String(s)
+			.replace(/&/g, "&amp;")
+			.replace(/</g, "&lt;")
+			.replace(/>/g, "&gt;")
+			.replace(/"/g, "&quot;")
+			.replace(/'/g, "&#39;");
+	}
+
+	function escAttr(s) {
+		return escHtml(s);
+	}
+
+	/** Interpolates a `{property}` URL template against a row, URL-encoding each substituted value. */
+	function interpolateHref(template, rowData) {
+		return String(template).replace(/\{([^}]+)\}/g, function (m, key) {
+			var v = rowData ? rowData[key] : undefined;
+			return v == null ? "" : encodeURIComponent(String(v));
+		});
+	}
+
+	/**
+	 * Normalizes a raw string to the `.tag.<domain>.<value>` CSS token, mirroring console-ui's
+	 * `org.apache.juneau.rest.server.console.Tag#normalize` lowercasing step exactly: plain (locale-insensitive)
+	 * lowercasing, equivalent to Java's `raw.toLowerCase(Locale.ROOT)`.  `Tag#normalize` then fail-closed REJECTs
+	 * (`IllegalArgumentException`) unless the lowercased result full-string-matches `^[a-z0-9_-]+$`; this renderer
+	 * cannot reject a data value outright (a cell must always render *something*), so instead of throwing, any
+	 * run of characters outside `[a-z0-9_-]` (e.g. whitespace) collapses to a single "-", with leading/trailing
+	 * hyphens trimmed - keeping the emitted token inside the same `^[a-z0-9_-]+$` shape the server enforces.
+	 * KEEP THIS IN SYNC WITH `Tag#normalize` / `TagHtmlRender#getContent`'s token algorithm.
+	 */
+	function normalizeTagToken(raw) {
+		return String(raw)
+			.toLowerCase()
+			.replace(/[^a-z0-9_-]+/g, "-")
+			.replace(/^-+|-+$/g, "");
+	}
+
+	/** Coerces a cell value to a Date, accepting epoch millis, numeric strings, and ISO/parseable date strings. */
+	function toDate(cellData) {
+		if (cellData == null || cellData === "") return null;
+		if (cellData instanceof Date) return isNaN(cellData.getTime()) ? null : cellData;
+		if (typeof cellData === "number") { var dn = new Date(cellData); return isNaN(dn.getTime()) ? null : dn; }
+		var s = String(cellData);
+		if (/^\d+$/.test(s)) { var de = new Date(Number(s)); return isNaN(de.getTime()) ? null : de; }
+		var d = new Date(s);
+		return isNaN(d.getTime()) ? null : d;
+	}
+
+	// --- generic renderers (pure) --------------------------------------------------------------------------------
+	// SERVER mode: display + class only.  Unknown-id policy (skip + warn) lives in the consumer (juneau-views.js).
+
+	registerRenderer("date", {
+		display: function (cellData) {
+			var d = toDate(cellData);
+			if (!d) return cellData == null ? "" : escHtml(cellData);
+			return escHtml(new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "2-digit" }).format(d));
+		},
+		sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+	});
+
+	registerRenderer("datetime", {
+		display: function (cellData) {
+			var d = toDate(cellData);
+			if (!d) return cellData == null ? "" : escHtml(cellData);
+			return escHtml(new Intl.DateTimeFormat(undefined, {
+				year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit"
+			}).format(d));
+		},
+		sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+	});
+
+	registerRenderer("ts-zulu", {
+		// Absolute UTC ("Zulu") ISO-8601 timestamp - stable, locale-independent, no date library.
+		display: function (cellData) {
+			var d = toDate(cellData);
+			if (!d) return cellData == null ? "" : escHtml(cellData);
+			return escHtml(d.toISOString().replace(/\.\d{3}Z$/, "Z"));
+		},
+		sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+	});
+
+	registerRenderer("bool", {
+		display: function (cellData) {
+			if (cellData == null || cellData === "") return "";
+			var truthy = (cellData === true || cellData === 1 || cellData === "true" || cellData === "1" || cellData === "yes");
+			return truthy ? "Yes" : "No";
+		}
+	});
+
+	registerRenderer("linked", {
+		// Consumes the column's `href` {property} template (merged into meta by the initializer); no template -> text.
+		display: function (cellData, rowData, meta) {
+			if (cellData == null) return "";
+			var text = String(cellData);
+			var href = meta && meta.href;
+			if (!href) return escHtml(text);
+			return '<a href="' + escAttr(interpolateHref(href, rowData)) + '">' + escHtml(text) + "</a>";
+		}
+	});
+
+	registerRenderer("truncate", {
+		display: function (cellData, rowData, meta) {
+			if (cellData == null) return "";
+			var s = String(cellData);
+			var max = meta && meta.length ? parseInt(meta.length, 10) : 64;
+			if (!(max > 0) || s.length <= max) return escHtml(s);
+			return '<span title="' + escAttr(s) + '">' + escHtml(s.substring(0, max)) + "\u2026</span>";
+		}
+	});
+
+	registerRenderer("json", {
+		display: function (cellData) {
+			if (cellData == null) return "";
+			var s;
+			try { s = JSON.stringify(cellData); } catch (e) { s = String(cellData); }
+			return "<code>" + escHtml(s) + "</code>";
+		}
+	});
+
+	registerRenderer("decimal", {
+		display: function (cellData, rowData, meta) {
+			if (cellData == null || cellData === "") return "";
+			var n = Number(cellData);
+			if (isNaN(n)) return escHtml(cellData);
+			var places = meta && meta.places != null ? parseInt(meta.places, 10) : 2;
+			return escHtml(n.toFixed(places >= 0 ? places : 2));
+		},
+		sort: function (cellData) { var n = Number(cellData); return isNaN(n) ? cellData : n; }
+	});
+
+	registerRenderer("tag", {
+		// Emits the shared `.tag.<domain>.<value>` chip; <domain> = meta.field (the column key), <value> = the
+		// cell.  Both CSS tokens are normalized via normalizeTagToken() to mirror console-ui's
+		// Tag#normalize/TagHtmlRender token algorithm (lowercase, non-[a-z0-9_-] runs -> "-") so themed
+		// chrome.css rules (e.g. `.tag.status.released`) match; the RAW value is kept as the display text.
+		display: function (cellData, rowData, meta) {
+			if (cellData == null || cellData === "") return "";
+			var value = String(cellData);
+			var domain = meta && meta.field ? String(meta.field) : "";
+			var domainToken = normalizeTagToken(domain);
+			var valueToken = normalizeTagToken(value);
+			var cls = "tag" + (domainToken ? " " + domainToken : "") + (valueToken ? " " + valueToken : "");
+			return '<span class="' + escAttr(cls) + '">' + escHtml(value) + "</span>";
+		},
+		"class": function () { return "tag-cell"; }
+	});
+
+	// ==================================================================================================================
+	// PUBLIC API
+	// ==================================================================================================================
+
+	NS.registerRenderer = registerRenderer;
+	NS.resolveRenderer = resolveRenderer;
+	NS.parseRenderId = parseRenderId;
+	// Exposed for reuse by the initializer + Option-B unit tests.
+	NS._render = NS._render || {};
+	NS._render.escHtml = escHtml;
+	NS._render.escAttr = escAttr;
+	NS._render.interpolateHref = interpolateHref;
+	NS._render.toDate = toDate;
+	NS._render.normalizeTagToken = normalizeTagToken;
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
new file mode 100644
index 0000000000..fa0cb7b529
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
@@ -0,0 +1,354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit (Task B.7).
+ *
+ * Builds the toolbar from viewDef.ribbon: export (feature-detected copy/csv via DataTables Buttons, with excel/pdf
+ * lit up only when JSZip/pdfMake are present), refresh, columnSearchToggle, option/optionGroup server-query toggles
+ * (with persisted state), and divider.
+ *
+ * CONSISTENCY REQUIREMENT (mirrors the server): when a column-scoped `option`/`optionGroup` toggle is ACTIVE, the
+ * client contributes the SAME `columns[N][search][value]=<value>` request param that the server-side
+ * RibbonAction.toQueryParams(ViewDef) produces (custom `param` options contribute `param=value` verbatim).  The
+ * server maps unconditionally (it has no notion of "active"); the CLIENT owns active state, so ONLY active toggles
+ * contribute here.  ribbonToQueryParams(viewDef, activeState) below is the pure counterpart of the Java mapping and
+ * shares its fixtures so the two implementations cannot drift.
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM/jQuery/DataTables-free (feature-detection takes its environment as an
+ * argument), so it is unit-checkable (Option B) and Option-B-portable.  The "DOM/JQUERY BINDING LAYER" is the thin
+ * shim that renders the toolbar and wires it to a DataTables instance.
+ */
+(function () {
+	"use strict";
+
+	var NS = window.JuneauViews = window.JuneauViews || {};
+
+	// ==================================================================================================================
+	// PURE LOGIC LAYER  (no DOM, no jQuery, no DataTables)
+	// ==================================================================================================================
+
+	/** Resolves a column `data` key to its zero-based index in the view (mirrors RibbonAction.columnIndex). */
+	function columnIndex(viewDef, columnKey) {
+		var cols = viewDef.columns || [];
+		for (var i = 0; i < cols.length; i++)
+			if (cols[i].data === columnKey) return i;
+		return -1;
+	}
+
+	/**
+	 * Maps ONE option/opt to the {name, value} request param it contributes, or null.  Column-scoped options resolve
+	 * to `columns[<index>][search][value]`; custom-param options contribute `param=value` verbatim; a valueless (or
+	 * column+param-less) option contributes nothing.  Byte-for-byte identical to the Java addOptionParam(...).
+	 */
+	function optionParam(viewDef, opt) {
+		if (opt == null || opt.value == null) return null;
+		if (opt.column != null) {
+			var idx = columnIndex(viewDef, opt.column);
+			if (idx < 0) return null;
+			return { name: "columns[" + idx + "][search][value]", value: opt.value };
+		}
+		if (opt.param != null) return { name: opt.param, value: opt.value };
+		return null;
+	}
+
+	/**
+	 * The pure counterpart of RibbonAction.toQueryParams(ViewDef): the request params the ribbon contributes given the
+	 * client's ACTIVE toggle state.  `activeState` is a map keyed by option/group id:
+	 *   - a top-level `option` contributes iff activeState[option.id] is truthy;
+	 *   - an `optionGroup` contributes its member whose id === activeState[group.id] (the selected radio value).
+	 * refresh/columnSearchToggle/divider/export are not query-contributing and are skipped.
+	 */
+	function ribbonToQueryParams(viewDef, activeState) {
+		var out = {};
+		var state = activeState || {};
+		(viewDef.ribbon || []).forEach(function (a) {
+			if (a.type === "option") {
+				if (state[a.id]) {
+					var p = optionParam(viewDef, a);
+					if (p) out[p.name] = p.value;
+				}
+			} else if (a.type === "optionGroup" && a.options) {
+				var selected = state[a.id];
+				a.options.forEach(function (o) {
+					if (o.id === selected) {
+						var p = optionParam(viewDef, o);
+						if (p) out[p.name] = p.value;
+					}
+				});
+			}
+		});
+		return out;
+	}
+
+	/**
+	 * Feature-detects the caller-provided export extensions in the given environment (defaults to window).  Returns
+	 * `{buttons, jszip, pdfmake}` booleans.  Export degrades gracefully: with no DataTables Buttons, no export button
+	 * is offered at all; excel needs JSZip; pdf needs pdfMake.
+	 */
+	function detectExportFeatures(win) {
+		win = win || (typeof window !== "undefined" ? window : {});
+		var $ = win.jQuery;
+		var hasButtons = !!($ && $.fn && $.fn.dataTable && $.fn.dataTable.Buttons);
+		return { buttons: hasButtons, jszip: !!win.JSZip, pdfmake: !!win.pdfMake };
+	}
+
+	/**
+	 * Given an `export` action and detected features, returns the button ids that should actually be offered.  With no
+	 * Buttons extension the result is empty (graceful degrade); optional excel/pdf are included only when their extra
+	 * dep is present, otherwise omitted.
+	 */
+	function resolveExportButtons(action, features) {
+		var out = [];
+		if (!features || !features.buttons) return out;   // degrade gracefully - no export cluster at all
+		(action.buttons || []).forEach(function (b) { out.push(b); });
+		(action.optional || []).forEach(function (b) {
+			if (b === "excel" && features.jszip) out.push(b);
+			else if (b === "pdf" && features.pdfmake) out.push(b);
+			// else: dep absent - omit/grey (feature-detected)
+		});
+		return out;
+	}
+
+	/** localStorage key for a persisted ribbon toggle (VIEW_META §6.8: juneau.view.<viewId>.ribbon.<optionId>). */
+	function ribbonStorageKey(viewId, optionId) {
+		return "juneau.view." + viewId + ".ribbon." + optionId;
+	}
+
+	/**
+	 * Default built-in id -> icon-name lookup (visual-parity design doc §4.A).  Keyed by *button id* for the export
+	 * cluster (one export action renders one button per resolved id) and by *action type* for refresh/
+	 * columnSearchToggle (each renders exactly one button).  `collapse` is not wired to any RibbonAction.type today -
+	 * it ships here purely for forward-compatibility with TODO-399 P4's (deferred) row-expander "collapse all"
+	 * affordance; inert until that action type exists.
+	 */
+	var DEFAULT_ICONS = {
+		copy: "content_copy", csv: "csv", excel: "table", pdf: "picture_as_pdf",
+		refresh: "refresh", columnSearchToggle: "manage_search", collapse: "unfold_less"
+	};
+
+	/**
+	 * Resolves the icon NAME (not markup) for a ribbon button - pure, DOM-free (§4.A).  An explicit `symbol` on the
+	 * action/Opt always wins; otherwise a `defaultKey` (the export button id, or the action `type`) resolves from
+	 * DEFAULT_ICONS; a custom option/optionGroup member with neither falls back to the neutral "tune" glyph, never
+	 * blank/unset.  Markup resolution (NS.icons.resolveIcon(name)) and the "unregistered name -> render title as
+	 * text" fallback both happen in the DOM binding layer (Task 3), since this pure layer has no access to the
+	 * registry's runtime contents (apps can register icons after page load).
+	 */
+	function resolveButtonIcon(actionOrOpt, defaultKey) {
+		if (actionOrOpt && actionOrOpt.symbol != null) return actionOrOpt.symbol;
+		if (defaultKey != null && Object.prototype.hasOwnProperty.call(DEFAULT_ICONS, defaultKey)) return DEFAULT_ICONS[defaultKey];
+		return "tune";
+	}
+
+	// ==================================================================================================================
+	// DOM / JQUERY BINDING LAYER  (thin shim; not exercised by the pure unit tests)
+	// ==================================================================================================================
+
+	function safeStorage() {
+		try { return window.localStorage; } catch (e) { return null; }
+	}
+
+	function loadPersistedState(viewDef) {
+		var state = {};
+		var store = safeStorage();
+		if (!store) return state;
+		(viewDef.ribbon || []).forEach(function (a) {
+			if (a.type === "option" && a.persist) {
+				var raw = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+				if (raw != null) state[a.id] = (raw === "true");
+			} else if (a.type === "optionGroup" && a.persist) {
+				var sel = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+				if (sel != null) state[a.id] = sel;
+			}
+		});
+		return state;
+	}
+
+	function persist(viewDef, id, value) {
+		var store = safeStorage();
+		if (store) store.setItem(ribbonStorageKey(viewDef.id, id), String(value));
+	}
+
+	/**
+	 * Builds the ribbon toolbar element for a view and wires it to its DataTables instance.  Returns the toolbar
+	 * element (or null when there is no ribbon).  `ctx` carries { table, dataTable (the DT api), activeState, redraw,
+	 * columnSearchOn, onColumnSearchToggle }.
+	 *
+	 * <p>Adjacent actions sharing a non-null {@code group} id (visual-parity design doc §4.A, item 2/5) are
+	 * clustered into ONE segmented {@code .juneau-view-ribbon-group} wrapper (shared borders, rounded only on the
+	 * outer ends - see juneau-views.css) via the local {@code place(el, groupId)} helper below.  An {@code export}
+	 * action's own resolved buttons are always clustered this way even without an explicit {@code group} (one
+	 * action, one visual cluster - each export action gets its own synthetic per-index id so consecutive distinct
+	 * export actions never merge).  A {@code divider} or an ungrouped action always closes any open cluster.
+	 */
+	function buildRibbon(viewDef, ctx) {
+		var actions = viewDef.ribbon || [];
+		if (!actions.length) return null;
+
+		var $ = window.jQuery;
+		var features = detectExportFeatures(window);
+		var bar = document.createElement("div");
+		bar.className = "juneau-view-ribbon";
+		bar.setAttribute("data-testid", "ribbon");
+
+		var openGroup = null;   // { id, el } - the currently-open adjacent-group wrapper, or null when ungrouped
+		function place(el, groupId) {
+			if (groupId == null) {
+				openGroup = null;
+				bar.appendChild(el);
+				return;
+			}
+			if (!openGroup || openGroup.id !== groupId) {
+				var wrap = document.createElement("span");
+				wrap.className = "juneau-view-ribbon-group";
+				bar.appendChild(wrap);
+				openGroup = { id: groupId, el: wrap };
+			}
+			openGroup.el.appendChild(el);
+		}
+
+		actions.forEach(function (a, idx) {
+			if (a.type === "divider") {
+				openGroup = null;
+				var d = document.createElement("span");
+				d.className = "juneau-view-ribbon-divider";
+				bar.appendChild(d);
+				return;
+			}
+			if (a.type === "export") {
+				var ids = resolveExportButtons(a, features);
+				if (ids.length && $ && ctx.dataTable && $.fn.dataTable.Buttons) {
+					try {
+						// Still registers/feature-gates each button with DataTables Buttons - but renders our own
+						// first-party icon buttons instead of delegating to Buttons' own DOM (design doc §4.A); a
+						// click programmatically triggers the SAME, already-reviewed Buttons action (design doc §7).
+						new $.fn.dataTable.Buttons(ctx.dataTable, { buttons: ids });
+						var exportGroupId = a.group != null ? a.group : ("__export" + idx);
+						ids.forEach(function (id) {
+							place(button(id, resolveButtonIcon(null, id), function () {
+								ctx.dataTable.button(id).trigger();
+							}), exportGroupId);
+						});
+					} catch (e) { /* Buttons present but init failed - degrade silently */ }
+				}
+				return;
+			}
+			if (a.type === "refresh") {
+				place(button(a.title || "Refresh", resolveButtonIcon(a, "refresh"), function () { ctx.redraw(); }), a.group || null);
+				return;
+			}
+			if (a.type === "columnSearchToggle") {
+				var csBtn = button(a.title || "Column search", resolveButtonIcon(a, "columnSearchToggle"), function () {
+					csBtn.setAttribute("aria-pressed", toggleColumnSearch(viewDef, ctx) ? "true" : "false");
+				});
+				csBtn.setAttribute("aria-pressed", ctx.columnSearchOn ? "true" : "false");
+				place(csBtn, a.group || null);
+				return;
+			}
+			if (a.type === "option") {
+				place(optionToggle(viewDef, a, ctx), a.group || null);
+				return;
+			}
+			if (a.type === "optionGroup") {
+				openGroup = null;
+				bar.appendChild(optionGroup(viewDef, a, ctx));
+			}
+		});
+		return bar;
+	}
+
+	/**
+	 * Builds one icon-only 32px ribbon/pill button (visual-parity design doc §4.A/§2.2).  No visible text label -
+	 * `label` becomes the button's native `title` (tooltip) and `aria-label` (screen-reader text) only.  Resolves
+	 * `iconName` via the icon registry (`NS.icons.resolveIcon`); the markup assigned to `innerHTML` is ALWAYS a
+	 * static, first-party, build-time-authored SVG string from that registry - never request-/app-supplied - so
+	 * this is not an HTML-injection sink (design doc §7).  An unregistered icon name falls back to rendering the
+	 * label as text (mirrors the "unknown render id -> warn once, fall back to raw value" convention already
+	 * documented for juneau-renders.js).
+	 */
+	function button(label, iconName, onClick) {
+		var b = document.createElement("button");
+		b.type = "button";
+		b.className = "juneau-view-ribbon-btn";
+		b.title = label;
+		b.setAttribute("aria-label", label);
+		var markup = NS.icons && NS.icons.resolveIcon ? NS.icons.resolveIcon(iconName) : null;
+		if (markup != null) {
+			b.innerHTML = markup;
+		} else {
+			b.textContent = b.title;
+		}
+		b.addEventListener("click", onClick);
+		return b;
+	}
+
+	function optionToggle(viewDef, action, ctx) {
+		var b = button(action.title || action.id, resolveButtonIcon(action, null), function () {
+			ctx.activeState[action.id] = !ctx.activeState[action.id];
+			b.setAttribute("aria-pressed", ctx.activeState[action.id] ? "true" : "false");
+			if (action.persist) persist(viewDef, action.id, !!ctx.activeState[action.id]);
+			ctx.redraw();
+		});
+		b.setAttribute("aria-pressed", ctx.activeState[action.id] ? "true" : "false");
+		return b;
+	}
+
+	function optionGroup(viewDef, group, ctx) {
+		var wrap = document.createElement("span");
+		wrap.className = "juneau-view-ribbon-group";
+		(group.options || []).forEach(function (o) {
+			var b = button(o.title || o.id, resolveButtonIcon(o, null), function () {
+				ctx.activeState[group.id] = (ctx.activeState[group.id] === o.id && group.deselectable) ? null : o.id;
+				if (group.persist) persist(viewDef, group.id, ctx.activeState[group.id]);
+				ctx.redraw();
+			});
+			wrap.appendChild(b);
+		});
+		return wrap;
+	}
+
+	/**
+	 * Flips the shared per-column-search-visibility flag on `ctx` and notifies the DOM binding layer (juneau-
+	 * views.js's initTable(...)) so it can show/hide the per-column search row it owns.  Returns the new state so
+	 * the caller (the columnSearchToggle button's click handler above) can reflect it in `aria-pressed` - this
+	 * function itself never touches a DOM node's attributes (kept callable/testable without a live button element).
+	 */
+	function toggleColumnSearch(viewDef, ctx) {
+		ctx.columnSearchOn = !ctx.columnSearchOn;
+		if (ctx.onColumnSearchToggle) ctx.onColumnSearchToggle(ctx.columnSearchOn);
+		return ctx.columnSearchOn;
+	}
+
+	// ==================================================================================================================
+	// PUBLIC API
+	// ==================================================================================================================
+
+	NS.ribbon = {
+		// pure
+		columnIndex: columnIndex,
+		optionParam: optionParam,
+		ribbonToQueryParams: ribbonToQueryParams,
+		detectExportFeatures: detectExportFeatures,
+		resolveExportButtons: resolveExportButtons,
+		ribbonStorageKey: ribbonStorageKey,
+		resolveButtonIcon: resolveButtonIcon,
+		// binding
+		loadPersistedState: loadPersistedState,
+		build: buildRibbon
+	};
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
new file mode 100644
index 0000000000..40236a1e11
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -0,0 +1,359 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-views.css - base ".tag" chip for the Apache Juneau rich-view toolkit (Task B.6).
+ *
+ * DEPENDENCY-FREE + NEUTRAL: this file styles ONLY the shape of the shared ".tag.<domain>.<value>" chip contract -
+ * padding, border-radius, inline layout - and deliberately ships NO colors (no color/background/border-color
+ * palette).  It exists so a client-rendered `render("tag:status")` cell is legible standalone, even before an app
+ * adopts console-ui's chrome.
+ *
+ * The ".tag.<domain>.<value>" class-name contract is OWNED by console-ui (TODO-361).  When console-ui's chrome.css
+ * is present it themes the SAME classes (colors by domain/value); this module takes NO dependency on console-ui and
+ * MUST NOT add a color palette here (per the 361/399 reconciliation).  Any color layering is console-ui's job.
+ *
+ * The banner class below is the fail-loud contract-version-mismatch surface rendered by juneau-views.js (B.8); it,
+ * too, avoids a color palette (uses currentColor / inherited text color only).
+ */
+
+/* Base chip - neutral shape/padding/border-radius, NO colors. */
+.tag {
+	display: inline-block;
+	padding: 0.15em 0.6em;
+	border: 1px solid;            /* border-color falls back to currentColor - no palette color set here */
+	border-radius: 0.75em;
+	font-size: 0.85em;
+	line-height: 1.4;
+	white-space: nowrap;
+	vertical-align: baseline;
+}
+
+/* Fail-loud contract-version-mismatch banner (B.8) - neutral, no palette color. */
+.juneau-view-error {
+	display: block;
+	padding: 0.5em 0.75em;
+	margin: 0.25em 0;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+	font-weight: bold;
+}
+
+/*
+ * DataTables table density/shape (design doc §4 IRS-parity pass) - compact rows + thin separators.  NEUTRAL -
+ * shape only (font-size, cell padding, border WIDTH/style); no color/background-color/border-color palette here
+ * (console-ui's chrome.css themes the SAME selectors with --jc-* colors, mirroring the ".tag"/ribbon reconciliation
+ * above).  Selectors list BOTH `table[data-juneau-view]` (the marker attribute present from first paint) and
+ * `table.dataTable` (the class DataTables' own init ADDS to that same element - it never removes the attribute,
+ * so both describe the identical element pre/post-init) so the rule applies whether or not DataTables has run
+ * yet.  Child-combinator (`>`) selectors deliberately mirror the vendored dataTables.dataTables.css structure
+ * (`table.dataTable>thead>tr>th`, `table.dataTable>tbody>tr>td`) so this rule's specificity TIES the vendored
+ * one instead of losing to it; this file is served AFTER the vendored DataTables CSS in every juneau-views page's
+ * <head>, so the tie resolves in our favor by cascade order.
+ */
+table[data-juneau-view],
+table.dataTable {
+	font-size: 0.8125rem;
+}
+
+table[data-juneau-view] > thead > tr > th,
+table[data-juneau-view] > thead > tr > td,
+table.dataTable > thead > tr > th,
+table.dataTable > thead > tr > td {
+	padding: 8px 10px;
+	font-weight: 600;
+	text-align: left;
+	border-bottom-width: 1px;
+	border-bottom-style: solid;   /* color falls back to currentColor - no palette color set here */
+}
+
+table[data-juneau-view] > tbody > tr > th,
+table[data-juneau-view] > tbody > tr > td,
+table.dataTable > tbody > tr > th,
+table.dataTable > tbody > tr > td {
+	padding: 6px 10px;
+	vertical-align: middle;
+	border-bottom-width: 1px;
+	border-bottom-style: solid;   /* color falls back to currentColor - no palette color set here */
+}
+
+/*
+ * DataTables ribbon visual-parity pass (design doc §4.A) - neutral 32px icon-only ribbon-button shape.  NEW rule
+ * (not a rewrite - the module shipped no ".juneau-view-ribbon*" CSS before this pass; buttons rendered with bare
+ * browser-default styling).  NEUTRAL - no palette color (border/color fall back to currentColor); console-ui's
+ * chrome.css layers the themed hover/active accent over these same classes.
+ *
+ * ".juneau-view-ribbon" is the CONTAINER buildRibbon(...) creates (juneau-ribbon.js) - it must lay its buttons out
+ * as a horizontal row itself; ".juneau-view-ribbon-btn" below only shapes each individual button (a per-button
+ * `display: flex` centers that button's own icon, it does NOT make sibling buttons flow horizontally - omitting a
+ * container rule left every button stacking vertically, one per line).
+ */
+.juneau-view-ribbon {
+	display: flex;
+	align-items: center;
+	flex-wrap: wrap;
+	gap: 0.25em;
+}
+
+.juneau-view-ribbon-btn {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 32px;
+	height: 32px;
+	padding: 0;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+	background: transparent;
+	cursor: pointer;
+}
+
+.juneau-view-ribbon-btn svg { width: 18px; height: 18px; fill: currentColor; }
+
+.juneau-view-ribbon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+
+/* Neutral shape for an ACTIVE toggle (`option`/`optionGroup` member button's aria-pressed="true") - a thicker
+ * border only (no palette color here); console-ui's chrome.css layers the themed background/color accent over
+ * this same attribute selector, exactly like it already does for :hover/:active. Without ANY visual affordance
+ * here, a toggled-on filter (e.g. a column-scoped `option` persisted to localStorage) is invisible - indistinguishable
+ * from every other unpressed button - so it can silently keep filtering out all rows after being clicked once. */
+.juneau-view-ribbon-btn[aria-pressed="true"] { border-width: 2px; }
+
+/*
+ * Segmented ribbon group (design doc §4.A, item 2/5 - IRS parity): a "single ribbon" of adjacent buttons that
+ * share a border (no double borders) with rounding only on the group's outer ends.  NEUTRAL - shape only (radius/
+ * margin-collapse); console-ui's chrome.css themes the member buttons' colors exactly like a standalone button
+ * (same ".juneau-view-ribbon-btn" class, so :hover/:active/[aria-pressed] all already apply unchanged).  The
+ * negative margin-left overlaps each member's left border onto its predecessor's right border (collapsing the
+ * pair into one hairline); a raised z-index on hover/focus/pressed keeps that member's own border/background on
+ * top of its neighbors' overlapping edges.
+ */
+.juneau-view-ribbon-group { display: inline-flex; }
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn {
+	border-radius: 0;
+	margin-left: -1px;
+	position: relative;
+}
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:first-child { margin-left: 0; border-radius: 0.25em 0 0 0.25em; }
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:last-child { border-radius: 0 0.25em 0.25em 0; }
+
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:hover,
+.juneau-view-ribbon-group .juneau-view-ribbon-btn:focus,
+.juneau-view-ribbon-group .juneau-view-ribbon-btn[aria-pressed="true"] { z-index: 1; }
+
+/*
+ * Unified paging ribbon (visual-parity follow-up) - hides BOTH DataTables major-version's native length-select/
+ * pagination class-name generations (DT1.x ".dataTables_length"/".dataTables_paginate", DT2.x ".dt-length"/
+ * ".dt-paging") and ships the first-party ribbon's neutral shape: `[First] [Prev] [<range> + page-size menu]
+ * [Next] [Last]` - a SINGLE segmented control (no separate page-size <select>, no second paging control
+ * elsewhere).  NEUTRAL - no palette color; console-ui's chrome.css layers the themed accent/disabled treatment
+ * over these same classes.  The native entry-count text (".dataTables_info"/".dt-info") needs no hide rule here:
+ * `buildOptions(...)` sets DataTables' own `info: false` (juneau-views.js) so that node is never created in the
+ * first place - disabled at the source rather than hidden after the fact.
+ */
+.dataTables_length, .dt-length, .dataTables_paginate, .dt-paging { display: none; }
+
+.juneau-view-pagingpill {
+	display: inline-flex;
+	align-items: stretch;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+}
+
+/* Corner-rounding on the outer segments only (no `overflow: hidden` on the pill itself - that would clip the
+   page-size menu's popup, which is positioned absolute relative to a segment INSIDE the pill). */
+.juneau-view-pagingpill > *:first-child { border-radius: 0.25em 0 0 0.25em; }
+.juneau-view-pagingpill > *:last-child { border-radius: 0 0.25em 0.25em 0; }
+
+.juneau-view-pagingpill-btn {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	width: 32px;
+	height: 32px;
+	padding: 0;
+	border: none;
+	border-right: 1px solid;      /* currentColor - segment divider; last-child override below */
+	background: transparent;
+	cursor: pointer;
+}
+.juneau-view-pagingpill-btn:last-child { border-right: none; }
+.juneau-view-pagingpill-btn:disabled { opacity: 0.4; cursor: not-allowed; }
+.juneau-view-pagingpill-btn svg { width: 18px; height: 18px; fill: currentColor; }
+
+/*
+ * The pill's central segment (visual-parity follow-up): BOTH the compact "n-n of n" range summary AND the
+ * page-size menu button that replaced the old standalone <select> - a WAI-ARIA menu-button (`.juneau-view-
+ * pagingpill-menubtn`, `aria-haspopup="listbox"`) whose popup (`.juneau-view-pagingpill-menu`, `role="listbox"`)
+ * lists the page-size options.  NEUTRAL - shape only; console-ui's chrome.css themes text/accent/background.
+ */
+.juneau-view-pagingpill-menuwrap {
+	position: relative;
+	display: flex;
+	border-right: 1px solid;      /* currentColor - segment divider */
+}
+
+.juneau-view-pagingpill-menubtn {
+	display: flex;
+	align-items: center;
+	gap: 0.35em;
+	height: 32px;
+	padding: 0 0.6em;
+	border: none;
+	background: transparent;
+	font-size: 0.8125rem;
+	white-space: nowrap;
+	cursor: pointer;
+}
+
+.juneau-view-pagingpill-info { white-space: nowrap; }
+
+.juneau-view-pagingpill-caret svg { width: 14px; height: 14px; fill: currentColor; }
+
+/* The popup itself - absolutely positioned below the menu button; `[hidden]` (set/cleared by juneau-views.js)
+   is enough to show/hide it, so no explicit `display` toggle rule is needed here. */
+.juneau-view-pagingpill-menu {
+	position: absolute;
+	top: 100%;
+	left: 0;
+	z-index: 10;
+	min-width: 100%;
+	margin: 0.2em 0 0;
+	padding: 0.25em 0;
+	list-style: none;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+}
+
+.juneau-view-pagingpill-menu-option {
+	padding: 0.3em 1.75em 0.3em 0.75em;
+	white-space: nowrap;
+	cursor: pointer;
+}
+
+.juneau-view-pagingpill-menu-option[aria-selected="true"] { font-weight: bold; }
+
+/* A plain checkmark glyph (not an icon-registry glyph - a bare CSS content string) indicating the current
+   page-size selection, mirroring the affordance a native <select> gives its chosen <option> for free. */
+.juneau-view-pagingpill-menu-option[aria-selected="true"]::after {
+	content: "\2713";
+	float: right;
+	margin-left: 0.75em;
+}
+
+/*
+ * Single unified top-toolbar row (IRS reference layout): a LEFT cluster (page-size + paging nav pill) and a RIGHT
+ * cluster (search, ribbon groups, refresh) laid out with `space-between` so the right cluster stays right-aligned
+ * - built once by `buildToolbarRow(...)` (juneau-views.js), which assembles both clusters above the table.
+ */
+.juneau-view-toolbar-row {
+	display: flex;
+	flex-wrap: wrap;
+	justify-content: space-between;
+	align-items: center;
+	gap: 0.5em;
+	margin: 0 0 0.5em 0;
+}
+
+.juneau-view-toolbar-left,
+.juneau-view-toolbar-right {
+	display: flex;
+	flex-wrap: wrap;
+	align-items: center;
+	gap: 0.5em;
+}
+
+.juneau-view-ribbon-divider {
+	display: inline-block;
+	width: 0.5em;               /* pure spacing only; no visible glyph. Stays in the DOM (unchanged JS branch). */
+}
+
+/*
+ * Per-column search row (design doc §4, item 4 - IRS parity fix): one text input per searchable column, inserted
+ * into <thead> by buildColumnSearchRow(...) (juneau-views.js) and shown/hidden by the columnSearchToggle ribbon
+ * button.  NEUTRAL - shape only; console-ui's chrome.css themes the input's border/background/focus colors.
+ */
+.juneau-view-columnsearch-row > th {
+	padding: 4px 8px;
+	font-weight: 400;
+}
+
+.juneau-view-columnsearch-input {
+	box-sizing: border-box;
+	width: 100%;
+	padding: 3px 6px;
+	font-size: 0.8125rem;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.2em;
+}
+
+/*
+ * TODO-399 Phase C - self-contained ".jc-*" tabs/sub-tabs page shell (Decision 1(A)).  Folded into this file rather
+ * than a separate juneau-pages.css: small enough not to warrant its own served asset/pom touch (see the module's
+ * ViewsMixin asset-serving pattern).  NEUTRAL - no color palette; console-ui's chrome.css MAY theme these same
+ * classes when present, mirroring the ".tag.<domain>.<value>" chip reconciliation above - this module takes NO
+ * dependency on console-ui either way.
+ */
+.jc-tab-bar,
+.jc-subtab-bar {
+	display: flex;
+	flex-wrap: nowrap;
+	overflow-x: auto;
+	white-space: nowrap;
+	gap: 0.5em;
+	margin: 0 0 0.5em 0;
+}
+
+.jc-tab,
+.jc-subtab {
+	flex: 0 0 auto;
+	display: inline-block;
+	padding: 0.35em 0.75em;
+	text-decoration: none;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+	cursor: pointer;
+}
+
+.jc-tab-active,
+.jc-subtab-active {
+	font-weight: bold;
+	border-bottom-width: 3px;
+}
+
+/* Panels are hidden by default; juneau-pages.js toggles ".jc-active" on the one panel that should be visible. */
+.jc-panel,
+.jc-subpanel {
+	display: none;
+}
+
+.jc-panel.jc-active,
+.jc-subpanel.jc-active {
+	display: block;
+}
+
+/* Fail-loud PAGE_META contract-version-mismatch banner - neutral, no palette color (mirrors .juneau-view-error). */
+.jc-page-error {
+	display: block;
+	padding: 0.5em 0.75em;
+	margin: 0.25em 0;
+	border: 1px solid;            /* currentColor - no palette color set here */
+	border-radius: 0.25em;
+	font-weight: bold;
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
new file mode 100644
index 0000000000..020059cd45
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -0,0 +1,651 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * juneau-views.js - client initializer for the Apache Juneau rich-view toolkit (Task B.8).
+ *
+ * On DOMContentLoaded it owns init for every table[data-juneau-view] element: it reads the table's id, finds the
+ * matching <script type="application/json" id="juneau-view:<id>"> VIEW_META sidecar, JSON.parses it, and - CRUCIAL -
+ * performs a FAIL-LOUD contract-version handshake: if the sidecar's `contractVersion` differs from the baked-in
+ * JUNEAU_VIEW_CONTRACT_VERSION, it console.errors, renders a visible in-table banner, and REFUSES to init (rather
+ * than silently mis-rendering).  Otherwise it builds the DataTables opts (columns[].render from the renderer
+ * registry, serverSide/ajax per dataMode, order from defaultOrder field->index resolution, rowClassRules applied in
+ * createdRow) and calls $(table).DataTable(opts), then wires the ribbon.
+ *
+ * The DataTables library itself (jQuery + DataTables JS/CSS) is NOT bundled - its license is not an ASF category-A
+ * license - so it stays caller-provided (CDN or self-hosted).  The distinct `data-juneau-view` marker guarantees no
+ * collision with juneau-datatables.js (which only touches data-juneau-datatable).
+ *
+ * Everything in the "PURE LOGIC LAYER" is DOM/jQuery/DataTables-free (plain data in, plain data out), per the
+ * Option-B architecture check; the "DOM/JQUERY BINDING LAYER" is the thin shim that scans, parses, and binds.
+ */
+(function () {
+	"use strict";
+
+	// Contract-version handshake: MUST equal ViewDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION (single source
+	// of truth on the server).  The initializer fails loud when a sidecar's contractVersion differs.
+	var JUNEAU_VIEW_CONTRACT_VERSION = "2";
+
+	var NS = window.JuneauViews = window.JuneauViews || {};
+	NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
+
+	// ==================================================================================================================
+	// PURE LOGIC LAYER  (no DOM, no jQuery, no DataTables)
+	// ==================================================================================================================
+
+	/**
+	 * Shared page-size vocabulary (visual-parity design doc §4.B) - a client-side default for this pass (not a new
+	 * ViewDef wire field; see the non-goal in design doc §11).  Consumed by buildOptions' default pageLength below
+	 * AND by the unified paging ribbon's page-size menu (buildPageSizeMenu, below).
+	 */
+	var PAGE_SIZE_OPTIONS = [
+		{ value: 25, label: "25 rows" },
+		{ value: 100, label: "100 rows" },
+		{ value: -1, label: "All rows" }
+	];
+
+	/** Resolves a column `data` key to its zero-based index in the view (-1 when absent). */
+	function columnIndexOf(viewDef, dataKey) {
+		var cols = viewDef.columns || [];
+		for (var i = 0; i < cols.length; i++)
+			if (cols[i].data === dataKey) return i;
+		return -1;
+	}
+
+	/**
+	 * Resolves `defaultOrder` [{data,dir}] to DataTables' positional `order` [[colIndex, dir]] by field name (m2) -
+	 * indices are not pinned server-side, so client-side column reorder stays correct.  Unknown fields are skipped.
+	 */
+	function resolveOrder(viewDef) {
+		var out = [];
+		(viewDef.defaultOrder || []).forEach(function (e) {
+			var idx = columnIndexOf(viewDef, e.data);
+			if (idx >= 0) out.push([idx, e.dir]);
+		});
+		return out;
+	}
+
+	/** Loose value equality that also matches boolean true vs "true", 1 vs "1", etc. (JSON type coercion). */
+	function valuesEqual(a, b) {
+		return a === b || String(a) === String(b);
+	}
+
+	/**
+	 * Evaluates a view's rowClassRules against one row, returning the list of CSS classes to add.  op grammar (§6.3):
+	 * eq/ne compare row[field] to the rule value; present/absent test whether row[field] is non-null/non-empty.
+	 */
+	function evaluateRowClassRules(rules, rowData) {
+		var out = [];
+		(rules || []).forEach(function (r) {
+			var v = rowData ? rowData[r.field] : undefined;
+			var present = (v != null && v !== "");
+			var match = false;
+			switch (r.op) {
+				case "eq": match = valuesEqual(v, r.value); break;
+				case "ne": match = !valuesEqual(v, r.value); break;
+				case "present": match = present; break;
+				case "absent": match = !present; break;
+				default: match = false;
+			}
+			if (match) out.push(r["class"]);
+		});
+		return out;
+	}
+
+	/** Formats a non-negative integer with thousands separators (e.g. 1463 -> "1,463"), matching IRS's paging summary style. */
+	function formatThousands(n) {
+		var s = String(Math.trunc(Math.abs(n)));
+		var out = "";
+		for (var i = 0; i < s.length; i++) {
+			if (i > 0 && (s.length - i) % 3 === 0) out += ",";
+			out += s.charAt(i);
+		}
+		return (n < 0 ? "-" : "") + out;
+	}
+
+	/**
+	 * Builds the compact "n-n of n" paging-summary string (visual-parity design doc §4.C, item 3) from a
+	 * DataTables `page.info()`-shaped object - pure, DOM/library-free (`pageInfo` is plain data).  Mirrors
+	 * DataTables' own default `language.info` convention (1-based inclusive display range: `start+1` to `end`)
+	 * against `recordsDisplay` (the FILTERED count - the same number that default info text's `_TOTAL_`
+	 * placeholder shows), falling back to `recordsTotal` if `recordsDisplay` is absent.  An empty result set
+	 * (`total` falsy) renders "0-0 of 0" rather than "1-0 of 0".
+	 */
+	function pagingSummaryText(pageInfo) {
+		var total = pageInfo.recordsDisplay != null ? pageInfo.recordsDisplay : pageInfo.recordsTotal;
+		if (!total) return "0-0 of 0";
+		return (pageInfo.start + 1) + "-" + pageInfo.end + " of " + formatThousands(total);
+	}
+
+	/**
+	 * Computes the unified paging pill's selected/disabled state from a DataTables `page.info()`-shaped object
+	 * (visual-parity design doc §4.C) - pure, DOM/library-free (`pageInfo` is plain data, never the DataTables API
+	 * object itself).  `first`/`previous` disable on the first page; `next`/`last` disable on the last page,
+	 * INCLUDING the always-disabled-when-there-is-only-one-page case (`pages === 0`, e.g. an empty result set, is
+	 * treated the same as "already on the last page").
+	 */
+	function pillState(pageInfo, pageLength) {
+		var page = pageInfo.page, pages = pageInfo.pages;
+		return {
+			selectedLength: pageLength,
+			firstDisabled: page === 0,
+			prevDisabled: page === 0,
+			nextDisabled: pages === 0 || page === pages - 1,
+			lastDisabled: pages === 0 || page === pages - 1
+		};
+	}
+
+	/**
+	 * Builds one DataTables column definition from a VIEW_META Column, binding its render id to the registry.  In
+	 * SERVER mode only the display facet runs (type !== "display" returns the raw value - the server did sort/filter).
+	 * An unknown render id warns once and falls back to the raw value (never throws).
+	 */
+	function buildColumnDef(col, deps) {
+		var def = {
+			data: col.data,
+			orderable: col.orderable !== false,
+			searchable: col.searchable !== false,
+			// The server's JSON serializer omits null-valued properties entirely, so a nullable column is simply
+			// ABSENT (undefined) on some rows.  Per datatables.net/tn/4, DataTables' data accessor throws the
+			// "Requested unknown parameter" warning for undefined/null cell data unless columns.defaultContent is
+			// set - it's only consulted at that data-resolution step, so it never runs (and never suppresses a
+			// renderer) when the column's data IS present; present-but-null values still flow into `render` below,
+			// which already coerces null to "".
+			defaultContent: ""
+		};
+		if (col.title != null) def.title = col.title;
+		if (col.name != null) def.name = col.name;
+		if (col.className != null) def.className = col.className;
+
+		if (col.render) {
+			var spec = deps.parseRenderId(col.render);
+			var renderer = deps.resolveRenderer(spec.id);
+			if (!renderer) {
+				deps.warn("Juneau view: unknown render id '" + spec.id + "' - falling back to raw value.");
+			} else if (renderer.display) {
+				var meta = mergeMeta(spec.meta, col);
+				def.render = function (data, type, rowData) {
+					if (type && type !== "display") return data;   // SERVER mode: sort/filter/type done server-side
+					try { return renderer.display(data, rowData, meta); }
+					catch (e) { return data == null ? "" : data; }
+				};
+			}
+		}
+		return def;
+	}
+
+	/** Merges a column's render.meta with runtime column context (href is needed by the `linked` renderer). */
+	function mergeMeta(renderMeta, col) {
+		var meta = {};
+		if (renderMeta) for (var k in renderMeta) if (Object.prototype.hasOwnProperty.call(renderMeta, k)) meta[k] = renderMeta[k];
+		if (col.href != null) meta.href = col.href;
+		meta.column = col.data;
+		return meta;
+	}
+
+	/**
+	 * Builds the DataTables options object from a VIEW_META view.  `deps` supplies the pure renderer-registry hooks
+	 * (parseRenderId/resolveRenderer/warn) and the ribbon param contributor (ribbonParams(activeState)).  The
+	 * serverSide fork (§6.7): server -> serverSide:true + ajax{dataSrc:"data"} (with ribbon active-toggle params
+	 * merged into the request); client -> serverSide:false + ajax{dataSrc:""}.
+	 */
+	function buildOptions(viewDef, deps) {
+		var opts = {};
+		opts.columns = (viewDef.columns || []).map(function (c) { return buildColumnDef(c, deps); });
+		opts.order = resolveOrder(viewDef);
+		// Text polish (design doc §4.B): the native search input's label is blanked (searchPlaceholder replaces it
+		// as the input's placeholder attribute); the native length-select's language.lengthMenu is deliberately NOT
+		// set here - Task 9's paging pill hides and replaces that control entirely, so there is nothing left for it
+		// to style (design doc §4.B's resolved ambiguity).
+		opts.language = { search: "", searchPlaceholder: "Search" };
+		opts.pageLength = PAGE_SIZE_OPTIONS[0].value;
+		// Disable DataTables' own "Showing X to Y of Z entries" line at the source (rather than CSS-hiding it
+		// after the fact) - the unified paging pill (buildPagingPill/buildToolbarRow below) fully replaces it.
+		opts.info = false;
+
+		if (viewDef.dataMode === "server") {
+			opts.serverSide = true;
+			opts.ajax = {
+				url: viewDef.dataUrl,
+				dataSrc: "data",
+				data: function (d) {
+					var extra = deps.ribbonParams ? deps.ribbonParams() : {};
+					for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) d[k] = extra[k];
+					return d;
+				}
+			};
+		} else {
+			opts.serverSide = false;
+			opts.ajax = { url: viewDef.dataUrl, dataSrc: "" };
+		}
+
+		opts.createdRow = function (rowEl, rowData) {
+			evaluateRowClassRules(viewDef.rowClassRules, rowData).forEach(function (cls) {
+				if (cls) rowEl.className += (rowEl.className ? " " : "") + cls;
+			});
+		};
+		return opts;
+	}
+
+	// ==================================================================================================================
+	// DOM / JQUERY BINDING LAYER  (thin shim)
+	// ==================================================================================================================
+
+	function warn(msg) { if (window.console && console.warn) console.warn(msg); }
+	function error(msg) { if (window.console && console.error) console.error(msg); }
+
+	/**
+	 * Builds one icon-only 32px toolbar button under the given `className` (visual-parity design doc §4.A/§4.C).
+	 * Deliberately self-contained rather than reusing juneau-ribbon.js's button(...) helper (Design decision #3):
+	 * this module's buttons are not RibbonActions and have no built-in-id/symbol-override duality to resolve, so
+	 * they resolve their fixed glyph names directly via window.JuneauViews.icons.resolveIcon(...) - the two files
+	 * share the icon mechanism without either depending on the other's toolbar-construction logic.  Falls back to
+	 * rendering the label as text when the glyph isn't registered (same convention as juneau-ribbon.js's button()).
+	 */
+	function toolbarButton(className, label, iconName, onClick) {
+		var b = document.createElement("button");
+		b.type = "button";
+		b.className = className;
+		b.title = label;
+		b.setAttribute("aria-label", label);
+		var icons = window.JuneauViews && window.JuneauViews.icons;
+		var markup = icons && icons.resolveIcon ? icons.resolveIcon(iconName) : null;
+		if (markup != null) {
+			b.innerHTML = markup;
+		} else {
+			b.textContent = b.title;
+		}
+		b.addEventListener("click", onClick);
+		return b;
+	}
+
+	/** One paging-ribbon nav segment - see toolbarButton(...). */
+	function pagingPillButton(label, iconName, onClick) {
+		return toolbarButton("juneau-view-pagingpill-btn", label, iconName, onClick);
+	}
+
+	/**
+	 * Builds the central "n-n of n" segment of the unified paging ribbon (visual-parity follow-up: consolidates
+	 * the old standalone page-size <select> INTO this segment rather than keeping it as a separate control).  It
+	 * is a WAI-ARIA "menu button": the button's own visible content IS the compact range summary (so it never
+	 * shows a redundant "Page size" label), `aria-haspopup="listbox"` + `aria-expanded` mark it as a popup
+	 * trigger, and the popup is a `role="listbox"` list with one `role="option"` per `PAGE_SIZE_OPTIONS` entry -
+	 * the SAME option set/labels the old <select> offered.  Selecting an option calls the same DataTables
+	 * `page.len(n).draw()` API the old <select>'s `change` handler used.
+	 *
+	 * <p>Keyboard: Enter/Space/ArrowDown on the button opens the popup (focusing its currently-selected option, or
+	 * the first one); ArrowUp/ArrowDown move focus between options while open; Enter/Space on a focused option
+	 * selects it; Escape closes the popup and returns focus to the button (also true of a plain option click, and
+	 * of focus leaving the control entirely).
+	 */
+	function buildPageSizeMenu(ctx) {
+		var wrap = document.createElement("span");
+		wrap.className = "juneau-view-pagingpill-menuwrap";
+
+		var btn = document.createElement("button");
+		btn.type = "button";
+		btn.className = "juneau-view-pagingpill-menubtn";
+		btn.title = "Rows per page";
+		btn.setAttribute("aria-haspopup", "listbox");
+		btn.setAttribute("aria-expanded", "false");
+
+		var infoEl = document.createElement("span");
+		infoEl.className = "juneau-view-pagingpill-info";
+		btn.appendChild(infoEl);
+
+		var icons = window.JuneauViews && window.JuneauViews.icons;
+		var caretMarkup = icons && icons.resolveIcon ? icons.resolveIcon("expand_more") : null;
+		var caretEl = document.createElement("span");
+		caretEl.className = "juneau-view-pagingpill-caret";
+		caretEl.setAttribute("aria-hidden", "true");
+		if (caretMarkup != null) caretEl.innerHTML = caretMarkup;
+		btn.appendChild(caretEl);
+
+		var menuEl = document.createElement("ul");
+		menuEl.className = "juneau-view-pagingpill-menu";
+		menuEl.setAttribute("role", "listbox");
+		menuEl.hidden = true;
+
+		var options = NS.init.PAGE_SIZE_OPTIONS.map(function (o) {
+			var optEl = document.createElement("li");
+			optEl.className = "juneau-view-pagingpill-menu-option";
+			optEl.setAttribute("role", "option");
+			optEl.tabIndex = -1;
+			optEl.textContent = o.label;
+			optEl.addEventListener("click", function () { selectOption(o.value); });
+			menuEl.appendChild(optEl);
+			return { value: o.value, el: optEl };
+		});
+
+		function indexOfSelected() {
+			for (var i = 0; i < options.length; i++) if (options[i].el.getAttribute("aria-selected") === "true") return i;
+			return -1;
+		}
+
+		function indexOfFocused() {
+			for (var i = 0; i < options.length; i++) if (options[i].el === document.activeElement) return i;
+			return -1;
+		}
+
+		function openMenu() {
+			menuEl.hidden = false;
+			btn.setAttribute("aria-expanded", "true");
+			var idx = indexOfSelected();
+			options[idx >= 0 ? idx : 0].el.focus();
+		}
+
+		function closeMenu(returnFocusToButton) {
+			menuEl.hidden = true;
+			btn.setAttribute("aria-expanded", "false");
+			if (returnFocusToButton) btn.focus();
+		}
+
+		function selectOption(value) {
+			ctx.dataTable.page.len(value).draw();
+			closeMenu(true);
+		}
+
+		btn.addEventListener("click", function () {
+			if (menuEl.hidden) openMenu(); else closeMenu(false);
+		});
+		btn.addEventListener("keydown", function (e) {
+			if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
+				e.preventDefault();
+				openMenu();
+			}
+		});
+		menuEl.addEventListener("keydown", function (e) {
+			var idx = indexOfFocused();
+			if (e.key === "ArrowDown") {
+				e.preventDefault();
+				options[(idx + 1) % options.length].el.focus();
+			} else if (e.key === "ArrowUp") {
+				e.preventDefault();
+				options[(idx - 1 + options.length) % options.length].el.focus();
+			} else if (e.key === "Enter" || e.key === " ") {
+				e.preventDefault();
+				if (idx >= 0) selectOption(options[idx].value);
+			} else if (e.key === "Escape") {
+				e.preventDefault();
+				closeMenu(true);
+			}
+		});
+		// Closing on focus-out (rather than only on Escape/selection) covers a mouse click or Tab landing
+		// anywhere outside the control - a real menu button must never leave its popup open once focus moves on.
+		wrap.addEventListener("focusout", function (e) {
+			if (!wrap.contains(e.relatedTarget)) closeMenu(false);
+		});
+
+		wrap.appendChild(btn);
+		wrap.appendChild(menuEl);
+
+		return {
+			el: wrap,
+			refresh: function (summaryText, selectedLength) {
+				infoEl.textContent = summaryText;
+				options.forEach(function (o) {
+					o.el.setAttribute("aria-selected", String(o.value) === String(selectedLength) ? "true" : "false");
+				});
+			}
+		};
+	}
+
+	/**
+	 * Builds the unified paging ribbon - `[First] [Prev] [<range> + page-size menu] [Next] [Last]` - and wires it
+	 * to the given DataTables instance (visual-parity follow-up).  Called unconditionally from initTable(...) -
+	 * every view table gets one, regardless of whether it declares any ribbon actions.  Supersedes BOTH the old
+	 * standalone page-size <select> (folded into buildPageSizeMenu's central segment above) AND the old
+	 * right-side compact prev/next ribbon (removed entirely) - paging now exists in exactly ONE place.  Returns
+	 * the bare pill element only - it no longer touches the DOM itself (buildToolbarRow(...) below owns all
+	 * toolbar-row DOM insertion in one place).  There is no native ".dataTables_info"/".dt-info" node to move -
+	 * buildOptions(...) sets `info: false`, so DataTables never creates that node in the first place.
+	 */
+	function buildPagingPill(viewDef, ctx) {
+		var pill = document.createElement("div");
+		pill.className = "juneau-view-pagingpill";
+		pill.setAttribute("data-testid", "paging");
+
+		var firstBtn = pagingPillButton("First page", "first_page", function () { ctx.dataTable.page("first").draw(); });
+		var prevBtn = pagingPillButton("Previous page", "chevron_left", function () { ctx.dataTable.page("previous").draw(); });
+		var sizeMenu = buildPageSizeMenu(ctx);
+		var nextBtn = pagingPillButton("Next page", "chevron_right", function () { ctx.dataTable.page("next").draw(); });
+		var lastBtn = pagingPillButton("Last page", "last_page", function () { ctx.dataTable.page("last").draw(); });
+		pill.appendChild(firstBtn);
+		pill.appendChild(prevBtn);
+		pill.appendChild(sizeMenu.el);
+		pill.appendChild(nextBtn);
+		pill.appendChild(lastBtn);
+
+		function refreshPillState() {
+			var info = ctx.dataTable.page.info();
+			var st = pillState(info, ctx.dataTable.page.len());
+			firstBtn.disabled = st.firstDisabled;
+			prevBtn.disabled = st.prevDisabled;
+			nextBtn.disabled = st.nextDisabled;
+			lastBtn.disabled = st.lastDisabled;
+			sizeMenu.refresh(pagingSummaryText(info), st.selectedLength);
+		}
+		ctx.dataTable.on("draw.dt", refreshPillState);
+		refreshPillState();   // correct initial disabled state before the first draw.dt fires
+
+		return pill;
+	}
+
+	/**
+	 * Builds and inserts the per-column search `<tr>` into `table`'s `<thead>` (visual-parity §4, item 4 - fixes
+	 * the columnSearchToggle button, which toggled `ctx.columnSearchOn` but had nothing wired to
+	 * `ctx.onColumnSearchToggle` to actually show/hide or filter anything).  One text input per SEARCHABLE column
+	 * (a non-searchable column gets an empty cell, keeping column count/alignment intact); each input's `input`
+	 * event applies a simple per-column text filter via `dt.column(idx).search(value).draw()`.  Starts hidden
+	 * (`ctx.onColumnSearchToggle`, wired by the caller, toggles visibility).  Returns null when the table has no
+	 * `<thead>` (defensive; every juneau view table has one).
+	 */
+	function buildColumnSearchRow(table, viewDef, dt) {
+		var thead = table.querySelector("thead");
+		if (!thead) return null;
+		var row = document.createElement("tr");
+		row.className = "juneau-view-columnsearch-row";
+		row.setAttribute("data-testid", "col-search-row");
+		row.style.display = "none";
+		(viewDef.columns || []).forEach(function (col, idx) {
+			var th = document.createElement("th");
+			if (col.searchable !== false) {
+				var input = document.createElement("input");
+				input.type = "text";
+				input.className = "juneau-view-columnsearch-input";
+				var label = "Search " + (col.title || col.data || "column " + idx);
+				input.placeholder = label;
+				input.setAttribute("aria-label", label);
+				input.addEventListener("input", function () { dt.column(idx).search(input.value).draw(); });
+				th.appendChild(input);
+			}
+			row.appendChild(th);
+		});
+		thead.appendChild(row);
+		return row;
+	}
+
+	/** Renders the fail-loud, visible in-table banner used on a contract-version mismatch (or a parse failure). */
+	function renderBanner(table, message) {
+		var caption = table.createCaption ? table.createCaption() : null;
+		if (!caption) {
+			caption = document.createElement("caption");
+			table.insertBefore(caption, table.firstChild);
+		}
+		caption.className = "juneau-view-error";
+		caption.textContent = message;
+	}
+
+	/**
+	 * Assembles ONE unified toolbar row and inserts it as the FIRST child of `wrapper`, i.e. ABOVE the table (IRS
+	 * reference layout).  Per the control-row layout spec: a LEFT cluster (`.juneau-view-toolbar-left`) holding
+	 * just the unified paging ribbon (nav + page-size, left-aligned - the only place paging exists), and a RIGHT
+	 * cluster (`.juneau-view-toolbar-right`, right-aligned via the row's `space-between`) holding, in order: the
+	 * native DataTables search box, then the ribbon bar (already internally grouped into filter-ribbon/copy-
+	 * download-ribbon clusters per juneau-ribbon.js).  `pill`/`bar` are each optional (a view with no ribbon
+	 * actions still gets its pill-only row; a pill is always built by initTable(...) so it is realistically
+	 * always present).  Moves DataTables' native ".dataTables_filter"/".dt-search" search box into the right
+	 * cluster rather than leaving it in its own DataTables-generated wrapper; degrades gracefully (right cluster
+	 * still built from whichever of search/bar exist) when no native search box is found (e.g. searching
+	 * disabled).
+	 */
+	function buildToolbarRow(wrapper, pill, bar) {
+		var filterEl = wrapper.querySelector(".dataTables_filter, .dt-search");
+		var row = document.createElement("div");
+		row.className = "juneau-view-toolbar-row";
+
+		var left = document.createElement("div");
+		left.className = "juneau-view-toolbar-left";
+		if (pill) left.appendChild(pill);
+
+		var right = document.createElement("div");
+		right.className = "juneau-view-toolbar-right";
+		if (filterEl) right.appendChild(filterEl);
+		if (bar) right.appendChild(bar);
+
+		row.appendChild(left);
+		row.appendChild(right);
+		wrapper.insertBefore(row, wrapper.firstChild);
+		return row;
+	}
+
+	function initTable(table) {
+		var $ = window.jQuery;
+		var id = table.getAttribute("data-juneau-view");
+		var sidecar = document.getElementById("juneau-view:" + id);
+		if (!sidecar) { error("Juneau view '" + id + "': missing JSON sidecar; refusing to init."); return; }
+
+		var viewDef;
+		try {
+			viewDef = JSON.parse(sidecar.textContent);
+		} catch (e) {
+			error("Juneau view '" + id + "': malformed JSON sidecar; refusing to init.");
+			renderBanner(table, "Juneau view '" + id + "': malformed configuration.");
+			return;
+		}
+
+		// FAIL-LOUD contract-version handshake (§6.2): a mismatch means the served JS is stale vs the JSON - refuse.
+		if (viewDef.contractVersion !== JUNEAU_VIEW_CONTRACT_VERSION) {
+			var m = "Juneau view '" + id + "': contract version mismatch (page='" + viewDef.contractVersion +
+				"', runtime='" + JUNEAU_VIEW_CONTRACT_VERSION + "'). Refusing to init - reload to clear a stale cached script.";
+			error(m);
+			renderBanner(table, m);
+			return;   // refuse to init rather than silently mis-render
+		}
+
+		if (!$ || !$.fn || !$.fn.DataTable) {
+			warn("Juneau view '" + id + "': jQuery/DataTables not present; cannot bind.");
+			return;
+		}
+		if ($.fn.dataTable.isDataTable(table)) return;   // idempotent
+
+		var activeState = (NS.ribbon && NS.ribbon.loadPersistedState) ? NS.ribbon.loadPersistedState(viewDef) : {};
+
+		var deps = {
+			parseRenderId: NS.parseRenderId,
+			resolveRenderer: NS.resolveRenderer,
+			warn: warn,
+			ribbonParams: function () {
+				return (NS.ribbon && NS.ribbon.ribbonToQueryParams) ? NS.ribbon.ribbonToQueryParams(viewDef, activeState) : {};
+			}
+		};
+
+		var opts = buildOptions(viewDef, deps);
+		var dt = $(table).DataTable(opts);
+
+		var pill = buildPagingPill(viewDef, { table: table, dataTable: dt });
+
+		// Hoisted above the `NS.ribbon.build` call (rather than scoped inside it) because the columnSearchToggle
+		// button's click handler reads `ctx.onColumnSearchToggle` at CLICK time, not at build time - as long as
+		// this same object is later given that callback (below), the button already wired to it works correctly
+		// regardless of which happens first.
+		var ctx = {
+			table: table,
+			dataTable: dt,
+			activeState: activeState,
+			columnSearchOn: false,
+			redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
+		};
+
+		var bar = (NS.ribbon && NS.ribbon.build) ? NS.ribbon.build(viewDef, ctx) : null;
+
+		var columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
+		ctx.onColumnSearchToggle = function (on) {
+			if (!columnSearchRow) return;
+			columnSearchRow.style.display = on ? "" : "none";
+			if (!on) {
+				Array.prototype.forEach.call(columnSearchRow.querySelectorAll("input"), function (inp) { inp.value = ""; });
+				dt.columns().search("").draw();
+			}
+		};
+
+		// One unified top-toolbar row (IRS reference layout) - LEFT the unified paging ribbon, RIGHT [search,
+		// ribbon], all sitting ABOVE the table as a single row (buildToolbarRow(...) owns the left/right split).
+		// Paging exists in exactly this one place - there is no second, right-side paging control any more.
+		var wrapper = table.parentNode;
+		if (wrapper) buildToolbarRow(wrapper, pill, bar);
+	}
+
+	/**
+	 * Inits every table[data-juneau-view] on the page, EXCEPT one scoped inside a [data-juneau-page] shell (TODO-399
+	 * Phase C seam): a page shell's juneau-pages.js runtime owns first-init for its own panels (lazy, on first tab
+	 * activation - DataTables mis-sizes columns initialized inside a display:none panel), rather than the eager
+	 * DOMContentLoaded scan below.  A standalone page with no page shell is unaffected - every one of its tables is
+	 * still inited exactly as before.
+	 */
+	function initAll() {
+		var tables = document.querySelectorAll("table[data-juneau-view]");
+		Array.prototype.forEach.call(tables, function (t) {
+			if (t.closest && t.closest("[data-juneau-page]")) return;
+			initTable(t);
+		});
+	}
+
+	// ==================================================================================================================
+	// PUBLIC API + bootstrap
+	// ==================================================================================================================
+
+	NS.init = {
+		PAGE_SIZE_OPTIONS: PAGE_SIZE_OPTIONS,
+		columnIndexOf: columnIndexOf,
+		resolveOrder: resolveOrder,
+		valuesEqual: valuesEqual,
+		evaluateRowClassRules: evaluateRowClassRules,
+		formatThousands: formatThousands,
+		pagingSummaryText: pagingSummaryText,
+		pillState: pillState,
+		buildColumnDef: buildColumnDef,
+		mergeMeta: mergeMeta,
+		buildOptions: buildOptions,
+		initAll: initAll,
+		// TODO-399 Phase C seam: previously private - exposed so juneau-pages.js can init one specific view's
+		// table on demand (lazy, on first tab activation).  Already idempotent (isDataTable guard below), so
+		// re-entry from the page runtime after the DOMContentLoaded scan has already run is always safe.
+		initTable: initTable,
+		// visual-parity pass: exposed for Option-A/manual verification.
+		buildPagingPill: buildPagingPill,
+		buildPageSizeMenu: buildPageSizeMenu,
+		buildColumnSearchRow: buildColumnSearchRow,
+		buildToolbarRow: buildToolbarRow
+	};
+
+	if (document.readyState === "loading") {
+		document.addEventListener("DOMContentLoaded", initAll);
+	} else {
+		initAll();
+	}
+})();
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
new file mode 100644
index 0000000000..0a5da6d02c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+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.
+ */
+class JuneauPagesJs_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);
+
+	private static String pagesJs() throws Exception {
+		return c.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+	}
+
+	/** Extracts a top-level function's source body (from its `function name(` line to the next top-level `\n\t}`). */
+	private static String functionBody(String src, String functionName) {
+		var start = src.indexOf("function " + functionName + "(");
+		assertTrue(start >= 0, () -> functionName + " not found:\n" + src);
+		var end = src.indexOf("\n\t}", start);
+		return src.substring(start, end < 0 ? src.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Task 7: pure hash-routing logic - parseHash / resolveInitial / hashFor
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_parseHashIsPureAndPubliclyExposed() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("NS.pages = {"), body);
+		assertTrue(body.contains("parseHash: parseHash"), body);
+		var fn = functionBody(body, "parseHash");
+		// DOM-free: splits the hash on "/" - no document/window DOM reads inside the function itself.
+		assertTrue(fn.contains("split(\"/\")"), fn);
+		assertFalse(fn.contains("document."), fn);
+	}
+
+	@Test void a02_resolveInitialExposedAndFallsBackToFirstTabAndSubtab() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("resolveInitial: resolveInitial"), body);
+		var fn = functionBody(body, "resolveInitial");
+		// Fallback-to-first-tab-when-missing/unknown (pt.1 of the design doc's page-runtime responsibilities).
+		assertTrue(fn.contains("tabs[0]"), fn);
+		// A tab with subtabs falls back to ITS first subtab too.
+		assertTrue(fn.contains("tab.subtabs[0]"), fn);
+		assertFalse(fn.contains("document."), fn);
+	}
+
+	@Test void a03_resolveInitialRejectsHashFromADifferentPage() throws Exception {
+		var body = pagesJs();
+		var fn = functionBody(body, "resolveInitial");
+		// A hash targeting a different pageId must not be treated as this page's active tab.
+		assertTrue(fn.contains("parsed.pageId === pageMeta.id"), fn);
+	}
+
+	@Test void a04_hashForExposedAndBuildsThreeSegmentHash() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("hashFor: hashFor"), body);
+		var fn = functionBody(body, "hashFor");
+		assertTrue(fn.contains("\"#\" + pageId"), fn);
+		assertTrue(fn.contains("subtabId"), fn);
+	}
+
+	@Test void a05_findByIdIsPureIdEqualityLookup() throws Exception {
+		var body = pagesJs();
+		var fn = functionBody(body, "findById");
+		assertTrue(fn.contains("list[i].id === id"), fn);
+		assertFalse(fn.contains("document."), fn);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Task 8: DOM binding shim - lazy init, columns.adjust() on re-show, hashchange wiring, no-selector-injection
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_activatePanelViews_lazyInitsFirstThenAdjustsColumnsOnReShow() throws Exception {
+		var body = pagesJs();
+		var fn = functionBody(body, "activatePanelViews");
+		// First activation: idempotent isDataTable guard decides between NS.init.initTable(...) (first time)...
+		assertTrue(fn.contains("isDataTable(t)"), fn);
+		assertTrue(fn.contains("NS.init.initTable"), fn);
+		// ...and columns.adjust() (every subsequent show - fixes sizing deferred while display:none).
+		assertTrue(fn.contains("columns.adjust()"), fn);
+	}
+
+	@Test void b02_hashchangeListenerWired() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("addEventListener(\"hashchange\""), body);
+	}
+
+	@Test void b03_activeStateReflectedViaAttributeComparisonNotSelectorInjection() throws Exception {
+		// Security: hash-derived tabId/subtabId must be compared against existing elements' attribute VALUES
+		// (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);
+		assertFalse(fn.contains("innerHTML"), fn);
+	}
+
+	@Test void b04_panelVisibilityToggledViaClassNotInlineStyle() throws Exception {
+		// No caller/hash-derived string is ever written into style="..." - only a static class name is toggled.
+		var body = pagesJs();
+		var fn = functionBody(body, "showActive");
+		assertTrue(fn.contains("classList.toggle(\"jc-active\""), fn);
+		assertFalse(fn.contains(".style."), fn);
+	}
+
+	@Test void b05_pageContractVersionMismatchRefusesToInitAndRendersBanner() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("contractVersion !== JUNEAU_PAGE_CONTRACT_VERSION"), body);
+		assertTrue(body.contains("renderBanner("), body);
+		assertTrue(body.contains("jc-page-error"), body);
+	}
+
+	@Test void b06_missingOrMalformedSidecarRefusesToInitRatherThanThrow() throws Exception {
+		var body = pagesJs();
+		var fn = functionBody(body, "initPage");
+		assertTrue(fn.contains("if (!sidecar)"), fn);
+		assertTrue(fn.contains("JSON.parse(sidecar.textContent)"), fn);
+		assertTrue(fn.contains("catch (e)"), fn);
+	}
+
+	@Test void b07_initAllPagesScansDataJuneauPageShellsOnDomContentLoaded() throws Exception {
+		var body = pagesJs();
+		assertTrue(body.contains("querySelectorAll(\"[data-juneau-page]\")"), body);
+		assertTrue(body.contains("DOMContentLoaded"), body);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
new file mode 100644
index 0000000000..a0d9e1d34d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
@@ -0,0 +1,165 @@
+/*
+ * 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.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Builder + JSON-serialization contract test for {@link PageDef}/{@link Tab}/{@link Subtab} (Phase C, Task 1).
+ *
+ * <p>
+ * Mirrors {@code ViewMeta_Contract_Test}'s structural-compare style: pins the builder round-trip, the ordered
+ * tab/subtab lists, and the serialized shape (baked {@code contractVersion}, preserved ids) without over-specifying
+ * whitespace/key-order beyond the top-level discriminator fields.
+ */
+class PageDef_Contract_Test extends TestBase {
+
+	public static class Release {
+		public String name;
+		public String status;
+	}
+
+	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();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Builder round-trip
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_builderRoundTrip_leafTabsOnly() {
+		var page = PageDef.create("admin")
+			.title("Admin")
+			.tabs(
+				Tab.create("releases", "Releases").view(view("releases")),
+				Tab.create("users", "Users").view(view("users")))
+			.build();
+
+		assertEquals("admin", page.id);
+		assertEquals("Admin", page.title);
+		assertEquals(2, page.tabs.size());
+		assertEquals("releases", page.tabs.get(0).id);
+		assertEquals("Releases", page.tabs.get(0).label);
+		assertEquals("releases", page.tabs.get(0).view.id);
+		assertEquals("users", page.tabs.get(1).id);
+	}
+
+	@Test void a02_builderRoundTrip_withSubtabs() {
+		var page = 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();
+
+		var catalog = page.tabs.get(1);
+		assertNull(catalog.view);
+		assertEquals(2, catalog.subtabs.size());
+		assertEquals("packages", catalog.subtabs.get(0).id);
+		assertEquals("Packages", catalog.subtabs.get(0).label);
+		assertEquals("packages", catalog.subtabs.get(0).view.id);
+		assertEquals("bundles", catalog.subtabs.get(1).id);
+	}
+
+	@Test void a03_contractVersionDefaultsToViewDefContractVersion() {
+		var page = PageDef.create("admin").tabs(Tab.create("t", "T").view(view("v"))).build();
+		assertEquals(ViewDef.CONTRACT_VERSION, page.contractVersion);
+	}
+
+	@Test void a04_tabOrderPreserved() {
+		var page = PageDef.create("admin")
+			.tabs(
+				Tab.create("c", "C").view(view("c")),
+				Tab.create("a", "A").view(view("a")),
+				Tab.create("b", "B").view(view("b")))
+			.build();
+		assertEquals(List.of("c", "a", "b"), page.tabs.stream().map(t -> t.id).toList());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// JSON serialization shape
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_serializesWithContractVersionAndIds() {
+		var page = PageDef.create("admin")
+			.title("Admin")
+			.tabs(Tab.create("releases", "Releases").view(view("releases")))
+			.build();
+		var json = Json.of(page);
+		assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+		assertTrue(json.contains("\"id\":\"admin\""), json);
+		assertTrue(json.contains("\"title\":\"Admin\""), json);
+		assertTrue(json.contains("\"tabs\":["), json);
+	}
+
+	@Test void b02_leafTabSerializesViewNotSubtabs() {
+		var page = PageDef.create("admin").tabs(Tab.create("releases", "Releases").view(view("releases"))).build();
+		var json = Json.of(page);
+		assertTrue(json.contains("\"view\":"), json);
+		assertFalse(json.contains("\"subtabs\""), json);
+	}
+
+	@Test void b03_subtabbedTabSerializesSubtabsNotView() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("catalog", "Catalog").subtabs(Subtab.create("packages", "Packages").view(view("packages"))))
+			.build();
+		var json = Json.of(page);
+		assertTrue(json.contains("\"subtabs\":["), json);
+		// The Tab's own "view" key must be omitted (not null) when subtabs are used instead.
+		assertFalse(json.contains("\"view\":null"), json);
+	}
+
+	@Test void b04_titleOmittedWhenUnset() {
+		// NOTE: checked structurally (not via a raw substring search) because the referenced view's own Column
+		// carries an unrelated "title" key - a substring check would false-positive on that nested field.
+		var page = PageDef.create("admin").tabs(Tab.create("t", "T").view(view("v"))).build();
+		var top = Json.to(Json.of(page), java.util.Map.class);
+		assertFalse(top.containsKey("title"), () -> Json.of(page));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Fluent-setter null-safety on Tab/Subtab labels
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_tabCreateNullIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> Tab.create(null, "Label"));
+		assertTrue(e.getMessage().contains("null"), e::getMessage);
+	}
+
+	@Test void c02_subtabCreateBlankIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> Subtab.create("   ", "Label"));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+	}
+
+	@Test void c03_pageDefCreateNullIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create(null));
+		assertTrue(e.getMessage().contains("null"), e::getMessage);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
new file mode 100644
index 0000000000..a8ea648b87
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validation contract test for {@link PageDef}/{@link Tab}/{@link Subtab} (Phase C, Task 2, design doc §"Bean
+ * model").
+ *
+ * <p>
+ * Pins the four rejection rules: a {@link Tab} declaring both/neither {@code view}/{@code subtabs}; duplicate
+ * {@code Tab.id} within a page; duplicate {@code Subtab.id} within a tab; and a referenced {@link ViewDef#id}
+ * repeated anywhere across the page (so hash routing + sidecar lookup stay unambiguous).
+ *
+ * <p>
+ * <b>Resolution of the plan's "unique Tab.id / Subtab.id within a page" wording:</b> {@code Tab.id} is required
+ * unique across the whole page (it is the second hash segment, {@code #pageId/tabId/...}); {@code Subtab.id} is
+ * required unique only within its own parent {@code Tab} (it is only ever resolved <i>after</i> the tab id has
+ * already disambiguated the parent, per the {@code #pageId/tabId/subtabId} hash shape) &mdash; so the same subtab
+ * id may safely recur under two different tabs.
+ */
+class PageDef_Validation_Test extends TestBase {
+
+	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();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Tab must have exactly one of view/subtabs
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_tabWithBothViewAndSubtabs_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin")
+				.tabs(Tab.create("t", "T").view(view("v")).subtabs(Subtab.create("s", "S").view(view("s"))))
+				.build());
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a02_tabWithNeitherViewNorSubtabs_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin").tabs(Tab.create("t", "T")).build());
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	@Test void a03_tabWithEmptySubtabsListAndNoView_rejected() {
+		// An explicitly-empty subtabs() call is equivalent to "no subtabs" -> still neither.
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin").tabs(Tab.create("t", "T").subtabs()).build());
+		assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Duplicate tab ids (page-wide)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_duplicateTabId_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin")
+				.tabs(Tab.create("releases", "R1").view(view("r1")), Tab.create("releases", "R2").view(view("r2")))
+				.build());
+		assertTrue(e.getMessage().contains("releases"), e::getMessage);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Duplicate subtab ids (within one tab)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_duplicateSubtabIdWithinSameTab_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin")
+				.tabs(Tab.create("catalog", "Catalog").subtabs(
+					Subtab.create("packages", "P1").view(view("p1")),
+					Subtab.create("packages", "P2").view(view("p2"))))
+				.build());
+		assertTrue(e.getMessage().contains("packages"), e::getMessage);
+	}
+
+	@Test void c02_sameSubtabIdAcrossDifferentTabs_allowed() {
+		// Different parent tabs -> no collision (the hash's tabId segment already disambiguates).
+		var page = PageDef.create("admin")
+			.tabs(
+				Tab.create("a", "A").subtabs(Subtab.create("x", "X").view(view("ax"))),
+				Tab.create("b", "B").subtabs(Subtab.create("x", "X").view(view("bx"))))
+			.build();
+		assertEquals("x", page.tabs.get(0).subtabs.get(0).id);
+		assertEquals("x", page.tabs.get(1).subtabs.get(0).id);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Duplicate referenced ViewDef.id across the whole page
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_duplicateViewIdAcrossTabs_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin")
+				.tabs(Tab.create("a", "A").view(view("shared")), Tab.create("b", "B").view(view("shared")))
+				.build());
+		assertTrue(e.getMessage().contains("shared"), e::getMessage);
+	}
+
+	@Test void d02_duplicateViewIdBetweenTabAndSubtab_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () ->
+			PageDef.create("admin")
+				.tabs(
+					Tab.create("a", "A").view(view("shared")),
+					Tab.create("b", "B").subtabs(Subtab.create("s", "S").view(view("shared"))))
+				.build());
+		assertTrue(e.getMessage().contains("shared"), e::getMessage);
+	}
+
+	@Test void d03_uniqueViewIds_accepted() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("a", "A").view(view("va")), Tab.create("b", "B").view(view("vb")))
+			.build();
+		assertEquals(2, page.tabs.size());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// PageDef requires at least one tab
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_noTabs_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").build());
+		assertTrue(e.getMessage().contains("tab"), e::getMessage);
+	}
+
+	@Test void e02_emptyTabsList_rejected() {
+		var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").tabs().build());
+		assertTrue(e.getMessage().contains("tab"), e::getMessage);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java
new file mode 100644
index 0000000000..40fbf53078
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java
@@ -0,0 +1,139 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.http.resource.*;
+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.*;
+
+/**
+ * End-to-end server wiring smoke test for {@link PageTable} + {@link ViewsMixin} (TODO-399 Phase C, Task 9).
+ *
+ * <p>
+ * Proves a REST resource composing {@link ViewsMixin} and returning a {@code PageTable.of(...)} response emits a
+ * well-formed page: the {@code [data-juneau-page]} shell, every child view's marker table + VIEW_META sidecar, the
+ * PAGE_META sidecar, and (via {@code ViewsMixin}) the asset links a real page would load &mdash; including the new
+ * opt-in {@code juneau-pages.js} alongside the existing {@code juneau-views.js}/{@code juneau-ribbon.js}/
+ * {@code juneau-renders.js}/{@code juneau-views.css} (design doc §"Client page runtime"; mirrors
+ * {@code ViewServerWiring_Test}/{@code ViewsMixin_Serving_Test}'s end-to-end coverage style).
+ */
+class PageServerWiring_Test extends TestBase {
+
+	public static class Release {
+		public String name;
+		public String status;
+	}
+
+	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"), Column.of("status").title("Status"))
+			.build();
+	}
+
+	private static PageDef buildAdminPage() {
+		return PageDef.create("admin")
+			.title("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();
+	}
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class PageHost extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/admin") public HttpResource adminPage() {
+			var markup = Html.of(PageTable.of(buildAdminPage()));
+			return HttpResourceBean.of(
+				ByteArrayBody.of(markup.getBytes(StandardCharsets.UTF_8), "text/html;charset=utf-8"),
+				list(ContentType.of("text/html;charset=utf-8")));
+		}
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(PageHost.class);
+
+	private static String sidecarBody(String html, String sidecarId) {
+		var open = html.indexOf("id=\"" + sidecarId + "\"");
+		assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	@Test void a01_pageEndpointEmitsShellAndAllChildViews() throws Exception {
+		var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+		assertTrue(html.contains("data-juneau-page=\"admin\""), html);
+		for (var id : List.of("releases", "packages", "bundles")) {
+			assertTrue(html.contains("data-juneau-view=\"" + id + "\""), html);
+			assertTrue(html.contains("id=\"juneau-view:" + id + "\""), html);
+		}
+		assertTrue(html.contains("id=\"juneau-page:admin\""), html);
+	}
+
+	@Test void a02_pageMetaRoundTripsTabTree() throws Exception {
+		var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+		var body = sidecarBody(html, "juneau-page:admin");
+		var meta = Json.to(body, Map.class);
+		assertEquals("admin", meta.get("id"));
+		assertEquals(PageDef.CONTRACT_VERSION, meta.get("contractVersion"));
+		var tabs = (List<?>) meta.get("tabs");
+		assertEquals(2, tabs.size());
+	}
+
+	@Test void a03_childViewSidecarsRoundTripToTheirOwnViewDef() throws Exception {
+		var html = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+		var body = sidecarBody(html, "juneau-view:releases");
+		var expected = Json.to(Json.of(view("releases")), Map.class);
+		var actual = Json.to(body, Map.class);
+		assertEquals(expected, actual, body);
+	}
+
+	@Test void a04_repeatedRequests_areByteIdentical() throws Exception {
+		var html1 = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+		var html2 = c.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+		assertEquals(html1, html2);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// The host also exposes the full asset set a real page would load (ViewsMixin, including the new pages.js).
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_hostServesAllFiveAssetsIncludingPagesJs() throws Exception {
+		for (var path : List.of(
+				ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH,
+				ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.PAGES_JS_PATH))
+			c.get(path).run().assertStatus(200);
+	}
+}
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
new file mode 100644
index 0000000000..8a93248bf4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      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.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Markup + escaping tests for the {@link PageTable} emitter (TODO-399 Phase C, Tasks 3-4).
+ *
+ * <p>
+ * Proves the emitter produces the {@code data-juneau-page} shell, a tab-bar entry per {@link Tab} (+ a sub-tab bar
+ * per {@link Tab} with subtabs), a panel per referenced view carrying the <b>same</b> {@code data-juneau-view}
+ * marker + VIEW_META sidecar {@code ViewTable.of(...)} already emits, and one PAGE_META sidecar &mdash; and that all
+ * server-emitted labels/titles are HTML-escaped with no {@code url()}/inline-style injection.
+ */
+class PageTable_Emit_Test extends TestBase {
+
+	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();
+	}
+
+	private static PageDef leafPage() {
+		return PageDef.create("admin")
+			.title("Admin")
+			.tabs(
+				Tab.create("releases", "Releases").view(view("releases")),
+				Tab.create("users", "Users").view(view("users")))
+			.build();
+	}
+
+	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();
+	}
+
+	/** Extracts the raw text between a sidecar's opening and closing {@code <script>} tags by element id. */
+	private static String sidecarBody(String html, String sidecarId) {
+		var open = html.indexOf("id=\"" + sidecarId + "\"");
+		assertTrue(open >= 0, () -> "sidecar '" + sidecarId + "' not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Structure (Task 3)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_emitsPageShellMarker() {
+		var html = Html.of(PageTable.of(leafPage()));
+		assertTrue(html.contains("data-juneau-page=\"admin\""), html);
+		assertTrue(html.contains(PageTable.PAGE_CLASS), html);
+	}
+
+	@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_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_CLASS), html);
+		// The leaf "releases" tab has no subtabs -> no subtab-bar markers referencing it.
+		assertFalse(html.contains("data-parent-tab=\"releases\""), html);
+	}
+
+	@Test void a04_eachViewGetsItsOwnMarkerTableAndSidecar() {
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		for (var id : List.of("releases", "packages", "bundles")) {
+			assertTrue(html.contains("data-juneau-view=\"" + id + "\""), html);
+			assertTrue(html.contains("id=\"juneau-view:" + id + "\""), html);
+		}
+	}
+
+	@Test void a05_childViewOutputIsByteForByteIdenticalToStandaloneViewTable() {
+		var v = view("releases");
+		var standalone = Html.of(ViewTable.of(v));
+		var page = PageDef.create("admin").tabs(Tab.create("releases", "Releases").view(v)).build();
+		var pageHtml = Html.of(PageTable.of(page));
+		assertTrue(pageHtml.contains(standalone), () -> "page output does not contain the standalone ViewTable output verbatim:\n" + pageHtml + "\n---standalone---\n" + standalone);
+	}
+
+	@Test void a06_emitsOnePageMetaSidecar() {
+		var html = Html.of(PageTable.of(leafPage()));
+		assertTrue(html.contains("id=\"juneau-page:admin\""), html);
+	}
+
+	@Test void a07_pageMetaCarriesTabTreeIds() {
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		var body = sidecarBody(html, "juneau-page:admin");
+		var meta = Json.to(body, Map.class);
+		assertEquals("admin", meta.get("id"));
+		var tabs = (List<?>) meta.get("tabs");
+		assertEquals(2, tabs.size());
+		var releasesTab = (Map<?,?>) tabs.get(0);
+		assertEquals("releases", releasesTab.get("id"));
+		assertEquals("Releases", releasesTab.get("label"));
+		assertFalse(releasesTab.containsKey("subtabs"), releasesTab::toString);
+		var catalogTab = (Map<?,?>) tabs.get(1);
+		var subtabs = (List<?>) catalogTab.get("subtabs");
+		assertEquals(2, subtabs.size());
+		assertEquals("packages", ((Map<?,?>) subtabs.get(0)).get("id"));
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Escaping / chrome (Task 4)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_tabLabelsAreHtmlEscaped() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("t", "<script>alert(1)</script>").view(view("v")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertFalse(html.contains("<script>alert(1)</script>"), html);
+		assertTrue(html.contains("&lt;script&gt;"), html);
+	}
+
+	@Test void b02_subtabLabelsAreHtmlEscaped() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("t", "T").subtabs(Subtab.create("s", "<img src=x onerror=alert(1)>").view(view("v"))))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertFalse(html.contains("<img src=x onerror=alert(1)>"), html);
+	}
+
+	@Test void b03_pageTitleIsHtmlEscapedIfRendered() {
+		var page = PageDef.create("admin").title("<b>Admin</b>").tabs(Tab.create("t", "T").view(view("v"))).build();
+		var html = Html.of(PageTable.of(page));
+		assertFalse(html.contains("<b>Admin</b>"), html);
+	}
+
+	@Test void b04_noInlineStyleAttributeEmitted() {
+		// The emitter must never write a caller string into style="..." (a label containing "url(...)" is just
+		// escaped display text here, not a CSS injection vector, since no style="" attribute exists at all).
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("t", "url(javascript:alert(1))").view(view("v")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		assertFalse(html.contains("style=\""), html);
+	}
+
+	@Test void b05_pageMetaCarriesContractVersion() {
+		var html = Html.of(PageTable.of(leafPage()));
+		var body = sidecarBody(html, "juneau-page:admin");
+		var meta = Json.to(body, Map.class);
+		assertEquals(PageDef.CONTRACT_VERSION, meta.get("contractVersion"));
+	}
+
+	@Test void b06_pageMetaScriptBreakoutIsNeutralized() {
+		var page = PageDef.create("admin")
+			.tabs(Tab.create("t", "x</script><!-- <script>alert(1)</script>").view(view("v")))
+			.build();
+		var html = Html.of(PageTable.of(page));
+		var body = sidecarBody(html, "juneau-page:admin");
+		assertFalse(body.contains("<"), () -> "raw '<' leaked into the PAGE_META sidecar:\n" + body);
+		assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in PAGE_META sidecar:\n" + body);
+		// Still valid, round-trippable JSON carrying the original label.
+		var meta = Json.to(body, Map.class);
+		var tabs = (List<?>) meta.get("tabs");
+		assertEquals("x</script><!-- <script>alert(1)</script>", ((Map<?,?>) tabs.get(0)).get("label"));
+	}
+
+	@Test void b07_hashHrefsUseIdsNotLabels() {
+		var html = Html.of(PageTable.of(pageWithSubtabs()));
+		assertTrue(html.contains("href=\"#admin/releases\""), html);
+		assertTrue(html.contains("href=\"#admin/catalog/packages\""), html);
+		assertTrue(html.contains("href=\"#admin/catalog/bundles\""), html);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
new file mode 100644
index 0000000000..5a99fff2ff
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
@@ -0,0 +1,302 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unified paging-pill construction/wiring function assertions for {@code juneau-views.js} (DataTables ribbon
+ * visual-parity design doc §4.C, plan Tasks 8/9/10). Option-A (content-substring + function-body-extraction)
+ * coverage, mirroring {@code ViewsMixin_Serving_Test}'s established idiom - the pill's DataTables-API-surface
+ * wiring (`page.len`, `page(...)`, `draw.dt`) and the cross-version native-control-hide CSS selector list.
+ */
+class PagingPill_Wiring_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+
+	/** Extracts a named function's body: from `function <name>(` to the next top-level `\n\t}`. */
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Task 8 - pure pillState(pageInfo, pageLength)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_pillState_firstAndLastPageDisabledConditions() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function pillState(");
+		assertTrue(fnBody.contains("page === 0"), fnBody);
+		assertTrue(fnBody.contains("page === pages - 1"), fnBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Task 9 - buildPagingPill(viewDef, ctx) DOM construction + wiring
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_buildPagingPill_wiresDataTablesPagingApiSurface() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("function buildPagingPill("), body);
+		assertTrue(body.contains(".page.len("), body);
+		assertTrue(body.contains(".page(\"first\")"), body);
+		assertTrue(body.contains(".page(\"previous\")"), body);
+		assertTrue(body.contains(".page(\"next\")"), body);
+		assertTrue(body.contains(".page(\"last\")"), body);
+		assertTrue(body.contains("\"draw.dt\""), body);
+		assertTrue(body.contains("juneau-view-pagingpill"), body);
+		assertTrue(body.contains("juneau-view-pagingpill-btn"), body);
+		// The unified paging ribbon's central segment is a page-size MENU BUTTON now - the old standalone
+		// <select> is gone (Task: consolidate page-size + nav into one control).
+		assertFalse(body.contains("juneau-view-pagingpill-select"), body);
+		assertTrue(body.contains("juneau-view-pagingpill-menubtn"), body);
+		assertTrue(body.contains("juneau-view-pagingpill-menu"), body);
+		// buildPagingPill returns the bare pill element - it no longer inserts its own row/moves the native info
+		// node (that node doesn't exist any more: buildOptions() now sets `info: false`); buildToolbarRow(...)
+		// owns all toolbar-row DOM insertion (paging pill placement regression, top-toolbar-row fix).
+		var fnBody = functionBody(body, "function buildPagingPill(");
+		assertTrue(fnBody.trim().endsWith("return pill;"), fnBody);
+		assertFalse(fnBody.contains("juneau-view-pagingpill-row"), fnBody);
+		assertFalse(fnBody.contains(".dataTables_info"), fnBody);
+		// Segment order: First, Prev, the page-size menu, Next, Last - a SINGLE unified segmented control.
+		var firstIdx = fnBody.indexOf("pill.appendChild(firstBtn)");
+		var prevIdx = fnBody.indexOf("pill.appendChild(prevBtn)");
+		var menuIdx = fnBody.indexOf("pill.appendChild(sizeMenu.el)");
+		var nextIdx = fnBody.indexOf("pill.appendChild(nextBtn)");
+		var lastIdx = fnBody.indexOf("pill.appendChild(lastBtn)");
+		assertTrue(firstIdx >= 0 && prevIdx > firstIdx && menuIdx > prevIdx && nextIdx > menuIdx && lastIdx > nextIdx, fnBody);
+	}
+
+	@Test void b03_buildPagingPill_carriesPagingDataTestId() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function buildPagingPill(");
+		assertTrue(fnBody.contains("pill.setAttribute(\"data-testid\", \"paging\")"), fnBody);
+	}
+
+	/**
+	 * The page-size menu (formerly a standalone {@code <select>}) is a proper WAI-ARIA menu button: {@code
+	 * aria-haspopup="listbox"}/{@code aria-expanded} on the button, {@code role="option"} entries with {@code
+	 * aria-selected} reflecting the current DataTables page length, keyboard-operable (Enter/Space/ArrowDown
+	 * opens; ArrowUp/ArrowDown move; Enter/Space selects; Escape closes and returns focus to the button), and
+	 * selecting an option drives the SAME {@code page.len(n).draw()} API the old {@code <select>} used.
+	 */
+	@Test void b04_buildPageSizeMenu_isAnAccessibleKeyboardOperableMenuButton() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("function buildPageSizeMenu("), body);
+		var fnBody = functionBody(body, "function buildPageSizeMenu(");
+		assertTrue(fnBody.contains("\"aria-haspopup\", \"listbox\""), fnBody);
+		assertTrue(fnBody.contains("\"aria-expanded\""), fnBody);
+		assertTrue(fnBody.contains("\"role\", \"option\""), fnBody);
+		assertTrue(fnBody.contains("\"aria-selected\""), fnBody);
+		assertTrue(fnBody.contains("\"ArrowDown\""), fnBody);
+		assertTrue(fnBody.contains("\"ArrowUp\""), fnBody);
+		assertTrue(fnBody.contains("\"Escape\""), fnBody);
+		assertTrue(fnBody.contains("ctx.dataTable.page.len("), fnBody);
+		assertTrue(fnBody.contains(".focus()"), fnBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Top-toolbar-row regression (paging pill must sit ABOVE the table, in the SAME row as search + ribbon)
+	//------------------------------------------------------------------------------------------------------------------
+
+	/**
+	 * Regression: the pill previously inserted its own ".juneau-view-pagingpill-row" next to DataTables' native
+	 * info node, which (a) put it at the BOTTOM of the table by default, and (b) broke once `info:false` removed
+	 * that info node entirely (the pill's insertion fallback appended to the END of the wrapper - still the
+	 * bottom). buildToolbarRow(...) now owns ALL toolbar placement: pill + native search + ribbon assembled into
+	 * ONE row and inserted as the wrapper's first child, i.e. above the table.
+	 */
+	@Test void b02_buildToolbarRow_assemblesPillSearchAndRibbonAboveTheTable() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function buildToolbarRow(");
+		assertTrue(fnBody.contains("pill"), fnBody);
+		assertTrue(fnBody.contains("bar"), fnBody);
+		assertTrue(fnBody.contains(".querySelector(\".dataTables_filter, .dt-search\")"), fnBody);
+		assertTrue(fnBody.contains("insertBefore(row, wrapper.firstChild)"), fnBody);
+		// Control-row layout: a LEFT cluster (pill only) and a RIGHT cluster (search + ribbon + extras).
+		assertTrue(fnBody.contains("juneau-view-toolbar-left"), fnBody);
+		assertTrue(fnBody.contains("juneau-view-toolbar-right"), fnBody);
+		assertTrue(fnBody.contains("left.appendChild(pill)"), fnBody);
+
+		var initBody = functionBody(body, "function initTable(");
+		assertTrue(initBody.contains("buildPagingPill("), initBody);
+		assertTrue(initBody.contains("buildToolbarRow(wrapper, pill, bar)"), initBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Task 10 - paging-pill shape CSS + native-control-hide selectors (DT1+DT2)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_viewsCss_hidesNativeLengthAndPaginationControlsBothDtGenerations() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".dataTables_length, .dt-length, .dataTables_paginate, .dt-paging {"), body);
+		var hideStart = body.indexOf(".dataTables_length, .dt-length, .dataTables_paginate, .dt-paging {");
+		var hideEnd = body.indexOf("}", hideStart);
+		assertTrue(body.substring(hideStart, hideEnd).contains("display: none"), body);
+	}
+
+	@Test void c02_viewsCss_hasPagingPillShapeAndDisabledDim() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-pagingpill {"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-btn {"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-btn:disabled { opacity:"), body);
+	}
+
+	/**
+	 * Regression: the DataTables info line ("Showing 0 to 0 of 0 entries...") must be disabled AT THE SOURCE
+	 * (`info: false` in buildOptions(), juneau-views.js) rather than CSS-hidden - so there must be no
+	 * ".dataTables_info"/".dt-info" hide rule left over in the CSS (a leftover rule would be dead code masking the
+	 * fact the node no longer exists at all).
+	 */
+	@Test void c03_viewsCss_hasNoDeadInfoLineHideRule() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertFalse(body.contains(".dataTables_info, .dt-info {"), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Item 2 - ribbon container must lay its buttons out horizontally (design doc §4.A regression)
+	//------------------------------------------------------------------------------------------------------------------
+
+	/**
+	 * Regression: the ribbon CONTAINER (the div buildRibbon(...) in juneau-ribbon.js creates with class
+	 * "juneau-view-ribbon") shipped with no CSS rule of its own - only ".juneau-view-ribbon-btn" was styled, which
+	 * only centers a button's OWN icon and does nothing for its siblings - so buttons rendered one per line
+	 * instead of as a horizontal ribbon. Asserts the CSS selector for the container that juneau-ribbon.js actually
+	 * emits is present and lays out as a horizontal flex row.
+	 */
+	@Test void c04_viewsCss_ribbonContainerIsHorizontalFlexAndMatchesEmittedClassName() throws Exception {
+		var css = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		var js = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(js.contains("bar.className = \"juneau-view-ribbon\";"), js);
+		assertTrue(css.contains(".juneau-view-ribbon {"), css);
+		var start = css.indexOf(".juneau-view-ribbon {");
+		var end = css.indexOf("}", start);
+		var rule = css.substring(start, end);
+		assertTrue(rule.contains("display: flex"), rule);
+	}
+
+	/**
+	 * Regression (item 5's visual-feedback fix): a persisted `option`/`optionGroup` toggle (e.g. the release-manager
+	 * "dropped-only" filter, `persist(true)`) can be active with NO visible affordance on the button - so a
+	 * still-active filter left over from a prior session silently keeps filtering out every row, and looks
+	 * identical to a bug. Asserts the neutral `aria-pressed="true"` shape rule exists here (console-ui's chrome.css
+	 * layers the themed accent color on the SAME selector).
+	 */
+	@Test void c05_viewsCss_hasNeutralAriaPressedShapeForActiveToggle() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-ribbon-btn[aria-pressed=\"true\"] {"), body);
+	}
+
+	/**
+	 * Unified paging ribbon: the old standalone page-size {@code <select>} rule is gone; the central segment's
+	 * menu-button/popup/option shapes exist instead.
+	 */
+	@Test void c06_viewsCss_hasPageSizeMenuShapeAndNoStandaloneSelect() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertFalse(body.contains(".juneau-view-pagingpill-select"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-menubtn {"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-menu {"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-menu-option {"), body);
+		assertTrue(body.contains(".juneau-view-pagingpill-menu-option[aria-selected=\"true\"]"), body);
+	}
+
+	/** data-testid hooks (selector-ambiguity removal): the unified paging ribbon and the right actions ribbon. */
+	@Test void c07_pagingAndRibbonContainers_carryDataTestIdHooks() throws Exception {
+		var viewsBody = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var pillFnBody = functionBody(viewsBody, "function buildPagingPill(");
+		assertTrue(pillFnBody.contains("pill.setAttribute(\"data-testid\", \"paging\")"), pillFnBody);
+
+		var ribbonBody = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var ribbonFnBody = functionBody(ribbonBody, "function buildRibbon(");
+		assertTrue(ribbonFnBody.contains("bar.setAttribute(\"data-testid\", \"ribbon\")"), ribbonFnBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Control-row layout item 3 - the "n-n of n" paging summary (pure formatThousands/pagingSummaryText + wiring)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_formatThousands_insertsSeparatorsEveryThreeDigits() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function formatThousands(");
+		assertTrue(fnBody.contains("% 3 === 0"), fnBody);
+	}
+
+	@Test void e02_pagingSummaryText_matchesIrsOneBasedInclusiveRangeAndEmptyCase() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function pagingSummaryText(");
+		assertTrue(fnBody.contains("pageInfo.start + 1"), fnBody);
+		assertTrue(fnBody.contains("pageInfo.end"), fnBody);
+		assertTrue(fnBody.contains("formatThousands(total)"), fnBody);
+		assertTrue(fnBody.contains("\"0-0 of 0\""), fnBody);
+	}
+
+	@Test void e03_buildPagingPill_rendersSummaryTextInsideThePageSizeMenu() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var pillFnBody = functionBody(body, "function buildPagingPill(");
+		assertTrue(pillFnBody.contains("sizeMenu.refresh(pagingSummaryText(info)"), pillFnBody);
+		var menuFnBody = functionBody(body, "function buildPageSizeMenu(");
+		assertTrue(menuFnBody.contains("juneau-view-pagingpill-info"), menuFnBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Unified paging ribbon regression - the old right-hand "page-controls-ribbon" (compact, ribbon-styled
+	// prev/next duplicate) is GONE; paging exists in exactly ONE place.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e04_buildCompactPagingRibbon_wasRemoved_pagingExistsInExactlyOnePlace() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertFalse(body.contains("function buildCompactPagingRibbon("), body);
+		assertFalse(body.contains("juneau-view-pagingribbon"), body);
+		var initBody = functionBody(body, "function initTable(");
+		assertFalse(initBody.contains("pagingRibbon"), initBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Control-row layout item 4 - the per-column search row buildColumnSearchRow(...) inserts/wires
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e05_buildColumnSearchRow_insertsHiddenPerColumnInputsWiredToColumnSearch() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function buildColumnSearchRow(");
+		assertTrue(fnBody.contains("juneau-view-columnsearch-row"), fnBody);
+		assertTrue(fnBody.contains("data-testid\", \"col-search-row\""), fnBody);
+		assertTrue(fnBody.contains("juneau-view-columnsearch-input"), fnBody);
+		assertTrue(fnBody.contains("row.style.display = \"none\""), fnBody);
+		assertTrue(fnBody.contains("dt.column(idx).search(input.value).draw()"), fnBody);
+		assertTrue(fnBody.contains("col.searchable !== false"), fnBody);
+
+		// initTable(...) must actually wire ctx.onColumnSearchToggle to show/hide + clear-and-redraw this row -
+		// the root cause of the previously-broken toggle was that nothing ever assigned this callback.
+		var initBody = functionBody(body, "function initTable(");
+		assertTrue(initBody.contains("buildColumnSearchRow("), initBody);
+		assertTrue(initBody.contains("ctx.onColumnSearchToggle = function"), initBody);
+		assertTrue(initBody.contains("dt.columns().search(\"\").draw()"), initBody);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
new file mode 100644
index 0000000000..49f70e5a9c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Default-icon lookup + {@code resolveButtonIcon(...)} pure-function assertions for {@code juneau-ribbon.js}
+ * (DataTables ribbon visual-parity design doc §4.A, plan Task 2). Option-A (content-substring +
+ * function-body-extraction) coverage, mirroring {@code ViewsMixin_Serving_Test}'s established idiom.
+ */
+class RibbonIcons_Resolution_Test extends TestBase {
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+
+	/** Extracts a named function's body: from `function <name>(` to the next top-level `\n\t}`. */
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	@Test void a01_ribbonJs_hasDefaultIconsMapWithAllSevenEntries() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("DEFAULT_ICONS"), body);
+		assertTrue(body.contains("copy: \"content_copy\""), body);
+		assertTrue(body.contains("csv: \"csv\""), body);
+		assertTrue(body.contains("excel: \"table\""), body);
+		assertTrue(body.contains("pdf: \"picture_as_pdf\""), body);
+		assertTrue(body.contains("refresh: \"refresh\""), body);
+		assertTrue(body.contains("columnSearchToggle: \"manage_search\""), body);
+		assertTrue(body.contains("collapse: \"unfold_less\""), body);
+	}
+
+	@Test void a02_resolveButtonIcon_explicitSymbolWinsOverDefault() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function resolveButtonIcon(");
+		assertTrue(fnBody.contains(".symbol"), fnBody);
+	}
+
+	@Test void a03_resolveButtonIcon_customOptionWithNoSymbolFallsBackToTune() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function resolveButtonIcon(");
+		assertTrue(fnBody.contains("\"tune\""), fnBody);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
new file mode 100644
index 0000000000..ce36a61ab1
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
@@ -0,0 +1,342 @@
+/*
+ * 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.server.datatables.*;
+import org.apache.juneau.rest.server.views.RowClassRule.Op;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.apache.juneau.rest.server.views.ViewDef.Dir;
+import org.junit.jupiter.api.*;
+
+/**
+ * Golden-fixture contract test for the {@code VIEW_META} wire format (design doc §6.10).
+ *
+ * <p>
+ * This is the load-bearing seam of the toolkit: it pins the {@link ViewDef} JSON serialization field-by-field
+ * against the frozen §6.10 example.  The serializer is the repo's canonical compact JSON marshaller
+ * ({@link Json#of(Object)} &rarr; {@code JsonSerializer.DEFAULT}) &mdash; the same config that serializes the
+ * {@code DataTablesResults} envelope in the TODO-355 tests, so the emitted sidecar and this fixture agree.
+ */
+class ViewMeta_Contract_Test extends TestBase {
+
+	/** Row bean whose simple name drives the {@code rowType} diagnostic field. */
+	public static class Release {
+		public String name;
+		public String status;
+		public String date;
+	}
+
+	/**
+	 * The frozen §6.10 golden fixture (pretty-printed; whitespace and key order are normalized away by the
+	 * structural map comparison below, while the top-level key <b>order</b> is pinned separately).
+	 */
+	private static final String EXPECTED_VIEW_META = """
+		{
+		  "contractVersion": "2",
+		  "id": "releases",
+		  "rowType": "Release",
+		  "dataMode": "server",
+		  "dataUrl": "servlet:/releases/data",
+		  "defaultOrder": [ { "data": "date", "dir": "desc" } ],
+		  "columns": [
+		    { "data": "name",   "title": "Name",   "orderable": true, "searchable": true,
+		      "render": { "id": "linked" }, "href": "servlet:/releases/{id}" },
+		    { "data": "status", "title": "Status", "orderable": true, "searchable": true,
+		      "render": { "id": "tag", "meta": { "field": "status" } } },
+		    { "data": "date",   "title": "Date",   "orderable": true, "searchable": true,
+		      "render": { "id": "date" } }
+		  ],
+		  "ribbon": [
+		    { "type": "export", "buttons": ["copy", "csv"], "optional": ["excel", "pdf"] },
+		    { "type": "columnSearchToggle" },
+		    { "type": "option", "id": "show-superseded", "title": "Show superseded",
+		      "column": "status", "value": "superseded", "persist": true },
+		    { "type": "refresh" }
+		  ],
+		  "rowClassRules": [
+		    { "field": "deleted", "op": "eq", "value": true, "class": "row-deleted" },
+		    { "field": "error",   "op": "present",             "class": "row-flagged" }
+		  ]
+		}
+		""";
+
+	/** Builds the representative "releases" view from the §5/§6.10 builder API. */
+	private static ViewDef releasesView() {
+		return ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.defaultOrder("date", Dir.DESC)
+			.columns(
+				Column.of("name").title("Name").render("linked").href("servlet:/releases/{id}"),
+				Column.of("status").title("Status").render("tag:status"),
+				Column.of("date").title("Date").render("date"))
+			.ribbon(
+				RibbonAction.export("copy", "csv").optional("excel", "pdf"),
+				RibbonAction.columnSearchToggle(),
+				RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+				RibbonAction.refresh())
+			.rowClassRule("deleted", Op.EQ, true, "row-deleted")
+			.rowClassRule("error", Op.PRESENT, "row-flagged")
+			.build();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// The frozen VIEW_META contract
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_releasesView_serializesToFrozenContract() {
+		var json = Json.of(releasesView());
+
+		// Field-by-field structural compare (order- and whitespace-independent): every key/value in the emitted
+		// JSON must match the golden fixture exactly, and no extra keys may leak.
+		var expected = Json.to(EXPECTED_VIEW_META, Map.class);
+		var actual = Json.to(json, Map.class);
+		assertEquals(expected, actual, json);
+	}
+
+	@Test void a02_topLevelKeyOrderMatchesContract() {
+		var actual = Json.to(Json.of(releasesView()), Map.class);
+		assertEquals(
+			List.of("contractVersion", "id", "rowType", "dataMode", "dataUrl", "defaultOrder", "columns", "ribbon", "rowClassRules"),
+			new ArrayList<>(actual.keySet()));
+	}
+
+	@Test void a03_reservedFieldsOmittedNotNull() {
+		var json = Json.of(releasesView());
+		// Reserved C/D/E stubs must be omitted entirely (not serialized as null keys).
+		for (var k : List.of("details", "rowActions", "catalog", "format", "description", "pinned", "defaultVisible")) {
+			var key = "\"" + k + "\"";
+			assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
+		}
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// rowClassRules grammar invariants (§6.3)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_rowClassRuleEqRequiresValue() {
+		var e = assertThrows(IllegalArgumentException.class,
+			() -> ViewDef.create("x").rowClassRule("deleted", Op.EQ, null, "row-deleted"));
+		assertTrue(e.getMessage().contains("value"), e::getMessage);
+	}
+
+	@Test void b02_rowClassRulePresentOmitsValue() {
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.rowClassRule("error", Op.PRESENT, "row-flagged")
+			.build();
+		var json = Json.of(v);
+		// A present/absent rule tests only field presence, so the "value" key must be omitted for that rule.
+		assertTrue(json.contains("\"op\":\"present\""), json);
+		assertFalse(json.contains("\"value\""), json);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// B.2: render-id string sugar (§6.6) - "id:field" -> {id, meta:{field}}
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_renderSugarTagField() {
+		// Everything after the first colon becomes meta.field.
+		var json = Json.of(Column.of("status").render("tag:status"));
+		assertTrue(json.contains("\"render\":{\"id\":\"tag\",\"meta\":{\"field\":\"status\"}}"), json);
+	}
+
+	@Test void c02_renderSugarBareIdOmitsMeta() {
+		// A bare id (no colon) yields a metadata-free renderer -> no "meta" key.
+		var json = Json.of(Column.of("date").render("date"));
+		assertTrue(json.contains("\"render\":{\"id\":\"date\"}"), json);
+		assertFalse(json.contains("\"meta\""), json);
+	}
+
+	@Test void c03_renderSugarEqualsCanonicalForm() {
+		// The string sugar must serialize identically to the hand-built canonical Render.
+		var sugar = Json.of(Column.of("status").render("tag:status"));
+		var canonical = Json.of(Column.of("status").render(Render.of("tag").meta("field", "status")));
+		assertEquals(canonical, sugar);
+	}
+
+	@Test void c04_renderSugarFieldMayContainColons() {
+		// Only the FIRST colon is the id/field delimiter; later colons stay in meta.field.
+		var json = Json.of(Column.of("t").render("ts-zulu:created:at"));
+		assertTrue(json.contains("\"render\":{\"id\":\"ts-zulu\",\"meta\":{\"field\":\"created:at\"}}"), json);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// B.2: column auto-seed from DataTablesColumns.of(rowType) when .columns(...) is omitted
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c10_columnsAutoSeedFromRowType() {
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.build();  // NOTE: no .columns(...) -> should auto-seed from DataTablesColumns.of(Release.class).
+
+		var expected = DataTablesColumns.of(Release.class);
+		assertNotNull(v.columns, "columns should be auto-seeded from the row type when omitted");
+		assertEquals(expected.size(), v.columns.size(), () -> "auto-seeded column count mismatch: " + Json.of(v.columns));
+		for (var i = 0; i < expected.size(); i++) {
+			var e = expected.get(i);
+			var c = v.columns.get(i);
+			assertEquals(e.get("data"), c.data, "data[" + i + "]");
+			assertEquals(e.get("title"), c.title, "title[" + i + "]");
+			assertEquals(e.get("orderable"), c.orderable, "orderable[" + i + "]");
+			assertEquals(e.get("searchable"), c.searchable, "searchable[" + i + "]");
+		}
+	}
+
+	@Test void c11_autoSeedTitlesMatchHumanizedNames() {
+		// Release{name,status,date} -> humanized titles, in Juneau bean-property order.  For a public-field bean with
+		// no explicit ordering, Juneau emits properties alphabetically (Date, Name, Status) -- the auto-seed mirrors
+		// DataTablesColumns.of() faithfully, so the order matches that source of truth rather than declaration order.
+		var v = ViewDef.create("releases").rowType(Release.class).dataMode(DataMode.SERVER).dataUrl("u").build();
+		var titles = v.columns.stream().map(c -> c.title).toList();
+		assertEquals(List.of("Date", "Name", "Status"), titles);
+	}
+
+	@Test void c12_explicitColumnsAreNotOverwrittenByAutoSeed() {
+		// When columns are declared explicitly, auto-seed must NOT clobber them.
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("u")
+			.columns(Column.of("name").title("Name"))
+			.build();
+		assertEquals(1, v.columns.size());
+		assertEquals("name", v.columns.get(0).data);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Gap-closing branch coverage: Render null-guards + repeated meta(), RowClassRule NE/ABSENT + misuse guards,
+	// ViewDef.create()/build()/queryableSettings() validation branches, Column setter instruction gaps.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_renderParseNullThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> Render.parse(null));
+		assertTrue(e.getMessage().contains("null"), e::getMessage);
+	}
+
+	@Test void d02_renderParseBlankThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> Render.parse("   "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+	}
+
+	@Test void d03_renderMetaAppendsSecondEntryToExistingMap() {
+		// First .meta() call creates the map (meta==null branch); a second call must append rather than replace it.
+		var r = Render.of("tag").meta("field", "status").meta("extra", "x");
+		assertEquals(2, r.meta.size());
+		assertEquals("status", r.meta.get("field"));
+		assertEquals("x", r.meta.get("extra"));
+	}
+
+	@Test void d04_rowClassRuleNeUsage() {
+		var r = RowClassRule.of("status", Op.NE, "active", "row-inactive");
+		assertEquals("ne", r.op);
+		assertEquals("active", r.value);
+	}
+
+	@Test void d05_rowClassRuleAbsentUsage() {
+		var r = RowClassRule.of("error", Op.ABSENT, "row-ok");
+		assertEquals("absent", r.op);
+		assertNull(r.value);
+	}
+
+	@Test void d06_rowClassRuleValueFormRejectsPresenceOp() {
+		// Misusing the (field, op, value, class) form with a presence-based op must be rejected.
+		var e = assertThrows(IllegalArgumentException.class,
+			() -> RowClassRule.of("error", Op.PRESENT, "x", "row-flagged"));
+		assertTrue(e.getMessage().contains("does not take a value"), e::getMessage);
+	}
+
+	@Test void d07_rowClassRulePresenceFormRejectsValueOp() {
+		// Misusing the (field, op, class) form with a value-based op must be rejected.
+		var e = assertThrows(IllegalArgumentException.class,
+			() -> RowClassRule.of("status", Op.EQ, "row-x"));
+		assertTrue(e.getMessage().contains("requires a value"), e::getMessage);
+	}
+
+	@Test void d08_viewDefCreateNullIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> ViewDef.create(null));
+		assertTrue(e.getMessage().contains("null"), e::getMessage);
+	}
+
+	@Test void d09_viewDefCreateBlankIdThrows() {
+		var e = assertThrows(IllegalArgumentException.class, () -> ViewDef.create("   "));
+		assertTrue(e.getMessage().contains("blank"), e::getMessage);
+	}
+
+	@Test void d10_buildWithoutRowTypeOrColumns_leavesColumnsNull() {
+		// Neither .rowType(...) nor .columns(...) declared -> build() has nothing to auto-seed from.
+		var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build();
+		assertNull(v.columns);
+	}
+
+	@Test void d11_queryableSettingsWithoutRowTypeThrows() {
+		var e = assertThrows(IllegalArgumentException.class,
+			() -> ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build().queryableSettings());
+		assertTrue(e.getMessage().contains("rowType"), e::getMessage);
+	}
+
+	@Test void d12_columnNameSetter() {
+		var c = Column.of("status").name("statusCol");
+		assertEquals("statusCol", c.name);
+	}
+
+	@Test void d13_columnClassNameSetter() {
+		var c = Column.of("status").className("text-right");
+		assertEquals("text-right", c.className);
+	}
+
+	@Test void d14_ribbonActionDividerAndHintSetters() {
+		var divider = RibbonAction.divider();
+		assertEquals("divider", divider.type);
+
+		var withHints = RibbonAction.option("o").symbol("*").color("red").deselectable();
+		assertEquals("*", withHints.symbol);
+		assertEquals("red", withHints.color);
+		assertTrue(withHints.deselectable);
+	}
+
+	@Test void d15_ribbonActionOptHintSetters() {
+		var opt = RibbonAction.Opt.of("a").title("A").color("blue").symbol("o");
+		assertEquals("A", opt.title);
+		assertEquals("blue", opt.color);
+		assertEquals("o", opt.symbol);
+	}
+
+	@Test void d16_ribbonActionGroupSetter_andOmittedWhenUnset() {
+		// The "group" affordance (visual-parity control-row layout: segmented ribbon clusters) - set + serialized.
+		var grouped = RibbonAction.columnSearchToggle().group("filters");
+		assertEquals("filters", grouped.group);
+		assertTrue(Json.of(grouped).contains("\"group\":\"filters\""), Json.of(grouped));
+
+		// Unset (the common case) -> omitted entirely, like every other optional RibbonAction field.
+		var ungrouped = RibbonAction.columnSearchToggle();
+		assertNull(ungrouped.group);
+		assertFalse(Json.of(ungrouped).contains("\"group\""), Json.of(ungrouped));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
new file mode 100644
index 0000000000..c098256f30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
@@ -0,0 +1,280 @@
+/*
+ * 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.net.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.config.*;
+import org.apache.juneau.rest.server.converter.*;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Server-side wiring + ribbon-toggle&rarr;query-arg mapping tests (design doc §6, Task B.5).
+ *
+ * <p>
+ * Two seams are proven here:
+ * <ul class='spaced-list'>
+ * 	<li>The <b>pure</b> {@link RibbonAction#toQueryParams(ViewDef)} mapping: a column-scoped {@code option} contributes
+ * 		the DataTables {@code columns[N][search][value]} request param (resolving the column key to its index), and a
+ * 		custom-param {@code option} contributes its own {@code param=value} (which the endpoint, not
+ * 		{@code DataTablesQueryProtocol}, must parse).
+ * 	<li>The <b>end-to-end</b> server wiring: a view's {@link ViewDef#queryableSettings()} helper registers the
+ * 		{@code DataTablesQueryProtocol(rowType)} so a {@link ProtocolQueryable}-converted op returns a
+ * 		{@code DataTablesResults} envelope with the ribbon toggle's filter applied &mdash; the same
+ * 		{@code columns[1][search][value]} key the pure mapping emits.
+ * </ul>
+ */
+class ViewServerWiring_Test extends TestBase {
+
+	/** Row bean: {@code name} (col 0), {@code status} (col 1), {@code date} (col 2). */
+	public static class Release {
+		public String name;
+		public String status;
+		public String date;
+		public Release() {}
+		public Release(String name, String status, String date) { this.name = name; this.status = status; this.date = date; }
+	}
+
+	/** The representative "releases" view with a column-scoped status quick-filter option ({@code status} = index 1). */
+	private static ViewDef releasesView() {
+		return ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(
+				Column.of("name").title("Name"),
+				Column.of("status").title("Status"),
+				Column.of("date").title("Date"))
+			.ribbon(
+				RibbonAction.columnSearchToggle(),
+				RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+				RibbonAction.refresh())
+			.build();
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Step 1: pure column-scoped mapping -> columns[N][search][value]
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_columnScopedOption_mapsToColumnSearchValueByIndex() {
+		var params = RibbonAction.toQueryParams(releasesView());
+		// status is column index 1, so the "show-superseded" toggle contributes columns[1][search][value]=superseded.
+		assertEquals("superseded", params.get("columns[1][search][value]"));
+	}
+
+	@Test void a02_nonOptionActions_contributeNothing() {
+		// columnSearchToggle/refresh are not query-contributing options -> the only mapped key is the option's.
+		var params = RibbonAction.toQueryParams(releasesView());
+		assertEquals(1, params.size(), () -> "unexpected params: " + params);
+	}
+
+	@Test void a03_unknownColumn_isRejected() {
+		var bad = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.ribbon(RibbonAction.option("o").column("nope").value("v"))
+			.build();
+		var e = assertThrows(IllegalArgumentException.class, () -> RibbonAction.toQueryParams(bad));
+		assertTrue(e.getMessage().contains("nope"), e::getMessage);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Step 2: non-column-scoped custom param -> param=value (endpoint's responsibility to parse)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_customParamOption_mapsToOwnParam() {
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(Column.of("name").title("Name"))
+			.ribbon(RibbonAction.option("include-deleted").param("includeDeleted").value("true"))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertEquals("true", params.get("includeDeleted"));
+		// It must NOT be shaped as a DataTables column-search param.
+		assertFalse(params.keySet().stream().anyMatch(k -> k.startsWith("columns[")), () -> "params: " + params);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Step 3/4: end-to-end server wiring via ViewDef.queryableSettings() -> DataTablesResults envelope
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Rest(converters=ProtocolQueryable.class)
+	public static class ReleasesHost implements BasicUniversalConfig {
+		static final ViewDef VIEW = releasesView();
+		@RestOp(path="/data")
+		public List<Release> data() {
+			return List.of(
+				new Release("1.0", "released", "2024-01-01"),
+				new Release("2.0", "superseded", "2023-01-01"),
+				new Release("3.0", "superseded", "2022-01-01"),
+				new Release("4.0", "draft", "2025-01-01"));
+		}
+		/** Server-side wiring helper: keeps the protocol's rowType in sync with the ViewDef. */
+		@Bean public QueryableSettings queryableSettings() {
+			return VIEW.queryableSettings();
+		}
+	}
+
+	/** The full DataTables column-descriptor block for the (name, status, date) view. */
+	private static String[] cols() {
+		return new String[] {
+			"columns[0][data]", "name",   "columns[0][searchable]", "true", "columns[0][orderable]", "true",
+			"columns[1][data]", "status", "columns[1][searchable]", "true", "columns[1][orderable]", "true",
+			"columns[2][data]", "date",   "columns[2][searchable]", "true", "columns[2][orderable]", "true"
+		};
+	}
+
+	private static String qs(String...kv) {
+		var parts = new ArrayList<String>();
+		for (var i = 0; i < kv.length; i += 2)
+			parts.add(enc(kv[i]) + "=" + enc(kv[i+1]));
+		return String.join("&", parts);
+	}
+
+	private static String enc(String s) {
+		return URLEncoder.encode(s, StandardCharsets.UTF_8);
+	}
+
+	@Test void c01_ribbonToggle_filtersServerSide_returningEnvelope() throws Exception {
+		var c = MockRestClient.buildJson(ReleasesHost.class);
+
+		// Derive the exact search key/value the ribbon toggle contributes (proves the pure mapping drives the wire).
+		var toggle = RibbonAction.toQueryParams(ReleasesHost.VIEW);
+		var key = "columns[1][search][value]";
+		assertEquals("superseded", toggle.get(key));
+
+		var kv = new ArrayList<>(List.of("draw", "1", "start", "0", "length", "10"));
+		kv.addAll(List.of(cols()));
+		kv.addAll(List.of(key, toggle.get(key)));
+
+		var body = c.get("/data?" + qs(kv.toArray(String[]::new))).run().cacheContent().assertStatus(200).getContent().asString();
+
+		// Envelope shape: {draw, recordsTotal, recordsFiltered, data}.
+		assertTrue(body.contains("\"draw\":1"), body);
+		assertTrue(body.contains("\"recordsTotal\":4"), body);
+		assertTrue(body.contains("\"recordsFiltered\":2"), body);
+		assertTrue(body.contains("\"data\":["), body);
+		// Only the two superseded releases survive the column filter.
+		assertTrue(body.contains("2.0") && body.contains("3.0"), body);
+		assertFalse(body.contains("1.0") || body.contains("4.0"), body);
+	}
+
+	@Test void c02_queryableSettings_carriesDataTablesProtocol() {
+		// The helper must yield a settings bean whose protocol is the DataTables adapter (not the native default).
+		var s = releasesView().queryableSettings();
+		assertNotNull(s);
+		assertNotSame(QueryableSettings.DEFAULT.protocol(), s.protocol());
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Step 4: gap-closing branch coverage for toQueryParams() edge cases (design doc §6.5)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_nullRibbon_contributesNothing() {
+		// No .ribbon(...) declared -> ribbon stays null; toQueryParams must short-circuit, not NPE.
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertTrue(params.isEmpty(), () -> "params: " + params);
+	}
+
+	@Test void d02_optionGroupWithMembers_mapsColumnAndParamOptions() {
+		// optionGroup member options are mapped uniformly with top-level options (both column- and param-scoped).
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"), Column.of("status").title("Status"))
+			.ribbon(RibbonAction.optionGroup("g")
+				.options(
+					RibbonAction.Opt.of("a").column("status").value("released"),
+					RibbonAction.Opt.of("b").param("custom").value("v2")))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertEquals("released", params.get("columns[1][search][value]"));
+		assertEquals("v2", params.get("custom"));
+		assertEquals(2, params.size(), () -> "params: " + params);
+	}
+
+	@Test void d03_optionGroupWithNoOptions_contributesNothing() {
+		// No .options(...) declared on the optionGroup -> options stays null; must be skipped, not NPE.
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.ribbon(RibbonAction.optionGroup("g"))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertTrue(params.isEmpty(), () -> "params: " + params);
+	}
+
+	@Test void d04_optionWithNoValue_contributesNothing() {
+		// A column-scoped option with no .value(...) set is a no-op (valueless options contribute nothing).
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.ribbon(RibbonAction.option("o").column("name"))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertTrue(params.isEmpty(), () -> "params: " + params);
+	}
+
+	@Test void d05_optionWithValueButNoColumnOrParam_contributesNothing() {
+		// A value-bearing option with neither .column(...) nor .param(...) set has nowhere to map -> no-op.
+		var v = ViewDef.create("x")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.columns(Column.of("name").title("Name"))
+			.ribbon(RibbonAction.option("o").value("v"))
+			.build();
+		var params = RibbonAction.toQueryParams(v);
+		assertTrue(params.isEmpty(), () -> "params: " + params);
+	}
+
+	@Test void d06_columnScopedOption_withNullColumns_throws() {
+		// No rowType and no .columns(...) declared -> viewDef.columns stays null; resolving a column-scoped option
+		// must still fail loud (not NPE) with the same "unknown column" message as the populated-columns case.
+		var v = ViewDef.create("x")
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/x/data")
+			.ribbon(RibbonAction.option("o").column("foo").value("v"))
+			.build();
+		var e = assertThrows(IllegalArgumentException.class, () -> RibbonAction.toQueryParams(v));
+		assertTrue(e.getMessage().contains("foo"), e::getMessage);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
new file mode 100644
index 0000000000..f89ceaf305
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
@@ -0,0 +1,208 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      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.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * Markup + escaping tests for the {@link ViewTable} emitter (design doc §6.1).
+ *
+ * <p>
+ * Proves the emitter produces the {@code data-juneau-view} table plus the {@code <script type="application/json">}
+ * VIEW_META sidecar, and &mdash; the security-critical part &mdash; that a {@link ViewDef} whose serialized JSON
+ * contains {@code <script>}/{@code </script>}-shaped strings is neutralized so it cannot break out of the
+ * {@code <script>} element.
+ */
+class ViewTable_Emit_Test extends TestBase {
+
+	/** Row bean for the client-mode {@code tbody} test. */
+	public static class Release {
+		public String name;
+		public String status;
+		public Release(String name, String status) { this.name = name; this.status = status; }
+	}
+
+	private static ViewDef view() {
+		return ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(
+				Column.of("name").title("Name"),
+				Column.of("status").title("Status"))
+			.build();
+	}
+
+	/** Extracts the raw text between the sidecar's opening and closing {@code <script>} tags. */
+	private static String sidecarBody(String html) {
+		var open = html.indexOf("id=\"juneau-view:releases\"");
+		assertTrue(open >= 0, () -> "sidecar script tag not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Markup shape
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_emitsViewTableAndSidecar() {
+		var html = Html.of(ViewTable.of(view()));
+		assertTrue(html.contains("id=\"releases\""), html);
+		assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+		assertTrue(html.contains("<script type=\"application/json\" id=\"juneau-view:releases\">"), html);
+	}
+
+	@Test void a02_usesViewMarkerNotDataTablesMarker() {
+		var html = Html.of(ViewTable.of(view()));
+		// Distinct marker: must NOT collide with the plain-DataTables path (data-juneau-datatable).
+		assertFalse(html.contains("data-juneau-datatable"), html);
+		assertEquals(ViewTable.MARKER_ATTR, "data-juneau-view");
+	}
+
+	@Test void a03_sidecarCarriesTheSerializedViewMeta() {
+		var html = Html.of(ViewTable.of(view()));
+		var body = sidecarBody(html);
+		// The sidecar JSON must round-trip back to the same VIEW_META the golden fixture pins.
+		var expected = Json.to(Json.of(view()), Map.class);
+		var actual = Json.to(body, Map.class);
+		assertEquals(expected, actual, body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Escaping contract (SECURITY-CRITICAL) - a </script> in the config must not break out of the <script> element.
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_scriptBreakoutIsNeutralized() {
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(Column.of("name").title("x</script><!-- <script>alert(1)</script>"))
+			.build();
+
+		var html = Html.of(ViewTable.of(v));
+		var body = sidecarBody(html);
+
+		// No raw '<' may survive inside the sidecar (so neither </script> nor <!-- nor <script can break out).
+		assertFalse(body.contains("<"), () -> "raw '<' leaked into the JSON sidecar:\n" + body);
+		// The break-out char must be present as its JSON unicode escape.
+		assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in sidecar:\n" + body);
+	}
+
+	@Test void b02_neutralizedPayloadStillRoundTripsAsData() {
+		var payload = "x</script><!-- <script>alert(1)</script>";
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(Column.of("name").title(payload))
+			.build();
+
+		var html = Html.of(ViewTable.of(v));
+		var body = sidecarBody(html);
+
+		// The escaped JSON is still valid JSON and the value decodes back to the original string (data preserved).
+		var parsed = Json.to(body, Map.class);
+		var cols = (List<?>) parsed.get("columns");
+		var col0 = (Map<?,?>) cols.get(0);
+		assertEquals(payload, col0.get("title"), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Optional rows (client mode) -> populated <tbody>
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_rowsOverloadPopulatesTbody() {
+		var rows = List.of(new Release("1.0", "released"), new Release("2.0", "draft"));
+		var html = Html.of(ViewTable.of(view(), rows));
+		assertTrue(html.contains("<tbody>"), html);
+		assertTrue(html.contains("released"), html);
+		assertTrue(html.contains("draft"), html);
+	}
+
+	@Test void c02_noRowsOmitsTbody() {
+		var html = Html.of(ViewTable.of(view()));
+		// Server mode with no rows: rows arrive via ajax, so no <tbody> is emitted up front.
+		assertFalse(html.contains("<tbody>"), html);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Re-serializability (Task 1 regression) - the String-backed rawText sidecar is NOT a one-shot Reader, so the same
+	// ViewTable bean survives repeated serialization (which is what the full HtmlDoc page path requires).
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_sameBeanReserializesByteIdenticalAndKeepsEscaping() {
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(Column.of("name").title("x</script><!-- <script>alert(1)</script>"))
+			.build();
+		var bean = ViewTable.of(v);
+
+		var html1 = Html.of(bean);
+		var html2 = Html.of(bean);
+
+		// Re-serializable: before the rawText swap the sidecar was a one-shot StringReader (consumed on first read).
+		assertFalse(html1.isEmpty(), () -> "first serialization was empty");
+		assertEquals(html1, html2, () -> "same ViewTable bean must re-serialize byte-identically:\n1:" + html1 + "\n2:" + html2);
+
+		// The B.3 escaping contract still holds on the (re)serialized output: no raw '<' survives, \u003c is present.
+		var body = sidecarBody(html2);
+		assertFalse(body.contains("<"), () -> "raw '<' leaked into the JSON sidecar after re-serialization:\n" + body);
+		assertTrue(body.contains("\\u003c"), () -> "expected \\u003c escaping in re-serialized sidecar:\n" + body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Emitter branch coverage (thead title fallback + Map-backed rows with a missing key)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d02_titleFallsBackToDataKeyWhenNoTitleSet() {
+		// A column with no .title(): the <thead> cell falls back to the data key.
+		var v = ViewDef.create("releases")
+			.rowType(Release.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.columns(Column.of("name"))   // no .title(...) -> header falls back to the data key
+			.build();
+		var html = Html.of(ViewTable.of(v));
+		assertTrue(html.contains("<th>name</th>"), html);
+	}
+
+	@Test void d03_mapBackedRowsWithMissingKeyRenderEmptyCell() {
+		// Map-backed rows exercise the direct-key-lookup path; an absent key yields an empty cell.
+		var r1 = new LinkedHashMap<String,Object>();
+		r1.put("name", "1.0");
+		r1.put("status", "released");
+		var r2 = new LinkedHashMap<String,Object>();
+		r2.put("name", "2.0");   // no "status" key -> null value -> empty <td>
+		var rows = List.of(r1, r2);
+
+		var html = Html.of(ViewTable.of(view(), rows));
+		assertTrue(html.contains("<tbody>"), html);
+		assertTrue(html.contains("released"), html);
+		assertTrue(html.contains("2.0"), html);
+	}
+}
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
new file mode 100644
index 0000000000..15e50808ce
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+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.
+ */
+class ViewsJs_PageSeam_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);
+
+	@Test void a01_initTableIsPubliclyExposedOnNsInit() throws Exception {
+		var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("initTable: initTable"), body);
+	}
+
+	@Test void a02_initAllSkipsTablesScopedUnderAJuneauPageShell() throws Exception {
+		var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		var initAllStart = body.indexOf("function initAll(");
+		assertTrue(initAllStart >= 0, () -> "initAll not found:\n" + body);
+		var initAllEnd = body.indexOf("\n\t}", initAllStart);
+		var initAllBody = body.substring(initAllStart, initAllEnd < 0 ? body.length() : initAllEnd);
+		assertTrue(initAllBody.contains("data-juneau-page"), initAllBody);
+	}
+
+	@Test void a03_initTableRemainsIdempotentGuardIntact() throws Exception {
+		// Regression: the seam must not remove the existing isDataTable idempotent re-entry guard.
+		var body = c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("isDataTable(table)"), body);
+	}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java
new file mode 100644
index 0000000000..04f61f89df
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving + versioned-URL tests for the {@code juneau-pages.js} asset on {@link ViewsMixin} (TODO-399 Phase C,
+ * Task 5).
+ *
+ * <p>
+ * Extends the existing {@code ViewsMixin_Serving_Test} Option-A serving-smoke pattern to the new opt-in page-runtime
+ * asset: 200 + correct content-type + {@code Cache-Control}, a 404 when the mixin is absent, and the
+ * {@code ?v=<buildVersion>} cache-buster via {@link ViewsMixin#viewAssetUrl(String)}.
+ */
+class ViewsMixin_Pages_Test extends TestBase {
+
+	public static class NoMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient cNoMixin = MockRestClient.buildLax(NoMixin.class);
+	private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+
+	@Test void a01_hostWithoutMixin_pagesJsRouteIs404() throws Exception {
+		cNoMixin.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(404);
+	}
+
+	@Test void a02_pagesJs_served() throws Exception {
+		cWithMixin.get(ViewsMixin.PAGES_JS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-pages.js");
+	}
+
+	@Test void a03_viewAssetUrl_carriesVersionAndContentHashCacheBusterForPagesJs() {
+		var v = ViewsMixin.class.getPackage().getImplementationVersion();
+		var expectedPrefix = "servlet:" + ViewsMixin.PAGES_JS_PATH + "?v=" + (v == null ? "dev" : v) + "-";
+		var url = ViewsMixin.viewAssetUrl(ViewsMixin.PAGES_JS_PATH);
+		assertTrue(url.startsWith(expectedPrefix), url);
+		assertTrue(url.substring(expectedPrefix.length()).matches("[0-9a-f]{8}"), url);
+	}
+
+	@Test void a04_pagesJs_bakesInPageContractVersionHandshake() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.PAGES_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("JUNEAU_PAGE_CONTRACT_VERSION"), body);
+		assertTrue(body.contains("\"" + PageDef.CONTRACT_VERSION + "\""), body);
+	}
+}
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
new file mode 100644
index 0000000000..4b9cf80702
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -0,0 +1,530 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+
+import java.nio.charset.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.bean.html5.Div;
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.entity.*;
+import org.apache.juneau.http.header.*;
+import org.apache.juneau.http.resource.*;
+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.servlet.*;
+import org.apache.juneau.rest.server.views.ViewDef.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving smoke + versioned-URL + contract-handshake tests for {@link ViewsMixin} (design doc §6.1, Task B.4).
+ *
+ * <p>
+ * Option-A coverage (mirrors {@code DataTablesMixin}/{@code ConsoleChromeMixin}'s serving tests): a host composing
+ * the mixin exposes each of the four runtime assets at its stable path with a {@code 200} + correct content-type +
+ * {@code Cache-Control}; a host without the mixin {@code 404}s the same paths.  The {@code ?v=<buildVersion>}
+ * cache-buster and the {@code CONTRACT_VERSION} handshake constant are asserted directly.
+ */
+class ViewsMixin_Serving_Test extends TestBase {
+
+	//------------------------------------------------------------------------------------------------------------------
+	// Hosts
+	//------------------------------------------------------------------------------------------------------------------
+
+	public static class NoMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	@Rest(mixins=ViewsMixin.class)
+	public static class WithMixin extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	/** Row bean for the emitted view page. */
+	public static class Rel {
+		public String name;
+		public String status;
+		public String date;
+	}
+
+	/** The §6.10 golden-shape view the page-consumption test emits + round-trips. */
+	static ViewDef releasesView() {
+		return ViewDef.create("releases")
+			.rowType(Rel.class)
+			.dataMode(DataMode.SERVER)
+			.dataUrl("servlet:/releases/data")
+			.defaultOrder("date", Dir.DESC)
+			.columns(
+				Column.of("name").title("Name").render("linked").href("servlet:/releases/{name}"),
+				Column.of("status").title("Status").render("tag:status"),
+				Column.of("date").title("Date").render("date"))
+			.ribbon(
+				RibbonAction.export("copy", "csv").optional("excel", "pdf"),
+				RibbonAction.columnSearchToggle(),
+				RibbonAction.option("show-superseded").title("Show superseded").column("status").value("superseded").persist(true),
+				RibbonAction.refresh())
+			.build();
+	}
+
+	// Emits the view the documented way (OD-2(a)): pre-serialize the ViewTable markup and serve it as trusted markup,
+	// rather than returning the DOM bean through the full HtmlDoc page path (the sidecar's one-shot Reader content is
+	// intended to be serialized exactly once - see ViewTable's class javadoc).
+	@Rest(mixins=ViewsMixin.class)
+	public static class ViewHost extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/releases") public HttpResource releasesPage() {
+			var markup = Html.of(ViewTable.of(releasesView()));
+			return HttpResourceBean.of(
+				ByteArrayBody.of(markup.getBytes(StandardCharsets.UTF_8), "text/html;charset=utf-8"),
+				list(ContentType.of("text/html;charset=utf-8")));
+		}
+	}
+
+	// Regression (Task 1): returns the ViewTable Div bean DIRECTLY through the servlet's full HtmlDoc page path.
+	// Before the rawText swap this 500'd because the sidecar's one-shot StringReader content could not survive that
+	// serializer (it worked only via HtmlSerializer/Html.of); the String-backed rawText makes the bean re-serializable.
+	@Rest(mixins=ViewsMixin.class)
+	public static class ViewBeanHost extends BasicRestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/releases") public Div releasesPage() {
+			return ViewTable.of(releasesView());
+		}
+	}
+
+	private static final MockRestClient cNoMixin = MockRestClient.buildLax(NoMixin.class);
+	private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
+	private static final MockRestClient cViewHost = MockRestClient.buildLax(ViewHost.class);
+	private static final MockRestClient cViewBeanHost = MockRestClient.buildLax(ViewBeanHost.class);
+
+	/** Extracts a named function's body: from `function <name>(` to the next top-level `\n\t}`. */
+	private static String functionBody(String body, String signature) {
+		var start = body.indexOf(signature);
+		assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+		var end = body.indexOf("\n\t}", start);
+		return body.substring(start, end < 0 ? body.length() : end);
+	}
+
+	/** Extracts the raw text between the sidecar's opening and closing {@code <script>} tags. */
+	private static String sidecarBody(String html) {
+		var open = html.indexOf("id=\"juneau-view:releases\"");
+		assertTrue(open >= 0, () -> "sidecar script tag not found:\n" + html);
+		var contentStart = html.indexOf('>', open) + 1;
+		var contentEnd = html.indexOf("</script>", contentStart);
+		return html.substring(contentStart, contentEnd);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// a) Opt-in / back-compat
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void a01_hostWithoutMixin_assetRoutesAre404() throws Exception {
+		cNoMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(404);
+		cNoMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(404);
+		cNoMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(404);
+		cNoMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(404);
+	}
+
+	@Test void h01_hostWithoutMixin_iconsJsRouteIs404() throws Exception {
+		cNoMixin.get(ViewsMixin.ICONS_JS_PATH).run().assertStatus(404);
+	}
+
+	@Test void a02_hostExistingRoute_unaffectedByMixin() throws Exception {
+		cWithMixin.get("/items").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("items");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// b) Each asset serves 200 + content-type + Cache-Control + a content substring
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void b01_viewsJs_served() throws Exception {
+		cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-views.js");
+	}
+
+	@Test void b02_ribbonJs_served() throws Exception {
+		cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-ribbon.js");
+	}
+
+	@Test void b03_rendersJs_served() throws Exception {
+		cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-renders.js");
+	}
+
+	@Test void b04_viewsCss_served() throws Exception {
+		cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/css")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-views.css");
+	}
+
+	@Test void b05_arbitraryVersionQueryString_stillServes200() throws Exception {
+		// The ?v=... cache-buster is a browser cache key, not part of mixin routing - any value still resolves.
+		cWithMixin.get(ViewsMixin.VIEWS_JS_PATH + "?v=whatever").run().assertStatus(200);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// c) Versioned asset URL (?v=<buildVersion>) via the viewAssetUrl(...) helper
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void c01_viewAssetUrl_carriesVersionAndContentHashCacheBuster() {
+		var v = ViewsMixin.class.getPackage().getImplementationVersion();
+		var expectedPrefix = "servlet:" + ViewsMixin.VIEWS_JS_PATH + "?v=" + (v == null ? "dev" : v) + "-";
+		var url = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+		assertTrue(url.startsWith(expectedPrefix), url);
+		assertTrue(url.substring(expectedPrefix.length()).matches("[0-9a-f]{8}"), url);
+	}
+
+	@Test void c02_viewAssetUrl_worksForEveryAssetPath() {
+		for (var path : new String[]{ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH, ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH})
+			assertTrue(ViewsMixin.viewAssetUrl(path).contains("?v="), path);
+	}
+
+	@Test void c03_viewAssetUrl_contentHash_isStableAcrossCalls_andDiffersAcrossDistinctAssets() {
+		// The content-hash cache-buster (Task 1) is computed once from each asset's own bytes - it must be stable
+		// across repeated calls (not recomputed per-call) and must differ between distinct assets.
+		var url1 = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+		var url2 = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+		assertEquals(url1, url2, "hash must be stable across repeated calls");
+		assertNotEquals(url1, ViewsMixin.viewAssetUrl(ViewsMixin.RIBBON_JS_PATH), "distinct assets must not collide on their content hash");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// d) Contract-version handshake constant (single source of truth = ViewDef.CONTRACT_VERSION)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void d01_contractVersion_equalsViewDefContractVersion() {
+		assertEquals(ViewDef.CONTRACT_VERSION, ViewsMixin.CONTRACT_VERSION);
+		assertEquals("2", ViewsMixin.CONTRACT_VERSION);
+	}
+
+	@Test void d02_viewsJs_bakesInContractVersionHandshake() throws Exception {
+		// The initializer must carry the handshake constant so the client can fail-loud on a contractVersion mismatch.
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION"), body);
+		assertTrue(body.contains("\"" + ViewsMixin.CONTRACT_VERSION + "\""), body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// e) B.6 - juneau-renders.js registry + base juneau-views.css .tag chip
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void e01_rendersJs_hasRegistryAndGenericRenderers() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
+		// Concrete API surface (call sites / registered ids), not just the header-comment prose.
+		assertTrue(body.contains("registerRenderer("), body);
+		assertTrue(body.contains("parseRenderId("), body);
+		assertTrue(body.contains("\"ts-zulu\""), body);   // the ts-zulu renderer is actually registered
+	}
+
+	@Test void e02_viewsCss_hasNeutralTagChip() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		// A real base-chip rule (not just the header comment): neutral shape/padding, no colors.
+		assertTrue(body.contains(".tag {"), body);
+		assertTrue(body.contains("border-radius:"), body);
+	}
+
+	@Test void e04_viewsCss_hasNeutralRibbonButtonShape() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-ribbon-btn {"), body);
+		assertTrue(body.contains("width: 32px"), body);
+		assertTrue(body.contains("height: 32px"), body);
+		assertTrue(body.contains("display: flex"), body);
+		assertTrue(body.contains("border: 1px solid"), body);
+		assertTrue(body.contains(".juneau-view-ribbon-btn svg {"), body);
+		assertTrue(body.contains("fill: currentColor"), body);
+	}
+
+	@Test void e03_rendersJs_tagRendererNormalizesCssTokenToMatchConsoleUi() throws Exception {
+		// 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.
+		var body = cWithMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("normalizeTagToken("), body);
+		var tagRendererStart = body.indexOf("registerRenderer(\"tag\"");
+		assertTrue(tagRendererStart >= 0, () -> "tag renderer not found:\n" + body);
+		var tagRendererEnd = body.indexOf("registerRenderer(", tagRendererStart + 1);
+		var tagRendererRegion = body.substring(tagRendererStart, tagRendererEnd < 0 ? body.length() : tagRendererEnd);
+		assertTrue(tagRendererRegion.contains("normalizeTagToken(domain)"), tagRendererRegion);
+		assertTrue(tagRendererRegion.contains("normalizeTagToken(value)"), tagRendererRegion);
+		// The RAW value (not the normalized token) must still be what the user sees as display text.
+		assertTrue(tagRendererRegion.contains("escHtml(value)"), tagRendererRegion);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// f) B.7 - juneau-ribbon.js runtime (feature-detected export + column-search-value contribution)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void f01_ribbonJs_hasFeatureDetectedExportAndParamContribution() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("Buttons"), body);       // feature-detect DataTables Buttons
+		assertTrue(body.contains("feature"), body);        // feature-detection markers
+		assertTrue(body.contains("columns["), body);       // mirrors RibbonAction.toQueryParams' columns[N][search][value]
+	}
+
+	@Test void f02_ribbonJs_rendersIconOnlyButtonsWithAriaLabel() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("setAttribute(\"aria-label\""), body);
+		assertTrue(body.contains(".title = "), body);
+		assertTrue(body.contains("resolveButtonIcon("), body);
+		assertTrue(body.contains("NS.icons.resolveIcon("), body);
+		var buttonFnStart = body.indexOf("function button(");
+		assertTrue(buttonFnStart >= 0, () -> "function button( not found:\n" + body);
+		var buttonFnEnd = body.indexOf("\n\t}", buttonFnStart);
+		var buttonFnBody = body.substring(buttonFnStart, buttonFnEnd < 0 ? body.length() : buttonFnEnd);
+		assertFalse(buttonFnBody.contains("b.textContent = label"), buttonFnBody);
+	}
+
+	@Test void f03_ribbonJs_exportButtonsTriggerDataTablesButtonsApi() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var exportStart = body.indexOf("a.type === \"export\"");
+		assertTrue(exportStart >= 0, () -> "export branch not found:\n" + body);
+		var exportEnd = body.indexOf("if (a.type ===", exportStart + 1);
+		var exportRegion = body.substring(exportStart, exportEnd < 0 ? body.length() : exportEnd);
+		assertTrue(exportRegion.contains(".button("), exportRegion);
+		assertTrue(exportRegion.contains(".trigger("), exportRegion);
+		assertFalse(exportRegion.contains(".buttons().container().appendTo(bar)"), exportRegion);
+	}
+
+	/**
+	 * Control-row layout item 2/5 (single segmented ribbon): the export cluster's buttons, and any two adjacent
+	 * actions sharing a {@code group} id, must be wrapped in ONE ".juneau-view-ribbon-group" span via the local
+	 * {@code place(el, groupId)} helper - not appended as loose siblings of "bar" (which would render as
+	 * individually-bordered buttons rather than one shared-border segmented cluster).
+	 */
+	@Test void f04_ribbonJs_groupsExportButtonsAndSharedGroupIdActionsIntoOneSegmentedCluster() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var fnBody = functionBody(body, "function buildRibbon(");
+		assertTrue(fnBody.contains("function place("), fnBody);
+		assertTrue(fnBody.contains("juneau-view-ribbon-group"), fnBody);
+		// export's own buttons always cluster (synthetic per-index id when no explicit .group is set).
+		assertTrue(fnBody.contains("__export"), fnBody);
+		assertTrue(fnBody.contains("place("), fnBody);
+		// A divider always closes any open cluster (a divider is a deliberate visual break, not a grouping seam).
+		var dividerStart = fnBody.indexOf("a.type === \"divider\"");
+		var dividerEnd = fnBody.indexOf("return;", dividerStart);
+		assertTrue(fnBody.substring(dividerStart, dividerEnd).contains("openGroup = null"), fnBody);
+	}
+
+	/**
+	 * Root-cause regression for the broken columnSearchToggle button (control-row layout item 4): the button's
+	 * click handler previously flipped `ctx.columnSearchOn` and invoked `ctx.onColumnSearchToggle` if present, but
+	 * NOTHING in juneau-views.js ever assigned that callback (grep-confirmed absent before this fix) - so toggling
+	 * silently did nothing, and the button never reflected state via `aria-pressed` either (unlike `optionToggle`'s
+	 * button, which already did). Asserts the ribbon-side half of the fix: the button's `aria-pressed` is now set
+	 * from `toggleColumnSearch(...)`'s return value, both initially and on every click.
+	 */
+	@Test void f05_ribbonJs_columnSearchToggleButtonReflectsAriaPressed() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+		var branchStart = body.indexOf("a.type === \"columnSearchToggle\"");
+		assertTrue(branchStart >= 0, () -> "columnSearchToggle branch not found:\n" + body);
+		var branchEnd = body.indexOf("if (a.type ===", branchStart + 1);
+		var branch = body.substring(branchStart, branchEnd < 0 ? body.length() : branchEnd);
+		assertTrue(branch.contains("csBtn.setAttribute(\"aria-pressed\""), branch);
+		assertTrue(branch.contains("toggleColumnSearch(viewDef, ctx)"), branch);
+
+		var fnBody = functionBody(body, "function toggleColumnSearch(");
+		assertTrue(fnBody.contains("return ctx.columnSearchOn;"), fnBody);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// g) B.8 - juneau-views.js initializer
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void g01_viewsJs_hasInitializerLogic() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("JSON.parse"), body);     // reads the sidecar
+		assertTrue(body.contains("serverSide"), body);     // wires server-side ajax mode
+		assertTrue(body.contains("createdRow"), body);     // applies rowClassRules
+	}
+
+	@Test void g04_viewsJs_columnDefsSetDefaultContentForUndefinedSafety() throws Exception {
+		// 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.
+		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);
+		var buildColumnDefEnd = body.indexOf("\n\t}", buildColumnDefStart);
+		var buildColumnDefBody = body.substring(buildColumnDefStart, buildColumnDefEnd < 0 ? body.length() : buildColumnDefEnd);
+		assertTrue(buildColumnDefBody.contains("defaultContent:"), buildColumnDefBody);
+	}
+
+	@Test void g05_viewsJs_hasSearchLanguagePolishAndPageSizeVocabulary() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("searchPlaceholder"), body);
+		assertTrue(body.contains("\"Search\""), body);
+		assertTrue(body.contains("\"25 rows\""), body);
+		assertTrue(body.contains("\"100 rows\""), body);
+		assertTrue(body.contains("\"All rows\""), body);
+		assertTrue(body.contains("pageLength"), body);
+	}
+
+	@Test void g02_hostPageEmitsSidecarThatRoundTrips() throws Exception {
+		// A ViewsMixin host page that emits a ViewTable carries the marker + sidecar the initializer consumes.
+		var html = cViewHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+		assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+		assertTrue(html.contains("id=\"juneau-view:releases\""), html);
+		// The sidecar JSON round-trips back to the same VIEW_META the model emits (structural compare).
+		var body = sidecarBody(html);
+		assertEquals(Json.to(Json.of(releasesView()), java.util.Map.class), Json.to(body, java.util.Map.class), body);
+	}
+
+	@Test void g03_beanReturnedThroughFullHtmlDocPagePath_is200AndReserializable() throws Exception {
+		// The reported bug: returning the ViewTable Div bean through the servlet's full HtmlDoc page path 500'd
+		// because the one-shot StringReader sidecar could not survive that serializer.  With rawText it is 200.
+		var html = cViewBeanHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+		assertTrue(html.contains("data-juneau-view=\"releases\""), html);
+		assertTrue(html.contains("id=\"juneau-view:releases\""), html);
+
+		// Repeated requests re-emit + re-serialize a fresh bean each time through the page path (not one-shot).
+		var html2 = cViewBeanHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+		assertEquals(html, html2, "full-page HtmlDoc output must be stable across repeated serializations");
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// h) Ribbon visual-parity Task 1 - juneau-icons.js icon registry
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void h02_iconsJs_served() throws Exception {
+		cWithMixin.get(ViewsMixin.ICONS_JS_PATH).run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("text/javascript")
+			.assertHeader("Cache-Control").isContains("max-age")
+			.assertContent().asString().isContains("juneau-icons.js");
+	}
+
+	@Test void h03_iconsJs_hasRegistryAndBundledGlyphs() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.ICONS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("registerIcon("), body);
+		assertTrue(body.contains("resolveIcon("), body);
+		for (var name : new String[]{
+				"content_copy", "csv", "table", "picture_as_pdf", "refresh", "manage_search", "unfold_less",
+				"first_page", "chevron_left", "chevron_right", "last_page", "tune", "filter_alt", "expand_more"})
+			assertTrue(body.contains("\"" + name + "\""), () -> "missing bundled glyph '" + name + "':\n" + body);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// n) Ribbon visual-parity Task 12/13 - responsive cleanup (tab/sub-tab scroll, control-row wrap, toolbar wrapper)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void n01_viewsCss_tabBarScrollsInsteadOfWraps() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		var barStart = body.indexOf(".jc-tab-bar,\n.jc-subtab-bar {");
+		assertTrue(barStart >= 0, () -> ".jc-tab-bar,\\n.jc-subtab-bar { not found:\n" + body);
+		var barEnd = body.indexOf("}", barStart);
+		var barRegion = body.substring(barStart, barEnd);
+		assertTrue(barRegion.contains("flex-wrap: nowrap"), barRegion);
+		assertTrue(barRegion.contains("overflow-x: auto"), barRegion);
+		assertFalse(barRegion.contains("flex-wrap: wrap;"), barRegion);
+
+		var tabStart = body.indexOf(".jc-tab,\n.jc-subtab {");
+		assertTrue(tabStart >= 0, () -> ".jc-tab,\\n.jc-subtab { not found:\n" + body);
+		var tabEnd = body.indexOf("}", tabStart);
+		var tabRegion = body.substring(tabStart, tabEnd);
+		assertTrue(tabRegion.contains("flex: 0 0 auto"), tabRegion);
+	}
+
+	@Test void n02_viewsJs_wrapsRibbonAndSearchInToolbarRow() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains("function buildToolbarRow("), body);
+		assertTrue(body.contains("juneau-view-toolbar-row"), body);
+		assertTrue(body.contains(".querySelector(\".dataTables_filter, .dt-search\""), body);
+	}
+
+	@Test void n03_viewsCss_controlRowsWrapAndDividerIsSpacing() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-toolbar-row {"), body);
+		var rowStart = body.indexOf(".juneau-view-toolbar-row {");
+		var rowEnd = body.indexOf("}", rowStart);
+		var rowRegion = body.substring(rowStart, rowEnd);
+		assertTrue(rowRegion.contains("flex-wrap: wrap"), rowRegion);
+		// Control-row layout: LEFT cluster + RIGHT cluster, pushed apart so the right cluster stays right-aligned
+		// (superseded the old single-cluster "justify-content: flex-start").
+		assertTrue(rowRegion.contains("justify-content: space-between"), rowRegion);
+
+		assertTrue(body.contains(".juneau-view-ribbon-divider {"), body);
+		var divStart = body.indexOf(".juneau-view-ribbon-divider {");
+		var divEnd = body.indexOf("}", divStart);
+		var divRegion = body.substring(divStart, divEnd);
+		assertFalse(divRegion.contains("border"), divRegion);
+	}
+
+	//------------------------------------------------------------------------------------------------------------------
+	// o) Control-row layout restructure (LEFT page-controls / RIGHT search+ribbon+refresh clusters)
+	//------------------------------------------------------------------------------------------------------------------
+
+	@Test void o01_viewsCss_hasLeftAndRightToolbarClusterShapes() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-toolbar-left,\n.juneau-view-toolbar-right {"), body);
+		var start = body.indexOf(".juneau-view-toolbar-left,\n.juneau-view-toolbar-right {");
+		var end = body.indexOf("}", start);
+		var region = body.substring(start, end);
+		assertTrue(region.contains("display: flex"), region);
+		assertTrue(region.contains("flex-wrap: wrap"), region);
+	}
+
+	/**
+	 * Control-row layout item 2/5 (single segmented ribbon): the group wrapper must NOT carry a `gap` (a gap would
+	 * leave a visible seam between adjacent buttons instead of a collapsed shared border), and its member buttons
+	 * must collapse borders via a negative margin with rounding only on the outer ends.
+	 */
+	@Test void o02_viewsCss_ribbonGroupCollapsesSharedBordersRoundedOnlyOnOuterEnds() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-ribbon-group { display: inline-flex; }"), body);
+
+		assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn {"), body);
+		var memberStart = body.indexOf(".juneau-view-ribbon-group .juneau-view-ribbon-btn {");
+		var memberEnd = body.indexOf("}", memberStart);
+		var memberRegion = body.substring(memberStart, memberEnd);
+		assertTrue(memberRegion.contains("border-radius: 0"), memberRegion);
+		assertTrue(memberRegion.contains("margin-left: -1px"), memberRegion);
+
+		assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn:first-child {"), body);
+		assertTrue(body.contains(".juneau-view-ribbon-group .juneau-view-ribbon-btn:last-child {"), body);
+	}
+
+	@Test void o03_viewsCss_hasNeutralColumnSearchRowAndInputShape() throws Exception {
+		var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+		assertTrue(body.contains(".juneau-view-columnsearch-row > th {"), body);
+		assertTrue(body.contains(".juneau-view-columnsearch-input {"), body);
+		var start = body.indexOf(".juneau-view-columnsearch-input {");
+		var end = body.indexOf("}", start);
+		assertTrue(body.substring(start, end).contains("border: 1px solid"), body);
+	}
+}
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 2e7187a9fb..a1ae6e6770 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -47,6 +47,7 @@
 		<module>juneau-rest-server-tracing-otel</module>
 		<module>juneau-rest-server-management-logging</module>
 		<module>juneau-rest-server-datatables</module>
+		<module>juneau-rest-server-views</module>
 		<module>juneau-rest-server-console-ui</module>
 		<module>juneau-rest-server-console-ui-freemarker</module>
 		<module>juneau-rest-server-console-ui-freemarker-datatables</module>