Save palette MVP, fixes #746 (#755)

This commit is contained in:
Lea Verou
2025-02-18 16:11:40 -05:00
committed by GitHub
parent 54d71d2319
commit 4921d1c32e
14 changed files with 730 additions and 182 deletions

View File

@@ -10,6 +10,7 @@
<script type="module" src="/assets/scripts/turbo.js"></script>
<script type="module" src="/assets/scripts/search.js"></script>
<script type="module" src="/assets/scripts/outline.js"></script>
{% if hasSidebar %}<script type="module" src="/assets/scripts/sidebar-tweaks.js"></script>{% endif %}
<script defer data-domain="backers.webawesome.com" src="https://plausible.io/js/script.js"></script>
{# Docs styles #}

View File

@@ -19,12 +19,24 @@
{% for tint_fg in tints | reverse -%}
{% set color_fg = palettes[paletteId][hue][tint_fg] %}
{% if (tint_fg - tint_bg) | abs == difference %}
<td data-tint-bg="{{ tint_bg }}" data-tint-fg="{{ tint_fg }}">
<div class="color swatch" style="--color: var(--wa-color-{{ hue }}-{{ tint_bg }}); color: var(--wa-color-{{ hue }}-{{ tint_fg }})">
{% set contrast_wcag = '' %}
{% if color_fg and color_bg %}
{% set contrast_wcag = color_bg.contrast(color_fg, 'WCAG21') %}
{% endif %}
{% set contrast_wcag = '' %}
{% if color_fg and color_bg -%}
{% set contrast_wcag = color_bg.contrast(color_fg, 'WCAG21') %}
{%- endif %}
<td v-for="contrast of [contrasts.{{ hue }}['{{ tint_bg }}']['{{ tint_fg }}']]"
data-tint-bg="{{ tint_bg }}" data-tint-fg="{{ tint_fg }}" data-original-contrast="{{ contrast_wcag }}">
<div v-content:number="contrast.value"
class="color swatch" :class="{
'value-up': contrast.value - contrast.original > 0.0001,
'value-down': contrast.original - contrast.value > 0.0001,
'contrast-fail': contrast.value < {{ minContrast }}
}"
style="--color: var(--wa-color-{{ hue }}-{{ tint_bg }}); color: var(--wa-color-{{ hue }}-{{ tint_fg }})"
:style="{
'--color': contrast.bgColor,
color: contrast.fgColor,
}"
>
{% if contrast_wcag %}
{{ contrast_wcag | number({maximumSignificantDigits: 2}) }}
{% else %}

View File

@@ -1,22 +1,72 @@
{% set hasSidebar = true %}
{% set hasOutline = true %}
{% set paletteId = page.fileSlug %}
{% set tints = ["95", "90", "80", "70", "60", "50", "40", "30", "20", "10", "05"] %}
{% extends '../_layouts/block.njk' %}
{% extends '../_includes/base.njk' %}
{% block head %}
<style>@import url('/dist/styles/color/{{ paletteId }}.css') layer(palette.{{ paletteId }});</style>
<link href="{{ page.url }}../tweak.css" rel="stylesheet">
<script type="module" src="{{ page.url }}../tweak.js"></script>
{% endblock %}
{% block header %}
<div id="palette-app" data-palette-id="{{ paletteId }}">
<div
:class="{
tweaking: tweaking.chroma,
'tweaking-chroma': tweaking.chroma,
'tweaking-hue': tweaking.chroma,
'tweaked-chroma': tweaked.chroma,
'tweaked-hue': tweaked.hue,
'tweaked-any': tweaked.chroma || tweaked.hue
}"
:style="{ '--chroma-scale': chromaScale }">
{% include 'breadcrumbs.njk' %}
<h1 v-if="saved" class="title">
{% raw %}{{ saved.title }}{% endraw %}
<wa-icon-button name="pencil" label="Rename palette" @click="rename"></wa-icon-button>
<wa-icon-button class="delete" name="trash" label="Delete palette" @click="deleteSaved"></wa-icon-button>
</h1>
<h1 v-if="!saved" class="title">{{ title }}</h1>
<div class="block-info">
<code class="class">.wa-palette-{{ paletteId }}</code>
{% include '../_includes/status.njk' %}
</div>
{% if description %}
<p class="summary">
{{ description | inlineMarkdown | safe }}
</p>
{% endif %}
{% endblock %}
{% block afterContent %}
{% set tints = ["95", "90", "80", "70", "60", "50", "40", "30", "20", "10", "05"] %}
{% set maxChroma = 0 %}
<div id="palette-app">
<div
:class="{ tweaking: tweaking.chroma, 'tweaking-chroma': tweaking.chroma, 'tweaking-hue': tweaking.chroma }"
:style="{ '--chroma-scale': chromaScale }">
<wa-callout size="small" class="tweaked-callout" variant="brand">
<wa-icon name="sliders-simple" slot="icon" variant="regular"></wa-icon>
This palette has been tweaked.
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="removeTweak(param)">{% raw %}{{ tweakHumanReadable }}{% endraw %}</wa-tag>
<wa-button @click="reset" appearance="outlined">
<span slot="prefix" class="icon-modifier">
<wa-icon name="circle-xmark" variant="regular"></wa-icon>
</span>
Reset
</wa-button>
<wa-button v-if="!saved" @click="save">
<span slot="prefix" class="icon-modifier">
<wa-icon name="sidebar" variant="regular"></wa-icon>
<wa-icon name="circle-plus" class="modifier" style="color: light-dark(var(--wa-color-green-70), var(--wa-color-green-60));"></wa-icon>
</span>
Save
</wa-button>
</wa-callout>
<table class="colors main wa-palette-{{ paletteId }}">
<thead>
@@ -107,18 +157,6 @@ style="--min: {{ chromaScaleBounds[0] }}; --max: {{ chromaScaleBounds[1] }};">
<div class="label-max">More vibrant</div>
</div>
</div>
</div>
<script>
globalThis.wa_data = {
paletteId: '{{ paletteId }}',
colors: {{ palettes[paletteId] | dump | safe }},
maxChroma: {{ maxChroma }},
}
</script>
<script type="module" src="{{ page.url }}../tweak.js"></script>
<h2>Used By</h2>
<section class="index-grid">
@@ -201,7 +239,28 @@ If you are using a Web Awesome theme that uses this palette, it will already be
To use a different palette than a theme default, or to use it in a custom theme, you can import this palette directly from the Web Awesome CDN.
{% set stylesheet = 'styles/color/' + page.fileSlug + '.css' %}
{% include 'import-stylesheet-code.md.njk' %}
<wa-tab-group class="import-stylesheet-code">
<wa-tab panel="html">In HTML</wa-tab>
<wa-tab panel="css">In CSS</wa-tab>
<wa-tab-panel name="html">
Add the following code to the `<head>` of your page:
```html { v-content:html="code.html.highlighted" }
<link rel="stylesheet" href="{% cdnUrl stylesheet %}" />
```
</wa-tab-panel>
<wa-tab-panel name="css">
Add the following code at the top of your CSS file:
```css { v-content:html="code.css.highlighted" }
@import url('{% cdnUrl stylesheet %}');
```
</wa-tab-panel>
</wa-tab-group>
{% endmarkdown %}
</div></div> {# end palette app #}
{% endblock %}

View File

@@ -0,0 +1,206 @@
const sidebar = (globalThis.sidebar = {});
sidebar.palettes = {
render() {
if (this.saved.length === 0) {
return;
}
for (let palette of this.saved) {
sidebar.palette.render(palette);
}
sidebar.updateCurrent();
},
saved: localStorage.savedPalettes ? JSON.parse(localStorage.savedPalettes) : [],
save(saved = this.saved) {
this.saved = saved ?? [];
if (saved.length > 0) {
localStorage.savedPalettes = JSON.stringify(saved);
} else {
delete localStorage.savedPalettes;
}
},
};
sidebar.palette = {
getUid() {
let savedPalettes = sidebar.palettes.saved;
let uids = new Set(savedPalettes.map(p => p.uid));
if (savedPalettes.length === 0) {
return 1;
}
// Find first available number
for (let i = 1; i < savedPalettes.length + 1; i++) {
if (!uids.has(i)) {
return i;
}
}
},
equals(p1, p2) {
if (!p1 || !p2) {
return false;
}
return p1.id === p2.id && p1.uid === p2.uid;
},
delete(palette) {
let savedPalettes = sidebar.palettes.saved;
let count = savedPalettes.length;
if (count === 0) {
return;
}
// TODO improve UX of this
if (!confirm(`Are you sure you want to delete palette “${palette.title}”?`)) {
return;
}
savedPalettes = savedPalettes.filter(p => !sidebar.palette.equals(palette, p));
if (savedPalettes.length === count) {
// Nothing was removed
return;
}
// Update UI
let pathname = `/docs/palettes/${palette.id}/`;
let url = pathname + palette.search;
let uls = new Set();
for (let a of document.querySelectorAll(`#sidebar a[href="${url}"]`)) {
let li = a.closest('li');
let ul = li.closest('ul');
uls.add(ul);
li.remove();
}
// Remove empty lists
for (let ul of uls) {
if (!ul.children.length) {
ul.remove();
}
}
sidebar.updateCurrent();
sidebar.palettes.save(savedPalettes);
if (sidebar.palette.equals(globalThis.paletteApp?.saved, palette)) {
paletteApp.saved = null;
}
},
getSaved(palette, savedPalettes = sidebar.palettes.saved) {
return savedPalettes.find(p => sidebar.palette.equals(p, palette));
},
render(palette) {
// Find existing <a>
let { title, id, search, uid } = palette;
for (let a of document.querySelectorAll(`#sidebar a[href^="/docs/palettes/${id}/"][data-uid="${uid}"]`)) {
// Palette already in sidebar, just update it
a.textContent = palette.title;
a.href = `/docs/palettes/${id}/${search}`;
return;
}
let pathname = `/docs/palettes/${id}/`;
let url = pathname + search;
let parentA = document.querySelector(`a[href="${pathname}"]`);
let parentLi = parentA?.closest('li');
let a;
if (parentLi) {
a = Object.assign(document.createElement('a'), { href: url, textContent: title });
a.dataset.uid = uid;
let badges = [...parentLi.querySelectorAll('wa-badge')].map(badge => badge.cloneNode(true));
let ul = parentLi.querySelector('ul') ?? parentLi.appendChild(document.createElement('ul'));
let li = document.createElement('li');
let deleteButton = Object.assign(document.createElement('wa-icon-button'), {
name: 'trash',
label: 'Delete',
className: 'delete',
});
deleteButton.addEventListener('click', () => {
let palette = { id, uid, title: a.textContent, search: a.search };
sidebar.palette.delete(palette);
});
li.append(a, ' ', ...badges, deleteButton);
ul.appendChild(li);
}
},
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);
}
this.render(palette, oldValues);
sidebar.updateCurrent();
sidebar.palettes.save(savedPalettes);
},
};
sidebar.updateCurrent = function () {
// Find the sidebar link with the longest shared prefix with the current URL
let pathParts = location.pathname.split('/').filter(Boolean);
let prefixes = [];
if (pathParts.length === 1) {
// If at /docs/ we just use that, otherwise we want at least two parts (/docs/xxx/)
prefixes.push('/' + pathParts[0] + '/');
} else {
for (let i = 2; i <= pathParts.length; i++) {
prefixes.push('/' + pathParts.slice(0, i).join('/') + '/');
}
}
// Last prefix includes the search too (if any)
if (location.search) {
let params = new URLSearchParams(location.search);
params.sort();
prefixes.push(prefixes.at(-1) + location.search);
}
// We want to start from the longest prefix
prefixes.reverse();
for (let prefix of prefixes) {
let a = document.querySelector(`#sidebar a[href^="${prefix}"]`);
if (a) {
for (let current of document.querySelectorAll('#sidebar a.current')) {
current.classList.remove('current');
}
a.classList.add('current');
break;
}
}
};
sidebar.render = function () {
this.palettes.render();
};
sidebar.render();
window.addEventListener('turbo:render', () => sidebar.render());

View File

@@ -1,6 +1,6 @@
/**
* Get import code for remixed themes and tweaked palettes.
*/
export { palette as getPaletteCode, theme as getThemeCode } from './tweak/code.js';
export { theme as getThemeCode } from './tweak/code.js';
export { cdnUrl, hueRanges, hues, selectors, tints, urls } from './tweak/data.js';
export { default as Permalink } from './tweak/permalink.js';

View File

@@ -1,7 +1,7 @@
/**
* Get import code for remixed themes and tweaked palettes.
*/
import { selectors, urls } from './data.js';
import { urls } from './data.js';
export function cssImport(url, options = {}) {
let { language = 'html', cdnUrl = '/dist/', attributes } = options;
@@ -41,66 +41,6 @@ export function theme(base, params, options) {
return ret.map(url => cssImport(url, options)).join('\n');
}
export function palette(paletteId, tweaks, options) {
let imports = [];
if (paletteId) {
imports.push(urls.palette(paletteId));
}
let css = '';
if (tweaks) {
let { hueShifts, chromaScale = 1 } = tweaks;
let declarations = [];
if (chromaScale !== 1) {
declarations.push(`--wa-chroma-scale: ${chromaScale};`);
}
if (hueShifts || chromaScale !== 1) {
let element = document.querySelector(`.wa-palette-${paletteId}`) ?? document.documentElement;
let cs = getComputedStyle(element);
for (let hue in hueShifts) {
let shift = hueShifts[hue];
if ((!shift && chromaScale === 1) || hue === 'orange') {
continue;
}
let shiftCode = shift > 0 ? `+ ${shift}` : `- ${-shift}`;
let c = chromaScale === 1 ? 'c' : `calc(c * var(--wa-chroma-scale))`;
let h = shift ? `calc(h ${shiftCode})` : 'h';
declarations.push(`--wa-color-${hue}-tweak: l ${c} ${h});`);
for (let suffix of ['', '-05', '-10', '-20', '-30', '-40', '-50', '-60', '-70', '-80', '-90', '-95']) {
let baseColor = cs.getPropertyValue(`--wa-color-${hue}${suffix}`);
// Work around https://bugs.webkit.org/show_bug.cgi?id=287637
let colorSpace = ['-95', '-90'].includes(suffix) ? ' lch' : 'oklch';
let value = `${colorSpace}(from ${baseColor.padEnd(7)} var(--wa-color-${hue}-tweak))`;
suffix = (suffix + ':').padEnd(4);
declarations.push(`--wa-color-${hue}${suffix} ${value};`);
}
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;
}
export function cssRule(selector, declarations, { indent = ' ' } = {}) {
selector = Array.isArray(selector) ? selector.flat().join(',\n') : selector;
declarations = Array.isArray(declarations) ? declarations.flat() : declarations;

View File

@@ -61,6 +61,8 @@ export default class Permalink extends URLSearchParams {
this.changed = true;
}
}
this.sort();
}
/**

View File

@@ -156,7 +156,7 @@ wa-page > header {
}
/* Pro badges */
wa-badge.pro::part(base) {
wa-badge.pro {
background-color: var(--wa-brand-orange);
border-color: var(--wa-brand-orange);
}
@@ -188,6 +188,29 @@ wa-badge.pro::part(base) {
}
}
}
wa-icon-button.delete {
vertical-align: -0.2em;
margin-inline-start: var(--wa-space-xs);
&:not(li:hover > *, :focus) {
opacity: 0;
}
}
}
wa-icon-button.delete {
&:hover {
color: var(--wa-color-danger-on-quiet);
}
&::part(base):hover {
background: var(--wa-color-danger-fill-quiet);
}
&:not(:hover, :focus) {
opacity: 0.5;
}
}
#sidebar-close-button {
@@ -232,16 +255,32 @@ wa-page > main {
margin-inline: auto;
}
h1.title wa-badge {
vertical-align: middle;
h1.title {
wa-icon-button {
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
&::part(base) {
&:not(:hover, :focus) {
opacity: 0.5;
}
}
wa-badge {
vertical-align: middle;
font-size: 1.5rem;
}
}
.block-info {
display: flex;
gap: var(--wa-space-xs);
flex-wrap: wrap;
align-items: center;
margin-block-end: var(--wa-flow-spacing);
code {
line-height: var(--wa-line-height-condensed);
}
}
/* Current link */
@@ -500,6 +539,27 @@ table.colors {
--icon-color: var(--wa-color-success-fill-quiet);
}
.icon-modifier {
position: relative;
display: inline-flex;
.modifier {
position: absolute;
bottom: -0.1em;
right: -0.3em;
font-size: 60%;
&::part(svg) {
stroke: var(--background-color, var(--wa-color-surface-default));
stroke-width: 100px;
paint-order: stroke;
overflow: visible;
stroke-linecap: round;
stroke-linejoin: round;
}
}
}
/* Layout Examples */
.layout-example-boundary {
border: var(--wa-border-width-s) dashed var(--wa-color-neutral-border-normal);

View File

@@ -0,0 +1,27 @@
---
layout: null
permalink: "/docs/palettes/data.json"
eleventyExcludeFromCollections: true
---
{
{% for palette in collections.palettes %}
{% set paletteId = palette.fileSlug -%}
{% set colors = palettes[paletteId] -%}
"{{ paletteId }}": {
"title": "{{ palette.data.title }}",
"colors": {
{% for hue, tints in colors -%}
"{{ hue }}": {
{% for tint, value in tints -%}
{% if tint != "05" -%}
{% set value = value.coords or value -%}
{% set key = "05" if tint == "5" else tint -%}
"{{ key }}": {{ value | dump | safe }}{{ ', ' if not loop.last }}
{%- endif %}
{% endfor -%}
}{{ ', ' if not loop.last }}
{% endfor %} {# end of hue #}
}
}{{ ', ' if not loop.last }} {# end of palette #}
{% endfor %}
}

View File

@@ -1,4 +1,5 @@
---
title: Default
description: This is the palette used in the default theme.
order: 0
---

View File

@@ -1,6 +1,7 @@
{
"layout": "palette.njk",
"tags": ["palettes", "palette"],
"wide": true,
"eleventyComputed": {
"snippet": ".wa-palette-{{ page.fileSlug }}",
"icon": "palette",

View File

@@ -138,3 +138,27 @@ wa-dropdown > .color.swatch {
--tweak-icon-opacity: 80%;
}
}
.tweaked-callout {
padding: var(--wa-space-xs);
padding-inline-start: var(--wa-space-m);
align-items: center;
&:not(.tweaked-any *) {
visibility: hidden;
}
&::part(message) {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
}
wa-button:first-of-type {
margin-inline-start: auto;
}
}
[v-if='saved'] {
display: none;
}

View File

@@ -1,8 +1,9 @@
// TODO move these to local imports
import Color from 'https://colorjs.io/dist/color.js';
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { cdnUrl, getPaletteCode, hueRanges, hues, Permalink, tints } from '../../assets/scripts/tweak.js';
import { createApp, nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { cdnUrl, hueRanges, hues, Permalink, tints } from '../../assets/scripts/tweak.js';
import { cssImport, cssLiteral, cssRule } from '../../assets/scripts/tweak/code.js';
import { selectors, urls } from '../../assets/scripts/tweak/data.js';
import Prism from '/assets/scripts/prism.js';
await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag)));
@@ -17,26 +18,39 @@ await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag)));
// return computedColor.endsWith(' 0)');
// })();
let paletteAppSpec = {
data() {
const { paletteId, colors, maxChroma } = wa_data;
let allPalettes = await fetch('/docs/palettes/data.json').then(r => r.json());
globalThis.allPalettes = allPalettes;
// Replace colors with their oklch coords (since they're all opaque and all in the same color space)
for (let hue in colors) {
for (let tint of tints) {
colors[hue][tint] = colors[hue][tint].coords;
for (let palette in allPalettes) {
for (let hue in allPalettes[palette].colors) {
let scale = allPalettes[palette].colors[hue];
for (let tint of tints) {
let color = scale[tint];
if (Array.isArray(color)) {
scale[tint] = new Color('oklch', color);
}
}
}
}
let paletteAppSpec = {
data() {
let appRoot = document.querySelector('#palette-app');
let paletteId = appRoot.dataset.paletteId;
let palette = allPalettes[paletteId];
return {
uid: undefined,
paletteId,
paletteTitle: palette.title,
originalColors: palette.colors,
permalink: new Permalink(),
hueRanges,
hueShifts: Object.fromEntries(hues.map(hue => [hue, 0])),
paletteId,
originalColors: colors,
maxChroma,
chromaScale: 1,
tweaking: {},
saved: null,
};
},
@@ -54,19 +68,23 @@ let paletteAppSpec = {
this.permalink.writeTo(this.hueShifts);
if (this.permalink.has('chroma-scale')) {
this.chromaScale = Number(this.permalink.get('chromaScale') || 1);
this.chromaScale = Number(this.permalink.get('chroma-scale') || 1);
}
}
},
mounted() {
if (this.isTweaked) {
// Update contrast colors
updateContrastTables(this.colors);
if (this.permalink.has('uid')) {
this.uid = Number(this.permalink.get('uid'));
}
let palette = { id: this.paletteId, uid: this.uid, search: location.search };
this.saved = sidebar.palette.getSaved(palette);
}
},
computed: {
global() {
return globalThis;
},
tweaks() {
return { hueShifts: this.hueShifts, chromaScale: this.chromaScale };
},
@@ -75,29 +93,123 @@ let paletteAppSpec = {
return Object.values(this.hueShifts).some(Boolean);
},
paletteHTML() {
return getPaletteCode(this.paletteId, this.tweaks, { language: 'html', cdnUrl });
},
code() {
let ret = {};
for (let language of ['html', 'css']) {
let code = getPaletteCode(this.paletteId, this.tweaks, { language, cdnUrl });
ret[language] = {
raw: code,
highlighted: Prism.highlight(code, Prism.languages[language], language),
};
}
paletteCSS() {
return getPaletteCode(this.paletteId, this.tweaks, { language: 'css', cdnUrl });
return ret;
},
colors() {
let ret = {};
for (let hue in this.originalColors) {
ret[hue] = {};
let originalScale = this.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) {
ret[hue][tint] = this.originalColors[hue][tint].slice();
let oklch = originalScale[tint].coords.slice();
if (this.hueShifts[hue]) {
ret[hue][tint][2] += this.hueShifts[hue];
oklch[2] += this.hueShifts[hue];
}
if (this.chromaScale !== 1) {
ret[hue][tint][1] *= this.chromaScale;
oklch[1] *= this.chromaScale;
}
scale[tint] = new Color('oklch', oklch);
}
}
return ret;
},
tweaked() {
return {
chroma: this.chromaScale !== 1,
hue: Object.values(this.hueShifts).some(Boolean),
};
},
tweaksHumanReadable() {
let ret = {};
if (this.chromaScale !== 1) {
ret.chromaScale = 'more ' + (this.chromaScale > 1 ? 'vibrant' : 'muted');
}
for (let hue in this.hueShifts) {
let shift = this.hueShifts[hue];
if (!shift) {
continue;
}
let relHue = shift < 0 ? arrayPrevious(hues, hue) : arrayNext(hues, hue);
let hueTweak =
{
red: 'redder',
orange: 'oranger',
indigo: 'more indigo',
}[relHue] ?? relHue + 'er';
ret[hue] = hueTweak + ' ' + hue + 's';
}
return ret;
},
originalContrasts() {
let ret = {};
for (let hue in this.originalColors) {
ret[hue] = {};
for (let tintBg of tints) {
ret[hue][tintBg] = {};
let bgColor = this.originalColors[hue][tintBg];
if (!bgColor || !bgColor.contrast) {
continue;
}
for (let tintFg of tints) {
let contrast = bgColor.contrast(this.originalColors[hue][tintFg], 'WCAG21');
ret[hue][tintBg][tintFg] = contrast;
}
}
}
return ret;
},
contrasts() {
let ret = {};
for (let hue in this.colors) {
ret[hue] = {};
for (let tintBg in this.colors[hue]) {
ret[hue][tintBg] = {};
let bgColor = this.colors[hue][tintBg];
for (let tintFg in this.colors[hue]) {
let fgColor = this.colors[hue][tintFg];
let value = bgColor.contrast(fgColor, 'WCAG21');
let original = this.originalContrasts[hue][tintBg][tintFg];
ret[hue][tintBg][tintFg] = { value, original, bgColor, fgColor };
}
}
}
@@ -107,44 +219,114 @@ let paletteAppSpec = {
},
watch: {
// Note: These could move to `v-html` directives if we widen the app root
paletteHTML() {
let codeElement = document.querySelector('#usage ~ wa-tab-group.import-stylesheet-code code.language-html');
codeElement.textContent = this.paletteHTML;
let copyButton = codeElement.previousElementSibling;
copyButton.value = this.paletteHTML;
Prism.highlightElement(codeElement);
},
paletteCSS() {
let codeElement = document.querySelector('#usage ~ wa-tab-group.import-stylesheet-code code.language-css');
codeElement.textContent = this.paletteCSS;
let copyButton = codeElement.previousElementSibling;
copyButton.value = this.paletteCSS;
Prism.highlightElement(codeElement);
},
hueShifts: {
deep: true,
handler() {
this.permalink.readFrom(this.hueShifts);
// Update page URL
this.permalink.updateLocation();
// Update contrast colors
updateContrastTables(this.colors);
},
},
chromaScale() {
this.permalink.set('chroma-scale', this.chromaScale, 1);
},
// Update page URL
this.permalink.updateLocation();
tweaks: {
deep: true,
async handler(value, oldValue) {
await nextTick(); // must run after individual watchers
// Update contrast colors
updateContrastTables(this.colors);
// Update page URL
this.permalink.updateLocation();
if (this.saved) {
this.save({ silent: true });
}
},
},
},
methods: {
save({ silent } = {}) {
let title = silent
? (this.saved?.title ?? this.paletteTitle)
: prompt('Palette title:', `${this.paletteTitle} (tweaked)`);
if (!title) {
return;
}
let uid = this.uid;
if (!uid) {
this.uid = uid = sidebar.palette.getUid();
this.permalink.set('uid', uid);
this.permalink.updateLocation();
}
let palette = { title, id: this.paletteId, uid, search: location.search };
sidebar.palette.save(palette, this.saved);
this.saved = palette;
},
rename() {
if (!this.saved) {
return;
}
let newTitle = prompt('New title:', this.saved.title);
if (!newTitle) {
return;
}
this.saved.title = newTitle;
sidebar.palette.save(this.saved);
},
deleteSaved() {
sidebar.palette.delete(this.saved);
this.saved = null;
},
reset() {
for (let hue in this.hueShifts) {
this.hueShifts[hue] = 0;
}
this.chromaScale = 1;
},
removeTweak(param) {
if (param === 'chromaScale') {
this.chromaScale = 1;
} else {
this.hueShifts[param] = 0;
}
},
},
directives: {
// Like v-text, but doesn't complain if the element has content,
// making it possible to use in a PE fashion, with the contents being the fallback
content(el, { value, arg }) {
if (!el.dataset.fallback) {
// Store the original content as a fallback the first time
el.dataset.fallback = el.textContent;
}
if (value === '') {
value = el.dataset.fallback;
} else {
if (arg === 'number') {
value = Number(value).toLocaleString(undefined, { maximumSignificantDigits: 2 });
}
}
if (arg === 'html') {
el.innerHTML = value;
} else {
el.textContent = value;
}
},
},
@@ -154,41 +336,76 @@ let paletteAppSpec = {
};
function init() {
globalThis.paletteApp?.unmount?.();
globalThis.paletteApp = createApp(paletteAppSpec).mount('#palette-app');
}
function updateContrastTables(colors) {
for (let table of document.querySelectorAll('.contrast-table')) {
let { minContrast } = table.dataset;
for (let tr of table.querySelectorAll('tr[data-hue]')) {
let { hue } = tr.dataset;
for (let td of tr.querySelectorAll('td[data-tint-bg][data-tint-fg]')) {
let swatch = td.querySelector('.color.swatch');
let { tintBg, tintFg, originalContrast } = td.dataset;
let bg = new Color('oklch', colors[hue][tintBg]);
let fg = new Color('oklch', colors[hue][tintFg]);
if (!originalContrast) {
td.dataset.originalContrast = originalContrast = swatch.textContent.trim();
}
let contrast = bg.contrast(fg, 'WCAG21').toLocaleString(undefined, { maximumSignificantDigits: 2 });
swatch.textContent = contrast;
swatch.classList.toggle('value-up', contrast > originalContrast);
swatch.classList.toggle('value-down', contrast < originalContrast);
swatch.classList.toggle('contrast-fail', contrast < minContrast);
swatch.style.setProperty('--color', bg.display());
swatch.style.setProperty('color', fg.display());
}
}
}
}
init();
addEventListener('turbo:render', init);
export function getPaletteCode(paletteId, tweaks, options) {
let palette = allPalettes[paletteId].colors;
let imports = [];
if (paletteId) {
imports.push(urls.palette(paletteId));
}
let css = '';
if (tweaks) {
let { hueShifts, chromaScale = 1 } = tweaks;
let declarations = [];
if (hueShifts || chromaScale !== 1) {
for (let hue in hueShifts) {
let shift = hueShifts[hue];
if ((!shift && chromaScale === 1) || hue === 'orange') {
continue;
}
let scale = palette[hue];
for (let tint of ['05', '10', '20', '30', '40', '50', '60', '70', '80', '90', '95']) {
let color = scale[tint];
if (Array.isArray(color)) {
color = new Color('oklch', coords);
} else {
color = color.clone();
}
color.set({ h: h => h + shift, c: c => c * chromaScale });
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];
}

View File

@@ -139,10 +139,8 @@ function render(changedAspect) {
// Update code snippets
for (let language in codeSnippets) {
let codeSnippet = codeSnippets[language];
let copyButton = codeSnippet.previousElementSibling;
let code = getThemeCode(data.baseTheme, data.params, { language, cdnUrl });
codeSnippet.textContent = code;
copyButton.value = code;
Prism.highlightElement(codeSnippet);
}
}