001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.nio.charset.*;
023import java.util.*;
024import java.util.concurrent.*;
025import java.util.concurrent.atomic.*;
026import java.util.stream.*;
027
028import org.apache.juneau.commons.utils.*;
029import org.apache.juneau.http.*;
030import org.apache.juneau.http.response.*;
031import org.apache.juneau.rest.server.*;
032import org.apache.juneau.rest.server.util.*;
033
034/**
035 * Mixin that serves the admin-console chrome stylesheet at {@code /juneau-console/chrome.css}.
036 *
037 * <p>
038 * Compose into a host resource via {@link Rest#mixins() @Rest(mixins=ConsoleChromeMixin.class)} (see
039 * {@code DataTablesMixin} in the {@code juneau-console-datatables} module for the identical asset-serving
040 * pattern this mirrors &mdash; not linked here since that module is not a dependency of this one). The served
041 * response is the static
042 * structural {@code chrome.css} (shipped in this module's classpath) with {@link Theme#OPEN}'s block, then the
043 * selected stock theme's override block if one other than {@code open} was selected, appended as
044 * {@code html:root{}} blocks.
045 *
046 * <h5 class='section'>Mount styles:</h5>
047 * <p>
048 * Both of the following arrangements serve the stylesheets at the {@link #CHROME_CSS_PATH} / {@link #THEME_CSS_DIR}
049 * URLs with no path juggling by the host resource:
050 * <ul>
051 *    <li><b>Composed</b> &mdash; the host resource is mounted wherever the application already mounts it (e.g.
052 *       {@code /rest/*}) and the stylesheets hang off that mount, at
053 *       <code>&lt;host-mount&gt;/juneau-console/chrome.css</code>.
054 *    <li><b>Standalone</b> &mdash; the host resource is registered with the servlet container at url-pattern
055 *       {@code /juneau-console/*} so the stylesheets sit at a fixed site-root URL, independent of which page or tab
056 *       rendered the referencing {@code <link>}.
057 * </ul>
058 * <p>
059 * The two arrangements need different operation paths, because a container mount at {@code /juneau-console/*}
060 * reports {@code servletPath="/juneau-console"} and Juneau resolves an operation's path against the request URI
061 * with {@code contextPath + servletPath} already removed &mdash; leaving only {@code /chrome.css} to match.
062 * Each operation below therefore declares <i>both</i> its prefixed path and the same path minus the
063 * {@code /juneau-console} prefix, so whichever one the arrangement leaves to be matched resolves.
064 *
065 * <h5 class='section'>Theme precedence</h5>
066 * <p>{@link Builder#theme(String)} if set, else {@link Theme#OPEN}. {@link Theme#OPEN}'s block is always appended
067 * first; a selected non-{@code open} stock theme adds exactly one more block.
068 *
069 * <h5 class='section'>Stylesheet load-order band:</h5>
070 * <p>
071 * At equal cascade specificity, whichever of {@code juneau-views.css} and this class's {@code chrome.css} is
072 * declared later in a page's {@code <head>} wins &mdash; the exact ambiguity the {@code overrideBlock} theme block
073 * below is raised to {@code html:root} to make irrelevant. Juneau's own in-tree page emitters (the examples
074 * module) don't rely on that fix alone: they declare a five-position stylesheet/script load-order band,
075 * enforced by a build-time test, so their own asset order is never left to chance in the first place:
076 * <ol>
077 *    <li>vendor stylesheets
078 *    <li>{@code juneau-views.css} &mdash; the views base layer
079 *    <li>first-party widget stylesheets that build on the views layer, e.g. {@code juneau-calendar.css}
080 *    <li>the consumer theme, e.g. this class's {@code chrome.css}
081 *    <li>page-local {@code <style>}
082 * </ol>
083 * <p>
084 * That guard reaches only the pages Juneau itself emits. It says nothing about, and claims no control over, an
085 * arbitrary host application's own document &mdash; a consumer is free to link {@code chrome.css} and
086 * {@code juneau-views.css} in either order, which is precisely why the {@code html:root} fix exists: so the
087 * active theme wins regardless of which order the consumer's links happen to appear in.
088 *
089 * <h5 class='section'>Example:</h5>
090 * <p class='bjava'>
091 *    <jc>// Stock chrome.css (Theme.OPEN tokens), mounted standalone at /juneau-console/*.</jc>
092 *    <ja>@Rest</ja>(mixins=ConsoleChromeMixin.<jk>class</jk>)
093 *    <jk>public class</jk> ConsoleAssetsRest <jk>extends</jk> BasicRestServlet {
094 *       <ja>@Bean</ja>
095 *       <jk>public</jk> ConsoleChromeMixin consoleChrome() {
096 *          <jk>return</jk> ConsoleChromeMixin.<jsm>create</jsm>().build();
097 *       }
098 *    }
099 *
100 *    <jc>// Bake a different stock palette into the served chrome.css.</jc>
101 *    ConsoleChromeMixin.<jsm>create</jsm>().theme(<js>"gray"</js>).build();
102 * </p>
103 *
104 * <p>
105 * Custom palettes aren't configured here. Author them in the FreeMarker chrome with {@code <@theme>}/{@code <@token>}
106 * (see the {@link Theme} example), which emits the stock-theme {@code <link>} plus an inline override block.
107 *
108 * @since 10.0.0
109 */
110// @formatter:off
111@Rest
112@SuppressWarnings({
113   "java:S125", // Comments are explanatory; they are not commented-out code.
114   "java:S1192" // Duplicated "html:root{" selector text is a CSS protocol fragment; a constant would obscure the emitter.
115})
116public class ConsoleChromeMixin {
117
118   /** The URL path at which the chrome stylesheet is served (relative to the host mount). */
119   public static final String CHROME_CSS_PATH = "/juneau-console/chrome.css";
120
121   /** {@link #CHROME_CSS_PATH} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */
122   static final String CHROME_CSS_PATH_UNPREFIXED = "/chrome.css";
123
124   /** Classpath location of the shipped structural stylesheet. */
125   static final String CHROME_CSS_RESOURCE = "/org/apache/juneau/console/chrome.css";
126
127   /**
128    * The URL directory the shipped stock-theme stylesheets are served under (relative to the host mount), e.g.
129    * {@code /juneau-console/themes/juneau-theme-light-red.css}. A subdirectory of the chrome mount so a
130    * templated {@code {file}} path cannot collide with the fixed {@link #CHROME_CSS_PATH} endpoint. The FTL
131    * {@code <@theme name="…"/>} directive links these; see {@link #themeAssetUrl(RestRequest, String)}.
132    */
133   public static final String THEME_CSS_DIR = "/juneau-console/themes/";
134
135   /** {@link #THEME_CSS_DIR} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */
136   static final String THEME_CSS_DIR_UNPREFIXED = "/themes/";
137
138   /** Classpath directory the shipped theme stylesheets live in (same package as {@link #CHROME_CSS_RESOURCE}). */
139   static final String THEME_CSS_RESOURCE_DIR = "/org/apache/juneau/console/";
140
141   /** The stock palettes by FTL/builder name, in documented order; the single source for the name list and file allowlist. */
142   private static final Map<String,Theme> STOCK_THEMES = stockThemes();
143
144   /**
145    * The built-in stock-theme names - the kebab-case of the {@link Theme} constants ({@link Theme#OPEN},
146    * {@link Theme#LIGHT_RED}, {@link Theme#LIGHT_BROWN}, {@link Theme#RED}, {@link Theme#GRAY}). Each maps to a
147    * shipped {@code juneau-theme-<name>.css} stylesheet. Consumed by the FTL {@code <@theme>} directive to fail an
148    * unknown name closed, and here to bound the served {@code {file}} to a known stock theme.
149    */
150   public static final List<String> BUILTIN_THEME_NAMES = List.copyOf(STOCK_THEMES.keySet());
151
152   /**
153    * The colour-scheme pin values, in documented order.
154    *
155    * <p>
156    * {@code auto} (the default) follows the browser's {@code prefers-color-scheme}; {@code light} and {@code dark}
157    * pin the page to that scheme. Accepted by {@code <@console scheme="…">}, {@code PageSpec.scheme(String)} and
158    * {@link #schemeHead(RestRequest, String, boolean)}.
159    */
160   public static final List<String> SCHEMES = List.of("auto", "light", "dark");
161
162   /**
163    * The inline pre-paint script that picks the page's colour scheme before first paint.
164    *
165    * <p>
166    * It sets {@code data-jc-scheme} on the root element from, in order: the visitor's stored choice (only when the
167    * root carries {@code data-jc-scheme-toggle}), the server pin ({@code data-jc-scheme-pin}), then the browser's
168    * {@code prefers-color-scheme}. It never throws: storage and media-query failures fall through to the next
169    * source, ending at {@code light}.
170    *
171    * <p>
172    * The text is a byte-for-byte constant so {@link #SCHEME_SCRIPT_CSP_HASH} is stable. Emit it as the first child
173    * of {@code <head>}, ahead of every stylesheet.
174    */
175   public static final String SCHEME_SCRIPT = String.join("\n",
176      "(function(d){var e=d.documentElement,k=\"juneau-console:scheme\",s=null;",
177      "if(e.hasAttribute(\"data-jc-scheme-toggle\")){try{s=localStorage.getItem(k)}catch(x){}}",
178      "if(s!==\"light\"&&s!==\"dark\"){s=e.getAttribute(\"data-jc-scheme-pin\");}",
179      "if(s!==\"light\"&&s!==\"dark\"){try{s=matchMedia(\"(prefers-color-scheme: dark)\").matches?\"dark\":\"light\"}catch(x){s=\"light\"}}",
180      "e.setAttribute(\"data-jc-scheme\",s);})(document);");
181
182   /**
183    * The CSP source expression for {@link #SCHEME_SCRIPT}: {@code 'sha256-<base64>'} over its UTF-8 bytes.
184    *
185    * <p>
186    * Add it to a {@code script-src} directive to allow the pre-paint script without a nonce. It covers only the
187    * script. The console shell's inline {@code <style>} (theme override blocks) still needs the request nonce or
188    * {@code 'unsafe-inline'} in {@code style-src}.
189    */
190   public static final String SCHEME_SCRIPT_CSP_HASH = sha256Source(SCHEME_SCRIPT);
191
192   /** The served stock stylesheet file names, derived from {@link #BUILTIN_THEME_NAMES}. */
193   private static final Set<String> ALLOWED_THEME_FILES = Stream.concat(
194         BUILTIN_THEME_NAMES.stream().map(n -> "juneau-theme-" + n + ".css"),
195         // The shared dark neutral layer is a served theme file but not a selectable theme, so it is not in
196         // BUILTIN_THEME_NAMES (theme="dark" must keep failing validation).
197         Stream.of("juneau-theme-dark.css"))
198      .collect(Collectors.toUnmodifiableSet());
199
200   private static Map<String,Theme> stockThemes() {
201      Map<String,Theme> m = m(
202         "open", Theme.OPEN,
203         "light-red", Theme.LIGHT_RED,
204         "light-brown", Theme.LIGHT_BROWN,
205         "red", Theme.RED,
206         "gray", Theme.GRAY
207      );
208      return u(m);
209   }
210
211   /** Content type emitted for the chrome stylesheet. */
212   static final String CONTENT_TYPE = "text/css;charset=utf-8";
213
214   /** {@code Cache-Control} header emitted for the chrome stylesheet (1 day). */
215   static final String CACHE_CONTROL = "max-age=86400, public";
216
217   /** Context-root-absolute path of the console shell script. */
218   public static final String CONSOLE_JS_PATH = "/juneau-console/juneau-console.js";
219
220   /** {@link #CONSOLE_JS_PATH} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */
221   public static final String CONSOLE_JS_PATH_UNPREFIXED = "/juneau-console.js";
222
223   /** Classpath location of the console shell script. */
224   public static final String CONSOLE_JS_RESOURCE = "/org/apache/juneau/console/juneau-console.js";
225
226   /** Context-root-absolute path of the count-badge script. */
227   public static final String BADGES_JS_PATH = "/juneau-console/juneau-badges.js";
228
229   /** {@link #BADGES_JS_PATH} minus the {@code /juneau-console} prefix. */
230   public static final String BADGES_JS_PATH_UNPREFIXED = "/juneau-badges.js";
231
232   /** Classpath location of the count-badge script. */
233   public static final String BADGES_JS_RESOURCE = "/org/apache/juneau/console/juneau-badges.js";
234
235   /** Context-root-absolute path of the chart-card runtime. */
236   public static final String CHARTS_JS_PATH = "/juneau-console/juneau-charts.js";
237
238   /** {@link #CHARTS_JS_PATH} minus the {@code /juneau-console} prefix. */
239   public static final String CHARTS_JS_PATH_UNPREFIXED = "/juneau-charts.js";
240
241   /** Classpath location of the chart-card runtime. */
242   public static final String CHARTS_JS_RESOURCE = "/org/apache/juneau/console/juneau-charts.js";
243
244   /** Context-root-absolute path of the job-log card runtime. */
245   public static final String JOBLOG_JS_PATH = "/juneau-console/juneau-joblog.js";
246
247   /** {@link #JOBLOG_JS_PATH} minus the {@code /juneau-console} prefix. */
248   public static final String JOBLOG_JS_PATH_UNPREFIXED = "/juneau-joblog.js";
249
250   /** Classpath location of the job-log card runtime. */
251   public static final String JOBLOG_JS_RESOURCE = "/org/apache/juneau/console/juneau-joblog.js";
252
253   /** Context-root-absolute path of the dashboard chrome runtime. */
254   public static final String DASHBOARD_JS_PATH = "/juneau-console/juneau-dashboard.js";
255
256   /** {@link #DASHBOARD_JS_PATH} minus the {@code /juneau-console} prefix. */
257   public static final String DASHBOARD_JS_PATH_UNPREFIXED = "/juneau-dashboard.js";
258
259   /** Classpath resource behind {@link #DASHBOARD_JS_PATH}. */
260   public static final String DASHBOARD_JS_RESOURCE = "/org/apache/juneau/console/juneau-dashboard.js";
261
262   /** Context-root-absolute path of the page-contract schema. */
263   public static final String SCHEMA_PATH = "/juneau-console/juneau-page.schema.json";
264
265   /** {@link #SCHEMA_PATH} minus the {@code /juneau-console} prefix. */
266   static final String SCHEMA_PATH_UNPREFIXED = "/juneau-page.schema.json";
267
268   /** Classpath location of the page-contract schema (same file as {@link PageContractSchema#RESOURCE}). */
269   static final String SCHEMA_RESOURCE = "/" + PageContractSchema.RESOURCE;
270
271   static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8";
272   static final String JSON_CONTENT_TYPE = "application/json;charset=utf-8";
273
274   /**
275    * The framework-authored role-token alias derivations.
276    *
277    * <p>
278    * {@code chrome.css} consumes <i>role</i>-named tokens ({@code --jc-header-bg}, {@code --jc-surface},
279    * {@code --jc-on-accent}, ...); {@link Theme#OPEN} (and every consumer theme) still defines the original
280    * <i>colour</i>-named leaf tokens ({@code --jc-white}, {@code --jc-border-2}, ...). This block bridges the two
281    * by deriving each role token <b>from</b> the legacy token it replaces, so a consumer's existing override of a
282    * legacy token still flows through to every derived role, and a consumer who sets a role token directly
283    * out-ranks the derived default by cascade order (this block is emitted inside the {@code Theme.OPEN} block,
284    * before any active-theme override block).
285    *
286    * <p>
287    * These aliases are <b>permanent</b> (no deprecation window) and are deliberately <i>not</i>
288    * {@link Theme#OPEN} tokens. A {@code var(--jc-*)}-valued token IS legal Theme-layer syntax &mdash;
289    * {@code Theme.Builder} recognizes it as a reference and resolves it to a concrete literal at {@code build()}
290    * time &mdash; but that resolution scope deliberately excludes these role aliases, which are appended here
291    * outside {@code Theme.OPEN}'s token map. Making them {@code Theme.OPEN} tokens instead would resolve each alias
292    * to a <i>fixed literal</i> at composition time, snapshotting the live CSS cascade (the dark-mode /
293    * user-agent overrides that reach these role tokens at use time) into a frozen value &mdash; so
294    * {@code Theme.OPEN} is kept all-literal and the aliases stay here as framework-authored literal text that is
295    * emitted verbatim (never routed through {@code CssValueGrammar}, which exists to validate <i>consumer</i>
296    * input, not the framework's own stylesheet). {@code Theme.OPEN} owns leaf values; this block owns derived
297    * values; no token name is declared by both.
298    *
299    * <p>
300    * {@code --jc-danger-text} and {@code --jc-success-text} are the text-ink roles of the danger and success
301    * colours; the fills keep {@code --jc-danger} and {@code --jc-success}.
302    *
303    * <p>
304    * {@code --jc-header-bg} (and, transitively, {@code --jc-nav-bg}) keys off {@code --jc-white}, not
305    * {@code --jc-chrome-bg}: in the light scheme the header and both page-nav rows (Page Tabs and Page Subtabs)
306    * stay white on every stock theme (the dark layer sets the header background directly), matching
307    * {@link Theme#OPEN}'s blue look. Theme accent still paints the selected-tab
308    * indicator, page-nav floor, and the rest of the page; {@code --jc-chrome-bg} remains the page-chrome
309    * fallback under {@code --jc-page-bg}, the hover fill, and dialog chrome. Pinning header/nav to
310    * {@code --jc-chrome-bg} washed those bars in light-red / light-brown / red / gray. {@code --jc-page-nav-accent}
311    * defaults to {@code --jc-accent} so a consumer can retint the page-nav floor / selected-section bar without
312    * recoloring buttons. {@code --jc-table-header-bg} also keys off {@code --jc-white} so DataTable / views-table
313    * column headers stay white (the original artwork gives {@code table.dataTable thead th} no background), not the page-chrome grey.
314    */
315   static final String OPEN_ROLE_ALIASES = String.join("",
316      "--jc-surface:var(--jc-white);",
317      "--jc-header-bg:var(--jc-white);",
318      "--jc-nav-bg:var(--jc-header-bg);",
319      "--jc-page-nav-accent:var(--jc-accent);",
320      "--jc-control-bg:var(--jc-surface);",
321      "--jc-table-bg:var(--jc-surface);",
322      "--jc-table-row-bg:var(--jc-surface);",
323      "--jc-on-accent:var(--jc-white);",
324      "--jc-on-btn-primary:var(--jc-on-accent);",
325      "--jc-hover-bg:var(--jc-chrome-bg);",
326      // DataTable / views-table headers are white (the original artwork gives `table.dataTable thead th` no background;
327      // the white card shows through). Do not key this off --jc-chrome-bg — that page-chrome grey tints
328      // every column header and is the wrong surface (the detail-table thead grey is a different widget).
329      "--jc-table-header-bg:var(--jc-white);",
330      "--jc-control-border:var(--jc-border-2);",
331      "--jc-header-icon-text:var(--jc-header-icon-color);",
332      "--jc-btn-primary-bg:var(--jc-btn-primary);",
333      "--jc-btn-primary-bg-hover:var(--jc-btn-primary-hover);",
334      // Text roles: danger/success used as ink, so the dark layer can lighten them without touching the fills.
335      "--jc-danger-text:var(--jc-danger);",
336      "--jc-success-text:var(--jc-success);"
337   );
338
339   /**
340    * The names of the role aliases the chrome declares (for example {@code --jc-surface} and
341    * {@code --jc-danger-text}), derived from the same declarations the stylesheet emits.
342    */
343   private static final Set<String> ROLE_ALIAS_NAMES = Arrays.stream(OPEN_ROLE_ALIASES.split(";"))
344      .map(d -> d.substring(0, d.indexOf(':')))
345      .collect(Collectors.toUnmodifiableSet());
346
347   /**
348    * Returns the names of the role aliases the chrome declares, such as {@code --jc-surface},
349    * {@code --jc-danger-text} and {@code --jc-header-bg}.
350    *
351    * <p>
352    * These are the custom properties the chrome derives from the {@link Theme#OPEN} tokens. They are not tokens of
353    * any {@link Theme}, but a page resolves them at run time, so the FreeMarker {@code <@token scheme="dark">}
354    * alias check accepts them as targets.
355    *
356    * @return An unmodifiable set of custom-property names; never <jk>null</jk>.
357    */
358   public static Set<String> roleAliasNames() {
359      return ROLE_ALIAS_NAMES;
360   }
361
362   /** The shipped static chrome.css bytes, read once from the classpath (shared - the static file never varies by theme). */
363   private static volatile String staticCss;
364
365   /**
366    * Read+cache+hash+serve helper for the configured theme/chrome assets, anchored on this class so
367    * {@link ClasspathAssetCache#buildVersion()} resolves this module's own implementation version (see that
368    * class's javadoc's version-anchor section).
369    */
370   private static final ClasspathAssetCache ASSET_CACHE = new ClasspathAssetCache(ConsoleChromeMixin.class);
371
372   private final boolean cacheAssets;
373   private final Theme theme;
374
375   /** Per-mixin-instance cache of the fully-assembled (static + theme blocks) response body, keyed by mount (see {@link #mountKey}). */
376   private final Map<String,byte[]> cachedBodies = new ConcurrentHashMap<>();
377
378   /** Test-only diagnostic: counts every call to {@link #buildBody(RestRequest)} (i.e. every cache miss / every call when caching is disabled). */
379   private final AtomicInteger buildCount = new AtomicInteger();
380
381   /**
382    * No-arg constructor, mirroring {@code FreemarkerMixin()}'s equivalent so a bean-store lookup miss can fall back
383    * to a default-configured instance without relying on {@code BeanInstantiator}'s builder-pattern detection.
384    */
385   public ConsoleChromeMixin() {
386      this(create());
387   }
388
389   /**
390    * Constructor.
391    *
392    * @param builder The builder.
393    */
394   protected ConsoleChromeMixin(Builder builder) {
395      this.cacheAssets = builder.cacheAssets;
396      this.theme = builder.theme;
397   }
398
399   /**
400    * Creates a new builder.
401    *
402    * @return A new builder.
403    */
404   public static Builder create() {
405      return new Builder();
406   }
407
408   /**
409    * [GET /juneau-console/chrome.css] &mdash; serve the admin-console chrome stylesheet.
410    *
411    * @param req The current request, used to key the per-mount body cache.
412    * @return The chrome stylesheet as a CSS {@link HttpResource}.
413    * @throws IOException If the shipped {@code chrome.css} resource could not be read (effectively unreachable
414    *    &mdash; the resource is shipped in the same jar as this class).
415    */
416   @RestGet(
417      path={CHROME_CSS_PATH, CHROME_CSS_PATH_UNPREFIXED},
418      summary="Admin-console chrome stylesheet",
419      description="Structural CSS for the admin-console chrome, with the selected stock theme's tokens appended.",
420      swagger=@OpSwagger(ignore=true)
421   )
422   public HttpResource getChromeCss(RestRequest req) throws IOException {
423      var body = cacheAssets ? cachedBody(req) : buildBody();
424      return ASSET_CACHE.wrap(body, CONTENT_TYPE, CACHE_CONTROL);
425   }
426
427   /**
428    * [GET /juneau-console/themes/{file}] &mdash; serve one of the shipped stock-theme stylesheets.
429    *
430    * <p>
431    * The {@code {file}} segment is fail-closed to the known {@link #BUILTIN_THEME_NAMES} stylesheet filenames, so
432    * this endpoint can never read an arbitrary classpath resource. Each stylesheet is a structure-free
433    * {@code html:root{}} token block (one per {@link Theme} constant); the FTL {@code <@theme name="…"/>} directive
434    * links the winning one after {@code chrome.css} so its tokens override {@code chrome.css}'s baked-in
435    * {@link Theme#OPEN} block.
436    *
437    * @param file The requested stylesheet filename (e.g. {@code juneau-theme-light-red.css}).
438    * @return The theme stylesheet as a CSS {@link HttpResource}.
439    * @throws NotFound If {@code file} is not one of the shipped stylesheet filenames.
440    */
441   @RestGet(
442      path={THEME_CSS_DIR + "{file}", THEME_CSS_DIR_UNPREFIXED + "{file}"},
443      summary="Admin-console stock theme stylesheet",
444      description="One of the shipped juneau-theme-<name>.css stock theme stylesheets (html:root token block), linked by the FTL <@theme> directive.",
445      swagger=@OpSwagger(ignore=true)
446   )
447   public HttpResource getThemeCss(@Path("file") String file) {
448      if (! ALLOWED_THEME_FILES.contains(file))
449         throw new NotFound("Unknown theme stylesheet: " + file);
450      return ASSET_CACHE.serve(THEME_CSS_RESOURCE_DIR + file, CONTENT_TYPE, CACHE_CONTROL);
451   }
452
453   /**
454    * [GET /juneau-console/juneau-console.js] &mdash; serve the console shell.
455    *
456    * @return The script.
457    */
458   @RestGet(
459      path={CONSOLE_JS_PATH, CONSOLE_JS_PATH_UNPREFIXED},
460      summary="Console shell script",
461      description="Renders the #juneau-page contract into header, nav, cards and footer.",
462      swagger=@OpSwagger(ignore=true)
463   )
464   public HttpResource getConsoleJs() {
465      return ASSET_CACHE.serve(CONSOLE_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
466   }
467
468   /**
469    * [GET /juneau-console/juneau-badges.js] &mdash; serve the count-badge script.
470    *
471    * @return The script.
472    */
473   @RestGet(
474      path={BADGES_JS_PATH, BADGES_JS_PATH_UNPREFIXED},
475      summary="Console count-badge script",
476      description="Polls and renders the header.badges entries of the #juneau-page contract.",
477      swagger=@OpSwagger(ignore=true)
478   )
479   public HttpResource getBadgesJs() {
480      return ASSET_CACHE.serve(BADGES_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
481   }
482
483   /**
484    * [GET /juneau-console/juneau-charts.js] &mdash; serve the chart-card runtime.
485    *
486    * @return The script.
487    */
488   @RestGet(
489      path={CHARTS_JS_PATH, CHARTS_JS_PATH_UNPREFIXED},
490      summary="Console chart-card runtime",
491      description="Renders type='chart' cards of the #juneau-page contract as inline SVG.",
492      swagger=@OpSwagger(ignore=true)
493   )
494   public HttpResource getChartsJs() {
495      return ASSET_CACHE.serve(CHARTS_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
496   }
497
498   /**
499    * [GET /juneau-console/juneau-joblog.js] &mdash; serve the job-log card runtime.
500    *
501    * @return The script.
502    */
503   @RestGet(
504      path={JOBLOG_JS_PATH, JOBLOG_JS_PATH_UNPREFIXED},
505      summary="Console job-log card runtime",
506      description="Submits, streams and cancels AsyncJobsMixin jobs for type='job-log' cards.",
507      swagger=@OpSwagger(ignore=true)
508   )
509   public HttpResource getJobLogJs() {
510      return ASSET_CACHE.serve(JOBLOG_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
511   }
512
513   /**
514    * [GET /juneau-console/juneau-dashboard.js] &mdash; serve the dashboard chrome runtime.
515    *
516    * @return The script.
517    */
518   @RestGet(
519      path={DASHBOARD_JS_PATH, DASHBOARD_JS_PATH_UNPREFIXED},
520      summary="Console dashboard chrome runtime",
521      description="The rail, grid, status header, persistence and share links of a PageSpec dashboard.",
522      swagger=@OpSwagger(ignore=true)
523   )
524   public HttpResource getDashboardJs() {
525      return ASSET_CACHE.serve(DASHBOARD_JS_RESOURCE, JS_CONTENT_TYPE, CACHE_CONTROL);
526   }
527
528   /**
529    * [GET /juneau-console/juneau-page.schema.json] &mdash; serve the page-contract schema.
530    *
531    * @return The schema.
532    */
533   @RestGet(
534      path={SCHEMA_PATH, SCHEMA_PATH_UNPREFIXED},
535      summary="Console page-contract schema",
536      description="JSON Schema 2020-12 for the #juneau-page contract.",
537      swagger=@OpSwagger(ignore=true)
538   )
539   public HttpResource getPageSchema() {
540      return ASSET_CACHE.serve(SCHEMA_RESOURCE, JSON_CONTENT_TYPE, CACHE_CONTROL);
541   }
542
543   /**
544    * Returns a real, browser-fetchable URL for a shipped theme stylesheet, resolved against the request's
545    * <b>context root</b> (not the page resource) and carrying the same {@code ?v=<buildVersion>-<hash8>}
546    * content-sensitive cache-buster {@link #chromeCssUrl(RestRequest)} uses.
547    *
548    * <p>
549    * The console document shell ({@code <@console>} / {@code <@theme>}) emits this as a {@code <link href>} to the
550    * standalone {@code /juneau-console/*} mount. Resolving with {@code context:} rather than {@code servlet:} is
551    * load-bearing: a page resource at {@code /rest/setup} reports that path as its servlet path, so
552    * {@code servlet:/juneau-console/themes/…} would become {@code /rest/setup/juneau-console/themes/…} and 404.
553    * A template-rendering consumer sits downstream of Juneau's {@code servlet:}-rewriting serializer, so it needs
554    * the URL already resolved here.
555    *
556    * @param req The current request, supplying the context path to resolve against.
557    * @param name One of the {@link #BUILTIN_THEME_NAMES}.
558    * @return The context-root-absolute theme-stylesheet URL with the version+content-hash cache-buster appended.
559    */
560   public static String themeAssetUrl(RestRequest req, String name) {
561      var file = "juneau-theme-" + name + ".css";
562      return consoleMountUrl(req, THEME_CSS_DIR + file, THEME_CSS_RESOURCE_DIR + file);
563   }
564
565   /**
566    * Returns a real, browser-fetchable URL for the shipped {@code chrome.css}, resolved against the request's
567    * <b>context root</b> (not the page resource) and carrying the same content-sensitive {@code ?v=…} cache-buster
568    * the theme stylesheets use.
569    *
570    * <p>
571    * The console document shell ({@code <@console>}) emits this as the first stylesheet in its head cascade. See
572    * {@link #themeAssetUrl(RestRequest, String)} for why the resolution is {@code context:} rather than
573    * {@code servlet:}.
574    *
575    * @param req The current request, supplying the context path to resolve against.
576    * @return The context-root-absolute {@code chrome.css} URL with the version+content-hash cache-buster appended.
577    */
578   public static String chromeCssUrl(RestRequest req) {
579      return consoleMountUrl(req, CHROME_CSS_PATH, CHROME_CSS_RESOURCE);
580   }
581
582   /**
583    * Context-root-absolute, cache-busted URL of the console shell script.
584    *
585    * @param req The current request.
586    * @return The URL.
587    */
588   public static String consoleJsUrl(RestRequest req) {
589      return consoleMountUrl(req, CONSOLE_JS_PATH, CONSOLE_JS_RESOURCE);
590   }
591
592   /**
593    * Context-root-absolute, cache-busted URL of the count-badge script.
594    *
595    * @param req The current request.
596    * @return The URL.
597    */
598   public static String badgesJsUrl(RestRequest req) {
599      return consoleMountUrl(req, BADGES_JS_PATH, BADGES_JS_RESOURCE);
600   }
601
602   /**
603    * Context-root-absolute, cache-busted URL of a console card runtime; the resolver of the {@code charts} and
604    * {@code joblog} toolkit packs.
605    *
606    * @param req The current request.
607    * @param path {@link #CHARTS_JS_PATH}, {@link #JOBLOG_JS_PATH} or {@link #DASHBOARD_JS_PATH}.
608    * @return The URL.
609    * @throws IllegalArgumentException If {@code path} is neither.
610    */
611   public static String consoleAssetUrl(RestRequest req, String path) {
612      if (CHARTS_JS_PATH.equals(path))
613         return consoleMountUrl(req, CHARTS_JS_PATH, CHARTS_JS_RESOURCE);
614      if (JOBLOG_JS_PATH.equals(path))
615         return consoleMountUrl(req, JOBLOG_JS_PATH, JOBLOG_JS_RESOURCE);
616      if (DASHBOARD_JS_PATH.equals(path))
617         return consoleMountUrl(req, DASHBOARD_JS_PATH, DASHBOARD_JS_RESOURCE);
618      throw iaex("Unknown console asset '%s'.", path);
619   }
620
621   /**
622    * Context-root-absolute URL for an asset on the standalone {@code /juneau-console/*} mount.
623    *
624    * <p>
625    * Always the prefixed path ({@link #CHROME_CSS_PATH} / {@link #THEME_CSS_DIR}), resolved with {@code context:}
626    * so the href is {@code {contextPath}/juneau-console/…} regardless of which page resource rendered the shell.
627    */
628   private static String consoleMountUrl(RestRequest req, String prefixedPath, String resource) {
629      return req.getUriResolver().resolve("context:" + prefixedPath) + ASSET_CACHE.cacheBuster(resource);
630   }
631
632   /**
633    * Maps one of the {@link #BUILTIN_THEME_NAMES} stock names to its {@link Theme} constant.
634    *
635    * <p>
636    * The single place the kebab-case stock name (as written on FTL {@code <@theme name="…">} / the
637    * {@code theme(String)} builder) is resolved to its shipped {@link Theme}, without a second copy of the
638    * name&rarr;theme table.
639    *
640    * @param name One of the {@link #BUILTIN_THEME_NAMES}.
641    * @return The stock {@link Theme} for {@code name}.
642    * @throws IllegalArgumentException If {@code name} is not a built-in stock-theme name.
643    */
644   public static Theme stockTheme(String name) {
645      var t = STOCK_THEMES.get(name);
646      if (t == null)
647         throw iaex("Unknown stock theme name: '%s'.  Built-in themes: %s.", name, String.join(", ", BUILTIN_THEME_NAMES));
648      return t;
649   }
650
651   /**
652    * Returns the fully-assembled response body for the mount the request arrived under, computing (and caching) it
653    * on first call for that mount.
654    *
655    * <p>
656    * The cache is keyed by mount rather than held in a single field so one mixin instance registered under more
657    * than one container mapping (see the class javadoc's mount-styles section) builds and caches its body
658    * independently for each mount it is reached under.
659    */
660   private byte[] cachedBody(RestRequest req) throws IOException {
661      try {
662         return cachedBodies.computeIfAbsent(mountKey(req), k -> {
663            try {
664               return buildBody();
665            } catch (IOException e) {  // HTT: staticCss() is the only throwing call and reads a resource shipped in this jar.
666               throw new UncheckedIOException(e);
667            }
668         });
669      } catch (UncheckedIOException e) {  // HTT: see above - the wrapped read cannot fail in a well-formed jar.
670         throw e.getCause();
671      }
672   }
673
674   /** The request's mount identity: the two request properties every emitted asset URL is derived from. */
675   private static String mountKey(RestRequest req) {
676      return req.getContextPath() + '\n' + req.getServletPath();
677   }
678
679   /**
680    * Builds the response body: the static structural CSS, then {@link Theme#OPEN}'s block, then the selected stock
681    * theme's override block if a non-{@code open} stock theme was selected.
682    */
683   private byte[] buildBody() throws IOException {
684      buildCount.incrementAndGet();
685      var sb = new StringBuilder(staticCss());
686      sb.append('\n').append(openRootBlock());
687      var active = theme == null ? Theme.OPEN : theme;
688      if (! active.getName().equals(Theme.OPEN.getName()))
689         sb.append('\n').append(overrideBlock(active));
690      return sb.toString().getBytes(StandardCharsets.UTF_8);
691   }
692
693   /**
694    * Test-only diagnostic: the number of times this instance has (re)assembled its response body.
695    *
696    * @return The build count.
697    */
698   int debugBuildCount() { return buildCount.get(); }
699
700   /**
701    * Renders a theme's override block: one {@code html:root&#123;...&#125;} carrying the theme's leaf tokens, then its
702    * alias tokens, each in declaration order.
703    *
704    * <p>
705    * The two halves are escaped differently. A leaf is a validated literal and goes through
706    * {@link CssValueEscaper}: a grammar-accepted value can still carry a character such as {@code ;} that would end
707    * the declaration early. An alias is a single anchored {@code var(--jc-name)} reference
708    * ({@link Theme.Builder#alias(String, String)}), in which nothing dangerous is representable, so it is emitted
709    * verbatim. That asymmetry is the reason {@link Theme#getAliases()} stays a map.
710    *
711    * <p>
712    * The block uses the {@code html} type prefix. Two {@code :root} blocks in separately linked stylesheets tie at
713    * specificity {@code (0,0,1,0)} and are resolved by {@code <link>} order, which this framework neither sets
714    * nor observes. {@code html:root} scores {@code (0,0,1,1)}, so a theme override wins in either order.
715    *
716    * <p>
717    * A theme with no leaves and no aliases renders as the empty string. Each declared name is re-checked against
718    * the reserved {@code --jc-chrome-*} namespace (defense in depth; a {@link Theme} built by its builder can't
719    * carry one). This method is public so the FreeMarker {@code <@theme>} directive renders its inline block with
720    * the same code that serves {@code chrome.css}.
721    *
722    * <h5 class='section'>Example:</h5>
723    * <p class='bjava'>
724    *    Theme <jv>theme</jv> = Theme.<jsm>create</jsm>(<js>"corporate"</js>)
725    *       .token(<js>"--jc-accent"</js>, <js>"#b45309"</js>)
726    *       .alias(<js>"--jc-tab-bar-bg"</js>, <js>"var(--jc-card-bg)"</js>)
727    *       .build();
728    *    String <jv>css</jv> = ConsoleChromeMixin.<jsm>overrideBlock</jsm>(<jv>theme</jv>);
729    *    <jc>// html:root{--jc-accent:#b45309;--jc-tab-bar-bg:var(--jc-card-bg);}</jc>
730    * </p>
731    *
732    * @param theme The theme.  Must not be <jk>null</jk>.
733    * @return The override block, or an empty string if the theme declares nothing.
734    */
735   public static String overrideBlock(Theme theme) {
736      if (theme.getTokens().isEmpty() && theme.getAliases().isEmpty())
737         return "";
738      var sb = new StringBuilder("html:root{");
739      for (var e : theme.getTokens().entrySet()) {
740         Theme.rejectReservedChromeDeclaration(e.getKey(), "theme '" + theme.getName() + "' leaf token");
741         sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';');
742      }
743      for (var e : theme.getAliases().entrySet()) {
744         Theme.rejectReservedChromeDeclaration(e.getKey(), "theme '" + theme.getName() + "' alias");
745         sb.append(e.getKey()).append(':').append(e.getValue()).append(';');
746      }
747      return sb.append('}').toString();
748   }
749
750   /**
751    * The two head insertion points for a colour-scheme-aware page.
752    *
753    * <p>
754    * They are separate because they land in different places: {@link #htmlAttributes()} goes inside the
755    * {@code <html>} start tag, and {@link #head()} goes immediately after {@code <head>}, ahead of every stylesheet.
756    *
757    * @param htmlAttributes The attributes for the {@code <html>} start tag, space-separated with no leading space,
758    *    or an empty string when the page is neither pinned nor toggleable.
759    * @param head The pre-paint script element followed by the dark-layer stylesheet link, newline-terminated.
760    */
761   public record SchemeHead(String htmlAttributes, String head) {}
762
763   /**
764    * Returns the colour-scheme head pieces for a page that does not render through the FreeMarker console directive.
765    *
766    * <p>
767    * A pinned page ({@code light} or {@code dark}) gets {@code data-jc-scheme} and {@code data-jc-scheme-pin} set to
768    * the pin, so it paints in that scheme even with scripts disabled. A toggleable page gets
769    * {@code data-jc-scheme-toggle}, which lets the pre-paint script honour the visitor's stored choice. The head
770    * piece is {@link #SCHEME_SCRIPT} (stamped with the request's CSP nonce when one is set) followed by a link to the
771    * shipped dark layer.
772    *
773    * <h5 class='section'>Example:</h5>
774    * <p class='bjava'>
775    *    SchemeHead <jv>h</jv> = ConsoleChromeMixin.<jsm>schemeHead</jsm>(<jv>req</jv>, <js>"auto"</js>, <jk>true</jk>);
776    *    <jk>var</jk> <jv>attrs</jv> = <jv>h</jv>.htmlAttributes().isEmpty() ? <js>""</js> : <js>" "</js> + <jv>h</jv>.htmlAttributes();
777    *    <jv>out</jv>.write(<js>"&lt;html lang=\"en\""</js> + <jv>attrs</jv> + <js>"&gt;&lt;head&gt;"</js> + <jv>h</jv>.head());
778    * </p>
779    *
780    * <p>
781    * The head piece carries only the scheme script and the dark layer.  It does not replace the page's own links to
782    * the chrome stylesheet and a theme palette, which the caller still emits.
783    *
784    * @param req The current request, supplying the context path and any CSP nonce.
785    * @param pin One of the {@link #SCHEMES}.
786    * @param toggle Whether the visitor may override the scheme with a stored choice.
787    * @return The head pieces.
788    * @throws IllegalArgumentException If {@code pin} is <jk>null</jk> or not one of the {@link #SCHEMES}.
789    */
790   public static SchemeHead schemeHead(RestRequest req, String pin, boolean toggle) {
791      var attrs = schemeHtmlAttributes(pin, toggle);
792      var nonce = req.getAttribute(RestRequest.CSP_NONCE_ATTR).as(String.class).orElse(null);
793      var head = schemeScriptElement(nonce) + "\n<link rel=\"stylesheet\" href=\"" + attrEscape(themeAssetUrl(req, "dark")) + "\">\n";
794      return new SchemeHead(attrs, head);
795   }
796
797   /**
798    * Returns the {@code <html>} start-tag attributes for a scheme pin and toggle flag.
799    *
800    * <p>
801    * The FreeMarker console directive and {@link #schemeHead(RestRequest, String, boolean)} share this so the two
802    * shells stamp identical attributes.
803    *
804    * @param pin One of the {@link #SCHEMES}.
805    * @param toggle Whether the visitor may override the scheme with a stored choice.
806    * @return The attributes, space-separated with no leading space, or an empty string.
807    * @throws IllegalArgumentException If {@code pin} is <jk>null</jk> or not one of the {@link #SCHEMES}.
808    */
809   public static String schemeHtmlAttributes(String pin, boolean toggle) {
810      if (pin == null || ! SCHEMES.contains(pin))
811         throw iaex("Unknown scheme '%s'.  Schemes: %s.", pin, String.join(", ", SCHEMES));
812      var sb = new StringBuilder();
813      if (! "auto".equals(pin))
814         sb.append("data-jc-scheme=\"").append(pin).append("\" data-jc-scheme-pin=\"").append(pin).append('"');
815      if (toggle)
816         sb.append(sb.isEmpty() ? "" : " ").append("data-jc-scheme-toggle");
817      return sb.toString();
818   }
819
820   /**
821    * Returns the {@link #SCHEME_SCRIPT} element, carrying {@code nonce} as an attribute when it is not <jk>null</jk>.
822    *
823    * @param nonce The request's CSP nonce, or <jk>null</jk>.
824    * @return The {@code <script>} element, with no trailing newline.
825    */
826   public static String schemeScriptElement(String nonce) {
827      return "<script" + nonceAttr(nonce) + ">" + SCHEME_SCRIPT + "</script>";
828   }
829
830   /**
831    * Returns {@code  nonce="…"} (with a leading space, attribute-escaped) or an empty string for <jk>null</jk>.
832    *
833    * @param nonce The request's CSP nonce, or <jk>null</jk>.
834    * @return The attribute text.
835    */
836   public static String nonceAttr(String nonce) {
837      return nonce == null ? "" : " nonce=\"" + attrEscape(nonce) + "\"";
838   }
839
840   /**
841    * Renders the dark-scheme override rule for app-declared dark tokens.
842    *
843    * <p>
844    * The rule is {@code @media not print{html:root[data-jc-scheme="dark"]{…}}}, so it wins over the light override
845    * block only while the page is dark and never reaches print. Leaves come first, then aliases, each in declaration
846    * order. A leaf must be a literal: a dark leaf that is itself a {@code var()} reference is rejected, because it
847    * would resolve against the light palette. A live link belongs in an alias. Names and values get the same checks
848    * as {@link Theme.Builder}: the name shape, the reserved {@code --jc-chrome-*} namespace, the value grammar, and
849    * one channel per name.
850    *
851    * <h5 class='section'>Example:</h5>
852    * <p class='bjava'>
853    *    String <jv>css</jv> = ConsoleChromeMixin.<jsm>schemeOverrideBlock</jsm>(
854    *       Map.<jsm>of</jsm>(<js>"--jc-link"</js>, <js>"#6cb4ff"</js>), Map.<jsm>of</jsm>(<js>"--jc-focus"</js>, <js>"var(--jc-accent)"</js>));
855    *    <jc>// @media not print{html:root[data-jc-scheme="dark"]{--jc-link:#6cb4ff;--jc-focus:var(--jc-accent);}}</jc>
856    * </p>
857    *
858    * @param tokens The dark leaf tokens by name.  Must not be <jk>null</jk>.
859    * @param aliases The dark aliases by name, each a {@code var(--jc-name)} reference.  Must not be <jk>null</jk>.
860    * @return The rule, or an empty string if both maps are empty.
861    * @throws IllegalArgumentException If a name or value fails validation.
862    */
863   public static String schemeOverrideBlock(Map<String,String> tokens, Map<String,String> aliases) {
864      if (tokens.isEmpty() && aliases.isEmpty())
865         return "";
866      var b = Theme.create("dark");
867      for (var e : tokens.entrySet()) {
868         if (CssValueGrammar.normalize(e.getValue()).toLowerCase(Locale.ROOT).contains("var("))
869            throw iaex("Dark token '%s' is '%s', but dark values must be literals.  Declare a live reference as an alias.", e.getKey(), e.getValue());
870         b.token(e.getKey(), e.getValue());
871      }
872      for (var e : aliases.entrySet())
873         b.alias(e.getKey(), e.getValue());
874      var t = b.build();
875      var sb = new StringBuilder("@media not print{html:root[data-jc-scheme=\"dark\"]{");
876      for (var e : t.getTokens().entrySet())
877         sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';');
878      for (var e : t.getAliases().entrySet())
879         sb.append(e.getKey()).append(':').append(e.getValue()).append(';');
880      return sb.append("}}").toString();
881   }
882
883   /** Escapes text for a double-quoted HTML attribute value. */
884   private static String attrEscape(String s) {
885      return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
886   }
887
888   /** Returns the CSP {@code 'sha256-…'} source expression for {@code text}'s UTF-8 bytes. */
889   private static String sha256Source(String text) {
890      try {
891         var digest = java.security.MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
892         return "'sha256-" + Base64.getEncoder().encodeToString(digest) + "'";
893      } catch (java.security.NoSuchAlgorithmException e) {  // HTT: every Java platform must provide SHA-256.
894         throw new IllegalStateException(e);
895      }
896   }
897
898   /**
899    * Renders {@link Theme#OPEN}'s {@code html:root{}} block with the framework-authored role-token alias
900    * derivations ({@link #OPEN_ROLE_ALIASES}) appended inside the same block, immediately after the leaf tokens.
901    * The aliases are appended as trusted framework literal text (not routed through {@code CssValueEscaper},
902    * which is for consumer-supplied values); folding them into {@code Theme.OPEN}'s block keeps the served
903    * response at exactly one {@code html:root{}} block for the default theme while still placing every derived
904    * default before any active-theme override block. See {@link #overrideBlock(Theme)} for why the selector carries
905    * the {@code html} type prefix.
906    */
907   private static String openRootBlock() {
908      var sb = new StringBuilder();
909      sb.append("html:root{");
910      for (var e : Theme.OPEN.getTokens().entrySet())
911         sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';');
912      sb.append(OPEN_ROLE_ALIASES);
913      sb.append('}');
914      return sb.toString();
915   }
916
917   /** Returns the shipped static chrome.css text, reading (and caching, process-wide - the static file never varies) it from the classpath on first call. */
918   private static String staticCss() throws IOException {
919      var s = staticCss;
920      if (s == null) {
921         synchronized (ConsoleChromeMixin.class) {
922            s = staticCss;
923            if (s == null) {  // HTT: the "already set" branch is only reachable under a lock-acquisition race - unhittable single-threaded.
924               try (var in = ConsoleChromeMixin.class.getResourceAsStream(CHROME_CSS_RESOURCE)) {
925                  if (in == null)
926                     throw new IOException("Classpath resource not found: " + CHROME_CSS_RESOURCE);
927                  s = IoUtils.read(in);
928               }
929               staticCss = s;
930            }
931         }
932      }
933      return s;
934   }
935
936   /**
937    * Builder for {@link ConsoleChromeMixin}.
938    */
939   public static class Builder {
940      boolean cacheAssets = true;
941      Theme theme;
942
943      /**
944       * Whether to cache the assembled response body after the first request (default <jk>true</jk>).
945       *
946       * @param value The new value.
947       * @return This object.
948       */
949      public Builder cacheAssets(boolean value) {
950         this.cacheAssets = value;
951         return this;
952      }
953
954      /**
955       * Selects one of the shipped stock palettes by name (the same names FTL {@code <@theme name="…">} accepts).
956       *
957       * <p>
958       * A custom palette is authored in FTL with {@code <@theme>}/{@code <@token>}.
959       *
960       * @param stockName One of the {@link ConsoleChromeMixin#BUILTIN_THEME_NAMES}
961       *    ({@code open}, {@code light-red}, {@code light-brown}, {@code red}, {@code gray}).
962       * @return This object.
963       * @throws IllegalArgumentException If {@code stockName} is not a built-in stock-theme name.
964       */
965      public Builder theme(String stockName) {
966         this.theme = stockTheme(stockName);
967         return this;
968      }
969
970      /**
971       * Builds the mixin.
972       *
973       * @return A new {@link ConsoleChromeMixin}.
974       */
975      public ConsoleChromeMixin build() {
976         return new ConsoleChromeMixin(this);
977      }
978   }
979}