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.util.*;
022import java.util.regex.*;
023
024/**
025 * The validated token model behind the console's FTL {@code <@theme>}/{@code <@token>} directives: an immutable theme
026 * name, an ordered map of leaf tokens (literals) and an ordered map of alias tokens (live {@code var()} references).
027 *
028 * <p>
029 * Applications don't build themes in Java. They author them in their FreeMarker chrome, and the directives build
030 * this object:
031 *
032 * <h5 class='section'>Example:</h5>
033 * <p class='bftl'>
034 *    &lt;@console&gt;
035 *       &lt;@theme name="light-red"&gt;
036 *          &lt;#-- A leaf: a literal (or a var() resolved to one at build time). --&gt;
037 *          &lt;@token name="--jc-pill-red-bg" value="#fdeceb"/&gt;
038 *          &lt;#-- An alias: a reference that stays live in the browser's cascade. --&gt;
039 *          &lt;@token name="--jc-tab-bar-bg" alias="var(--jc-card-bg)"/&gt;
040 *       &lt;/@theme&gt;
041 *       &lt;@main/&gt;
042 *    &lt;/@console&gt;
043 * </p>
044 *
045 * <p>
046 * That is equivalent to the following, which is what {@code <@theme>} does internally after seeding the builder
047 * from the stock palette:
048 *
049 * <p class='bjava'>
050 *    Theme <jv>theme</jv> = Theme.<jsm>create</jsm>(<js>"light-red"</js>)
051 *       .token(<js>"--jc-pill-red-bg"</js>, <js>"#fdeceb"</js>)
052 *       .alias(<js>"--jc-tab-bar-bg"</js>, <js>"var(--jc-card-bg)"</js>)
053 *       .build();
054 *    String <jv>css</jv> = ConsoleChromeMixin.<jsm>overrideBlock</jsm>(<jv>theme</jv>);
055 *    <jc>// html:root{--jc-pill-red-bg:#fdeceb;--jc-tab-bar-bg:var(--jc-card-bg);}</jc>
056 * </p>
057 *
058 * <p>
059 * The five stock palettes ({@link #OPEN}, {@link #LIGHT_RED}, {@link #LIGHT_BROWN}, {@link #RED}, {@link #GRAY})
060 * are selected by name: {@code <@theme name="gray">} in FTL, or {@code ConsoleChromeMixin.create().theme("gray")}
061 * for the served {@code chrome.css}.
062 *
063 * <h5 class='section'>The two channels:</h5>
064 * <p>
065 * The contract in one sentence: <b>a leaf is always a literal; an alias is always a reference; and no token name
066 * is declared by both.</b> The two channels are disjoint by <i>value shape</i> rather than by convention, and each
067 * carries its own validation:
068 *
069 * <table class='styled'>
070 *    <tr><th>&nbsp;</th><th>leaf ({@link #getTokens()})</th><th>alias ({@link #getAliases()})</th></tr>
071 *    <tr><td>example</td><td><c>--jc-accent: #b45309</c></td><td><c>--jc-tab-bar-bg: var(--jc-card-bg)</c></td></tr>
072 *    <tr><td>value shape</td><td>literal</td><td>reference</td></tr>
073 *    <tr><td>validated by</td><td>{@code CssValueGrammar}'s accept-known-safe allowlist</td><td>the anchored
074 *       {@code var(--jc-name)} recognizer ({@link Builder#alias(String, String)})</td></tr>
075 *    <tr><td>emitted</td><td>escaped, via {@code CssValueEscaper}</td><td>verbatim &mdash; see
076 *       <i>Injection safety</i> below</td></tr>
077 * </table>
078 *
079 * <p>
080 * The rule a theme author needs to remember: <b>a literal belongs in {@link Builder#token(String, String)}; a
081 * reference belongs in {@link Builder#alias(String, String)}.</b> To make {@code --jc-tab-bar-bg} be {@code #fff},
082 * declare it with {@code token()}. To make it <i>follow</i> {@code --jc-card-bg} through the live cascade,
083 * {@link Builder#alias(String, String) alias} it.
084 *
085 * <h5 class='section'>Why the alias channel exists at all:</h5>
086 * <p>
087 * A leaf cannot carry a derived token. {@link Builder#build()} recognizes a {@code var(--jc-name)} value as a
088 * reference and <b>resolves it to a frozen literal</b>, which snapshots the live CSS cascade at composition time
089 * &mdash; so a token that must <i>keep following</i> its target can never be a leaf. {@code
090 * ConsoleChromeMixin.OPEN_ROLE_ALIASES} is the framework's own answer to the same problem, but those are
091 * unconditional framework defaults rather than per-theme derivations. The alias map is the third channel:
092 * <b>consumer-authored derivations that survive to the wire as references.</b>
093 *
094 * <h5 class='section'>The var() asymmetry &mdash; read this before "unifying" the two paths:</h5>
095 * <p>
096 * These two adjacent channels have <b>opposite</b> {@code var()} semantics, deliberately:
097 * <ul class='spaced-list'>
098 *    <li>A {@code var()} in a <b>leaf</b> is <b>resolved away</b> before the wire ({@link Builder#build()}), so the
099 *       served declaration carries the literal. That is asserted by {@code
100 *       ConsoleChromeMixin_OverrideBlock_Test.a05}, which pins that {@code --jc-tag-red-text:var(} never appears in
101 *       the body.
102 *    <li>A {@code var()} in an <b>alias</b> must <b>survive as a reference</b> in the served declaration. Freezing
103 *       it to a literal would defeat the entire purpose of the channel.
104 * </ul>
105 * <p>
106 * So a change that "unifies the two {@code var()} paths" silently destroys this type's reason to exist. The same
107 * warning is repeated at the emission site ({@code ConsoleChromeMixin.overrideBlock}), because that is the other
108 * place the mistake is reachable from.
109 *
110 * <h5 class='section'>The dark channel:</h5>
111 * <p>
112 * A theme may also declare <b>dark accents</b> with {@link Builder#dark(String, String)}, read back through
113 * {@link #getDarkTokens()}. They apply on top of the shared dark neutral layer, when the page is in the dark scheme.
114 * Only literals are accepted: a {@code var()} would resolve against the light palette, which would bake the wrong
115 * value into the dark scheme. The stock themes use the channel to keep their link, focus and accent colours
116 * legible on a dark surface.
117 *
118 * <h5 class='section'>Security:</h5>
119 * <p>
120 * Identifiers (theme name, token name, alias name) are rejected fail-closed by anchored {@code String.matches(...)}
121 * guards. Leaf values are validated by {@code CssValueGrammar}'s accept-known-safe allowlist grammar (not a
122 * {@code url(}-blocklist) and are escaped at emission time by {@code CssValueEscaper}. A leaf may also be a
123 * {@code var(--jc-name)} reference to another known token; {@link Builder#build() build()} resolves it to a
124 * concrete literal (own tokens shadowing {@link #OPEN}'s), so {@code var(} never appears in a {@link #getTokens()}
125 * value. An unknown reference, a cycle, or a chain longer than the resolution depth cap is a loud {@code build()}
126 * failure, never a silent fallback. Declared names in the reserved {@code --jc-chrome-*} namespace are rejected
127 * (see {@code rejectReservedChromeDeclaration}).
128 *
129 * <h5 class='section'>Injection safety of the alias channel:</h5>
130 * <p>
131 * Every alias value must match the fully-anchored {@code var(--jc-name)} shape ({@code VAR_REFERENCE}), which
132 * admits <b>exactly</b> this alphabet and nothing else: the three letters of {@code var} in either case, an opening
133 * paren, optional ASCII spaces, the literal {@code --jc-}, one or more of {@code [a-z0-9-]}, optional ASCII spaces,
134 * and a closing paren. (The recognizer's whitespace class can only ever match a plain space here, because every
135 * other character it would accept &mdash; tab, newline, carriage return, form feed, vertical tab &mdash; is a C0
136 * control character that {@code CssValueGrammar}'s belt has already rejected on the raw value, before this
137 * recognizer runs.)
138 *
139 * <p>
140 * So not one of {@code ;}, <code>}</code>, {@code "}, {@code '}, {@code \}, <code>/&#42;</code>, {@code url(} or a
141 * control character is <i>representable</i> in an accepted value &mdash; the emitted text is provably safe with
142 * <b>no escaper at all</b>. That is a strictly stronger claim than the leaf channel's "safe because we escape it",
143 * and it is the reason the channel is restricted to references rather than given an escaper of its own (escaping a
144 * {@code var()} reference would corrupt its parens).
145 *
146 * <p>
147 * A corollary worth stating: because an alias cannot carry a literal, the alias channel is <b>structurally</b>
148 * incapable of smuggling in a colour value, a font stack, or any other palette content. It can only ever point one
149 * name at another.
150 *
151 * @since 10.0.0
152 */
153@SuppressWarnings({
154   "java:S1192" // Duplicated literals are CSS custom-property / theme-token values; constants would obscure the token map.
155})
156public final class Theme {
157
158   /** Anchored (full-string) guard for a theme name. Anchoring matters: an unanchored {@code find()} would accept {@code "--jc-foo;--bar"}-shaped garbage as a substring match. */
159   private static final String NAME_PATTERN = "^[a-z][a-z0-9-]*$";
160
161   /** Anchored (full-string) guard for a CSS custom-property token name. */
162   private static final String TOKEN_NAME_PATTERN = "^--jc-[a-z0-9-]+$";
163
164   /**
165    * Anchored recognizer for a {@code var(--jc-name)} Theme-layer reference, run on the post-belt (control-char
166    * rejected, comment-stripped, trimmed) value.
167    *
168    * <p>
169    * Fully anchored ({@code ^...$}) so a reference can only ever be the <b>entire</b> value, never a prefix or
170    * suffix of some larger value: {@code linear-gradient(var(--jc-a), #fff)} never matches this and is rejected by
171    * the unchanged grammar. There is no fallback branch &mdash; {@code var(--jc-x, #fff)} simply fails to match
172    * (comma inside the parens) and falls through to grammar rejection.
173    */
174   static final Pattern VAR_REFERENCE = Pattern.compile("^[Vv][Aa][Rr]\\(\\s*(--jc-[a-z0-9-]+)\\s*\\)$");
175
176   /** The reserved token-name prefix guarded by {@link #rejectReservedChromeDeclaration(String, String)}. */
177   static final String CHROME_TOKEN_PREFIX = "--jc-chrome-";
178
179   /**
180    * The maximum number of reference hops resolved before {@code build()} fails, enforced independently of cycle
181    * detection so a long <i>acyclic</i> chain is bounded too.
182    */
183   private static final int MAX_REFERENCE_HOPS = 8;
184
185   /**
186    * The default, "open" theme.
187    *
188    * <p>
189    * Ships with the framework so out-of-the-box adoption needs zero theme configuration: an Inter/system-ui font
190    * stack, a neutral blue-gray page gradient, and blue accents.  Its token set is exactly the set
191    * {@code chrome.css} references &mdash; see {@code ConsoleChromeMixin_Test}'s bidirectional cross-check (every
192    * token here is a {@code var(--jc-*)} in {@code chrome.css}, and vice versa).
193    */
194   public static final Theme OPEN = create("open")
195      .token("--jc-font", "'Inter', 'Source Sans 3', system-ui, sans-serif")
196      .token("--jc-page-bg", "linear-gradient(180deg, #b0c4df 0%, #c7d5e8 22%, #e4eaf2 55%, #f5f6f9 100%)")
197      .token("--jc-header-icon-color", "#747474")
198      .token("--jc-accent", "#1589EE")
199      .token("--jc-accent-wash", "rgba(21,137,238,0.1)")
200      .token("--jc-link", "#015194")
201      .token("--jc-text", "#080707")
202      .token("--jc-text-soft", "#080707cc")
203      .token("--jc-text-muted", "#706e6b")
204      .token("--jc-border", "#dddbda")
205      .token("--jc-border-2", "#888c90")
206      .token("--jc-card-bg", "#ffffff")
207      .token("--jc-chrome-bg", "#f3f2f2")
208      .token("--jc-white", "#ffffff")
209      .token("--jc-btn-primary", "#1a5297")
210      .token("--jc-btn-primary-hover", "#005fb2")
211      .token("--jc-danger", "#c23934")
212      .token("--jc-success", "#2e844a")
213      .token("--jc-avatar-bg", "linear-gradient(135deg, #1589EE, #1a5297)")
214      .token("--jc-radius", "0.25rem")
215      // Pill palette: the CANONICAL status-chip colour family the client `pill` catalog renderer
216      // paints from - the ".tag.<domain>.<value>" palette rules in chrome.css consume these directly.  Declared
217      // before the legacy --jc-tag-* triads so those can be authored as var(--jc-pill-*) references to this single
218      // source of truth.  Values are the exact literals the --jc-tag-* tokens use.
219      .token("--jc-pill-green-bg", "#b8e6c4")
220      .token("--jc-pill-green-text", "#155724")
221      .token("--jc-pill-green-border", "#9fd6ad")
222      .token("--jc-pill-blue-bg", "#dceefb")
223      .token("--jc-pill-blue-text", "#0c5460")
224      .token("--jc-pill-blue-border", "#c3e0f3")
225      .token("--jc-pill-amber-bg", "#fff3cd")
226      .token("--jc-pill-amber-text", "#856404")
227      .token("--jc-pill-amber-border", "#ffe69c")
228      .token("--jc-pill-neutral-bg", "#e2e3e5")
229      .token("--jc-pill-neutral-text", "#383d41")
230      .token("--jc-pill-neutral-border", "#c6c8ca")
231      .token("--jc-pill-red-bg", "#f8d7da")
232      .token("--jc-pill-red-text", "#721c24")
233      .token("--jc-pill-red-border", "#f5c6cb")
234      // Legacy status/tag palette - RETAINED as var(--jc-pill-*) aliases (never renamed away): the .jc-badge /
235      // .jc-avatar-status-* chrome here AND the frozen widgets calendar palette (juneau-calendar.css, out of v1)
236      // still consume --jc-tag-*, so aliasing keeps one source of truth without a cross-module rename.  build()
237      // resolves each reference to the pill literal, so getTokens()/the served :root still carry a 6-hex value.
238      .token("--jc-tag-green-bg", "var(--jc-pill-green-bg)")
239      .token("--jc-tag-green-text", "var(--jc-pill-green-text)")
240      .token("--jc-tag-green-border", "var(--jc-pill-green-border)")
241      .token("--jc-tag-blue-bg", "var(--jc-pill-blue-bg)")
242      .token("--jc-tag-blue-text", "var(--jc-pill-blue-text)")
243      .token("--jc-tag-blue-border", "var(--jc-pill-blue-border)")
244      .token("--jc-tag-amber-bg", "var(--jc-pill-amber-bg)")
245      .token("--jc-tag-amber-text", "var(--jc-pill-amber-text)")
246      .token("--jc-tag-amber-border", "var(--jc-pill-amber-border)")
247      .token("--jc-tag-neutral-bg", "var(--jc-pill-neutral-bg)")
248      .token("--jc-tag-neutral-text", "var(--jc-pill-neutral-text)")
249      .token("--jc-tag-neutral-border", "var(--jc-pill-neutral-border)")
250      .token("--jc-tag-red-bg", "var(--jc-pill-red-bg)")
251      .token("--jc-tag-red-text", "var(--jc-pill-red-text)")
252      .token("--jc-tag-red-border", "var(--jc-pill-red-border)")
253      // Probe palette (client-side status probes).  A separate family from the pill/tag triads: probes SHOW a status
254      // dot and carry their own light status wash + ink, keyed to the four probe statuses success/error/warning/neutral.  Each
255      // status sets a background wash, a text (ink) colour, and the dot fill.  The selected-state ring reuses the
256      // existing --jc-accent-selected token (no new ring token).  Values are the measured status palette, independent
257      // of both the pill palette and the theme accent (neutral is a neutral grey, not the accent).
258      .token("--jc-probe-success-bg", "#d1fae5")
259      .token("--jc-probe-success-text", "#065f46")
260      .token("--jc-probe-success-dot", "#10b981")
261      .token("--jc-probe-error-bg", "#fee2e2")
262      .token("--jc-probe-error-text", "#991b1b")
263      .token("--jc-probe-error-dot", "#dc2626")
264      .token("--jc-probe-warning-bg", "#fef3c7")
265      .token("--jc-probe-warning-text", "#92400e")
266      .token("--jc-probe-warning-dot", "#f59e0b")
267      .token("--jc-probe-neutral-bg", "#e5e7eb")
268      .token("--jc-probe-neutral-text", "#374151")
269      .token("--jc-probe-neutral-dot", "#6b7280")
270      // --jc-font must stay first and each tag triad contiguous; see Theme_TokenOrdering_Test.
271      .token("--jc-header-height", "56px")               // (a) keeps .jc-header height and .jc-nav sticky offset in sync
272      .token("--jc-nav-indicator-width", "3px")          // (g) .jc-nav floor, .jc-nav-tab top bar, HTML-slot page-nav floor + selected-section top
273      .token("--jc-page-nav-hairline", "2px")            // line between page-nav sections row and children row
274      .token("--jc-page-nav-section-font-size", "13px")  // Page Tabs (top row) type; independent of children
275      .token("--jc-page-nav-child-font-size", "12px")    // page-nav children (second row) type; independent of sections
276      .token("--jc-nav-tab-padding-block", "8px")        // Page Tab (and HTML-slot section) vertical padding; with line-height and the indicator border sets tab height
277      .token("--jc-nav-tab-padding-inline", "10px")      // Page Tab horizontal padding
278      .token("--jc-nav-tab-line-height", "1.5")          // Page Tab / Page Subtab line box (unitless multiple of the font size)
279      .token("--jc-page-nav-child-padding-block", "6px")   // Page Subtab vertical padding; less than the tab's 8px so the strip stays subordinate
280      .token("--jc-page-nav-child-padding-inline", "10px") // Page Subtab horizontal padding
281      .token("--jc-card-shadow", "0 2px 2px rgba(0, 0, 0, 0.05)")  // (c) content-card / title-card elevation; table-chrome clears it
282      .token("--jc-danger-wash", "#fdeceb")              // (d) themeable .jc-btn-danger:hover fill (was a hardcoded hex)
283      .token("--jc-success-wash", "#eaf6ee")             // (d) themeable .jc-btn-success:hover fill (was a hardcoded hex)
284      .token("--jc-space-1", "4px")                      // (e) spacing scale seam (consumed by the .jc-prose rhythm)
285      .token("--jc-space-2", "8px")
286      .token("--jc-space-3", "12px")
287      .token("--jc-space-4", "16px")
288      .token("--jc-space-5", "24px")
289      .token("--jc-space-6", "32px")
290      // Accessibility: a themeable focus-ring colour so the generic :focus-visible rule in chrome.css
291      // gives every interactive control a WCAG 2.4.7-compliant keyboard-focus indicator by default.  A darker
292      // shade of the blue accent, so the ring still reads as the accent colour but clears 3:1 on the page
293      // gradient's lightest-blue top stop, not only on white.
294      .token("--jc-focus", "#116ab9")
295      // Accessibility: the ribbon-format selected-state face (WCAG 1.4.11 non-text 3:1 remedy).  A new, OPAQUE
296      // token rather than a re-valued --jc-accent-wash: the wash has 13 hover/table-wash consumers in chrome.css
297      // that must stay untouched, so the selected face is confined to this independent token instead.
298      // Same literal as --jc-accent at this theme's baseline (the shipped colour choice does not change, only
299      // the mechanism, wash to opaque) - but tracked separately: a consumer overriding --jc-accent will NOT move
300      // this token; it must be overridden on its own.  A permanent widening of the theming surface.
301      .token("--jc-accent-selected", "#1589EE")
302      .token("--jc-table-stripe-bg", "#fafaf9")
303      .token("--jc-main-bg", "#f5f6f9")  // inner-main well; not a retarget of old card tints
304      .token("--jc-card-padding", "16px 16px 8px")  // content-card / title-card inset; --jc-space-4 / --jc-space-4 / --jc-space-2
305      .token("--jc-chrome-icon", "#666666")  // idle ribbon / paging glyph; not --jc-text-soft (body ink)
306      // Chart tokens, read by juneau-charts.js when it registers the ECharts theme (series palette, then roles).
307      .token("--jc-chart-1", "#1b6ac9")
308      .token("--jc-chart-2", "#e07a1f")
309      .token("--jc-chart-3", "#2e8540")
310      .token("--jc-chart-4", "#9b3fb5")
311      .token("--jc-chart-5", "#c23934")
312      .token("--jc-chart-6", "#0e8a8a")
313      .token("--jc-chart-7", "#7a6a00")
314      .token("--jc-chart-8", "#5a5a5a")
315      .token("--jc-chart-bg", "transparent")
316      .token("--jc-chart-text", "#080707")
317      .token("--jc-chart-axis", "#dddbda")
318      .token("--jc-chart-grid", "#ececec")
319      .token("--jc-chart-tooltip-bg", "#ffffff")
320      .token("--jc-chart-tooltip-text", "#080707")
321      .token("--jc-chart-mark", "#5a5a5a")
322      .token("--jc-chart-band", "rgba(0, 0, 0, 0.06)")
323      .token("--jc-chart-heat-low", "#e8f1fb")
324      .token("--jc-chart-heat-high", "#1b6ac9")
325      // Dark accents: lighter blues than the light palette, legible on the shared dark neutral layer.
326      .dark("--jc-link", "#6cb4ff")
327      .dark("--jc-focus", "#4aa3ff")
328      .dark("--jc-accent-selected", "#4aa3ff")
329      .dark("--jc-accent-wash", "rgba(21,137,238,0.22)")
330      .build();
331
332   /**
333    * A warm, light-brown ("parchment") retint of {@link #OPEN}'s structure.
334    *
335    * <p>
336    * Recolors the chrome, accent, links, text, borders, and primary button to a warm brown palette, while keeping
337    * every one of {@link #OPEN}'s semantic status/tag tokens (info blue, success green, error red, warning amber,
338    * neutral gray) verbatim &mdash; a brown "error" pill would stop reading as an error, so the status palette is
339    * deliberately untouched. Authored as seeded from {@link #OPEN}, overriding
340    * only the 15 browned tokens below; the other 40 &mdash; including all five tag triads and every structural
341    * token &mdash; are inherited from {@link #OPEN} unchanged, so this theme's token <i>key</i> set is provably
342    * identical to {@link #OPEN}'s.
343    *
344    * <p>
345    * Caveat: like {@link #OPEN}'s own {@code --jc-accent}, this theme's {@code --jc-accent} (<c>#a9772f</c>) clears
346    * WCAG AA only for non-text/large-surface use (its {@code --jc-focus}/{@code --jc-accent-selected} consumers); a
347    * consumer repurposing it as small body text should use {@code --jc-link} (<c>#754d16</c>) instead, which
348    * passes AA normal-text contrast.
349    */
350   public static final Theme LIGHT_BROWN = deriveFrom("light-brown", OPEN).resetDark()
351      .token("--jc-page-bg", "linear-gradient(180deg, #dccaa6 0%, #e8dcc4 22%, #f2ebdc 55%, #faf6ee 100%)")
352      .token("--jc-accent", "#a9772f")
353      .token("--jc-accent-wash", "rgba(169,119,47,0.1)")
354      .token("--jc-link", "#754d16")
355      .token("--jc-text", "#2e2415")
356      .token("--jc-text-soft", "#2e2415cc")
357      .token("--jc-text-muted", "#7a6a4f")
358      .token("--jc-border", "#d8cbb0")
359      .token("--jc-border-2", "#977f4e")
360      .token("--jc-chrome-bg", "#efe6d4")
361      .token("--jc-btn-primary", "#8a6d3b")
362      .token("--jc-btn-primary-hover", "#74592f")
363      .token("--jc-avatar-bg", "linear-gradient(135deg, #a9772f, #8a6d3b)")
364      .token("--jc-focus", "#926729")
365      .token("--jc-accent-selected", "#a9772f")
366      // Dark accents: a lighter link and the lighter focus ring; the accent itself is already legible as a non-text colour on dark.
367      .dark("--jc-link", "#d9a65c")
368      .dark("--jc-focus", "#a9772f")
369      .dark("--jc-accent-wash", "rgba(169,119,47,0.25)")
370      .build();
371
372   /**
373    * A light-chrome red retint of {@link #OPEN}'s structure &mdash; the sibling of {@link #LIGHT_BROWN}.
374    *
375    * <p>
376    * Recolors the chrome, accent, links, text, borders, and primary button to a wine/rose palette, while keeping
377    * every one of {@link #OPEN}'s semantic status/tag tokens (info blue, success green, error red, warning amber,
378    * neutral gray) verbatim &mdash; a red "error" pill that matched the brand would stop reading as an error, so
379    * the status palette is deliberately untouched. Seeded from {@link #OPEN}, overriding only the 15 recolored
380    * tokens below; the other 40 &mdash; including all five tag triads and every structural token &mdash; are
381    * inherited from {@link #OPEN} unchanged, so this theme's token <i>key</i> set is
382    * provably identical to {@link #OPEN}'s.
383    *
384    * <p>
385    * Like {@link #RED}, this theme keeps a <b>light</b> {@code --jc-chrome-bg} (a rose tint, not a saturated red) for the
386    * page-chrome fallback, hover fill, and dialog chrome. Header and page-nav rows stay white via
387    * {@code --jc-header-bg} &rarr; {@code --jc-white}; red is reserved for accent, links, focus, and the primary
388    * button.
389    *
390    * <p>
391    * Caveat: {@link #RED}'s {@code --jc-btn-primary} sits close in hue to {@link #OPEN}'s unchanged semantic
392    * {@code --jc-danger} (<c>#c23934</c>). This theme avoids that collision by using a darker wine primary
393    * (<c>#6B1D2A</c>) rather than danger red or the mockup placeholder <c>#a31f34</c>; {@code --jc-danger} itself
394    * is deliberately left untouched. A consumer still should not reuse {@code --jc-danger} as brand.
395    */
396   public static final Theme LIGHT_RED = deriveFrom("light-red", OPEN).resetDark()
397      .token("--jc-page-bg", "linear-gradient(180deg, #e8cfd3 0%, #f0dfe3 22%, #f6eef0 55%, #faf7f8 100%)")
398      .token("--jc-accent", "#B42348")
399      .token("--jc-accent-wash", "rgba(180,35,72,0.1)")
400      .token("--jc-link", "#8B1538")
401      .token("--jc-text", "#2a181c")
402      .token("--jc-text-soft", "#2a181ccc")
403      .token("--jc-text-muted", "#6e585c")
404      .token("--jc-border", "#e4d4d7")
405      .token("--jc-border-2", "#a37c85")
406      .token("--jc-chrome-bg", "#f3ecee")
407      .token("--jc-btn-primary", "#6B1D2A")
408      .token("--jc-btn-primary-hover", "#541620")
409      .token("--jc-avatar-bg", "linear-gradient(135deg, #B42348, #6B1D2A)")
410      .token("--jc-focus", "#B42348")
411      .token("--jc-accent-selected", "#B42348")
412      // Dark accents: lighter rose so the link and accent stay legible on a dark surface.
413      .dark("--jc-link", "#f27a9a")
414      .dark("--jc-accent", "#e0557a")
415      .dark("--jc-focus", "#e0557a")
416      .dark("--jc-accent-selected", "#e0557a")
417      .dark("--jc-accent-wash", "rgba(224,85,122,0.22)")
418      .build();
419
420   /**
421    * A Jira-inspired red retint of {@link #OPEN}'s structure.
422    *
423    * <p>
424    * "Jira-inspired" describes the structural feel &mdash; white content, a white header and page-nav with a
425    * distinctly-colored accent floor, subtle neutral borders, a restrained accent &mdash; rendered in a red key
426    * color (Jira's own brand is blue); no
427    * Atlassian trademark appears in this theme's name or token values. Recolors the chrome, accent, links, brand,
428    * text, and borders, while keeping every one of {@link #OPEN}'s semantic status/tag tokens (info blue, success
429    * green, error red, warning amber, neutral gray) verbatim, so status pills stay unambiguous. Authored as
430    * seeded from {@link #OPEN}, overriding only the 16 recolored tokens below;
431    * the other 35 &mdash; including all five tag triads and every structural token &mdash; are inherited from
432    * {@link #OPEN} unchanged, so this theme's token <i>key</i> set is provably identical to {@link #OPEN}'s.
433    *
434    * <p>
435    * Caveat: {@code --jc-btn-primary} (<c>#BF2600</c>) sits close in hue to {@link #OPEN}'s unchanged semantic
436    * {@code --jc-danger} (<c>#c23934</c>); a primary button and a danger button rendered side by side rely on
437    * shape/label conventions (solid brand vs. danger-wash hover), not color alone, to stay distinguishable &mdash;
438    * {@code --jc-danger} itself is deliberately left untouched.
439    */
440   public static final Theme RED = deriveFrom("red", OPEN).resetDark()
441      .token("--jc-page-bg", "linear-gradient(180deg, #f4f5f7 0%, #fafbfc 55%, #ffffff 100%)")
442      .token("--jc-accent", "#FF5630")
443      .token("--jc-accent-wash", "rgba(255,86,48,0.1)")
444      .token("--jc-link", "#BF2600")
445      .token("--jc-text", "#172B4D")
446      .token("--jc-text-soft", "#172B4Dcc")
447      .token("--jc-text-muted", "#5E6C84")
448      .token("--jc-border", "#DFE1E6")
449      .token("--jc-border-2", "#727f94")
450      // Chrome-scale token consumed by --jc-hover-bg and dialog chrome. Header / page-nav rows stay white
451      // (--jc-header-bg → --jc-white); table headers stay white (--jc-table-header-bg → --jc-white).
452      .token("--jc-chrome-bg", "#ffd9cf")
453      .token("--jc-btn-primary", "#BF2600")
454      .token("--jc-btn-primary-hover", "#a01f00")
455      .token("--jc-avatar-bg", "linear-gradient(135deg, #FF5630, #BF2600)")
456      .token("--jc-radius", "0.1875rem")
457      .token("--jc-focus", "#ff481f")
458      .token("--jc-accent-selected", "#FF5630")
459      // Dark accents: a lighter link and a tint of the chrome red; the accent itself reads on dark unchanged.
460      .dark("--jc-link", "#ff8a70")
461      .dark("--jc-chrome-bg", "#7a1a05")
462      .dark("--jc-accent-wash", "rgba(255,86,48,0.22)")
463      .build();
464
465   /**
466    * A neutral, general-purpose light-gray retint of {@link #OPEN}'s structure &mdash; the ASF Jira-inspired
467    * "clean neutral" member of the stock-theme set.
468    *
469    * <p>
470    * Neutralizes the chrome, page background, text, and borders to a coherent grayscale ramp, while keeping
471    * every one of {@link #OPEN}'s semantic status/tag tokens (info blue, success green, danger red, warning
472    * amber, neutral gray) <b>and</b> {@link #OPEN}'s blue interactive affordances (accent, link, primary button,
473    * focus ring, avatar gradient) verbatim &mdash; a pure-grayscale accent would make links/buttons stop reading
474    * as clickable, so the restrained blue affordance set is deliberately left untouched (this is the
475    * "7 grayed / 48 kept" derivation; see the design item for the vetoed all-monochrome alternative). Authored
476    * as seeded from {@link #OPEN}, overriding only the 7 grayed tokens below;
477    * the other 48 &mdash; including all five tag triads, every structural token, and the blue affordance set
478    * &mdash; are inherited from {@link #OPEN} unchanged, so this theme's token <i>key</i> set is provably
479    * identical to {@link #OPEN}'s.
480    *
481    * <p>
482    * Caveat: like {@link #OPEN}'s own {@code --jc-accent}, this theme's (unchanged, kept-from-OPEN)
483    * {@code --jc-accent} clears WCAG AA only for non-text/large-surface use; a consumer repurposing it as small
484    * body text should use {@code --jc-link} instead, which passes AA normal-text contrast.
485    */
486   public static final Theme GRAY = deriveFrom("gray", OPEN)
487      .token("--jc-page-bg", "linear-gradient(180deg, #e8e8e8 0%, #f0f0f0 55%, #ffffff 100%)")
488      .token("--jc-text", "#262626")
489      .token("--jc-text-soft", "#262626cc")
490      .token("--jc-text-muted", "#737373")
491      .token("--jc-border", "#e0e0e0")
492      .token("--jc-border-2", "#8a8a8a")
493      .token("--jc-chrome-bg", "#f4f4f4")
494      .build();
495
496   private final String name;
497   private final Map<String,String> tokens;
498   private final Map<String,String> aliases;
499   private final Map<String,String> darkTokens;
500
501   private Theme(String name, Map<String,String> tokens, Map<String,String> aliases, Map<String,String> darkTokens) {
502      this.name = name;
503      // Insertion-ordered rather than Map.copyOf: ConsoleChromeMixin emits this map's iteration order directly as
504      // the served stylesheet's :root{} declarations, and Map.copyOf's order is perturbed by a per-JVM salt - which
505      // would make the response body differ between two processes serving an identical token set.  cp(Map) is
506      // contractually a LinkedHashMap copy, so it preserves that order; do not swap it for an unordered copy.
507      this.tokens = u(cp(tokens));
508      this.aliases = u(cp(aliases));
509      this.darkTokens = u(cp(darkTokens));
510   }
511
512   /**
513    * Starts building a new named theme.
514    *
515    * @param name The theme name. Must not be <jk>null</jk> and must match {@code ^[a-z][a-z0-9-]*$}.
516    * @return A new builder.
517    * @throws IllegalArgumentException If the name is <jk>null</jk>, empty, or not in the legal shape (e.g. contains
518    *    uppercase, whitespace, or a path-traversal-shaped segment like {@code "../evil"} &mdash; gated here, not
519    *    just at classpath-asset-resolution time, so a later fast-follow that interpolates the name into an asset
520    *    path doesn't need to re-gate).
521    */
522   public static Builder create(String name) {
523      if (name == null || ! name.matches(NAME_PATTERN))
524         throw iaex("Invalid theme name: '%s'.  Must match %s.", name, NAME_PATTERN);
525      return new Builder(name);
526   }
527
528   /**
529    * Seeds a builder with every token of {@code seed} under a new name. Used to author the stock palettes
530    * ({@link #LIGHT_RED}, {@link #LIGHT_BROWN}, {@link #RED}, {@link #GRAY}) from {@link #OPEN} without restating
531    * the whole map.
532    *
533    * <p>
534    * The seed's dark tokens are copied too.  A derived theme that declares its own accents should call
535    * {@code resetDark()} first, or it inherits the seed's dark accents.
536    */
537   private static Builder deriveFrom(String name, Theme seed) {
538      if (seed == null)
539         throw iaex("Invalid theme seed: null.");
540      var b = create(name);
541      for (var e : seed.getTokens().entrySet())
542         b.token(e.getKey(), e.getValue());
543      for (var e : seed.getDarkTokens().entrySet())
544         b.dark(e.getKey(), e.getValue());
545      return b;
546   }
547
548   /**
549    * Returns this theme's name.
550    *
551    * @return This theme's name.
552    */
553   public String getName() { return name; }
554
555   /**
556    * Returns this theme's token overrides.
557    *
558    * @return
559    *    An immutable map of token name (e.g. {@code "--jc-accent"}) to CSS value, iterating in the order the tokens
560    *    were declared on the builder. Never <jk>null</jk>.
561    */
562   public Map<String,String> getTokens() { return tokens; }
563
564   /**
565    * Returns this theme's dark accent tokens: name to a literal CSS value, applied on top of the shared dark neutral
566    * layer when the page is in the dark scheme.
567    *
568    * <p>
569    * Every value is a literal. A {@code var()} is rejected at {@link Builder#dark(String, String)}, because it could
570    * only resolve against the light palette.
571    *
572    * @return An immutable, insertion-ordered map of token name to CSS value.  Never <jk>null</jk>.
573    */
574   public Map<String,String> getDarkTokens() { return darkTokens; }
575
576   /**
577    * Returns this theme's alias tokens: name to a live {@code var(--jc-*)} reference.
578    *
579    * <p>
580    * Unlike a {@link #getTokens() leaf}, an alias is never resolved at build time. It reaches the browser verbatim,
581    * so it follows its target through the cascade, including any later override of the target. The map is
582    * insertion-ordered (the order {@link Builder#alias(String, String)} was called in), which keeps the emitted
583    * block byte-stable, and it is unmodifiable. It is empty for every stock theme.
584    *
585    * <p>
586    * This is a {@code Map}, not a rendered {@code String}, on purpose: the leaf half and the alias half of an
587    * override block are escaped differently (see {@link ConsoleChromeMixin#overrideBlock(Theme)}), so the
588    * emitter needs the two halves apart. Do not simplify this into a rendered string.
589    *
590    * @return The alias tokens.  Never <jk>null</jk>.
591    */
592   public Map<String,String> getAliases() {
593      return aliases;
594   }
595
596   /**
597    * The single shared, fail-closed guard rejecting a declaration of a reserved chrome-scale token name, invoked on
598    * <b>every</b> path by which a token declaration can reach the served stylesheet.
599    *
600    * <p>
601    * <b>What it protects.</b> {@code juneau-views.css} declares one shared control-scale ladder
602    * ({@code --jc-chrome-control-height}, {@code --jc-chrome-font-size-1}, {@code --jc-chrome-glyph-size}, ...) at
603    * {@code :root}, and a contract test in that module pins it as declared exactly once so that every surface
604    * shares one ladder. A token block emitted here sits at {@code html:root}, which out-specifies that
605    * {@code :root} &mdash; so a consumer redeclaring a ladder step would win, render perfectly, and turn that
606    * contract into decoration with no test failing anywhere. Documentation is not a mitigation for that, so the
607    * rejection lives here.
608    *
609    * <p>
610    * <b>Where it fires.</b>
611    * <ul class='spaced-list'>
612    *    <li>{@link Builder#token(String, String)} and {@link Builder#alias(String, String)}, at declaration &mdash;
613    *       the only way a name enters a theme.
614    *    <li>{@code ConsoleChromeMixin.overrideBlock(Theme)}, at emission &mdash; defense in depth; unreachable for a
615    *       theme built by this class.
616    * </ul>
617    *
618    * <p>
619    * <b>It fires on the declared name only, never on a target.</b> Aliasing a stock-theme token <i>to</i> a ladder step
620    * is legal and useful; <i>declaring</i> a ladder step is not.
621    *
622    * <p>
623    * <b>The {@link Theme#OPEN} exemption.</b> The reserved namespace is {@code --jc-chrome-*} <i>except</i> the
624    * names {@link Theme#OPEN} itself already declares. Two names are exempt today:
625    * {@code --jc-chrome-bg} (page-chrome grey that predates the ladder) and {@code --jc-chrome-icon} (idle
626    * ribbon / paging glyph ink). Both are consumed by {@code chrome.css} and are legitimately overridable.
627    * Without the exemption this guard would reject {@link Theme#OPEN}'s own token
628    * set &mdash; and therefore every stock palette seeded from {@link Theme#OPEN}, which is the normal way
629    * to author a palette. The exemption is expressed against
630    * {@link Theme#OPEN} rather than as a hardcoded name list so it cannot drift: it needs no copy of the ladder's
631    * names (which live in a module this one deliberately cannot see), it stays correct if a chrome-named leaf is
632    * ever added to or removed from {@link Theme#OPEN}, and it fails <i>closed</i> for every new name &mdash; no
633    * ladder step is a {@link Theme#OPEN} token, and adding one could only ever happen through a reviewed edit to
634    * {@link Theme#OPEN} that its own pinned token count would surface.
635    *
636    * <p>
637    * A prefix test rather than an anchored {@code matches(...)}: by the time a name reaches here it has already
638    * passed a full-string {@code ^--jc-[a-z0-9-]+$} shape guard, so its alphabet is closed and
639    * {@link String#startsWith(String)} is total &mdash; there is no string that passes the shape guard, begins with
640    * the reserved prefix, and escapes this check.
641    *
642    * <p>While {@link #OPEN} itself is being built, {@code OPEN} is still {@code null} and the guard is skipped: the
643    * stock palette is the source of the exemption list, not subject to it.
644    *
645    * @param name The declared token name. Already shape-validated by the caller.
646    * @param source A short description of the channel the declaration arrived through, for the failure message.
647    * @throws IllegalArgumentException If {@code name} is a reserved chrome-scale token name.
648    */
649   static void rejectReservedChromeDeclaration(String name, String source) {
650      if (OPEN == null)
651         return;
652      if (name.startsWith(CHROME_TOKEN_PREFIX) && ! OPEN.getTokens().containsKey(name))
653         throw iaex("Cannot declare reserved token '%s' (%s).  The '%s' namespace is the shared control-scale ladder declared by juneau-views.css; a token block served here out-specifies it.  Referencing one of these tokens is legal - declaring one is not.",
654            name, source, CHROME_TOKEN_PREFIX);
655   }
656
657   /**
658    * Builder for {@link Theme}.
659    */
660   public static final class Builder {
661      private final String name;
662      private final Map<String,String> tokens = new LinkedHashMap<>();
663      private final Map<String,String> aliases = new LinkedHashMap<>();
664      private final Map<String,String> darkTokens = new LinkedHashMap<>();
665
666      private Builder(String name) { this.name = name; }
667
668      /**
669       * Adds (or overrides) a single CSS custom-property token.
670       *
671       * <p>
672       * The value may be either a <b>literal</b> or a {@code var(--jc-name)} <b>reference</b> to another known
673       * token:
674       * <ul class='spaced-list'>
675       *    <li>A literal is validated eagerly here by {@code CssValueGrammar}'s accept-known-safe allowlist grammar
676       *       after normalization (trim &rarr; reject C0/C1/DEL &rarr; strip comments &rarr; reject {@code url(} in
677       *       any spelling &rarr; grammar).
678       *    <li>A value that, after that same normalization belt, matches {@code var(--jc-name)} is recognized as a
679       *       reference and stored <i>unresolved</i> &mdash; it is resolved to a concrete literal at {@link #build()}
680       *       time (its target may not be defined yet, e.g. a forward reference). {@code var()} is never a
681       *       {@code CssValueGrammar} value shape; it is Theme-layer syntax recognized one layer above the grammar.
682       * </ul>
683       * Escaping happens later, at emission time, via {@code CssValueEscaper} (see {@code ConsoleChromeMixin}).
684       *
685       * <p>A declared name in the reserved {@code --jc-chrome-*} namespace that {@link Theme#OPEN} does not already
686       * declare is rejected.
687       *
688       * @param name The token name. Must not be <jk>null</jk> and must match {@code ^--jc-[a-z0-9-]+$}.
689       * @param value
690       *    The CSS value &mdash; a literal, or a {@code var(--jc-name)} reference.
691       * @return This object.
692       * @throws IllegalArgumentException
693       *    If the name is not in the legal shape (full-string {@code String.matches(...)}, not {@code find(...)}
694       *    &mdash; {@code "--jc-foo;--bar"} must REJECT even though {@code "--jc-foo"} matches as a leading
695       *    substring), if the value contains a control character or a {@code url(} production, or if the value is
696       *    neither a {@code var(--jc-name)} reference nor one of the allowlisted CSS value shapes. A reference whose
697       *    target is unknown, cyclic, or too deeply chained is instead reported at {@link #build()} time.
698       */
699      public Builder token(String name, String value) {
700         if (name == null || ! name.matches(TOKEN_NAME_PATTERN))
701            throw iaex("Invalid theme token name: '%s'.  Must match %s.", name, TOKEN_NAME_PATTERN);
702         rejectReservedChromeDeclaration(name, "theme '" + this.name + "' leaf token");
703         // Recognition runs on the SAME post-belt string the grammar would see (one shared belt, no second
704         // comment-stripping pass).  A reference is stored unresolved; a literal is validated eagerly, exactly
705         // as before this feature existed.
706         var normalized = CssValueGrammar.normalize(value);
707         if (referencedName(normalized) != null)
708            tokens.put(name, normalized);
709         else
710            tokens.put(name, CssValueGrammar.normalizeAndValidate(value));
711         return this;
712      }
713
714      /**
715       * Adds (or overrides) a dark accent token: a literal applied on top of the shared dark neutral layer when the
716       * page is in the dark scheme.
717       *
718       * <p>
719       * The name and value go through the same guards as {@link #token(String, String)}, with one difference: the
720       * value must be a <b>literal</b>. A {@code var()} reference is rejected, because the only thing it could
721       * resolve against is the light palette, which would bake the wrong value into the dark scheme.
722       *
723       * @param name The token name.  Must match {@code ^--jc-[a-z0-9-]+$}.
724       * @param value The CSS literal.
725       * @return This object.
726       * @throws IllegalArgumentException
727       *    If the name is malformed or in the reserved {@code --jc-chrome-*} namespace without being a {@link Theme#OPEN}
728       *    token, or if the value is a {@code var()} reference or is not one of the allowlisted CSS value shapes.
729       */
730      public Builder dark(String name, String value) {
731         if (name == null || ! name.matches(TOKEN_NAME_PATTERN))
732            throw iaex("Invalid theme dark token name: '%s'.  Must match %s.", name, TOKEN_NAME_PATTERN);
733         rejectReservedChromeDeclaration(name, "theme '" + this.name + "' dark token");
734         // Literal only: CssValueGrammar has no var() shape, so a reference fails here rather than being resolved.
735         darkTokens.put(name, CssValueGrammar.normalizeAndValidate(value));
736         return this;
737      }
738
739      /** Drops every dark accent, for a derived theme that declares its own set instead of inheriting its seed's. */
740      private Builder resetDark() {
741         darkTokens.clear();
742         return this;
743      }
744
745      /**
746       * Declares an alias token: a name whose value is a live reference to another {@code --jc-*} token.
747       *
748       * <p>
749       * The target must be exactly one {@code var(--jc-name)} reference, with no fallback, no surrounding value
750       * and no second reference. It is stored after the shared normalization belt (trim and comment strip, with
751       * control characters rejected on the raw value) and emitted verbatim. Nothing dangerous is representable in
752       * that shape, so an alias needs no escaper. A literal belongs in {@link #token(String, String)}. A
753       * {@code var()} given to {@code token()} is resolved to its literal at build time; one given here is not.
754       *
755       * <p>
756       * A name may be declared by only one channel: {@link #build()} rejects a name given to both
757       * {@code token()} and {@code alias()}, in either order.
758       *
759       * <h5 class='section'>Example:</h5>
760       * <p class='bjava'>
761       *    <jc>// Follows --jc-card-bg live, including any later override of it.</jc>
762       *    <jv>builder</jv>.alias(<js>"--jc-tab-bar-bg"</js>, <js>"var(--jc-card-bg)"</js>);
763       *
764       *    <jc>// Rejected: a literal belongs in token(), not alias().</jc>
765       *    <jv>builder</jv>.alias(<js>"--jc-tab-bar-bg"</js>, <js>"#fff"</js>);
766       * </p>
767       *
768       * <p>A declared name in the reserved {@code --jc-chrome-*} namespace that {@link Theme#OPEN} does not already
769       * declare is rejected.
770       *
771       * @param name The alias name.  Must match {@code ^--jc-[a-z0-9-]+$}.
772       * @param target The reference.  Must be a single {@code var(--jc-name)}.
773       * @return This object.
774       * @throws IllegalArgumentException If the name or the target is malformed.
775       */
776      public Builder alias(String name, String target) {
777         if (name == null || ! name.matches(TOKEN_NAME_PATTERN))
778            throw iaex("Invalid theme alias name: '%s'.  Must match %s.", name, TOKEN_NAME_PATTERN);
779         rejectReservedChromeDeclaration(name, "theme '" + this.name + "' alias");
780         if (target == null)
781            throw iaex("Invalid theme alias target for '%s': null.", name);
782         var normalized = CssValueGrammar.normalize(target);
783         if (! VAR_REFERENCE.matcher(normalized).matches())
784            throw iaex("Invalid theme alias target for '%s': '%s'.  Must be a var(--jc-name) reference.", name, target);
785         aliases.put(name, normalized);
786         return this;
787      }
788
789      /**
790       * Builds the immutable {@link Theme}, resolving every {@code var(--jc-name)} reference to a concrete literal.
791       *
792       * <p>
793       * Resolution scope is this builder's own tokens, shadowing {@link Theme#OPEN}'s tokens (exact shadowing: a
794       * name defined on this builder wins outright, even if resolving that entry then fails &mdash; there is no
795       * silent fall-through to {@code Theme.OPEN}'s value for a shadowed name). Resolution runs on a copy of the
796       * token map, so a failed {@code build()} leaves this builder unchanged and retryable.
797       *
798       * <p>Rejects a name declared by both {@link #token(String, String)} and {@link #alias(String, String)}.
799       *
800       * @return A new {@link Theme} whose every token value is a resolved six-shape literal (the substring
801       *    {@code var(} appears in none of them).
802       * @throws IllegalArgumentException
803       *    If a reference names an unknown token, forms a cycle (the message carries the cycle path), or exceeds the
804       *    resolution depth cap.
805       */
806      public Theme build() {
807         var resolved = resolveReferences();
808         for (var n : aliases.keySet())
809            if (tokens.containsKey(n))
810               throw iaex("Theme '%s' declares '%s' as both a leaf token and an alias.  A name may be declared by only one channel.", name, n);
811         return new Theme(name, resolved, aliases, darkTokens);
812      }
813
814      /** Resolves every reference in a copy of the token map, preserving declaration order; never mutates {@link #tokens}. */
815      private Map<String,String> resolveReferences() {
816         var resolved = new LinkedHashMap<String,String>();
817         for (var e : tokens.entrySet()) {
818            var target = referencedName(e.getValue());
819            resolved.put(e.getKey(), target == null ? e.getValue() : resolveReference(e.getKey(), target));
820         }
821         return resolved;
822      }
823
824      /**
825       * Iteratively walks a reference chain from {@code firstTarget} to the literal it names, with exact shadowing
826       * (own tokens win over {@link Theme#OPEN}'s), cycle detection, and an independent hop cap; re-validates the
827       * resolved literal against the grammar as defense-in-depth.
828       */
829      private String resolveReference(String definingName, String firstTarget) {
830         // Theme.OPEN is null only while Theme.OPEN itself is being built - and it has no references, so this
831         // fallback map is never actually consulted during that construction.
832         var openTokens = Theme.OPEN == null ? Collections.<String,String>emptyMap() : Theme.OPEN.getTokens();
833         var visited = new LinkedHashSet<String>();
834         visited.add(definingName);
835         var target = firstTarget;
836         var hops = 0;
837         while (true) {
838            if (visited.contains(target))
839               throw iaex("Theme token '%s' contains a cyclic reference: %s.", definingName, cyclePath(visited, target));
840            if (++hops > MAX_REFERENCE_HOPS)
841               throw iaex("Theme token '%s' exceeds the maximum reference depth of %d hops.", definingName, MAX_REFERENCE_HOPS);
842            visited.add(target);
843
844            String targetValue;
845            if (tokens.containsKey(target))
846               targetValue = tokens.get(target);          // own map wins outright (exact shadowing)
847            else if (openTokens.containsKey(target))
848               targetValue = openTokens.get(target);
849            else
850               throw iaex("Theme token '%s' references unknown token '%s'.", definingName, target);
851
852            var next = referencedName(targetValue);
853            if (next == null)
854               // The resolved literal - and only that literal, with no var() present - is re-validated by the
855               // unchanged grammar as defense-in-depth against a bad value hiding under a referenceable name.
856               return CssValueGrammar.normalizeAndValidate(targetValue);
857            target = next;
858         }
859      }
860
861      /** Returns the {@code --jc-name} a {@code var(--jc-name)} reference points at, or <jk>null</jk> if {@code value} is not a reference. */
862      private static String referencedName(String value) {
863         var m = VAR_REFERENCE.matcher(value);
864         return m.matches() ? m.group(1) : null;
865      }
866
867      /** Renders a cycle path like {@code --jc-a -> --jc-b -> --jc-a} for a build-failure message. */
868      private static String cyclePath(Set<String> visited, String repeated) {
869         return String.join(" -> ", visited) + " -> " + repeated;
870      }
871   }
872}