Admin-Console Chrome (juneau-console)
Three new opt-in modules add a themeable, security-hardened admin-console "chrome" (CSS
theme tokens, a status/domain pill component, and FreeMarker-macro composition) on top of
juneau-rest-server, without bleeding a template engine or the DataTables toolkit into the
core:
| Module | Adds | Depends on |
|---|---|---|
juneau-console | ConsoleChromeMixin (dynamic, per-mount-themed chrome.css endpoint), Theme (--jc-* token allowlist grammar), the chrome.css / juneau-console.js shell assets, the page-contract schema, and PageContractAssert (test helper) | juneau-rest-server, juneau-bean-html5 |
juneau-console-freemarker | ConsoleFreemarkerMixin (reserved-classpath base.ftlh + the <@tag> macro, and the <@console> / <@page> / <@navigation> / <@card> directives) | juneau-rest-server-view-freemarker (provided), juneau-console |
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console</artifactId>
<version>${juneau.version}</version>
</dependency>
<!-- FreeMarker composition (base.ftlh, <@tag>): -->
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-console-freemarker</artifactId>
<version>${juneau.version}</version>
</dependency>
A chrome-only consumer (no FreeMarker, or FreeMarker without DataTables) pulls in exactly one
or two of these — never all three, and never the unrelated -datatables module unless it
explicitly asks for the macro.
Why a security-hardened theme, not a raw CSS override?
Letting a consumer supply arbitrary CSS custom-property values (colors, fonts, a page
background) is an XSS/CSS-injection surface: a naive var(--jc-page-bg: url(javascript:...))-
style value sunk into an unescaped stylesheet can exfiltrate data or inject markup. Theme
closes that gap with a normalize-then-accept-known-safe pipeline, not a denylist:
- Reject the raw value if it contains any C0/C1/DEL control character (checked before
trimming, so a value like
"\u0000 url(evil)"can't hide a control character at the boundary). - Trim, then strip CSS
/* ... */comments. - Case-insensitively reject any
url(production outright — style values in this module never carry a URL token, structurally. - Match what remains against an accept-known-safe grammar: hex/functional colors, length
units, font-family lists, and a small set of gradient functions (
linear-gradient,radial-gradient, etc.) with a grammar tight enough that a gradient argument list cannot itself smuggle a nested function call outside the allowed shape.
Colors, fonts, and lengths sink only into non-URL-capable CSS properties (color,
background-color, border-color, font-family, sizes/spacing, etc.). The only
URL-capable sink for a var(--jc-*) token is background-image, and it's restricted to two
specific tokens: --jc-page-bg and --jc-avatar-bg. Since step 3 above structurally rejects
any url() production before a value is ever accepted, neither of those two tokens can be
used to inject a URL anyway — v1 has no url() token form at all. The console logo is a
static constant baked into the shipped chrome.css, never a consumer-suppliable token.
ConsoleChromeMixin — the dynamic chrome.css endpoint
ConsoleChromeMixin serves the structural chrome stylesheet with Theme.OPEN's tokens appended, plus the five stock theme stylesheets (juneau-theme-<name>.css).
@Rest(mixins=ConsoleChromeMixin.class)
public class ConsoleAssetsRest extends BasicRestServlet {
@Bean
public ConsoleChromeMixin consoleChrome() {
return ConsoleChromeMixin.create().build(); // Theme.OPEN
// or: ConsoleChromeMixin.create().theme("gray").build();
}
}
Stock themes
open (default), light-red, light-brown, red and gray. Select one per page in the FreeMarker chrome with <@theme name="gray"/>, which links that theme's stylesheet. Use the builder's theme("gray") only to bake a palette into the served chrome.css itself.

The petstore themes page shows every stock theme side by side.
Custom palettes
Custom palettes are authored in FTL, not Java. Nest <@token> overrides in <@theme>, and <@console> emits the stock-theme <link> followed by an inline <style>html:root{...}</style> block:
<@console>
<@theme name="light-red">
<#-- A leaf: a literal value. -->
<@token name="--jc-pill-red-bg" value="#fdeceb"/>
<#-- An alias: a live reference that follows its target through the cascade. -->
<@token name="--jc-tab-bar-bg" alias="var(--jc-card-bg)"/>
</@theme>
<@main/>
</@console>
Every value goes through the same allowlist grammar and escaper as the stock palettes. A var() in value= is resolved to a literal at render time; an alias= stays a reference. A new name in the reserved --jc-chrome-* namespace is rejected; only the two such tokens that Theme.OPEN already defines (--jc-chrome-bg, --jc-chrome-icon) can be overridden.
Dark scheme
Every stock theme has a dark counterpart. The console picks the scheme before first paint and sets data-jc-scheme (light or dark) on the root element. The choice comes from, in order:
- the visitor's stored choice, only on pages that opt in with a toggle;
- the page's pin (
lightordark), when it has one; - the operating system's
prefers-color-scheme.
auto is the default pin, which means "follow the operating system".

Pinning and the toggle
In FreeMarker, set both on the console directive. scheme accepts auto, light or dark. schemeToggle is a strict boolean that adds the header's Auto / Light / Dark control and lets the visitor's stored choice win over the pin:
<@console brand="Admin" scheme="auto" schemeToggle=true>
<@main/>
</@console>
Programmatic pages use PageSpec.scheme("dark") and PageSpec.schemeToggle(true).
App dark tokens
Add scheme="dark" to a <@token> to set it for the dark scheme only. Dark declarations render in their own rule after the light block. A dark value= must be a literal, because a var() would resolve against the light palette. A dark alias= may point at a stock theme token, at another dark token name, or at one of the console role aliases such as --jc-surface.
<@theme name="light-brown">
<@token name="--jc-pill-red-bg" value="#fdeceb"/>
<@token name="--jc-link" value="#d9a65c" scheme="dark"/>
</@theme>
App dark CSS
Write your own dark rules with the selector the console itself uses, so they win over the light rules at the same specificity:
html:root[data-jc-scheme="dark"] .my-panel {
background: var(--jc-surface);
}
Content-Security-Policy
The pre-paint script is inline and its text is a constant. Allow it with its hash instead of a nonce by adding ConsoleChromeMixin.SCHEME_SCRIPT_CSP_HASH to script-src. The hash covers only that script. When the request carries a CSP nonce, the script is stamped with it as well, so a script-src that allows the nonce works without the hash. The inline <style> blocks (theme and token overrides) still need the request nonce, or 'unsafe-inline', in style-src.
Pages that do not use the FreeMarker chrome
ConsoleChromeMixin.schemeHead(req, pin, toggle) returns a SchemeHead with two pieces. Put htmlAttributes() on the <html> start tag and head() first in <head>: it holds the pre-paint script and the dark stylesheet link. The page still emits its own chrome.css and palette links.
SchemeHead h = ConsoleChromeMixin.schemeHead(req, "auto", true);
// <html lang="en" {h.htmlAttributes()}><head>{h.head()} ...
Client API
JuneauConsole.schemeControl(doc) builds the Auto / Light / Dark radio group for a page that wants the control somewhere other than the header, and keeps it in step with the page. The page must still set schemeToggle=true (the data-jc-scheme-toggle attribute on the root element), because the stored choice is honoured only on pages that opt in; without it the control cannot hold a choice. Whenever the effective scheme changes (the operating system, another tab, or any control), the document receives a juneau:theme-changed event whose detail.scheme is light or dark:
document.addEventListener('juneau:theme-changed', e => redrawChart(e.detail.scheme));
Enum pills — the <@tag> macro
Status/domain pills are rendered by the <@tag> macro (defined in the reserved base.ftlh, see
below). It takes a domain and a value and emits <span class="tag <domain> <value>">, which
chrome.css styles as a pill:
<@tag domain="status" value=release.status/>
<#-- <span class="tag status released">...</span> -->
value may be a string or an enum constant (its name() is used). Both arguments are lowercased and
validated against ^[a-z0-9_-]+$; anything else (including HTML-significant characters) fails the
render with a TemplateModelException rather than emitting unsafe markup.
FreeMarker composition — <@tag>
ConsoleFreemarkerMixin extends FreemarkerMixin: register it exactly where you'd register a
plain FreemarkerMixin, typed as FreemarkerMixin (the return type is load-bearing —
FreemarkerViewRenderer looks it up by that exact type):
import org.apache.juneau.rest.server.view.freemarker.*;
import org.apache.juneau.console.freemarker.*;
@Rest(mixins=FreemarkerMixin.class)
public class AdminResource extends BasicRestServlet {
@Bean
public FreemarkerMixin freemarker() {
return ConsoleFreemarkerMixin.create().basePath("/templates/").build();
}
}
ConsoleFreemarkerMixin augments the resolved Configuration's TemplateLoader with a
second, classpath-root-rooted ClassTemplateLoader — your own basePath-relative
templates are tried first, so nothing under your /templates/ tree is ever shadowed. Pull in
the reserved chrome template with a loader-root-absolute include (a bare relative path
resolves against the including template's own directory, not the classpath root, and would
miss):
<#include "/org/apache/juneau/console/base.ftlh">
...
<@tag domain="status" value=release.status/>
For tables, use <@card type="datatables"> (see Building a Console Page). Server-side
processing uses the data-juneau-datatable-ajax attribute described in
DataTables.
Consumer-supplied Configuration is honored, not augmented. If your BeanStore already
has a freemarker.template.Configuration bean (Spring Boot autoconfig, or a microservice
BasicBeanStore.put), resolveConfiguration returns that exact instance untouched — the
console loader (and, transitively, the <@tag> macro) is not spliced in. Add
the console template location to your own Configuration's loader if you need it in that
setup.
DataTablesTable now honors @Html(render=...) (behavior change, not purely additive)
DataTablesTable.of(MarshallingContext, String, Collection<?>, Class<?>) — the
bean-row-type overload — now resolves and applies each property's @Html(render=...) the
same way the ordinary HtmlSerializer/HtmlDocSerializer path already does. This is not a
purely additive change: a bean already using @Html(render=...) for the existing
serializer path now also renders through that same render when passed through this
DataTablesTable overload, where previously it rendered the raw value. Map-based rows (no
BeanPropertyMeta to resolve a render from) are unaffected and continue to render raw values.
FreeMarker directive reference
ConsoleFreemarkerMixin registers these directives as shared variables on the Configuration, each only
if you have not already defined a variable of that name. Every directive rejects an unknown attribute, and
rejects a format= attribute, with a TemplateModelException that names the directive. Attribute values
that are lists (init, css, toolkit) accept either a comma-delimited string or a sequence.
Nesting: <@page> is the outermost directive. Its body holds the page content and <@card> elements;
it then includes the chrome template (default base.ftlh, settable with the builder's
chromeTemplate(String)), and that template must contain the <@console> element. Everything else that
describes the chrome nests inside <@console>.
| Directive | Attributes | Where it goes |
|---|---|---|
<@page> | tab, init, css, toolkit | Outermost; needs a body; cannot be nested or repeated. |
<@console> | theme, icon, favicon, brand, title, chrome, scheme, schemeToggle | Inside the chrome template, once; cannot be nested. Writes the whole HTML document. |
<@navigation> | layout | Inside <@console>; not nestable. |
<@node> | id, label, href, visible, selected | Inside <@navigation> or another <@node>. |
<@main/> | none | Inside <@console>; self-closing, marks where the page body goes. |
<@badge> | id, src, refreshMs, href, placement, table, tone, labelOne, labelOther, labelMine, tooltipMax, refreshes, refreshesAllowMissing, scopeParam, scopeBy, stateFilterColumn | Inside <@console>; repeatable, one per badge id. See <@badge> and BadgeDef. |
<@card> | type, id, title, src, template, requires, class | Inside <@page>; not nestable. |
<@theme> / <@token> | name / name, value, alias, scheme | <@theme> inside <@console>; <@token> inside <@theme>. |
slots: <@head>, <@scripts>, <@brand>, <@actions>, <@title>, <@footer>, <@body> | phase (head, scripts), text (footer) | Inside <@console>. |
<@card>
class- space-separated CSS class names added to the card's outer wrapper element (div.jc-card), after the built-injc-cardclass. Each name must match[A-Za-z_-][A-Za-z0-9_-]*; anything else fails the render. Use it to keep an existing page-scope selector working, for example<@card id="skills" class="ssc-skills">keeps.ssc-skills .rowrules andclosest(".ssc-skills")lookups matching. It applies to cards that have a wrapper; the markup between cards is not wrapped and takes no class.
<@page>
tab- the active navigation path as node ids joined by/(for exampletab="new/input"). An unknown path fails the render and the message lists the known paths.init- script URLs emitted as<script src>after the toolkit scripts, in the order given.css- stylesheet URLs emitted as<link rel="stylesheet">after the theme.toolkit- toolkit packs to load.viewsis always registered;calendaris registered too and is never inferred from a card type, so a calendar page liststoolkit="views,calendar". An unknown pack name fails the render. Register your own with the builder'sregisterToolkitPack(name, cssPaths, jsPaths). The template functionjcHasToolkit("views")reports whether a pack was requested.
<@console>
theme- one ofopen(default),light-red,light-brown,red,gray; anything else fails the render listing the built-in names. A nested<@theme>takes precedence.brand- header title text.title- the document<title>; defaults tobrand.icon- header logo URL, resolved against thecontext:/URI.favicon- emitted as<link rel="icon">.chrome- strict boolean (trueorfalseonly; anything else, including an empty string, fails). Defaultfalse.scheme-auto(default),lightordark; the page's pin for the color scheme.schemeToggle- strict boolean, defaultfalse; adds the header's Auto / Light / Dark control. See Dark scheme.
The directive also emits the chrome.css link, the theme link, any inline token override block, <meta name="page-tab">, and, when the request carries a CSRF token, a csrf-token meta tag and the
data-juneau-csrf body attributes.
<@navigation> and <@node>
layout-horizontalorvertical; any other value fails the render.id- required; must match^[A-Za-z][A-Za-z0-9_-]{0,63}$.label- required.href- optional for a node with children, required for a leaf.visible- strict boolean, defaulttrue. Whenfalsethe node and its children are omitted, which is how you gate entries on a permission check.selected- strict boolean, defaultfalse; selects that node's id path as the active navigation.
<@navigation layout="vertical">
<@node id="setup" label="Setup" href="/rest/setup"/>
<@node id="admin" label="Admin" visible=user.admin>
<@node id="users" label="Users" href="/rest/users"/>
</@node>
</@navigation>
Slot directives
Each slot captures its body as markup. <@head> adds to <head>; <@scripts> adds before </body>;
<@body> supplies extra attributes for the <body> tag; <@brand> and <@actions> fill header slots;
<@title> replaces the header title; <@footer> fills the footer.
<@head phase="before-page-css">places the markup before the page'scsslinks.<@scripts phase="after-toolkit">places the markup immediately after the toolkit scripts. Any otherphasevalue fails the render.<@footer text="...">sets plain text; a<@footer>with a body supplies markup. Giving both fails the render.
<@badge> and BadgeDef
A badge is a count pill in the console header (or in a table's ribbon). juneau-badges.js polls a same-origin
JSON endpoint that returns {total, mine?, items?[]}, shows a label such as "3 changes pending", hides itself at
zero, and lists up to tooltipMax items in a hover popover. BadgeDef is the Java form, added with
PageSpec.badge(BadgeDef); <@badge> is its FreeMarker twin and builds the same BadgeDef. Both end up in the
page contract's header.badges[] array.
<@console theme="open" brand="Petstore">
<@navigation layout="horizontal"> ... </@navigation>
<@badge id="pending" src="/console/pets/changes/pending" href="/console/pets/changes"
refreshMs=60000 placement="header" refreshesAllowMissing="pets"/>
</@console>
The same badge in Java:
BadgeDef badge = BadgeDef.create("pending")
.src("/console/pets/changes/pending")
.href("/console/pets/changes")
.refreshMs(60_000)
.placement(BadgeDef.Placement.HEADER)
.refreshesAllowMissing("pets");
badge.validate();
pageSpec.badge(badge);
Attributes (the BadgeDef method is the same name unless noted):
id- required; unique across everyPageSpecand<@badge>badge on the page. A duplicate fails the render.src- required, a same-origin path (SafePathTemplate.isSameOriginPath).href- optional click target, also same-origin; the script refuses an unsafesrcorhrefagain at run time.refreshMs- poll interval in milliseconds. The script defaults to 60000; a value below 5000 (BadgeDef.MIN_REFRESH_MS) is rejected byvalidate(), not clamped.placement-header(default) ortoolbar.toolbarrenders at the right end of the ribbon of the card named bytable, so it requirestable.tone-info,warning(default) ordanger.labelOne,labelOther,labelMine(BadgeDef.label(Map)with keysone,other,mine) - templates using{total}and{mine}. Stock defaults:{total} change pending,{total} changes pendingand({mine} yours).tooltipMax- items listed in the popover, 0 to 50 (the script clamps); default 10.scopeParamandscopeBy(BadgeDef.scope(param, by, values)) - the query parameter sent with each poll and how its value is keyed:nav(the leaf of the active navigation) orview(the active tab of the card named bytable, soscopeBy="view"requirestable). In the directive both must be given together; the Java form defaults a missingbytonav. The per-key value lists come from the body (below). A scoped badge whose key has no values on the current page hides itself and keeps polling.stateFilterColumn(stateFilter(column)) - the destination table's filter column. With a scope, the click URL gets astate=parameter built byJuneauViews.urlState.encode(...).refreshesandrefreshesAllowMissing- card ids to reload when an item id seen on one poll is missing from the next. Everytableandrefreshesid must name a card on the page, or the render fails at</@console>; ids inrefreshesAllowMissingare exempt, for a header badge that refreshes a card on another page (the client skips a card that is not mounted).
The directive's optional body is JSON5 with three keys, because those fields have no flat shape: visibleWhen
(a list of {field, op, value} rules, all of which must match; op is eq, ne, present, absent, in or
contains), scopeValues (a map from nav node id, or view tab id, to a list of values) and params (extra query
parameters). Any other key fails the render. For scopeBy="nav", each scopeValues key must be a node id in the
navigation tree.
<@badge id="health" src="/rest/health" refreshMs=30000 tone="warning">
{"visibleWhen": [{"field": "status", "op": "eq", "value": "degraded"}]}
</@badge>
A badge is presentation only. The src endpoint must still authorize the caller, and a page should not declare a
badge for users who cannot read it. The script sends Accept: application/json (Juneau negotiates content, so a
plain GET would be answered with HTML), treats a 401 or 403 as "remove the badge and stop polling", and retries any
other failure with exponential backoff. validate() throws IllegalArgumentException for a missing src, an unsafe
src or href, an out-of-range refreshMs, placement="toolbar" without table, and scopeBy="view" without
table; the directive reports the same messages as render failures.
Source: BadgeDef in juneau-console-views; BadgeDirectiveModel, PageSpec.badge and PageCapture in
juneau-console-freemarker; juneau-badges.js in juneau-console. For a worked
example with its endpoint, see Petstore: Pending Changes.
Cards, themes, and tokens
<@card type> is html (the default), datatables, terminal-view, terminal, chart or job-log (see
Terminal View Region and Terminal Region); js, json and calendar were removed in 10.0.0 and
fail with a message naming the replacement. An html card takes exactly one of a body, template= or
src=; a datatables card requires id and takes a table URL or JSON catalog as its body. <@token>
requires exactly one of value or alias; scheme="dark" limits it to the dark scheme.
Theme token catalog
These are the tokens defined by Theme.OPEN; the four other stock themes override a subset (page
background, accent, link, text and border colors, chrome and button colors, avatar gradient) and inherit the
rest. Token names match ^--jc-[a-z0-9-]+$, and theme names ^[a-z][a-z0-9-]*$.
| Group | Tokens |
|---|---|
| Typography and shape | --jc-font, --jc-radius, --jc-card-padding, --jc-card-shadow |
| Page surfaces | --jc-page-bg, --jc-main-bg, --jc-card-bg, --jc-chrome-bg, --jc-white, --jc-table-stripe-bg |
| Text and borders | --jc-text, --jc-text-soft, --jc-text-muted, --jc-border, --jc-border-2, --jc-link |
| Accent and focus | --jc-accent, --jc-accent-wash, --jc-accent-selected, --jc-focus |
| Buttons and status | --jc-btn-primary, --jc-btn-primary-hover, --jc-danger, --jc-danger-wash, --jc-success, --jc-success-wash |
| Header and icons | --jc-header-icon-color, --jc-header-height, --jc-chrome-icon, --jc-avatar-bg |
| Navigation | --jc-nav-indicator-width, --jc-page-nav-hairline, --jc-page-nav-section-font-size, --jc-page-nav-child-font-size |
| Spacing | --jc-space-1 through --jc-space-6 |
Pills (<@tag> colors) | --jc-pill-{green,blue,amber,neutral,red}-{bg,text,border}; --jc-tag-*-{bg,text,border} are aliases of the matching pill tokens |
| Probe status | --jc-probe-{ok,fail,warn,neutral}-{bg,text,dot} |
--jc-page-bg and --jc-avatar-bg hold gradients; every other color token takes a plain color.
The page contract and the console shell
Every <@console> page carries one JSON document, the page contract, in
<script type="application/json" id="juneau-page">, and loads one script,
/juneau-console/juneau-console.js. The server still renders <head> (CSS, theme, CSP-sensitive
links). Everything else is drawn by the shell in the browser: the header, the nav (any depth), the
cards and the footer. The contract's schema ships as
org/apache/juneau/console/juneau-page.schema.json in juneau-console.
<script type="application/json" id="juneau-page">
{"contractVersion":"1","title":"Releases","theme":{"name":"open"},
"header":{"title":"Release Manager","chrome":true},
"nav":[{"id":"setup","label":"Setup","href":"/rest/setup"},
{"id":"releases","label":"Releases","href":"/rest/releases"}],
"activeNav":["releases"],
"cards":[{"id":"releases","type":"datatables","table":{"dataUrl":"/rest/releases/data"}}],
"footer":{"text":"Apache Juneau Release Manager"}}
</script>
Authoring
The FreeMarker directives write the contract; you never write JSON by hand.
<@console brand="Release Manager" chrome="true">
<@navigation>
<@node id="setup" label="Setup" href="/rest/setup"/>
<@node id="new" label="New Release" href="/rest/runs">
<@node id="input" label="Input" href="/rest/runs?tab=input"/>
<@node id="exec" label="Execution" href="/rest/runs?tab=exec"/>
</@node>
</@navigation>
<@main/>
<@footer text="Apache Juneau Release Manager"/>
</@console>
<@page tab="new/input">setsactiveNav. The shell stampsaria-current="page"on every node of that path and draws one child row per selected ancestor. With notab=, the shell falls back to the longest nodehrefthat prefixes the current URL, on path-segment boundaries (/rest/runsmatches/rest/runs/7but not/rest/runsx). Query strings count only when the node's href has one.- Markup in a
<@page>body becomes<template data-card>elements, which are inert until the shell mounts them. Inline<script>s in them run at mount, in document order. <@card type="html">takes exactly one of a body,template=or a same-originsrc=.<@card type="datatables" id="…">carries its table catalog in the contract. The shell hands it toJuneauViews.regions.mountonDOMContentLoaded, so views JS may load after the shell.- An
htmlcard'ssrc=fragment is inserted as served, so the endpoint must be first-party and must escape any data it embeds. Addsanitize=true("sanitize": truein the page contract,CardSpec.sanitize(true)in Java) to rebuild the fragment through the views attribute-allowlist sanitizer instead: script,on*handlers,javascript:anddata:URLs and every attribute off the allowlist are dropped, and benign markup (text, tables, links, images) survives.sanitize=needssrc=and the views toolkit on the page; without the toolkit the card paints nothing and reports E-JS-8. The default stays off. Turn it on when the fragment's content is not fully under your control (it embeds user-supplied or third-party markup, or comes from an endpoint you do not own); leave it off for a fragment that needsclass,id,style, forms, SVG or iframes, which the allowlist drops. - Plain-text footers use
<@footer text="…"/>. A<@footer>body is a markup slot. jcHasToolkit("views")reports whether the page asked for the views toolkit.- A
<@card type="datatables">loads jQuery, DataTables and the Juneau glue (juneau-datatables.js) before theviewsruntime, client or server mode. The host composesDataTablesMixin(the glue) andWebJarsMixin(the libraries). Other cards can require more packs. See Card Asset Dependencies.
JavaScript API
window.JuneauConsole exposes contract(), card(id) (both deep-frozen), registerCard(type, handler)
for custom card types, mount(contract, {root}), chrome (user menu and detail-bar hydration), and
schemeControl(doc) (the Auto / Light / Dark color-scheme control; see Dark scheme).
The shell dispatches juneau:console-mounted on document with {activeNav, activeNavSource, cards}.
Failures are loud
A template mistake fails the render with an HTTP 500 and a message naming the directive and the fix
(error codes E-1 to E-18): an unknown attribute, a non-literal boolean, a duplicate card id, an
unknown tab= path (the message lists the known paths), stray markup outside <@main/>, or one of
the removed card types. In the browser, a contract the shell cannot use (unsupported
page contractVersion, an unknown card type, a missing template, a cross-origin src) shows a
.jc-console-error banner and logs [juneau-console] … to the console (E-JS-1 to E-JS-13). It never
renders a blank page silently.
Testing pages
PageContractAssert (in org.apache.juneau.console.test, no JUnit dependency) parses
the contract from a rendered page:
assertPage(body)
.isValid()
.hasActiveNav("new", "input")
.hasNavChildren("new", "input", "exec")
.hasCard("releases", "datatables")
.hasFooterText("Apache Juneau Release Manager");
ConsoleTemplateValidator lints templates without rendering them:
var findings = ConsoleTemplateValidator.create()
.templateRoot(Path.of("src/main/resources/templates"))
.chromeTemplate("base.ftlh")
.validateAll();
assertTrue(findings.isEmpty(), findings::toString);
Set ConsoleFreemarkerMixin.create().devMode(true) (or -Djuneau.console.devMode=true) to validate
every contract against the schema at render time (E-14).
Limitations and out-of-scope (v1)
- No server-rendered chrome. Header, nav, cards and footer exist only as the page contract and
are drawn by
juneau-console.js. A page viewed without JavaScript shows its<head>styling and nothing else. Tests should assert on the contract (PageContractAssert), not on chrome HTML. - No
url()token form. Everyvar(--jc-*)value is validated by the accept-known-safe grammar described above, which structurally excludesurl()productions; a future release may add ajavascript:/data:/url()-scheme allowlist alongside an actualurl()token form, but v1 ships with neither. - The logo is a static
chrome.cssconstant, not a configurable token.
See also
In This Section
📄️ 3.3.62.1. Building a Console Page
This is the end-to-end tutorial for adding a page to an admin console built on Juneau: a resource that serves the
📄️ 3.3.62.2. Card Asset Dependencies
A console card can need scripts and stylesheets beyond the views toolkit. A datatables card needs jQuery,
📄️ 3.3.62.3. Chart Cards
A chart card draws a line, bar or donut chart from rows of JSON. Juneau draws it as inline SVG with no third-party
📄️ 3.3.62.4. Job-Log Card
A job-log card starts a long-running job and shows its progress as it runs. It has a small form and a log pane.