From 827dd9f222767bb0d36a3a903aeb91b54b72b613 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Mon, 24 Mar 2025 12:37:12 -0400 Subject: [PATCH] Port changes --- docs/_data/hueRanges.js | 2 +- docs/_includes/palette.njk | 36 ++ docs/_layouts/palette.njk | 255 ++++----- docs/_layouts/theme.njk | 2 +- docs/assets/scripts/sidebar-tweaks.js | 73 +-- docs/assets/scripts/tweak.js | 2 +- docs/assets/scripts/tweak/data.js | 228 +++++++-- docs/assets/scripts/tweak/permalink.js | 103 ++-- docs/assets/scripts/tweak/util.js | 312 ++++++++++- docs/assets/styles/docs.css | 54 +- .../docs/palettes/app/color/generate-grays.js | 28 + .../palettes/app/color/generate-palette.js | 162 ++++++ .../docs/palettes/app/color/generate-scale.js | 138 +++++ docs/docs/palettes/app/color/generate.js | 3 + .../docs/palettes/app/color/get-max-chroma.js | 91 ++++ .../palettes/app/color/get-palette-code.js | 65 +++ docs/docs/palettes/app/color/tweak.js | 74 +++ docs/docs/palettes/app/color/util.js | 154 ++++++ docs/docs/palettes/app/tweak.css | 306 ++++++++--- docs/docs/palettes/app/tweak.js | 483 +++++++++--------- .../app/vue-components/color-popup.js | 82 +++ .../app/vue-components/color-select.js | 73 +++ .../app/vue-components/color-slider.js | 343 +++++++++++++ .../app/vue-components/color-swatch-picker.js | 56 ++ .../palettes/app/vue-components/info-tip.js | 37 ++ docs/docs/themes/remix.css | 25 - docs/docs/themes/remix.js | 20 +- 27 files changed, 2580 insertions(+), 627 deletions(-) create mode 100644 docs/_includes/palette.njk create mode 100644 docs/docs/palettes/app/color/generate-grays.js create mode 100644 docs/docs/palettes/app/color/generate-palette.js create mode 100644 docs/docs/palettes/app/color/generate-scale.js create mode 100644 docs/docs/palettes/app/color/generate.js create mode 100644 docs/docs/palettes/app/color/get-max-chroma.js create mode 100644 docs/docs/palettes/app/color/get-palette-code.js create mode 100644 docs/docs/palettes/app/color/tweak.js create mode 100644 docs/docs/palettes/app/color/util.js create mode 100644 docs/docs/palettes/app/vue-components/color-popup.js create mode 100644 docs/docs/palettes/app/vue-components/color-select.js create mode 100644 docs/docs/palettes/app/vue-components/color-slider.js create mode 100644 docs/docs/palettes/app/vue-components/color-swatch-picker.js create mode 100644 docs/docs/palettes/app/vue-components/info-tip.js diff --git a/docs/_data/hueRanges.js b/docs/_data/hueRanges.js index 1f022b31b..4d938b992 100644 --- a/docs/_data/hueRanges.js +++ b/docs/_data/hueRanges.js @@ -1 +1 @@ -export { hueRanges as default } from '../assets/scripts/tweak/data.js'; +export { HUE_RANGES as default } from '../assets/scripts/tweak/data.js'; diff --git a/docs/_includes/palette.njk b/docs/_includes/palette.njk new file mode 100644 index 000000000..b559627ee --- /dev/null +++ b/docs/_includes/palette.njk @@ -0,0 +1,36 @@ + + + + + + {% for tint in tints -%} + + {%- endfor %} + + + + {%- set hueBefore = hues[hues|length - 2] -%} + {% for hue in hues -%} + {% set scale = palettes[paletteId][hue] %} + {% set coreTint = scale.maxChromaTint %} + {%- set coreColor = scale[coreTint] -%} + {%- set maxChroma = coreColor.c if coreColor.c > maxChroma else maxChroma -%} + + + + {% for tint in tints -%} + {%- set color = scale[tint] -%} + + {%- endfor -%} + + + {% endfor %} +
Core tint{{ tint }}
{{ hue | capitalize }} +
+ {{ scale.maxChromaTint }} +
+
+
+ +
+
diff --git a/docs/_layouts/palette.njk b/docs/_layouts/palette.njk index 9e5861b91..876708f7d 100644 --- a/docs/_layouts/palette.njk +++ b/docs/_layouts/palette.njk @@ -12,33 +12,41 @@ {% endblock %} {% block header %} -
+
+
{% include 'breadcrumbs.njk' %} -

- {% raw %}{{ saved.title }}{% endraw %} - - +

+ {{ title }} +

-

{{ title }}

-
- .wa-palette-{{ paletteId }} +
+ .wa-palette-{{ page.fileSlug }} {% include '../_includes/status.njk' %} {% if not isPro %} PRO @@ -49,17 +57,28 @@ {{ description | inlineMarkdown | safe }}

{% endif %} +
+ +
+
{% endblock %} {% block afterContent %} -{% set maxChroma = 0 %} - This palette has been tweaked. @@ -68,15 +87,12 @@ Reset - - - - - - Save - +

Scales

+ +{% include "palette.njk" %} + @@ -87,127 +103,68 @@ {%- endfor %} -{# Initialize to last hue before gray #} -{%- set hueBefore = hues[hues|length - 2] -%} -{% for hue in hues -%} -{% set coreTint = palettes[paletteId][hue].maxChromaTint %} -{%- set coreColor = palettes[paletteId][hue][coreTint] -%} -{%- set maxChroma = coreColor.c if coreColor.c > maxChroma else maxChroma -%} -{% if hue === 'gray' %} - -{% else %} - -{% endif %} - - - {% for tint in tints -%} - {%- set color = palettes[paletteId][hue][tint] -%} - -
- -
- - {%- endfor -%} - -{%- endfor %} + + + + + -{% set chromaScaleBounds = [ -(0.08 / maxChroma) | number({maximumFractionDigits: 2}), -(0.3 / maxChroma]) | number({maximumFractionDigits: 2}) -%} - +

Used By

@@ -310,8 +267,22 @@ Add the following code at the top of your CSS file: - {% endmarkdown %} + +
+

Saved variations

+ + +
+ {# {% include "svgs/palette.njk" %} #} + {% include "svgs/thumbnail-placeholder.njk" %} +
+ +
+
+
+ +
{# end palette app #} {% endblock %} diff --git a/docs/_layouts/theme.njk b/docs/_layouts/theme.njk index 63693354c..e8b33347f 100644 --- a/docs/_layouts/theme.njk +++ b/docs/_layouts/theme.njk @@ -81,7 +81,7 @@ wa_data.palettes = { {% set palette = defaultPalette %} - +
{% for hue in hues %} {% set currentBrand = hue == brand %} diff --git a/docs/assets/scripts/sidebar-tweaks.js b/docs/assets/scripts/sidebar-tweaks.js index 1c245717d..67929be70 100644 --- a/docs/assets/scripts/sidebar-tweaks.js +++ b/docs/assets/scripts/sidebar-tweaks.js @@ -13,23 +13,33 @@ sidebar.palettes = { sidebar.updateCurrent(); }, - updateSaved() { - this.saved = localStorage.savedPalettes ? JSON.parse(localStorage.savedPalettes) : []; + saved: [], + + /** + * Update saved palettes from local storage + */ + fromLocalStorage() { + // Replace contents of array without breaking references + let saved = localStorage.savedPalettes ? JSON.parse(localStorage.savedPalettes) : []; + this.saved.splice(0, this.saved.length, ...saved); }, - save(saved = this.saved) { - this.saved = saved ?? []; - - if (saved.length > 0) { - localStorage.savedPalettes = JSON.stringify(saved); + /** + * Write palettes to local storage + */ + toLocalStorage() { + if (this.saved.length > 0) { + localStorage.savedPalettes = JSON.stringify(this.saved); } else { delete localStorage.savedPalettes; } }, }; -sidebar.palettes.updateSaved(); -addEventListener('storage', event => sidebar.palettes.updateSaved()); +sidebar.palettes.fromLocalStorage(); + +// Palettes were updated in another tab +addEventListener('storage', () => sidebar.palettes.fromLocalStorage()); sidebar.palette = { getUid() { @@ -59,7 +69,9 @@ sidebar.palette = { delete(palette) { let savedPalettes = sidebar.palettes.saved; let count = savedPalettes.length; - if (count === 0) { + + if (count === 0 || !palette.uid) { + // No stored palettes or this palette has not been saved return; } @@ -68,7 +80,9 @@ sidebar.palette = { return; } - savedPalettes = savedPalettes.filter(p => !sidebar.palette.equals(palette, p)); + for (let index; index > -1; index = savedPalettes.findIndex(p => p.uid === palette.uid)) { + savedPalettes.splice(index, 1); + } if (savedPalettes.length === count) { // Nothing was removed @@ -96,17 +110,14 @@ sidebar.palette = { sidebar.updateCurrent(); - sidebar.palettes.save(savedPalettes); + sidebar.palettes.toLocalStorage(); - if (sidebar.palette.equals(globalThis.paletteApp?.saved, palette)) { + if (globalThis.paletteApp?.saved?.uid === palette.uid) { + // We deleted the currently active palette paletteApp.postDelete(); } }, - getSaved(palette, savedPalettes = sidebar.palettes.saved) { - return savedPalettes.find(p => sidebar.palette.equals(p, palette)); - }, - render(palette) { // Find existing let { title, id, search, uid } = palette; @@ -146,23 +157,27 @@ sidebar.palette = { } }, - save(palette, saved) { - let savedPalettes = sidebar.palettes.saved; - let existing = this.getSaved(saved ?? palette, savedPalettes); - let oldValues; - - if (existing) { - // Rename - oldValues = { ...existing }; - Object.assign(existing, palette); - } else { - savedPalettes.push(palette); + /** + * Save a palette, either by updating its existing entry or creating a new one + * @param {object} palette + */ + save(palette) { + if (!palette.uid) { + // First time saving + palette.uid = this.getUid(); } + let savedPalettes = sidebar.palettes.saved; + let existingIndex = palette.uid ? sidebar.palettes.saved.findIndex(p => p.uid === palette.uid) : -1; + let newIndex = existingIndex > -1 ? existingIndex : savedPalettes.length; + + let [oldValues] = sidebar.palettes.saved.splice(newIndex, 1, palette); + this.render(palette, oldValues); sidebar.updateCurrent(); + sidebar.palettes.toLocalStorage(); - sidebar.palettes.save(savedPalettes); + return palette; }, }; diff --git a/docs/assets/scripts/tweak.js b/docs/assets/scripts/tweak.js index 764eafab7..7c8805ccb 100644 --- a/docs/assets/scripts/tweak.js +++ b/docs/assets/scripts/tweak.js @@ -2,5 +2,5 @@ * Get import code for remixed themes and tweaked palettes. */ export { getThemeCode } from './tweak/code.js'; -export { cdnUrl, hueRanges, hues, selectors, tints, urls } from './tweak/data.js'; +export { HUE_RANGES, cdnUrl, hues, selectors, tints, urls } from './tweak/data.js'; export { default as Permalink } from './tweak/permalink.js'; diff --git a/docs/assets/scripts/tweak/data.js b/docs/assets/scripts/tweak/data.js index 24c768d6a..388f9db68 100644 --- a/docs/assets/scripts/tweak/data.js +++ b/docs/assets/scripts/tweak/data.js @@ -12,49 +12,6 @@ export const urls = { typography: id => `styles/themes/${id}/typography.css`, }; -export const selectors = { - palette: id => - [':where(:root)', ':host', ":where([class^='wa-theme-'], [class*=' wa-theme-'])", `.wa-palette-${id}`].join(',\n'), -}; - -export const hueRanges = { - red: { min: 5, max: 35 }, // 30 - orange: { min: 35, max: 60 }, // 25 - yellow: { min: 60, max: 112 }, // 45 - green: { min: 112, max: 170 }, // 55 - cyan: { min: 170, max: 220 }, // 50 - blue: { min: 220, max: 265 }, // 45 - indigo: { min: 265, max: 290 }, // 25 - purple: { min: 290, max: 320 }, // 30 - pink: { min: 320, max: 365 }, // 45 -}; - -export const moreHue = { - red: 'Redder', - orange: 'More orange', // https://www.reddit.com/r/grammar/comments/u9n0uo/is_it_oranger_or_more_orange/ - yellow: 'Yellower', - green: 'Greener', - cyan: 'More cyan', - blue: 'Bluer', - indigo: 'More indigo', - pink: 'Pinker', -}; - -/** - * Max gray chroma (% of chroma of undertone) per hue - */ -export const maxGrayChroma = { - red: 0.2, - orange: 0.2, - yellow: 0.25, - green: 0.25, - cyan: 0.3, - blue: 0.35, - indigo: 0.35, - purple: 0.3, - pink: 0.25, -}; - export const docsURLs = { colors: '/docs/themes/', palette: '/docs/palettes/', @@ -68,6 +25,189 @@ export const icons = { typography: 'font-case', }; -export const hues = Object.keys(hueRanges); +export const selectors = { + palette: id => + [':where(:root)', ':host', ":where([class^='wa-theme-'], [class*=' wa-theme-'])", `.wa-palette-${id}`].join(',\n'), +}; +export const HUE_RANGES = { + red: { min: 15, max: 35 }, // 20 + orange: { min: 35, max: 75 }, // 40 + yellow: { min: 75, max: 110 }, // 35 + green: { min: 115, max: 170 }, // 55 + cyan: { min: 170, max: 220 }, // 50 + blue: { min: 220, max: 265 }, // 45 + indigo: { min: 265, max: 290 }, // 25 + purple: { min: 290, max: 320 }, // 30 + pink: { min: 320, max: 375 }, // 55 +}; + +export const hues = Object.keys(HUE_RANGES); +export const allHues = [...hues, 'gray']; export const tints = ['05', '10', '20', '30', '40', '50', '60', '70', '80', '90', '95']; + +export const L_RANGES = { + '05': { min: 0.18, max: 0.2 }, + 10: { min: 0.23, max: 0.25 }, + 20: { min: 0.31, max: 0.35 }, + 30: { min: 0.38, max: 0.43 }, + 40: { min: 0.45, max: 0.5 }, + 50: { min: 0.55, max: 0.6 }, + 60: { min: 0.65, max: 0.7 }, + 70: { min: 0.73, max: 0.78 }, + 80: { min: 0.82, max: 0.85 }, + 90: { min: 0.91, max: 0.93 }, + 95: { min: 0.95, max: 0.97 }, +}; + +for (let range of [HUE_RANGES, L_RANGES]) { + for (let key in range) { + range[key].mid = (range[key].min + range[key].max) / 2; + } +} + +/** + * Most common tint per hue. + * Largely the statistical mode, but also informed by the average and median. + */ +export const HUE_TOP_TINT = { + red: 50, + orange: 70, + yellow: 80, + green: 80, + cyan: 70, + blue: 50, + indigo: 40, + purple: 50, + pink: 50, + gray: 40, +}; + +/* +┌─────────┬──────┬──────┬────────┬──────┬────────┬───────┐ +│ (index) │ min │ max │ median │ avg │ stddev │ count │ +├─────────┼──────┼──────┼────────┼──────┼────────┼───────┤ +│ red │ 0.74 │ 1 │ 0.92 │ 0.88 │ 0.085 │ 9 │ +│ yellow │ 0.72 │ 1 │ 0.98 │ 0.92 │ 0.11 │ 8 │ +│ green │ 0.55 │ 0.93 │ 0.75 │ 0.75 │ 0.1 │ 8 │ +│ cyan │ 0.7 │ 0.88 │ 0.82 │ 0.81 │ 0.053 │ 8 │ +│ blue │ 0.54 │ 1 │ 0.83 │ 0.82 │ 0.15 │ 9 │ +│ indigo │ 0.63 │ 1 │ 0.87 │ 0.86 │ 0.13 │ 8 │ +│ purple │ 0.58 │ 0.99 │ 0.86 │ 0.84 │ 0.11 │ 8 │ +│ pink │ 0.74 │ 1 │ 0.93 │ 0.89 │ 0.089 │ 8 │ +└─────────┴──────┴──────┴────────┴──────┴────────┴───────┘ +*/ +/** Max(Average, Median) % of max P3 chroma per hue, relative to palette maximum and capped to 0.8 */ +export const HUE_CHROMA_SCALE = { + red: 0.92, + orange: 0.96, // interpolated + yellow: 1, + green: 0.7, + cyan: 0.81, + blue: 0.83, + indigo: 0.87, + purple: 0.86, + pink: 0.92, +}; + +export const CHROMA_SCALE_LIGHTEST = { + 95: 1, + 90: 0.8, + 80: 0.5, + 70: 0.2, + 60: 0.2, + 50: 0.15, + 40: 0.1, +}; + +export const MAX_CHROMA_BY_TINT = { + 95: 0.11, +}; + +/** + * Chroma levels to identify gray. + * First number: below this we identify as gray regardless + * Second number: below this we identify as gray if it's also in the bottom 25% of colors when sorted by chroma + */ +export const GRAY_CHROMA_BY_TINT = { + '05': [0.03, 0.05], + 10: [0.035, 0.06], + 20: [0.045, 0.06], + 30: [0.05, 0.06], + 40: [0.05, 0.06], + 50: [0.04, 0.06], + 60: [0.03, 0.05], + 70: [0.02, 0.04], + 80: [0.015, 0.03], + 90: [0.007, 0.01], + 95: [0.004, 0.005], +}; + +export const moreHue = { + red: 'Redder', + orange: 'More orange', // https://www.reddit.com/r/grammar/comments/u9n0uo/is_it_oranger_or_more_orange/ + yellow: 'Yellower', + green: 'Greener', + cyan: 'More cyan', + blue: 'Bluer', + indigo: 'More indigo', + purple: 'Purpler', + pink: 'Pinker', +}; + +export const hueBefore = {}; +export const hueAfter = {}; + +for (let i = 0; i < hues.length; i++) { + hueBefore[hues[i]] = hues[i - 1] ?? hues.at(-1); + hueAfter[hues[i]] = hues[i + 1] ?? hues[0]; +} + +export const HUE_SHIFTS = [ + // Reds + { range: [0, 25], peak: [10, 25], shift: { dark: 15, light: -18 }, maxConsecutive: { dark: 4, light: -2 } }, + // Yellows + { range: [30, 112], peak: [70, 100], shift: { dark: -48, light: 16 }, maxConsecutive: { dark: -20, light: 4 } }, + + // Greens + { range: [140, 160], peak: [145, 155], shift: { dark: 15, light: -5 }, maxConsecutive: { dark: 7, light: -5 } }, + // Blues + { range: [240, 265], peak: [245, 260], shift: { dark: -3, light: -15 }, maxConsecutive: { dark: -3, light: -4 } }, +]; + +export const CHROMA_CURVES = { + 50: { dark: 0.9, light: 0.8 }, + 60: { dark: 1, light: 1.2 }, + 70: { light: 1.2 }, + 80: { dark: 1.1, light: 2 }, + 90: { dark: 3, light: 2 }, +}; + +export const MAX_CHROMA_BOUNDS = { min: 0.08, max: 0.3 }; + +/** + * Max gray chroma (% of chroma of undertone) per hue + */ +export const MAX_GRAY_CHROMA_SCALE = { + red: 0.2, + orange: 0.2, + yellow: 0.25, + green: 0.25, + cyan: 0.3, + blue: 0.35, + indigo: 0.35, + purple: 0.3, + pink: 0.25, +}; + +/** Default accent tint if all chromas are 0, but also the tint accent colors will be nudged towards (see chromaTolerance) */ +export const DEFAULT_ACCENT = 60; + +/** Min and max allowed tints */ +export const MIN_ACCENT = 40; +export const MAX_ACCENT = 90; + +/** Chroma tolerance: Chroma will need to differ more than this to gravitate away from defaultAccent */ +export const CHROMA_TOLERANCE = 0.000001; + +export const ROLES = ['brand', 'neutral', 'success', 'warning', 'danger']; diff --git a/docs/assets/scripts/tweak/permalink.js b/docs/assets/scripts/tweak/permalink.js index 27031dfd4..3d98b709e 100644 --- a/docs/assets/scripts/tweak/permalink.js +++ b/docs/assets/scripts/tweak/permalink.js @@ -13,56 +13,42 @@ export default class Permalink extends URLSearchParams { return Object.fromEntries(this.entries()); } - #mappings = new WeakMap(); - - mapObject(obj, mapping = {}) { - this.#mappings.set(obj, mapping); - } - - readFrom(obj) { - let mapping = this.#mappings.get(obj) ?? {}; - let { keyFrom = IDENTITY, valueFrom = IDENTITY } = mapping; - - for (let key in obj) { - let value = obj[key]; - let mappedValue = valueFrom(value); - let mappedKey = keyFrom(key); - this.set(mappedKey, mappedValue); - } - } - - writeTo(obj) { - let mapping = this.#mappings.get(obj) ?? {}; - let { keyTo = IDENTITY, valueTo = IDENTITY, canExtend = false } = mapping; - - for (let [key, value] of this) { - let mappedKey = keyTo(key); - let mappedValue = valueTo(value); - - if (canExtend || mappedKey in obj) { - obj[mappedKey] = mappedValue; - } - } - } - set(key, value, defaultValue) { - let oldValue = this.get(key); + if (equals(value, defaultValue) || equals(value, '')) { + value = null; + } - if (!value || value == defaultValue) { + value ??= null; // undefined -> null + + let oldValue = Array.isArray(value) ? this.getAll(key) : this.get(key); + let changed = !equals(value, oldValue); + + if (!changed) { + // Nothing to do here + return; + } + + if (Array.isArray(value)) { super.delete(key); + value = value.slice(); - if (oldValue) { - this.changed = true; + for (let v of value) { + if (v || v === 0) { + if (typeof v === 'object') { + super.append(key, JSON.stringify(v)); + } else { + super.append(key, v); + } + } } + } else if (value === null) { + super.delete(key); } else { super.set(key, value); - - if (String(value) !== String(oldValue)) { - this.changed = true; - } } this.sort(); + this.changed ||= changed; } /** @@ -79,3 +65,40 @@ export default class Permalink extends URLSearchParams { } } } + +function equals(value, oldValue) { + if (Array.isArray(value) || Array.isArray(oldValue)) { + value = toArray(value); + oldValue = toArray(oldValue); + + if (value.length !== oldValue.length) { + return false; + } + + return value.every((v, i) => equals(v, oldValue[i])); + } + + // (value ?? oldValue ?? true) returns true if they're both empty (null or undefined) + [value, oldValue] = [value, oldValue].map(v => (!v && v !== false && v !== 0 ? null : v)); + return value === oldValue || String(value) === String(oldValue); +} + +/** + * Convert a value to an array. `undefined` and `null` values are converted to an empty array. + * @param {*} value - The value to convert. + * @returns {any[]} The converted array. + */ +function toArray(value) { + value ??= []; + + if (Array.isArray(value)) { + return value; + } + + // Don't convert "foo" into ["f", "o", "o"] + if (typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') { + return Array.from(value); + } + + return [value]; +} diff --git a/docs/assets/scripts/tweak/util.js b/docs/assets/scripts/tweak/util.js index 12ee8dd55..acf7028cc 100644 --- a/docs/assets/scripts/tweak/util.js +++ b/docs/assets/scripts/tweak/util.js @@ -1,36 +1,304 @@ +// https://lea.verou.me/blog/2016/12/resolve-promises-externally-with-this-one-weird-trick/ +export function promise() { + let res, rej; + + let promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + + return Object.assign(promise, { resolve: res, reject: rej }); +} + export function normalizeAngles(angles) { - // First, normalize - angles = angles.map(h => ((h % 360) + 360) % 360); + // First, normalize each angle individually + let normalizedAngles = angles.map(h => ((h % 360) + 360) % 360); - // Remove top and bottom 25% and find average - let averageHue = - angles - .toSorted((a, b) => a - b) - .slice(angles.length / 4, -angles.length / 4) - .reduce((a, b) => a + b, 0) / angles.length; - - for (let i = 0; i < angles.length; i++) { - let h = angles[i]; - let prevHue = angles[i - 1]; - let delta = h - prevHue; + for (let i = 1; i < angles.length; i++) { + let angle = normalizedAngles[i]; + let prevAngle = normalizedAngles[i - 1]; + let delta = angle - prevAngle; if (Math.abs(delta) > 180) { - let equivalent = [h + 360, h - 360]; - // Offset hue to minimize difference in the direction that brings it closer to the average - let delta = h - averageHue; + let equivalent = [angle + 360, angle - 360]; - if (Math.abs(equivalent[0] - prevHue) <= Math.abs(equivalent[1] - prevHue)) { - angles[i] = equivalent[0]; - } else { - angles[i] = equivalent[1]; - } + // Offset hue to minimize difference in the direction that brings it closer to the previous hue + let deltas = equivalent.map(e => Math.abs(e - prevAngle)); + + normalizedAngles[i] = equivalent[deltas[0] < deltas[1] ? 0 : 1]; } } - return angles; + return normalizedAngles; } export function subtractAngles(θ1, θ2) { let [a, b] = normalizeAngles([θ1, θ2]); return a - b; } + +/** + * Given an object of keys to ranges, find the closest range. + * Ranges are assumed to be mutually exclusive. + * @param {Object} ranges + * @param {number} value + * @param {object} options + * @param {"angle" | undefined} options.type + * @param {number} [options.tolerance=Infinity] If value is not within any range, how close can it be? + * @param {(range: {min: number, max: number}) => {min: number, max: number}} options.getRange + * @returns {{key: string, distance: number}} The key of the closest range. Distance is 0 if the value is within the range, negative if below, positive if above. + */ +export function getRange(ranges, value, options) { + let { type } = options || {}; + let keys = Object.keys(ranges); + let closest = { key: keys[0], distance: Infinity }; + + for (let key of keys) { + let range = ranges[key]; + + if (options?.getRange) { + range = options.getRange(range); + } + + let { min, max } = range; + + if (Array.isArray(range)) { + [min, max] = range; + } + + let deltaMin = type === 'angle' ? subtractAngles(value, min) : value - min; + let deltaMax = type === 'angle' ? subtractAngles(value, max) : value - max; + + if (deltaMin >= 0 && deltaMax <= 0) { + return { key, distance: 0 }; + } + + if (Math.abs(deltaMin) < Math.abs(closest.distance)) { + closest = { key, distance: deltaMin }; + } + + if (deltaMax > 0 && Math.abs(deltaMax) < Math.abs(closest.distance)) { + closest = { key, distance: deltaMax }; + } + } + + // TODO use angle functions to check tolerance against angles + if (options?.tolerance !== undefined && Math.abs(closest.distance) > options.tolerance) { + return; + } + + return closest; +} + +export function camelCase(str) { + return (str + '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +export function capitalize(str) { + if (!str) { + return str; + } + + str = str + ''; + return str[0].toUpperCase() + str.slice(1); +} + +export function arrayNext(array, element) { + let index = array.indexOf(element); + return array[(index + 1) % array.length]; +} + +export function arrayPrevious(array, element) { + let index = array.indexOf(element); + return array[(index - 1 + array.length) % array.length]; +} + +export function levelToIndex(level) { + if (level === '05') { + return 0; + } + + return level === '95' ? 10 : +level / 10; +} + +export function indexToLevel(i) { + if (i === 0) { + return '05'; + } + + return (i === 10 ? 95 : i * 10) + ''; +} + +export function previousLevel(level) { + if (level === '05') { + return; + } + + return indexToLevel(levelToIndex(level) - 1); +} + +export function nextLevel(level) { + if (level === '95') { + return; + } + + return indexToLevel(levelToIndex(level) + 1); +} + +export function relativeLevel(level, steps) { + if (level == 100) { + // loose intentional + return relativeLevel(95, ++steps); + } + + if (level == 95) { + // loose intentional + return relativeLevel(90, ++steps); + } + + if (level == 0) { + // loose intentional + return relativeLevel(5, --steps); + } + + if (level == 5) { + // loose intentional + return relativeLevel(10, --steps); + } + + let index = clamp(0, levelToIndex(level) + steps, 10); + + return indexToLevel(index); +} + +/** + * + * @param {number} p Number from 0-1 where 0 is start and 1 is end + * @param {*} start Number for p=0 + * @param {*} end Number for p=1 + * @returns + */ +export function interpolate(p, range = [0, 1], options) { + let [start, end] = range; + + if (p <= 0 || p >= 1 || range.length === 2) { + let value = start + p * (end - start); + return options?.unclamped ? value : clamp(start, value, end); + } + + // If we're here, there are more points in the range + let interval = 1 / (range.length - 1); + let index = Math.floor(p / interval); + let intervalProgress = progress(p, [index * interval, (index + 1) * interval]); + return interpolate(intervalProgress, range.slice(index, index + 2), options); +} + +/** + * Inverse of interpolate: given a value, find the progress between start and end. + * @param {*} value + * @param {*} range + * @returns + */ +export function progress(value, range = [0, 1], options) { + let [start, end] = range; + + if (value <= start || value >= end || range.length === 2) { + let ret = (value - start) / (end - start); + + return options?.unclamped ? ret : clamp(0, ret, 1); + } + + // If we're here, there are more points in the range + let index = range.findIndex((v, i) => value > range[i - 1] && value <= v); + return (index - 1) / (range.length - 1); +} + +export function mapRange(value, { from, to, progression }) { + let p = progress(value, from); + + if (progression) { + p = progression(p); + } + + return interpolate(p, to); +} + +export function clamp(min, value, max) { + if (max < min) { + [min, max] = [max, min]; + } + + if (min !== undefined) { + value = Math.max(min, value); + } + + if (max !== undefined) { + value = Math.min(max, value); + } + + return value; +} + +export function clampAngle(min, value, max) { + [min, value, max] = normalizeAngles([min, value, max]); + return clamp(min, value, max); +} + +export function interpolateAngles(p, range) { + range = normalizeAngles(range); + return interpolate(p, range, { unclamped: true }); +} + +export function progressAngle(angle, range) { + [angle, ...range] = normalizeAngles([angle, ...range]); + return progress(angle, range, { unclamped: true }); +} + +/** + * Round a number to the nearest multiple of `roundTo` or to the closest number in an array of numbers + * @param {number} value + * @param {number | number[]} roundTo + * @returns + */ +export function roundTo(value, roundTo = 1) { + if (Array.isArray(roundTo)) { + let closest = roundTo[0]; + let closestDistance = Math.abs(value - closest); + + for (let candidate of roundTo) { + let distance = Math.abs(value - candidate); + + if (distance < closestDistance) { + closest = candidate; + closestDistance = distance; + } + } + + return closest; + } + + let decimals = roundTo.toString().split('.')[1]?.length ?? 0; + let ret = Math.round(value / roundTo) * roundTo; + + if (decimals > 0) { + // Eliminate IEEE 754 floating point errors + ret = +ret.toFixed(decimals); + } + + return ret; +} + +export function slugify(str) { + return str + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Convert accented letters to ASCII + .replace(/[^\w\s-]/g, '') // Remove remaining non-ASCII characters + .trim() + .replace(/\s+/g, '-') // Convert whitespace to hyphens + .toLowerCase(); +} + +export function log(...args) { + console.log(...args); + return args[0]; +} diff --git a/docs/assets/styles/docs.css b/docs/assets/styles/docs.css index 3a275228f..267824c06 100644 --- a/docs/assets/styles/docs.css +++ b/docs/assets/styles/docs.css @@ -440,9 +440,14 @@ wa-page > main:has(> .index-grid) { &.color { border-color: transparent; transition: background var(--wa-transition-slow); - background: linear-gradient(var(--color-2, transparent) 0% 100%) no-repeat border-box var(--color,); - background-position: var(--color-2-position, bottom); - background-size: var(--color-2-width, 100%) var(--color-2-height, 50%); + background: + linear-gradient(var(--color-top, transparent) 0% 100%) top no-repeat border-box, + linear-gradient(var(--color-bottom, transparent) 0% 100%) bottom no-repeat border-box var(--color,); + background-position: top, bottom; + background-size: + var(--color-top-width, 100%) var(--color-top-height, 30%), + var(--color-bottom-width, 100%) var(--color-bottom-height, 30%); + color: var(--swatch-text-color); &.contrast-fail { outline: 1px dashed var(--wa-color-red); @@ -641,3 +646,46 @@ table.colors { max-height: 21lh; } } + +.color-select { + &.default::part(display-input) { + opacity: 0.6; + font-style: italic; + } + + > small { + margin-inline-start: var(--wa-space-xs); + padding-block: 0 var(--wa-space-xs); + } + + &::part(combobox)::before, + wa-option::before { + content: ''; + display: inline-block; + width: 1.2em; + aspect-ratio: 1; + margin-inline-end: var(--wa-space-xs); + flex: none; + border-radius: var(--wa-border-radius-m); + background: var(--color); + border: 1px solid var(--wa-color-surface-default); + } + + wa-option { + white-space: nowrap; + + &::before { + width: 1em; + margin-inline: var(--wa-space-xs); + } + + &::part(checked-icon) { + order: 2; + } + } + + .default-badge { + opacity: 0.6; + margin-inline-start: var(--wa-space-xs); + } +} diff --git a/docs/docs/palettes/app/color/generate-grays.js b/docs/docs/palettes/app/color/generate-grays.js new file mode 100644 index 000000000..be4612bf3 --- /dev/null +++ b/docs/docs/palettes/app/color/generate-grays.js @@ -0,0 +1,28 @@ +import { tints } from '/assets/scripts/tweak/data.js'; + +export function generateGrays(colors, { grayColor, grayChroma }) { + let ret = {}; + let undertoneScale = colors[grayColor]; + + // These will be the same, since scaling them won't change the relationship + ret.maxChromaTint = undertoneScale.maxChromaTint; + Object.defineProperty(ret, 'core', { + enumerable: false, + get() { + return this[this.maxChromaTint]; + }, + }); + ret.maxChromaTintRaw = undertoneScale.maxChromaTintRaw; + + for (let tint of tints) { + let colorUndertone = undertoneScale[tint].clone().to('oklch'); + ret[tint] = colorUndertone.set({ c: c => c * grayChroma }); + } + + ret.maxChroma = ret[ret.maxChromaTint].get('oklch.c'); + ret.maxChromaRaw = ret[ret.maxChromaTintRaw].get('oklch.c'); + + return ret; +} + +export default generateGrays; diff --git a/docs/docs/palettes/app/color/generate-palette.js b/docs/docs/palettes/app/color/generate-palette.js new file mode 100644 index 000000000..5bfce4ba2 --- /dev/null +++ b/docs/docs/palettes/app/color/generate-palette.js @@ -0,0 +1,162 @@ +// TODO move these to local imports +import Color from 'https://colorjs.io/dist/color.js'; +import generateGrays from './generate-grays.js'; +import generateScale from './generate-scale.js'; +import getMaxChroma from './get-max-chroma.js'; +import { getCoreTint } from './util.js'; +import { + HUE_CHROMA_SCALE, + HUE_RANGES, + HUE_TOP_TINT, + L_RANGES, + MAX_ACCENT, + MIN_ACCENT, +} from '/assets/scripts/tweak/data.js'; +import { + clamp, + clampAngle, + interpolate, + normalizeAngles, + progressAngle, + roundTo, + subtractAngles, +} from '/assets/scripts/tweak/util.js'; + +export default function generatePalette(seedHues, { huesAfter: allHuesAfter, ...options } = {}) { + let ret = {}; + + // Generate scales from seed hues + let firstSeedHue; + + let coreLevels = {}; + let seedMeta = {}; + + for (let hue in seedHues) { + let seedColors = seedHues[hue]; + + if (!seedColors) { + continue; + } + + firstSeedHue ??= hue; + + let coreLevel = (coreLevels[hue] = getCoreTint(seedColors)); + let coreColor = seedColors[coreLevel]; + let [l, c, h] = coreColor.getAll('oklch'); + + let lOffset = l - L_RANGES[coreLevel].mid; + let cScale = c / getMaxChroma(l, h); + let relativeCScale = cScale / HUE_CHROMA_SCALE[hue]; + let levelOffset = coreLevel - HUE_TOP_TINT[hue]; + seedMeta[hue] = { lOffset, cScale, relativeCScale, levelOffset }; + + ret[hue] = generateScale(seedColors); + } + + if (!firstSeedHue) { + // No valid seed colors, abort mission + return null; + } + + // Fill in remaining hues + let hueBefore = firstSeedHue; + + for (let hue of allHuesAfter[firstSeedHue]) { + if (hue in ret) { + continue; + } + + let huesAfter = allHuesAfter[hue]; + let seedHuesAfter = huesAfter.filter(hue => seedHues[hue]); + let neighboringSeedHues = [seedHuesAfter.at(-1), seedHuesAfter[0]]; + + // A number from 0 to 1 indicating how close we are to each neighboring seed hue (0 if only one seed hue) + let hueProgress = + seedHuesAfter.length === 1 + ? 0 + : progressAngle( + HUE_RANGES[hue].mid, + neighboringSeedHues.map(hue => HUE_RANGES[hue].mid), + ); + + // Hue of the core color of the previous seed scale + let hBefore = ret[hueBefore][ret[hueBefore].maxChromaTint].get('oklch.h'); + + // We start from the midpoint of the hue range + let h = HUE_RANGES[hue].mid; + + // Shift if too close to seed hues + let hBeforeDelta = subtractAngles(h, hBefore); + + if (Math.abs(hBeforeDelta) < 40) { + h = hBefore + 40 * Math.sign(hBeforeDelta); + } + + if (seedHuesAfter.length > 1) { + let hueAfter = seedHuesAfter[0]; + let hAfter = ret[hueAfter][ret[hueAfter].maxChromaTint].get('oklch.h'); + [hBefore, h, hAfter] = normalizeAngles([hBefore, h, hAfter]); + let hAfterDelta = subtractAngles(hAfter, h); + + if (hAfter - 40 < hBefore + 40) { + // It's not possible to have a distance of at least 40deg from both neighboring hues + // so at least maximize distance + h = (hBefore + hAfter) / 2; + } else if (hAfterDelta < 40) { + h = hAfter - 40; + } + } + + // Make sure hue is still within range for this scale + h = clampAngle(HUE_RANGES[hue].min, h, HUE_RANGES[hue].max); + + let coreLevelOffset = interpolate( + hueProgress, + neighboringSeedHues.map(hue => seedMeta[hue].levelOffset), + ); + let coreLevel = clamp(MIN_ACCENT, roundTo(HUE_TOP_TINT[hue] + coreLevelOffset, 10), MAX_ACCENT); + + coreLevels[hue] = coreLevel; + let lOffsets = neighboringSeedHues.map(hue => seedMeta[hue].lOffset); + let lOffset = interpolate(hueProgress, lOffsets); + let l = L_RANGES[coreLevel].mid + lOffset; + + let cScale = 1; + + if (hue === 'yellow') { + // Yellow tends to be the brighest hue in the palette + cScale = Math.max( + ...Object.values(seedMeta) + .map(meta => meta.relativeCScale) + .filter(c => c > 0), + ); + } else { + cScale = interpolate( + hueProgress, + neighboringSeedHues.map(neighboringHue => seedMeta[neighboringHue].relativeCScale), + ); + } + + cScale *= HUE_CHROMA_SCALE[hue]; + + let maxC = getMaxChroma(l, h); + let c = cScale * maxC; + // let c = interpolate( + // hueProgress, + // pinnedScale.map(scale => scale.maxChroma), + // ); + + let coreColor = new Color('oklch', [l, c, h]).toGamut('p3'); + + ret[hue] = generateScale(coreColor); + hueBefore = hue; + } + + if ('gray' in seedHues) { + ret.gray = generateScale(seedHues.gray); + } else { + ret.gray = generateGrays(ret, options); + } + + return ret; +} diff --git a/docs/docs/palettes/app/color/generate-scale.js b/docs/docs/palettes/app/color/generate-scale.js new file mode 100644 index 000000000..6cf3711ee --- /dev/null +++ b/docs/docs/palettes/app/color/generate-scale.js @@ -0,0 +1,138 @@ +import { getCoreTint, getHueShift, getLightness, identifyColor } from './util.js'; +import { + CHROMA_CURVES, + CHROMA_SCALE_LIGHTEST, + L_RANGES, + MAX_CHROMA_BY_TINT, + tints, +} from '/assets/scripts/tweak/data.js'; +import { clamp, interpolate, progress } from '/assets/scripts/tweak/util.js'; + +/** + * Generate a scale of tints from one or more key colors + * @param {Color | Record} seedColors + * @returns {Record} + */ +export function generateScale(seedColors) { + if (seedColors.constructor.name === 'Color') { + // Single color given + let { level } = identifyColor(seedColors); + seedColors = { [level]: seedColors }; + } + + // Find core color + let coreLevel = getCoreTint(seedColors); + let coreColor = seedColors[coreLevel]; + let coreChroma = coreColor.get('oklch.c'); + + let scale = {}; + + Object.defineProperties(scale, { + maxChromaTint: { value: coreLevel, enumerable: false, configurable: true }, + maxChromaTintRaw: { value: coreLevel, enumerable: false, configurable: true }, + maxChroma: { value: coreChroma, enumerable: false, configurable: true }, + maxChromaRaw: { value: coreChroma, enumerable: false, configurable: true }, + core: { + get() { + return this[this.maxChromaTint]; + }, + enumerable: false, + }, + }); + + // First, add pinned colors + for (let tint in seedColors) { + scale[tint] = seedColors[tint]; + } + + // For finding lightest and darkest pinned colors + let pinnedTints = Object.keys(seedColors).sort((a, b) => a - b); + let chromaCurve = CHROMA_CURVES[clamp(50, coreLevel, 90)]; + + // Now generate the rest, starting from the edges + if (!('95' in scale)) { + let lightestPinnedTint = pinnedTints.at(-1); + let lightest = seedColors[lightestPinnedTint]; + let lOffset = lightest.get('oklch.l') - L_RANGES[lightestPinnedTint].mid; + let chromaScale = CHROMA_SCALE_LIGHTEST[lightestPinnedTint]; + let hueShift = getHueShift(lightest, lightestPinnedTint, '95'); + + let color = lightest.clone().to('oklch'); + color.set({ + l: getLightness(95, lOffset), + c: clamp(0, lightest.get('oklch.c') * chromaScale, MAX_CHROMA_BY_TINT[95]), + h: h => h + hueShift, + }); + + scale[95] = color; + } + + if (!('05' in scale)) { + let darkestPinnedTint = pinnedTints[0]; + let darkest = seedColors[darkestPinnedTint]; + let lOffset = darkest.get('oklch.l') - L_RANGES[darkestPinnedTint].mid; + let color = darkest.clone().to('oklch'); + let hueShift = getHueShift(darkest, darkestPinnedTint, '05'); + + color.set({ + l: getLightness('05', lOffset), + // TODO c + h: h => h + hueShift, + }); + + scale['05'] = color; + } + + let tintBefore = '05'; + + for (let tint of tints) { + if (tint in scale) { + // Pinned or already generated + tintBefore = tint; + continue; + } + + // Generated color + // First, find closest pinned colors before and after + let tintAfter = pinnedTints.find(level => level > tint) ?? '95'; + let neighboringTints = [tintBefore, tintAfter]; + let neighboringColors = neighboringTints.map(t => scale[t]); + let tintProgress = progress(tint, neighboringTints); + + let color = coreColor.clone().to('oklch'); + + // Lightness + let lOffset = interpolate( + tintProgress, + neighboringTints.map(t => scale[t].get('oklch.l') - L_RANGES[t].mid), + ); + + // Interpolate hue linearly and chroma with a power curve + color.set({ + l: getLightness(tint, lOffset), + c: interpolate( + tintProgress, + neighboringColors.map(c => c.get('oklch.c')), + { + progression: tint > coreLevel ? p => p ** chromaCurve.light : undefined, + }, + ), + h: interpolate( + tintProgress, + neighboringColors.map(c => c.get('oklch.h')), + ), + }); + + scale[tint] = color; + } + + for (let tint in scale) { + if (!(tint in seedColors) && scale[tint].toGamut) { + scale[tint] = scale[tint].toGamut('p3'); + } + } + + return scale; +} + +export default generateScale; diff --git a/docs/docs/palettes/app/color/generate.js b/docs/docs/palettes/app/color/generate.js new file mode 100644 index 000000000..b66d27b2f --- /dev/null +++ b/docs/docs/palettes/app/color/generate.js @@ -0,0 +1,3 @@ +export { generateGrays, generateGrays as grays } from './generate-grays.js'; +export { generatePalette, generatePalette as palette } from './generate-palette.js'; +export { generateScale, generateScale as scale } from './generate-scale.js'; diff --git a/docs/docs/palettes/app/color/get-max-chroma.js b/docs/docs/palettes/app/color/get-max-chroma.js new file mode 100644 index 000000000..f7c2a82dd --- /dev/null +++ b/docs/docs/palettes/app/color/get-max-chroma.js @@ -0,0 +1,91 @@ +/** + * Memoized calculation of OKLCH gamut boundary for a given L and H + * Currently unused, but we can use it if existing code becomes too slow. + */ +import Color from 'https://colorjs.io/dist/color.js'; +import { interpolate, progress, progressAngle, roundTo } from '/assets/scripts/tweak/util.js'; + +/** Max oklch.c per h and l (rounded to 1 significant digit) */ +const maxChroma = {}; +const OOG_CHROMA = 0.4; // guaranteed to be OOG for every P3 color +const C_THRESHOLD = 0.03; +const MIN_H_STEP = 0.1; +const MIN_L_STEP = 0.001; + +export default function getMaxChroma(l, h) { + let { hStep, lStep, count } = calculateBoundary(l, h); + + let hRounded = roundTo(h, hStep); + let lRounded = roundTo(l, lStep); + + // Calculate gamut boundary around this point + let hProgress = progressAngle(h - hRounded, [-hStep, 0, hStep]); + let lProgress = progress(l - lRounded, [-lStep, 0, lStep]); + let maxChromaH = []; + + for (let i of [-1, 0, 1]) { + let h = roundTo(hRounded + i * hStep, hStep); + + let cs = [-1, 0, 1].map(j => { + let l = roundTo(lRounded + j * lStep, lStep); + + return maxChroma[l][h]; + }); + + maxChromaH.push(interpolate(lProgress, cs)); + } + + // Interpolate between the 9 points using bilinear interpolation + let c = interpolate(hProgress, maxChromaH); + + return c; +} + +function calculateBoundary(pointL, pointH, lStep = 0.1, hStep = 10) { + let hRounded = roundTo(pointH, hStep); + let lRounded = roundTo(pointL, lStep); + let ret = { count: 0, hStep, lStep }; + + for (let i of [-1, 0, 1]) { + let l = roundTo(lRounded + i * lStep, lStep); + maxChroma[l] ??= {}; + + for (let j of [-1, 0, 1]) { + let h = roundTo(hRounded + j * hStep, hStep); + + if (maxChroma[l][h] !== undefined) { + continue; + } + + let gamutBoundary = new Color('oklch', [l, OOG_CHROMA, h]).toGamut('p3', { method: 'oklch.c' }); + let c = gamutBoundary.get('c'); + maxChroma[l][h] = c; + ret.count++; + let tooFar = { h: false, l: false }; + + if (i > -1) { + let lPrev = roundTo(lRounded + (i - 1) * lStep, lStep); + let cPrev = maxChroma[lPrev][h]; + tooFar.l = Math.abs(c - cPrev) > C_THRESHOLD && lStep > MIN_L_STEP; + + if (tooFar.l) { + ret.lStep /= 2; + ret.count += calculateBoundary(pointL, pointH, ret.lStep, ret.hStep).count; + } + } + + if (j > -1) { + let hPrev = roundTo(hRounded + (j - 1) * hStep, hStep); + let cPrev = maxChroma[l][hPrev]; + tooFar.h = Math.abs(c - cPrev) > C_THRESHOLD && hStep > MIN_H_STEP; + + if (tooFar.h) { + ret.hStep /= 2; + ret.count += calculateBoundary(pointL, pointH, ret.lStep, ret.hStep).count; + } + } + } + } + + return ret; +} diff --git a/docs/docs/palettes/app/color/get-palette-code.js b/docs/docs/palettes/app/color/get-palette-code.js new file mode 100644 index 000000000..8b572943b --- /dev/null +++ b/docs/docs/palettes/app/color/get-palette-code.js @@ -0,0 +1,65 @@ +import { stringifyColor } from './util.js'; +import { cssImport, cssLiteral, cssRule } from '/assets/scripts/tweak/code.js'; +import { selectors, tints, urls } from '/assets/scripts/tweak/data.js'; + +export function getPaletteCode({ base, colors, tweaked, ...options }) { + let imports = []; + + if (base && options.imports !== false) { + imports.push(urls.palette(base)); + } + + let ret = imports.map(url => cssImport(url, options)).join('\n'); + + let declarations = []; + let prefix = options.prefix ?? 'wa-color'; + + let css = ''; + + if (tweaked) { + for (let hue in colors) { + if (hue === 'gray') { + if (!tweaked.grayChroma && !tweaked.grayColor) { + continue; + } + } else if (!tweaked.chromaScale && !tweaked.hue?.[hue]) { + continue; + } + + let scale = colors[hue]; + + for (let tint of tints) { + let color = scale[tint]; + let stringified = stringifyColor(color); + declarations.push(`--${prefix}-${hue}-${tint}: ${stringified};`); + } + + let coreTint = scale.maxChromaTint; + if (coreTint) { + declarations.push( + `--${prefix}-${hue}: var(--${prefix}-${hue}-${coreTint});`, + `--${prefix}-${hue}-key: ${coreTint};`, + ); + } + + declarations.push(''); + } + } + + if (declarations.length > 0) { + let selector = options.selector ?? selectors.palette(base); + css += cssRule(selector, declarations); + } + + if (css) { + if (imports.length) { + ret += '\n\n'; + } + + ret += `${cssLiteral(css, options)}`; + } + + return ret; +} + +export default getPaletteCode; diff --git a/docs/docs/palettes/app/color/tweak.js b/docs/docs/palettes/app/color/tweak.js new file mode 100644 index 000000000..330437865 --- /dev/null +++ b/docs/docs/palettes/app/color/tweak.js @@ -0,0 +1,74 @@ +// TODO move these to local imports +import generateGrays from './generate-grays.js'; +import { tints } from '/assets/scripts/tweak/data.js'; + +export function tweakPalette(baseColors, tweaks, tweaked) { + let ret = {}; + + if (!tweaked) { + return baseColors; + } + + for (let hue in baseColors) { + let originalScale = baseColors[hue]; + let scale = (ret[hue] = {}); + let descriptors = Object.getOwnPropertyDescriptors(originalScale); + Object.defineProperties(scale, { + maxChromaTint: { ...descriptors.maxChromaTint, enumerable: false }, + maxChromaTintRaw: { ...descriptors.maxChromaTintRaw, enumerable: false }, + core: { + get() { + return this[this.maxChromaTint]; + }, + enumerable: false, + }, + }); + + if (hue === 'gray') { + if (tweaked.grayChroma || tweaked.grayColor) { + let grayColor = tweaks.grayColor ?? this.originalGrayColor; + let grayChroma = this.computedGrayChroma; + ret.gray = generateGrays(baseColors, { grayColor, grayChroma }); + } else { + ret.gray = originalScale; + } + continue; + } + + for (let tint of tints) { + scale[tint] = tweakColor(hue, originalScale[tint], tweaks, tweaked); + } + } + + return ret; +} + +export function tweakColor(hue, originalColor, tweaks, tweaked) { + if (!tweaked) { + return originalColor; + } + + let color = originalColor; + let { hueShifts, chromaScale = 1, grayColor, grayChroma } = tweaks; + + let tweak = {}; + let thisTweaked = false; + + if (tweaked.hue && hueShifts[hue]) { + tweak.h = h => h + hueShifts[hue]; + thisTweaked = true; + } + + if (tweaked.chromaScale && chromaScale !== 1) { + tweak.c = c => c * chromaScale; + thisTweaked = true; + } + + if (thisTweaked) { + color = color.clone().to('oklch').set(tweak); + } + + return color; +} + +export default tweakPalette; diff --git a/docs/docs/palettes/app/color/util.js b/docs/docs/palettes/app/color/util.js new file mode 100644 index 000000000..eaaef470a --- /dev/null +++ b/docs/docs/palettes/app/color/util.js @@ -0,0 +1,154 @@ +import { + CHROMA_TOLERANCE, + DEFAULT_ACCENT, + GRAY_CHROMA_BY_TINT, + HUE_RANGES, + HUE_SHIFTS, + L_RANGES, + MAX_ACCENT, + MIN_ACCENT, + tints, +} from '/assets/scripts/tweak/data.js'; +import { clamp, getRange, mapRange } from '/assets/scripts/tweak/util.js'; + +export function identifyColor(color, colors) { + let [l, c, h] = color.getAll('oklch'); + let level = getRange(L_RANGES, l).key; + let hue; + + // Identify grays + let grayBounds = GRAY_CHROMA_BY_TINT[level]; + if (c <= grayBounds[1]) { + // Possibly gray + if (c <= grayBounds[0]) { + // Definitely gray + hue = 'gray'; + } else if (colors) { + // May or may not be gray, compare to palette max chroma + // FIXME this does not take level into account, so is more likely to identify lighter colors as gray + let maxChroma = Math.max(...colors.map(color => color.get('oklch.c'))); + + if (c / maxChroma < 0.2) { + hue = 'gray'; + } + } + } + + hue ??= getRange(HUE_RANGES, h, { type: 'angle' }).key; + + return { hue, level }; +} + +export function getLightness(level, distance) { + return clamp(L_RANGES[level].min, L_RANGES[level].mid + distance, L_RANGES[level].max); +} + +/** + * How many tints are between two tints? + * E.g. `getTintDistance('90', '95')` should return `1` + * @param {number | string} tint1 + * @param {number | string} tint2 + * @returns {number} + */ +export function getTintDistance(tint1, tint2) { + tint1 = String(tint1); + tint2 = String(tint2); + return tints.indexOf(tint2) - tints.indexOf(tint1); +} +export function getHueShift(color, fromTint, toTint) { + let tintDistance = getTintDistance(fromTint, toTint); + let hueShift = getRange(HUE_SHIFTS, color.get('oklch.h'), { + getRange: v => v.range, + type: 'angle', + tolerance: 0, + }); + + if (!hueShift) { + return 0; + } + + hueShift = HUE_SHIFTS[hueShift.key]; + + let { peak, range } = hueShift; + let h = color.get('oklch.h'); + let breakpoints = [range[0], ...peak, range[1]]; + let intensity = mapRange(h, breakpoints, [0, 1, 1, 0]); + let type = tintDistance < 0 ? 'dark' : 'light'; + let shift = hueShift.shift[type]; + + let ret = shift * intensity; + let maxConsecutive = hueShift.maxConsecutive[type] ?? hueShift.maxConsecutive; + let maxShift = Math.sign(shift) * maxConsecutive * Math.abs(tintDistance); + + ret = clamp(undefined, ret, maxShift); + + return ret; +} + +export function getCoreTint(scale) { + let tintsInScale = Object.keys(scale); + + if (tintsInScale.length <= 1) { + return tintsInScale[0]; + } + + let ret = DEFAULT_ACCENT in scale ? DEFAULT_ACCENT : tintsInScale[Math.floor(tintsInScale.length / 2)]; + let maxChroma = 0; + + for (let tint in scale) { + let color = scale[tint]; + let chroma = color.get('oklch.c'); + + if (chroma > maxChroma + CHROMA_TOLERANCE && tint >= MIN_ACCENT && tint <= MAX_ACCENT) { + ret = tint; + maxChroma = chroma; + } + } + + return ret; +} + +export function getContrasts(colors, originalContrasts) { + let ret = {}; + + for (let hue in colors) { + ret[hue] = {}; + + for (let tintBg of tints) { + ret[hue][tintBg] = {}; + let bgColor = colors[hue][tintBg]; + + if (!bgColor || !bgColor.contrast) { + continue; + } + + for (let tintFg of tints) { + let fgColor = colors[hue][tintFg]; + let value = bgColor.contrast(fgColor, 'WCAG21'); + if (originalContrasts) { + let original = originalContrasts[hue][tintBg][tintFg]; + ret[hue][tintBg][tintFg] = { value, original, bgColor, fgColor }; + } else { + ret[hue][tintBg][tintFg] = value; + } + } + } + } + + return ret; +} + +/** + * Return hex code iff a color is within sRGB, otherwise fall back to its default string representation + * + * @param {Color} color + * @returns {string} + */ +export function stringifyColor(color) { + if (color?.constructor.name !== 'Color') { + return color; + } + + let format = color.inGamut('srgb') ? 'hex' : undefined; + return color.toString({ format }); +} diff --git a/docs/docs/palettes/app/tweak.css b/docs/docs/palettes/app/tweak.css index ea2971986..ad1704f84 100644 --- a/docs/docs/palettes/app/tweak.css +++ b/docs/docs/palettes/app/tweak.css @@ -1,3 +1,4 @@ +/* CSS included both in predefined palettes and custom ones */ :root { --fa-sliders-simple: '\f1de'; } @@ -14,17 +15,16 @@ wa-dropdown > .color.swatch { cursor: pointer; } -.decorated-slider { +.color-slider { display: grid; grid-template-columns: auto 1fr auto; - margin-block-end: var(--wa-space-xl); wa-slider { grid-column: 1 / -1; --track-height: 1em; --track-color-inactive: transparent; --track-color-active: transparent; - --thumb-color: var(--color-tweaked, var(--color)); + --thumb-color: var(--color); --thumb-shadow: 0 0 0 var(--thumb-gap) var(--wa-color-surface-default), var(--wa-shadow-offset-x-m) var(--wa-shadow-offset-y-m) var(--wa-shadow-blur-m) calc(var(--wa-shadow-offset-x-m) * -1 + var(--thumb-gap)) var(--wa-color-shadow); @@ -34,8 +34,29 @@ wa-dropdown > .color.swatch { } &::part(base) { + position: relative; background: linear-gradient(to right in var(--color-interpolation-space, oklab), var(--color-1), var(--color-2)); } + + .tick { + --width: 1px; + --height: 0.5em; + --tick-color: var(--wa-color-neutral-border-normal); + width: 4px; + height: 2.4em; + background: no-repeat; + background-image: linear-gradient(var(--tick-color) 0 100%), linear-gradient(var(--tick-color) 0 100%); + background-position: top, bottom; + background-size: var(--width) var(--height); + position: absolute; + left: calc(var(--default-value-progress) * 100% - (var(--default-value-progress) - 0.5) * var(--thumb-size)); + translate: -50% 0; + bottom: -0.5em; + + &:hover { + --tick-color: var(--wa-color-neutral-border-loud); + } + } } [slot='label'] { @@ -45,10 +66,6 @@ wa-dropdown > .color.swatch { .clear-button { vertical-align: middle; font-size: var(--wa-font-size-xs); - - &:not(.tweaked *) { - display: none; - } } .label-min, @@ -67,33 +84,56 @@ wa-dropdown > .color.swatch { } } -.hue-shift-slider { - --color-1: oklch(from var(--color) l c calc(h + var(--min, 0))); - --color-2: oklch(from var(--color) l c calc(h + var(--max, 0))); - --color-interpolation-space: oklch; -} - -.chroma-scale-slider { - --color: var(--wa-color-brand); - --color-1: oklch(from var(--color) l calc(c * var(--min)) h); - --color-2: oklch(from var(--color) l calc(c * var(--max)) h); -} - -.gray-chroma-slider { - --color: var(--wa-color-gray); - --color-1: oklch(from var(--wa-color-gray) l 0 none); - --color-2: oklch(from var(--color-gray-undertone) l calc(c * var(--max)) h); - margin-top: var(--wa-space-m); +[data-component='h'] { + --color-interpolation-space: oklch increasing hue; } .popup { + display: flex; + flex-flow: column; + gap: var(--wa-space-m); background: var(--wa-color-surface-default); + color: var(--wa-color-text-normal); border: 1px solid var(--wa-color-surface-border); - padding: var(--wa-space-xl); + padding: var(--wa-space-m) var(--wa-space-l); border-radius: var(--wa-border-radius-m); + color-scheme: light; - code { - white-space: nowrap; + .copyable-code { + display: flex; + gap: var(--wa-space-xs); + align-items: center; + + code { + flex: 1; + max-width: 20ch; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + + > legend { + /* Force legend to be rendered inside the fieldset */ + float: left; + clear: all; + padding: 0; + } + + .wa-heading-s { + display: flex; + gap: var(--wa-gap-xs); + align-items: center; + + > :nth-child(1 of .align-end) { + margin-inline-start: auto; + } + } +} + +@scope (.wa-dark) to (.wa-light) { + .popup { + color-scheme: dark; } } @@ -102,44 +142,6 @@ wa-dropdown > .color.swatch { white-space: nowrap; } - td:not([data-hue='gray'] *) { - --tweak-c: calc(c * var(--chroma-scale, 1)); - --tweak-h: calc(h + var(--hue-shift, 0)); - - --color-tweaked-no-chroma-scale: oklch(from var(--color) l c var(--tweak-h)); - --color-tweaked-no-hue-shift: oklch(from var(--color) l var(--tweak-c) h); - - &:is([data-tint='90'], [data-tint='95']) { - /* Work around https://bugs.webkit.org/show_bug.cgi?id=287637 */ - - --color-tweaked-no-chroma-scale: lch(from var(--color) l c var(--tweak-h)); - --color-tweaked-no-hue-shift: lch(from var(--color) l var(--tweak-c) h); - - /* outline: 1px dashed red; */ - } - } - - .color.swatch { - --color-2: var(--color-tweaked); - --color-2-height: 100%; - - &:is(.tweaking *) { - --color-2-height: 70%; - } - - &:is(.tweaking-chroma *) { - --color: var(--color-tweaked-no-chroma-scale); - } - - &:is(.tweaking-hue *) { - --color: var(--color-tweaked-no-hue-shift); - } - - &:is(.tweaking-gray-chroma *) { - --color: var(--color-tweaked-no-gray-chroma); - } - } - .tweak-icon { position: absolute; top: 50%; @@ -148,8 +150,9 @@ wa-dropdown > .color.swatch { opacity: var(--tweak-icon-opacity, 0%); } - .core-column:hover { + .color.swatch:hover { --tweak-icon-opacity: 40%; + --copy-icon-opacity: 40%; } &.tweaked .core-column { @@ -160,6 +163,7 @@ wa-dropdown > .color.swatch { .tweaked-callout { padding: var(--wa-space-xs); padding-inline-start: var(--wa-space-m); + margin-block: var(--wa-space-m); align-items: center; &:not(.tweaked-any *) { @@ -179,7 +183,12 @@ wa-dropdown > .color.swatch { /* Better UI before Vue initializes */ [v-if='saved'], -[v-if^='tweaked'] { +[v-if^='tweaked'], +[v-cloak] { + display: none; +} + +.static-palette:has(+ .colors:not([v-cloak])) { display: none; } @@ -203,3 +212,166 @@ wa-dropdown > .color.swatch { gap: var(--wa-space-xs); } } + +[id='palette-info'] { + display: grid; + grid-template-columns: 1fr auto; + grid-auto-flow: column; + + > * { + grid-column: 1; + } +} + +.hue-wheel { + --r: clamp(2em, 6rem, 25vmin); + grid-column: 2; + grid-row: 1 / 5; + position: relative; + width: calc(var(--r) * 2); + aspect-ratio: 1; + border: 2px solid transparent; + border-radius: 50%; + --lc: var(--avg-l) var(--max-c); + --lc2: var(--avg-l) calc(var(--max-c) / 2); + margin-top: calc(var(--r) * -0.05); + background: conic-gradient( + in oklch, + oklch(var(--lc) 0), + oklch(var(--lc) 60), + oklch(var(--lc) 120), + oklch(var(--lc) 180), + oklch(var(--lc) 240), + oklch(var(--lc) 300), + oklch(var(--lc) 360) + ); + + &, + &::before { + --stops: oklch(var(--lc) 0), oklch(var(--lc) 60), oklch(var(--lc) 120), oklch(var(--lc) 180), oklch(var(--lc) 240), + oklch(var(--lc) 300), oklch(var(--lc) 360); + } + + &::before { + content: ''; + display: block; + height: 100%; + border-radius: 50%; + mask: radial-gradient(white, transparent); + background: radial-gradient(oklch(var(--avg-l) calc(var(--gray-chroma) * var(--max-c)) 0) 5%, transparent 30%), + conic-gradient( + in oklch, + oklch(var(--lc2) 0), + oklch(var(--lc2) 60), + oklch(var(--lc2) 120), + oklch(var(--lc2) 180), + oklch(var(--lc2) 240), + oklch(var(--lc2) 300), + oklch(var(--lc2) 360) + ); + } + + .color { + --scale-c: calc(var(--c) / var(--max-c)); + --distance: calc(var(--r) * var(--scale-c)); + + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(calc(var(--h) * 1deg - 90deg)) translateX(var(--distance)); + position: absolute; + z-index: 1; + width: calc(1.2em + 0.3em * var(--scale-c)); + aspect-ratio: 1; + + &:hover { + --scale: 1.2; + --line-color: white; + --line-style: solid; + } + + &::before { + content: ''; + position: absolute; + z-index: -1; + width: 100%; + height: 0; + border-top: 2px var(--line-style, dashed) var(--line-color, var(--wa-color-gray-80)); + padding-top: 100%; + top: calc(50% - 1px); + right: 50%; + width: var(--distance); + } + + &::after { + content: ''; + display: block; + position: relative; + height: 100%; + border-radius: 50%; + border: 2px solid white; + box-shadow: var(--wa-shadow-l); + background: var(--color); + transition: var(--wa-transition-fast); + scale: var(--scale, 1); + } + } + + wa-tooltip { + /* Prevent flickering */ + pointer-events: none; + } +} + +.scale-filter { + wa-tab wa-icon { + margin-right: 0.4em; + } +} + +.title wa-icon-button[name='pencil'] { + margin-inline-start: var(--wa-space-xs); +} + +.selected-swatch, +.color-select wa-option::before { + content: ''; + display: inline-block; + width: 1.2em; + aspect-ratio: 1; + flex: none; + border-radius: var(--wa-border-radius-m); + background: var(--color); + border: 1px solid var(--wa-color-surface-default); +} + +.color-select wa-option { + white-space: nowrap; + + &::before { + width: 1em; + margin-inline: var(--wa-space-xs); + } + + &::part(checked-icon) { + order: 2; + } + + wa-icon[name='square-plus'] { + vertical-align: -0.15em; + color: var(--color-gray); + opacity: 0.6; + } +} + +.color-popup { + display: block; + + .popup { + min-width: 25ch; + } +} + +wa-icon[name='thumbtack'], +wa-icon-button[name='thumbtack']::part(icon) { + rotate: 45deg; +} diff --git a/docs/docs/palettes/app/tweak.js b/docs/docs/palettes/app/tweak.js index d6cba8cfa..85ce7533f 100644 --- a/docs/docs/palettes/app/tweak.js +++ b/docs/docs/palettes/app/tweak.js @@ -1,17 +1,34 @@ // TODO move these to local imports import Color from 'https://colorjs.io/dist/color.js'; -import { cdnUrl, hueRanges, hues, Permalink, tints } from '../../assets/scripts/tweak.js'; -import { cssImport, cssLiteral, cssRule } from '../../assets/scripts/tweak/code.js'; -import { maxGrayChroma, moreHue, selectors, urls } from '../../assets/scripts/tweak/data.js'; -import { subtractAngles } from '../../assets/scripts/tweak/util.js'; // import { createApp, nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'; import { createApp, nextTick } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js'; +import generatePalette from './color/generate-palette.js'; +import getMaxChroma from './color/get-max-chroma.js'; +import getPaletteCode from './color/get-palette-code.js'; import allPalettes from './color/palettes.js'; +import { tweakColor, tweakPalette } from './color/tweak.js'; +import { getContrasts, identifyColor } from './color/util.js'; +import ColorPopup from './vue-components/color-popup.js'; +import ColorSlider from './vue-components/color-slider.js'; +import ColorSwatchPicker from './vue-components/color-swatch-picker.js'; +import InfoTip from './vue-components/info-tip.js'; import Prism from '/assets/scripts/prism.js'; - -await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag))); - -const percentFormatter = value => value.toLocaleString(undefined, { style: 'percent' }); +import { Permalink } from '/assets/scripts/tweak.js'; +import { + allHues, + cdnUrl, + HUE_RANGES, + hueAfter, + hueBefore, + hues, + L_RANGES, + MAX_CHROMA_BOUNDS, + MAX_GRAY_CHROMA_SCALE, + moreHue, + ROLES, + tints, +} from '/assets/scripts/tweak/data.js'; +import { camelCase, capitalize, log, slugify, subtractAngles } from '/assets/scripts/tweak/util.js'; let paletteAppSpec = { data() { @@ -22,37 +39,41 @@ let paletteAppSpec = { return { uid: undefined, paletteId, - paletteTitle: palette.title, + originalPaletteTitle: palette.title, originalColors: palette.colors, + baseColors: { ...palette.colors }, permalink: new Permalink(), - hueRanges, hueShifts: Object.fromEntries(hues.map(hue => [hue, 0])), chromaScale: 1, grayChroma: undefined, grayColor: undefined, - tweaking: {}, saved: null, + unsavedChanges: false, + savedPalettes: sidebar.palettes.saved, }; }, created() { // Non-reactive variables to expose - Object.assign(this, { moreHue }); - - // Read URL params and apply them. This facilitates permalinks. - this.permalink.mapObject(this.hueShifts, { - keyTo: key => key.replace(/-shift$/, ''), - keyFrom: key => key + '-shift', - valueFrom: value => (!value ? '' : Number(value)), - valueTo: value => (!value ? 0 : Number(value)), + Object.assign(this, { + moreHue, + hueBefore, + hueAfter, + HUE_RANGES, + L_RANGES, + hues, + allHues, + tints, + MAX_CHROMA_BOUNDS, }); - this.grayChroma = this.originalGrayChroma; - this.grayColor = this.originalGrayColor; - if (location.search) { - // Update from URL - this.permalink.writeTo(this.hueShifts); + // Read URL params and apply them. This facilitates permalinks. + for (let hue in this.hueShifts) { + if (this.permalink.has(hue + '-shift')) { + this.hueShifts[hue] = Number(this.permalink.get(hue + '-shift')); + } + } for (let param of ['chroma-scale', 'gray-color', 'gray-chroma']) { if (this.permalink.has(param)) { @@ -70,19 +91,50 @@ let paletteAppSpec = { if (this.permalink.has('uid')) { this.uid = Number(this.permalink.get('uid')); + this.saved = sidebar.palettes.saved.find(p => p.uid === this.uid); } - - this.saved = sidebar.palette.getSaved(this.getPalette()); } }, mounted() { - for (let ref in this.$refs) { - this.$refs[ref].tooltipFormatter = percentFormatter; - } + nextTick().then(() => { + if (!this.tweaked || this.saved) { + this.unsavedChanges = false; + } + }); }, computed: { + /** + * Stage of interaction with the palette app + * 0: Static + * 1: Started editing + * 2: Edited + * @returns + */ + step() { + return this.tweaked ? 1 : 0; + }, + + slug() { + return this.paletteId; + }, + + /** Default palette title for saving */ + defaultPaletteTitle() { + return this.originalPaletteTitle + ' (tweaked)'; + }, + + paletteTitle() { + if (this.step === 0) { + return this.originalPaletteTitle; + } else if (this.saved) { + return this.saved.title; + } else { + return this.defaultPaletteTitle; + } + }, + tweaks() { return { hueShifts: this.hueShifts, @@ -92,14 +144,16 @@ let paletteAppSpec = { }; }, - isTweaked() { - return Object.values(this.hueShifts).some(Boolean); - }, - code() { let ret = {}; for (let language of ['html', 'css']) { - let code = getPaletteCode(this.paletteId, this.colors, this.tweaked, { language, cdnUrl }); + let code = getPaletteCode({ + base: this.paletteId, + colors: this.colors, + tweaked: this.tweaked, + language, + cdnUrl, + }); ret[language] = { raw: code, highlighted: Prism.highlight(code, Prism.languages[language], language), @@ -110,22 +164,7 @@ let paletteAppSpec = { }, colors() { - return applyTweaks.call(this, this.originalColors, this.tweaks, this.tweaked); - }, - - colorsMinusChromaScale() { - let tweaked = { ...this.tweaked, chromaScale: false }; - return applyTweaks.call(this, this.originalColors, this.tweaks, tweaked); - }, - - colorsMinusHueShifts() { - let tweaked = { ...this.tweaked, hue: false }; - return applyTweaks.call(this, this.originalColors, this.tweaks, tweaked); - }, - - colorsMinusGrayChroma() { - let tweaked = { ...this.tweaked, grayChroma: false }; - return applyTweaks.call(this, this.originalColors, this.tweaks, tweaked); + return tweakPalette.call(this, this.baseColors, this.tweaks, this.tweaked); }, tweaked() { @@ -137,8 +176,8 @@ let paletteAppSpec = { let ret = { chromaScale: this.chromaScale !== 1, hue, - grayChroma: this.grayChroma !== this.originalGrayChroma, - grayColor: this.grayColor !== this.originalGrayColor, + grayChroma: this.grayChroma !== undefined && this.grayChroma !== this.originalGrayChroma, + grayColor: this.grayColor !== undefined && this.grayColor !== this.originalGrayColor, }; let anyTweaked = Object.values(ret).some(Boolean); @@ -159,7 +198,7 @@ let paletteAppSpec = { continue; } - let relHue = shift < 0 ? arrayPrevious(hues, hue) : arrayNext(hues, hue); + let relHue = shift < 0 ? hueBefore[hue] : hueAfter[hue]; let hueTweak = moreHue[relHue] ?? relHue + 'er'; ret[hue] = capitalize(hueTweak + ' ' + hue + 's'); @@ -184,43 +223,105 @@ let paletteAppSpec = { }, originalContrasts() { - return getContrasts(this.originalColors); + return getContrasts(this.baseColors); }, contrasts() { return getContrasts(this.colors, this.originalContrasts); }, - originalCoreColors() { + baseCoreColors() { let ret = {}; - for (let hue in this.originalColors) { - let maxChromaTintRaw = this.originalColors[hue].maxChromaTintRaw; - ret[hue] = this.originalColors[hue][maxChromaTintRaw]; + for (let hue in this.baseColors) { + ret[hue] = this.baseColors[hue].core; } return ret; }, + baseMaxChromaHue() { + let maxChroma = -1; + let maxChromaHue = null; + + for (let hue in this.baseCoreColors) { + let color = this.baseCoreColors[hue]; + let chroma = color.get('oklch.c'); + if (chroma > maxChroma || !maxChromaHue) { + maxChroma = chroma; + maxChromaHue = hue; + } + } + return maxChromaHue; + }, + + baseMaxChromaColor() { + return this.baseCoreColors[this.baseMaxChromaHue]; + }, + + baseMaxChroma() { + return this.baseMaxChromaColor.get('oklch.c'); + }, + coreColors() { let ret = {}; for (let hue in this.colors) { - let maxChromaTintRaw = this.colors[hue].maxChromaTintRaw; - ret[hue] = this.colors[hue][maxChromaTintRaw]; + ret[hue] = this.colors[hue].core; } return ret; }, + maxChroma() { + return Math.max( + ...Object.values(this.coreColors) + .map(color => color.get('oklch.c')) + .filter(c => c >= 0), + ); + }, + + coreLevels() { + let ret = {}; + + for (let hue in this.colors) { + let maxChromaTint = this.colors[hue].maxChromaTint; + ret[hue] = maxChromaTint; + } + + return ret; + }, + + level() { + let levels = Object.values(this.coreLevels).sort((a, b) => a - b); + levels = levels.slice(levels.length / 4, -levels.length / 4); // Remove top and bottom 25% + let trimmedMean = levels.map(Number).reduce((a, b) => a + b, 0) / levels.length; + return Math.round(trimmedMean / 10) * 10; + }, + + shiftBounds() { + return Object.fromEntries( + hues.map(hue => { + let range = HUE_RANGES[hue]; + let coreHue = Math.round(this.baseCoreColors[hue].get('oklch.h')); + return [hue, { min: range.min - coreHue, max: range.max - coreHue }]; + }), + ); + }, + + chromaScaleBounds() { + return { min: MAX_CHROMA_BOUNDS.min / this.baseMaxChroma, max: MAX_CHROMA_BOUNDS.max / this.baseMaxChroma }; + }, + originalGrayColor() { - let grayHue = this.originalCoreColors.gray.get('h'); + let grayHue = this.baseCoreColors.gray.get('oklch.h'); let minDistance = Infinity; let closestHue = null; - for (let name in this.originalCoreColors) { + // Find core color whose hue is closest to our gray + for (let name in this.baseCoreColors) { if (name === 'gray') { continue; } - let hue = this.originalCoreColors[name].get('h'); + let hue = this.baseCoreColors[name].get('oklch.h'); let distance = Math.abs(subtractAngles(hue, grayHue)); if (distance < minDistance) { minDistance = distance; @@ -232,13 +333,12 @@ let paletteAppSpec = { }, originalGrayChroma() { - let coreTint = this.originalColors.gray.maxChromaTint; - let grayChroma = this.originalColors.gray[coreTint].get('c'); + let grayChroma = this.baseColors.gray.core.get('oklch.c'); if (grayChroma === 0 || grayChroma === null) { return 0; } - let grayColorChroma = this.originalColors[this.originalGrayColor][coreTint].get('c'); + let grayColorChroma = this.baseColors[this.originalGrayColor].core.get('oklch.c'); return grayChroma / grayColorChroma; }, @@ -248,19 +348,42 @@ let paletteAppSpec = { * This property is the gray chroma % that is actually applied. */ computedGrayChroma() { - return Math.min(this.grayChroma, this.maxGrayChroma); + let grayChroma = this.grayChroma ?? this.originalGrayChroma; + return Math.min(grayChroma, this.maxGrayChroma); + }, + + computedGrayColor() { + return this.grayColor ?? this.originalGrayColor; }, maxGrayChroma() { - return maxGrayChroma[this.grayColor] ?? 0.3; + return MAX_GRAY_CHROMA_SCALE[this.grayColor] ?? 0.3; }, - }, + + huesAfter() { + let ret = {}; + let huesRotated = [...hues]; + for (let hue of hues) { + let first = huesRotated.shift(); + ret[hue] = huesRotated.slice(); + huesRotated.push(first); + } + return ret; + }, + + /** Get other variants of the same base palette that are not this one */ + savedVariations() { + return this.savedPalettes.filter(palette => palette.id === this.paletteId && palette.uid !== this.uid); + }, + }, // end computed watch: { hueShifts: { deep: true, handler() { - this.permalink.readFrom(this.hueShifts); + for (let hue in this.hueShifts) { + this.permalink.set(hue + '-shift', this.hueShifts[hue], 0); + } }, }, @@ -284,58 +407,50 @@ let paletteAppSpec = { // Update page URL this.permalink.updateLocation(); - if (this.saved) { - this.save({ silent: true }); - } + this.unsavedChanges = true; }, }, - }, + }, // end watch methods: { - getPalette() { - return { id: this.paletteId, uid: this.uid, search: location.search }; - }, - - save({ silent } = {}) { - let title = silent - ? (this.saved?.title ?? this.paletteTitle) - : prompt('Palette title:', `${this.paletteTitle} (tweaked)`); - - if (!title) { - return; - } + capitalize, + slugify, + getMaxChroma, + log, + async save({ title } = {}) { let uid = this.uid; - if (!uid) { - // First time saving - this.uid = uid = sidebar.palette.getUid(); + this.saved ??= { id: this.paletteId, uid: this.uid, search: location.search }; + if (title) { + // Renaming + this.saved.title = title; + } else { + this.saved.title ??= this.defaultPaletteTitle; + } + + sidebar.palette.save(this.saved); + + if (uid !== this.saved.uid) { + // UID changed (most likely from saving a new palette) + this.uid = this.saved.uid; this.permalink.set('uid', uid); this.permalink.updateLocation(); } - let palette = { ...this.getPalette(), uid, title }; - - sidebar.palette.save(palette, this.saved); - this.saved = palette; + this.unsavedChanges = false; }, rename() { - if (!this.saved) { - return; + let newTitle = prompt('Palette title:', this.saved?.title ?? this.defaultPaletteTitle); + + if (newTitle && newTitle !== this.saved?.title) { + this.save({ title: newTitle }); } - - let newTitle = prompt('New title:', this.saved.title); - - if (!newTitle) { - return; - } - - this.saved.title = newTitle; - sidebar.palette.save(this.saved); }, + // Cannot name this delete() because Vue complains deleteSaved() { sidebar.palette.delete(this.saved); }, @@ -351,28 +466,30 @@ let paletteAppSpec = { * Remove a specific tweak or all tweaks * @param {string} [param] - The tweak to remove. If not provided, all tweaks are removed. */ - reset(param) { + reset(param, context = this) { if (!param || param === 'chromaScale') { - this.chromaScale = 1; + context.chromaScale = 1; } if (param in this.hueShifts) { - this.hueShifts[param] = 0; + context.hueShifts[param] = 0; } else if (!param) { for (let hue in this.hueShifts) { - this.hueShifts[hue] = 0; + context.hueShifts[hue] = 0; } } if (!param || param === 'grayColor') { - this.grayColor = this.originalGrayColor; + context.grayColor = this.originalGrayColor; } if (!param || param === 'grayChroma') { - this.grayChroma = this.originalGrayChroma; + context.grayChroma = this.originalGrayChroma; } + + return context; }, - }, + }, // end methods directives: { // Like v-text, but doesn't complain if the element has content, @@ -399,6 +516,13 @@ let paletteAppSpec = { }, }, + components: { + ColorPopup, + ColorSlider, + ColorSwatchPicker, + InfoTip, + }, + compilerOptions: { isCustomElement: tag => tag.startsWith('wa-'), }, @@ -417,140 +541,3 @@ function init() { init(); addEventListener('turbo:render', init); - -export function getPaletteCode(paletteId, colors, tweaked, options) { - let imports = []; - - if (paletteId) { - imports.push(urls.palette(paletteId)); - } - - let css = ''; - let declarations = []; - - if (tweaked) { - for (let hue in colors) { - if (hue === 'orange') { - continue; - } else if (hue === 'gray') { - if (!tweaked.grayChroma && !tweaked.grayColor) { - continue; - } - } else if (!tweaked.chromaScale && !tweaked.hue?.[hue]) { - continue; - } - - for (let tint of tints) { - let color = colors[hue][tint]; - let stringified = color.toString({ format: color.inGamut('srgb') ? 'hex' : undefined }); - declarations.push(`--wa-color-${hue}-${tint}: ${stringified};`); - } - - declarations.push(''); - } - - if (declarations.length > 0) { - css += cssRule(selectors.palette(paletteId), declarations); - } - } - - let ret = imports.map(url => cssImport(url, options)).join('\n'); - - if (css) { - ret += `\n\n${cssLiteral(css, options)}`; - } - - return ret; -} - -function arrayNext(array, element) { - let index = array.indexOf(element); - return array[(index + 1) % array.length]; -} - -function arrayPrevious(array, element) { - let index = array.indexOf(element); - return array[(index - 1 + array.length) % array.length]; -} - -function applyTweaks(originalColors, tweaks, tweaked) { - let ret = {}; - let { hueShifts, chromaScale = 1, grayColor, grayChroma } = tweaks; - - if (!tweaked) { - return originalColors; - } - - if (tweaked.grayChroma) { - grayChroma = this.computedGrayChroma; - } - - for (let hue in originalColors) { - let originalScale = originalColors[hue]; - let scale = (ret[hue] = {}); - let descriptors = Object.getOwnPropertyDescriptors(originalScale); - Object.defineProperties(scale, { - maxChromaTint: { ...descriptors.maxChromaTint, enumerable: false }, - maxChromaTintRaw: { ...descriptors.maxChromaTintRaw, enumerable: false }, - }); - - for (let tint of tints) { - let color = originalScale[tint].clone(); - - if (tweaked.hue && hueShifts[hue]) { - color.set({ h: h => h + hueShifts[hue] }); - } - - if (tweaked.chromaScale && chromaScale !== 1) { - color.set({ c: c => c * chromaScale }); - } - - if (hue === 'gray' && (tweaked.grayChroma || tweaked.grayColor)) { - let colorUndertone = originalColors[grayColor][tint].clone(); - color = colorUndertone.set({ c: c => c * grayChroma }); - } - - scale[tint] = color; - } - } - - return ret; -} - -function camelCase(str) { - return (str + '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -function capitalize(str) { - return str[0].toUpperCase() + str.slice(1); -} - -function getContrasts(colors, originalContrasts) { - let ret = {}; - - for (let hue in colors) { - ret[hue] = {}; - - for (let tintBg of tints) { - ret[hue][tintBg] = {}; - let bgColor = colors[hue][tintBg]; - - if (!bgColor || !bgColor.contrast) { - continue; - } - - for (let tintFg of tints) { - let fgColor = colors[hue][tintFg]; - let value = bgColor.contrast(fgColor, 'WCAG21'); - if (originalContrasts) { - let original = originalContrasts[hue][tintBg][tintFg]; - ret[hue][tintBg][tintFg] = { value, original, bgColor, fgColor }; - } else { - ret[hue][tintBg][tintFg] = value; - } - } - } - } - - return ret; -} diff --git a/docs/docs/palettes/app/vue-components/color-popup.js b/docs/docs/palettes/app/vue-components/color-popup.js new file mode 100644 index 000000000..d0ecfff01 --- /dev/null +++ b/docs/docs/palettes/app/vue-components/color-popup.js @@ -0,0 +1,82 @@ +import Color from 'https://colorjs.io/dist/color.js'; +import { stringifyColor } from '../color/util.js'; +import InfoTip from './info-tip.js'; + +export default { + props: { + title: String, + token: String, + color: Color, + deletable: Boolean, + pinnable: Boolean, + pinned: Boolean, + placement: String, + }, + data() { + return {}; + }, + emits: ['delete', 'pin'], + mounted() { + let popup = this.$refs.popup; + + if (popup) { + // Find trigger + let trigger = popup.previousElementSibling; + if (trigger) { + trigger.slot ||= 'trigger'; + } + } + }, + computed: { + stringifiedColor() { + return stringifyColor(this.color); + }, + }, + template: ` + + + + + + + + + + `, + compilerOptions: { + isCustomElement: tag => tag.startsWith('wa-'), + }, + components: { + InfoTip, + }, +}; diff --git a/docs/docs/palettes/app/vue-components/color-select.js b/docs/docs/palettes/app/vue-components/color-select.js new file mode 100644 index 000000000..acd412d9e --- /dev/null +++ b/docs/docs/palettes/app/vue-components/color-select.js @@ -0,0 +1,73 @@ +import { capitalize } from '/assets/scripts/tweak/util.js'; + +export default { + props: { + modelValue: String, + label: String, + getLabel: { + type: Function, + default: capitalize, + }, + getContent: { + type: Function, + }, + getColor: { + type: Function, + default: value => `var(--wa-color-${value})`, + }, + values: { + type: Array, + default: [], + }, + groups: { + type: Object, + }, + }, + emits: ['update:modelValue', 'input'], + data() { + return {}; + }, + computed: { + computedGroups() { + let ret = {}; + + if (this.values?.length) { + ret[''] = this.values; + } + + if (this.groups) { + for (let group in this.groups) { + if (this.groups[group]?.length) { + ret[group] = this.groups[group]; + } + } + } + + return ret; + }, + + firstGroup() { + return Object.keys(this.computedGroups)[0]; + }, + }, + + methods: { + capitalize, + handleInput(e) { + this.$emit('input', this.modelValue); + }, + }, + template: ` + + + + + `, +}; diff --git a/docs/docs/palettes/app/vue-components/color-slider.js b/docs/docs/palettes/app/vue-components/color-slider.js new file mode 100644 index 000000000..446c143b5 --- /dev/null +++ b/docs/docs/palettes/app/vue-components/color-slider.js @@ -0,0 +1,343 @@ +const template = ` +
+ +
+ {{ label }} + + +
+ +
+
+
+
{{ labelMin }}
+
{{ labelMax }}
+
+`; + +import Color from 'https://colorjs.io/dist/color.js'; +import InfoTip from './info-tip.js'; +import { capitalize, promise, roundTo } from '/assets/scripts/tweak/util.js'; + +export default { + props: { + coord: { + type: String, + required: true, + validator(value) { + return ['l', 'c', 'h'].includes(value); + }, + }, + color: Color, + defaultColor: Color, + + defaultValue: Number, + defaultValueRelative: Number, + + /** Used for relative types. Defaults to defaultValue if not provided. */ + baseValue: Number, + + /** Used for formatting only. Only specify if different from base value. */ + formatBaseValue: { + type: Number, + default: undefined, + }, + + modelValue: { + type: Number, + }, + min: Number, + max: Number, + minRelative: Number, + maxRelative: Number, + step: { + type: Number, + default: 1, + }, + + type: { + type: String, + default: 'raw', + }, + formatType: { + type: String, + }, + + label: String, + labelMin: String, + labelMax: String, + labelDefault: String, + }, + emits: ['update:modelValue', 'update:color', 'input'], + data() { + return { + mounted: promise(), + initialColor: this.color, + value: undefined, + tweaking: false, + }; + }, + created() { + if (!this.color && !this.defaultColor) { + console.warn( + `[${this.label}]`, + ' requires at least one of the following props: color, defaultColor', + ); + } + + if (this.modelValue !== undefined) { + this.value = this.getAbsoluteValue(this.modelValue); + } else if (this.color) { + this.value = this.colorCoords[this.coordIndex]; + } + }, + mounted() { + if (this.$refs.slider) { + this.$refs.slider.tooltipFormatter = value => this.formatValue(value); + this.$refs.slider.colorSliderData = this; // for debugging + } + + this.mounted.resolve(); + }, + beforeUnmount() { + delete this.$refs.slider?.colorSliderData; + }, + computed: { + computedMin() { + if (this.minRelative !== undefined) { + return getAbsoluteValue(this.minRelative); + } + + return this.min; + }, + + computedMax() { + if (this.maxRelative !== undefined) { + return this.getAbsoluteValue(this.maxRelative); + } + + return this.max; + }, + + computedColor() { + return this.getColorAt(this.value); + }, + + computedColorCoords() { + return this.computedColor.oklch.slice(); + }, + + colorCoords() { + let color = this.color ?? this.computedColor; + return color?.oklch.slice(); + }, + + computedColorString() { + return `oklch(${this.computedColorCoords.join(' ')})`; + }, + + colorString() { + return `oklch(${this.colorCoords.join(' ')})`; + }, + + defaultCoords() { + if (this.defaultColor) { + return this.defaultColor.oklch.slice(); + } + + let ret = this.color.oklch.slice(); + + if (this.defaultValue !== undefined) { + ret[this.coordIndex] = this.defaultValue; + } + + return ret; + }, + + coordIndex() { + return ['l', 'c', 'h'].indexOf(this.coord); + }, + + colorMin() { + return this.getColorAt(this.computedMin); + }, + + colorMax() { + return this.getColorAt(this.computedMax); + }, + + isRelative() { + return this.type && this.type !== 'raw'; + }, + + computedBaseValue() { + if (!this.isRelative) { + return; + } + + if (this.baseValue !== undefined) { + return this.baseValue; + } + + return this.computedDefaultValue; + }, + + computedDefaultValue() { + if (this.defaultValue !== undefined) { + return this.defaultValue; + } + + if (this.defaultValueRelative !== undefined) { + return this.getAbsoluteValue(this.defaultValueRelative); + } + + if (this.baseValue !== undefined) { + return this.baseValue; + } + + return this.defaultCoords[this.coordIndex]; + }, + + computedDefaultColor() { + return this.defaultColor ?? this.getColorAt(this.computedDefaultValue); + }, + + computedLabelDefault() { + let labelDefault = this.labelDefault || 'Default value'; + let formattedDefaultValue = this.formatValue(this.computedDefaultValue); + return `${labelDefault} (${formattedDefaultValue})`; + }, + + defaultProgress() { + return (this.computedDefaultValue - this.computedMin) / (this.computedMax - this.computedMin); + }, + + relativeValue() { + this.computedBaseValue; + return this.getRelativeValue(this.value); + }, + }, + methods: { + capitalize, + + getAbsoluteValue(relativeValue) { + return getAbsoluteValue({ + type: this.type, + relativeValue, + baseValue: this.baseValue ?? this.computedBaseValue, + }); + }, + + getRelativeValue(absoluteValue) { + return getRelativeValue({ + type: this.type, + absoluteValue, + baseValue: this.baseValue ?? this.computedBaseValue, + }); + }, + + formatValue(value = this.value) { + let formatType = this.formatType ?? this.type; + let style = formatType === 'scale' ? 'percent' : undefined; + + if (formatType && formatType !== 'raw') { + let baseValue = this.formatBaseValue ?? this.computedBaseValue; + value = getRelativeValue({ type: formatType, absoluteValue: value, baseValue }); + } + + value = roundTo(value, this.step); + return value.toLocaleString(undefined, { style }); + }, + + getColorAt(value) { + let coords = this.defaultCoords.slice(); + coords[this.coordIndex] = value; + return new Color('oklch', coords); + }, + + /** Called when value changes due to user interaction */ + handleInput(value) { + this.value = value; + this.tweaking = true; + this.$emit('input', value); + }, + + inputEnd() { + this.tweaking = false; + }, + + reset() { + this.handleInput(this.computedDefaultValue); + this.inputEnd(); + }, + }, + watch: { + computedColorString() { + if (this.color && this.colorString !== this.computedColorString) { + // Color changed, communicate to the outside world + this.$emit('update:color', this.computedColor); + } + }, + + colorString() { + if (this.color && this.colorString !== this.computedColorString) { + // Color changed in the outside world, update our internals + if (this.colorCoords[this.coordIndex] !== this.value) { + this.value = this.colorCoords[this.coordIndex]; + + let modelValue = this.getRelativeValue(this.value); + this.$emit('update:modelValue', modelValue); + } + } + }, + + relativeValue() { + this.$emit('update:modelValue', this.relativeValue); + }, + }, + template, + components: { InfoTip }, + compilerOptions: { + isCustomElement: tag => tag.startsWith('wa-'), + }, +}; + +function getAbsoluteValue({ type, relativeValue, baseValue }) { + if (baseValue === undefined) { + type = 'raw'; + } + + if (type === 'shift') { + return relativeValue + baseValue; + } + + if (type === 'scale') { + return relativeValue * baseValue; + } + + return relativeValue; +} + +function getRelativeValue({ type, absoluteValue, baseValue }) { + if (baseValue === undefined) { + type = 'raw'; + } + + if (type === 'shift') { + return absoluteValue - baseValue; + } + + if (type === 'scale') { + if (!absoluteValue) { + return 0; + } + + return absoluteValue / baseValue; + } + + return absoluteValue; +} diff --git a/docs/docs/palettes/app/vue-components/color-swatch-picker.js b/docs/docs/palettes/app/vue-components/color-swatch-picker.js new file mode 100644 index 000000000..b377b99b4 --- /dev/null +++ b/docs/docs/palettes/app/vue-components/color-swatch-picker.js @@ -0,0 +1,56 @@ +const template = ` + + +
{{ label }}
+
+`; + +import Color from 'https://colorjs.io/dist/color.js'; +import InfoTip from './info-tip.js'; +import { hues } from '/assets/scripts/tweak/data.js'; +import { capitalize, promise, roundTo } from '/assets/scripts/tweak/util.js'; + +export default { + props: { + modelValue: String, + label: { + type: String, + default: 'Color', + }, + colors: Object, + }, + emits: ['update:modelValue', 'input'], + data() { + return { + defaultValue: this.modelValue, + }; + }, + created() { + Object.assign(this, { hues }); + }, + computed: {}, + methods: { + capitalize, + + /** Called when value changes due to user interaction */ + handleInput(value) { + this.value = value; + this.$emit('input', value); + this.$emit('update:modelValue', value); + }, + + reset() { + this.handleInput(this.defaultValue); + }, + }, + template, + components: { InfoTip }, + compilerOptions: { + isCustomElement: tag => tag.startsWith('wa-'), + }, +}; diff --git a/docs/docs/palettes/app/vue-components/info-tip.js b/docs/docs/palettes/app/vue-components/info-tip.js new file mode 100644 index 000000000..74c85bdba --- /dev/null +++ b/docs/docs/palettes/app/vue-components/info-tip.js @@ -0,0 +1,37 @@ +import Color from 'https://colorjs.io/dist/color.js'; +import { stringifyColor } from '../color/util.js'; + +let maxUid = 0; + +export default { + props: {}, + data() { + let uid = ++maxUid; + return { uid, id: 'info-tip-' + uid }; + }, + mounted() { + let tooltip = this.$refs.tooltip; + if (tooltip) { + // Find trigger + let trigger = tooltip.previousElementSibling; + if (trigger) { + if (trigger.id) { + // Already has id + this.id = trigger.id; + } else { + trigger.id = this.id; + } + } + } + }, + computed: {}, + template: ` + + + + + `, + compilerOptions: { + isCustomElement: tag => tag.startsWith('wa-'), + }, +}; diff --git a/docs/docs/themes/remix.css b/docs/docs/themes/remix.css index e9472e600..a7bf258a7 100644 --- a/docs/docs/themes/remix.css +++ b/docs/docs/themes/remix.css @@ -94,31 +94,6 @@ } } } - - .selected-swatch, - wa-select[name='brand'] wa-option::before { - content: ''; - display: inline-block; - width: 1.2em; - aspect-ratio: 1; - flex: none; - border-radius: var(--wa-border-radius-m); - background: var(--color); - border: 1px solid var(--wa-color-surface-default); - } - - wa-select[name='brand'] wa-option { - white-space: nowrap; - - &::before { - width: 1em; - margin-inline: var(--wa-space-xs); - } - - &::part(checked-icon) { - order: 2; - } - } } #test_select wa-option:state(selected) { diff --git a/docs/docs/themes/remix.js b/docs/docs/themes/remix.js index f997541f0..50f91fffa 100644 --- a/docs/docs/themes/remix.js +++ b/docs/docs/themes/remix.js @@ -54,8 +54,12 @@ function init() { urlParams: new Permalink(), }; - data.urlParams.mapObject(data.params); - data.urlParams.writeTo(data.params); + // Apply params from permalink + for (let key in data.params) { + if (data.urlParams.has(key)) { + data.params[key] = data.urlParams.get(key); + } + } if (computed.isRemixed) { // Start with the remixing UI open if the theme has been remixed @@ -121,14 +125,22 @@ function render(changedAspect) { let brand = data.params.brand || data.defaultParams.brand; selects.brand.style.setProperty('--color', `var(--wa-color-${brand})`); - selects.brand.className = `wa-palette-${computed.palette}`; + + // Add current palette class and remove any other palette classes + let paletteClass = `wa-palette-${computed.palette}`; + selects.brand.className = selects.brand.className.replace(/\bwa-palette-[a-z]+\b/g, paletteClass); + selects.brand.classList.add(paletteClass); for (let aspect in data.params) { let value = data.params[aspect]; selects[aspect].value = value; } - data.urlParams.readFrom(data.params); + for (let key in data.params) { + if (data.params[key]) { + data.urlParams.set(key, data.params[key]); + } + } // Update demo URL domChange(() => {