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

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

commit 0241c70c26c8142e0a1352605da9843d988eda33
Author: James Bognar <[email protected]>
AuthorDate: Tue Aug 18 09:08:00 2026 -0400

    Adopt console-ui chrome/theme, rich views toolkit, and add Playwright suite
    
    De-forks the app shell onto juneau-rest-server-console-ui's themed chrome
    (ReleaseManagerTheme, ConsoleAssetsRest) instead of app-local chrome CSS,
    and rebuilds the Releases tab on juneau-rest-server-views with the ribbon,
    unified paging control, per-column search and themed table styling.
    
    Adds an Admin surface and a standalone Playwright end-to-end suite under
    e2e/, which boots its own instance on a dedicated test port and is not
    wired into the Maven build.
---
 .gitignore                                         |   6 +
 e2e/package-lock.json                              |  78 +++
 e2e/package.json                                   |  15 +
 e2e/playwright.config.ts                           |  64 +++
 e2e/tests/home.spec.ts                             |  14 +
 e2e/tests/nav.spec.ts                              |  58 +++
 e2e/tests/releases.spec.ts                         | 190 ++++++++
 e2e/tests/smoke.spec.ts                            |  52 ++
 e2e/tsconfig.json                                  |  14 +
 pom.xml                                            |  15 +
 .../org/apache/juneau/releng/AppConfiguration.java |  11 +
 .../apache/juneau/releng/ConsoleAssetsRest.java    |  78 +++
 .../apache/juneau/releng/ReleaseManagerTheme.java  |  74 +++
 .../java/org/apache/juneau/releng/RootRest.java    |   3 +-
 .../org/apache/juneau/releng/rest/AdminRest.java   |  98 ++++
 .../apache/juneau/releng/rest/CredentialRest.java  |  36 +-
 .../org/apache/juneau/releng/rest/HomeRest.java    |   5 +-
 .../org/apache/juneau/releng/rest/ReleaseRest.java | 104 +++-
 .../apache/juneau/releng/rest/ReleaseRunRest.java  |   5 +-
 src/main/resources/static/css/chrome.css           | 539 ++++-----------------
 src/main/resources/static/css/new-release.css      | 208 ++++----
 src/main/resources/static/js/new-release.js        |  24 +-
 src/main/resources/templates/admin.ftlh            |  35 ++
 src/main/resources/templates/base.ftlh             |  60 ++-
 src/main/resources/templates/credentials.ftlh      |   6 +-
 src/main/resources/templates/home.ftlh             |   8 +-
 src/main/resources/templates/new-release.ftlh      |  12 +-
 src/main/resources/templates/release-detail.ftlh   |   4 +-
 src/main/resources/templates/releases.ftlh         |  44 +-
 .../apache/juneau/releng/rest/AdminRestTest.java   | 179 +++++++
 .../apache/juneau/releng/rest/ReleaseRestTest.java |  48 ++
 31 files changed, 1439 insertions(+), 648 deletions(-)

diff --git a/.gitignore b/.gitignore
index 5e6084f339..65096e8747 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,9 @@ target/
 # Eclipse IDE metadata (machine-specific; the shared .settings/ convention 
prefs ARE tracked)
 .classpath
 .project
+
+# Playwright e2e suite (e2e/) - dependencies + generated run artifacts
+e2e/node_modules/
+e2e/test-results/
+e2e/playwright-report/
+e2e/.playwright/
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
new file mode 100644
index 0000000000..d1799ef082
--- /dev/null
+++ b/e2e/package-lock.json
@@ -0,0 +1,78 @@
+{
+  "name": "release-manager-e2e",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "release-manager-e2e",
+      "version": "1.0.0",
+      "devDependencies": {
+        "@playwright/test": "^1.55.0"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.62.1",
+      "resolved": 
"https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/@playwright/test/-/test-1.62.1.tgz";,
+      "integrity": 
"sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.62.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": 
"https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/fsevents/-/fsevents-2.3.2.tgz";,
+      "integrity": 
"sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.62.1",
+      "resolved": 
"https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/playwright/-/playwright-1.62.1.tgz";,
+      "integrity": 
"sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.62.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.62.1",
+      "resolved": 
"https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/playwright-core/-/playwright-core-1.62.1.tgz";,
+      "integrity": 
"sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    }
+  }
+}
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000000..3d54242d4c
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,15 @@
+{
+  "name": "release-manager-e2e",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Playwright end-to-end tests for the Apache Juneau Release 
Manager app. Standalone (not wired into the Maven build) — boots its own app 
instance on a dedicated test port.",
+  "scripts": {
+    "test": "playwright test",
+    "test:headed": "playwright test --headed",
+    "test:ui": "playwright test --ui",
+    "report": "playwright show-report"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.55.0"
+  }
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000000..3d84997d8c
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,64 @@
+import { defineConfig, devices } from '@playwright/test';
+import path from 'path';
+import os from 'os';
+
+/**
+ * Dedicated test port for the Spring Boot app this suite boots for itself. 
MUST NOT be 8790 — that port is
+ * owned by a separately-running coordinator instance of the app that this 
suite must never touch.
+ */
+const TEST_PORT = 8791;
+const BASE_URL = `http://127.0.0.1:${TEST_PORT}`;
+
+// The app repo root is the parent of this e2e/ directory.
+const APP_ROOT = path.resolve(__dirname, '..');
+
+// Personal JDK layout used across this workspace: ~/jdk/default -> current 
JDK 17 install.
+const JAVA_HOME = process.env.JAVA_HOME ?? path.join(os.homedir(), 'jdk', 
'default');
+
+export default defineConfig({
+  testDir: './tests',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  // Single worker always: all tests share ONE webServer-booted app instance 
(dedicated test port), and hammering
+  // a single dev-mode Tomcat/Spring instance with concurrent first-touch 
requests was observed to intermittently
+  // 500 (likely a lazy-init race in the app, not something this suite should 
paper over with retries). Re-tested
+  // with the unified-paging-ribbon suite (Aug 2026): re-enabling parallelism 
(8 workers) still failed ~2/3 runs
+  // on the very first navigation (`/rest/releases` intermittently 500s before 
the app's lazy init settles).
+  workers: 1,
+  reporter: 'html',
+  timeout: 30_000,
+
+  use: {
+    baseURL: BASE_URL,
+    trace: 'on-first-retry',
+    screenshot: 'only-on-failure',
+  },
+
+  projects: [
+    {
+      name: 'chromium',
+      use: { ...devices['Desktop Chrome'] },
+    },
+  ],
+
+  // Boots the app on its OWN dedicated port (8791), fully independent of the 
coordinator's live instance on
+  // 8790. `spring-boot.run.arguments=--server.port=...` passes a command-line 
argument to the forked app JVM,
+  // which Spring's property-source precedence honors over the 
server.port=8790 baked into
+  // application.properties. Maven dependency resolution can be slow on a cold 
cache, but the app itself starts
+  // in ~1s once the classpath is resolved — 120s covers both. 
`reuseExistingServer: !CI` lets a developer who
+  // already has a local instance running on 8791 skip the (re)boot; it never 
touches 8790 either way.
+  webServer: {
+    command:
+      `mvn -q spring-boot:run 
-Dspring-boot.run.arguments=--server.port=${TEST_PORT}`,
+    url: BASE_URL + '/rest/home',
+    cwd: APP_ROOT,
+    timeout: 120_000,
+    reuseExistingServer: !process.env.CI,
+    env: {
+      ...process.env,
+      JAVA_HOME,
+      PATH: `${path.join(JAVA_HOME, 'bin')}:${process.env.PATH}`,
+    },
+  },
+});
diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts
new file mode 100644
index 0000000000..244096e6f4
--- /dev/null
+++ b/e2e/tests/home.spec.ts
@@ -0,0 +1,14 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Home page', () => {
+  test('boots and renders the workflow heading', async ({ page }) => {
+    const response = await page.goto('/rest/home');
+    expect(response?.status()).toBe(200);
+
+    await expect(page.getByRole('heading', { name: 'Workflow' 
})).toBeVisible();
+    await expect(page.getByRole('heading', { name: 'Prerequisites' 
})).toBeVisible();
+
+    // Home nav tab is marked active on its own page.
+    await expect(page.getByRole('link', { name: 'Home' 
})).toHaveClass(/active/);
+  });
+});
diff --git a/e2e/tests/nav.spec.ts b/e2e/tests/nav.spec.ts
new file mode 100644
index 0000000000..e465188a36
--- /dev/null
+++ b/e2e/tests/nav.spec.ts
@@ -0,0 +1,58 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/**
+ * Each top-nav tab points at a real /rest/* route (base.ftlh) — this table is 
the single source of truth for
+ * which route each tab should land on and what landmark proves the right page 
rendered. Kept independent of the
+ * control-row/ribbon markup (which is in flux) — these are page-level 
landmarks only.
+ */
+const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [
+  { name: 'Home', path: '/rest/home', heading: 'Workflow' },
+  { name: 'Credentials', path: '/rest/credentials', heading: 
/apache|github|gpg/i },
+  { name: 'Releases', path: '/rest/releases', heading: 'All Releases' },
+  // New Release has no single fixed heading — its Input-vs-Execution subtab 
starts on whichever one matches
+  // current run state (state-dependent), so it's asserted separately below 
via the always-present subtab bar.
+  { name: 'New Release', path: '/rest/runs', heading: null },
+  { name: 'Admin', path: '/rest/admin', heading: 'Admin' },
+];
+
+// The Admin page composes its own tab bar reusing the 
"Releases"/"Credentials" labels, so every top-nav lookup
+// is scoped to the persistent header nav (base.ftlh's `nav.jc-nav`) to avoid 
ambiguity there.
+function topNav(page: Page) {
+  return page.locator('nav.jc-nav');
+}
+
+test.describe('Top navigation', () => {
+  for (const tab of NAV_TABS) {
+    test(`"${tab.name}" tab is present and navigates to a working page`, async 
({ page }) => {
+      await page.goto('/rest/home');
+
+      const link = topNav(page).getByRole('link', { name: tab.name, exact: 
true });
+      await expect(link).toBeVisible();
+      await expect(link).toHaveAttribute('href', tab.path);
+
+      const response = await page.goto(tab.path);
+      expect(response?.status()).toBe(200);
+
+      if (tab.name === 'New Release') {
+        // Input/Execution subtabs (role="tab") are always present regardless 
of whether a run is active.
+        await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible();
+        await expect(page.getByRole('tab', { name: 'Execution' 
})).toBeVisible();
+      } else if (tab.heading instanceof RegExp) {
+        // Credentials has no single fixed heading (data-driven content), so 
assert on a resilient text
+        // landmark instead of a specific DOM structure.
+        await expect(page.getByText(tab.heading).first()).toBeVisible();
+      } else if (tab.heading) {
+        await expect(page.getByRole('heading', { name: tab.heading 
})).toBeVisible();
+      }
+    });
+  }
+
+  test('all five nav tabs are present on every page', async ({ page }) => {
+    for (const startTab of NAV_TABS) {
+      await page.goto(startTab.path);
+      for (const tab of NAV_TABS) {
+        await expect(topNav(page).getByRole('link', { name: tab.name, exact: 
true })).toBeVisible();
+      }
+    }
+  });
+});
diff --git a/e2e/tests/releases.spec.ts b/e2e/tests/releases.spec.ts
new file mode 100644
index 0000000000..60947eb463
--- /dev/null
+++ b/e2e/tests/releases.spec.ts
@@ -0,0 +1,190 @@
+import { test, expect, type Locator, type Page } from '@playwright/test';
+
+/**
+ * The Releases tab renders a rich-view toolkit table: `table#releases` / 
`table[data-juneau-view="releases"]`,
+ * hydrated client-side from `/rest/releases/data` by juneau-views.js. Row 
content comes from real `git tag
+ * juneau-*` history in the local apache/juneau checkout (rm.repo.dir), so 
"9.2.0" is expected to always be
+ * present as a released version in this environment.
+ */
+const TABLE_SELECTOR = '#releases, [data-juneau-view="releases"]';
+
+function releasesTable(page: Page): Locator {
+  return page.locator(TABLE_SELECTOR);
+}
+
+function dataRows(page: Page): Locator {
+  // Scope to tbody rows only — the header row (with any per-column search 
inputs) lives in <thead>.
+  return releasesTable(page).locator('tbody tr');
+}
+
+test.describe('Releases table', () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto('/rest/releases');
+    await expect(releasesTable(page)).toBeVisible();
+    // Wait for the real ajax-loaded data (not DataTables' transient 
"loading"/"no data" placeholder row) before
+    // any test measures row counts, so a race against the initial draw can't 
be mistaken for a filter effect.
+    await expect(releasesTable(page).getByText('9.2.0', { exact: true 
}).first()).toBeVisible();
+  });
+
+  test('loads more than zero data rows', async ({ page }) => {
+    await expect(dataRows(page).first()).toBeVisible();
+    const count = await dataRows(page).count();
+    expect(count).toBeGreaterThan(0);
+  });
+
+  test('a known released version appears in the table', async ({ page }) => {
+    await expect(releasesTable(page).getByText('9.2.0', { exact: true 
}).first()).toBeVisible();
+  });
+
+  test('status pills render with expected text', async ({ page }) => {
+    // "tag:status" rendering keeps the raw status value as display text (e.g. 
RELEASED for tag-derived rows).
+    // Other statuses (DRAFT/DISTRIBUTED/FAILED) are state-dependent and not 
asserted here since they depend on
+    // whatever in-progress runs happen to exist locally.
+    await expect(releasesTable(page).locator('.tag.status').filter({ hasText: 
'RELEASED' }).first()).toBeVisible();
+  });
+
+  test('search filters the visible row count down', async ({ page }) => {
+    const initialCount = await dataRows(page).count();
+    expect(initialCount).toBeGreaterThan(0);
+
+    const searchBox = 
page.getByRole('searchbox').or(page.getByPlaceholder(/search/i)).first();
+    await expect(searchBox).toBeVisible();
+    await searchBox.fill('9.0');
+
+    // expect.poll auto-retries until DataTables' redraw settles.
+    await expect
+      .poll(async () => dataRows(page).count(), { message: 'row count should 
drop below the pre-filter count' })
+      .toBeLessThan(initialCount);
+
+    // "9.0" should isolate the 9.0.x releases specifically.
+    await expect(releasesTable(page).getByText('9.0.0', { exact: true 
}).first()).toBeVisible();
+  });
+
+  test.describe('ribbon (right actions cluster — data-testid="ribbon")', () => 
{
+    // Accessible-name-based assertions only — the control row's exact 
grouping/order isn't asserted here,
+    // just that the expected actions exist, are reachable by accessible name, 
and live in the right cluster.
+    function ribbon(page: Page): Locator {
+      return page.getByTestId('ribbon');
+    }
+
+    test('export (copy/csv) and refresh actions are present and enabled', 
async ({ page }) => {
+      const copyBtn = ribbon(page).getByRole('button', { name: /copy/i });
+      const csvBtn = ribbon(page).getByRole('button', { name: /csv/i });
+      const refreshBtn = ribbon(page).getByRole('button', { name: /refresh/i 
});
+
+      await expect(copyBtn).toBeVisible();
+      await expect(copyBtn).toBeEnabled();
+      await expect(csvBtn).toBeVisible();
+      await expect(csvBtn).toBeEnabled();
+      await expect(refreshBtn).toBeVisible();
+      await expect(refreshBtn).toBeEnabled();
+    });
+
+    test('refresh re-draws the table without erroring', async ({ page }) => {
+      const refreshBtn = ribbon(page).getByRole('button', { name: /refresh/i 
});
+      await refreshBtn.click();
+      // The table should still be present and populated after a refresh.
+      await expect(dataRows(page).first()).toBeVisible();
+    });
+  });
+
+  test.describe('paging (unified ribbon — single control, 
data-testid="paging")', () => {
+    // Paging now exists in exactly ONE place: the unified segmented ribbon 
rendered with data-testid="paging"
+    // (juneau-views.js buildPagingPill). Scoping to it is no longer strictly 
required for disambiguation (the
+    // old redundant right-side compact prev/next ribbon is gone), but keeping 
the scope is cheap and future-proof.
+    function paging(page: Page): Locator {
+      return page.getByTestId('paging');
+    }
+
+    test('paging ribbon controls are present by accessible name', async ({ 
page }) => {
+      // aria-labels per juneau-views.js's buildPagingPill: "First 
page"/"Previous page"/"Next page"/"Last page".
+      const pill = paging(page);
+      await expect(pill.getByRole('button', { name: 'First page' 
})).toBeVisible();
+      await expect(pill.getByRole('button', { name: 'Previous page' 
})).toBeVisible();
+      await expect(pill.getByRole('button', { name: 'Next page' 
})).toBeVisible();
+      await expect(pill.getByRole('button', { name: 'Last page' 
})).toBeVisible();
+    });
+
+    test('first/prev/next/last are correctly enabled/disabled at the 
boundaries', async ({ page }) => {
+      const totalRows = await dataRows(page).count();
+      test.skip(totalRows <= 25, 'not enough rows in this environment to 
exercise multi-page paging');
+
+      const pill = paging(page);
+      const firstBtn = pill.getByRole('button', { name: 'First page' });
+      const prevBtn = pill.getByRole('button', { name: 'Previous page' });
+      const nextBtn = pill.getByRole('button', { name: 'Next page' });
+      const lastBtn = pill.getByRole('button', { name: 'Last page' });
+
+      // On page 1: First/Prev disabled, Next/Last enabled (assuming more than 
one page of data).
+      await expect(firstBtn).toBeDisabled();
+      await expect(prevBtn).toBeDisabled();
+      await expect(nextBtn).toBeEnabled();
+      await expect(lastBtn).toBeEnabled();
+
+      await lastBtn.click();
+      // On the last page: First/Prev enabled, Next/Last disabled.
+      await expect(nextBtn).toBeDisabled();
+      await expect(lastBtn).toBeDisabled();
+      await expect(firstBtn).toBeEnabled();
+      await expect(prevBtn).toBeEnabled();
+
+      await firstBtn.click();
+      await expect(firstBtn).toBeDisabled();
+      await expect(prevBtn).toBeDisabled();
+    });
+
+    test('when there are enough rows, paging to the last page changes the 
displayed rows', async ({ page }) => {
+      const totalRows = await dataRows(page).count();
+      // Default page size is 25 rows (juneau-views.js PAGE_SIZE_OPTIONS[0]); 
only meaningful to page if there's
+      // more than one page's worth of data. Skip gracefully otherwise rather 
than asserting a false negative.
+      test.skip(totalRows <= 25, 'not enough rows in this environment to 
exercise multi-page paging');
+
+      const firstRowBefore = await dataRows(page).first().innerText();
+      await paging(page).getByRole('button', { name: 'Last page' }).click();
+      await expect
+        .poll(async () => dataRows(page).first().innerText())
+        .not.toBe(firstRowBefore);
+    });
+
+    test('the range segment doubles as a page-size menu button', async ({ page 
}) => {
+      const menuBtn = paging(page).locator('.juneau-view-pagingpill-menubtn');
+      await expect(menuBtn).toBeVisible();
+      await expect(menuBtn).toHaveAttribute('aria-haspopup', 'listbox');
+      await expect(menuBtn).toHaveAttribute('aria-expanded', 'false');
+
+      await menuBtn.click();
+      await expect(menuBtn).toHaveAttribute('aria-expanded', 'true');
+
+      const menu = paging(page).getByRole('listbox');
+      await expect(menu).toBeVisible();
+      const options = menu.getByRole('option');
+      // "25 rows" / "100 rows" / "All rows", per juneau-views.js 
PAGE_SIZE_OPTIONS.
+      await expect(options).toHaveCount(3);
+
+      // Escape closes the menu and returns focus to the button, without 
changing the page size.
+      await page.keyboard.press('Escape');
+      await expect(menuBtn).toHaveAttribute('aria-expanded', 'false');
+      await expect(menuBtn).toBeFocused();
+    });
+
+    test('picking a larger page size from the menu grows the visible row count 
and updates the range text', async ({ page }) => {
+      const totalRows = await dataRows(page).count();
+      test.skip(totalRows <= 25, 'not enough rows in this environment to 
observe a row-count increase at size 100');
+
+      const menuBtn = paging(page).locator('.juneau-view-pagingpill-menubtn');
+      const initialRangeText = await menuBtn.innerText();
+
+      await menuBtn.click();
+      await paging(page).getByRole('option', { name: '100 rows' }).click();
+
+      // expect.poll auto-retries until DataTables' redraw settles.
+      await expect
+        .poll(async () => dataRows(page).count(), { message: 'row count should 
grow once page size is 100' })
+        .toBeGreaterThan(25);
+      await expect
+        .poll(async () => menuBtn.innerText())
+        .not.toBe(initialRangeText);
+      await expect(menuBtn).toHaveAttribute('aria-expanded', 'false');
+    });
+  });
+});
diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts
new file mode 100644
index 0000000000..862b58fe77
--- /dev/null
+++ b/e2e/tests/smoke.spec.ts
@@ -0,0 +1,52 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/** Collects `console.error`-severity messages for the lifetime of a page 
navigation. */
+function collectConsoleErrors(page: Page): string[] {
+  const errors: string[] = [];
+  page.on('console', (msg) => {
+    if (msg.type() === 'error') errors.push(msg.text());
+  });
+  page.on('pageerror', (err) => errors.push(err.message));
+  return errors;
+}
+
+test.describe('Smoke: primary content renders without console errors', () => {
+  test('New Release page renders its primary content', async ({ page }) => {
+    const errors = collectConsoleErrors(page);
+    const response = await page.goto('/rest/runs');
+    expect(response?.status()).toBe(200);
+
+    // Input/Execution subtabs are always present regardless of whether a run 
is active.
+    await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible();
+    await expect(page.getByRole('tab', { name: 'Execution' })).toBeVisible();
+
+    expect(errors, `console errors on /rest/runs: ${errors.join('; 
')}`).toEqual([]);
+  });
+
+  test('Admin page renders its primary content', async ({ page }) => {
+    const errors = collectConsoleErrors(page);
+    const response = await page.goto('/rest/admin');
+    expect(response?.status()).toBe(200);
+
+    await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
+    // Composed tab bar (PageTable) — leaf tabs for the two dogfooded views. 
Scoped to .jc-tab-bar (the
+    // PageTable-owned tablist) since the top nav also has same-named 
"Releases"/"Credentials" links.
+    const adminTabBar = page.locator('.jc-tab-bar');
+    await expect(adminTabBar.getByText('Releases', { exact: true 
})).toBeVisible();
+    await expect(adminTabBar.getByText('Credentials', { exact: true 
})).toBeVisible();
+
+    expect(errors, `console errors on /rest/admin: ${errors.join('; 
')}`).toEqual([]);
+  });
+
+  test('Credentials page renders its primary content', async ({ page }) => {
+    const errors = collectConsoleErrors(page);
+    const response = await page.goto('/rest/credentials');
+    expect(response?.status()).toBe(200);
+
+    // One card per managed credential (Apache LDAP / GPG / GitHub) — assert 
on the label text rather than a
+    // specific count, since the set of managed credentials could grow.
+    await expect(page.getByText(/GitHub token/i)).toBeVisible();
+
+    expect(errors, `console errors on /rest/credentials: ${errors.join('; 
')}`).toEqual([]);
+  });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000000..a8428a10e7
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "lib": ["ES2022", "DOM"],
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "types": ["node"]
+  },
+  "include": ["playwright.config.ts", "tests/**/*.ts"]
+}
diff --git a/pom.xml b/pom.xml
index 62a0d01259..2cfb48f198 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,6 +61,21 @@
             <artifactId>juneau-rest-server-view-freemarker</artifactId>
             <version>${juneau.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.apache.juneau</groupId>
+            <artifactId>juneau-rest-server-console-ui</artifactId>
+            <version>${juneau.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.juneau</groupId>
+            <artifactId>juneau-rest-server-console-ui-freemarker</artifactId>
+            <version>${juneau.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.juneau</groupId>
+            <artifactId>juneau-rest-server-views</artifactId>
+            <version>${juneau.version}</version>
+        </dependency>
         <dependency>
             <groupId>org.apache.juneau</groupId>
             <artifactId>juneau-secret-keychain</artifactId>
diff --git a/src/main/java/org/apache/juneau/releng/AppConfiguration.java 
b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
index fbbb9a61d0..495b99d0e5 100644
--- a/src/main/java/org/apache/juneau/releng/AppConfiguration.java
+++ b/src/main/java/org/apache/juneau/releng/AppConfiguration.java
@@ -183,6 +183,17 @@ public class AppConfiguration {
                return new ServletRegistrationBean<>(rest, "/rest/*");
        }
 
+       /**
+        * The shared console-ui chrome stylesheet + themeable 
logo/page-background assets, mounted independently of
+        * {@code /rest/*} so the site-absolute {@code /juneau-console/*} URLs 
every tab's {@code base.ftlh} links
+        * against resolve the same way regardless of which tab rendered the 
page. See {@link ConsoleAssetsRest}'s
+        * class Javadoc for why the servlet itself corrects for the 
container's servlet-path handling.
+        */
+       @Bean
+       public ServletRegistrationBean<Servlet> consoleAssetsRegistration() {
+               return new ServletRegistrationBean<>(new ConsoleAssetsRest(), 
"/juneau-console/*");
+       }
+
        // 
==========================================================================================
        // Release-orchestration engine and New Release tab wiring.
        // 
==========================================================================================
diff --git a/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.java 
b/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.java
new file mode 100644
index 0000000000..b8d1746553
--- /dev/null
+++ b/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.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.releng;
+
+import java.io.IOException;
+
+import org.apache.juneau.commons.inject.Bean;
+import org.apache.juneau.rest.server.Rest;
+import org.apache.juneau.rest.server.console.ConsoleChromeMixin;
+import org.apache.juneau.rest.server.servlet.BasicRestServlet;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * Serves the shared console-ui chrome stylesheet and its two themeable assets 
(logo, page-background image) at
+ * the server root, independent of the app's own {@code /rest/*} mount, so the 
{@code <link>} reference in every
+ * tab's {@code base.ftlh} resolves the same way regardless of which tab 
rendered the page.
+ *
+ * <p>
+ * Registered directly (not composed onto {@code HomeRest}/{@code 
ReleaseRest}/etc.) via a dedicated
+ * {@code ServletRegistrationBean} in {@link AppConfiguration} at {@code 
/juneau-console/*} &mdash; see there for
+ * why.
+ *
+ * <h5 class='section'>Servlet-path double-consumption fix:</h5>
+ * <p>
+ * {@code ConsoleChromeMixin}'s {@code @RestGet} paths are absolute-looking 
literals baked into the shipped jar
+ * ({@code ConsoleChromeMixin#CHROME_CSS_PATH} et al., e.g. {@code 
"/juneau-console/chrome.css"}). Juneau's
+ * request dispatch resolves an operation's path against {@code 
getContextPath() + getServletPath()} subtracted
+ * from the request URI &mdash; <b>not</b> against the servlet container's 
{@code getPathInfo()}. Mounting this
+ * servlet at the container url-pattern {@code "/juneau-console/*"} (mirroring 
the app's other
+ * {@code ServletRegistrationBean} mounts, e.g. {@code NexusMockRest} at 
{@code /mock/nexus/*}) makes the
+ * container report {@code servletPath="/juneau-console"}, leaving only {@code 
"/chrome.css"} once that prefix is
+ * subtracted &mdash; one copy of the {@code /juneau-console} segment short of 
what the mixin's hardcoded path
+ * expects, so the real endpoint would only resolve at the doubled
+ * {@code /juneau-console/juneau-console/chrome.css}. {@link #service} 
corrects this at the servlet boundary by
+ * wrapping every request so {@code getServletPath()} reports an empty string, 
which makes Juneau compute the
+ * same path it would if this servlet were mounted at the site root &mdash; 
without actually claiming the site
+ * root (and disrupting the app's other routes: Spring MVC static resources, 
{@code /rest/*},
+ * {@code /mock/nexus/*}, {@code /events/*}), since the container's own {@code 
/juneau-console/*} url-pattern
+ * still gates which requests even reach this servlet.
+ */
+@Rest(mixins=ConsoleChromeMixin.class)
+public class ConsoleAssetsRest extends BasicRestServlet {
+       private static final long serialVersionUID = 1L;
+
+       @Bean
+       public ConsoleChromeMixin consoleChrome() {
+               return ConsoleChromeMixin.create()
+                       .theme(ReleaseManagerTheme.INSTANCE)
+                       .logo("/static/img/oakleaf.svg")
+                       .pageBackgroundImage("/static/img/topo-bg.png")
+                       .build();
+       }
+
+       @Override /* Overridden from HttpServlet */
+       public void service(HttpServletRequest req, HttpServletResponse res) 
throws ServletException, IOException {
+               super.service(new HttpServletRequestWrapper(req) {
+                       @Override public String getServletPath() { return ""; }
+               }, res);
+       }
+}
diff --git a/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java 
b/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java
new file mode 100644
index 0000000000..1bf98b19c7
--- /dev/null
+++ b/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java
@@ -0,0 +1,74 @@
+/*
+ * 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.releng;
+
+import org.apache.juneau.rest.server.console.Theme;
+
+/**
+ * The Release Manager app's console-ui theme.
+ *
+ * <p>
+ * Its token values currently just restate {@link Theme#OPEN}'s own values 
&mdash; the app's existing look already
+ * matches the shipped default. A dedicated class (rather than an inline 
builder call at the mixin-wiring site)
+ * gives the app one obvious, named place to diverge its palette later. The 
logo and page-background image are
+ * deliberately <b>not</b> part of this theme &mdash; those are {@code 
ConsoleChromeMixin} builder inputs (see
+ * {@link ConsoleAssetsRest}), kept out of the token model entirely.
+ */
+public final class ReleaseManagerTheme {
+
+       /** The Release Manager's console-ui theme. */
+       public static final Theme INSTANCE = build();
+
+       private ReleaseManagerTheme() {}
+
+       private static Theme build() {
+               return Theme.create("release-manager")
+                       .token("--jc-font", "'Inter', 'Source Sans 3', 
system-ui, sans-serif")
+                       .token("--jc-page-bg", "linear-gradient(180deg, #b0c4df 
0%, #c7d5e8 22%, #e4eaf2 55%, #f5f6f9 100%)")
+                       .token("--jc-header-icon-color", "#747474")
+                       .token("--jc-accent", "#1589EE")
+                       .token("--jc-accent-wash", "rgba(21,137,238,0.1)")
+                       .token("--jc-link", "#0174d3")
+                       .token("--jc-text", "#080707")
+                       .token("--jc-text-soft", "#080707cc")
+                       .token("--jc-text-muted", "#706e6b")
+                       .token("--jc-border", "#dddbda")
+                       .token("--jc-border-2", "#ced4da")
+                       .token("--jc-card-bg", "#f5f6f9")
+                       .token("--jc-chrome-bg", "#f3f2f2")
+                       .token("--jc-white", "#ffffff")
+                       .token("--jc-btn-primary", "#1a5297")
+                       .token("--jc-btn-primary-hover", "#005fb2")
+                       .token("--jc-danger", "#c23934")
+                       .token("--jc-success", "#2e844a")
+                       .token("--jc-avatar-bg", "linear-gradient(135deg, 
#1589EE, #1a5297)")
+                       .token("--jc-radius", "0.25rem")
+                       .token("--jc-tag-green-bg", "#b8e6c4")
+                       .token("--jc-tag-green-text", "#155724")
+                       .token("--jc-tag-green-border", "#9fd6ad")
+                       .token("--jc-tag-blue-bg", "#dceefb")
+                       .token("--jc-tag-blue-text", "#0c5460")
+                       .token("--jc-tag-blue-border", "#c3e0f3")
+                       .token("--jc-tag-amber-bg", "#fff3cd")
+                       .token("--jc-tag-amber-text", "#856404")
+                       .token("--jc-tag-amber-border", "#ffe69c")
+                       .token("--jc-tag-neutral-bg", "#e2e3e5")
+                       .token("--jc-tag-neutral-text", "#383d41")
+                       .token("--jc-tag-neutral-border", "#c6c8ca")
+                       .build();
+       }
+}
diff --git a/src/main/java/org/apache/juneau/releng/RootRest.java 
b/src/main/java/org/apache/juneau/releng/RootRest.java
index 969f289719..625bd25d28 100644
--- a/src/main/java/org/apache/juneau/releng/RootRest.java
+++ b/src/main/java/org/apache/juneau/releng/RootRest.java
@@ -24,9 +24,10 @@ import org.apache.juneau.releng.rest.ReleaseRest;
 import org.apache.juneau.releng.rest.MilestoneRest;
 import org.apache.juneau.releng.rest.CredentialRest;
 import org.apache.juneau.releng.rest.ReleaseRunRest;
+import org.apache.juneau.releng.rest.AdminRest;
 
 @Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = 
{ HomeRest.class, ReleaseRest.class,
-               MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class 
})
+               MilestoneRest.class, CredentialRest.class, 
ReleaseRunRest.class, AdminRest.class })
 @SuppressWarnings({ "java:S110" // Inheritance depth is imposed by the Juneau 
REST servlet hierarchy.
 })
 public class RootRest extends BasicSpringRestServletGroup {
diff --git a/src/main/java/org/apache/juneau/releng/rest/AdminRest.java 
b/src/main/java/org/apache/juneau/releng/rest/AdminRest.java
new file mode 100644
index 0000000000..6b0c439c69
--- /dev/null
+++ b/src/main/java/org/apache/juneau/releng/rest/AdminRest.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.releng.rest;
+
+import org.apache.juneau.commons.inject.Bean;
+import org.apache.juneau.marshall.html.HtmlSerializer;
+import org.apache.juneau.rest.server.Rest;
+import org.apache.juneau.rest.server.RestGet;
+import org.apache.juneau.rest.server.servlet.BasicRestResource;
+import org.apache.juneau.rest.server.view.View;
+import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
+import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
+import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
+import 
org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
+import org.apache.juneau.rest.server.views.PageDef;
+import org.apache.juneau.rest.server.views.PageTable;
+import org.apache.juneau.rest.server.views.Tab;
+import org.apache.juneau.rest.server.views.ViewsMixin;
+
+/**
+ * Admin tab (TODO-399 Phase C dogfood): a single multi-tab page composing the 
app's existing
+ * {@link ReleaseRest#releasesView() Releases} and {@link 
CredentialRest#credentialsView() Credentials} rich views
+ * into one {@link PageDef}, rendered by {@link PageTable}.
+ *
+ * <p>
+ * This resource references each tab's child {@link 
org.apache.juneau.rest.server.views.ViewDef ViewDef} by calling
+ * the sibling resources' declarative view-builder methods directly &mdash; it 
does not duplicate their column/ribbon
+ * definitions, and each child view's {@code dataUrl} stays absolute (its 
owning resource's own mount), so the
+ * ajax data draws still hit {@link ReleaseRest#data()} / {@link 
CredentialRest#status()} exactly as they do from
+ * the standalone Releases/Credentials pages. Per {@link PageTable}'s 
contract, the emitted per-view markup (marker
+ * table + VIEW_META sidecar) is byte-for-byte identical to what {@link 
ReleaseRest#page()} /
+ * {@code CredentialRest}'s own view would emit standalone &mdash; this 
resource only adds the tab-bar/panel shell
+ * and the PAGE_META sidecar around them.
+ */
+@Rest(path = "/admin", title = "Admin", responseProcessors = 
FreemarkerViewRenderer.class, mixins = ViewsMixin.class)
+public class AdminRest extends BasicRestResource {
+
+       /** This resource's absolute mount (RootRest {@code /rest/*} + {@code 
/admin}), used to resolve asset URLs. */
+       static final String MOUNT = "/rest/admin";
+
+       // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an 
exact-type bean lookup (see
+       // ConsoleFreemarkerMixin's class Javadoc).
+       @Bean
+       public FreemarkerMixin freemarker() {
+               return 
ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+       }
+
+       /**
+        * The composed page definition: one leaf tab per existing rich view. 
{@code build()} validates unique tab ids
+        * and unique referenced {@code ViewDef} ids across the page (TODO-399 
Phase C {@code PageDef} validation
+        * rules).
+        */
+       static PageDef adminPage() {
+               return PageDef.create("admin")
+                       .title("Admin")
+                       .tabs(
+                               Tab.create("releases", 
"Releases").view(ReleaseRest.releasesView()),
+                               Tab.create("credentials", 
"Credentials").view(CredentialRest.credentialsView()))
+                       .build();
+       }
+
+       /** Human page &mdash; the composed tab/sub-tab page shell (emitted as 
trusted markup) + PAGE_META sidecar. */
+       @RestGet("/")
+       public View page() {
+               var markup = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(adminPage()));
+               return FreemarkerView.of("admin")
+                       .attr("pageTable", markup)
+                       .attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH))
+                       .attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH))
+                       .attr("iconsJsUrl", asset(ViewsMixin.ICONS_JS_PATH))
+                       .attr("ribbonJsUrl", asset(ViewsMixin.RIBBON_JS_PATH))
+                       .attr("viewsJsUrl", asset(ViewsMixin.VIEWS_JS_PATH))
+                       .attr("pagesJsUrl", asset(ViewsMixin.PAGES_JS_PATH));
+       }
+
+       /**
+        * Resolves a toolkit asset to an absolute, cache-busted URL for the 
FreeMarker head block, mirroring
+        * {@code ReleaseRest#asset(String)}.
+        */
+       private static String asset(String path) {
+               return ViewsMixin.viewAssetUrl(path).replace("servlet:", MOUNT);
+       }
+}
diff --git a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java 
b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
index fa3b625b6c..848c546d20 100644
--- a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java
@@ -30,6 +30,12 @@ import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
+import 
org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
+import org.apache.juneau.rest.server.views.Column;
+import org.apache.juneau.rest.server.views.RibbonAction;
+import org.apache.juneau.rest.server.views.ViewDef;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.apache.juneau.rest.server.views.ViewDef.Dir;
 import org.apache.juneau.releng.credential.CredentialService;
 import org.apache.juneau.releng.credential.CredentialStatus;
 import org.apache.juneau.releng.credential.Validator.ValidationResult;
@@ -38,15 +44,43 @@ import 
org.apache.juneau.releng.credential.Validator.ValidationResult;
 @Rest(path = "/credentials", title = "Credentials", responseProcessors = 
FreemarkerViewRenderer.class)
 public class CredentialRest extends BasicRestResource {
 
+       /** This resource's absolute mount (RootRest {@code /rest/*} + {@code 
/credentials}), used by {@link #credentialsView()}. */
+       static final String MOUNT = "/rest/credentials";
+
        private final CredentialService service;
 
        public CredentialRest(CredentialService service) {
                this.service = service;
        }
 
+       /**
+        * The rich-view toolkit's declarative view of the Credentials list 
(TODO-399 Phase C dogfood): a second,
+        * independently-composable {@link ViewDef} alongside {@link 
ReleaseRest#releasesView()}, wired into the RM
+        * {@code Admin} tab page ({@code AdminRest}). Client-side data mode: 
{@link #status()} already returns the
+        * bare {@code List<CredentialStatus>} the toolkit's client-mode ajax 
(({@code dataSrc: ""})) expects, so no new
+        * server-side query wiring is needed.
+        */
+       static ViewDef credentialsView() {
+               return ViewDef.create("credentials")
+                       .rowType(CredentialStatus.class)
+                       .dataMode(DataMode.CLIENT)
+                       .dataUrl(MOUNT + "/status")
+                       .defaultOrder("name", Dir.ASC)
+                       .columns(
+                               Column.of("name").title("Name"),
+                               Column.of("label").title("Label"),
+                               Column.of("present").title("Present"),
+                               Column.of("lastValid").title("Valid"),
+                               Column.of("lastMessage").title("Message"))
+                       .ribbon(RibbonAction.refresh())
+                       .build();
+       }
+
+       // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an 
exact-type bean lookup (see
+       // ConsoleFreemarkerMixin's class Javadoc).
        @Bean
        public FreemarkerMixin freemarker() {
-               return 
FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+               return 
ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
        }
 
        /** Human page. */
diff --git a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java 
b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
index 13eb2b32ba..eb6c115be6 100644
--- a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java
@@ -25,14 +25,17 @@ import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
+import 
org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 
 /** Home tab: default landing page with basic usage instructions. */
 @Rest(path = "/home", title = "Home", responseProcessors = 
FreemarkerViewRenderer.class)
 public class HomeRest extends BasicRestResource {
 
+       // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an 
exact-type bean lookup (see
+       // ConsoleFreemarkerMixin's class Javadoc).
        @Bean
        public FreemarkerMixin freemarker() {
-               return 
FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+               return 
ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
        }
 
        /** Human page. */
diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java 
b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
index ccc0fb8ebb..6b8879d7e8 100644
--- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java
@@ -21,39 +21,129 @@ import java.util.List;
 import org.apache.juneau.commons.inject.Bean;
 import org.apache.juneau.http.Path;
 import org.apache.juneau.http.response.NotFound;
+import org.apache.juneau.marshall.html.HtmlSerializer;
 import org.apache.juneau.rest.server.Rest;
 import org.apache.juneau.rest.server.RestGet;
+import org.apache.juneau.rest.server.converter.ProtocolQueryable;
+import org.apache.juneau.rest.server.converter.QueryableSettings;
 import org.apache.juneau.rest.server.servlet.BasicRestResource;
 import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
+import 
org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
+import org.apache.juneau.rest.server.views.Column;
+import org.apache.juneau.rest.server.views.RibbonAction;
+import org.apache.juneau.rest.server.views.ViewDef;
+import org.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.apache.juneau.rest.server.views.ViewDef.Dir;
+import org.apache.juneau.rest.server.views.ViewTable;
+import org.apache.juneau.rest.server.views.ViewsMixin;
 import org.apache.juneau.releng.release.Release;
 import org.apache.juneau.releng.release.ReleaseListService;
 
-/** Releases tab: server-rendered HTML page + JSON data endpoint. */
-@Rest(path = "/releases", title = "Releases", responseProcessors = 
FreemarkerViewRenderer.class)
+/**
+ * Releases tab: server-rendered HTML page + DataTables server-side-processing 
data endpoint.
+ *
+ * <p>
+ * Built on the {@code juneau-rest-server-views} rich-view toolkit: {@link 
#releasesView()} declares the typed
+ * {@link ViewDef} (columns + renderers + ribbon), {@link #page()} emits its 
{@link ViewTable} shell as trusted markup
+ * into the FreeMarker template, and {@link #data()} serves the {@code 
DataTablesResults} envelope via
+ * {@link ProtocolQueryable} + the view's {@link ViewDef#queryableSettings() 
queryable settings}. The four runtime
+ * assets are served by the composed {@link ViewsMixin} at this resource's 
mount.
+ */
+@Rest(path = "/releases", title = "Releases", responseProcessors = 
FreemarkerViewRenderer.class, mixins = ViewsMixin.class)
 public class ReleaseRest extends BasicRestResource {
 
+       /** This resource's absolute mount (RootRest {@code /rest/*} + {@code 
/releases}), used to resolve asset/data URLs. */
+       static final String MOUNT = "/rest/releases";
+
        private final ReleaseListService service;
 
        public ReleaseRest(ReleaseListService service) {
                this.service = service;
        }
 
+       // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an 
exact-type bean lookup (see
+       // ConsoleFreemarkerMixin's class Javadoc).
        @Bean
        public FreemarkerMixin freemarker() {
-               return 
FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+               return 
ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+       }
+
+       /**
+        * The DataTables server-side-processing settings for {@link #data()}, 
sourced from the view definition so the
+        * protocol's positional column resolution stays in sync with the 
declared columns.
+        */
+       @Bean
+       public QueryableSettings queryableSettings() {
+               return releasesView().queryableSettings();
+       }
+
+       /**
+        * The typed rich-view definition for the Releases tab (server-side 
mode): a linked Version column, {@code tag}-
+        * rendered Status/Stage pills (emitting the shared {@code 
.tag.<domain>.<value>} classes the app's console-ui
+        * palette themes), timestamp/date columns, and a copy/csv export + 
column-search + status quick-filter + refresh
+        * ribbon. Data arrives via ajax draws against {@link #data()}. Static 
(no instance state) so {@code AdminRest}
+        * can reuse this same declarative definition when composing the {@code 
Admin} tab page (TODO-399 Phase C
+        * dogfood).
+        */
+       static ViewDef releasesView() {
+               return ViewDef.create("releases")
+                       .rowType(Release.class)
+                       .dataMode(DataMode.SERVER)
+                       .dataUrl(MOUNT + "/data")
+                       .defaultOrder("version", Dir.DESC)
+                       .columns(
+                               
Column.of("version").title("Version").render("linked").href(MOUNT + 
"/{version}/1"),
+                               Column.of("rc").title("RC"),
+                               
Column.of("status").title("Status").render("tag:status"),
+                               
Column.of("stage").title("Stage").render("tag:stage"),
+                               Column.of("voteCloses").title("Vote 
closes").render("ts-zulu"),
+                               
Column.of("released").title("Released").render("date"),
+                               
Column.of("githubReleaseUrl").title("GitHub").render("linked").href("{githubReleaseUrl}").orderable(false),
+                               
Column.of("milestoneUrl").title("Milestone").render("linked").href("{milestoneUrl}").orderable(false))
+                       .ribbon(
+                               // "filters" clusters the column-search toggle 
and the dropped-only quick-filter into one
+                               // segmented ribbon group (visual-parity 
control-row layout: filter-ribbon); "export" actions are
+                               // clustered into their own group automatically 
(one action, one visual cluster - see juneau-
+                               // ribbon.js's buildRibbon).
+                               
RibbonAction.columnSearchToggle().group("filters"),
+                               
RibbonAction.option("dropped-only").title("Dropped 
only").column("status").value("DROPPED").persist(true)
+                                       .symbol("filter_alt").group("filters"),
+                               RibbonAction.export("copy", 
"csv").optional("excel", "pdf"),
+                               RibbonAction.refresh())
+                       .build();
        }
 
-       /** Human page — server-rendered releases table, enhanced by 
DataTables. */
+       /** Human page — the rich-view table shell (emitted as trusted markup) 
+ JSON sidecar, hydrated by the toolkit JS. */
        @RestGet("/")
        public View page() {
-               return FreemarkerView.of("releases").attr("releases", 
service.list());
+               var markup = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(releasesView()));
+               return FreemarkerView.of("releases")
+                       .attr("viewTable", markup)
+                       .attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH))
+                       .attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH))
+                       .attr("iconsJsUrl", asset(ViewsMixin.ICONS_JS_PATH))
+                       .attr("ribbonJsUrl", asset(ViewsMixin.RIBBON_JS_PATH))
+                       .attr("viewsJsUrl", asset(ViewsMixin.VIEWS_JS_PATH));
        }
 
-       /** Machine endpoint — bare JSON array for curl/CLI/DataTables ajax. */
-       @RestGet("/data")
+       /**
+        * Resolves a toolkit asset to an absolute, cache-busted URL for the 
FreeMarker head block. {@link ViewsMixin}'s
+        * {@code servlet:}-relative form is rewritten to this resource's 
absolute mount because the FreeMarker template
+        * is rendered outside Juneau's {@code HtmlDoc} URL-resolution (which 
would otherwise resolve {@code servlet:}).
+        */
+       private static String asset(String path) {
+               return ViewsMixin.viewAssetUrl(path).replace("servlet:", MOUNT);
+       }
+
+       /**
+        * Machine endpoint — the DataTables server-side-processing envelope 
({@code {draw, recordsTotal, recordsFiltered,
+        * data}}). The method returns the row {@code List}; {@link 
ProtocolQueryable} parses the DataTables request, runs
+        * the shared query engine (search/sort/paginate), and wraps the page 
in a {@code DataTablesResults} envelope.
+        */
+       @RestGet(path = "/data", converters = ProtocolQueryable.class)
        public List<Release> data() {
                return service.list();
        }
diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java 
b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
index eeecdb002c..fd5deb6a12 100644
--- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
+++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java
@@ -31,6 +31,7 @@ import org.apache.juneau.rest.server.view.View;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerView;
 import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer;
+import 
org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin;
 import org.apache.juneau.releng.engine.DropRcService;
 import org.apache.juneau.releng.engine.ExecutionMode;
 import org.apache.juneau.releng.engine.Preview;
@@ -50,9 +51,11 @@ public class ReleaseRunRest extends BasicRestResource {
                this.dropRc = dropRc;
        }
 
+       // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an 
exact-type bean lookup (see
+       // ConsoleFreemarkerMixin's class Javadoc).
        @Bean
        public FreemarkerMixin freemarker() {
-               return 
FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
+               return 
ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build();
        }
 
        /** Human page — the pipeline control panel for the active run (or an 
empty start form). */
diff --git a/src/main/resources/static/css/chrome.css 
b/src/main/resources/static/css/chrome.css
index 0435d3d012..3678e78a81 100644
--- a/src/main/resources/static/css/chrome.css
+++ b/src/main/resources/static/css/chrome.css
@@ -16,301 +16,20 @@
  */
 
 /* ==========================================================================
-   Apache Juneau · Release Manager — shared "chrome" stylesheet
+   Apache Juneau · Release Manager — app-specific chrome extensions
    ==========================================================================
 
    PURPOSE
-   This file defines the admin-console app shell (header, primary nav, card,
-   DataTable, tag/pill system) using only open, redistributable assets. It is
-   designed to evolve into the *real* shared chrome CSS for the Release
-   Manager UI.
-
-   SWAPPABLE THEME TOKENS
-   All brand-specific bits are expressed as CSS custom properties on :root
-   so a sibling app can re-skin the exact same markup/CSS by overriding a
-   small token block — no other rule in this file should need to change.
-
-   Default theme = "Apache" (this file, below) — open assets only.
+   The shared admin-console shell (header, nav, card, button, tag-base rules)
+   is now served by the shipped juneau-rest-server-console-ui chrome.css at
+   /juneau-console/chrome.css (see base.ftlh's <head> link). This file holds
+   only what's genuinely app-specific and has no console-ui equivalent:
+   DataTables-chrome integration, credential cards, the release-detail list
+   grid, two app-only tag values, and Home-tab content styling. Every
+   var(--jc-*) reference below resolves against the tokens the shipped chrome
+   response appends (Theme.OPEN, overridden by ReleaseManagerTheme).
    ========================================================================== 
*/
 
-:root {
-  /* ---- Swappable brand tokens (override per-app) ---- */
-  --rm-font: 'Inter', 'Source Sans 3', system-ui, -apple-system, sans-serif;
-  --rm-logo: url("/img/oakleaf.svg");
-  --rm-logo-size: 36px 48px;
-  --rm-page-bg: url("/img/topo-bg.png") center top / cover no-repeat fixed, 
linear-gradient(180deg, #b0c4df 0%, #c7d5e8 22%, #e4eaf2 55%, #f5f6f9 100%);
-  --rm-header-icon-color: #747474;
-
-  /* ---- Brand accent ---- */
-  --rm-accent: #1589EE;
-  --rm-accent-2: #1589ed;
-  --rm-accent-wash: #1589ee1a;
-
-  /* ---- Text ---- */
-  --rm-link: #0174d3;
-  --rm-text: #080707;
-  --rm-text-soft: #080707cc;
-  --rm-text-muted: #706e6b;
-  --rm-text-muted-2: #3e3e3c;
-
-  /* ---- Borders / surfaces ---- */
-  --rm-border: #dddbda;
-  --rm-border-2: #ced4da;
-  --rm-card-bg: #f5f6f9;
-  --rm-chrome-bg: #f3f2f2;
-  --rm-white: #ffffff;
-
-  /* ---- Buttons / status ---- */
-  --rm-btn-primary: #1a5297;
-  --rm-btn-primary-hover: #005fb2;
-  --rm-danger: #c23934;
-  --rm-success: #2e844a;
-
-  /* ---- Tag / pill palette ---- */
-  --rm-tag-green-bg: #b8e6c4;
-  --rm-tag-green-text: #155724;
-  --rm-tag-green-border: #9fd6ad;
-
-  --rm-tag-blue-bg: #dceefb;
-  --rm-tag-blue-text: #0c5460;
-  --rm-tag-blue-border: #c3e0f3;
-
-  --rm-tag-amber-bg: #fff3cd;
-  --rm-tag-amber-text: #856404;
-  --rm-tag-amber-border: #ffe69c;
-
-  --rm-tag-neutral-bg: #e2e3e5;
-  --rm-tag-neutral-text: #383d41;
-  --rm-tag-neutral-border: #c6c8ca;
-
-  --rm-radius: 0.25rem;
-}
-
-/* ==========================================================================
-   Base / page
-   ========================================================================== 
*/
-
-* { box-sizing: border-box; }
-
-html, body {
-  margin: 0;
-  padding: 0;
-  font-family: var(--rm-font);
-  color: var(--rm-text);
-  background: var(--rm-page-bg);
-  min-height: 100vh;
-}
-
-body {
-  display: flex;
-  flex-direction: column;
-}
-
-a { color: var(--rm-link); text-decoration: none; }
-a:hover { text-decoration: underline; }
-
-/* ==========================================================================
-   Header
-   ========================================================================== 
*/
-
-.rm-header {
-  display: flex;
-  align-items: center;
-  height: 68px;
-  background: var(--rm-white);
-  position: sticky;
-  top: 0;
-  z-index: 20;
-}
-
-.rm-brand {
-  display: flex;
-  align-items: center;
-  flex: 1 1 auto;
-  min-width: 0;
-}
-
-.rm-brand-mark {
-  width: 48px;
-  height: 48px;
-  margin: 10px 20px;
-  flex: 0 0 auto;
-  border-radius: var(--rm-radius);
-  background-color: var(--rm-white);
-  background-image: var(--rm-logo);
-  background-repeat: no-repeat;
-  background-position: center;
-  background-size: var(--rm-logo-size);
-}
-
-.rm-brand-title {
-  font-size: 1.125rem;
-  font-weight: 600;
-  color: var(--rm-text-soft);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.rm-brand-title .rm-brand-sep {
-  margin: 0 6px;
-  color: var(--rm-text-muted);
-  font-weight: 400;
-}
-
-.rm-header-actions {
-  display: flex;
-  align-items: center;
-  gap: 4px;
-  padding-right: 16px;
-  flex: 0 0 auto;
-}
-
-.rm-icon-btn {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 32px;
-  height: 32px;
-  border-radius: 50%;
-  color: var(--rm-header-icon-color);
-  background: transparent;
-  border: none;
-  cursor: pointer;
-}
-
-.rm-icon-btn:hover { background: var(--rm-chrome-bg); }
-
-.rm-icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
-
-.rm-avatar {
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  background: linear-gradient(135deg, var(--rm-accent), var(--rm-btn-primary));
-  color: var(--rm-white);
-  font-size: 0.75rem;
-  font-weight: 700;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-left: 8px;
-}
-
-/* ==========================================================================
-   Primary navigation
-   ========================================================================== 
*/
-
-.nav {
-  display: flex;
-  align-items: stretch;
-  background: var(--rm-white);
-  border-bottom: 3px solid var(--rm-accent);
-  padding: 0 16px;
-  position: sticky;
-  top: 68px;
-  z-index: 19;
-}
-
-.nav-tab {
-  display: flex;
-  align-items: center;
-  font-size: 0.8125rem;
-  font-weight: 600;
-  color: var(--rm-text-soft);
-  padding: 8px 10px;
-  margin-right: 2px;
-  border-top: 3px solid transparent;
-  border-bottom: none;
-  cursor: pointer;
-  transition: background-color 0.1s ease-in;
-}
-
-.nav-tab:hover {
-  background: var(--rm-accent-wash);
-  text-decoration: none;
-}
-
-.nav-tab.active {
-  background: var(--rm-accent-wash);
-  border-top-color: var(--rm-accent);
-  color: var(--rm-text);
-}
-
-/* ==========================================================================
-   Page scaffolding
-   ========================================================================== 
*/
-
-.rm-main {
-  flex: 1 1 auto;
-  padding: 20px 24px 40px;
-}
-
-.rm-page-header {
-  display: flex;
-  align-items: baseline;
-  justify-content: space-between;
-  max-width: 1180px;
-  margin: 0 auto 14px;
-  flex-wrap: wrap;
-  gap: 10px;
-}
-
-.rm-page-header h1 {
-  font-size: 1.375rem;
-  font-weight: 700;
-  color: var(--rm-text);
-  margin: 0;
-}
-
-.rm-page-header .rm-page-sub {
-  font-size: 0.8125rem;
-  color: var(--rm-text-muted);
-  margin-top: 2px;
-}
-
-.rm-breadcrumb {
-  font-size: 0.75rem;
-  color: var(--rm-text-muted);
-  margin-bottom: 4px;
-}
-
-.rm-breadcrumb a { color: var(--rm-text-muted); }
-.rm-breadcrumb .sep { margin: 0 6px; }
-
-/* ==========================================================================
-   Card
-   ========================================================================== 
*/
-
-.rm-card {
-  max-width: 1180px;
-  margin: 0 auto;
-  background: var(--rm-card-bg);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
-  padding: 18px 20px 8px;
-}
-
-.rm-card-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 12px;
-  flex-wrap: wrap;
-  gap: 10px;
-}
-
-.rm-card-header h2 {
-  font-size: 1rem;
-  font-weight: 700;
-  color: var(--rm-text);
-  margin: 0;
-}
-
-.rm-card-header a {
-  font-size: 0.8125rem;
-}
-
 /* ==========================================================================
    Detail list (release detail page)
    ========================================================================== 
*/
@@ -325,101 +44,40 @@ a:hover { text-decoration: underline; }
 
 .rm-detail-list dt {
   font-weight: 600;
-  color: var(--rm-text-soft);
+  color: var(--jc-text-soft);
 }
 
 .rm-detail-list dd {
   margin: 0;
 }
 
+/* Release Manager app's own "stage" vocabulary (Release.stage — not part of
+   the mockup's controlled vocabulary above, but slotted into the same
+   green/blue/neutral domains for visual consistency). */
+.tag.stage.distributed { background: var(--jc-tag-green-bg); color: 
var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); }
+.tag.stage.awaiting-vote { background: var(--jc-tag-blue-bg); color: 
var(--jc-tag-blue-text); border-color: var(--jc-tag-blue-border); }
+
 /* ==========================================================================
-   Buttons
+   Buttons (app-specific variants not shipped by console-ui's chrome.css,
+   which only defines .jc-btn/-primary/-outline/-danger/-success)
    ========================================================================== 
*/
 
-.btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  font-family: var(--rm-font);
-  font-size: 0.8125rem;
-  font-weight: 600;
-  padding: 7px 14px;
-  border-radius: var(--rm-radius);
-  border: 1px solid transparent;
-  cursor: pointer;
-  line-height: 1.2;
-}
-
-.btn-primary {
-  background: var(--rm-btn-primary);
-  color: var(--rm-white);
-}
-.btn-primary:hover { background: var(--rm-btn-primary-hover); }
-
-.btn-outline {
-  background: var(--rm-white);
-  color: var(--rm-text-soft);
-  border-color: var(--rm-border-2);
+/* A deliberately cautionary amber action, distinct from the primary/danger
+   variants (e.g. "Arm this run" for LIVE mutation). Layers on the shipped
+   .jc-btn base the same way .jc-btn-danger/-success do. */
+.jc-btn-warning {
+  background-color: var(--jc-tag-amber-bg);
+  color: var(--jc-tag-amber-text);
+  border-color: var(--jc-tag-amber-border);
 }
-.btn-outline:hover { background: var(--rm-chrome-bg); }
+.jc-btn-warning:hover { filter: brightness(0.97); }
 
-.btn-danger {
-  background: var(--rm-white);
-  color: var(--rm-danger);
-  border-color: var(--rm-border-2);
+/* Compact button modifier for dense action rows (e.g. the New-Release rail's
+   per-step Dry-run/Run/Resume buttons). */
+.jc-btn-sm {
+  font-size: 0.75rem;
+  padding: 5px 10px;
 }
-.btn-danger:hover { background: #fdeceb; }
-
-/* ==========================================================================
-   Tag / pill system
-   ========================================================================== 
*/
-
-.tag {
-  display: inline-block;
-  padding: 1px 6px;
-  border-radius: 3px;
-  font-size: 10px;
-  font-weight: 700;
-  letter-spacing: 0.5px;
-  text-transform: uppercase;
-  border: 1px solid transparent;
-  white-space: nowrap;
-  line-height: 1.5;
-  /* Neutral default so any value outside the known domain vocabulary below
-     (e.g. free-text release "stage" values this app has beyond the mockup's
-     controlled vocabulary) still renders as a filled pill. */
-  background: var(--rm-tag-neutral-bg);
-  color: var(--rm-tag-neutral-text);
-  border-color: var(--rm-tag-neutral-border);
-}
-
-/* domain: status */
-.tag.status.released,
-.tag.stage.released { background: var(--rm-tag-green-bg); color: 
var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); }
-
-.tag.status.voting,
-.tag.status.rc,
-.tag.stage.voting,
-.tag.stage.rc { background: var(--rm-tag-blue-bg); color: 
var(--rm-tag-blue-text); border-color: var(--rm-tag-blue-border); }
-
-.tag.status.draft,
-.tag.status.pending,
-.tag.stage.draft,
-.tag.stage.pending,
-.tag.stage.building { background: var(--rm-tag-amber-bg); color: 
var(--rm-tag-amber-text); border-color: var(--rm-tag-amber-border); }
-
-.tag.status.dropped,
-.tag.status.unknown,
-.tag.stage.dropped,
-.tag.stage.unknown,
-.tag.stage.archived,
-.tag.stage.cancelled { background: var(--rm-tag-neutral-bg); color: 
var(--rm-tag-neutral-text); border-color: var(--rm-tag-neutral-border); }
-
-/* Release Manager app's own "stage" vocabulary (Release.stage — not part of
-   the mockup's controlled vocabulary above, but slotted into the same
-   green/blue/neutral domains for visual consistency). */
-.tag.stage.distributed { background: var(--rm-tag-green-bg); color: 
var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); }
-.tag.stage.awaiting-vote { background: var(--rm-tag-blue-bg); color: 
var(--rm-tag-blue-text); border-color: var(--rm-tag-blue-border); }
 
 /* ==========================================================================
    Data table (DataTables integration)
@@ -430,7 +88,7 @@ a:hover { text-decoration: underline; }
   align-items: center;
   justify-content: space-between;
   font-size: 0.8125rem;
-  color: var(--rm-text-muted-2);
+  color: #3e3e3c;
   padding: 6px 2px 10px;
   flex-wrap: wrap;
   gap: 10px;
@@ -445,13 +103,13 @@ a:hover { text-decoration: underline; }
 
 .rm-table-length select,
 .rm-table-search input {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 4px 8px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
 }
 
 .rm-table-search input { width: 200px; }
@@ -467,9 +125,9 @@ table.rm-table {
   width: 100%;
   min-width: 720px;
   border-collapse: collapse;
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
   overflow: hidden;
 }
 
@@ -478,11 +136,11 @@ table.rm-table thead th {
   font-weight: 700;
   letter-spacing: 0.3px;
   text-transform: uppercase;
-  color: var(--rm-text-muted-2);
+  color: #3e3e3c;
   text-align: left;
   padding: 10px 12px;
-  border-bottom: 2px solid var(--rm-border);
-  background: var(--rm-chrome-bg);
+  border-bottom: 2px solid var(--jc-border);
+  background: var(--jc-chrome-bg);
   white-space: nowrap;
 }
 
@@ -490,27 +148,27 @@ table.rm-table thead th.sortable { cursor: pointer; 
position: relative; }
 table.rm-table thead th.sortable::after {
   content: '\2195';
   margin-left: 5px;
-  color: var(--rm-border-2);
+  color: var(--jc-border-2);
   font-weight: 400;
 }
 
 table.rm-table tbody td {
   font-size: 0.8125rem;
-  color: var(--rm-text);
+  color: var(--jc-text);
   padding: 9px 12px;
-  border-bottom: 1px solid var(--rm-border);
+  border-bottom: 1px solid var(--jc-border);
   vertical-align: middle;
 }
 
 table.rm-table tbody tr:nth-child(odd) { background: #fbfbfc; }
-table.rm-table tbody tr:hover { background: var(--rm-accent-wash); }
+table.rm-table tbody tr:hover { background: var(--jc-accent-wash); }
 table.rm-table tbody tr:last-child td { border-bottom: none; }
 
 td.details-control {
   width: 28px;
   text-align: center;
   cursor: pointer;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
 }
 
 td.details-control .chevron {
@@ -518,8 +176,8 @@ td.details-control .chevron {
   width: 16px;
   height: 16px;
   border-radius: 50%;
-  border: 1px solid var(--rm-border-2);
-  color: var(--rm-text-muted-2);
+  border: 1px solid var(--jc-border-2);
+  color: #3e3e3c;
   font-size: 10px;
   line-height: 15px;
   text-align: center;
@@ -529,9 +187,9 @@ td.details-control .chevron {
 tr.row-expanded td.details-control .chevron { transform: rotate(90deg); }
 
 tr.detail-row td {
-  background: var(--rm-card-bg);
+  background: var(--jc-card-bg);
   padding: 14px 18px;
-  border-bottom: 1px solid var(--rm-border);
+  border-bottom: 1px solid var(--jc-border);
 }
 
 tr.detail-row .detail-grid {
@@ -545,42 +203,31 @@ tr.detail-row dt {
   font-size: 0.6875rem;
   text-transform: uppercase;
   letter-spacing: 0.3px;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin: 0 0 2px;
 }
 
 tr.detail-row dd {
   margin: 0;
-  color: var(--rm-text);
+  color: var(--jc-text);
 }
 
 .rm-table-info {
   font-size: 0.75rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   padding: 10px 2px 4px;
 }
 
 .version-cell {
   font-weight: 700;
-  color: var(--rm-text);
+  color: var(--jc-text);
 }
 
-.rc-cell { color: var(--rm-text-muted-2); }
+.rc-cell { color: #3e3e3c; }
 
 .links-cell a { margin-right: 10px; font-size: 0.8125rem; }
 .links-cell a:last-child { margin-right: 0; }
 
-/* ==========================================================================
-   Footer
-   ========================================================================== 
*/
-
-.rm-footer {
-  text-align: center;
-  font-size: 0.75rem;
-  color: var(--rm-text-muted);
-  padding: 24px 0 32px;
-}
-
 /* ==========================================================================
    DataTables integration (Release Manager app only — not part of the mockup)
    ==========================================================================
@@ -597,8 +244,8 @@ tr.detail-row dd {
    ========================================================================== 
*/
 
 div.dt-container {
-  font-family: var(--rm-font);
-  color: var(--rm-text-muted-2);
+  font-family: var(--jc-font);
+  color: #3e3e3c;
   font-size: 0.8125rem;
 }
 
@@ -606,7 +253,7 @@ div.dt-container .dt-length,
 div.dt-container .dt-search,
 div.dt-container .dt-info,
 div.dt-container .dt-paging {
-  color: var(--rm-text-muted-2);
+  color: #3e3e3c;
   padding: 6px 2px;
 }
 
@@ -614,20 +261,20 @@ div.dt-container .dt-search input,
 div.dt-container .dt-length select,
 div.dt-container select.dt-input,
 div.dt-container input.dt-input {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 4px 8px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
 }
 
 div.dt-container .dt-search input { width: 200px; }
 
 table.dataTable.rm-table thead > tr > th {
-  background: var(--rm-chrome-bg);
-  color: var(--rm-text-muted-2);
+  background: var(--jc-chrome-bg);
+  color: #3e3e3c;
 }
 
 table.dataTable.rm-table thead > tr > th.dt-orderable-asc,
@@ -637,30 +284,30 @@ table.dataTable.rm-table thead > tr > 
th.dt-orderable-desc {
 
 table.dataTable.rm-table thead > tr > th .dt-column-order:before,
 table.dataTable.rm-table thead > tr > th .dt-column-order:after {
-  color: var(--rm-border-2);
+  color: var(--jc-border-2);
 }
 
 table.dataTable.rm-table thead > tr > th.dt-ordering-asc 
.dt-column-order:before,
 table.dataTable.rm-table thead > tr > th.dt-ordering-desc 
.dt-column-order:after {
-  color: var(--rm-accent);
+  color: var(--jc-accent);
 }
 
 div.dt-container .dt-paging .dt-paging-button {
-  font-family: var(--rm-font);
-  border-radius: var(--rm-radius);
+  font-family: var(--jc-font);
+  border-radius: var(--jc-radius);
 }
 
 div.dt-container .dt-paging .dt-paging-button.current,
 div.dt-container .dt-paging .dt-paging-button.current:hover {
-  background: var(--rm-accent) !important;
-  border-color: var(--rm-accent) !important;
-  color: var(--rm-white) !important;
+  background: var(--jc-accent) !important;
+  border-color: var(--jc-accent) !important;
+  color: var(--jc-white) !important;
 }
 
 div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover {
-  background: var(--rm-accent-wash) !important;
-  border-color: var(--rm-accent) !important;
-  color: var(--rm-text) !important;
+  background: var(--jc-accent-wash) !important;
+  border-color: var(--jc-accent) !important;
+  color: var(--jc-text) !important;
 }
 
 /* ==========================================================================
@@ -672,7 +319,7 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
   margin: 0;
   padding: 4px 0 4px 20px;
   font-size: 0.8125rem;
-  color: var(--rm-text);
+  color: var(--jc-text);
   line-height: 1.6;
 }
 
@@ -691,8 +338,8 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
 .rm-page-sub code {
   font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
   font-size: 0.75rem;
-  background: var(--rm-chrome-bg);
-  border: 1px solid var(--rm-border);
+  background: var(--jc-chrome-bg);
+  border: 1px solid var(--jc-border);
   border-radius: 3px;
   padding: 1px 5px;
 }
@@ -710,9 +357,9 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
    ========================================================================== 
*/
 
 .cred {
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
   padding: 16px 18px;
   margin-bottom: 14px;
 }
@@ -723,7 +370,7 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
   margin: 0 0 8px;
   font-size: 0.9375rem;
   font-weight: 700;
-  color: var(--rm-text);
+  color: var(--jc-text);
 }
 
 .cred .row {
@@ -735,18 +382,18 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
 }
 
 .cred input {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 6px 10px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
 }
 
 .cred .msg {
   font-size: 0.75rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin-top: 8px;
 }
 
@@ -758,6 +405,6 @@ div.dt-container .dt-paging 
.dt-paging-button:not(.current):not(.disabled):hover
   font-weight: 600;
 }
 
-.pill.valid { background: var(--rm-tag-green-bg); color: 
var(--rm-tag-green-text); }
-.pill.invalid { background: #fdeceb; color: var(--rm-danger); }
-.pill.unset { background: var(--rm-tag-neutral-bg); color: 
var(--rm-tag-neutral-text); }
+.pill.valid { background: var(--jc-tag-green-bg); color: 
var(--jc-tag-green-text); }
+.pill.invalid { background: #fdeceb; color: var(--jc-danger); }
+.pill.unset { background: var(--jc-tag-neutral-bg); color: 
var(--jc-tag-neutral-text); }
diff --git a/src/main/resources/static/css/new-release.css 
b/src/main/resources/static/css/new-release.css
index 94b8ede47a..1bcf625c4a 100644
--- a/src/main/resources/static/css/new-release.css
+++ b/src/main/resources/static/css/new-release.css
@@ -38,12 +38,12 @@
    New step-status tag values (extends chrome.css's tag system; see header)
    ========================================================================== 
*/
 
-.tag.status.succeeded { background: var(--rm-tag-green-bg); color: 
var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); }
-.tag.status.running   { background: var(--rm-tag-blue-bg);  color: 
var(--rm-tag-blue-text);  border-color: var(--rm-tag-blue-border); }
+.tag.status.succeeded { background: var(--jc-tag-green-bg); color: 
var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); }
+.tag.status.running   { background: var(--jc-tag-blue-bg);  color: 
var(--jc-tag-blue-text);  border-color: var(--jc-tag-blue-border); }
 .tag.status.skipped,
 .tag.status.awaiting-vote,
-.tag.status.neutral   { background: var(--rm-tag-neutral-bg); color: 
var(--rm-tag-neutral-text); border-color: var(--rm-tag-neutral-border); }
-.tag.status.failed    { background: #fdeceb; color: var(--rm-danger); 
border-color: #f3c6c2; }
+.tag.status.neutral   { background: var(--jc-tag-neutral-bg); color: 
var(--jc-tag-neutral-text); border-color: var(--jc-tag-neutral-border); }
+.tag.status.failed    { background: #fdeceb; color: var(--jc-danger); 
border-color: #f3c6c2; }
 
 .tag.status.running::before { content: '\25CF'; margin-right: 4px; font-size: 
8px; position: relative; top: -1px; animation: rm-pulse 1.4s ease-in-out 
infinite; }
 .tag.status.awaiting-vote::before { content: '\23F8'; margin-right: 3px; 
font-size: 9px; position: relative; top: 0; }
@@ -57,9 +57,9 @@
 .rm-run-header {
   max-width: 1180px;
   margin: 0 auto 16px;
-  background: var(--rm-card-bg);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
+  background: var(--jc-card-bg);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
   padding: 16px 20px;
 }
 
@@ -81,7 +81,7 @@
 .rm-run-title {
   font-size: 1.0625rem;
   font-weight: 700;
-  color: var(--rm-text);
+  color: var(--jc-text);
   margin: 0;
 }
 
@@ -97,14 +97,14 @@
   align-items: center;
   gap: 4px;
   font-size: 0.75rem;
-  color: var(--rm-text-muted-2);
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border-2);
+  color: var(--jc-text-soft);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border-2);
   border-radius: 999px;
   padding: 3px 10px;
 }
 
-.rm-meta-chip b { color: var(--rm-text); font-weight: 700; }
+.rm-meta-chip b { color: var(--jc-text); font-weight: 700; }
 
 .rm-run-actions {
   display: flex;
@@ -118,19 +118,19 @@
   align-items: center;
   gap: 10px;
   font-size: 0.8125rem;
-  color: var(--rm-text-muted-2);
-  background: var(--rm-tag-neutral-bg);
-  border: 1px solid var(--rm-tag-neutral-border);
-  border-radius: var(--rm-radius);
+  color: var(--jc-text-soft);
+  background: var(--jc-tag-neutral-bg);
+  border: 1px solid var(--jc-tag-neutral-border);
+  border-radius: var(--jc-radius);
   padding: 9px 14px;
 }
 
-.rm-vote-banner b { color: var(--rm-text); }
+.rm-vote-banner b { color: var(--jc-text); }
 
 .rm-vote-banner .rm-vote-countdown {
   margin-left: auto;
   font-weight: 700;
-  color: var(--rm-text);
+  color: var(--jc-text);
   white-space: nowrap;
 }
 
@@ -145,7 +145,7 @@
   font-size: 0.75rem;
   line-height: 1.65;
   padding: 12px 14px;
-  border-radius: var(--rm-radius);
+  border-radius: var(--jc-radius);
   max-height: 240px;
   overflow-y: auto;
   white-space: pre-wrap;
@@ -183,7 +183,7 @@
   font-size: 0.6875rem;
   text-transform: uppercase;
   letter-spacing: 0.3px;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin: 14px 0 6px;
 }
 
@@ -191,7 +191,7 @@
   text-transform: none;
   letter-spacing: 0;
   font-size: 0.6875rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
 }
 
 /* ==========================================================================
@@ -209,26 +209,26 @@
 .rm-step-title-row h3 {
   font-size: 0.9375rem;
   font-weight: 700;
-  color: var(--rm-text);
+  color: var(--jc-text);
   margin: 0;
 }
 
 .rm-step-num {
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   font-weight: 600;
   font-size: 0.8125rem;
 }
 
 .rm-step-desc {
   font-size: 0.8125rem;
-  color: var(--rm-text-muted-2);
+  color: var(--jc-text-soft);
   margin: 0 0 12px;
   max-width: 62ch;
 }
 
 .rm-step-times {
   font-size: 0.75rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin-bottom: 12px;
 }
 
@@ -239,19 +239,14 @@
   margin-bottom: 4px;
 }
 
-.btn-sm {
-  font-size: 0.75rem;
-  padding: 5px 10px;
-}
-
-.btn[disabled] {
+.jc-btn[disabled] {
   opacity: 0.45;
   cursor: not-allowed;
 }
 
 .rm-step-note {
   font-size: 0.75rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin-top: 8px;
 }
 
@@ -270,18 +265,18 @@
   gap: 4px;
   font-size: 0.75rem;
   font-weight: 600;
-  color: var(--rm-text-muted-2);
+  color: var(--jc-text-soft);
 }
 
 .rm-vote-form select,
 .rm-vote-form textarea {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 6px 10px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
   resize: vertical;
 }
 
@@ -299,9 +294,9 @@
 
 .rm-rail-col {
   flex: 0 0 340px;
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
   overflow: hidden;
   max-height: 640px;
   overflow-y: auto;
@@ -312,16 +307,16 @@
   align-items: center;
   gap: 8px;
   padding: 9px 12px;
-  border-bottom: 1px solid var(--rm-border);
+  border-bottom: 1px solid var(--jc-border);
   cursor: pointer;
 }
 
 .rm-rail-item:last-child { border-bottom: none; }
-.rm-rail-item:hover { background: var(--rm-accent-wash); }
+.rm-rail-item:hover { background: var(--jc-accent-wash); }
 
 .rm-rail-item.selected {
-  background: var(--rm-accent-wash);
-  box-shadow: inset 3px 0 0 var(--rm-accent);
+  background: var(--jc-accent-wash);
+  box-shadow: inset 3px 0 0 var(--jc-accent);
 }
 
 .rm-rail-num {
@@ -329,7 +324,7 @@
   width: 18px;
   text-align: right;
   font-size: 0.6875rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
 }
 
 .rm-rail-name {
@@ -337,7 +332,7 @@
   min-width: 0;
   font-size: 0.8125rem;
   font-weight: 600;
-  color: var(--rm-text);
+  color: var(--jc-text);
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -348,9 +343,9 @@
 .rm-detail-col {
   flex: 1 1 auto;
   min-width: 0;
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
   padding: 18px 20px;
 }
 
@@ -367,9 +362,9 @@
 }
 
 .rm-step-row {
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border);
-  border-radius: var(--rm-radius);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border);
+  border-radius: var(--jc-radius);
   overflow: hidden;
 }
 
@@ -381,11 +376,11 @@
   cursor: pointer;
 }
 
-.rm-step-row-head:hover { background: var(--rm-accent-wash); }
+.rm-step-row-head:hover { background: var(--jc-accent-wash); }
 
 .rm-step-row.expanded .rm-step-row-head {
-  border-bottom: 1px solid var(--rm-border);
-  background: var(--rm-accent-wash);
+  border-bottom: 1px solid var(--jc-border);
+  background: var(--jc-accent-wash);
 }
 
 .rm-step-chevron {
@@ -394,8 +389,8 @@
   width: 16px;
   height: 16px;
   border-radius: 50%;
-  border: 1px solid var(--rm-border-2);
-  color: var(--rm-text-muted-2);
+  border: 1px solid var(--jc-border-2);
+  color: var(--jc-text-soft);
   font-size: 10px;
   line-height: 15px;
   text-align: center;
@@ -409,7 +404,7 @@
   width: 20px;
   text-align: right;
   font-size: 0.75rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
 }
 
 .rm-step-row-name {
@@ -417,7 +412,7 @@
   min-width: 0;
   font-size: 0.8438rem;
   font-weight: 600;
-  color: var(--rm-text);
+  color: var(--jc-text);
 }
 
 .rm-step-row-body {
@@ -432,13 +427,13 @@
    ========================================================================== 
*/
 
 .rm-table td.step-num-cell {
-  color: var(--rm-text-muted-2);
+  color: var(--jc-text-soft);
   width: 32px;
 }
 
 .rm-table td.step-name-cell {
   font-weight: 600;
-  color: var(--rm-text);
+  color: var(--jc-text);
 }
 
 .rm-table .step-actions-cell {
@@ -446,10 +441,10 @@
   text-align: right;
 }
 
-.rm-table .step-actions-cell .btn { margin-left: 6px; }
+.rm-table .step-actions-cell .jc-btn { margin-left: 6px; }
 
 tr.step-detail-row td {
-  background: var(--rm-card-bg);
+  background: var(--jc-card-bg);
   padding: 14px 18px;
 }
 
@@ -471,27 +466,27 @@ tr.step-detail-row td {
 }
 
 .row input {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 6px 10px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
 }
 
 .hint {
   font-size: 0.8125rem;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   font-style: italic;
 }
 
 /* Marks a step whose apply() mutates remote state (spec §3.8), slotted into
    the same amber domain chrome.css already uses for "pending"/in-progress. */
 .tag.mutating {
-  background: var(--rm-tag-amber-bg);
-  color: var(--rm-tag-amber-text);
-  border-color: var(--rm-tag-amber-border);
+  background: var(--jc-tag-amber-bg);
+  color: var(--jc-tag-amber-text);
+  border-color: var(--jc-tag-amber-border);
 }
 
 /* new-release.js renders the console into a <pre>, not the mockup's <div> —
@@ -503,9 +498,9 @@ pre.rm-console {
 /* A review-gate step held after its read-only work ran — reuses the same 
paused affordance as the
    vote gate, since both are "done its work, awaiting a human". */
 .tag.status.awaiting-review {
-  background: var(--rm-tag-blue-bg);
-  color: var(--rm-tag-blue-text);
-  border-color: var(--rm-tag-blue-border);
+  background: var(--jc-tag-blue-bg);
+  color: var(--jc-tag-blue-text);
+  border-color: var(--jc-tag-blue-border);
 }
 
 .tag.status.awaiting-review::before { content: '\23F8'; margin-right: 3px; 
font-size: 9px; }
@@ -518,16 +513,16 @@ pre.rm-console {
   max-width: 1180px;
   margin: 0 auto 16px;
   padding: 10px 14px;
-  border: 1px solid var(--rm-tag-neutral-border);
+  border: 1px solid var(--jc-tag-neutral-border);
   border-radius: 8px;
   font-size: 13px;
-  color: var(--rm-muted);
+  color: var(--jc-text-muted);
   display: flex;
   align-items: center;
   gap: 10px;
 }
 
-.rm-mode-banner.safe { background: var(--rm-tag-green-bg); border-color: 
var(--rm-tag-green-border); }
+.rm-mode-banner.safe { background: var(--jc-tag-green-bg); border-color: 
var(--jc-tag-green-border); }
 .rm-mode-banner.live { background: #fdeceb; border-color: #f3c6c2; }
 
 .rm-mode-chip {
@@ -541,8 +536,8 @@ pre.rm-console {
   border: 1px solid transparent;
 }
 
-.rm-mode-chip.safe { background: var(--rm-tag-green-bg); color: 
var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); }
-.rm-mode-chip.live { background: #fdeceb; color: var(--rm-danger); 
border-color: #f3c6c2; }
+.rm-mode-chip.safe { background: var(--jc-tag-green-bg); color: 
var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); }
+.rm-mode-chip.live { background: #fdeceb; color: var(--jc-danger); 
border-color: #f3c6c2; }
 
 /* ==========================================================================
    App-local Input / Execution subtabs (client-side show/hide; see 
new-release.js)
@@ -554,17 +549,17 @@ pre.rm-console {
   max-width: 1180px;
   margin: 0 auto 16px;
   padding: 4px;
-  background: var(--rm-white);
-  border: 1px solid var(--rm-border);
+  background: var(--jc-white);
+  border: 1px solid var(--jc-border);
   border-radius: 999px;
   width: fit-content;
 }
 
 .rm-subtab {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   font-weight: 600;
-  color: var(--rm-text-muted-2);
+  color: var(--jc-text-soft);
   background: transparent;
   border: none;
   border-radius: 999px;
@@ -572,11 +567,11 @@ pre.rm-console {
   cursor: pointer;
 }
 
-.rm-subtab:hover { background: var(--rm-accent-wash); }
+.rm-subtab:hover { background: var(--jc-accent-wash); }
 
 .rm-subtab.active {
-  background: var(--rm-accent);
-  color: var(--rm-white);
+  background: var(--jc-accent);
+  color: var(--jc-white);
 }
 
 .rm-subtab-panel[hidden] { display: none; }
@@ -613,24 +608,24 @@ pre.rm-console {
 .rm-field label {
   font-size: 0.75rem;
   font-weight: 700;
-  color: var(--rm-text-muted-2);
+  color: var(--jc-text-soft);
 }
 
 .rm-field-opt {
   font-weight: 500;
   font-style: italic;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
 }
 
 .rm-field input,
 .rm-field textarea {
-  font-family: var(--rm-font);
+  font-family: var(--jc-font);
   font-size: 0.8125rem;
   padding: 8px 11px;
-  border: 1px solid var(--rm-border-2);
-  border-radius: var(--rm-radius);
-  background: var(--rm-white);
-  color: var(--rm-text);
+  border: 1px solid var(--jc-border-2);
+  border-radius: var(--jc-radius);
+  background: var(--jc-white);
+  color: var(--jc-text);
   width: 100%;
   box-sizing: border-box;
 }
@@ -643,13 +638,13 @@ pre.rm-console {
 .rm-field input:focus,
 .rm-field textarea:focus {
   outline: none;
-  border-color: var(--rm-accent);
-  box-shadow: 0 0 0 2px var(--rm-accent-wash);
+  border-color: var(--jc-accent);
+  box-shadow: 0 0 0 2px var(--jc-accent-wash);
 }
 
 .rm-field input[readonly] {
-  background: var(--rm-card-bg);
-  color: var(--rm-text-muted-2);
+  background: var(--jc-card-bg);
+  color: var(--jc-text-soft);
 }
 
 .rm-mode-picker {
@@ -662,7 +657,7 @@ pre.rm-console {
   font-weight: 600;
   letter-spacing: 0.02em;
   text-transform: uppercase;
-  color: var(--rm-text-muted);
+  color: var(--jc-text-muted);
   margin-bottom: 8px;
 }
 
@@ -686,7 +681,7 @@ pre.rm-console {
   margin: 0;
   box-shadow: none;
   border: none;
-  accent-color: var(--rm-accent);
+  accent-color: var(--jc-accent);
 }
 
 .rm-form-actions {
@@ -699,16 +694,7 @@ pre.rm-console {
 /* The run is armed for live mutation (LIVE only). */
 .tag.armed {
   background: #fdeceb;
-  color: var(--rm-danger);
+  color: var(--jc-danger);
   border-color: #f3c6c2;
   font-weight: 700;
 }
-
-/* "Arm this run" — a deliberately cautionary amber action distinct from the 
primary/danger buttons. */
-.btn-warning {
-  background: var(--rm-tag-amber-bg);
-  color: var(--rm-tag-amber-text);
-  border: 1px solid var(--rm-tag-amber-border);
-}
-
-.btn-warning:hover { filter: brightness(0.97); }
diff --git a/src/main/resources/static/js/new-release.js 
b/src/main/resources/static/js/new-release.js
index 24efd4632a..7020120f91 100644
--- a/src/main/resources/static/js/new-release.js
+++ b/src/main/resources/static/js/new-release.js
@@ -56,9 +56,9 @@
           knownIssues: val('nr-knownIssues'), acknowledgements: 
val('nr-acknowledgements')
         })
       });
-      if (msg) { msg.textContent = r.ok ? 'Saved.' : 'Save failed.'; 
msg.style.color = r.ok ? '' : 'var(--rm-danger)'; }
+      if (msg) { msg.textContent = r.ok ? 'Saved.' : 'Save failed.'; 
msg.style.color = r.ok ? '' : 'var(--jc-danger)'; }
     } catch (e) {
-      if (msg) { msg.textContent = 'Save failed.'; msg.style.color = 
'var(--rm-danger)'; }
+      if (msg) { msg.textContent = 'Save failed.'; msg.style.color = 
'var(--jc-danger)'; }
     }
   };
 
@@ -108,20 +108,20 @@
   // Mirrors the mockup's renderActions(step): button set is entirely a 
function of current status
   // (spec §3 decisions #11/#12 — every button below is the SAME preview/apply 
call regardless of status).
   function renderActions(stepId, status) {
-    const dryRun = '<button class="btn btn-outline btn-sm" 
onclick="nrDryRun(\'' + stepId + '\')">Dry-run</button>';
+    const dryRun = '<button class="jc-btn jc-btn-outline jc-btn-sm" 
onclick="nrDryRun(\'' + stepId + '\')">Dry-run</button>';
     if (status === 'running')
-      return '<button class="btn btn-outline btn-sm" disabled>Dry-run</button> 
<button class="btn btn-primary btn-sm" disabled>Running&hellip;</button>';
+      return '<button class="jc-btn jc-btn-outline jc-btn-sm" 
disabled>Dry-run</button> <button class="jc-btn jc-btn-primary jc-btn-sm" 
disabled>Running&hellip;</button>';
     if (status === 'failed')
-      return dryRun + ' <button class="btn btn-primary btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Resume</button>';
+      return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Resume</button>';
     if (status === 'awaiting-vote')
       return renderVoteGateActions();
     if (status === 'awaiting-review')
-      return dryRun + ' <button class="btn btn-primary btn-sm" 
onclick="nrConfirmReview(\'' + stepId + '\')">Confirm review</button>';
+      return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" 
onclick="nrConfirmReview(\'' + stepId + '\')">Confirm review</button>';
     if (status === 'succeeded')
-      return dryRun + ' <button class="btn btn-outline btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Re-run</button>';
+      return dryRun + ' <button class="jc-btn jc-btn-outline jc-btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Re-run</button>';
     if (status === 'skipped')
-      return dryRun + ' <button class="btn btn-outline btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Run anyway</button>';
-    return dryRun + ' <button class="btn btn-primary btn-sm" 
onclick="nrApply(\'' + stepId + '\')">' + runLabel + '</button>';
+      return dryRun + ' <button class="jc-btn jc-btn-outline jc-btn-sm" 
onclick="nrApply(\'' + stepId + '\')">Run anyway</button>';
+    return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" 
onclick="nrApply(\'' + stepId + '\')">' + runLabel + '</button>';
   }
 
   // §5.15/§5.16: entering vote-gate only opens the vote (sets AWAITING_VOTE) 
— it is NOT itself the
@@ -131,12 +131,12 @@
   // requires the operator to pick a real outcome and type the tally summary 
read off the vote thread.
   function renderVoteGateActions() {
     if (mode !== 'LIVE')
-      return '<button class="btn btn-primary btn-sm" 
onclick="nrVoteResult(\'passed\')">' + runLabel + '</button>'
+      return '<button class="jc-btn jc-btn-primary jc-btn-sm" 
onclick="nrVoteResult(\'passed\')">' + runLabel + '</button>'
         + '<span class="rm-step-note">Records a simulated passing vote (no 
real 72h wait or tally) and advances the run.</span>';
     return '<div class="rm-vote-form">'
       + '<label>Vote outcome<select id="nr-vote-outcome"><option 
value="passed">Passed</option><option 
value="rejected">Rejected</option></select></label>'
       + '<label>Tally summary<textarea id="nr-vote-tally" rows="3" 
placeholder="+1/0/-1 counts, binding voters, read off the vote 
thread"></textarea></label>'
-      + '<button class="btn btn-primary btn-sm" 
onclick="nrVoteResult()">Submit vote result</button>'
+      + '<button class="jc-btn jc-btn-primary jc-btn-sm" 
onclick="nrVoteResult()">Submit vote result</button>'
       + '</div>';
   }
 
@@ -264,7 +264,7 @@
     if (armArea) {
       armArea.innerHTML = snap.armed
         ? '<span class="tag armed" title="This run is armed for live 
mutation">ARMED</span>'
-        : '<button class="btn btn-warning" onclick="nrArm()">Arm this 
run</button>';
+        : '<button class="jc-btn jc-btn-warning" onclick="nrArm()">Arm this 
run</button>';
     }
 
     let selected = null;
diff --git a/src/main/resources/templates/admin.ftlh 
b/src/main/resources/templates/admin.ftlh
new file mode 100644
index 0000000000..8b8f174d55
--- /dev/null
+++ b/src/main/resources/templates/admin.ftlh
@@ -0,0 +1,35 @@
+<#--
+  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.
+-->
+
+<#assign activeTab = "admin">
+<#macro content>
+    <div class="jc-card">
+        <div class="jc-card-header">
+            <h2>Admin</h2>
+        </div>
+
+        <#-- TODO-399 Phase C: the composed tabs/sub-tabs page shell 
(PageTable.of(...) via AdminRest.page()),
+             pre-serialized server-side and inserted as trusted markup: 
?no_esc suppresses this .ftlh's HTML
+             auto-escaping so the emitted [data-juneau-page]/<table 
data-juneau-view>/<script> markup is not
+             double-escaped. juneau-pages.js hash-routes the tabs and 
lazy-inits each panel's DataTable on first
+             activation via juneau-views.js's initTable. -->
+        <div class="rm-table-scroll">
+            ${pageTable?no_esc}
+        </div>
+    </div>
+</#macro>
+<#include "base.ftlh">
diff --git a/src/main/resources/templates/base.ftlh 
b/src/main/resources/templates/base.ftlh
index 56233bc319..e59bac0ac7 100644
--- a/src/main/resources/templates/base.ftlh
+++ b/src/main/resources/templates/base.ftlh
@@ -28,22 +28,31 @@
     <meta charset="utf-8">
     <title>Apache Juneau · Release Manager</title>
     <link rel="icon" type="image/svg+xml" href="/img/oakleaf.svg">
-    <#if activeTab?? && activeTab == 'releases'>
+    <#if activeTab?? && (activeTab == 'releases' || activeTab == 'admin')>
     <link rel="stylesheet" href="/datatables/dataTables.dataTables.min.css">
+    <#-- DataTables Buttons extension (copy/csv export) — caller-provided per 
ASF category-A discipline; CDN
+         coordinates per the toolkit design §6.11. Only the copy/csv cluster 
needs this; excel/pdf (JSZip/pdfmake)
+         stay unloaded and are feature-detected off by juneau-ribbon.js. -->
+    <link rel="stylesheet" 
href="https://cdn.datatables.net/buttons/3.2.0/css/buttons.dataTables.min.css";>
+    <#-- First-party toolkit base .tag chip stylesheet (neutral); the app's 
console-ui palette themes the same
+         .tag.<domain>.<value> classes. This same stylesheet also carries the 
TODO-399 Phase C .jc-tab/.jc-panel
+         page-shell base rules the Admin tab's juneau-pages.js runtime relies 
on. -->
+    <link rel="stylesheet" 
href="${viewsCssUrl!'/rest/releases/juneau-views.css'}">
     </#if>
+    <link rel="stylesheet" href="/juneau-console/chrome.css">
     <link rel="stylesheet" href="/css/chrome.css">
 </head>
 <body>
-    <header class="rm-header">
-        <div class="rm-brand">
-            <div class="rm-brand-mark" role="img" aria-label="Apache 
Juneau"></div>
-            <div class="rm-brand-title">Apache Juneau <span 
class="rm-brand-sep">&middot;</span> Release Manager</div>
+    <header class="jc-header">
+        <div class="jc-brand">
+            <div class="jc-logo" role="img" aria-label="Apache Juneau"></div>
+            <div class="jc-brand-title">Apache Juneau <span 
class="jc-brand-sep">&middot;</span> Release Manager</div>
         </div>
-        <div class="rm-header-actions">
-            <button class="rm-icon-btn" title="Help" aria-label="Help">
+        <div class="jc-header-actions">
+            <button class="jc-icon-btn" title="Help" aria-label="Help">
                 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" 
fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="16.5" 
text-anchor="middle" font-size="11" font-weight="700" 
fill="currentColor">?</text></svg>
             </button>
-            <div class="rm-avatar" title="Release Manager">RM</div>
+            <div class="jc-avatar" title="Release Manager">RM</div>
         </div>
     </header>
 
@@ -51,22 +60,41 @@
          Active tab is driven by the `activeTab` attribute each page template
          assigns before including this layout — no hardcoded per-page string
          compares here. -->
-    <nav class="nav">
-        <a class="nav-tab<#if activeTab?? && activeTab == 'home'> 
active</#if>" href="/rest/home">Home</a>
-        <a class="nav-tab<#if activeTab?? && activeTab == 'credentials'> 
active</#if>" href="/rest/credentials">Credentials</a>
-        <a class="nav-tab<#if activeTab?? && activeTab == 'releases'> 
active</#if>" href="/rest/releases">Releases</a>
-        <a class="nav-tab<#if activeTab?? && activeTab == 'new'> active</#if>" 
href="/rest/runs">New Release</a>
+    <nav class="jc-nav">
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'home'> 
active</#if>" href="/rest/home">Home</a>
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'credentials'> 
active</#if>" href="/rest/credentials">Credentials</a>
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'releases'> 
active</#if>" href="/rest/releases">Releases</a>
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'new'> 
active</#if>" href="/rest/runs">New Release</a>
+        <a class="jc-nav-tab<#if activeTab?? && activeTab == 'admin'> 
active</#if>" href="/rest/admin">Admin</a>
     </nav>
 
-    <main class="rm-main">
+    <main class="jc-main">
         <#-- child templates define the "content" macro, then <#include> this 
layout -->
         <@content/>
     </main>
 
-    <#if activeTab?? && activeTab == 'releases'>
+    <#if activeTab?? && (activeTab == 'releases' || activeTab == 'admin')>
+    <#-- Caller-provided libs: self-hosted jQuery + DataTables, plus the 
DataTables Buttons extension (copy/csv)
+         from CDN. Then the first-party toolkit runtime in load order renders 
-> icons -> ribbon -> views;
+         juneau-views.js scans table[data-juneau-view] on DOMContentLoaded and 
hydrates the server-side table +
+         ribbon. juneau-icons.js MUST load before juneau-ribbon.js: the 
ribbon/paging-pill buttons resolve their
+         glyphs from the icon registry (window.JuneauViews.icons) at build 
time, so the registry must already
+         exist when juneau-ribbon.js/juneau-views.js render them. The old 
hand-rolled /js/releases.js DataTable
+         init is retired — the toolkit owns initialization now. -->
     <script src="/datatables/jquery.min.js"></script>
     <script src="/datatables/dataTables.min.js"></script>
-    <script src="/js/releases.js"></script>
+    <script 
src="https://cdn.datatables.net/buttons/3.2.0/js/dataTables.buttons.min.js";></script>
+    <script 
src="https://cdn.datatables.net/buttons/3.2.0/js/buttons.html5.min.js";></script>
+    <script src="${rendersJsUrl!'/rest/releases/juneau-renders.js'}"></script>
+    <script src="${iconsJsUrl!'/rest/releases/juneau-icons.js'}"></script>
+    <script src="${ribbonJsUrl!'/rest/releases/juneau-ribbon.js'}"></script>
+    <script src="${viewsJsUrl!'/rest/releases/juneau-views.js'}"></script>
+    </#if>
+    <#if activeTab?? && activeTab == 'admin'>
+    <#-- TODO-399 Phase C: the opt-in tabs/sub-tabs page runtime — only the 
Admin tab's composed PageDef page loads
+         this; the single-view Releases tab above does not. Must load after 
juneau-views.js (it calls the
+         now-public NS.init.initTable to lazy-init a panel's DataTable on 
first activation). -->
+    <script src="${pagesJsUrl!'/rest/admin/juneau-pages.js'}"></script>
     </#if>
     <#if activeTab?? && activeTab == 'credentials'>
     <script src="/js/credentials.js"></script>
diff --git a/src/main/resources/templates/credentials.ftlh 
b/src/main/resources/templates/credentials.ftlh
index c4898d8ec2..e6543bd3aa 100644
--- a/src/main/resources/templates/credentials.ftlh
+++ b/src/main/resources/templates/credentials.ftlh
@@ -17,7 +17,7 @@
 
 <#assign activeTab = "credentials">
 <#macro content>
-    <div class="rm-card">
+    <div class="jc-card">
         <#list credentials as c>
         <div class="cred" data-name="${c.name}">
             <h3>${c.label}
@@ -30,8 +30,8 @@
                     <input type="text" class="acct" placeholder="<#if 
c.name=='apache'>Apache availid<#else>GPG key ID</#if>">
                 </#if>
                 <input type="password" class="secret" placeholder="<#if 
c.name=='apache'>password<#elseif c.name=='gpg'>passphrase<#else>token</#if>">
-                <button class="btn btn-primary" onclick="rmSet('${c.name}', 
this)">Save</button>
-                <button class="btn btn-outline" 
onclick="rmValidate('${c.name}', this)">Validate</button>
+                <button class="jc-btn jc-btn-primary" 
onclick="rmSet('${c.name}', this)">Save</button>
+                <button class="jc-btn jc-btn-outline" 
onclick="rmValidate('${c.name}', this)">Validate</button>
             </div>
             <div class="msg">${c.lastMessage!''}</div>
         </div>
diff --git a/src/main/resources/templates/home.ftlh 
b/src/main/resources/templates/home.ftlh
index 631075926d..1ab226d10d 100644
--- a/src/main/resources/templates/home.ftlh
+++ b/src/main/resources/templates/home.ftlh
@@ -17,8 +17,8 @@
 
 <#assign activeTab = "home">
 <#macro content>
-    <div class="rm-card">
-        <div class="rm-card-header">
+    <div class="jc-card">
+        <div class="jc-card-header">
             <h2>Workflow</h2>
         </div>
         <ol class="rm-home-steps">
@@ -28,8 +28,8 @@
         </ol>
     </div>
 
-    <div class="rm-card">
-        <div class="rm-card-header">
+    <div class="jc-card">
+        <div class="jc-card-header">
             <h2>Prerequisites</h2>
         </div>
         <ul class="rm-home-prereqs">
diff --git a/src/main/resources/templates/new-release.ftlh 
b/src/main/resources/templates/new-release.ftlh
index b3aead6c27..70ad3dd45a 100644
--- a/src/main/resources/templates/new-release.ftlh
+++ b/src/main/resources/templates/new-release.ftlh
@@ -38,7 +38,7 @@
 
     <#-- ===================== INPUT SUBTAB: release metadata + narrative 
===================== -->
     <section class="rm-subtab-panel" id="nr-panel-input" 
data-subtab="input"<#if hasRun> hidden</#if>>
-        <div class="rm-card rm-form-card">
+        <div class="jc-card rm-form-card">
             <#if !hasRun>
             <h2>Start a new release</h2>
             <div class="rm-form-grid">
@@ -114,9 +114,9 @@
 
             <div class="rm-form-actions">
                 <#if !hasRun>
-                <button class="btn btn-primary" 
onclick="nrStart()">Start</button>
+                <button class="jc-btn jc-btn-primary" 
onclick="nrStart()">Start</button>
                 <#else>
-                <button class="btn btn-primary" onclick="nrSaveDetails()">Save 
details</button>
+                <button class="jc-btn jc-btn-primary" 
onclick="nrSaveDetails()">Save details</button>
                 <span id="nr-details-msg" class="rm-step-note"></span>
                 </#if>
             </div>
@@ -126,7 +126,7 @@
     <#-- ===================== EXECUTION SUBTAB: step rail + detail/console 
===================== -->
     <section class="rm-subtab-panel" id="nr-panel-exec" data-subtab="exec"<#if 
!hasRun> hidden</#if>>
     <#if !hasRun>
-        <div class="rm-card">
+        <div class="jc-card">
             <p class="hint">No active run yet. Fill in the Input tab and click 
Start to begin.</p>
         </div>
     <#else>
@@ -142,11 +142,11 @@
                         <#if armed!false>
                             <span class="tag armed" title="This run is armed 
for live mutation">ARMED</span>
                         <#else>
-                            <button class="btn btn-warning" 
onclick="nrArm()">Arm this run</button>
+                            <button class="jc-btn jc-btn-warning" 
onclick="nrArm()">Arm this run</button>
                         </#if>
                         </span>
                     </#if>
-                    <button class="btn btn-danger" onclick="nrDropRc()">Drop 
RC</button>
+                    <button class="jc-btn jc-btn-danger" 
onclick="nrDropRc()">Drop RC</button>
                 </div>
             </div>
             <div class="rm-run-meta" style="margin-top:10px;">
diff --git a/src/main/resources/templates/release-detail.ftlh 
b/src/main/resources/templates/release-detail.ftlh
index 65ad2f47fd..293ed4ccd6 100644
--- a/src/main/resources/templates/release-detail.ftlh
+++ b/src/main/resources/templates/release-detail.ftlh
@@ -17,8 +17,8 @@
 
 <#assign activeTab = "releases">
 <#macro content>
-    <div class="rm-card">
-        <div class="rm-card-header">
+    <div class="jc-card">
+        <div class="jc-card-header">
             <h2>Release ${release.version!'—'}</h2>
             <a href="/rest/releases">&larr; All releases</a>
         </div>
diff --git a/src/main/resources/templates/releases.ftlh 
b/src/main/resources/templates/releases.ftlh
index c947c753ac..ae76387609 100644
--- a/src/main/resources/templates/releases.ftlh
+++ b/src/main/resources/templates/releases.ftlh
@@ -17,47 +17,17 @@
 
 <#assign activeTab = "releases">
 <#macro content>
-    <div class="rm-card">
-        <div class="rm-card-header">
+    <div class="jc-card">
+        <div class="jc-card-header">
             <h2>All Releases</h2>
         </div>
 
+        <#-- Rich-view toolkit table + JSON sidecar, pre-serialized 
server-side (ReleaseRest.page via
+             ViewTable.of) and inserted as trusted markup: ?no_esc suppresses 
this .ftlh's HTML auto-escaping
+             so the emitted <table data-juneau-view>/<script> markup is not 
double-escaped. juneau-views.js
+             hydrates it into a server-side DataTable + ribbon on 
DOMContentLoaded. -->
         <div class="rm-table-scroll">
-            <table id="releases" class="rm-table">
-                <thead>
-                    <tr>
-                        
<th>Version</th><th>RC</th><th>Status</th><th>Stage</th>
-                        <th>Vote closes</th><th>Released</th><th>Links</th>
-                    </tr>
-                </thead>
-                <tbody>
-                    <#list releases as r>
-                    <#if r.rc?? && r.rc != '—'>
-                        <#assign rcNumber = r.rc?replace('RC', '')>
-                    <#else>
-                        <#assign rcNumber = '1'>
-                    </#if>
-                    <tr>
-                        <td class="version-cell"><#if r.version??><a 
href="/rest/releases/${r.version}/${rcNumber}">${r.version}</a><#else>&mdash;</#if></td>
-                        <td class="rc-cell">${r.rc!'—'}</td>
-                        <td><span class="tag status 
${(r.status!'UNKNOWN')?lower_case}">${r.status!'—'}</span></td>
-                        <td>
-                            <#if !(r.stage??) || r.stage == '—'>
-                                &mdash;
-                            <#else>
-                                <span class="tag stage 
${r.stage?lower_case?replace(' ', '-')}">${r.stage}</span>
-                            </#if>
-                        </td>
-                        <td><@ts value=r.voteCloses/></td>
-                        <td>${r.released!'—'}</td>
-                        <td class="links-cell">
-                            <#if r.githubReleaseUrl??><a 
href="${r.githubReleaseUrl}">GitHub</a></#if>
-                            <#if r.milestoneUrl??><a 
href="${r.milestoneUrl}">Milestone</a></#if>
-                        </td>
-                    </tr>
-                    </#list>
-                </tbody>
-            </table>
+            ${viewTable?no_esc}
         </div>
     </div>
 </#macro>
diff --git a/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java 
b/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java
new file mode 100644
index 0000000000..134bd115b9
--- /dev/null
+++ b/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.releng.rest;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.juneau.commons.inject.StackOverlay;
+import org.apache.juneau.commons.utils.IoUtils;
+import org.apache.juneau.marshall.html.HtmlSerializer;
+import org.apache.juneau.rest.mock.MockRestClient;
+import org.apache.juneau.rest.server.views.PageTable;
+import org.apache.juneau.rest.server.views.ViewTable;
+import org.apache.juneau.rest.server.views.ViewsMixin;
+import org.junit.jupiter.api.Test;
+
+/**
+ * TODO-399 Phase C dogfood (tasks 10-12): the RM {@code Admin} tab composes 
the existing Releases/Credentials
+ * {@link org.apache.juneau.rest.server.views.ViewDef ViewDef}s into one 
{@link org.apache.juneau.rest.server.views.PageDef PageDef}
+ * page, rendered by {@link PageTable} and served through {@link AdminRest}.
+ */
+class AdminRestTest {
+
+       /**
+        * {@link MockRestClient#create(Object)} caches its {@code RestContext} 
per resource class (see
+        * {@code ReleaseRestTest}'s identical helper javadoc); pass a fresh 
{@link StackOverlay} to opt out.
+        */
+       @SuppressWarnings({
+               "resource" // Caller owns and closes the returned 
MockRestClient (via try-with-resources); Eclipse JDT @Owning warning is by 
design.
+       })
+       private static MockRestClient client() {
+               return MockRestClient.builder(new 
AdminRest()).overridingBeanStore(new StackOverlay()).build();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------
+       // Task 10: PageDef composition
+       // 
-----------------------------------------------------------------------------------------------------------
+
+       @Test
+       void adminPageComposesOneTabPerExistingView() {
+               var page = AdminRest.adminPage();
+               assertEquals("admin", page.id);
+               assertEquals(2, page.tabs.size());
+               assertEquals("releases", page.tabs.get(0).id);
+               assertEquals("releases", page.tabs.get(0).view.id);
+               assertEquals("credentials", page.tabs.get(1).id);
+               assertEquals("credentials", page.tabs.get(1).view.id);
+       }
+
+       @Test
+       void adminPageBuildsWithoutValidationErrors() {
+               // PageDef.build() rejects duplicate tab ids / duplicate 
referenced ViewDef ids (Phase C task 2 rules);
+               // building here (rather than only in AdminRest.adminPage()) 
proves the composition is actually valid,
+               // not just that adminPage() happens not to throw.
+               assertDoesNotThrow(AdminRest::adminPage);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------
+       // Task 11: PageTable wiring + no regression to the standalone per-view 
output
+       // 
-----------------------------------------------------------------------------------------------------------
+
+       @Test
+       void pageServesPageTableShellWithBothPanelsAndSidecars() throws 
Exception {
+               try (var client = client()) {
+                       try (var resp = client.request("GET", "/").run()) {
+                               assertEquals(200, resp.getStatusCode());
+                               var body = resp.getBodyAsString();
+                               
assertTrue(body.contains("data-juneau-page='admin'"), "Missing page shell: " + 
body);
+                               
assertTrue(body.contains("data-juneau-view='releases'"), "Missing releases 
panel: " + body);
+                               
assertTrue(body.contains("data-juneau-view='credentials'"), "Missing 
credentials panel: " + body);
+                               assertTrue(body.contains("juneau-page:admin"), 
"Missing PAGE_META sidecar: " + body);
+                               
assertTrue(body.contains("juneau-view:releases"), "Missing releases VIEW_META 
sidecar: " + body);
+                               
assertTrue(body.contains("juneau-view:credentials"), "Missing credentials 
VIEW_META sidecar: " + body);
+                       }
+               }
+       }
+
+       @Test
+       void wrappedReleasesViewMarkupIsByteForByteIdenticalToStandalone() {
+               var wrapped = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage()));
+               var standalone = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(ReleaseRest.releasesView()));
+               assertTrue(wrapped.contains(standalone),
+                       "Page-wrapped Releases view markup diverged from the 
standalone ViewTable.of(...) output.");
+       }
+
+       @Test
+       void wrappedCredentialsViewMarkupIsByteForByteIdenticalToStandalone() {
+               var wrapped = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage()));
+               var standalone = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(CredentialRest.credentialsView()));
+               assertTrue(wrapped.contains(standalone),
+                       "Page-wrapped Credentials view markup diverged from the 
standalone ViewTable.of(...) output.");
+       }
+
+       @Test
+       void assetsAreServedAtTheAdminMount() throws Exception {
+               try (var client = client()) {
+                       try (var resp = client.request("GET", 
ViewsMixin.PAGES_JS_PATH).run()) {
+                               assertEquals(200, resp.getStatusCode());
+                               
assertTrue(resp.getBodyAsString().contains("JuneauViews"));
+                       }
+                       try (var resp = client.request("GET", 
ViewsMixin.VIEWS_JS_PATH).run()) {
+                               assertEquals(200, resp.getStatusCode());
+                       }
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------
+       // Task 12: the composed page uses the self-contained -views tab shell 
(PageTable's classes), not hand-rolled
+       // per-page tab markup — verified here structurally (the shell's own 
marker classes); base.ftlh's nav-link and
+       // asset-include wiring for the Admin tab is exercised end-to-end by 
pageServesPageTableShellWithBothPanelsAndSidecars
+       // once rendered through the FreeMarker template at /admin.
+       // 
-----------------------------------------------------------------------------------------------------------
+
+       @Test
+       void composedPageUsesTheSharedTabShellClassesNotBespokeMarkup() {
+               var html = 
HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage()));
+               assertTrue(html.contains("class='" + PageTable.TAB_BAR_CLASS + 
"'") || html.contains(PageTable.TAB_BAR_CLASS));
+               assertTrue(html.contains(PageTable.TAB_CLASS));
+               assertTrue(html.contains(PageTable.PANEL_CLASS));
+       }
+
+       /**
+        * The app's own cross-resource nav (base.ftlh's {@code .jc-nav}) gains 
an Admin entry, and the Admin tab pulls
+        * in the opt-in {@code juneau-pages.js} runtime after {@code 
juneau-views.js} &mdash; the top-level nav
+        * mechanism itself (real links across {@code @Rest(children=...)} 
resources) is intentionally unchanged
+        * (design doc non-goal: no top-level cross-resource navigation 
redesign; Phase C owns only the in-page tab
+        * switch, which {@link 
#composedPageUsesTheSharedTabShellClassesNotBespokeMarkup()} already verifies 
uses the
+        * shared shell classes).
+        */
+       @Test
+       void baseTemplateWiresTheAdminNavLinkAndPagesRuntime() throws 
IOException {
+               String base;
+               try (var in = 
AdminRestTest.class.getResourceAsStream("/templates/base.ftlh")) {
+                       assertNotNull(in, "templates/base.ftlh not found on the 
test classpath");
+                       base = new String(IoUtils.readBytes(in), 
StandardCharsets.UTF_8);
+               }
+               assertTrue(base.contains("href=\"/rest/admin\""), "Missing 
Admin nav link: " + base);
+               assertTrue(base.contains("activeTab == 'admin'"), "Missing 
admin-tab conditional asset wiring: " + base);
+               assertTrue(base.contains("pagesJsUrl"), "Missing 
juneau-pages.js include for the Admin tab: " + base);
+       }
+
+       /**
+        * Regression: {@code juneau-icons.js} was never included on the page 
(only renders/ribbon/views were), so the
+        * icon registry was absent at ribbon-build time and every 
ribbon/paging-pill button fell back to rendering its
+        * label as plain text instead of a glyph. Asserts the include exists 
AND is ordered before {@code ribbonJsUrl}
+        * (the ribbon/pill buttons resolve their icons from the registry when 
they're built, so it must already exist).
+        */
+       @Test
+       void baseTemplateIncludesIconsJsBeforeRibbonJs() throws IOException {
+               String base;
+               try (var in = 
AdminRestTest.class.getResourceAsStream("/templates/base.ftlh")) {
+                       assertNotNull(in, "templates/base.ftlh not found on the 
test classpath");
+                       base = new String(IoUtils.readBytes(in), 
StandardCharsets.UTF_8);
+               }
+               assertTrue(base.contains("iconsJsUrl"), "Missing 
juneau-icons.js include: " + base);
+               var iconsIdx = base.indexOf("iconsJsUrl");
+               var ribbonIdx = base.indexOf("ribbonJsUrl");
+               assertTrue(ribbonIdx >= 0, "Missing juneau-ribbon.js include: " 
+ base);
+               assertTrue(iconsIdx < ribbonIdx,
+                       "juneau-icons.js must be included before 
juneau-ribbon.js (icon registry must exist when the ribbon builds its buttons): 
" + base);
+       }
+}
diff --git a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java 
b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
index f48fbcc67f..3a32235872 100644
--- a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
+++ b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java
@@ -91,4 +91,52 @@ class ReleaseRestTest {
                        }
                }
        }
+
+       /**
+        * The {@code /data} endpoint speaks the DataTables 
server-side-processing contract: given a request carrying
+        * DataTables params it returns a {@code DataTablesResults} envelope 
({@code {draw, recordsTotal, recordsFiltered,
+        * data}}) with server-side per-column filtering applied &mdash; not 
the bare {@code List<Release>} array it used
+        * to return. Wired via the {@code juneau-rest-server-views} toolkit 
({@code ViewDef.queryableSettings()} +
+        * {@code ProtocolQueryable}); this proves the envelope shape and that 
filtering happens on the server.
+        */
+       /**
+        * Regression: the rendered Releases page never included {@code 
juneau-icons.js} (only renders/ribbon/views
+        * were wired up), so the icon registry was absent when the ribbon 
built its buttons and every button fell back
+        * to rendering its label as plain text instead of a glyph. Asserts the 
served page's script list carries the
+        * icons include, ordered before {@code juneau-ribbon.js} (the ribbon 
resolves icons from the registry when it
+        * builds its buttons, so the registry must already exist).
+        */
+       @Test
+       void pageIncludesIconsJsScriptBeforeRibbonJs() throws Exception {
+               try (var client = client(rest(List.of(release("9.2.1", 
"RELEASED"))))) {
+                       try (var resp = client.request("GET", "/").run()) {
+                               assertEquals(200, resp.getStatusCode());
+                               var body = resp.getBodyAsString();
+                               assertTrue(body.contains("juneau-icons.js"), 
"Missing juneau-icons.js script include: " + body);
+                               var iconsIdx = body.indexOf("juneau-icons.js");
+                               var ribbonIdx = 
body.indexOf("juneau-ribbon.js");
+                               assertTrue(ribbonIdx >= 0, "Missing 
juneau-ribbon.js script include: " + body);
+                               assertTrue(iconsIdx < ribbonIdx,
+                                       "juneau-icons.js must be included 
before juneau-ribbon.js: " + body);
+                       }
+               }
+       }
+
+       @Test
+       void dataReturnsDataTablesEnvelopeWithServerSideFilterApplied() throws 
Exception {
+               var releases = List.of(release("9.2.1", "RELEASED"), 
release("9.3.0", "VOTING"));
+               try (var client = client(rest(releases))) {
+                       try (var resp = client.request("GET",
+                                       
"/data?draw=3&start=0&length=10&columns[0][data]=status&columns[0][search][value]=RELEASED").run())
 {
+                               assertEquals(200, resp.getStatusCode());
+                               var body = resp.getBodyAsString();
+                               assertFalse(body.trim().startsWith("["), 
"Expected an envelope object, not a bare array: " + body);
+                               assertTrue(body.contains("recordsTotal"), 
"Missing recordsTotal: " + body);
+                               assertTrue(body.contains("recordsFiltered"), 
"Missing recordsFiltered: " + body);
+                               assertTrue(body.contains("draw"), "Missing 
draw: " + body);
+                               assertTrue(body.contains("9.2.1"), "Filtered-in 
row missing: " + body);
+                               assertFalse(body.contains("9.3.0"), 
"Filtered-out row present: " + body);
+                       }
+               }
+       }
 }

Reply via email to