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 * <@console> 035 * <@theme name="light-red"> 036 * <#-- A leaf: a literal (or a var() resolved to one at build time). --> 037 * <@token name="--jc-pill-red-bg" value="#fdeceb"/> 038 * <#-- An alias: a reference that stays live in the browser's cascade. --> 039 * <@token name="--jc-tab-bar-bg" alias="var(--jc-card-bg)"/> 040 * </@theme> 041 * <@main/> 042 * </@console> 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> </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 — 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 * — 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 — 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 — tab, newline, carriage return, form feed, vertical tab — 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>/*</code>, {@code url(} or a 141 * control character is <i>representable</i> in an accepted value — 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 — {@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 — 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 — 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 — including all five tag triads and every structural 341 * token — 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 — 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 — 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 — including all five tag triads and every structural token — 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} → {@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 — white content, a white header and page-nav with a 425 * distinctly-colored accent floor, subtle neutral borders, a restrained accent — 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 — including all five tag triads and every structural token — 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 — 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 — 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 — 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 — including all five tag triads, every structural token, and the blue affordance set 478 * — 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"} — 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} — 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 — 613 * the only way a name enters a theme. 614 * <li>{@code ConsoleChromeMixin.overrideBlock(Theme)}, at emission — 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 — 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 — 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 — 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 → reject C0/C1/DEL → strip comments → reject {@code url(} in 677 * any spelling → 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> — 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 — 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 * — {@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 — 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}