Themer 2nd slice: Look & Feel (#920)

* Exclude Create link from sidebar, for reals this time

* Fix bug

* Very rough prototype of look & feel

* a11y

* Clean up data files

* Automatically generate theme metadata

* Read look & feel params straight from theme

* First stab at dimensionality icons

* Fix rounding 0 bug

* Add border width slider

* [Image-comparer] Expose wrapper as part

* [Comparer] `pointer-events: none` while dragging

* Dark mode slider

* Adjust increments and ranges for look + feel sliders

* Fix preview

* Fix bug where dark mode was not inverted

* Ability to select panel from URL

* Create mixin for Vue form controls and use it in `<swatch-select>`

* Prototype of slider min/max icon buttons

* Nx tooltip

* Icons

* Prevent failed request

* info-tip: Support passing text as prop

* Clearable

* [Brutalist] Match `--wa-shadow-offset-x-scale` to `--wa-shadow-offset-y-scale`

* Add 'Blocky' dimension (derived from Awesome theme)

* Only show Reset button when `clearable` is set

* Remove `clearable` from Look & Feel sliders

* Add tooltips to min/max buttons

* Remove superfluous `aria-label`

* Do not assume that all hyphens in URLs mean nesting, make it explicit

* Formatting

* Fix bug where styles were not applied on page load

* Update Subtle dimension to maximize compatibility

* `<wa-scoped>`: Do not allow non-template children

* Workaround for card not updating

* Update Glossy dimension to maximize compatibility

* Sync scrolling between regular and inverted preview

* Fix bug

* Make changing the base theme reset customizations

* Fix palette page

* Remove cancel button from editable text

* Don't error in theme pages

* Update Playful dimension to maximize compatibility

* Rename 'Look and Feel' to 'Elements' for better parallel structure

* Hide dimensionality controls

* Make back icon motion more subtle

* Expand spacing slider bounds

* Add `tabindex="-1"` where missing in theme showcase

* Remove extraneous gap from theme headers

* fix edit button bug

* rename comparer => comparison; fix aria-controls

* Always save theme name on blur

* Add changelog for themer and new patterns category

---------

Co-authored-by: lindsaym-fa <dev@lindsaym.design>
Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
This commit is contained in:
Lea Verou
2025-05-20 10:16:49 -04:00
committed by GitHub
parent 8b17b3d3e0
commit 0b4c1a5934
58 changed files with 1027 additions and 450 deletions

61
docs/_data/themes.js Normal file
View File

@@ -0,0 +1,61 @@
import fs from 'fs';
import path from 'path';
// import { inlined } from '../../dist/components/icon/library.wa.js';
const __dirname = path.resolve();
const THEME_DIR = path.join(__dirname, 'dist/styles/themes/');
const themeFiles = fs.readdirSync(THEME_DIR).filter(file => file.endsWith('.css') && !file.endsWith('base.css'));
const declarationRegex = /^\s*--wa-(?<property>[a-z-]+)?:\s*(?<value>.+?)\s*(\/\*.+?\*\/)?\s*;$/gm;
const importRegex = /^\s*@import\s+url\(['"](?<path>.+?)['"]\);$/gm;
const themes = {};
for (const file of themeFiles) {
const id = file.replace('.css', '');
const { imports, declarations } = readCSSFile(file);
let theme = { palette: 'default', declarations, imports };
for (const url of imports) {
if (url.endsWith('/color.css')) {
// Color settings
const color = readCSSFile(url);
for (const colorUrl of color.imports) {
if (colorUrl.startsWith('../../color/')) {
// Color palette
theme.palette = getFileSlug(colorUrl);
} else if (colorUrl.startsWith('../../brand/')) {
// Brand color
theme.brand = getFileSlug(colorUrl);
}
}
} else if (url.endsWith('/dimension.css')) {
theme.dimension = true;
}
}
let icon = {};
icon.family = theme.declarations['icon-family'] ?? theme.default?.iconFamily ?? 'classic';
icon.variant = theme.declarations['icon-variant'] ?? theme.default?.iconVariant ?? 'solid';
theme.icons = icon;
theme.rounding = Number(theme.declarations['border-radius-scale'] ?? theme.default?.rounding ?? 1);
theme.spacing = Number(theme.declarations['space-scale'] ?? theme.default?.spacing ?? 1);
theme.borderWidth = Number(theme.declarations['border-width-scale'] ?? theme.default?.borderWidth ?? 1);
themes[id] = theme;
}
export default themes;
function readCSSFile(url) {
const contents = fs.readFileSync(path.join(THEME_DIR, url), 'utf8');
const imports = [...contents.matchAll(importRegex)].map(match => match.groups.path);
const declarations = Object.fromEntries(
[...contents.matchAll(declarationRegex)].map(match => [match.groups.property, match.groups.value]),
);
return { imports, declarations };
}
function getFileSlug(url) {
return url.split('/').pop().replace('.css', '');
}

View File

@@ -1,4 +1,4 @@
{% if page -%}
{% if page 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

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -118,7 +118,7 @@
<div class="wa-stack">
<h3 class="wa-heading-m">Chalmun's Spaceport Cantina</h3>
<div class="wa-cluster wa-gap-xs">
<wa-rating value="4.6" read-only></wa-rating>
<wa-rating value="4.6" readonly tabindex="-1"></wa-rating>
<strong>4.6</strong>
<span>(419 reviews)</span>
</div>
@@ -144,7 +144,7 @@
<div class="wa-stack">
<div class="wa-flank:end">
<h3 id="odds-label" class="wa-heading-m">Tell Me the Odds</h3>
<wa-switch size="large" aria-labelledby="odds-label"></wa-switch>
<wa-switch size="large" aria-labelledby="odds-label" tabindex="-1"></wa-switch>
</div>
<p class="wa-body-s">Allow protocol droids to inform you of probabilities, such as the success rate of navigating an asteroid field. We recommend setting this to "Never."</p>
</div>
@@ -175,7 +175,7 @@
</dl>
</div>
<div slot="footer">
<a href="" class="wa-cluster wa-gap-2xs">
<a href="" class="wa-cluster wa-gap-2xs" tabindex="-1">
<span>Download Receipt</span>
<wa-icon name="arrow-right"></wa-icon>
</a>
@@ -195,7 +195,7 @@
<span class="wa-caption-l">per year</span>
</span>
<p class="wa-caption-l">Carry great power (and great responsibility).</p>
<wa-button variant="brand">Get this Plan</wa-button>
<wa-button variant="brand" tabindex="-1">Get this Plan</wa-button>
</div>
<div slot="footer" class="wa-stack wap-gap-s">
<h4 class="wa-heading-s">What You Get</h4>
@@ -231,11 +231,11 @@
<wa-avatar image="https://images.unsplash.com/photo-1633268335280-a41fbde58707?q=80&w=3348&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" label="Avatar of a man wearing a sci-fi helmet (Photograph by Nandu Vasudevan)"></wa-avatar>
</div>
<div slot="footer" class="wa-grid wa-gap-xs" style="--min-column-size: 10ch;">
<wa-button appearance="outlined">
<wa-button appearance="outlined" tabindex="-1">
<wa-icon slot="prefix" name="at"></wa-icon>
Email
</wa-button>
<wa-button appearance="outlined">
<wa-button appearance="outlined" tabindex="-1">
<wa-icon slot="prefix" name="phone"></wa-icon>
Phone
</wa-button>
@@ -243,7 +243,7 @@
</wa-card>
<wa-card>
<div class="wa-flank:end">
<a href="" class="wa-flank wa-link-plain">
<a href="" class="wa-flank wa-link-plain" tabindex="-1">
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-yellow-90); --text-color: var(--wa-color-yellow-50)">
<wa-icon slot="icon" name="egg-fried"></wa-icon>
</wa-avatar>
@@ -253,7 +253,7 @@
</div>
</a>
<wa-dropdown>
<wa-icon-button id="more-actions-2" slot="trigger" name="ellipsis-vertical" label="View menu"></wa-icon-button>
<wa-icon-button id="more-actions-2" slot="trigger" name="ellipsis-vertical" label="View menu" tabindex="-1"></wa-icon-button>
<wa-menu>
<wa-menu-item>Copy link</wa-menu-item>
<wa-menu-item>Rename</wa-menu-item>
@@ -270,7 +270,7 @@
<div class="wa-stack wa-gap-xl">
<p class="wa-caption-m">You havent created any decks yet. Get started by selecting an aspect that matches your play style.</p>
<div class="wa-grid wa-gap-xl" style="--min-column-size: 30ch;">
<a href="" class="wa-flank wa-align-items-start wa-link-plain">
<a href="" class="wa-flank wa-align-items-start wa-link-plain" tabindex="-1">
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-blue-90);color: var(--wa-color-blue-50);">
<wa-icon slot="icon" name="shield"></wa-icon>
</wa-avatar>
@@ -283,7 +283,7 @@
</p>
</div>
</a>
<a href="" class="wa-flank wa-align-items-start wa-link-plain">
<a href="" class="wa-flank wa-align-items-start wa-link-plain" tabindex="-1">
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-green-90);color: var(--wa-color-green-50);">
<wa-icon slot="icon" name="chevrons-up"></wa-icon>
</wa-avatar>
@@ -296,7 +296,7 @@
</p>
</div>
</a>
<a href=""class="wa-flank wa-align-items-start wa-link-plain">
<a href=""class="wa-flank wa-align-items-start wa-link-plain" tabindex="-1">
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-red-90);color: var(--wa-color-red-50);">
<wa-icon slot="icon" name="explosion"></wa-icon>
</wa-avatar>
@@ -309,7 +309,7 @@
</p>
</div>
</a>
<a href="" class="wa-flank wa-align-items-start wa-link-plain">
<a href="" class="wa-flank wa-align-items-start wa-link-plain" tabindex="-1">
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-yellow-90);color: var(--wa-color-yellow-50);">
<wa-icon slot="icon" name="moon-stars"></wa-icon>
</wa-avatar>
@@ -325,7 +325,7 @@
</div>
</div>
<div slot="footer">
<a href="" class="wa-cluster wa-gap-xs">
<a href="" class="wa-cluster wa-gap-xs" tabindex="-1">
<span>Or start a deck from scratch</span>
<wa-icon name="arrow-right"></wa-icon>
</a>

View File

@@ -399,3 +399,12 @@ export function attr(value, name) {
return safe(ret);
}
/**
* Format an object as JSON, with formatting & indentation (unlike the default `dump` filter)
* @param {*} value
* @returns {string}
*/
export function json(value) {
return JSON.stringify(value, null, 2);
}

View File

@@ -12,7 +12,7 @@ export default class WaScoped extends HTMLElement {
super();
this.attachShadow({ mode: 'open' });
this.observer = new MutationObserver(() => this.render());
this.observer = new MutationObserver(records => this.render(records));
this.observer.observe(this, { childList: true, subtree: true, characterData: true });
}
@@ -23,7 +23,7 @@ export default class WaScoped extends HTMLElement {
);
}
render() {
render(records) {
this.observer.takeRecords();
this.observer.disconnect();
@@ -33,17 +33,18 @@ export default class WaScoped extends HTMLElement {
let nodes = [];
for (let template of this.childNodes) {
// Other solutions we can try if needed: <script type="text/html">, or comment nodes
if (template instanceof HTMLTemplateElement) {
if (template.content.childNodes.length > 0) {
nodes.push(template.content.cloneNode(true));
} else if (template.childNodes.length > 0) {
// Fake template, suck its children out of the light DOM
nodes.push(...template.childNodes);
if (!(template instanceof HTMLTemplateElement)) {
if (template.nodeType === Node.ELEMENT_NODE) {
console.warn('<wa-scoped> can only contain <template> elements');
}
} else {
// Regular child, suck it out of the light DOM
nodes.push(template);
continue;
}
if (template.content.childNodes.length > 0) {
nodes.push(template.content.cloneNode(true));
} else if (template.childNodes.length > 0) {
// Fake template, suck its children out of the light DOM
nodes.push(...template.childNodes);
}
}

View File

@@ -3,7 +3,9 @@ layout: null
permalink: '/assets/data/palettes.js'
eleventyExcludeFromCollections: true
---
export default {
import Color from 'https://colorjs.io/dist/color.js';
const palettes = {
{%- for palette in collections.palette | sort %}
{%- if not palette.data.unlisted %}
{% set paletteId = palette.fileSlug -%}
@@ -30,3 +32,26 @@ export default {
{%- endif -%}
{% endfor %}
};
// Create Color instances for each color
for (let palette in palettes) {
for (let hue in palettes[palette].colors) {
let scale = palettes[palette].colors[hue];
for (let tint in scale) {
let color = scale[tint];
try {
if (Array.isArray(color)) {
scale[tint] = new Color('oklch', color);
}
else if (typeof color === 'string' && isNaN(color)) {
scale[tint] = new Color(color);
}
} catch (e) {
console.error(e);
}
}
}
}
export default palettes;

View File

@@ -3,19 +3,24 @@ layout: null
permalink: '/assets/data/themes.js'
eleventyExcludeFromCollections: true
---
export default {
{%- for theme in collections.theme | sort %}
{%- if not theme.data.unlisted %}
{%- if not theme.data.unlisted and theme.fileSlug !== 'edit' and theme.fileSlug !== 'custom' %}
{% set themeId = theme.fileSlug -%}
{%- set colors = themes[themeId] -%}
{%- set themeMeta = themes[themeId] -%}
'{{ themeId }}': {
id: '{{ themeId }}',
title: '{{ theme.data.title }}',
palette: '{{ theme.data.palette }}',
brand: '{{ theme.data.brand }}',
palette: '{{ themeMeta.palette }}',
brand: '{{ themeMeta.brand }}',
isPro: {{ theme.data.isPro or 'pro' in theme.data.tags }},
fonts: {{ (theme.data.fonts | dump or 'null') | safe }},
icons: {{ (theme.data.icons | dump or 'null') | safe }},
fonts: {{ (theme.data.fonts | json or 'null') | safe }},
icons: {{ (themeMeta.icons | json or 'null') | safe }},
rounding: {{ themeMeta.rounding }},
spacing: {{ themeMeta.spacing }},
borderWidth: {{ themeMeta.borderWidth }},
dimension: {{ (theme.data.dimension or themeMeta.dimension or false) | json | safe }},
},
{%- endif %}
{% endfor %}

View File

@@ -41,7 +41,10 @@ export const themeConfig = {
},
},
icon: {
library: { cssProperty: '--wa-icon-library', default: 'default' },
library: {
cssProperty: '--wa-icon-library',
default: 'default',
},
family: {
cssProperty: '--wa-icon-family',
default(baseTheme) {
@@ -55,8 +58,41 @@ export const themeConfig = {
},
},
},
rounding: {
cssProperty: '--wa-border-radius-scale',
default(baseTheme) {
return baseTheme?.rounding ?? 1;
},
},
spacing: {
cssProperty: '--wa-space-scale',
default(baseTheme) {
return baseTheme?.spacing ?? 1;
},
},
borderWidth: {
cssProperty: '--wa-border-width-scale',
default(baseTheme) {
return baseTheme?.borderWidth ?? 1;
},
},
dimensionality: {
url: id => `styles/themes/${id}/dimension.css`,
docs: '/docs/themes/',
icon: 'cube',
default() {
return this.base;
},
},
};
export function getPath(key) {
if (key.startsWith('icon-')) {
// TODO detect what the nested prefixes are from theme config metadata
return ['icon', ...key.slice(5)];
}
}
// Shallow remixing params in correct order
// base must be first. brand needs to come after palette, which needs to come after colors.
export const themeParams = Object.keys(themeConfig).filter(aspect => themeConfig[aspect].url);

View File

@@ -1,4 +1,5 @@
import { deepEach, deepGet, deepSet } from './util/deep.js';
import { camelCase, kebabCase } from './util/string.js';
export default class Permalink extends URLSearchParams {
/** Params changed since last URL I/O */
@@ -22,15 +23,16 @@ export default class Permalink extends URLSearchParams {
setAll(values, defaults) {
deepEach(values, (value, key, parent, path) => {
let fullPath = [...path, key];
let param = fullPath.join('-');
let defaultValue = deepGet(defaults, fullPath);
let param = fullPath.map(kebabCase).join('-');
if (typeof value === 'object') {
// We'll handle this when we descend into it
return;
}
if (!value || value === defaultValue) {
let defaultValue = deepGet(defaults, fullPath);
if (equals(value, defaultValue)) {
// Remove the param from the URL
this.delete(param);
return;
@@ -40,17 +42,36 @@ export default class Permalink extends URLSearchParams {
});
}
getAll(...args) {
if (args.length > 0) {
return super.getAll(...args);
}
/**
* Convert the URL params to a (potentially nested) object.
* @param {object} options - Options object.
* @param {(key: string, value: string) => string[]} options.getPath - Function to get the path of a param.
* @returns {object} The nested object.
*/
toObject(options = {}) {
// Default getPath() assumes hyphens always mean nesting
let { ignoreKeys = [], getPath = param => param.split('-') } = options;
// Get all values as a nested object
// Assumes that hyphens always mean nesting
let obj = {};
for (let [key, value] of this.entries()) {
let path = key.split('-');
let path = getPath(key, value);
if (path === null || ignoreKeys.includes(key)) {
// Skip this param
continue;
}
// Default to key if `getPath()` returns undefined
path ??= key;
path = Array.isArray(path) ? path : [path];
// Camel case any remaining hyphens
path = path.map(camelCase);
deepSet(obj, path, value);
}

View File

@@ -45,7 +45,7 @@ export function getThemeCode(theme, options = {}) {
let value = deepGet(theme, [...path, aspect]);
if (!value) {
if (!value && value !== 0) {
return;
}
@@ -64,8 +64,8 @@ export function getThemeCode(theme, options = {}) {
if (declarations.length > 0) {
let cssCode = cssRule(selectors.theme(id), declarations, options);
let faKitAttribute = ` data-fa-kit-code="${theme.icon.kit}"`;
if (theme.icon.kit) {
if (theme.icon?.kit) {
let faKitAttribute = ` data-fa-kit-code="${theme.icon.kit}"`;
options.attributes ??= '';
options.attributes += faKitAttribute;
cssCode =

View File

@@ -22,3 +22,21 @@ export function slugify(str) {
.replace(/\s+/g, '-') // Convert whitespace to hyphens
.toLowerCase();
}
/**
* Convert a string to camel case.
* @param {string} str - The string to convert.
* @returns {string} The camel case string.
*/
export function camelCase(str) {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}
/**
* Convert a string to kebab case.
* @param {string} str - The string to convert.
* @returns {string} The kebab case string.
*/
export function kebabCase(str) {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}

View File

@@ -141,6 +141,34 @@ wa-card:has(
}
}
.theme-icon.theme-dimensionality-icon {
display: flex;
flex-flow: column;
gap: var(--wa-space-2xs);
justify-content: center;
width: 100%;
min-height: 6.75rem;
box-sizing: border-box;
wa-card {
display: block;
&::part(body) {
display: flex;
gap: var(--wa-space-xs);
}
wa-input {
flex: 4;
min-width: 1em;
}
wa-button {
flex: 1;
}
}
}
.fonts-icon {
font-family: var(--wa-font-family-body);
padding-block: var(--wa-space-s);

View File

@@ -1,9 +1,10 @@
import inputMixin from '../mixins/input.js';
const template = `
<span class="editable-text">
<template v-if="isEditing">
<input ref="input" class="wa-size-s" :aria-label="label" :value="value" @input="handleInput" @keydown.enter="done" @keydown.esc="cancel" />
<wa-icon-button name="check" label="Done editing" @click="done"></wa-icon-button>
<wa-icon-button name="xmark" label="Cancel" @click="cancel"></wa-icon-button>
<input ref="input" class="wa-size-s" :aria-label="label" :value="value" @input="handleInput" @keydown.enter="done" @keydown.esc="cancel" @blur="handleBlur" />
<wa-icon-button v-if="blur !== 'done'" name="check" label="Done editing" @click="done"></wa-icon-button>
</template>
<template v-else>
<span class="text" ref="wrapper" @focus="edit" @click="edit" tabindex="0">{{ value }}</span>
@@ -13,17 +14,22 @@ const template = `
`;
export default {
mixins: [inputMixin],
props: {
modelValue: String,
label: {
type: String,
default: 'Rename',
},
blur: {
type: String,
validator(value) {
return ['', 'done', 'cancel'].includes(value);
},
},
},
emits: ['update:modelValue', 'submit'],
data() {
return {
value: this.modelValue,
previousValue: undefined,
isEditing: false,
};
@@ -69,14 +75,12 @@ export default {
this.isEditing = false;
this.value = this.previousValue;
},
handleInput(event) {
this.value = event.target.value;
},
},
watch: {
value(newValue) {
this.$emit('update:modelValue', newValue);
handleBlur(event) {
this.done(event);
},
},
template,
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -1,13 +1,13 @@
import themes from '../../data/themes.js';
import PageCard from './page-card.js';
import { defaultTitle, pairings, sameAs } from '/assets/data/fonts.js';
import { themeConfig } from '/assets/data/theming.js';
import { cssImport, getThemeCode } from '/assets/scripts/tweak/code.js';
import themes from '/docs/themes/data.js';
const template = `
<page-card class="fonts-card" :info="computedPairing">
<template #icon>
<wa-scoped slot="header" class="fonts-icon-host" inert>
<wa-scoped slot="header" class="fonts-icon-host" inert :key="html">
<template v-html="html"></template>
<template>
<link rel="stylesheet" href="/dist/styles/native/content.css">

View File

@@ -10,3 +10,4 @@ export { default as ThemeCard } from './theme-card.js';
export { default as UiPanelContainer } from './ui-panel-container.js';
export { default as UiPanel } from './ui-panel.js';
export { default as UiScrollable } from './ui-scrollable.js';
export { default as UiSlider } from './ui-slider.js';

View File

@@ -2,7 +2,7 @@ const template = `
<slot>
<wa-icon :slot class="info-tip-default-trigger" :id="id" name="circle-question" variant="regular" tabindex="0"></wa-icon>
</slot>
<wa-tooltip :slot :for="id" ref="tooltip"><slot name="content"></slot></wa-tooltip>
<wa-tooltip :slot :for="id" ref="tooltip"><slot name="content">{{ text }}</slot></wa-tooltip>
`;
let maxUid = 0;
@@ -10,6 +10,7 @@ let maxUid = 0;
export default {
props: {
slot: String,
text: String,
},
data() {
let uid = ++maxUid;

View File

@@ -1,6 +1,6 @@
import palettes from '../../data/palettes.js';
import PageCard from './page-card.js';
import { hues } from '/assets/data/index.js';
import palettes from '/docs/palettes/data.js';
// TODO import from data.js once available
const allHues = [...hues, 'gray'];

View File

@@ -9,12 +9,6 @@
scrollbar-width: thin;
}
@keyframes back-icon-hover {
to {
transform: translateX(-0.2em);
}
}
.panel {
/* Remove the uniform spacing used in wa-details */
--spacing: 0;
@@ -31,7 +25,7 @@
border: none;
transition:
translate var(--wa-transition-slow) allow-discrete,
opacity var(--wa-transition-slow) 25ms allow-discrete;
opacity var(--wa-transition-slow) 50ms allow-discrete;
/* Ensure horizontal scrollbar isn't visible when translate takes effect */
overflow-x: hidden !important;
@@ -62,11 +56,11 @@
vertical-align: -0.15em;
margin-inline-end: var(--wa-space-xs);
font-size: var(--wa-font-size-m);
transition: transform var(--wa-transition-normal);
transition: transform var(--wa-transition-normal) var(--wa-transition-easing);
}
&:hover .back-icon {
animation: back-icon-hover var(--wa-transition-slow) alternate infinite;
transform: translateX(-0.25em);
}
label {

View File

@@ -1,8 +1,9 @@
import { capitalize } from '../../scripts/util/string.js';
import inputMixin from '../mixins/input.js';
import InfoTip from './info-tip.js';
const template = `
<wa-radio-group :label class="swatch-select" :class="'swatch-shape-' + shape" orientation="horizontal" :value="modelValue" @input="handleInput">
<wa-radio-group :label class="swatch-select" :class="'swatch-shape-' + shape" orientation="horizontal" :value @input="handleInput">
<info-tip v-for="value in values">
<wa-radio-button :value :label="getLabel(value)" :style="{'--color': getColor(value)}"></wa-radio-button>
<template #content>
@@ -13,6 +14,7 @@ const template = `
`;
export default {
mixins: [inputMixin],
props: {
modelValue: String,
name: String,
@@ -35,32 +37,17 @@ export default {
default: [],
},
},
emits: ['update:modelValue', 'input'],
data() {
return {
value: this.modelValue,
};
},
computed: {},
methods: {
capitalize,
handleInput(e) {
this.value = e.target.value;
this.$emit('input', this.value);
},
},
watch: {
value() {
this.$emit('update:modelValue', this.value);
},
},
template,
components: {
InfoTip,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},

View File

@@ -1,6 +1,7 @@
import themes from '../../data/themes.js';
import { capitalize } from '../../scripts/util/string.js';
import PageCard from './page-card.js';
import { getThemeCode } from '/assets/scripts/tweak/code.js';
import themes from '/docs/themes/data.js';
const iconTemplates = {
colors: `
@@ -15,7 +16,13 @@ const iconTemplates = {
<div style="background: var(--wa-color-brand-fill-normal); border-color: var(--wa-color-brand-border-normal); color: var(--wa-color-brand-on-normal);">A</div>
<div style="background: var(--wa-color-brand-fill-quiet); border-color: var(--wa-color-brand-border-quiet); color: var(--wa-color-brand-on-quiet);">A</div>
</div>`,
theme: `
dimensionality: `
<wa-card size="small">
<wa-input value="Input" size="small"></wa-input>
<wa-button size="small" variant="brand">Go</wa-button>
</wa-card>
`,
overall: `
<div class="row row-1">
<h2>Aa</h2>
<div class="swatches">
@@ -32,21 +39,22 @@ const iconTemplates = {
};
const template = `
<page-card class="theme-card" :class="type + '-card'" :info="themeMeta">
<page-card class="theme-card" :class="type + '-card'" :info="themeMeta" :data-theme="theme">
<template #icon>
<wa-scoped slot="header" class="theme-icon-host" inert>
<wa-scoped slot="header" class="theme-icon-host" inert :key="themeCode">
<template v-html="themeCode"></template>
<template>
<link rel="stylesheet" href="/dist/styles/utilities.css">
<link rel="stylesheet" href="/dist/styles/native/content.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<template v-if="type == 'colors'" >
<template v-if="type === 'colors'">
${iconTemplates.colors}
</template>
<div v-else-if="type in iconTemplates && type !== 'overall'" class="theme-icon" :class="'theme-' + type + '-icon'" v-html="iconTemplates[type]" role="presentation">
</div>
<div v-else class="theme-icon theme-overall-icon" :class="'wa-theme-' + theme" role="presentation">
${iconTemplates.theme}
${iconTemplates.overall}
</div>
</template>
</wa-scoped>
@@ -64,7 +72,7 @@ export default {
type: {
type: String,
validator(value) {
return !value || ['colors'].includes(value);
return !value || value in iconTemplates;
},
},
rest: Object,
@@ -74,15 +82,29 @@ export default {
return {};
},
created() {
this.iconTemplates = iconTemplates;
},
computed: {
themeMeta() {
return themes[this.theme] ?? {};
let ret = themes[this.theme] ? { ...themes[this.theme] } : {};
// if (this.type === 'dimensionality' && typeof ret.dimension === 'string') {
// ret.title = capitalize(ret.dimension);
// }
return ret;
},
themeCode() {
let theme = { ...(this.rest || {}), [this.type || 'base']: this.theme };
theme.base ||= 'default';
// if (theme.dimensionality) {
// if (!themes[theme.dimensionality]?.dimension || theme.dimensionality === theme.base) {
// theme.dimensionality = '';
// }
// }
return getThemeCode(theme, { id: this.theme, language: 'html', cdnUrl: '/dist/' });
},
},

View File

@@ -0,0 +1,46 @@
.ui-slider {
display: grid;
grid-template:
'label label label'
'min slider max';
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--wa-space-2xs);
wa-slider {
display: block;
grid-area: slider;
width: 100%;
}
&:has(.ui-slider-min) wa-slider {
&::part(label) {
margin-inline-start: calc(-1 * (var(--wa-space-s) + 1rem + 2 * var(--wa-border-width-m)));
}
}
.clear-button {
vertical-align: middle;
margin-inline-start: var(--wa-space-xs);
font-size: var(--wa-font-size-xs);
}
}
.ui-slider-header {
grid-area: label;
}
.ui-slider-min,
.ui-slider-max {
width: min-content;
}
.ui-slider-min {
grid-area: min;
margin-inline-start: calc(-1 * var(--wa-space-s));
}
.ui-slider-max {
grid-area: max;
margin-inline-end: calc(-1 * var(--wa-space-s));
}

View File

@@ -0,0 +1,86 @@
import inputMixin from '../mixins/input.js';
import InfoTip from './info-tip.js';
let maxUid = 0;
const template = `
<div class="ui-slider">
<div class="ui-slider-header">
<label :for="sliderId">{{ label }}</label>
<info-tip v-if="clearable && (value !== defaultValue ?? initialValue)" :text="'Reset to ' + valueFormatter(defaultValue ?? initialValue)">
<wa-icon-button @click="value = defaultValue ?? initialValue" class="clear-button" name="circle-xmark" library="system" variant="regular" :label="'Reset to ' + tooltipFormatter(defaultValue ?? initialValue)"></wa-icon-button>
</info-tip>
</div>
<info-tip v-if="$slots.min" :text="'Set to min (' + valueFormatter(min) + ')'">
<wa-button class="ui-slider-min" appearance="plain" size="small" @click="value = min"><slot name="min"></slot></wa-button>
</info-tip>
<wa-slider ref="slider" :id="sliderId" class="ui-slider" :value @input="handleInput"
:min="min" :max="max" :step="step">
</wa-slider>
<info-tip v-if="$slots.max" :text="'Set to max (' + valueFormatter(max) + ')'">
<wa-button class="ui-slider-max" appearance="plain" size="small" @click="value = max"><slot name="max"></slot></wa-button>
</info-tip>
</div>
`;
export default {
mixins: [inputMixin],
props: {
label: String,
id: String,
defaultValue: Number,
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
step: {
type: Number,
default(rawProps) {
return (rawProps.max - rawProps.min) / 100;
},
},
format: [Function, String],
clearable: Boolean,
},
data() {
let uid = ++maxUid;
return { uid, value: this.modelValue };
},
mounted() {
if (this.format) {
this.$refs.slider.tooltipFormatter = this.valueFormatter;
}
},
computed: {
sliderId() {
return this.id || `ui-slider-${this.uid}`;
},
valueFormatter() {
if (typeof this.format === 'string') {
return v => this.format.replaceAll('{value}', v);
}
return this.format;
},
},
watch: {
tooltip() {
if (this.$refs.slider) {
this.$refs.slider.tooltipFormatter = this.tooltipFormatter;
}
},
},
template,
components: {
InfoTip,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,32 @@
/**
* Mixin for components that behave like form controls.
*/
export default {
props: {
modelValue: {
type: [String, Number, Boolean],
},
},
data() {
return {
initialValue: this.modelValue,
value: this.modelValue,
};
},
emits: ['update:modelValue', 'input'],
methods: {
handleInput(e) {
this.value = e.target.value;
this.$emit('input', this.value);
},
},
watch: {
value(value) {
this.$emit('update:modelValue', value);
},
modelValue(value) {
this.value = value;
},
},
};

View File

@@ -1,7 +1,7 @@
---
title: Component Cheatsheet
layout: docs
unpublished: true
unlisted: true
---
<style>

View File

@@ -1,8 +1,8 @@
---
title: Comparer
title: Comparison
description: Compare visual differences between similar content with a sliding panel.
tags: [imagery, niche]
icon: comparer
icon: comparison
---
This is especially useful for comparing images, but can be used for comparing any type of content (for an example of using it to compare entire UIs, check out our [theme pages](/docs/themes/default/)).
@@ -10,7 +10,7 @@ For best results, use content that shares the same dimensions.
The slider can be controlled by dragging or pressing the left and right arrow keys. (Tip: press shift + arrows to move the slider in larger intervals, or home + end to jump to the beginning or end.)
```html {.example}
<wa-comparer>
<wa-comparison>
<img
slot="before"
src="https://images.unsplash.com/photo-1517331156700-3c241d2b4d83?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80&sat=-100&bri=-5"
@@ -21,7 +21,7 @@ The slider can be controlled by dragging or pressing the left and right arrow ke
src="https://images.unsplash.com/photo-1517331156700-3c241d2b4d83?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80"
alt="Color version of kittens in a basket looking around."
/>
</wa-comparer>
</wa-comparison>
```
## Examples
@@ -31,7 +31,7 @@ The slider can be controlled by dragging or pressing the left and right arrow ke
Use the `position` attribute to set the initial position of the slider. This is a percentage from `0` to `100`.
```html {.example}
<wa-comparer position="25">
<wa-comparison position="25">
<img
slot="before"
src="https://images.unsplash.com/photo-1520903074185-8eca362b3dce?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1200&q=80"
@@ -42,5 +42,5 @@ Use the `position` attribute to set the initial position of the slider. This is
src="https://images.unsplash.com/photo-1520640023173-50a135e35804?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2250&q=80"
alt="A person sitting on a yellow curb tying shoelaces on a boot."
/>
</wa-comparer>
</wa-comparison>
```

View File

@@ -1,32 +0,0 @@
---
layout: null
permalink: '/docs/palettes/data.js'
eleventyExcludeFromCollections: true
---
export default {
{%- for palette in collections.palette | sort %}
{%- if not palette.data.unlisted %}
{% set paletteId = palette.fileSlug -%}
{%- set colors = palettes[paletteId] -%}
'{{ paletteId }}': {
id: '{{ paletteId }}',
title: '{{ palette.data.title }}',
colors: {
{% for hue, tints in colors -%}
'{{ hue }}': {
{% for tint, value in tints -%}
{%- if tint != '05' -%}
'{{ '05' if tint == '5' else tint }}': '{{ value | safe }}',
{%- endif %}
{% endfor %}
get key() {
return this[this.maxChromaTint];
}
},
{% endfor -%} // end colors
}
}, // end palette
{%- endif -%}
{% endfor %}
};

View File

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

View File

@@ -5,6 +5,7 @@ import { maxGrayChroma, moreHue, selectors, themeConfig } from '../../assets/dat
import { cdnUrl, hueRanges, hues, tints } from '../../assets/scripts/tweak.js';
import { cssImport, cssLiteral, cssRule } from '../../assets/scripts/tweak/code.js';
import { subtractAngles } from '../../assets/scripts/tweak/util.js';
import allPalettes from '/assets/data/palettes.js';
import Prism from '/assets/scripts/prism.js';
import { SwatchSelect } from '/assets/vue/components/index.js';
import content from '/assets/vue/directives/content.js';
@@ -22,22 +23,8 @@ await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag)));
// 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 = {

View File

@@ -14,8 +14,10 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- 🚨 BREAKING: Renamed `<image-comparer>` to `<wa-comparer>` and improved compatibility for non-image content
- 🚨 BREAKING: Renamed `<image-comparer>` to `<wa-comparison>` and improved compatibility for non-image content
- 🚨 BREAKING: Added slot detection to `<wa-dialog>` and `<wa-drawer>` so you don't need to specify `with-header` and `with-footer`; headers are on by default now, but you can use the `without-header` attribute to turn them off
- Added [a theme builder](/docs/themes/edit/) to create your own themes
- Added a new Blog & News pattern category
- Added a new free component: `<wa-scroller>` (#1 of 14 per stretch goals)
- Added support for Duotone Thin, Light, and Regular styles and the Sharp Duotone family of styles to `<wa-icon>`
- Fixed a bug that caused `<wa-radio-group>` to have an undesired margin below it

View File

@@ -3,11 +3,7 @@ title: Active
description: Energetic and tactile, always in motion.
isPro: true
tags: pro
palette: rudimentary
brand: green
fonts:
body: Inter
icons:
family: classic
style: solid
dimension: Subtle
---

View File

@@ -2,11 +2,7 @@
title: Awesome
description: Punchy and vibrant, the rockstar of themes.
order: 0.2
palette: bright
brand: blue
fonts:
body: Quicksand
icons:
family: classic
style: solid
dimension: Blocky
---

View File

@@ -3,13 +3,8 @@ title: Brutalist
description: Sharp, square, and unapologetically bold.
isPro: true
tags: pro
palette: default
brand: blue
fonts:
body: Space Grotesk
heading: IBM Plex Sans Condensed
code: Space Mono
icons:
family: sharp
style: solid
---

View File

@@ -2,9 +2,4 @@
title: Classic
description: Timeless elegance that never goes out of style.
order: 0.1
palette: classic
brand: blue
icons:
family: classic
style: light
---

View File

@@ -1,22 +0,0 @@
---
layout: null
permalink: '/docs/themes/data.js'
eleventyExcludeFromCollections: true
---
export default {
{%- for theme in collections.theme | sort %}
{%- if not theme.data.unlisted %}
{% set themeId = theme.fileSlug -%}
{%- set colors = themes[themeId] -%}
'{{ themeId }}': {
id: '{{ themeId }}',
title: '{{ theme.data.title }}',
palette: '{{ theme.data.palette }}',
brand: '{{ theme.data.brand }}',
isPro: {{ theme.data.isPro or 'pro' in theme.data.tags }},
fonts: {{ (theme.data.fonts | dump or 'null') | safe }},
icon: {{ (theme.data.icons | dump or 'null') | safe }},
},
{%- endif %}
{% endfor %}
};

View File

@@ -2,13 +2,8 @@
title: Default
description: Your trusty companion, like a perfectly broken-in pair of jeans.
order: 0
palette: default
brand: blue
fonts:
body: ui-sans-serif
code: ui-monospace
longform: ui-serif
icons:
family: classic
style: solid
---

View File

@@ -35,13 +35,13 @@ unlisted: true
{% include 'theme-showcase.njk' %}
{% endset %}
<wa-comparer style="width: 100%" position="90">
<div slot="after" class="theme-showcase wa-gap-xl">
<wa-comparison style="width: 100%" position="90">
<div slot="after" class="theme-showcase wa-gap-0">
{{ content | safe }}
</div>
<div slot="before" class="theme-showcase wa-gap-xl wa-invert">
<div slot="before" class="theme-showcase wa-gap-0 wa-invert">
{{ content | safe }}
</div>
</wa-comparer>
</wa-comparison >
<script type="module" src="../demo/index.js"></script>

View File

@@ -2,11 +2,13 @@
import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';
import { pairingsList, sameAs } from '/assets/data/fonts.js';
import { allHues, cdnUrl, iconLibraries } from '/assets/data/index.js';
import { themeDefaults } from '/assets/data/theming.js';
import palettes from '/assets/data/palettes.js';
import themes from '/assets/data/themes.js';
import { getPath, themeDefaults } from '/assets/data/theming.js';
import Prism from '/assets/scripts/prism.js';
import { getThemeCode } from '/assets/scripts/tweak/code.js';
import { deepClone, deepEach, deepMerge } from '/assets/scripts/util/deep.js';
import { capitalize, slugify } from '/assets/scripts/util/string.js';
import { deepClone, deepEach, deepEntries, deepGet, deepMerge } from '/assets/scripts/util/deep.js';
import { camelCase, capitalize, slugify } from '/assets/scripts/util/string.js';
import {
ColorSelect,
EditableText,
@@ -19,11 +21,10 @@ import {
ThemeCard,
UiPanel,
UiPanelContainer,
UiSlider,
} from '/assets/vue/components/index.js';
import content from '/assets/vue/directives/content.js';
import savedMixin from '/assets/vue/mixins/saved.js';
import palettes from '/docs/palettes/data.js';
import themes from '/docs/themes/data.js';
let appSpec = {
mixins: [savedMixin],
@@ -44,19 +45,7 @@ let appSpec = {
id: id === 'edit' ? 'custom' : id,
isCustom,
urlParams: location.search,
theme: {
base: isCustom ? '' : id,
palette: '',
typography: '',
colors: '',
brand: '',
icon: {
kit: '',
library: '',
family: '',
style: '',
},
},
theme: getBlankTheme(isCustom ? '' : id),
ui: {
panel: 'styles',
showCode: false,
@@ -82,13 +71,60 @@ let appSpec = {
});
if (location.search) {
let urlTheme = this.permalink.getAll();
let urlTheme = this.permalink.toObject({
ignoreKeys: ['panel', 'color-scheme'],
getPath,
});
deepMerge(this.theme, urlTheme, { emptyValues: [undefined, ''] });
if (this.permalink.has('panel')) {
this.ui.panel = this.permalink.get('panel');
}
}
this.isCreated = true;
},
mounted() {
let { preview, previewInvert } = this.$refs;
if (!preview || !previewInvert) {
return;
}
let contentWindow, contentWindowInvert;
preview.addEventListener('load', () => {
try {
contentWindow = preview.contentWindow;
} catch (e) {}
if (contentWindow) {
contentWindow.addEventListener('scroll', e => {
let { scrollX, scrollY } = contentWindow;
if (contentWindowInvert) {
contentWindowInvert.scrollTo(scrollX, scrollY);
}
});
}
});
previewInvert.addEventListener('load', () => {
try {
contentWindowInvert = previewInvert.contentWindow;
} catch (e) {}
if (contentWindowInvert) {
contentWindowInvert.addEventListener('scroll', e => {
let { scrollX, scrollY } = contentWindowInvert;
if (contentWindow) {
contentWindow.scrollTo(scrollX, scrollY);
}
});
}
});
},
computed: {
originalTitle() {
if (this.isCustom) {
@@ -139,6 +175,16 @@ let appSpec = {
return ret;
},
customizations() {
return deepEntries(this.theme, {
filter: (value, key, parent, path) => {
let fullPath = [...path, key];
let defaultValue = deepGet(this.defaults, fullPath);
return key !== 'base' && typeof value !== 'object' && value !== '' && value !== defaultValue;
},
});
},
computed() {
let ret = deepClone(themeDefaults);
@@ -185,22 +231,23 @@ let appSpec = {
tweaked() {
return Object.values(this.theme).filter(Boolean).length > 0;
},
urlParamsInvert() {
let invert = 'color-scheme=invert';
return (this.urlParams ? this.urlParams + '&' : '?') + invert;
},
},
watch: {
theme: {
deep: true,
handler() {
this.permalink.setAll(this.theme, this.defaults);
async handler() {
await this.$nextTick(); // give defaults a chance to update
// Update page URL
this.permalink.setAll(this.theme, this.defaults);
this.permalink.updateLocation();
let theme = JSON.parse(JSON.stringify(this.theme));
this.$refs.preview?.contentWindow.postMessage({
type: 'updatePreview',
theme,
id: this.slug,
});
this.updatePreview();
this.unsavedChanges = true;
},
@@ -231,6 +278,30 @@ let appSpec = {
console.log(...args);
return args[0];
},
updatePreview() {
// Update page URL
let theme = JSON.parse(JSON.stringify(this.theme));
let message = {
type: 'updatePreview',
theme,
id: this.slug,
};
this.$refs.preview?.contentWindow.postMessage(message);
this.$refs.previewInvert?.contentWindow.postMessage(message);
},
resetTo(base) {
let kit = this.theme.icon.kit;
let theme = getBlankTheme(base);
if (kit) {
theme.icon.kit = kit;
}
return (this.theme = theme);
},
},
components: {
@@ -245,6 +316,7 @@ let appSpec = {
UiPanel,
UiPanelContainer,
SwatchSelect,
UiSlider,
},
directives: { content },
@@ -267,3 +339,23 @@ function init() {
init();
addEventListener('turbo:render', init);
function getBlankTheme(base) {
return {
base,
palette: '',
typography: '',
colors: '',
brand: '',
icon: {
kit: '',
library: '',
family: '',
style: '',
},
rounding: '',
spacing: '',
borderWidth: '',
dimensionality: '',
};
}

View File

@@ -22,7 +22,7 @@ unlisted: true
</a>
<div class="title">
<h1><editable-text :model-value="title" label="theme name" @submit="newTitle => save({title: newTitle})"></editable-text></h1>
<h1><editable-text :model-value="title" label="theme name" @submit="newTitle => save({title: newTitle})" blur="cancel"></editable-text></h1>
<wa-icon-button v-if="saved" class="delete" name="trash" label="Delete theme" @click="deleteSaved"></wa-icon-button>
</div>
@@ -88,6 +88,8 @@ unlisted: true
<icons-card size="small" :action="e => ui.panel = 'icons'" :family="computed.icon.family" :style="computed.icon.style"
title="Icons" :subtitle="`${ capitalize(computed.icon.family) } &bull; ${ capitalize(computed.icon.style) }`">
</icons-card>
<theme-card type="dimensionality" :theme="computed.dimensionality" title="Elements" size="small" :action="e => ui.panel = 'elements'"></theme-card>
</ui-panel>
<ui-panel v-model="ui.panel" value="theme" :step="1">
@@ -95,7 +97,12 @@ unlisted: true
<label for="starting-theme">Starting Theme</label>
</template>
<wa-radio-group id="starting-theme" class="card-picker" v-model="theme.base">
<wa-callout v-if="customizations.length > 0" variant="danger" size="small">
<wa-icon slot="icon" name="circle-exclamation" variant="regular"></wa-icon>
Editing the starting theme will reset your <span v-text="customizations.length + ' customization' + (customizations.length > 1 ? 's' : '')"></span>.
</wa-callout>
<wa-radio-group id="starting-theme" class="card-picker" :value="theme.base" @input="e => resetTo(e.target.value)">
<template v-for="(themeMeta, themeId) in themes">
<wa-radio v-if="themeId !== 'custom'" :value="themeId" size="small" :aria-selected="!theme.base && themeId === 'default' ? 'true' : null">
<theme-card :theme="themeId"></theme-card>
@@ -156,8 +163,61 @@ unlisted: true
</wa-input>
</ui-panel>
<ui-panel v-model="ui.panel" value="elements" title="Elements" :step="1">
<ui-slider label="Rounding" :default-value="1" :min="0" :max="2" :step="0.125" format="{value}x" :model-value="computed.rounding" @update:model-value="value => theme.rounding = value">
<template #min>
<div class="corner" style="--border-radius: 0;"></div>
</template>
<template #max>
<div class="corner" style="--border-radius: var(--wa-border-radius-l);"></div>
</template>
</ui-slider>
<ui-slider label="Spacing" :default-value="1":min="0.5" :max="2" :step="0.125" format="{value}x" :model-value="computed.spacing" @update:model-value="value => theme.spacing = value">
<template #min>
<div class="spacing" style="--spacing: 0.5;"><div></div><div></div><div></div></div>
</template>
<template #max>
<div class="spacing" style="--spacing: 1.5;"><div></div><div></div><div></div></div>
</template>
</ui-slider>
<ui-slider label="Border thickness" :default-value="1" :min="0.5" :max="3" :step="0.5" format="{value}x" :model-value="computed.borderWidth" @update:model-value="value => theme.borderWidth = value">
<template #min>
<div class="borders" style="--border-width: 1px;"></div>
</template>
<template #max>
<div class="borders" style="--border-width: var(--wa-border-width-l);"></div>
</template>
</ui-slider>
<!--
<theme-card type="dimensionality" :theme="computed.dimensionality" title="Dimensionality" :subtitle="computed.dimensionality ? undefined : 'Flat'" size="small" :action="e => ui.panel = 'dimensionality'"></theme-card>
-->
</ui-panel>
<!--
<ui-panel v-model="ui.panel" value="dimensionality" title="Dimensionality" :step="2">
<template #title>
<label for="dimensionality-radio-group">Dimensionality</label>
</template>
<wa-radio-group id="dimensionality-radio-group" class="card-picker" v-model="theme.dimensionality">
<wa-radio value="" size="small" :aria-selected="!theme.dimensionality || theme.dimensionality === computed.base ? 'true' : null">
<theme-card type="dimensionality" :theme="computed.base"></theme-card>
</wa-radio>
<template v-for="(themeMeta, themeId) in themes">
<wa-radio v-if="themeId !== computed.base && themeMeta.dimension" :value="themeId" size="small" :aria-selected="theme.dimensionality === themeId ? 'true' : null">
<theme-card type="dimensionality" :theme="themeId"></theme-card>
</wa-radio>
</template>
</wa-radio-group>
</ui-panel>
-->
<ui-panel v-model="ui.panel" value="icon-family" title="Icon Family" :step="2">
<wa-radio-group id="starting-theme" class="card-picker" v-model="theme.icon.family">
<template #title>
<label for="icon-family-radio-group">Icon Family</label>
</template>
<wa-radio-group id="icon-family-radio-group" class="card-picker" v-model="theme.icon.family">
<wa-radio v-for="family of iconLibraries.default.family" :value="family" size="small" :aria-selected="computed.icon.family === family ? 'true' : null">
<icons-card type="family" vary="style" :family="family" :library="computed.icon.library"></icons-card>
</wa-radio>
@@ -165,7 +225,10 @@ unlisted: true
</ui-panel>
<ui-panel v-model="ui.panel" value="icon-style" title="Icon Style" :step="2">
<wa-radio-group id="starting-theme" class="card-picker" v-model="theme.icon.style">
<template #title>
<label for="icon-style-radio-group">Icon Style</label>
</template>
<wa-radio-group id="icon-style-radio-group" class="card-picker" v-model="theme.icon.style">
<wa-radio v-for="style of iconLibraries.default.style" :value="style" size="small" :aria-selected="computed.icon.style === style ? 'true' : null">
<icons-card type="style" :style="style" :defaults="computed.icon"></icons-card>
</wa-radio>
@@ -174,6 +237,9 @@ unlisted: true
</ui-panel-container>
<div class="preview-container">
<iframe class="preview" ref="preview" :src="`{{ page.url }}../preview/${ui.preview}/${urlParams}`"></iframe>
<wa-comparison position="80">
<iframe slot="before" class="preview" ref="previewInvert" :src="`{{ page.url }}../preview/${ui.preview}/${urlParamsInvert}`"></iframe>
<iframe slot="after" class="preview" ref="preview" :src="`{{ page.url }}../preview/${ui.preview}/${urlParams}`"></iframe>
</wa-comparison>
</div>
</wa-page>

View File

@@ -1,7 +1,7 @@
@import url('/assets/styles/theme-icons.css');
@import url('/assets/vue/components/panel.css');
@import url('/assets/vue/components/scrollable.css');
@import url('/assets/vue/components/ui-slider.css');
:root {
--ui-border: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-surface-border);
}
@@ -80,6 +80,16 @@ wa-page > header {
max-inline-size: 140ch;
margin: auto;
wa-comparison {
height: 100%;
&::part(base),
&::part(before),
&::part(after) {
height: 100%;
}
}
iframe.preview {
border-radius: var(--wa-border-radius-m);
border: var(--ui-border);
@@ -118,6 +128,14 @@ wa-radio-group.card-picker {
--spacing: 0;
}
> wa-callout {
margin-bottom: 0;
&[size='small'][variant='danger'] {
font-weight: var(--wa-font-weight-semibold);
}
}
> footer {
display: flex;
align-items: center;
@@ -147,3 +165,76 @@ wa-card wa-icon.angle-right {
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
}
.dimensionality-card {
.page-name .wa-caption-m {
display: none;
}
}
.ui-slider {
.corner,
.borders,
.spacing {
aspect-ratio: 1;
width: max(var(--wa-font-size-l), var(--wa-border-radius-l) * 1.25);
}
.corner,
.borders {
--border-color: var(--wa-color-border-normal);
background-color: var(--wa-color-fill-quiet);
border-color: var(--border-color, var(--wa-color-border-normal));
border-style: solid;
border-width: var(--border-width, var(--wa-border-width-m));
&:is(wa-button:hover *) {
background-color: var(--wa-color-fill-normal);
}
}
.corner {
border-bottom: none;
border-left: none;
border-top-right-radius: var(--border-radius);
}
.spacing {
display: grid;
grid-template-columns: 3fr 2fr;
gap: calc(2px + var(--spacing) * 2px);
border-radius: 2px;
div {
padding: calc(2px + var(--spacing) * 1px);
border-radius: inherit;
background-color: var(--wa-color-border-quiet);
}
div:nth-child(3) {
grid-column: 2;
grid-row: 1 / 3;
}
}
&:hover,
&:focus-within {
.corner,
.borders {
--border-color: var(--wa-color-brand);
}
.spacing {
background-color: lab(from var(--wa-color-brand) l a b / 10%);
&:is(wa-button:hover *) {
background-color: lab(from var(--wa-color-brand) l a b / 15%);
div {
background-color: var(--wa-color-border-normal);
}
}
}
}
}

View File

@@ -3,11 +3,6 @@ title: Glossy
description: Bustling with plenty of luster and shine.
isPro: true
tags: pro
palette: elegant
brand: indigo
fonts:
body: Figtree
icons:
family: classic
style: solid
---

View File

@@ -3,15 +3,10 @@ title: Matter
description: Digital design inspired by the real world.
isPro: true
tags: pro
palette: mild
brand: indigo
fonts:
body: 'Wix Madefor Text'
code: Roboto Mono
longform: Roboto Serif
icons:
family: classic
style: regular
---
Set the page theme to "{{ title }}" from the top right to preview the following examples.

View File

@@ -2,13 +2,8 @@
title: Mellow
description: Soft and soothing, like a lazy Sunday morning.
isPro: true
tags: pro
palette: natural
fonts:
body: Mulish
heading: Lora
longform: Lora
icons:
family: classic
style: solid
---

View File

@@ -3,13 +3,9 @@ title: Playful
description: Cheerful and engaging, like a playground on screen.
isPro: true
tags: pro
palette: rudimentary
brand: purple
dimension: Smooth
fonts:
body: Nunito
heading: Fredoka
code: Azeret Mono
icons:
family: classic
style: solid
---

View File

@@ -3,13 +3,8 @@ title: Premium
description: The ultimate in sophistication and style.
isPro: true
tags: pro
palette: anodized
brand: cyan
fonts:
body: DM Sans
heading: Playfair Display
longform: Playfair
icons:
family: sharp
style: regular
---

View File

@@ -1,7 +1,7 @@
import palettes from '../../palettes/data.js';
import themes from '../data.js';
import { allHues } from '/assets/data/index.js';
import { themeConfig, themeDefaults, themeParams } from '/assets/data/theming.js';
import palettes from '/assets/data/palettes.js';
import themes from '/assets/data/themes.js';
import { getPath, themeConfig, themeDefaults, themeParams } from '/assets/data/theming.js';
import Permalink from '/assets/scripts/permalink.js';
import { getThemeCode } from '/assets/scripts/tweak/code.js';
import { deepClone, deepEach, deepGet, deepMerge } from '/assets/scripts/util/deep.js';
@@ -58,9 +58,17 @@ export const documentTheme = { ...theme };
if (location.search) {
let permalink = new Permalink();
// Apply any overrides from URL
let urlOverrides = permalink.getAll();
let urlOverrides = permalink.toObject({
ignoreKeys: ['color-scheme'],
getPath,
});
updateTheme(urlOverrides, { silent: true });
let colorScheme = permalink.get('color-scheme');
if (colorScheme) {
document.body.classList.add('wa-' + colorScheme);
}
}
theme.base ??= 'default';
@@ -168,6 +176,8 @@ export async function updatePreview(options = {}) {
let changeDom = false;
// DOM diffing of old and new <link> elements
// We want to keep any <link> elements that have not changed,
// and add any new ones near the old ones, in the right order
for (let aspect of themeParams) {
allStylesheets[aspect] ??= {};
let stylesheets = allStylesheets[aspect];
@@ -238,7 +248,15 @@ export async function updatePreview(options = {}) {
first.before(link);
} else {
// If no first, it means we didn't find any theme stylesheets
document.head.append(link);
// We may still have <style> elements though
let firstStyleElement = document.querySelector(
'style:is(.wa-themer, .wa-palette, [class^="wa-theme-"], [class*=" wa-theme-"])',
);
if (firstStyleElement) {
firstStyleElement.before(link);
} else {
document.head.append(link);
}
}
}

View File

@@ -3,11 +3,6 @@ title: Tailspin
description: Like a bird in flight, guiding you from there to here.
isPro: true
tags: pro
palette: vogue
brand: indigo
fonts:
body: Inter
icons:
family: classic
style: solid
---