Compare commits

..

2 Commits

Author SHA1 Message Date
Cory LaViska
7fc267397f use closest 2025-03-21 12:46:00 -04:00
Cory LaViska
86787da4ea fix remove event and return null when empty 2025-03-20 16:42:02 -04:00
64 changed files with 1296 additions and 4694 deletions

View File

@@ -9,7 +9,6 @@ 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 { readFile } from 'fs/promises';
import nunjucks from 'nunjucks';
import componentList from './_data/componentList.js';
import * as filters from './_utils/filters.js';
import { outlinePlugin } from './_utils/outline.js';
@@ -41,11 +40,6 @@ const passThroughExtensions = ['js', 'css', 'png', 'svg', 'jpg', 'mp4'];
const passThrough = [...passThroughExtensions.map(ext => 'docs/**/*.' + ext)];
export default function (eleventyConfig) {
/**
* This is the guard we use for now to make sure our final built files dont need a 2nd pass by the server. This keeps us able to still deploy the bare HTML files on Vercel until the app is ready.
*/
const serverBuild = process.env.WEBAWESOME_SERVER === 'true';
// NOTE - alpha setting removes certain pages
if (isAlpha) {
eleventyConfig.ignores.add('**/experimental/**');
@@ -74,35 +68,9 @@ export default function (eleventyConfig) {
return `https://early.webawesome.com/webawesome@${packageData.version}/dist/` + (location || '').replace(/^\//, '');
});
// Turns `{% server "foo" %} into `{{ server.foo | safe }}` when the WEBAWESOME_SERVER variable is set to "true"
// Turns `{% server_variable "foo" %} into `{{ server.foo | safe }}`
eleventyConfig.addShortcode('server', function (property) {
if (serverBuild) {
return `{{ server.${property} | safe }}`;
}
return '';
});
eleventyConfig.addTransform('second-nunjucks-transform', function NunjucksTransform(content) {
// For a server build, we expect a server to run the second transform.
if (serverBuild) {
return content;
}
// Only run the transform on files nunjucks would transform.
if (!this.page.inputPath.match(/.(md|html|njk)$/)) {
return content;
}
/** This largely mimics what an app would do and just stubs out what we don't care about. */
return nunjucks.renderString(content, {
// Stub the server EJS shortcodes.
server: {
head: '',
loginOrAvatar: '',
flashes: '',
},
});
return `{{ server.${property} | safe }}`;
});
// Paired shortcodes - {% shortCode %}content{% endShortCode %}
@@ -164,6 +132,30 @@ export default function (eleventyConfig) {
]),
);
// SSR plugin
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,
});
}
// Build the search index
eleventyConfig.addPlugin(
searchPlugin({
@@ -190,31 +182,6 @@ 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,
});
}
return {
markdownTemplateEngine: 'njk',
dir: {

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,11 +1,8 @@
{% set ancestors = page.url | ancestors %}
{% if ancestors.length > 0 %}
{% set breadcrumbs = page.url | breadcrumbs %}
{% if breadcrumbs.length > 0 %}
<wa-breadcrumb id="docs-breadcrumbs">
{% for ancestor in ancestors %}
{% if ancestor.page.url != "/" %}
<wa-breadcrumb-item href="{{ ancestor.page.url }}">{{ ancestor.data.title }}</wa-breadcrumb-item>
{% endif %}
{% for crumb in breadcrumbs %}
<wa-breadcrumb-item href="{{ crumb.url }}">{{ crumb.title }}</wa-breadcrumb-item>
{% endfor %}
<wa-breadcrumb-item>{# Current page #}</wa-breadcrumb-item>
</wa-breadcrumb>

View File

@@ -12,7 +12,7 @@
</tr>
</thead>
{% for hue in hues -%}
<tr data-hue="{{ hue }}" v-if="'{{hue}}' in paletteScales">
<tr data-hue="{{ hue }}">
<th>{{ hue | capitalize }}</th>
{% for tint_bg in tints -%}
{% set color_bg = palettes[paletteId][hue][tint_bg] %}

View File

@@ -1,18 +1,12 @@
{# Cards for pages listed by category #}
<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 pages.meta.url %}<a href="{{ pages.meta.url }}">{{ pages.meta.title }}</a>
{% else %}
{{ pages.meta.title }}
{% endif %}
</h2>
{% endif %}
{%- for page in pages -%}
{% include "page-card.njk" %}
{%- endfor -%}
{% for category, pages in allPages | groupByTags(categories) -%}
<h2 class="index-category">{{ category | getCategoryTitle(categories) }}</h2>
{%- for page in pages -%}
{%- if not page.data.parent or listChildren -%}
{% include "page-card.njk" %}
{%- endif -%}
{%- endfor -%}
{%- endfor -%}
</section>

View File

@@ -2,7 +2,7 @@
<a href="{{ page.url }}"{{ page.data.keywords | attr('data-keywords') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
{% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" %}
</div>
<span class="page-name">{{ page.data.title }}</span>
{% if pageSubtitle -%}

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,12 +1,9 @@
{# Some collections (like "patterns") will not have any items in the alpha build for example. So this checks to make sure the collection exists. #}
{% if collections[tag] -%}
{% set groupUrl %}/docs/{{ tag }}/{% endset %}
{% set groupItem = groupUrl | getCollectionItemFromUrl %}
{% set children = groupItem.data.children if groupItem.data.children.length > 0 else (collections[tag] | sort) %}
<wa-details {{ ((tag in (tags or [])) or (groupUrl in page.url)) | attr('open') }}>
<h2 slot="summary">
{% if groupItem %}
{% if groupUrl | getCollectionItemFromUrl %}
<a href="{{ groupUrl }}" title="Overview">{{ title or (tag | capitalize) }}
<wa-icon name="grid-2"></wa-icon>
</a>
@@ -15,8 +12,10 @@
{% endif %}
</h2>
<ul>
{% for page in children %}
{% for page in collections[tag] | sort %}
{% if not page.data.parent -%}
{% include 'sidebar-link.njk' %}
{%- endif %}
{% endfor %}
</ul>
</wa-details>

View File

@@ -1,4 +1,4 @@
{% if not (isAlpha and page.data.noAlpha) and not page.data.unlisted -%}
{% if not (isAlpha and page.data.noAlpha) and page.fileSlug != tag and not page.data.unlisted -%}
<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

@@ -1,5 +1,6 @@
---
layout: page-outline
tags: ["overview"]
---
{% set forTag = forTag or (page.url | split('/') | last) %}
{% if description %}
@@ -12,10 +13,8 @@ layout: page-outline
</wa-input>
</div>
{% set allPages = allPages or collections[forTag] %}
{% if allPages and allPages.length > 0 %}
{% set allPages = collections[forTag] %}
{% include "grouped-pages.njk" %}
{% endif %}
<link href="/assets/styles/filter.css" rel="stylesheet">
<script type="module" src="/assets/scripts/filter.js"></script>

View File

@@ -1,57 +1,47 @@
{% set hasSidebar = true %}
{% set hasOutline = true %}
{% set paletteId = "default" if page.fileSlug == 'custom' else page.fileSlug %}
{% set isCustom = page.fileSlug == 'custom' %}
{% set paletteId = page.fileSlug %}
{% set tints = ["95", "90", "80", "70", "60", "50", "40", "30", "20", "10", "05"] %}
{% extends '../_includes/base.njk' %}
{% 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="{
seeded: isSeeded,
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" v-if="saved || !isCustom || step > 0">.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 || isCustom">PRO</wa-badge>
<wa-badge class="pro" v-if="tweaked">PRO</wa-badge>
{% endif %}
</div>
{% if description %}
@@ -59,28 +49,13 @@
{{ description | inlineMarkdown | safe }}
</p>
{% endif %}
{% raw %}
<div class="hue-wheel" v-if="!isCustom || step > 1">
<template v-for="color, hue in coreColors">
<template v-if="!isCustom || seedHues[hue]">
<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>{{ capitalize(hue) }} {{ coreLevels[hue] }}</wa-tooltip>
</template>
</template>
</div>
{% endraw %}
</header>
{% endblock %}
{% block afterContent %}
<wa-callout size="small" class="tweaked-callout" variant="warning" v-if="!isCustom">
{% 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">
@@ -93,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>
@@ -109,126 +87,128 @@
{%- endfor %}
</tr>
</thead>
{% raw %}
<tbody v-cloak>
<tr v-for="hue in paletteScalesList" :data-hue="hue" :key="hue"
class="color-scale" :class="{
tweaked: hue === 'gray' ? tweaked.grayChroma || tweaked.grayColor : hueShifts[hue],
}"
{# Initialize to last hue before gray #}
{%- set hueBefore = hues[hues|length - 2] -%}
{% for hue in hues -%}
{% set coreTint = palettes[paletteId][hue].maxChromaTint %}
{%- set coreColor = palettes[paletteId][hue][coreTint] -%}
{%- set maxChroma = coreColor.c if coreColor.c > maxChroma else maxChroma -%}
{% if hue === 'gray' %}
<tr data-hue="{{ hue }}" class="color-scale"
:class="{tweaking: tweaking.grayChroma, tweaked: tweaked.grayChroma || tweaked.grayColor }">
{% else %}
<tr data-hue="{{ hue }}" class="color-scale"
:class="{tweaking: tweaking.{{ hue }}, tweaked: hueShifts.{{ hue }} }"
:style="{ '--hue-shift': hueShifts.{{ hue }} || '' }">
{% endif %}
<th>
{{ hue | capitalize }}
</th>
<td class="core-column"
style="--color: var(--wa-color-{{ hue }})"
:style="{
'--color-tweaked': colors.{{ hue }}[{{ coreTint }}],
'--color-gray-undertone': colors[grayColor][{{coreTint}}],
'--color-tweaked-no-gray-chroma': colorsMinusGrayChroma.{{ hue }}[{{ coreTint }}],
}">
<wa-dropdown>
<div slot="trigger" id="core-{{ hue }}-swatch" data-tint="core" class="color swatch"
style="background-color: var(--wa-color-{{ hue }}); color: var(--wa-color-{{ hue }}-{{ '05' if palettes[paletteId][hue].maxChromaTint > 60 else '95' }});"
>
{{ palettes[paletteId][hue].maxChromaTint }}
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<div class="popup">
{% if hue === 'gray' %}
<wa-radio-group class="core-color" orientation="horizontal" v-model="grayColor">
{% for h in hues -%}
{%- if h !== 'gray' -%}
<wa-radio-button id="gray-undertone-{{ h }}" value="{{ h }}" label="{{ h | capitalize }}" style="--color: var(--wa-color-{{ h }})"></wa-radio-button>
<wa-tooltip for="gray-undertone-{{ h }}" hoist>
{{ h | capitalize }}
</wa-tooltip>
{%- endif -%}
{%- endfor -%}
<div slot="label">
Gray undertone
</div>
</wa-radio-group>
<div class="decorated-slider gray-chroma-slider" :style="{'--max': maxGrayChroma}">
<wa-slider name="gray-chroma" v-model="grayChroma" ref="grayChromaSlider"
value="0" min="0" :max="maxGrayChroma" step="0.01"
@input="tweaking.grayChroma = true" @change="tweaking.grayChroma = false">
<div slot="label">
Gray colorfulness
<wa-icon-button @click="grayChroma = originalGrayChroma" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
</div>
</wa-slider>
<div class="label-min">Neutral</div>
<div class="label-max" v-content="moreHue[grayColor]">Warmer/Cooler</div>
</div>
{% else %}
{%- set hueAfter = hues[loop.index0 + 1] -%}
{%- set hueAfter = hues[0] if hueAfter == 'gray' else hueAfter -%}
{%- set minShift = hueRanges[hue].min - coreColor.h | round -%}
{%- set maxShift = hueRanges[hue].max - coreColor.h | round -%}
<div class="decorated-slider hue-shift-slider" style="--min: {{ minShift }}; --max: {{ maxShift }};">
<wa-slider name="{{ hue }}-shift" v-model="hueShifts.{{ hue }}" value="0"
min="{{ minShift }}" max="{{ maxShift }}" step="1"
@input="tweaking.hue = tweaking.{{hue}} = true"
@change="tweaking.hue = tweaking.{{ hue }} = false">
<div slot="label">
Tweak {{ hue }} hue
<wa-icon-button @click="hueShifts.{{ hue }} = 0" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
</div>
</wa-slider>
<div class="label-min">More {{hueBefore}}</div>
<div class="label-max">More {{hueAfter}}</div>
</div>
{%- set hueBefore = hue -%}
{% endif %}
<div class="wa-gap-s">
<code>--wa-color-{{ hue }}</code>
<wa-copy-button value="--wa-color-{{ hue }}" copy-label="--wa-color-{{ hue }}"></wa-copy-button>
</div>
</div>`
</wa-dropdown>
</td>
{% for tint in tints -%}
{%- set color = palettes[paletteId][hue][tint] -%}
<td data-tint="{{ tint }}" style="--color: var(--wa-color-{{ hue }}-{{ tint }})"
:style="{
'--swatch-text-color': `light-dark(var(--wa-color-${ hue }-10), white)`,
'--hue-shift': hueShifts[hue] || ''
'--color-tweaked': colors.{{ hue }}[{{ tint }}],
'--color-tweaked-no-gray-chroma': colorsMinusGrayChroma.{{ hue }}[{{ tint }}],
}">
<th>
{{ capitalize(hue) }}
<info-tip v-if="isCustom && !seedHues[hue]">
<wa-icon name="sparkles" style="color: var(--wa-color-gray-50)"></wa-icon>
<template #content>Generated scale</template>
</info-tip>
</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]"
:pinned="!!seedColors[colorToIndex[hue].core]"
:deletable="isCustom" @delete="deleteColor(colorToIndex[hue].core)"
:pinnable="isCustom" @pin="addColor(coreColors[hue] + '')">
<div slot="trigger" :id="`core-${ hue }-swatch`" class="color swatch" :style="{colorScheme: coreLevels[hue] > 60 ? 'light' : 'dark'}">
<span v-content="coreLevels[hue]"></span>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<template #content>
<template v-if="hue === 'gray'">
<color-swatch-picker :model-value="computedGrayColor" @update:model-value="grayColor = $event" label="Gray undertone" :colors="coreColors"></color-swatch-picker>
</template>
<template v-else>
<color-slider v-if="isCustom && seedColors[colorToIndex[hue].core]"
coord="h" type="shift"
v-model:color="seedColors[colorToIndex[hue].core].color"
:default-value="seedColors[colorToIndex[hue].core].inputColor.oklch.h"
: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>
<color-slider v-if="!isCustom && baseCoreColors[hue]"
coord="h" type="shift"
v-model="hueShifts[hue]"
:default-color="baseCoreColors[hue]"
:min="HUE_RANGES[hue].min + 1" :max="HUE_RANGES[hue].max"
label="Adjust hue" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
></color-slider>
</template>
<color-slider v-if="hue === 'gray'" coord="c" type="scale"
:model-value="computedGrayChroma"
@update:model-value="grayChroma = $event"
:default-color="baseCoreColors[computedGrayColor]"
:base-value="baseCoreColors[originalGrayColor].oklch.c"
:default-value-relative="originalGrayChroma"
:min="0" :max-relative="maxGrayChroma" :step="0.00001"
label="Gray colorfulness" label-min="Neutral" :label-max="moreHue[computedGrayColor]"
></color-slider>
<color-slider v-else-if="isCustom" v-model:color="seedColors[colorToIndex[hue].core].color"
:default-value="seedColors[colorToIndex[hue].core].inputColor?.oklch.c"
coord="c"
:min="Math.max(coreColors.gray.oklch.c, ...Object.keys(seedHues[hue]).filter(t => t !== coreLevels[hue]).map(t => seedHues[hue][t].oklch.c))"
:max="getMaxChroma(colors[hue].core?.oklch.l, colors[hue].core?.oklch.h) - 0.001" :step="0.00001"
label="Adjust colorfulness" label-min="More muted" label-max="More vibrant"
label-default="Entered color"
format-type="scale"
></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]"
:pinned="!!seedColors[colorToIndex[hue][tint]]"
:deletable="isCustom" @delete="deleteColor(colorToIndex[hue][tint])"
:pinnable="isCustom" @pin="addColor({hue, pinnedHue: hue, level: tint})">
<div slot="trigger" class="color swatch" :style="{ colorScheme: tint > 60 ? 'light' : 'dark' }">
<wa-icon class="pinned-icon" name="thumbtack" variant="regular" v-if="seedColors[colorToIndex[hue][tint]]"></wa-icon>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<template #content v-if="isCustom && seedHues[hue] && (tint == '95' || tint == '05' || seedColors[colorToIndex[hue][tint]]) && tweakBase[hue][tint]">
<color-slider v-if="HUE_RANGES[hue]" v-model:color="colors[hue][tint]"
:default-value="colors[hue][tweakBase[hue][tint]].oklch.h"
@input="!seedColors[colorToIndex[hue][tint]] ? addColor({hue, pinnedHue: hue, level: tint}) : null"
@update:color="seedColors[colorToIndex[hue][tint]] ? seedColors[colorToIndex[hue][tint]].color = $event : null"
coord="h"
:min="HUE_RANGES[hue].mid - 70" :max="HUE_RANGES[hue].mid + 70" :step="1"
label="Hue shift" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
:label-default="`${capitalize(hue)} ${tweakBase[hue][tint]}`"
format-type="shift"
></color-slider>
<color-slider v-if="hue != 'gray'" v-model:color="colors[hue][tint]"
:default-value="colors[hue][tweakBase[hue][tint]].oklch.c"
@input="!seedColors[colorToIndex[hue][tint]] ? addColor({hue, pinnedHue: hue, level: tint}) : null"
@update:color="seedColors[colorToIndex[hue][tint]] ? seedColors[colorToIndex[hue][tint]].color = $event : null"
coord="c"
:min="coreColors.gray.oklch.c + 0.001"
:max="tint == coreLevels[hue] ? maxChroma(colors[hue][tweakBase[hue][tint]].oklch.l, colors[hue][tweakBase[hue][tint]].oklch.h) : coreColors[hue].oklch.c - 0.001" :step="0.001"
label="Colorfulness" label-min="More muted" label-max="More vibrant"
format-type="scale"
:label-default="`${capitalize(hue)} ${tweakBase[hue][tint]}`"
></color-slider>
</template>
</color-popup>
</td>
</tr>
{% endraw %}
</tbody>
<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 v-if="!isCustom" :class="{ tweaked: chromaScale !== 1 }"
type="scale"
v-model="chromaScale"
coord="c"
:default-color="baseMaxChromaColor"
:default-value="baseMaxChroma"
:min="MAX_CHROMA_BOUNDS.min" :max="MAX_CHROMA_BOUNDS.max" :step="0.01"
label="Overall colorfulness" label-min="More muted" label-max="More vibrant"
></color-slider>
{% 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>
{% if page.fileSlug != 'custom' %}
<h2>Used By</h2>
<section class="index-grid">
@@ -238,7 +218,6 @@
{%- endif -%}
{% endfor %}
</section>
{% endif %}
{% markdown %}
## Color Contrast
@@ -331,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 {{ 'custom palettes' if page.fileSlug == 'custom' else title + ' 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

@@ -68,7 +68,7 @@ wa_data.palettes = {
<wa-option label="{{ palette.data.title }}" value="{{ palette.fileSlug if not currentPalette }}" {{ (palette.fileSlug if currentPalette) | attr('data-id') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
{% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" %}
</div>
<span class="page-name">
{{ palette.data.title }}
@@ -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

@@ -29,9 +29,6 @@ function getCollection(name) {
}
export function getCollectionItemFromUrl(url, collection) {
if (!url) {
return null;
}
collection ??= getCollection.call(this, 'all') || [];
return collection.find(item => item.url === url);
}
@@ -45,33 +42,35 @@ export function split(text, separator) {
return (text + '').split(separator).filter(Boolean);
}
export function ancestors(url, { withCurrent = false, withRoot = false } = {}) {
let ret = [];
let currentUrl = url;
let currentItem = getCollectionItemFromUrl.call(this, url);
export function breadcrumbs(url, { withCurrent = false } = {}) {
const parts = split(url, '/');
const ret = [];
if (!currentItem) {
// Might have eleventyExcludeFromCollections, jump to parent
let parentUrl = this.ctx.parentUrl;
if (parentUrl) {
url = parentUrl;
while (parts.length) {
let partialUrl = '/' + parts.join('/') + '/';
let item = getCollectionItemFromUrl.call(this, partialUrl);
if (item && (partialUrl !== url || withCurrent)) {
let title = item.data.title;
if (title) {
ret.unshift({ url: partialUrl, title });
}
}
parts.pop();
if (item?.data.parent) {
let parentURL = item.data.parent;
if (!item.data.parent.startsWith('/')) {
// Parent is in the same directory
parts.push(item.data.parent);
parentURL = '/' + parts.join('/') + '/';
}
let parentBreadcrumbs = breadcrumbs.call(this, parentURL, { withCurrent: true });
return [...parentBreadcrumbs, ...ret];
}
}
for (let item; (item = getCollectionItemFromUrl.call(this, url)); url = item.data.parentUrl) {
ret.unshift(item);
}
if (!withRoot && ret[0]?.page.url === '/') {
// Remove root
ret.shift();
}
if (!withCurrent && ret.at(-1)?.page.url === currentUrl) {
// Remove current page
ret.pop();
}
return ret;
}
@@ -181,178 +180,69 @@ export function sort(arr, by = { 'data.order': 1, 'data.title': '' }) {
/**
* Group an 11ty collection (or any array of objects with a `data.tags` property) by certain tags.
* @param {object[]} collection
* @param { Object<string, string> | string[]} [options] Options object or array of tags to group by.
* @param {string[] | true} [options.tags] Tags to group by. If true, groups by all tags.
* If not provided/empty, defaults to grouping by page hierarchy, with any pages with more than 1 children becoming groups.
* @param {string[]} [options.groups] The groups to use if only a subset or a specific order is desired. Defaults to `options.tags`.
* @param {string[]} [options.titles] Any title overrides for groups.
* @param {string | false} [options.other="Other"] The title to use for the "Other" group. If `false`, the "Other" group is removed..
* @returns { Object.<string, object[]> } An object of group ids to arrays of page objects.
* @param { Object<string, string> | (string | Object<string, string>)[]} [tags] The tags to group by. If not provided/empty, defaults to grouping by all tags.
* @returns { Object.<string, object[]> } An object with keys for each tag, and an array of items for each tag.
*/
export function groupPages(collection, options = {}, page) {
export function groupByTags(collection, tags) {
if (!collection) {
console.error(`Empty collection passed to groupPages() to group by ${JSON.stringify(options)}`);
console.error(`Empty collection passed to groupByTags() to group by ${JSON.stringify(tags)}`);
}
if (!tags) {
// Default to grouping by union of all tags
tags = Array.from(new Set(collection.flatMap(item => item.data.tags)));
} else if (Array.isArray(tags)) {
// May contain objects of one-off tag -> label mappings
tags = tags.map(tag => (typeof tag === 'object' ? Object.keys(tag)[0] : tag));
} else if (typeof tags === 'object') {
// tags is an object of tags to labels, so we just want the keys
tags = Object.keys(tags);
}
if (Array.isArray(options)) {
options = { tags: options };
}
let { tags, groups, titles = {}, other = 'Other' } = options;
if (groups === undefined && Array.isArray(tags)) {
groups = tags;
}
let grouping;
if (tags) {
grouping = {
isGroup: item => undefined,
getCandidateGroups: item => item.data.tags,
getGroupMeta: group => ({}),
};
} else {
grouping = {
isGroup: item => (item.data.children.length >= 2 ? item.page.url : undefined),
getCandidateGroups: item => {
let parentUrl = item.data.parentUrl;
if (page?.url === parentUrl) {
return [];
}
return [parentUrl];
},
getGroupMeta: group => {
let item = byUrl[group] || getCollectionItemFromUrl.call(this, group);
return {
title: item?.data.title,
url: group,
item,
};
},
sortGroups: groups => sort(groups.map(url => byUrl[url]).filter(Boolean)).map(item => item.page.url),
};
}
let byUrl = {};
let byParentUrl = {};
let ret = Object.fromEntries(tags.map(tag => [tag, []]));
ret.other = [];
for (let item of collection) {
let url = item.page.url;
let parentUrl = item.data.parentUrl;
let categorized = false;
byUrl[url] = item;
if (parentUrl) {
byParentUrl[parentUrl] ??= [];
byParentUrl[parentUrl].push(item);
}
}
let urlToGroups = {};
for (let item of collection) {
let url = item.page.url;
let parentUrl = item.data.parentUrl;
if (grouping.isGroup(item)) {
continue;
}
let parentItem = byUrl[parentUrl];
if (parentItem && !grouping.isGroup(parentItem)) {
// Their parent is also here and is not a group
continue;
}
let candidateGroups = grouping.getCandidateGroups(item);
if (groups) {
candidateGroups = candidateGroups.filter(group => groups.includes(group));
}
urlToGroups[url] ??= [];
for (let group of candidateGroups) {
urlToGroups[url].push(group);
}
}
let ret = {};
for (let url in urlToGroups) {
let groups = urlToGroups[url];
let item = byUrl[url];
if (groups.length === 0) {
// Not filtered out but also not categorized
groups = ['other'];
}
for (let group of groups) {
ret[group] ??= [];
ret[group].push(item);
if (!ret[group].meta) {
if (group === 'other') {
ret[group].meta = { title: other };
} else {
ret[group].meta = grouping.getGroupMeta(group);
ret[group].meta.title = titles[group] ?? ret[group].meta.title ?? capitalize(group);
}
for (let tag of tags) {
if (item.data.tags.includes(tag)) {
ret[tag].push(item);
categorized = true;
}
}
}
if (other === false) {
delete ret.other;
}
// Sort
let sortedGroups = groups ?? grouping.sortGroups?.(Object.keys(ret));
if (sortedGroups) {
ret = sortObject(ret, sortedGroups);
}
Object.defineProperty(ret, 'meta', {
value: {
groupCount: Object.keys(ret).length,
},
enumerable: false,
});
return ret;
}
/**
* Sort an object by its keys
* @param {*} obj
* @param {function | string[]} order
*/
function sortObject(obj, order) {
let ret = {};
let sortedKeys = Array.isArray(order) ? order : Object.keys(obj).sort(order);
for (let key of sortedKeys) {
if (key in obj) {
ret[key] = obj[key];
if (!categorized) {
ret.other.push(item);
}
}
// Add any keys that weren't in the order
for (let key in obj) {
if (!(key in ret)) {
ret[key] = obj[key];
// Remove empty categories
for (let category in ret) {
if (ret[category].length === 0) {
delete ret[category];
}
}
return ret;
}
function capitalize(str) {
str += '';
return str.charAt(0).toUpperCase() + str.slice(1);
export function getCategoryTitle(category, categories) {
let title;
if (Array.isArray(categories)) {
// Find relevant entry
// [{id: "Title"}, id2, ...]
title = categories.find(entry => typeof entry === 'object' && entry?.[category])?.[category];
} else if (typeof categories === 'object') {
// {id: "Title", id2: "Title 2", ...}
title = categories[category];
}
if (title) {
return title;
}
// Capitalized
return category.charAt(0).toUpperCase() + category.slice(1);
}
const IDENTITY = x => x;

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 > -1; index = savedPalettes.findIndex(p => p.uid === palette.uid)) {
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

@@ -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

@@ -440,14 +440,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 +641,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

@@ -2,10 +2,13 @@
title: Components
description: Components are the essential building blocks to create intuitive, cohesive experiences. Browse the library of customizable, framework-friendly web components included in Web Awesome.
layout: overview
override:tags: []
categories:
tags: [actions, feedback, imagery, inputs, navigation, organization, helpers]
titles:
feedback: 'Feedback & Status'
helpers: 'Utilities'
- actions
- feedback: 'Feedback & Status'
- imagery
- inputs
- navigation
- organization
- helpers: 'Utilities'
override:tags: []
---

View File

@@ -1,80 +1,10 @@
/**
* Global data for all pages
*/
import { sort } from '../_utils/filters.js';
export default {
eleventyComputed: {
// Default parent. Can be overridden by explicitly setting parent in the data.
// parent can refer to either an ancestor page in the URL or another page in the same directory
parent(data) {
let { parent, page } = data;
if (parent) {
return parent;
}
return page.url.split('/').filter(Boolean).at(-2);
},
parentUrl(data) {
let { parent, page } = data;
return getParentUrl(page.url, parent);
},
parentItem(data) {
let { parentUrl } = data;
return data.collections.all.find(item => item.url === parentUrl);
},
children(data) {
let { collections, page, parentOf } = data;
let mainTag = data.tags?.[0];
let collection = data.collections[mainTag] ?? [];
if (parentOf) {
return collections[parentOf];
}
let collection = collections.all ?? [];
let url = page.url;
let ret = collection.filter(item => {
return item.data.parentUrl === url;
});
sort(ret);
return ret;
return collection.filter(item => item.data.parent === data.page.fileSlug);
},
},
};
function getParentUrl(url, parent) {
let parts = url.split('/').filter(Boolean);
let ancestorIndex = parts.findLastIndex(part => part === parent);
let retParts = parts.slice();
if (ancestorIndex > -1) {
// parent is an ancestor
retParts.splice(ancestorIndex + 1);
} else {
// parent is a sibling in the same directory
retParts.splice(-1, 1, parent);
}
let ret = retParts.join('/');
if (url.startsWith('/')) {
ret = '/' + ret;
}
if (!retParts.at(-1).includes('.') && !ret.endsWith('/')) {
// If no extension, make sure to end with a slash
ret += '/';
}
if (ret === '/docs/') {
ret = '/';
}
return ret;
}

View File

@@ -2,7 +2,6 @@
title: Layout
description: Layout components and utility classes help you organize content that can adapt to any device or screen size. See the [installation instructions](#installation) to use Web Awesome's layout tools in your project.
layout: overview
parentOf: layout
categories: ["components", "utilities"]
override:tags: []
---
@@ -23,4 +22,4 @@ Or, you can choose to import _only_ the utilities:
```html
<link rel="stylesheet" href="{% cdnUrl 'styles/utilities.css' %}" />
```
{% endmarkdown %}
{% endmarkdown %}

View File

@@ -1,28 +0,0 @@
import { tints } from '/assets/scripts/tweak/data.js';
export function generateGrays(colors, { grayColor, grayChroma }) {
let ret = {};
let undertoneScale = colors[grayColor];
// These will be the same, since scaling them won't change the relationship
ret.maxChromaTint = undertoneScale.maxChromaTint;
Object.defineProperty(ret, 'core', {
enumerable: false,
get() {
return this[this.maxChromaTint];
},
});
ret.maxChromaTintRaw = undertoneScale.maxChromaTintRaw;
for (let tint of tints) {
let colorUndertone = undertoneScale[tint].clone().to('oklch');
ret[tint] = colorUndertone.set({ c: c => c * grayChroma });
}
ret.maxChroma = ret[ret.maxChromaTint].get('oklch.c');
ret.maxChromaRaw = ret[ret.maxChromaTintRaw].get('oklch.c');
return ret;
}
export default generateGrays;

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,83 +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, slug = base, colors, tweaked, roles, ...options }) {
let imports = [];
if (base && options.imports !== false && !tweaked.seedColors) {
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 (!tweaked.seedColors) {
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 (roles) {
for (let role in roles) {
let hue = roles[role];
if (!hue) {
continue;
}
for (let suffix of [...tints.map(t => '-' + t), '', '-key']) {
declarations.push(`--${prefix}-${role}${suffix}: var(--${prefix}-${hue}${suffix});`);
}
declarations.push('');
}
}
if (declarations.length > 0) {
let selector = options.selector ?? selectors.palette(slug);
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,74 +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;
ret.gray = generateGrays(baseColors, { grayColor, grayChroma });
} else {
ret.gray = originalScale;
}
continue;
}
for (let tint of tints) {
scale[tint] = tweakColor(hue, originalScale[tint], tweaks, tweaked);
}
}
return ret;
}
export function tweakColor(hue, originalColor, tweaks, tweaked) {
if (!tweaked) {
return originalColor;
}
let color = originalColor;
let { hueShifts, chromaScale = 1, grayColor, grayChroma } = tweaks;
let tweak = {};
let thisTweaked = false;
if (tweaked.hue && hueShifts[hue]) {
tweak.h = h => h + hueShifts[hue];
thisTweaked = true;
}
if (tweaked.chromaScale && chromaScale !== 1) {
tweak.c = c => c * chromaScale;
thisTweaked = true;
}
if (thisTweaked) {
color = color.clone().to('oklch').set(tweak);
}
return color;
}
export default tweakPalette;

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,187 +0,0 @@
/* CSS for custom palettes only */
#seed-colors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22ch, 1fr));
gap: var(--wa-space-m);
> .add-button {
flex-flow: column wrap;
height: auto;
min-height: 15ch;
border: var(--wa-panel-border-width) var(--wa-panel-border-style) var(--wa-color-surface-border);
--border-color: var(--wa-color-surface-border);
border-radius: var(--wa-panel-border-radius);
background-color: var(--wa-color-surface-default);
box-shadow: var(--wa-shadow-s);
wa-icon {
font-size: 200%;
margin: 0;
margin-top: 0.35em;
}
}
> wa-card {
--spacing: var(--wa-space-s);
[slot='image'] {
position: relative;
height: 5.5rem;
width: 100%;
border-start-start-radius: var(--inner-border-radius);
border-start-end-radius: var(--inner-border-radius);
background-color: var(--color);
color: canvastext;
.tweak-icon {
position: absolute;
top: var(--wa-space-s);
right: var(--wa-space-s);
--background-color-hover: oklab(from currentColor l a b / 15%);
--text-color-hover: currentColor;
&:not(:hover, :focus, :has(+ :focus-within)) {
opacity: 50%;
}
&:is(.tweaked *) {
&::part(base) {
transition: var(--wa-transition-normal);
transition-property: padding, border, opacity;
background-color: var(--color-original);
padding: var(--wa-space-s);
border: 1px solid hsl(0 0 100 / 60%);
}
}
}
.name {
display: flex;
gap: var(--wa-space-xs);
position: absolute;
bottom: var(--wa-space-xs);
left: var(--wa-space-s);
font-weight: var(--wa-font-weight-semibold);
wa-dropdown.pin-hue {
wa-button {
--outlined-border-color: oklab(from currentColor l a b / 10%);
--outlined-background-color-hover: transparent;
--border-width: 1.5px;
--text-color: currentColor;
--wa-space: var(--wa-space-xs);
--wa-space-smaller: var(--wa-space-2xs);
}
&.pin-hue.pinned {
wa-button {
--outlined-border-color: oklab(from currentColor l a b / 40%);
font-weight: var(--wa-font-weight-bold);
}
}
wa-icon[name='thumbtack'] {
opacity: 60%;
}
}
.level {
font-weight: var(--wa-font-weight-bold);
}
}
}
wa-input {
margin-top: var(--wa-space-xs);
}
wa-icon-button {
color: light-dark(black, white);
transition: opacity var(--wa-transition-slow);
--background-color-hover: oklab(from currentColor l a b / 15%);
--text-color-hover: currentColor;
}
}
.color-to-role {
--border-width: 0;
margin-inline-start: calc(-1 * var(--wa-space-3xs));
&::part(tags) {
margin-inline-start: 0;
}
&::part(combobox) {
padding: var(--wa-space-3xs);
min-height: auto;
}
}
}
wa-icon-button.delete-button {
position: absolute;
top: var(--wa-space-s);
right: var(--wa-space-s);
--text-color-hover: var(--wa-color-danger-on-normal);
}
.pinned-icon {
opacity: 70%;
}
#suggested-colors {
margin-top: var(--wa-space-2xl);
h3 {
margin-bottom: 0;
}
&::part(content) {
padding-block-start: 0;
}
p.wa-caption-m {
margin-block: var(--wa-space-xs) var(--wa-space-m);
text-wrap: pretty;
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: var(--wa-space-s);
wa-button {
/* --background-color-hover: var(--background-color); */
height: var(--wa-form-control-height);
aspect-ratio: 1.2;
wa-icon {
transition: var(--wa-transition-normal);
}
&:not(:focus, :hover) wa-icon {
opacity: 0;
}
}
}
}
#roles {
margin-block: var(--wa-space-2xl);
> div {
display: flex;
flex-wrap: wrap;
gap: var(--wa-space-m);
> wa-select {
flex: 1;
max-width: 20ch;
}
}
}
.seed-color-tweak .popup {
min-width: clamp(0ch, 50ch, 90vw);
}

View File

@@ -1,390 +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%);
}
.color.swatch: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);
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);
}
}
[data-slug='custom'] > :not(.seeded) #seed-colors ~ :not(#saved),
#outline:has(+ main > [data-slug='custom'] > :not(.seeded)) li:nth-child(n + 2) {
display: none;
}
[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-radius: 50%;
--lc: var(--avg-l) var(--max-c);
--lc2: var(--avg-l) calc(var(--max-c) / 2);
margin-top: calc(var(--r) * -0.05);
background: conic-gradient(
in oklch,
oklch(var(--lc) 0),
oklch(var(--lc) 60),
oklch(var(--lc) 120),
oklch(var(--lc) 180),
oklch(var(--lc) 240),
oklch(var(--lc) 300),
oklch(var(--lc) 360)
);
&,
&::before {
--stops: oklch(var(--lc) 0), oklch(var(--lc) 60), oklch(var(--lc) 120), oklch(var(--lc) 180), oklch(var(--lc) 240),
oklch(var(--lc) 300), oklch(var(--lc) 360);
}
&::before {
content: '';
display: block;
height: 100%;
border-radius: 50%;
-webkit-mask: radial-gradient(white, transparent);
background: radial-gradient(oklch(var(--avg-l) calc(var(--gray-chroma) * var(--max-c)) 0) 5%, transparent 30%),
conic-gradient(
in oklch,
oklch(var(--lc2) 0),
oklch(var(--lc2) 60),
oklch(var(--lc2) 120),
oklch(var(--lc2) 180),
oklch(var(--lc2) 240),
oklch(var(--lc2) 300),
oklch(var(--lc2) 360)
);
}
.color {
--scale-c: calc(var(--c) / var(--max-c));
--distance: calc(var(--r) * var(--scale-c));
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(calc(var(--h) * 1deg - 90deg)) translateX(var(--distance));
position: absolute;
z-index: 1;
width: calc(1.2em + 0.3em * var(--scale-c));
aspect-ratio: 1;
&:hover {
--scale: 1.2;
--line-color: white;
--line-style: solid;
}
&::before {
content: '';
position: absolute;
z-index: -1;
width: 100%;
height: 0;
border-top: 2px var(--line-style, dashed) var(--line-color, var(--wa-color-gray-80));
padding-top: 100%;
top: calc(50% - 1px);
right: 50%;
width: var(--distance);
}
&::after {
content: '';
display: block;
position: relative;
height: 100%;
border-radius: 50%;
border: 2px solid white;
box-shadow: var(--wa-shadow-l);
background: var(--color);
transition: var(--wa-transition-fast);
scale: var(--scale, 1);
}
}
wa-tooltip {
/* Prevent flickering */
pointer-events: none;
}
}
.scale-filter {
wa-tab wa-icon {
margin-right: 0.4em;
}
}
.title wa-icon-button[name='pencil'] {
margin-inline-start: var(--wa-space-xs);
}
.seeded {
wa-badge.status {
display: none;
}
wa-badge.pro {
filter: grayscale(0.95);
}
}
.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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,357 +0,0 @@
const template = `
<wa-card size="small" class="color" :class="{tweaked}"
:style="{'--color': value, '--color-original': inputValue}">
<div slot="image" :style="{ colorScheme: level <= 60 ? 'dark' : 'light'}">
<color-popup placement="top-start" class="seed-color-tweak" :pinned=true deletable @delete="$emit('delete')" title="Edit color">
<wa-icon-button name="sliders-simple" class="tweak-icon"></wa-icon-button>
<template #content>
<color-slider label="Hue" label-default="Entered color"
coord="h" :min="0" :max="359" :step="1"
v-model:color="color" :default-value="inputLCH[2]" ></color-slider>
<color-slider label="Colorfulness" label-default="Entered color"
coord="c" :min="0" :max="maxChroma" :step="0.001"
v-model:color="color" :default-value="inputLCH[1]" format-type="scale" :format-base-value="maxChroma" ></color-slider>
<color-slider label="Lightness" label-default="Entered color"
coord="l" :min="0" :max="1" :step="0.01"
v-model:color="color" :default-value="inputLCH[0]" format-type="scale" :format-base-value="1" ></color-slider>
</template>
</color-popup>
<div class="name">
<wa-dropdown class="pin-hue" :class="{pinned: pinnedHue}">
<wa-button slot="trigger" appearance="outlined" caret>
<wa-icon name="thumbtack" v-if="pinnedHue" variant="solid" slot="prefix"></wa-icon>
{{ capitalize(hue) || 'New color' }}
</wa-button>
<wa-menu @wa-select="pinnedHue = $event.detail.item.value">
<wa-menu-item type="checkbox" :checked="pinnedHue ? null : ''">Automatic <em>({{ capitalize(detectedColorInfo.hue) }})</em></wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-label>Pin to…</wa-menu-label>
<wa-menu-item v-for="hue in allHues" type="checkbox" :value="hue" :checked="pinnedHue === hue ? '' : null">{{ capitalize(hue) }}</wa-menu-item>
</wa-menu>
</wa-dropdown>
<span class="level">{{ level }}</span>
</div>
</div>
<wa-select class="color-to-role" multiple appearance="plain" placeholder="(No states)" max-options-visible="2"
ref="roles" :value.attr="Object.keys(roles).join(' ')" :value="Object.keys(roles)"
:getTag="getTag"
@input="$emit('update:roles', $event.target.value)">
<wa-option v-for="role in ROLES" :value="role" :class="{'default': !roles[role]}">{{ capitalize(role) }}</wa-option>
</wa-select>
<wa-input :value="valueRaw" @input="handleInput" @focus="inputFocused = true" @blur="inputFocused = false" ref="input"></wa-input>
</wa-card>
`;
import Color from 'https://colorjs.io/dist/color.js';
// import { nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { nextTick } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';
import getMaxChroma from '../color/get-max-chroma.js';
import { identifyColor } from '../color/util.js';
import ColorPopup from './color-popup.js';
import ColorSlider from './color-slider.js';
import InfoTip from './info-tip.js';
import { ROLES, allHues } from '/assets/scripts/tweak/data.js';
import { capitalize } from '/assets/scripts/tweak/util.js';
await customElements.whenDefined('wa-select');
let maxUid = 0;
const expose = [
'valueRaw',
'value',
'inputValueRaw',
'inputValue',
'colorRaw',
'color',
'inputColorRaw',
'inputColor',
'hue',
'pinnedHue',
'level',
'tweaked',
];
export default {
expose,
props: {
modelValue: {
type: Object,
default(rawProps) {
return { value: '' };
},
},
otherColors: {
type: Array,
},
roles: {
type: Object,
default: {},
},
},
emits: ['update:modelValue', 'update:roles', 'delete'],
data() {
let uid = this.modelValue.uid ?? maxUid++;
if (this.modelValue.uid) {
maxUid = Math.max(maxUid, uid);
}
this.modelValue.uid = uid;
let valueRaw = this.modelValue.value;
let inputValueRaw = this.modelValue.inputValue ?? valueRaw;
let color = tryColor(this.modelValue.value);
let inputColor = tryColor(inputValueRaw);
return {
uid,
initialProps: { ...this.modelValue },
valueRaw,
value: color ? valueRaw : undefined,
color,
inputValueRaw,
inputValue: inputColor ? inputValueRaw : undefined,
inputColor,
pinnedHue: this.modelValue.pinnedHue,
editing: 0,
inputFocused: false,
watching: {},
};
},
created() {
// Non-reactive variables to expose
Object.assign(this, { ROLES, allHues });
},
async mounted() {
if (this.modelValue.editImmediately) {
let input = this.$refs.input;
await input.updateComplete;
input.focus();
input.select();
}
},
computed: {
inputLCH() {
return this.inputColor?.oklch;
},
currentLCH() {
return this.color?.oklch;
},
tweaked() {
if (this.inputFocused || this.editing > 0 || !this.inputLCH || !this.currentLCH) {
return false;
}
return this.inputLCH.some((coord, i) => coord !== this.currentLCH[i]);
},
computedValue() {
let ret = {};
for (let property of expose) {
ret[property] = this[property];
}
return ret;
},
colorRaw() {
let ret = tryColor(this.modelValue.valueRaw);
if (ret) {
this.value = this.modelValue.valueRaw;
}
return ret;
},
colorInfo() {
let ret = { ...this.detectedColorInfo };
if (this.pinnedHue) {
ret.hue = this.pinnedHue;
}
return ret;
},
detectedColorInfo() {
if (!this.color) {
return { hue: undefined, level: undefined };
}
return identifyColor(this.color, this.otherColors);
},
hue() {
return this.colorInfo.hue;
},
level() {
return this.colorInfo.level;
},
stringifiedColor() {
// return stringifyColor(this.colorRaw);
return this.color + '';
},
inputColorRaw() {
let ret = tryColor(this.inputValueRaw);
if (ret) {
this.inputValue = this.inputValueRaw;
}
return ret;
},
maxChroma() {
if (!this.color) {
return 0.4;
}
return getMaxChroma(this.color.oklch.l, this.color.oklch.h);
},
},
methods: {
capitalize,
handleInput(event) {
this.editing++;
let value = event.target.value;
// Editing the input manually also incorporates any tweaks as part of the color itself
// I.e. input color and color are now the same
this.valueRaw = this.inputValueRaw = value;
nextTick().then(() => {
if (this.colorRaw) {
this.color = this.colorRaw;
this.$refs.input.setCustomValidity('');
} else {
this.$refs.input.setCustomValidity('Invalid color');
this.$refs.input.reportValidity();
}
this.editing--;
});
},
mutateModelValue(mutator) {
if (this.watching.modelValue === null) {
// If we're not watching modelValue, it means we're reacting to a change to it
// so no point in updating it again
return;
}
if (this.watching.modelValue) {
this.watching.modelValue();
this.watching.modelValue = null;
}
mutator();
this.watching.modelValue = this.$watch('modelValue', {
deep: true,
handler() {
let computedValue = this.computedValue;
// What changed?
if (this.modelValue.value !== computedValue.value) {
this.valueRaw = this.modelValue.value;
}
if (this.modelValue.color + '' !== computedValue.color + '') {
this.color = this.modelValue.color;
}
},
});
},
getTag(option) {
let isDefault = option.classList.contains('default');
let tag = Object.assign(document.createElement('wa-tag'), {
part: `tag${isDefault ? ' default' : ''}`,
exportparts: `
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base`,
size: 'small',
removable: !isDefault,
'data-value': option.value,
id: 'tag-' + option.value,
innerHTML: option.label + ` <wa-tooltip hoist for="tag-${option.value}">Default role</wa-tooltip>`,
});
return tag;
},
},
watch: {
/** colorRaw -> color */
colorRaw: {
deep: true,
handler() {
if (this.colorRaw) {
this.color = this.colorRaw;
}
},
},
/** inputColorRaw -> inputColor */
inputColorRaw: {
deep: true,
handler() {
if (this.inputColorRaw) {
this.inputColor = this.inputColorRaw;
}
},
},
/** color -> value, valueRaw, modelValue.value */
color: {
deep: true,
handler() {
if (this.tweaked && this.color) {
// If tweaked, color is the source of truth
this.value = this.valueRaw = this.color + '';
}
},
},
/** computedValue -> modelValue */
computedValue: {
deep: true,
immediate: true,
handler() {
this.mutateModelValue(() => {
Object.assign(this.modelValue, this.computedValue);
this.$emit('update:modelValue', this.modelValue);
});
},
},
},
template,
components: { InfoTip, ColorSlider, ColorPopup },
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function tryColor(value) {
if (!value) {
return null;
}
if (value instanceof Color) {
return value;
}
try {
return new Color(value);
} catch (e) {
return null;
}
}

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: 1,
},
type: {
type: String,
default: 'raw',
},
formatType: {
type: String,
},
label: String,
labelMin: String,
labelMax: String,
labelDefault: String,
},
emits: ['update:modelValue', 'update:color', 'input'],
data() {
return {
mounted: promise(),
initialColor: this.color,
value: undefined,
tweaking: false,
};
},
created() {
if (!this.color && !this.defaultColor) {
console.warn(
`[${this.label}]`,
'<color-slider> requires at least one of the following props: color, defaultColor',
);
}
if (this.modelValue !== undefined) {
this.value = this.getAbsoluteValue(this.modelValue);
} else if (this.color) {
this.value = this.colorCoords[this.coordIndex];
}
},
mounted() {
if (this.$refs.slider) {
this.$refs.slider.tooltipFormatter = value => this.formatValue(value);
this.$refs.slider.colorSliderData = this; // for debugging
}
this.mounted.resolve();
},
beforeUnmount() {
delete this.$refs.slider?.colorSliderData;
},
computed: {
computedMin() {
if (this.minRelative !== undefined) {
return getAbsoluteValue(this.minRelative);
}
return this.min;
},
computedMax() {
if (this.maxRelative !== undefined) {
return this.getAbsoluteValue(this.maxRelative);
}
return this.max;
},
computedColor() {
return this.getColorAt(this.value);
},
computedColorCoords() {
return this.computedColor.oklch.slice();
},
colorCoords() {
let color = this.color ?? this.computedColor;
return color?.oklch.slice();
},
computedColorString() {
return `oklch(${this.computedColorCoords.join(' ')})`;
},
colorString() {
return `oklch(${this.colorCoords.join(' ')})`;
},
defaultCoords() {
if (this.defaultColor) {
return this.defaultColor.oklch.slice();
}
let ret = this.color.oklch.slice();
if (this.defaultValue !== undefined) {
ret[this.coordIndex] = this.defaultValue;
}
return ret;
},
coordIndex() {
return ['l', 'c', 'h'].indexOf(this.coord);
},
colorMin() {
return this.getColorAt(this.computedMin);
},
colorMax() {
return this.getColorAt(this.computedMax);
},
isRelative() {
return this.type && this.type !== 'raw';
},
computedBaseValue() {
if (!this.isRelative) {
return;
}
if (this.baseValue !== undefined) {
return this.baseValue;
}
return this.computedDefaultValue;
},
computedDefaultValue() {
if (this.defaultValue !== undefined) {
return this.defaultValue;
}
if (this.defaultValueRelative !== undefined) {
return this.getAbsoluteValue(this.defaultValueRelative);
}
if (this.baseValue !== undefined) {
return this.baseValue;
}
return this.defaultCoords[this.coordIndex];
},
computedDefaultColor() {
return this.defaultColor ?? this.getColorAt(this.computedDefaultValue);
},
computedLabelDefault() {
let labelDefault = this.labelDefault || 'Default value';
let formattedDefaultValue = this.formatValue(this.computedDefaultValue);
return `${labelDefault} (${formattedDefaultValue})`;
},
defaultProgress() {
return (this.computedDefaultValue - this.computedMin) / (this.computedMax - this.computedMin);
},
relativeValue() {
this.computedBaseValue;
return this.getRelativeValue(this.value);
},
},
methods: {
capitalize,
getAbsoluteValue(relativeValue) {
return getAbsoluteValue({
type: this.type,
relativeValue,
baseValue: this.baseValue ?? this.computedBaseValue,
});
},
getRelativeValue(absoluteValue) {
return getRelativeValue({
type: this.type,
absoluteValue,
baseValue: this.baseValue ?? this.computedBaseValue,
});
},
formatValue(value = this.value) {
let formatType = this.formatType ?? this.type;
let style = formatType === 'scale' ? 'percent' : undefined;
if (formatType && formatType !== 'raw') {
let baseValue = this.formatBaseValue ?? this.computedBaseValue;
value = getRelativeValue({ type: formatType, absoluteValue: value, baseValue });
}
value = roundTo(value, this.step);
return value.toLocaleString(undefined, { style });
},
getColorAt(value) {
let coords = this.defaultCoords.slice();
coords[this.coordIndex] = value;
return new Color('oklch', coords);
},
/** Called when value changes due to user interaction */
handleInput(value) {
this.value = value;
this.tweaking = true;
this.$emit('input', value);
},
inputEnd() {
this.tweaking = false;
},
reset() {
this.handleInput(this.computedDefaultValue);
this.inputEnd();
},
},
watch: {
computedColorString() {
if (this.color && this.colorString !== this.computedColorString) {
// Color changed, communicate to the outside world
this.$emit('update:color', this.computedColor);
}
},
colorString() {
if (this.color && this.colorString !== this.computedColorString) {
// Color changed in the outside world, update our internals
if (this.colorCoords[this.coordIndex] !== this.value) {
this.value = this.colorCoords[this.coordIndex];
let modelValue = this.getRelativeValue(this.value);
this.$emit('update:modelValue', modelValue);
}
}
},
relativeValue() {
this.$emit('update:modelValue', this.relativeValue);
},
},
template,
components: { InfoTip },
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function getAbsoluteValue({ type, relativeValue, baseValue }) {
if (baseValue === undefined) {
type = 'raw';
}
if (type === 'shift') {
return relativeValue + baseValue;
}
if (type === 'scale') {
return relativeValue * baseValue;
}
return relativeValue;
}
function getRelativeValue({ type, absoluteValue, baseValue }) {
if (baseValue === undefined) {
type = 'raw';
}
if (type === 'shift') {
return absoluteValue - baseValue;
}
if (type === 'scale') {
if (!absoluteValue) {
return 0;
}
return absoluteValue / baseValue;
}
return absoluteValue;
}

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,85 +0,0 @@
const template = `
<color-popup :title :token="token" :color="modelValue"
:pinned :pinnable @pin="$emit('pin')" :deletable @delete="$emit('delete')">
<div slot="trigger" class="color swatch" :style="{ '--color': modelValue, colorScheme: level > 60 ? 'light' : 'dark' }">
<wa-icon class="pinned-icon" name="thumbtack" variant="regular" v-if="pinned"></wa-icon>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<template #content>
<color-slider v-if="(isEdge || pinned) && tweakBase && HUE_RANGES[hue]"
:color="modelValue" @update:model-value="$emit('update:modelValue', $event)" :default-value="colors[hue][tweakBase].oklch.h"
@input="!pinned ? $emit('pin') : null"
coord="h" :min="HUE_RANGES[hue].min + 1" :max="HUE_RANGES[hue].max" :step="1"
label="Hue shift" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
:label-default="\`\${capitalize(hue)} \${tweakBase}\`"
></color-slider>
</template>
</color-popup>
`;
import Color from 'https://colorjs.io/dist/color.js';
import ColorPopup from './color-popup.js';
import ColorSlider from './color-slider.js';
import InfoTip from './info-tip.js';
import { HUE_RANGES, hueAfter, hueBefore, hues, moreHue } from '/assets/scripts/tweak/data.js';
import { capitalize, clamp, promise, roundTo } from '/assets/scripts/tweak/util.js';
export default {
props: {
modelValue: Color,
hue: {
type: String,
required: true,
},
level: {
type: [String, Number],
required: true,
},
coreLevel: {
type: [String, Number],
required: true,
},
pinned: Boolean,
pinnable: Boolean,
deletable: Boolean,
colors: {
type: Object,
required: true,
},
tweakBase: [String, Number],
},
emits: ['update:modelValue', 'pin', 'delete'],
data() {
return {};
},
created() {
// Attach non-reactive data
Object.assign(this, { moreHue, HUE_RANGES });
},
computed: {
title() {
return capitalize(this.hue) + ' ' + this.level;
},
hueBefore() {
return hueBefore[this.hue];
},
hueAfter() {
return hueAfter[this.hue];
},
token() {
return `--wa-color-${this.hue}-${this.level}`;
},
isEdge() {
return this.level == '95' || this.level == '05';
},
isCore() {
return this.level == this.coreLevel;
},
},
methods: { capitalize },
template,
components: { InfoTip, ColorSlider, ColorPopup },
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

@@ -1,71 +0,0 @@
---
title: Custom
isPro: true
override:tags: [palettes, pro]
order: 99
description: Create your own color palette from scratch, from one or more seed colors.
status: experimental
---
<link href="{{ page.url }}../app/custom.css" rel="stylesheet">
<h2 v-if="step > 0" v-cloak>My Colors</h2>
<p v-if="step > 0" v-cloak>
Just add your colors, in any order. Well sort them out for you, generate tints, and suggest additional colors.
</p>
<div id="seed-colors">
<template v-for="color, i in seedColors" :key="color.uid ?? maxSeedUid">
<color-input v-model="seedColors[i]"
:other-colors="seedColors.filter((_, j) => j !== i).map(c => c.color)"
:roles="seedColorRoles[i]"
@update:roles="roles => setColorRole(i, roles)"
@delete="deleteColor(i)"></color-input>
</template>
<wa-button class="add-button" appearance="outlined" @click="addColor(undefined, {editImmediately: true})">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
<span v-content="step > 0 ? 'Add color' : 'New palette'">New palette</span>
</wa-button>
</div>
<wa-details id="suggested-colors" v-if="step > 0" v-cloak open>
<h3 class="wa-heading-m" slot="summary">Suggestions</h3>
<p class="wa-caption-m">
Generated by our fancy-schmancy algorithm to complement your colors.
See a color you like? Grab it before its gone!
</p>
<div class="suggestions wa-cluster wa-align-items-start wa-gap-s">
<template v-for="color, hue in suggestedColors">
<info-tip>
<wa-button :style="{'--background-color': color}" @click="addColor({hue})">
<wa-icon name="plus"></wa-icon>
</wa-button>
<template #content>{% raw %}{{ capitalize(hue) }}{% endraw %}</template>
</info-tip>
</template>
</div>
</wa-details>
<section id="roles" v-if="step > 0" v-cloak>
<h2>Roles</h2>
<div>
<color-select v-for="computedRole, role in computedRoles"
:model-value="computedRoles[role]"
@update:model-value="value => setRoleColor(role, value)"
:class="{'default': !roles[role]}"
:label="capitalize(role) + ':'"
:groups="{
Dynamic: !['brand', 'neutral'].includes(role) ? ['brand', 'neutral'] : undefined,
Colors: Object.keys(paletteScales),
Common: suggestedForRole[role]
}"
:get-label="capitalize"
:get-content="value => capitalize(value) + (seedHues[value] || computedRoles[value] || value === 'gray' ? '' : ' <wa-icon name=square-plus variant=regular></wa-icon>')"
:get-color="value => coreColors[computedRoles[value] ?? value]">
{# <wa-badge class="default-badge" v-if="!roles[role]" slot="suffix" variant="neutral" appearance="outlined">Default</wa-badge> #}
</color-select>
</div>
</section>

View File

@@ -5,6 +5,6 @@ layout: overview
override:tags: []
forTag: palette
categories:
tags: [other, pro]
other: Free
pro: Pro
---

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

@@ -2,5 +2,7 @@
title: Patterns
description: Patterns are reusable solutions to common design problems.
layout: overview
categories: ["e-commerce"]
listChildren: true
override:tags: []
---

View File

@@ -31,7 +31,8 @@ If you're customizing the default dark styles, scope your styles to the followin
```css
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/* your custom styles here */
}
```

View File

@@ -1,13 +1,13 @@
---
title: Themes
description: Themes are collections of design tokens that thread through every Web Awesome component and pattern.
description: Themes are collections of design tokens that thread through every Web Awesome component and pattern.
Themes play a crucial role in [customizing Web Awesome](/docs/customizing).
layout: overview
override:tags: []
forTag: theme
categories:
tags: [other, pro]
other: Free
pro: Pro
---
<div class="max-line-length">
@@ -30,7 +30,7 @@ In pre-made themes, we use a light color scheme by default.
Additionally, styles may be scoped to the `:root` selector to be activated automatically.
For pre-made themes, *all* custom properties are scoped to `:root`, the theme class, and `wa-light`.
Finally, we scope themes to `:host` to ensure the styles are applied to the shadow roots of custom elements.
Finally, we scope themes to `:host` and `:host-context()` to ensure the styles are applied to the shadow roots of custom elements.
For example, the default theme is set up like this:
@@ -44,7 +44,8 @@ For example, the default theme is set up like this:
}
.wa-dark,
.wa-invert {
.wa-invert,
:host-context(.wa-dark) {
/* subset of CSS custom properties for a dark color scheme */
}
```

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

@@ -3,5 +3,4 @@ title: Design Tokens
description: A theme is a collection of predefined CSS custom properties that control global styles from color to shadows. These custom properties thread through all Web Awesome components for a consistent look and feel.
layout: overview
override:tags: []
categories: {tags: true}
---

View File

@@ -8,6 +8,7 @@ import { replace } from 'esbuild-plugin-replace';
import { mkdir, readFile } from 'fs/promises';
import getPort, { portNumbers } from 'get-port';
import { globby } from 'globby';
import nunjucks from 'nunjucks';
import ora from 'ora';
import { dirname, join, relative } from 'path';
import process from 'process';
@@ -344,6 +345,35 @@ if (isDeveloping) {
'/dist/': './dist-cdn/',
},
},
middleware: [
function simulateWebawesomeApp(req, res, next) {
// Accumulator for strings so we can pass them through nunjucks a second time similar to how the webawesome-app
// will be running nunjucks twice.
const finalString = [];
const encoding = 'utf-8';
const _write = res.write;
res.write = function (chunk, encoding) {
finalString.push(chunk.toString());
};
const _end = res.end;
res.end = function (...args) {
const transformedStr = nunjucks.renderString(finalString.join(''), {
// Stub the server EJS shortcodes.
server: {
head: '',
loginOrAvatar: '',
flashes: '',
},
});
_write.call(res, transformedStr, encoding);
_end.call(res, ...args);
};
next();
},
],
callbacks: {
ready: (_err, instance) => {
// 404 errors

View File

@@ -80,7 +80,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -71,7 +71,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -78,7 +78,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -68,7 +68,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -114,8 +114,10 @@
--wa-color-neutral-on-loud: white;
}
/** need to wrap :host-context() in an :is() selector for unsupported browsers */
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -85,7 +85,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -83,7 +83,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -75,7 +75,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -74,7 +74,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -84,7 +84,8 @@
}
.wa-dark,
.wa-invert {
.wa-invert,
:is(:host-context(.wa-dark)) {
/**
* Foundational Colors
*/

View File

@@ -1,6 +1,7 @@
:where(:root),
:host,
.wa-theme-tailspin {
.wa-theme-tailspin,
:is(:host-context(.wa-theme-tailspin)) {
h1,
h2,
h3,