Compare commits

..

13 Commits

Author SHA1 Message Date
Cory LaViska
c191709eb1 improve search grid styles 2025-03-26 11:49:57 -04:00
Cory LaViska
0454f494f5 add debounce to search so it feels more natural 2025-03-26 11:34:54 -04:00
Cory LaViska
0db9ca12e3 Remove unused SSR module and remove first load fade (#835)
* disable SSR module in 11ty

* remove first load fade
2025-03-26 14:45:29 +00:00
Lea Verou
041555fe99 border-radius: 0 on plain details 2025-03-26 10:04:25 -04:00
Lea Verou
b41dbd2de7 Fix: Specify default card background 2025-03-25 16:53:16 -04:00
Lea Verou
7c6f31e0c7 [Card docs] Use style utilities instead of custom CSS 2025-03-25 16:31:40 -04:00
Lea Verou
9e84274a93 [Card] Round all corners of the image for appearance=plain 2025-03-25 16:31:40 -04:00
Lea Verou
2b3803f91e [Card] Support appearance, closes #609 2025-03-25 16:31:40 -04:00
Lea Verou
faed8da3cd Fix broken link 2025-03-25 14:14:53 -04:00
Lea Verou
17cf902f53 Add appearance to details, closes #569
Except `accent` as that's a) far less useful and b) trickier due to the icon color
2025-03-25 14:14:53 -04:00
Lea Verou
8214ff6b2d Several fixes around overviews, outlines etc (#825)
* Fix outline for headings that have links

Previously produced blank items because it assumed any link in a heading is an anchor

* Filter unlisted items from overviews

Previously they were filtered only when the card was rendered, so their heading was still shown

* [Overview] Add id to group headings

* Hide headings from empty groups

Should never happen but you never know

* [Overview] Ensure "Other" is always last even when no sorting
2025-03-25 11:39:04 -04:00
Cory LaViska
c9979e15f8 adds a hard coded delay to drastically reduce theme picker jank (#829) 2025-03-24 20:49:08 +00:00
Cory LaViska
fcfe2bde7d Add FOUCE utilities (#686)
* add fouce utilities

* add comment

* Update docs/docs/installation.md

Co-authored-by: Lea Verou <lea@verou.me>

* commit PR suggestion

* rename wa-reduce-fouce to wa-cloak

* remove class as requested

* add cloak class

* wait a cycle

* move turbo to same file

* reduce fade

* disable SSR and add Turbo FOUCE helper

* disable SSR

* fix test suite

* workflow dispatch

* update fouce util

* no need to remove cloak class

* simplify fouce util

* add allDefined util

* update changelog

---------

Co-authored-by: Lea Verou <lea@verou.me>
Co-authored-by: konnorrogers <konnor5456@gmail.com>
2025-03-24 20:33:24 +00:00
61 changed files with 1505 additions and 3344 deletions

View File

@@ -1,11 +1,12 @@
# # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SSR Tests
on:
push:
branches: [next]
# push:
# branches: [next]
workflow_dispatch:
jobs:
ssr_test:

View File

@@ -7,10 +7,10 @@ import { highlightCodePlugin } from './_utils/highlight-code.js';
import { markdown } from './_utils/markdown.js';
import { removeDataAlphaElements } from './_utils/remove-data-alpha-elements.js';
// import { formatCodePlugin } from './_utils/format-code.js';
import litPlugin from '@lit-labs/eleventy-plugin-lit';
// import litPlugin from '@lit-labs/eleventy-plugin-lit';
import { readFile } from 'fs/promises';
import nunjucks from 'nunjucks';
import componentList from './_data/componentList.js';
// import componentList from './_data/componentList.js';
import * as filters from './_utils/filters.js';
import { outlinePlugin } from './_utils/outline.js';
import { replaceTextPlugin } from './_utils/replace-text.js';
@@ -29,7 +29,6 @@ const globalData = {
package: packageData,
isAlpha,
layout: 'page.njk',
server: {
head: '',
loginOrAvatar: '',
@@ -190,30 +189,30 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy(glob);
}
// SSR plugin
// Make sure this is the last thing, we dont want to run the risk of accidentally transforming shadow roots with the nunjucks 2nd transform.
if (!isDev) {
//
// Problematic components in SSR land:
// - animation (breaks on navigation + ssr with Turbo)
// - mutation-observer (why SSR this?)
// - resize-observer (why SSR this?)
// - tooltip (why SSR this?)
//
const omittedModules = [];
const componentModules = componentList
.filter(component => !omittedModules.includes(component.tagName.split(/wa-/)[1]))
.map(component => {
const name = component.tagName.split(/wa-/)[1];
const componentDirectory = process.env.UNBUNDLED_DIST_DIRECTORY || path.join('.', 'dist');
return path.join(componentDirectory, 'components', name, `${name}.js`);
});
eleventyConfig.addPlugin(litPlugin, {
mode: 'worker',
componentModules,
});
}
// // SSR plugin
// // Make sure this is the last thing, we don't want to run the risk of accidentally transforming shadow roots with the nunjucks 2nd transform.
// if (!isDev) {
// //
// // Problematic components in SSR land:
// // - animation (breaks on navigation + ssr with Turbo)
// // - mutation-observer (why SSR this?)
// // - resize-observer (why SSR this?)
// // - tooltip (why SSR this?)
// //
// const omittedModules = [];
// const componentModules = componentList
// .filter(component => !omittedModules.includes(component.tagName.split(/wa-/)[1]))
// .map(component => {
// const name = component.tagName.split(/wa-/)[1];
// const componentDirectory = process.env.UNBUNDLED_DIST_DIRECTORY || path.join('.', 'dist');
// return path.join(componentDirectory, 'components', name, `${name}.js`);
// });
//
// eleventyConfig.addPlugin(litPlugin, {
// mode: 'worker',
// componentModules,
// });
// }
return {
markdownTemplateEngine: 'njk',

View File

@@ -1 +1 @@
export { HUE_RANGES as default } from '../assets/scripts/tweak/data.js';
export { hueRanges as default } from '../assets/scripts/tweak/data.js';

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}">
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}" class="wa-cloak">
<head>
{% include 'head.njk' %}
<meta name="theme-color" content="#f36944">

View File

@@ -3,8 +3,8 @@
<section id="grid" class="index-grid">
{% set groupedPages = allPages | groupPages(categories, page) %}
{% for category, pages in groupedPages -%}
{% if groupedPages.meta.groupCount > 1 %}
<h2 class="index-category">
{% if groupedPages.meta.groupCount > 1 and pages.length > 0 %}
<h2 class="index-category" id="{{ category | slugify }}">
{% if pages.meta.url %}<a href="{{ pages.meta.url }}">{{ pages.meta.title }}</a>
{% else %}
{{ pages.meta.title }}

View File

@@ -23,10 +23,9 @@
<script src="/assets/scripts/hydration-errors.js"></script>
<link rel="stylesheet" href="/assets/styles/hydration-errors.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm"></script>
{# Web Awesome #}
<script type="module" src="/dist/webawesome.ssr-loader.js"></script>
<script type="module" src="/dist/webawesome.loader.js"></script>
<script type="module" src="/assets/scripts/theme-picker.js"></script>
{# Preset Theme #}

View File

@@ -1,36 +0,0 @@
<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>

View File

@@ -1,4 +1,4 @@
{% if not (isAlpha and page.data.noAlpha) and not page.data.unlisted -%}
{% if page | show -%}
<li>
<a href="{{ page.url }}">{{ page.data.title }}</a>
{% if page.data.status == 'experimental' %}<wa-icon name="flask"></wa-icon>{% endif %}

View File

@@ -1,22 +1,22 @@
{% if since -%}
<wa-badge variant="neutral" class="since">Since {{ since }}</wa-badge>
<wa-badge variant="neutral">Since {{ since }}</wa-badge>
{% endif -%}
{%- if status %}
{%- if status == "wip" %}
<wa-badge variant="danger" class="status">
<wa-badge variant="danger">
<wa-icon name="pickaxe"></wa-icon>
Work In Progress
</wa-badge>
{%- elif status == "experimental" %}
<wa-badge variant="warning" class="status">
<wa-badge variant="warning">
<wa-icon name="flask"></wa-icon>
Experimental
</wa-badge>
{%- elif status == "stable" %}
<wa-badge variant="brand" class="status">Stable</wa-badge>
<wa-badge variant="brand">Stable</wa-badge>
{%- else %}
<wa-badge class="status">{{ status}}</wa-badge>
<wa-badge>{{ status}}</wa-badge>
{%- endif -%}
{%- endif %}

View File

@@ -7,46 +7,38 @@
{% block head %}
<style>@import url('/dist/styles/color/{{ paletteId }}.css') layer(palette.{{ paletteId }});</style>
<link href="{{ page.url }}../app/tweak.css" rel="stylesheet">
<script type="module" src="{{ page.url }}../app/tweak.js"></script>
<link href="{{ page.url }}../tweak.css" rel="stylesheet">
<script type="module" src="{{ page.url }}../tweak.js"></script>
{% endblock %}
{% block header %}
<div id="palette-app" data-slug="{{ page.fileSlug }}" data-palette-id="{{ page.fileSlug }}">
<div id="palette-app" data-palette-id="{{ paletteId }}">
<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': Object.keys(tweaksHumanReadable).length,
'tweaked-any': tweaked
}"
:style="{
'--chroma-scale': chromaScale,
'--gray-chroma': tweaked?.grayChroma ? grayChroma : originalGrayChroma,
'--max-c': maxChroma,
'--avg-l': L_RANGES[level].mid,
'--gray-chroma': tweaked?.grayChroma ? grayChroma : '',
}">
<header id="palette-info">
{% include 'breadcrumbs.njk' %}
<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 v-if="saved" class="title">
{% raw %}{{ saved.title }}{% endraw %}
<wa-icon-button name="pencil" label="Rename palette" @click="rename"></wa-icon-button>
<wa-icon-button class="delete" name="trash" label="Delete palette" @click="deleteSaved"></wa-icon-button>
</h1>
<h1 v-if="!saved" class="title">{{ title }}</h1>
<div class="block-info" v-cloak>
<code class="class">.wa-palette-<span v-content="slug">{{ page.fileSlug }}</span></code>
<div class="block-info">
<code class="class">.wa-palette-{{ paletteId }}</code>
{% include '../_includes/status.njk' %}
{% if not isPro %}
<wa-badge class="pro" v-if="tweaked">PRO</wa-badge>
@@ -57,28 +49,17 @@
{{ 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)" v-content="tweakHumanReadable"></wa-tag>
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="reset(param)">{% raw %}{{ tweakHumanReadable }}{% endraw %}</wa-tag>
</div>
<wa-button @click="reset()" appearance="outlined" variant="danger">
@@ -87,12 +68,15 @@
</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>
@@ -103,68 +87,127 @@
{%- endfor %}
</tr>
</thead>
<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="{
'--swatch-text-color': `light-dark(var(--wa-color-${ hue }-10), white)`,
'--hue-shift': hueShifts[hue] || ''
}">
<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>
{# 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>
<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>
</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 -%}
<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 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>
</color-popup>
</td>
</tr>
</tbody>
</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 }})"
:style="{
'--color-tweaked': colors.{{ hue }}[{{ tint }}],
'--color-tweaked-no-gray-chroma': colorsMinusGrayChroma.{{ hue }}[{{ tint }}],
}">
<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 %}
</table>
<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.001"
label="Overall colorfulness" label-min="More muted" label-max="More vibrant"
></color-slider>
{% 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>
<h2>Used By</h2>
@@ -267,22 +310,8 @@ 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 %}

View File

@@ -81,7 +81,7 @@ wa_data.palettes = {
{% set palette = defaultPalette %}
</wa-select>
<wa-select class="color-select" name="brand" label="Brand color" value="" clearable>
<wa-select name="brand" label="Brand color" value="" clearable>
<div class="selected-swatch" slot="prefix"></div>
{% for hue in hues %}
{% set currentBrand = hue == brand %}

View File

@@ -178,6 +178,10 @@ export function sort(arr, by = { 'data.order': 1, 'data.title': '' }) {
});
}
export function show(page) {
return !(page.data.noAlpha && page.data.isAlpha) && !page.data.unlisted;
}
/**
* Group an 11ty collection (or any array of objects with a `data.tags` property) by certain tags.
* @param {object[]} collection
@@ -198,7 +202,7 @@ export function groupPages(collection, options = {}, page) {
options = { tags: options };
}
let { tags, groups, titles = {}, other = 'Other' } = options;
let { tags, groups, titles = {}, other = 'Other', filter = show } = options;
if (groups === undefined && Array.isArray(tags)) {
groups = tags;
@@ -237,6 +241,10 @@ export function groupPages(collection, options = {}, page) {
let byUrl = {};
let byParentUrl = {};
if (filter) {
collection = collection.filter(filter);
}
for (let item of collection) {
let url = item.page.url;
let parentUrl = item.data.parentUrl;
@@ -313,6 +321,13 @@ export function groupPages(collection, options = {}, page) {
if (sortedGroups) {
ret = sortObject(ret, sortedGroups);
} else {
// At least make sure other is last
if (ret.other) {
let otherGroup = ret.other;
delete ret.other;
ret.other = otherGroup;
}
}
Object.defineProperty(ret, 'meta', {

View File

@@ -39,7 +39,7 @@ export function outlinePlugin(options = {}) {
}
// Create a clone of the heading so we can remove links and [data-no-outline] elements from the text content
clone.querySelectorAll('a').forEach(a => a.remove());
clone.querySelectorAll('.wa-visually-hidden, [hidden], [aria-hidden="true"]').forEach(el => el.remove());
clone.querySelectorAll('[data-no-outline]').forEach(el => el.remove());
// Generate the link

View File

@@ -1,3 +1,11 @@
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function updateResults(input) {
const filter = input.value.toLowerCase().trim();
let filtered = Boolean(filter);
@@ -18,8 +26,10 @@ function updateResults(input) {
}
}
const debouncedUpdateResults = debounce(updateResults, 300);
document.documentElement.addEventListener('input', e => {
if (e.target?.matches('#block-filter wa-input')) {
updateResults(e.target);
debouncedUpdateResults(e.target);
}
});

View File

@@ -13,33 +13,23 @@ sidebar.palettes = {
sidebar.updateCurrent();
},
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);
updateSaved() {
this.saved = localStorage.savedPalettes ? JSON.parse(localStorage.savedPalettes) : [];
},
/**
* Write palettes to local storage
*/
toLocalStorage() {
if (this.saved.length > 0) {
localStorage.savedPalettes = JSON.stringify(this.saved);
save(saved = this.saved) {
this.saved = saved ?? [];
if (saved.length > 0) {
localStorage.savedPalettes = JSON.stringify(saved);
} else {
delete localStorage.savedPalettes;
}
},
};
sidebar.palettes.fromLocalStorage();
// Palettes were updated in another tab
addEventListener('storage', () => sidebar.palettes.fromLocalStorage());
sidebar.palettes.updateSaved();
addEventListener('storage', event => sidebar.palettes.updateSaved());
sidebar.palette = {
getUid() {
@@ -69,9 +59,7 @@ sidebar.palette = {
delete(palette) {
let savedPalettes = sidebar.palettes.saved;
let count = savedPalettes.length;
if (count === 0 || !palette.uid) {
// No stored palettes or this palette has not been saved
if (count === 0) {
return;
}
@@ -80,9 +68,7 @@ sidebar.palette = {
return;
}
for (let index; (index = savedPalettes.findIndex(p => p.uid === palette.uid)) > -1; ) {
savedPalettes.splice(index, 1);
}
savedPalettes = savedPalettes.filter(p => !sidebar.palette.equals(palette, p));
if (savedPalettes.length === count) {
// Nothing was removed
@@ -110,14 +96,17 @@ sidebar.palette = {
sidebar.updateCurrent();
sidebar.palettes.toLocalStorage();
sidebar.palettes.save(savedPalettes);
if (globalThis.paletteApp?.saved?.uid === palette.uid) {
// We deleted the currently active palette
if (sidebar.palette.equals(globalThis.paletteApp?.saved, 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;
@@ -157,27 +146,23 @@ sidebar.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();
}
save(palette, saved) {
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 existing = this.getSaved(saved ?? palette, savedPalettes);
let oldValues;
let [oldValues] = sidebar.palettes.saved.splice(newIndex, 1, palette);
if (existing) {
// Rename
oldValues = { ...existing };
Object.assign(existing, palette);
} else {
savedPalettes.push(palette);
}
this.render(palette, oldValues);
sidebar.updateCurrent();
sidebar.palettes.toLocalStorage();
return palette;
sidebar.palettes.save(savedPalettes);
},
};

View File

@@ -1,12 +1,30 @@
let initialPageLoadComplete = false;
window.addEventListener('load', () => {
initialPageLoadComplete = true;
});
// Helper for view transitions
export function domChange(fn, { behavior = 'smooth' } = {}) {
export function domChange(fn, { behavior = 'smooth', ignoreInitialLoad = true } = {}) {
const canUseViewTransitions =
document.startViewTransition && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Skip transitions on initial page load
if (!initialPageLoadComplete && ignoreInitialLoad) {
fn(false);
return null;
}
if (canUseViewTransitions && behavior === 'smooth') {
document.startViewTransition(fn);
const transition = document.startViewTransition(() => {
fn(true);
// Wait a brief delay before finishing the transition to prevent jumpiness
return new Promise(resolve => setTimeout(resolve, 200));
});
return transition;
} else {
fn(true);
fn(false);
return null;
}
}

View File

@@ -1,3 +1,6 @@
import 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm';
import { preventTurboFouce } from '/dist/webawesome.js';
if (!window.___turboScrollPositions___) {
window.___turboScrollPositions___ = {};
}
@@ -70,3 +73,4 @@ function fixDSD(e) {
window.addEventListener('turbo:before-cache', saveScrollPosition);
window.addEventListener('turbo:before-render', restoreScrollPosition);
window.addEventListener('turbo:render', restoreScrollPosition);
preventTurboFouce();

View File

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

View File

@@ -12,6 +12,49 @@ 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/',
@@ -25,189 +68,6 @@ export const icons = {
typography: 'font-case',
};
export const selectors = {
palette: id =>
[':where(:root)', ':host', ":where([class^='wa-theme-'], [class*=' wa-theme-'])", `.wa-palette-${id}`].join(',\n'),
};
export const hues = Object.keys(hueRanges);
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'];

View File

@@ -13,42 +13,56 @@ export default class Permalink extends URLSearchParams {
return Object.fromEntries(this.entries());
}
set(key, value, defaultValue) {
if (equals(value, defaultValue) || equals(value, '')) {
value = null;
#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);
}
}
value ??= null; // undefined -> null
writeTo(obj) {
let mapping = this.#mappings.get(obj) ?? {};
let { keyTo = IDENTITY, valueTo = IDENTITY, canExtend = false } = mapping;
let oldValue = Array.isArray(value) ? this.getAll(key) : this.get(key);
let changed = !equals(value, oldValue);
for (let [key, value] of this) {
let mappedKey = keyTo(key);
let mappedValue = valueTo(value);
if (!changed) {
// Nothing to do here
return;
}
if (Array.isArray(value)) {
super.delete(key);
value = value.slice();
for (let v of value) {
if (v || v === 0) {
if (typeof v === 'object') {
super.append(key, JSON.stringify(v));
} else {
super.append(key, v);
}
}
if (canExtend || mappedKey in obj) {
obj[mappedKey] = mappedValue;
}
} else if (value === null) {
}
}
set(key, value, defaultValue) {
let oldValue = this.get(key);
if (!value || value == defaultValue) {
super.delete(key);
if (oldValue) {
this.changed = true;
}
} else {
super.set(key, value);
if (String(value) !== String(oldValue)) {
this.changed = true;
}
}
this.sort();
this.changed ||= changed;
}
/**
@@ -65,40 +79,3 @@ 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];
}

View File

@@ -1,304 +1,36 @@
// 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 each angle individually
let normalizedAngles = angles.map(h => ((h % 360) + 360) % 360);
// First, normalize
angles = angles.map(h => ((h % 360) + 360) % 360);
for (let i = 1; i < angles.length; i++) {
let angle = normalizedAngles[i];
let prevAngle = normalizedAngles[i - 1];
let delta = angle - prevAngle;
// 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;
if (Math.abs(delta) > 180) {
let equivalent = [angle + 360, angle - 360];
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;
// 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];
if (Math.abs(equivalent[0] - prevHue) <= Math.abs(equivalent[1] - prevHue)) {
angles[i] = equivalent[0];
} else {
angles[i] = equivalent[1];
}
}
}
return normalizedAngles;
return angles;
}
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];
}

View File

@@ -370,10 +370,22 @@ wa-page > main:has(> .index-grid) {
.index-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(22ch, 100%), 1fr));
grid-template-columns: repeat(4, 1fr);
gap: var(--wa-space-2xl);
margin-block-end: var(--wa-space-3xl);
@media screen and (max-width: 1470px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 960px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: 500px) {
grid-template-columns: repeat(1, 1fr);
}
a {
border-radius: var(--wa-border-radius-l);
text-decoration: none;
@@ -440,14 +452,9 @@ wa-page > main:has(> .index-grid) {
&.color {
border-color: transparent;
transition: background var(--wa-transition-slow);
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);
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%);
&.contrast-fail {
outline: 1px dashed var(--wa-color-red);
@@ -646,46 +653,3 @@ 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);
}
}

View File

@@ -15,9 +15,9 @@ icon: card
<strong>Mittens</strong><br />
This kitten is as cute as he is playful. Bring him home today!<br />
<small>6 weeks old</small>
<small class="wa-caption-m">6 weeks old</small>
<div slot="footer">
<div slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating label="Rating"></wa-rating>
</div>
@@ -27,16 +27,6 @@ icon: card
.card-overview {
width: 300px;
}
.card-overview small {
color: var(--wa-color-text-quiet);
}
.card-overview [slot='footer'] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
@@ -65,9 +55,9 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
```html {.example}
<wa-card with-header class="card-header">
<div slot="header">
<div slot="header" class="wa-split">
Header Title
<wa-icon-button name="gear" variant="solid" label="Settings"></wa-icon-button>
<wa-icon-button name="gear" variant="solid" label="Settings" class="wa-size-m"></wa-icon-button>
</div>
This card has a header. You can put all sorts of things in it!
@@ -78,19 +68,9 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
max-width: 300px;
}
.card-header [slot='header'] {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
margin: 0;
}
.card-header wa-icon-button {
font-size: var(--wa-font-size-m);
}
</style>
```
@@ -103,7 +83,7 @@ If using SSR, you need to also use the `with-footer` attribute to add a footer t
<wa-card with-footer class="card-footer">
This card has a footer. You can put all sorts of things in it!
<div slot="footer">
<div slot="footer" class="wa-split">
<wa-rating></wa-rating>
<wa-button variant="brand">Preview</wa-button>
</div>
@@ -113,12 +93,6 @@ If using SSR, you need to also use the `with-footer` attribute to add a footer t
.card-footer {
max-width: 300px;
}
.card-footer [slot='footer'] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
@@ -153,7 +127,7 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="small">
This is a small card.
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
@@ -162,7 +136,7 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="medium">
This is a medium card (default).
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
@@ -171,14 +145,39 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="large">
This is a large card.
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
</wa-card>
</div>
```
<style>
</style>
### Appearance
Use the `appearance` attribute to change the card's visual appearance.
```html {.example}
<div class="wa-grid">
<wa-card>
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<div slot="header">Outlined (default)</div>
Card content.
</wa-card>
{% for appearance in ['outlined filled', 'outlined accent', 'plain', 'filled', 'accent'] -%}
<wa-card appearance="{{ appearance }}">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<div slot="header">{{ appearance | capitalize }}</div>
Card content.
</wa-card>
{%- endfor %}
</div>
```

View File

@@ -77,6 +77,31 @@ The details component automatically adapts to right-to-left languages:
</wa-details>
```
### Appearance
Use the `appearance` attribute to change the elements visual appearance.
```html {.example}
<div class="wa-stack">
<wa-details summary="Outlined (default)">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Filled" appearance="filled">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Filled + Outlined" appearance="filled outlined">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Plain" appearance="plain">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
</div>
```
### Grouping Details
Details are designed to function independently, but you can simulate a group or "accordion" where only one is shown at a time by listening for the `wa-show` event.

View File

@@ -37,10 +37,6 @@ This snippet includes three parts:
Now you can [start using Web Awesome!](/docs/usage)
:::info
While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it.
:::
---
## Using Font Awesome Kit Codes

View File

@@ -33,7 +33,7 @@ Use the [variant utility classes](../utilities/color.md) to set the button's sem
### Appearance
Use the [appearance utility classes](../utilities/appearance.md) to change the button's visual appearance:
Use the [appearance utility classes](/docs/utilities/appearance) to change the button's visual appearance:
```html {.example}
<div style="margin-block-end: 1rem;">

View File

@@ -57,7 +57,7 @@ Use the [variant utility classes](../utilities/color.md) to set the callout's co
### Appearance
Use the [appearance utility classes](../utilities/appearance.md) to change the callout's visual appearance (the default is `outlined filled`).
Use the [appearance utility classes](/docs/utilities/appearance) to change the callout's visual appearance (the default is `outlined filled`).
```html {.example}
<article class="wa-callout wa-brand wa-outlined wa-accent">

View File

@@ -19,6 +19,35 @@ file: styles/native/details.css
## Examples
### Appearance
Use the [appearance utility classes](/docs/utilities/appearance) to change the element's visual appearance:
```html {.example}
<div class="wa-stack">
<details>
<summary>Outlined (default)</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-filled">
<summary>Filled</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-filled wa-outlined">
<summary>Filled + Outlined</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-plain">
<summary>Plain</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
</div>
```
### Right-to-Left Languages
The details styling automatically adapts to right-to-left languages:

View File

@@ -1,28 +0,0 @@
import { tints } from '/assets/scripts/tweak/data.js';
export function generateGrays(colors, { grayColor, grayChroma, grayLevel }) {
let ret = {};
let undertoneScale = colors[grayColor];
// These will be the same, since scaling them won't change the relationship
ret.maxChromaTint = grayLevel ?? 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;

View File

@@ -1,162 +0,0 @@
// 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;
}

View File

@@ -1,138 +0,0 @@
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;

View File

@@ -1,3 +0,0 @@
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';

View File

@@ -1,91 +0,0 @@
/**
* 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;
}

View File

@@ -1,65 +0,0 @@
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;

View File

@@ -1,28 +0,0 @@
// TODO move these to local imports
import Color from 'https://colorjs.io/dist/color.js';
import { tints } from '/assets/scripts/tweak/data.js';
let palettes = await fetch('/docs/palettes/data.json').then(r => r.json());
for (let palette in palettes) {
for (let hue in palettes[palette].colors) {
let scale = palettes[palette].colors[hue];
for (let tint of tints) {
let color = scale[tint];
if (Array.isArray(color)) {
scale[tint] = new Color('oklch', color);
}
}
Object.defineProperty(scale, 'core', {
get() {
return this[this.maxChromaTint];
},
enumerable: false,
});
}
}
globalThis.allPalettes = palettes;
export default palettes;

View File

@@ -1,75 +0,0 @@
// 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;
let grayLevel = baseColors.gray?.maxChromaTint;
ret.gray = generateGrays(baseColors, { grayColor, grayChroma, grayLevel });
} 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;

View File

@@ -1,154 +0,0 @@
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 });
}

View File

@@ -1,406 +0,0 @@
/* CSS included both in predefined palettes and custom ones */
:root {
--fa-sliders-simple: '\f1de';
}
.core-column {
position: relative;
> wa-dropdown {
min-width: 100%;
}
}
wa-dropdown > .color.swatch {
cursor: pointer;
}
.color-slider {
display: grid;
grid-template-columns: auto 1fr auto;
wa-slider {
grid-column: 1 / -1;
--track-height: 1em;
--track-color-inactive: transparent;
--track-color-active: transparent;
--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);
&:active {
--thumb-size: 2em;
}
&::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'] {
min-height: 1.1lh;
}
.clear-button {
vertical-align: middle;
font-size: var(--wa-font-size-xs);
}
.label-min,
.label-max {
font-size: var(--wa-font-size-xs);
}
.label-min {
grid-column: 1;
margin-inline-start: 0.15em;
}
.label-max {
grid-column: 3;
margin-inline-end: 0.1em;
}
}
[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-m) var(--wa-space-l);
border-radius: var(--wa-border-radius-m);
color-scheme: light;
.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;
}
}
.color-scale {
th {
white-space: nowrap;
}
.tweak-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
right: var(--wa-space-s);
opacity: var(--tweak-icon-opacity, 0%);
}
.copy-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
opacity: var(--copy-icon-opacity, 0%);
}
.color.swatch:hover {
--tweak-icon-opacity: 40%;
--copy-icon-opacity: 40%;
}
wa-dropdown[open] .copy-icon {
--copy-icon-opacity: 60%;
}
&.tweaked .core-column {
--tweak-icon-opacity: 80%;
}
}
.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 *) {
visibility: hidden;
}
&::part(message) {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
}
wa-button:first-of-type {
margin-inline-start: auto;
}
}
/* Better UI before Vue initializes */
[v-if='saved'],
[v-if^='tweaked'],
[v-cloak] {
display: none;
}
.static-palette:has(+ .colors:not([v-cloak])) {
display: none;
}
.core-color {
wa-radio-button::part(base) {
width: 2em;
height: 2em;
padding: 0;
border-radius: var(--wa-border-radius-circle);
background: var(--color);
background-clip: border-box;
}
wa-radio-button:is([checked], :state(checked))::part(base) {
box-shadow:
inset 0 0 0 var(--indicator-width) var(--indicator-color),
inset 0 0 0 calc(var(--indicator-width) + 1.5px) var(--wa-color-surface-default);
}
&::part(form-control-input) {
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);
--cover-size: calc(100% - var(--visible-size, 0%));
background:
radial-gradient(closest-side, var(--wa-color-surface-default) 100%, transparent 0) center / var(--cover-size)
var(--cover-size),
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)
);
background-origin: border-box;
background-clip: padding-box, border-box;
background-repeat: no-repeat;
transition: var(--wa-transition-slow) ease-in background-size;
&:is(:hover, :focus-within) {
--visible-size: 100%;
}
&,
&::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);
mask-size: var(--visible-size, 0%) var(--visible-size, 0%);
mask-repeat: no-repeat;
transition: inherit;
transition-property: mask-size;
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;
z-index: 2;
}
&::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;
}

View File

@@ -1,554 +0,0 @@
// TODO move these to local imports
import Color from 'https://colorjs.io/dist/color.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';
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() {
let appRoot = document.querySelector('#palette-app');
let paletteId = appRoot.dataset.paletteId;
let palette = allPalettes[paletteId];
return {
uid: undefined,
paletteId,
originalPaletteTitle: palette.title,
originalColors: palette.colors,
baseColors: { ...palette.colors },
permalink: new Permalink(),
hueShifts: Object.fromEntries(hues.map(hue => [hue, 0])),
chromaScale: 1,
grayChroma: undefined,
grayColor: undefined,
saved: null,
unsavedChanges: false,
savedPalettes: sidebar.palettes.saved,
};
},
created() {
// Non-reactive variables to expose
Object.assign(this, {
moreHue,
hueBefore,
hueAfter,
HUE_RANGES,
L_RANGES,
hues,
allHues,
tints,
MAX_CHROMA_BOUNDS,
});
if (location.search) {
// 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)) {
let value = this.permalink.get(param);
if (!isNaN(value)) {
// Convert numeric values to numbers
value = Number(value);
}
let prop = camelCase(param);
this[prop] = value;
}
}
if (this.permalink.has('uid')) {
this.uid = Number(this.permalink.get('uid'));
this.saved = sidebar.palettes.saved.find(p => p.uid === this.uid);
}
}
},
mounted() {
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,
chromaScale: this.chromaScale,
grayColor: this.grayColor,
grayChroma: this.grayChroma,
};
},
code() {
let ret = {};
for (let language of ['html', 'css']) {
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),
};
}
return ret;
},
colors() {
return tweakPalette.call(this, this.baseColors, this.tweaks, this.tweaked);
},
tweaked() {
let anyHueTweaked = Object.values(this.hueShifts).some(Boolean);
let hue = anyHueTweaked
? Object.fromEntries(Object.entries(this.hueShifts).map(([hue, shift]) => [hue, shift !== 0]))
: false;
let ret = {
chromaScale: this.chromaScale !== 1,
hue,
grayChroma: this.grayChroma !== undefined && this.grayChroma !== this.originalGrayChroma,
grayColor: this.grayColor !== undefined && this.grayColor !== this.originalGrayColor,
};
let anyTweaked = Object.values(ret).some(Boolean);
return anyTweaked ? ret : false;
},
tweaksHumanReadable() {
let ret = {};
if (this.chromaScale !== 1) {
ret.chromaScale = 'More ' + (this.chromaScale > 1 ? 'vibrant' : 'muted');
}
for (let hue in this.hueShifts) {
let shift = this.hueShifts[hue];
if (!shift) {
continue;
}
let relHue = shift < 0 ? hueBefore[hue] : hueAfter[hue];
let hueTweak = moreHue[relHue] ?? relHue + 'er';
ret[hue] = capitalize(hueTweak + ' ' + hue + 's');
}
if (this.tweaked.grayChroma || this.tweaked.grayColor) {
if (this.tweaked.grayChroma === 0) {
ret.grayChroma = 'Achromatic grays';
} else {
if (this.tweaked.grayColor) {
ret.grayColor = capitalize(this.grayColor) + ' gray undertone';
}
if (this.tweaked.grayChroma) {
let more = this.tweaked.grayChroma > this.originalGrayChroma;
ret.grayChroma = `More ${more ? 'colorful' : 'neutral'} grays`;
}
}
}
return ret;
},
originalContrasts() {
return getContrasts(this.baseColors);
},
contrasts() {
return getContrasts(this.colors, this.originalContrasts);
},
baseCoreColors() {
let ret = {};
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) {
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.baseCoreColors.gray.get('oklch.h');
let minDistance = Infinity;
let closestHue = null;
// Find core color whose hue is closest to our gray
for (let name in this.baseCoreColors) {
if (name === 'gray') {
continue;
}
let hue = this.baseCoreColors[name].get('oklch.h');
let distance = Math.abs(subtractAngles(hue, grayHue));
if (distance < minDistance) {
minDistance = distance;
closestHue = name;
}
}
return closestHue ?? 'indigo';
},
originalGrayChroma() {
let grayChroma = this.baseColors.gray.core.get('oklch.c');
if (grayChroma === 0 || grayChroma === null) {
return 0;
}
let grayColorChroma = this.baseColors[this.originalGrayColor].core.get('oklch.c');
return grayChroma / grayColorChroma;
},
/**
* We want to preserve the original grayChroma selection so that when the user switches to another undertone
* that supports higher chromas, their selection will be there.
* This property is the gray chroma % that is actually applied.
*/
computedGrayChroma() {
let grayChroma = this.grayChroma ?? this.originalGrayChroma;
return Math.min(grayChroma, this.maxGrayChroma);
},
computedGrayColor() {
return this.grayColor ?? this.originalGrayColor;
},
maxGrayChroma() {
return MAX_GRAY_CHROMA_SCALE[this.grayColor] ?? 0.35;
},
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() {
for (let hue in this.hueShifts) {
this.permalink.set(hue + '-shift', this.hueShifts[hue], 0);
}
},
},
chromaScale() {
this.permalink.set('chroma-scale', this.chromaScale, 1);
},
grayColor() {
this.permalink.set('gray-color', this.grayColor, this.originalGrayColor);
},
grayChroma() {
this.permalink.set('gray-chroma', this.grayChroma, this.originalGrayChroma);
},
tweaks: {
deep: true,
async handler(value, oldValue) {
await nextTick(); // must run after individual watchers
// Update page URL
this.permalink.updateLocation();
this.unsavedChanges = true;
},
},
saved: {
deep: true,
handler() {
this.unsavedChanges = !this.saved;
},
},
}, // end watch
methods: {
capitalize,
slugify,
getMaxChroma,
log,
async save({ title } = {}) {
let uid = this.uid;
this.saved ??= { id: this.paletteId, uid: this.uid };
if (title) {
// Renaming
this.saved.title = title;
} else {
this.saved.title ??= this.defaultPaletteTitle;
}
this.saved.search = location.search;
this.saved = 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', this.uid);
this.permalink.updateLocation();
await this.$nextTick();
this.save(); // Save again to update the search param to include the UID
}
this.unsavedChanges = false;
},
rename() {
let newTitle = prompt('Palette title:', this.saved?.title ?? this.defaultPaletteTitle);
if (newTitle && newTitle !== this.saved?.title) {
this.save({ title: newTitle });
}
},
// Cannot name this delete() because Vue complains
deleteSaved() {
sidebar.palette.delete(this.saved);
},
postDelete() {
this.saved = null;
this.permalink.delete('uid');
this.uid = undefined;
this.permalink.updateLocation();
},
/**
* Remove a specific tweak or all tweaks
* @param {string} [param] - The tweak to remove. If not provided, all tweaks are removed.
*/
reset(param, context = this) {
if (!param || param === 'chromaScale') {
context.chromaScale = 1;
}
if (param in this.hueShifts) {
context.hueShifts[param] = 0;
} else if (!param) {
for (let hue in this.hueShifts) {
context.hueShifts[hue] = 0;
}
}
if (!param || param === 'grayColor') {
context.grayColor = this.originalGrayColor;
}
if (!param || param === 'grayChroma') {
context.grayChroma = this.originalGrayChroma;
}
return context;
},
}, // end methods
directives: {
// Like v-text, but doesn't complain if the element has content,
// making it possible to use in a PE fashion, with the contents being the fallback
content(el, { value, arg }) {
if (!el.dataset.fallback) {
// Store the original content as a fallback the first time
el.dataset.fallback = el.textContent;
}
if (value === '') {
value = el.dataset.fallback;
} else {
if (arg === 'number') {
value = Number(value).toLocaleString(undefined, { maximumSignificantDigits: 2 });
}
}
if (arg === 'html') {
el.innerHTML = value;
} else {
el.textContent = value;
}
},
},
components: {
ColorPopup,
ColorSlider,
ColorSwatchPicker,
InfoTip,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function init() {
let paletteAppContainer = document.querySelector('#palette-app');
globalThis.paletteApp?.unmount?.();
if (!paletteAppContainer) {
return;
}
globalThis.paletteApp = createApp(paletteAppSpec).mount(paletteAppContainer);
}
init();
addEventListener('turbo:render', init);

View File

@@ -1,82 +0,0 @@
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,
},
};

View File

@@ -1,73 +0,0 @@
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>
`,
};

View File

@@ -1,343 +0,0 @@
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: 0.001,
},
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;
}

View File

@@ -1,56 +0,0 @@
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-'),
},
};

View File

@@ -1,37 +0,0 @@
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-'),
},
};

View File

@@ -0,0 +1,205 @@
:root {
--fa-sliders-simple: '\f1de';
}
.core-column {
position: relative;
> wa-dropdown {
min-width: 100%;
}
}
wa-dropdown > .color.swatch {
cursor: pointer;
}
.decorated-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-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);
&:active {
--thumb-size: 2em;
}
&::part(base) {
background: linear-gradient(to right in var(--color-interpolation-space, oklab), var(--color-1), var(--color-2));
}
}
[slot='label'] {
min-height: 1.1lh;
}
.clear-button {
vertical-align: middle;
font-size: var(--wa-font-size-xs);
&:not(.tweaked *) {
display: none;
}
}
.label-min,
.label-max {
font-size: var(--wa-font-size-xs);
}
.label-min {
grid-column: 1;
margin-inline-start: 0.15em;
}
.label-max {
grid-column: 3;
margin-inline-end: 0.1em;
}
}
.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);
}
.popup {
background: var(--wa-color-surface-default);
border: 1px solid var(--wa-color-surface-border);
padding: var(--wa-space-xl);
border-radius: var(--wa-border-radius-m);
code {
white-space: nowrap;
}
}
.color-scale {
th {
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%;
transform: translateY(-50%);
right: var(--wa-space-s);
opacity: var(--tweak-icon-opacity, 0%);
}
.core-column:hover {
--tweak-icon-opacity: 40%;
}
&.tweaked .core-column {
--tweak-icon-opacity: 80%;
}
}
.tweaked-callout {
padding: var(--wa-space-xs);
padding-inline-start: var(--wa-space-m);
align-items: center;
&:not(.tweaked-any *) {
visibility: hidden;
}
&::part(message) {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
}
wa-button:first-of-type {
margin-inline-start: auto;
}
}
/* Better UI before Vue initializes */
[v-if='saved'],
[v-if^='tweaked'] {
display: none;
}
.core-color {
wa-radio-button::part(base) {
width: 2em;
height: 2em;
padding: 0;
border-radius: var(--wa-border-radius-circle);
background: var(--color);
background-clip: border-box;
}
wa-radio-button:is([checked], :state(checked))::part(base) {
box-shadow:
inset 0 0 0 var(--indicator-width) var(--indicator-color),
inset 0 0 0 calc(var(--indicator-width) + 1.5px) var(--wa-color-surface-default);
}
&::part(form-control-input) {
gap: var(--wa-space-xs);
}
}

580
docs/docs/palettes/tweak.js Normal file
View File

@@ -0,0 +1,580 @@
// TODO move these to local imports
import Color from 'https://colorjs.io/dist/color.js';
import { createApp, nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { cdnUrl, hueRanges, hues, Permalink, tints } from '../../assets/scripts/tweak.js';
import { cssImport, cssLiteral, cssRule } from '../../assets/scripts/tweak/code.js';
import { maxGrayChroma, moreHue, selectors, urls } from '../../assets/scripts/tweak/data.js';
import { subtractAngles } from '../../assets/scripts/tweak/util.js';
import Prism from '/assets/scripts/prism.js';
await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag)));
// // Detect https://bugs.webkit.org/show_bug.cgi?id=287637
// const SAFARI_OKLCH_BUG = (() => {
// let dummy = document.createElement('div');
// document.body.appendChild(dummy);
// dummy.style.color = 'oklch(from #d5e0e6 l c h)';
// let computedColor = getComputedStyle(dummy).color;
// dummy.remove();
// return computedColor.endsWith(' 0)');
// })();
let allPalettes = await fetch('/docs/palettes/data.json').then(r => r.json());
globalThis.allPalettes = allPalettes;
for (let palette in allPalettes) {
for (let hue in allPalettes[palette].colors) {
let scale = allPalettes[palette].colors[hue];
for (let tint of tints) {
let color = scale[tint];
if (Array.isArray(color)) {
scale[tint] = new Color('oklch', color);
}
}
}
}
const percentFormatter = value => value.toLocaleString(undefined, { style: 'percent' });
let paletteAppSpec = {
data() {
let appRoot = document.querySelector('#palette-app');
let paletteId = appRoot.dataset.paletteId;
let palette = allPalettes[paletteId];
return {
uid: undefined,
paletteId,
paletteTitle: palette.title,
originalColors: palette.colors,
permalink: new Permalink(),
hueRanges,
hueShifts: Object.fromEntries(hues.map(hue => [hue, 0])),
chromaScale: 1,
grayChroma: undefined,
grayColor: undefined,
tweaking: {},
saved: null,
};
},
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)),
});
this.grayChroma = this.originalGrayChroma;
this.grayColor = this.originalGrayColor;
if (location.search) {
// Update from URL
this.permalink.writeTo(this.hueShifts);
for (let param of ['chroma-scale', 'gray-color', 'gray-chroma']) {
if (this.permalink.has(param)) {
let value = this.permalink.get(param);
if (!isNaN(value)) {
// Convert numeric values to numbers
value = Number(value);
}
let prop = camelCase(param);
this[prop] = value;
}
}
if (this.permalink.has('uid')) {
this.uid = Number(this.permalink.get('uid'));
}
this.saved = sidebar.palette.getSaved(this.getPalette());
}
},
mounted() {
for (let ref in this.$refs) {
this.$refs[ref].tooltipFormatter = percentFormatter;
}
},
computed: {
tweaks() {
return {
hueShifts: this.hueShifts,
chromaScale: this.chromaScale,
grayColor: this.grayColor,
grayChroma: this.grayChroma,
};
},
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 });
ret[language] = {
raw: code,
highlighted: Prism.highlight(code, Prism.languages[language], language),
};
}
return ret;
},
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);
},
tweaked() {
let anyHueTweaked = Object.values(this.hueShifts).some(Boolean);
let hue = anyHueTweaked
? Object.fromEntries(Object.entries(this.hueShifts).map(([hue, shift]) => [hue, shift !== 0]))
: false;
let ret = {
chromaScale: this.chromaScale !== 1,
hue,
grayChroma: this.grayChroma !== this.originalGrayChroma,
grayColor: this.grayColor !== this.originalGrayColor,
};
let anyTweaked = Object.values(ret).some(Boolean);
return anyTweaked ? ret : false;
},
tweaksHumanReadable() {
let ret = {};
if (this.chromaScale !== 1) {
ret.chromaScale = 'More ' + (this.chromaScale > 1 ? 'vibrant' : 'muted');
}
for (let hue in this.hueShifts) {
let shift = this.hueShifts[hue];
if (!shift) {
continue;
}
let relHue = shift < 0 ? arrayPrevious(hues, hue) : arrayNext(hues, hue);
let hueTweak = moreHue[relHue] ?? relHue + 'er';
ret[hue] = capitalize(hueTweak + ' ' + hue + 's');
}
if (this.tweaked.grayChroma || this.tweaked.grayColor) {
if (this.tweaked.grayChroma === 0) {
ret.grayChroma = 'Achromatic grays';
} else {
if (this.tweaked.grayColor) {
ret.grayColor = capitalize(this.grayColor) + ' gray undertone';
}
if (this.tweaked.grayChroma) {
let more = this.tweaked.grayChroma > this.originalGrayChroma;
ret.grayChroma = `More ${more ? 'colorful' : 'neutral'} grays`;
}
}
}
return ret;
},
originalContrasts() {
return getContrasts(this.originalColors);
},
contrasts() {
return getContrasts(this.colors, this.originalContrasts);
},
originalCoreColors() {
let ret = {};
for (let hue in this.originalColors) {
let maxChromaTintRaw = this.originalColors[hue].maxChromaTintRaw;
ret[hue] = this.originalColors[hue][maxChromaTintRaw];
}
return ret;
},
coreColors() {
let ret = {};
for (let hue in this.colors) {
let maxChromaTintRaw = this.colors[hue].maxChromaTintRaw;
ret[hue] = this.colors[hue][maxChromaTintRaw];
}
return ret;
},
originalGrayColor() {
let grayHue = this.originalCoreColors.gray.get('h');
let minDistance = Infinity;
let closestHue = null;
for (let name in this.originalCoreColors) {
if (name === 'gray') {
continue;
}
let hue = this.originalCoreColors[name].get('h');
let distance = Math.abs(subtractAngles(hue, grayHue));
if (distance < minDistance) {
minDistance = distance;
closestHue = name;
}
}
return closestHue ?? 'indigo';
},
originalGrayChroma() {
let coreTint = this.originalColors.gray.maxChromaTint;
let grayChroma = this.originalColors.gray[coreTint].get('c');
if (grayChroma === 0 || grayChroma === null) {
return 0;
}
let grayColorChroma = this.originalColors[this.originalGrayColor][coreTint].get('c');
return grayChroma / grayColorChroma;
},
/**
* We want to preserve the original grayChroma selection so that when the user switches to another undertone
* that supports higher chromas, their selection will be there.
* This property is the gray chroma % that is actually applied.
*/
computedGrayChroma() {
return Math.min(this.grayChroma, this.maxGrayChroma);
},
maxGrayChroma() {
return maxGrayChroma[this.grayColor] ?? 0.3;
},
},
watch: {
hueShifts: {
deep: true,
handler() {
this.permalink.readFrom(this.hueShifts);
},
},
chromaScale() {
this.permalink.set('chroma-scale', this.chromaScale, 1);
},
grayColor() {
this.permalink.set('gray-color', this.grayColor, this.originalGrayColor);
},
grayChroma() {
this.permalink.set('gray-chroma', this.grayChroma, this.originalGrayChroma);
},
tweaks: {
deep: true,
async handler(value, oldValue) {
await nextTick(); // must run after individual watchers
// Update page URL
this.permalink.updateLocation();
if (this.saved) {
this.save({ silent: true });
}
},
},
},
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;
}
let uid = this.uid;
if (!uid) {
// First time saving
this.uid = uid = sidebar.palette.getUid();
this.permalink.set('uid', uid);
this.permalink.updateLocation();
}
let palette = { ...this.getPalette(), uid, title };
sidebar.palette.save(palette, this.saved);
this.saved = palette;
},
rename() {
if (!this.saved) {
return;
}
let newTitle = prompt('New title:', this.saved.title);
if (!newTitle) {
return;
}
this.saved.title = newTitle;
sidebar.palette.save(this.saved);
},
deleteSaved() {
sidebar.palette.delete(this.saved);
},
postDelete() {
this.saved = null;
this.permalink.delete('uid');
this.uid = undefined;
this.permalink.updateLocation();
},
/**
* Remove a specific tweak or all tweaks
* @param {string} [param] - The tweak to remove. If not provided, all tweaks are removed.
*/
reset(param) {
if (!param || param === 'chromaScale') {
this.chromaScale = 1;
}
if (param in this.hueShifts) {
this.hueShifts[param] = 0;
} else if (!param) {
for (let hue in this.hueShifts) {
this.hueShifts[hue] = 0;
}
}
if (!param || param === 'grayColor') {
this.grayColor = this.originalGrayColor;
}
if (!param || param === 'grayChroma') {
this.grayChroma = this.originalGrayChroma;
}
},
},
directives: {
// Like v-text, but doesn't complain if the element has content,
// making it possible to use in a PE fashion, with the contents being the fallback
content(el, { value, arg }) {
if (!el.dataset.fallback) {
// Store the original content as a fallback the first time
el.dataset.fallback = el.textContent;
}
if (value === '') {
value = el.dataset.fallback;
} else {
if (arg === 'number') {
value = Number(value).toLocaleString(undefined, { maximumSignificantDigits: 2 });
}
}
if (arg === 'html') {
el.innerHTML = value;
} else {
el.textContent = value;
}
},
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function init() {
let paletteAppContainer = document.querySelector('#palette-app');
globalThis.paletteApp?.unmount?.();
if (!paletteAppContainer) {
return;
}
globalThis.paletteApp = createApp(paletteAppSpec).mount(paletteAppContainer);
}
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;
}

View File

@@ -14,6 +14,8 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- Added the `wa-cloak` utility to prevent FOUCE
- Added the `allDefined()` utility for awaiting component registration
- Fixed `wa-pill` class for text fields
- Fixed `pill` style for `<wa-input>` elements
- Fixed a bug in `<wa-color-picker>` that prevented light dismiss from working when clicking immediately above the color picker dropdown

View File

@@ -94,6 +94,31 @@
}
}
}
.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) {

View File

@@ -54,12 +54,8 @@ function init() {
urlParams: new Permalink(),
};
// Apply params from permalink
for (let key in data.params) {
if (data.urlParams.has(key)) {
data.params[key] = data.urlParams.get(key);
}
}
data.urlParams.mapObject(data.params);
data.urlParams.writeTo(data.params);
if (computed.isRemixed) {
// Start with the remixing UI open if the theme has been remixed
@@ -125,22 +121,14 @@ function render(changedAspect) {
let brand = data.params.brand || data.defaultParams.brand;
selects.brand.style.setProperty('--color', `var(--wa-color-${brand})`);
// 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);
selects.brand.className = `wa-palette-${computed.palette}`;
for (let aspect in data.params) {
let value = data.params[aspect];
selects[aspect].value = value;
}
for (let key in data.params) {
if (data.params[key]) {
data.urlParams.set(key, data.params[key]);
}
}
data.urlParams.readFrom(data.params);
// Update demo URL
domChange(() => {

View File

@@ -8,6 +8,30 @@ Web Awesome components are just regular HTML elements, or [custom elements](http
If you're new to custom elements, often referred to as "web components," this section will familiarize you with how to use them.
## Awaiting Registration
Unlike traditional frameworks, custom elements don't have a centralized initialization phase. This means you need to verify that a custom element has been properly registered before attempting to interact with its properties or methods.
You can use the [`customElements.whenDefined()`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined) method to ensure a specific component is ready:
```ts
await customElements.whenDefined('wa-button');
// <wa-button> is ready to use!
const button = document.querySelector('wa-button');
```
When working with multiple components, checking each one individually can become tedious. For convenience, Web Awesome provides the `allDefined()` function which automatically detects and waits for all Web Awesome components in the DOM to be initialized before resolving.
```ts
import { allDefined } from '/dist/webawesome.js';
// Waits for all Web Awesome components in the DOM to be registered
await allDefined();
// All Web Awesome components on the page are ready!
```
## Attributes & Properties
Many components have properties that can be set using attributes. For example, buttons accept a `size` attribute that maps to the `size` property which dictates the button's size.

View File

@@ -0,0 +1,32 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
While convenient, autoloading can lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/).
The [FOUCE style utility](/docs/utilities/fouce/#opting-in) (which is automatically applied if you use the [Web Awesome utilities](/docs/utilities/)) takes care of hiding custom elements until they and their contents have been registered, up to a maximum of two seconds.
In many cases, this is not enough, and you may wish to hide a broader wrapper element or even the entire page until all WA elements within it have loaded. To do that, you can add the `wa-reduce-fouce` class to any element on the page or even apply it to the whole page by placing the class on the `<html>` element.
```html
<html class="wa-cloak">
...
</html>
```
As soon as all elements are registered _or_ after two seconds have elapsed, the autoloader will show the page. The two-second timeout prevents blank screens from persisting on slow networks and pages that have errors.
:::details Are you using Turbo in your app?
If you're using [Turbo](https://turbo.hotwired.dev/) to serve a multi-page application (MPA) as a single page application (SPA), you might notice FOUCE when navigating from page to page. This is because Turbo renders the new page's content before the autoloader has a change to register new components.
The following function acts as a middleware to ensure components are registered _before_ the page shows, eliminating FOUCE for page-to-page navigation with Turbo.
```js
import { preventTurboFouce } from '/dist/webawesome.js';
preventTurboFouce();
```
:::

View File

@@ -1,131 +0,0 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
{% markdown %}
No class is needed to use this utility, it will be applied automatically as long as it its CSS is included.
Here is a comparison of the loading experience with and without this utility,
with a simulated slow loading time:
{% endmarkdown %}
<div class="wa-split wa-align-items-end">
<strong>Normal loading</strong>
<wa-button onclick="document.querySelectorAll('iframe').forEach(iframe => iframe.srcdoc = iframe.srcdoc)">
<wa-icon name="refresh"></wa-icon>
Refresh
</wa-button>
<strong>With FOUCE reduction</strong>
</div>
{% set sample_card %}
<link id="theme-stylesheet" rel="stylesheet" href="/dist/styles/themes/default.css" render="blocking" fetchpriority="high">
<link rel="stylesheet" href="/dist/styles/webawesome.css">
<link rel="stylesheet" href="/dist/styles/forms.css">
<script type=module>
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const loadScript = src => new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.type = "module";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
await delay(500);
await loadScript("/dist/components/button/button.js");
await delay(500);
await loadScript("/dist/components/card/card.js");
await delay(500);
await loadScript("/dist/components/rating/rating.js");
</script>
<wa-card with-footer with-image class="card-overview">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<strong>Mittens</strong><br />
This kitten is as cute as he is playful. Bring him home today!<br />
<small>6 weeks old</small>
<div slot="footer">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</div>
</wa-card>
<style>
.card-overview small {
color: var(--wa-color-text-quiet);
}
.card-overview [slot=footer] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
{% endset %}
<div class="iframes">
<iframe srcdoc='<body class="wa-fouce-off">{{ sample_card }}</body>'></iframe>
<iframe srcdoc='{{ sample_card }}'></iframe>
</div>
<style>
.iframes {
display: flex;
gap: var(--wa-space-m);
margin-top: var(--wa-space-l);
iframe {
flex: 1;
height: 60ch;
}
}
</style>
{% markdown %}
## How does it work?
The utility consists of a timeout (`2s` by default) and a fade duration (`200ms` by default).
- If the element is _ready_ before the timeout, it will appear immediately.
- If it takes longer than _timeout_ + _fade_, it will fade in over the fade duration.
- If it takes somewhere between _timeout_ and _timeout_ + _fade_, you will get an interrupted fade.
An element is considered ready when both of these are true:
1. Either It has been registered or has a `did-ssr` attribute (indicating it was pre-rendered)
2. If its a Web Awesome component, its contents are also ready
## Customization
You can use the following CSS variables to customize the behavior:
| Variable | Description | Default |
| --- | --- | --- |
| `--wa-fouce-fade` | The transition duration for the fade effect. | `200ms` |
| `--wa-fouce-timeout` | The timeout after which elements will appear even if not registered | `2s` |
The fade duration cannot be longer than the timeout.
This means that you can disable FOUCE reduction on an element by setting `--wa-fouce-timeout: 0s`.
For example, if instead of `did-ssr` you used an `ssr` attribute to mark elements that were pre-rendered, you can do this to get them to appear immediately:
```css
[ssr] {
--wa-fouce-timeout: 0s;
}
```
You can also opt-out from FOUCE reduction for an element and its contents by adding the `.wa-fouce-off` class to it.
Applying this class to the root element will disable the utility for the entire page.
{% endmarkdown %}

View File

@@ -5,15 +5,16 @@
--spacing: var(--wa-space);
--border-width: var(--wa-panel-border-width);
--border-color: var(--wa-color-surface-border);
--outlined-background-color: var(--wa-color-surface-default);
--outlined-border-color: var(--wa-color-surface-border);
--border-radius: var(--wa-panel-border-radius);
--inner-border-radius: calc(var(--border-radius) - var(--border-width));
--inner-border-color: var(--outlined-border-color);
display: flex;
flex-direction: column;
background-color: var(--wa-color-surface-default);
border-color: var(--border-color);
background-color: var(--background-color, var(--wa-color-surface-default));
border-color: var(--border-color, var(--wa-color-surface-border));
border-radius: var(--border-radius);
border-style: var(--wa-panel-border-style);
box-shadow: var(--wa-shadow-s);
@@ -21,6 +22,20 @@
color: var(--wa-color-text-normal);
}
:host(:is([appearance~='accent'], .wa-accent)) {
color: var(--text-color, var(--wa-color-text-normal));
}
:host([appearance~='filled']),
:host(.wa-filled) {
--inner-border-color: oklab(from var(--outlined-border-color) l a b / 65%);
}
:host([appearance='plain']) {
--inner-border-color: transparent;
box-shadow: none;
}
/* Take care of top and bottom radii */
.image,
:host(:not([with-image])) .header,
@@ -46,10 +61,19 @@
}
}
/* Round all corners for plain appearance */
:host([appearance='plain']) .image {
border-radius: var(--inner-border-radius);
&::slotted(img) {
border-radius: inherit !important;
}
}
.header {
display: block;
border-block-end-style: inherit;
border-block-end-color: var(--border-color);
border-block-end-color: var(--inner-border-color);
border-block-end-width: var(--border-width);
padding: calc(var(--spacing) / 2) var(--spacing);
}
@@ -62,7 +86,7 @@
.footer {
display: block;
border-block-start-style: inherit;
border-block-start-color: var(--border-color);
border-block-start-color: var(--inner-border-color);
border-block-start-width: var(--border-width);
padding: var(--spacing);
}

View File

@@ -2,6 +2,7 @@ import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import styles from './card.css';
@@ -22,19 +23,24 @@ import styles from './card.css';
* @csspart footer - The container that wraps the card's footer.
*
* @cssproperty [--border-radius=var(--wa-panel-border-radius)] - The radius for the card's corners. Expects a single value.
* @cssproperty [--border-color=var(--wa-color-surface-border)] - The color of the card's borders, including inner borders. Expects a single value.
* @cssproperty [--border-color=var(--wa-color-surface-border)] - The color of the card's borders. Expects a single value.
* @cssproperty [--inner-border-color=var(--wa-color-surface-border)] - The color of the card's inner borders, e.g. those separating headers and footers from the main content. Expects a single value.
* @cssproperty [--border-width=var(--wa-panel-border-width)] - The width of the card's borders. Expects a single value.
* @cssproperty [--spacing=var(--wa-space)] - The amount of space around and between sections of the card. Expects a single value.
*/
@customElement('wa-card')
export default class WaCard extends WebAwesomeElement {
static shadowStyle = [sizeStyles, styles];
static shadowStyle = [sizeStyles, appearanceStyles, styles];
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
/** The component's size. Will be inherited by any descendants with a `size` attribute. */
@property({ reflect: true, initial: 'medium' }) size: 'small' | 'medium' | 'large' | 'inherit' = 'inherit';
/** The card's visual appearance. */
@property({ reflect: true })
appearance: 'accent' | 'filled' | 'outlined' | 'plain' = 'outlined';
/** Renders the card with a header. Only needed for SSR, otherwise is automatically added. */
@property({ attribute: 'with-header', type: Boolean, reflect: true }) withHeader = false;

View File

@@ -9,6 +9,7 @@ import { getTargetElement, waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import nativeStyles from '../../styles/native/details.css';
import appearanceStyles from '../../styles/utilities/appearance.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon/icon.js';
import styles from './details.css';
@@ -45,7 +46,7 @@ import styles from './details.css';
*/
@customElement('wa-details')
export default class WaDetails extends WebAwesomeElement {
static shadowStyle = [nativeStyles, styles];
static shadowStyle = [appearanceStyles, nativeStyles, styles];
private detailsObserver: MutationObserver;
private readonly localize = new LocalizeController(this);
@@ -67,6 +68,9 @@ export default class WaDetails extends WebAwesomeElement {
/** Disables the details so it can't be toggled. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The element's visual appearance. */
@property({ reflect: true }) appearance: 'filled' | 'outlined' | 'plain' = 'outlined';
firstUpdated() {
this.body.style.height = this.open ? 'auto' : '0';
if (this.open) {

View File

@@ -2,10 +2,12 @@ details:where(:not(:host *)),
:host {
--icon-color: var(--wa-color-text-quiet);
--spacing: var(--wa-space-m);
--outlined-border-color: var(--wa-color-surface-border);
background-color: var(--wa-color-surface-default);
border: var(--wa-panel-border-width) var(--wa-color-surface-border) var(--wa-panel-border-style);
background-color: var(--background-color, var(--wa-color-surface-default));
border: var(--wa-panel-border-width) var(--border-color, var(--wa-color-surface-border)) var(--wa-panel-border-style);
border-radius: var(--wa-panel-border-radius);
color: var(--text-color, inherit);
padding: var(--spacing);
/* Print styles */
@@ -19,6 +21,12 @@ details:where(:not(:host *)),
}
}
details.wa-plain,
:host(wa-details[appearance='plain']),
:host(wa-details.wa-plain) {
border-radius: 0;
}
details:where(:not(:host *)) summary,
:host summary /* nesting these summary styles in the rule above breaks in Firefox and Safari */ {
display: flex;

View File

@@ -1,20 +1,17 @@
/*
* Utility to minimize FOUCE and show custom elements only after they're registered
*/
:not(:defined),
:state(wa-defined):has(:not(:defined)),
.wa-cloak:has(:not(:defined)) {
animation: 2s step-end wa-fouce-cloak;
}
@keyframes wa-fade-in {
@keyframes wa-fouce-cloak {
from {
opacity: 0;
}
}
:not(:defined),
:state(wa-defined):has(:not(:defined)) {
/* The clamp() ensures that if --wa-fouce-timeout is set to 0s, the whole effect is disabled */
--wa-fouce-animation: clamp(0s, var(--wa-fouce-fade, 200ms), var(--wa-fouce-timeout, 2s)) var(--wa-fouce-timeout, 2s)
wa-fade-in both;
&:where(:not([did-ssr], .wa-fouce-off, .wa-fouce-off *)) {
animation: var(--wa-fouce-animation);
to {
opacity: 1;
}
}

View File

@@ -50,6 +50,12 @@ export async function discover(root: Element | ShadowRoot) {
console.warn(imp.reason); // eslint-disable-line no-console
}
}
// Wait a cycle to allow the first Lit update to run
await new Promise(requestAnimationFrame);
// Dispatch an event when discovery is complete.
document.dispatchEvent(new CustomEvent('wa-discovery-complete'));
}
/**
@@ -69,3 +75,22 @@ function register(tagName: string): Promise<void> {
import(path).then(() => resolve()).catch(() => reject(new Error(`Unable to autoload <${tagName}> from ${path}`)));
});
}
/**
* Acts as a middleware for Turbo's `turbo:before-render` event to ensure components are auto-loaded before showing the
* next page, eliminating page-to-page FOUCE in a Turbo environment.
*/
export function preventTurboFouce(timeout = 2000) {
document.addEventListener('turbo:before-render', async (event: CustomEvent) => {
const newBody = event.detail.newBody;
event.preventDefault();
try {
// Wait until all elements are registered or two seconds, whichever comes first
await Promise.race([discover(newBody), new Promise(resolve => setTimeout(resolve, timeout))]);
} finally {
event.detail.resume();
}
});
}

64
src/utilities/defined.ts Normal file
View File

@@ -0,0 +1,64 @@
interface AllDefinedOptions {
/**
* A callback that accepts a custom element tag name and returns `true` if the custom element should be defined before
* resolving or `false` to skip it. The tag name is always in lowercase.
*/
match: (tagName: string) => boolean;
/**
* To wait for additional custom elements that may not be on the page when the function is called, provide them here.
*/
additionalElements: string | string[];
/**
* The root in which to look for custom elements. Defaults to `document`. By design, shadow roots are not traversed,
* but you can call this function and set `root` to a custom element's shadow root if needed.
*/
root: Document | ShadowRoot;
}
/**
* Waits for custom elements that are currently on the page to be registered before resolving. This is sugar for
* awaiting `customElements.whenDefined()` multiple times. By default, the function waits for all undefined Web Awesome
* elements, but you can pass a custom match function to wait for other custom elements instead.
*
* The function returns with `Promise.all()`, so any loading errors will cause it to reject. Make sure you handle errors
* accordingly using a try/catch block or a `.catch()`.
*
* @example
* // Wait for Web Awesome elements
* await allDefined();
*
* // Wait for all custom elements that start with `foo-` as well as the `<bar-button>` element
* await allDefined({
* match: tagName => tagName.startsWith('foo-'),
* additionalElements: ['bar-button', 'baz-dialog']
* });
*/
export async function allDefined(options?: Partial<AllDefinedOptions>) {
const opts: AllDefinedOptions = {
match: tagName => tagName.startsWith('wa-'),
additionalElements: [],
root: document,
...options,
};
// Ensure additional elements is an array
const additionalElements = Array.isArray(opts.additionalElements)
? opts.additionalElements
: [opts.additionalElements];
// Discover undefined elements in the document
const undefinedElements = [...opts.root.querySelectorAll(':not(:defined)')]
.map(el => el.localName)
.filter((tag, index, arr) => arr.indexOf(tag) === index) // make it unique
.filter(tag => opts.match(tag)); // make sure it matches
const tagsToAwait = [...undefinedElements, ...additionalElements];
// Wait for all to be registered
await Promise.all(tagsToAwait.map(tag => customElements.whenDefined(tag)));
// Wait a cycle for the first update
await new Promise(requestAnimationFrame);
}

View File

@@ -1,6 +1,7 @@
export { registerIconLibrary, unregisterIconLibrary } from './components/icon/library.js';
export { discover, startLoader, stopLoader } from './utilities/autoloader.js';
export { discover, preventTurboFouce, startLoader, stopLoader } from './utilities/autoloader.js';
export { getBasePath, getKitCode, setBasePath, setKitCode } from './utilities/base-path.js';
export { allDefined } from './utilities/defined.js';
export { registerTranslation } from './utilities/localize.js';
// Utilities

View File

@@ -1,4 +1,3 @@
import { litSsrPlugin } from '@lit-labs/testing/web-test-runner-ssr-plugin.js';
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
import { readFileSync } from 'fs';
@@ -53,7 +52,6 @@ export default {
ts: true,
target: 'es2020',
}),
litSsrPlugin(),
],
browsers: [
playwrightLauncher({ product: 'chromium', concurrency }),
@@ -78,12 +76,10 @@ export default {
${componentImports.map(str => `"${str}"`).join(',\n')}
]
window.SSR_ONLY = ${process.env['SSR_ONLY'] === 'true'}
window.CSR_ONLY = ${process.env['CSR_ONLY'] === 'true'}
</script>
<script type="module">
;(async () => {
await import('@lit-labs/ssr-client/lit-element-hydrate-support.js')
await Promise.allSettled(window.clientComponents.map(str => import(str)));
})()
</script>