mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 12:09:26 +00:00
Port changes
This commit is contained in:
@@ -1 +1 @@
|
||||
export { hueRanges as default } from '../assets/scripts/tweak/data.js';
|
||||
export { HUE_RANGES as default } from '../assets/scripts/tweak/data.js';
|
||||
|
||||
36
docs/_includes/palette.njk
Normal file
36
docs/_includes/palette.njk
Normal file
@@ -0,0 +1,36 @@
|
||||
<table class="colors main wa-palette-{{ paletteId }} static-palette">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="core-column">Core tint</th>
|
||||
{% for tint in tints -%}
|
||||
<th>{{ tint }}</th>
|
||||
{%- endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- 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 -%}
|
||||
<tr data-hue="{{ hue }}" class="color-scale" style="--swatch-text-color: light-dark(var(--wa-color-{{ hue }}-10), white)">
|
||||
<th>{{ hue | capitalize }}</th>
|
||||
<td class="core-column" style="--color: var(--wa-color-{{ hue }})">
|
||||
<div class="color swatch" style="color-scheme: {{ 'light' if scale.maxChromaTint > 60 else 'dark' }};">
|
||||
{{ scale.maxChromaTint }}
|
||||
</div>
|
||||
</td>
|
||||
{% for tint in tints -%}
|
||||
{%- set color = scale[tint] -%}
|
||||
<td style="--color: var(--wa-color-{{ hue }}-{{ tint }}); color-scheme: ">
|
||||
<div class="color swatch" style="color-scheme: {{ 'light' if tint > 60 else 'dark' }};">
|
||||
<wa-copy-button value="--wa-color-{{ hue }}-{{ tint }}" copy-label="--wa-color-{{ hue }}-{{ tint }}"></wa-copy-button>
|
||||
</div>
|
||||
</td>
|
||||
{%- endfor -%}
|
||||
</tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -12,33 +12,41 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div id="palette-app" data-palette-id="{{ paletteId }}">
|
||||
<div id="palette-app" data-slug="{{ page.fileSlug }}" data-palette-id="{{ page.fileSlug }}">
|
||||
<div
|
||||
:class="{
|
||||
tweaking: tweaking.chroma,
|
||||
'tweaking-chroma': tweaking.chroma,
|
||||
'tweaking-hue': tweaking.chroma,
|
||||
'tweaking-gray-chroma': tweaking.grayChroma,
|
||||
'tweaked-chroma': tweaked?.chroma,
|
||||
'tweaked-hue': tweaked?.hue,
|
||||
'tweaked-any': tweaked
|
||||
'tweaked-any': Object.keys(tweaksHumanReadable).length,
|
||||
}"
|
||||
:style="{
|
||||
'--chroma-scale': chromaScale,
|
||||
'--gray-chroma': tweaked?.grayChroma ? grayChroma : '',
|
||||
'--gray-chroma': tweaked?.grayChroma ? grayChroma : originalGrayChroma,
|
||||
'--max-c': maxChroma,
|
||||
'--avg-l': L_RANGES[level].mid,
|
||||
}">
|
||||
|
||||
<header id="palette-info">
|
||||
{% 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 class="title">
|
||||
<span v-content="saved?.title || (step > 0 ? defaultPaletteTitle : paletteTitle)">{{ title }}</span>
|
||||
<template v-if="saved || step > 0">
|
||||
<wa-icon-button name="pencil" label="Rename palette" @click="rename"></wa-icon-button>
|
||||
<wa-icon-button v-if="saved" class="delete" name="trash" label="Delete palette" @click="deleteSaved"></wa-icon-button>
|
||||
<wa-button @click="save()" :disabled="!unsavedChanges"
|
||||
:variant="unsavedChanges ? 'success' : 'neutral'" size="small" :appearance="unsavedChanges ? 'accent' : 'outlined'">
|
||||
<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>
|
||||
<span v-content="unsavedChanges ? 'Save' : 'Saved'">Save</span>
|
||||
</wa-button>
|
||||
</template>
|
||||
</h1>
|
||||
<h1 v-if="!saved" class="title">{{ title }}</h1>
|
||||
|
||||
<div class="block-info">
|
||||
<code class="class">.wa-palette-{{ paletteId }}</code>
|
||||
<div class="block-info" v-cloak>
|
||||
<code class="class">.wa-palette-<span v-content="slug">{{ page.fileSlug }}</span></code>
|
||||
{% include '../_includes/status.njk' %}
|
||||
{% if not isPro %}
|
||||
<wa-badge class="pro" v-if="tweaked">PRO</wa-badge>
|
||||
@@ -49,17 +57,28 @@
|
||||
{{ description | inlineMarkdown | safe }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<div class="hue-wheel">
|
||||
<template v-for="color, hue in coreColors">
|
||||
<div :id="`hue-wheel-${hue}`" class="color"
|
||||
:style="{
|
||||
'--color': color,
|
||||
'--h': color.get('oklch.h'),
|
||||
'--c': color.get('oklch.c'),
|
||||
'--l': color.get('oklch.l'),
|
||||
}"></div>
|
||||
<wa-tooltip :for="`hue-wheel-${ hue }`" hoist v-content="capitalize(hue) + ' ' + coreLevels[hue]"></wa-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block afterContent %}
|
||||
|
||||
{% set maxChroma = 0 %}
|
||||
|
||||
<wa-callout size="small" class="tweaked-callout" variant="warning">
|
||||
<wa-icon name="sliders-simple" slot="icon" variant="regular"></wa-icon>
|
||||
This palette has been tweaked.
|
||||
<div class="wa-cluster wa-gap-xs">
|
||||
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="reset(param)">{% raw %}{{ tweakHumanReadable }}{% endraw %}</wa-tag>
|
||||
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="reset(param)" v-content="tweakHumanReadable"></wa-tag>
|
||||
</div>
|
||||
|
||||
<wa-button @click="reset()" appearance="outlined" variant="danger">
|
||||
@@ -68,15 +87,12 @@
|
||||
</span>
|
||||
Reset
|
||||
</wa-button>
|
||||
<wa-button v-if="!saved" @click="save" variant="success">
|
||||
<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>
|
||||
|
||||
<h2>Scales</h2>
|
||||
|
||||
{% include "palette.njk" %}
|
||||
|
||||
<table class="colors main wa-palette-{{ paletteId }}">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -87,127 +103,68 @@
|
||||
{%- endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
{# 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' %}
|
||||
<tr data-hue="{{ hue }}" class="color-scale"
|
||||
:class="{tweaking: tweaking.grayChroma, tweaked: tweaked.grayChroma || tweaked.grayColor }">
|
||||
{% else %}
|
||||
<tr data-hue="{{ hue }}" class="color-scale"
|
||||
:class="{tweaking: tweaking.{{ hue }}, tweaked: hueShifts.{{ hue }} }"
|
||||
:style="{ '--hue-shift': hueShifts.{{ hue }} || '' }">
|
||||
{% endif %}
|
||||
<th>
|
||||
{{ hue | capitalize }}
|
||||
</th>
|
||||
<td class="core-column"
|
||||
style="--color: var(--wa-color-{{ hue }})"
|
||||
:style="{
|
||||
'--color-tweaked': colors.{{ hue }}[{{ coreTint }}],
|
||||
'--color-gray-undertone': colors[grayColor][{{coreTint}}],
|
||||
'--color-tweaked-no-gray-chroma': colorsMinusGrayChroma.{{ hue }}[{{ coreTint }}],
|
||||
}">
|
||||
<wa-dropdown>
|
||||
<div slot="trigger" id="core-{{ hue }}-swatch" data-tint="core" class="color swatch"
|
||||
style="background-color: var(--wa-color-{{ hue }}); color: var(--wa-color-{{ hue }}-{{ '05' if palettes[paletteId][hue].maxChromaTint > 60 else '95' }});"
|
||||
>
|
||||
{{ palettes[paletteId][hue].maxChromaTint }}
|
||||
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
|
||||
</div>
|
||||
<div class="popup">
|
||||
{% if hue === 'gray' %}
|
||||
<wa-radio-group class="core-color" orientation="horizontal" v-model="grayColor">
|
||||
{% for h in hues -%}
|
||||
{%- if h !== 'gray' -%}
|
||||
<wa-radio-button id="gray-undertone-{{ h }}" value="{{ h }}" label="{{ h | capitalize }}" style="--color: var(--wa-color-{{ h }})"></wa-radio-button>
|
||||
<wa-tooltip for="gray-undertone-{{ h }}" hoist>
|
||||
{{ h | capitalize }}
|
||||
</wa-tooltip>
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<div slot="label">
|
||||
Gray undertone
|
||||
</div>
|
||||
</wa-radio-group>
|
||||
<div class="decorated-slider gray-chroma-slider" :style="{'--max': maxGrayChroma}">
|
||||
<wa-slider name="gray-chroma" v-model="grayChroma" ref="grayChromaSlider"
|
||||
value="0" min="0" :max="maxGrayChroma" step="0.01"
|
||||
@input="tweaking.grayChroma = true" @change="tweaking.grayChroma = false">
|
||||
<div slot="label">
|
||||
Gray colorfulness
|
||||
<wa-icon-button @click="grayChroma = originalGrayChroma" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
|
||||
</div>
|
||||
</wa-slider>
|
||||
<div class="label-min">Neutral</div>
|
||||
<div class="label-max" v-content="moreHue[grayColor]">Warmer/Cooler</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{%- set hueAfter = hues[loop.index0 + 1] -%}
|
||||
{%- set hueAfter = hues[0] if hueAfter == 'gray' else hueAfter -%}
|
||||
{%- set minShift = hueRanges[hue].min - coreColor.h | round -%}
|
||||
{%- set maxShift = hueRanges[hue].max - coreColor.h | round -%}
|
||||
|
||||
<div class="decorated-slider hue-shift-slider" style="--min: {{ minShift }}; --max: {{ maxShift }};">
|
||||
<wa-slider name="{{ hue }}-shift" v-model="hueShifts.{{ hue }}" value="0"
|
||||
min="{{ minShift }}" max="{{ maxShift }}" step="1"
|
||||
@input="tweaking.hue = tweaking.{{hue}} = true"
|
||||
@change="tweaking.hue = tweaking.{{ hue }} = false">
|
||||
<div slot="label">
|
||||
Tweak {{ hue }} hue
|
||||
<wa-icon-button @click="hueShifts.{{ hue }} = 0" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
|
||||
</div>
|
||||
</wa-slider>
|
||||
<div class="label-min">More {{hueBefore}}</div>
|
||||
<div class="label-max">More {{hueAfter}}</div>
|
||||
</div>
|
||||
{%- set hueBefore = hue -%}
|
||||
{% endif %}
|
||||
<div class="wa-gap-s">
|
||||
<code>--wa-color-{{ hue }}</code>
|
||||
<wa-copy-button value="--wa-color-{{ hue }}" copy-label="--wa-color-{{ hue }}"></wa-copy-button>
|
||||
</div>
|
||||
</div>`
|
||||
</wa-dropdown>
|
||||
</td>
|
||||
{% for tint in tints -%}
|
||||
{%- set color = palettes[paletteId][hue][tint] -%}
|
||||
<td data-tint="{{ tint }}" style="--color: var(--wa-color-{{ hue }}-{{ tint }})"
|
||||
<tbody v-cloak>
|
||||
<tr v-for="hue of allHues" :data-hue="hue" :key="hue"
|
||||
class="color-scale" :class="{
|
||||
tweaked: hue === 'gray' ? tweaked.grayChroma || tweaked.grayColor : hueShifts[hue],
|
||||
}"
|
||||
:style="{
|
||||
'--color-tweaked': colors.{{ hue }}[{{ tint }}],
|
||||
'--color-tweaked-no-gray-chroma': colorsMinusGrayChroma.{{ hue }}[{{ tint }}],
|
||||
'--swatch-text-color': `light-dark(var(--wa-color-${ hue }-10), white)`,
|
||||
'--hue-shift': hueShifts[hue] || ''
|
||||
}">
|
||||
<div class="color swatch" style="--color: var(--wa-color-{{ hue }}-{{ tint }})">
|
||||
<wa-copy-button value="--wa-color-{{ hue }}-{{ tint }}" copy-label="--wa-color-{{ hue }}-{{ tint }}"></wa-copy-button>
|
||||
</div>
|
||||
</td>
|
||||
{%- endfor -%}
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
<th v-content="capitalize(hue)"></th>
|
||||
<td class="core-column" :style="{'--original-color': `var(--wa-color-${ hue })`, '--color': colors[hue][coreLevels[hue]]}">
|
||||
<color-popup :title="capitalize(hue) + ' (core)'" :token="`--wa-color-${ hue }`" :color="coreColors[hue]">
|
||||
<div slot="trigger" :id="`core-${ hue }-swatch`" class="color swatch" :style="{colorScheme: coreLevels[hue] > 60 ? 'light' : 'dark'}">
|
||||
<span v-content="coreLevels[hue]"></span>
|
||||
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
|
||||
</div>
|
||||
<template #content>
|
||||
<template v-if="hue === 'gray'">
|
||||
<color-swatch-picker :model-value="computedGrayColor" @update:model-value="grayColor = $event" label="Gray undertone" :colors="coreColors"></color-swatch-picker>
|
||||
</template>
|
||||
<template v-else>
|
||||
<color-slider v-if="baseCoreColors[hue]"
|
||||
coord="h" type="shift"
|
||||
v-model="hueShifts[hue]"
|
||||
:default-color="baseCoreColors[hue]"
|
||||
:min="HUE_RANGES[hue].min + 1" :max="HUE_RANGES[hue].max"
|
||||
label="Adjust hue" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
|
||||
></color-slider>
|
||||
</template>
|
||||
|
||||
<color-slider v-if="hue === 'gray'" coord="c" type="scale"
|
||||
:model-value="computedGrayChroma"
|
||||
@update:model-value="grayChroma = $event"
|
||||
:default-color="baseCoreColors[computedGrayColor]"
|
||||
:base-value="baseCoreColors[originalGrayColor].oklch.c"
|
||||
:default-value-relative="originalGrayChroma"
|
||||
:min="0" :max-relative="maxGrayChroma" :step="0.00001"
|
||||
label="Gray colorfulness" label-min="Neutral" :label-max="moreHue[computedGrayColor]"
|
||||
></color-slider>
|
||||
</template>
|
||||
</color-popup>
|
||||
</td>
|
||||
<td v-for="tint in tints.toReversed()" :data-tint="tint" :style="{'--original-color': `var(--wa-color-${ hue }-${tint})`, '--color': colors[hue][tint] }">
|
||||
<color-popup :title="capitalize(hue) + ' ' + tint" :token="`--wa-color-${ hue }-${ tint }`" :color="colors[hue][tint]">
|
||||
<div slot="trigger" class="color swatch" :style="{ colorScheme: tint > 60 ? 'light' : 'dark' }">
|
||||
<wa-icon name="copy" variant="regular" class="copy-icon"></wa-icon>
|
||||
</div>
|
||||
</color-popup>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% set chromaScaleBounds = [
|
||||
(0.08 / maxChroma) | number({maximumFractionDigits: 2}),
|
||||
(0.3 / maxChroma]) | number({maximumFractionDigits: 2}) -%}
|
||||
<div class="decorated-slider chroma-scale-slider wa-palette-{{ paletteId }}"
|
||||
:class="{ tweaked: chromaScale !== 1 }"
|
||||
style="--min: {{ chromaScaleBounds[0] }}; --max: {{ chromaScaleBounds[1] }};">
|
||||
<wa-slider name="chroma-scale" ref="chromaScaleSlider"
|
||||
v-model="chromaScale" value="1" step="0.01"
|
||||
min="{{ chromaScaleBounds[0] }}" max="{{ chromaScaleBounds[1] }}"
|
||||
@input="tweaking.chroma = true"
|
||||
@change="tweaking.chroma = false">
|
||||
<div slot="label">
|
||||
Overall colorfulness
|
||||
<wa-icon-button @click="chromaScale = 1" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
|
||||
</div>
|
||||
</wa-slider>
|
||||
<div class="label-min">More muted</div>
|
||||
<div class="label-max">More vibrant</div>
|
||||
</div>
|
||||
<color-slider :class="{ tweaked: chromaScale !== 1 }"
|
||||
type="scale"
|
||||
v-model="chromaScale"
|
||||
coord="c"
|
||||
:default-color="baseMaxChromaColor"
|
||||
:default-value="baseMaxChroma"
|
||||
:min="MAX_CHROMA_BOUNDS.min" :max="MAX_CHROMA_BOUNDS.max" :step="0.01"
|
||||
label="Overall colorfulness" label-min="More muted" label-max="More vibrant"
|
||||
></color-slider>
|
||||
|
||||
<h2>Used By</h2>
|
||||
|
||||
@@ -310,8 +267,22 @@ Add the following code at the top of your CSS file:
|
||||
</wa-tab-panel>
|
||||
</wa-tab-group>
|
||||
|
||||
|
||||
{% endmarkdown %}
|
||||
|
||||
<section id="saved" class="index-grid" v-if="savedVariations?.length">
|
||||
<h2 class="index-category">Saved variations</h2>
|
||||
<a v-for="palette of savedVariations" :href="'/docs/palettes/' + palette.id">
|
||||
<wa-card with-header>
|
||||
<div slot="header">
|
||||
{# {% include "svgs/palette.njk" %} #}
|
||||
{% include "svgs/thumbnail-placeholder.njk" %}
|
||||
</div>
|
||||
<span class="page-name" v-text="palette.title"></span>
|
||||
</wa-card>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
|
||||
</div></div> {# end palette app #}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ wa_data.palettes = {
|
||||
{% set palette = defaultPalette %}
|
||||
</wa-select>
|
||||
|
||||
<wa-select name="brand" label="Brand color" value="" clearable>
|
||||
<wa-select class="color-select" name="brand" label="Brand color" value="" clearable>
|
||||
<div class="selected-swatch" slot="prefix"></div>
|
||||
{% for hue in hues %}
|
||||
{% set currentBrand = hue == brand %}
|
||||
|
||||
@@ -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 <a>
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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<string, {min: number, max: number}>} 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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
28
docs/docs/palettes/app/color/generate-grays.js
Normal file
28
docs/docs/palettes/app/color/generate-grays.js
Normal file
@@ -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;
|
||||
162
docs/docs/palettes/app/color/generate-palette.js
Normal file
162
docs/docs/palettes/app/color/generate-palette.js
Normal file
@@ -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;
|
||||
}
|
||||
138
docs/docs/palettes/app/color/generate-scale.js
Normal file
138
docs/docs/palettes/app/color/generate-scale.js
Normal file
@@ -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<number | string, Color>} seedColors
|
||||
* @returns {Record<number | string, Color>}
|
||||
*/
|
||||
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;
|
||||
3
docs/docs/palettes/app/color/generate.js
Normal file
3
docs/docs/palettes/app/color/generate.js
Normal file
@@ -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';
|
||||
91
docs/docs/palettes/app/color/get-max-chroma.js
Normal file
91
docs/docs/palettes/app/color/get-max-chroma.js
Normal file
@@ -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;
|
||||
}
|
||||
65
docs/docs/palettes/app/color/get-palette-code.js
Normal file
65
docs/docs/palettes/app/color/get-palette-code.js
Normal file
@@ -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;
|
||||
74
docs/docs/palettes/app/color/tweak.js
Normal file
74
docs/docs/palettes/app/color/tweak.js
Normal file
@@ -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;
|
||||
154
docs/docs/palettes/app/color/util.js
Normal file
154
docs/docs/palettes/app/color/util.js
Normal file
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
82
docs/docs/palettes/app/vue-components/color-popup.js
Normal file
82
docs/docs/palettes/app/vue-components/color-popup.js
Normal file
@@ -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: `
|
||||
<wa-dropdown class="color-popup" :placement>
|
||||
<slot></slot>
|
||||
<component :is="title ? 'fieldset' : 'div'" class="popup" ref="popup">
|
||||
<component :is="title ? 'legend' : 'div'" class="wa-heading-s" v-if="title || token || deletable || pinnable">
|
||||
<span v-if="title">{{ title }}</span>
|
||||
<wa-copy-button v-if="title && token" :value="token" :copy-label="token"></wa-copy-button>
|
||||
|
||||
<info-tip v-if="deletable && pinned">
|
||||
<wa-button size="small" variant="danger" appearance="plain" class="delete-button align-end" @click="$emit('delete')">
|
||||
<wa-icon name="trash" variant="regular"></wa-icon>
|
||||
</wa-button>
|
||||
<template #content>
|
||||
Delete from my colors
|
||||
</template>
|
||||
</info-tip>
|
||||
<info-tip v-if="pinnable && !pinned">
|
||||
<wa-button appearance="outlined" size="small" class="pin-color align-end" @click="$emit('pin')">
|
||||
<wa-icon name="thumbtack" variant="regular" slot="prefix"></wa-icon>
|
||||
Pin
|
||||
</wa-button>
|
||||
<template #content>
|
||||
Prevent this color from changing as others are edited
|
||||
</template>
|
||||
</info-tip>
|
||||
</component>
|
||||
|
||||
<slot name="content"></slot>
|
||||
|
||||
<div class="wa-stack wa-gap-xs">
|
||||
<div class="copyable-code" v-if="token && !title">
|
||||
<code>{{ token }}</code>
|
||||
<wa-copy-button :value="token"></wa-copy-button>
|
||||
</div>
|
||||
<div class="copyable-code" v-if="color">
|
||||
<code>{{ stringifiedColor }}</code>
|
||||
<wa-copy-button :value="stringifiedColor"></wa-copy-button>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</wa-dropdown>`,
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.startsWith('wa-'),
|
||||
},
|
||||
components: {
|
||||
InfoTip,
|
||||
},
|
||||
};
|
||||
73
docs/docs/palettes/app/vue-components/color-select.js
Normal file
73
docs/docs/palettes/app/vue-components/color-select.js
Normal file
@@ -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: `
|
||||
<wa-select class="color-select" name="brand" :label="label" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)"
|
||||
:style="{'--color': getColor(modelValue)}">
|
||||
<template v-for="values, group in computedGroups">
|
||||
<template v-if="group">
|
||||
<wa-divider v-if="group !== firstGroup"></wa-divider>
|
||||
<small>{{ group }}</small>
|
||||
</template>
|
||||
<wa-option v-if="values?.length" v-for="value of values" :label="getLabel(value)" :value="value" :style="{'--color': getColor(value)}" v-html="getContent?.(value) ?? getLabel(value)"></wa-option>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</wa-select>
|
||||
`,
|
||||
};
|
||||
343
docs/docs/palettes/app/vue-components/color-slider.js
Normal file
343
docs/docs/palettes/app/vue-components/color-slider.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const template = `
|
||||
<div class="color-slider" :style="{
|
||||
'--color': computedColor, '--color-1': colorMin, '--color-2': colorMax,
|
||||
'--default-value-progress': defaultProgress,
|
||||
}" :data-component="coord || null">
|
||||
<wa-slider ref="slider" :min="computedMin" :max="computedMax" :step="step" :value="value"
|
||||
@input="handleInput($event.target.value);" @change="inputEnd($event.target.value)">
|
||||
<div slot="label">
|
||||
{{ label }}
|
||||
<wa-icon-button v-if="value !== computedDefaultValue" @click="reset" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
|
||||
<info-tip>
|
||||
<div class="tick"></div>
|
||||
<template #content>{{ computedLabelDefault }}</template>
|
||||
</info-tip>
|
||||
</div>
|
||||
</wa-slider>
|
||||
<div class="label-min">{{ labelMin }}</div>
|
||||
<div class="label-max">{{ labelMax }}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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}]`,
|
||||
'<color-slider> 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;
|
||||
}
|
||||
56
docs/docs/palettes/app/vue-components/color-swatch-picker.js
Normal file
56
docs/docs/palettes/app/vue-components/color-swatch-picker.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const template = `
|
||||
<wa-radio-group class="core-color" orientation="horizontal" :value="modelValue" @input="handleInput($event.target.value)">
|
||||
<template v-for="h in hues">
|
||||
<info-tip>
|
||||
<wa-radio-button :label="capitalize(h)" :value="h" :style="{'--color': colors[h]}"></wa-radio-button>
|
||||
<template #content>{{ capitalize(h) }}</template>
|
||||
</info-tip>
|
||||
</template>
|
||||
<div slot="label">{{ label }}</div>
|
||||
</wa-radio-group>
|
||||
`;
|
||||
|
||||
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-'),
|
||||
},
|
||||
};
|
||||
37
docs/docs/palettes/app/vue-components/info-tip.js
Normal file
37
docs/docs/palettes/app/vue-components/info-tip.js
Normal file
@@ -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: `
|
||||
<slot>
|
||||
<wa-icon-button :id="id" name="circle-question" variant="regular"></wa-icon-button>
|
||||
</slot>
|
||||
<wa-tooltip :for="id" ref="tooltip"><slot name="content"></slot></wa-tooltip>
|
||||
`,
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag.startsWith('wa-'),
|
||||
},
|
||||
};
|
||||
25
docs/docs/themes/remix.css
vendored
25
docs/docs/themes/remix.css
vendored
@@ -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) {
|
||||
|
||||
20
docs/docs/themes/remix.js
vendored
20
docs/docs/themes/remix.js
vendored
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user