Themer first slice (#857)

* Basic scaffolding

* Generate theme & palette data.js that other JS can import

* Make it possible to include page-card without links

* WIP

* Add `appearance` to details, closes #569

Except `accent` as that's a) far less useful and b) trickier due to the icon color

* Fix broken link

* WIP

* WIP

* Icons icon

* Unify styles for interactive cards

* Prevent focusing inside theme icons

* Fixes

* Action page cards

* Panel scrollables

* scrollable

* Scroll shadows

* Add renaming UI

* UI

* Move styling of heading icons to `ui.css`

* Support permalinks & CRUD

* Make clickable cards more accessible

* Style cards a little better

* Default to styles panel if theme is selected

* Update theme-icons.css

* Custom themes should be saved under Custom

* Get theme code

* Bigger title

* Fixes

* Use theme Vue app for remixing too

* Fix preview jank and make preview script more flexible

* Make radio groups scrollable

* Add affordance to button cards

* Sticky

* `<color-select>`

* Fix theme remixing

* Improve previewing logic

* Fix preview

* Move `domChange()` to separate module

`theme-picker.js` includes side-effects, which may not always be desirable everywhere we may want to import `domChange()`

* Update preview.js

* Panel animation

* Hide Save button if no changes and not saved

* Do not show blank code when no selection has been made

* Use theme slug in filename

* Remove unused component

* Better UI for editing title (and any other text)

* Tweak UI of renaming

* Better indicate default selection

* Fix preview reverting bug

* Fill out app preview with more examples

* Remove `zoom` from theme showcase (yields unexpected/painful results Safari), improve display in wider viewports

* Pending delete

* Make styles panel cards scrollable

* Fix some of the Safari issues

* Update search.css

* Update panel.css

* Select preview UI

* Fix typo

* Frame colors setting as color contrast

* Show dark mode in color mappings

* Brand color

* Swatch styling

* Fix caret icon

* Move Starting theme to the same level as other controls

* Rename typography to Fonts

* Fix bug: Swatch select should show swatches from the selected palette

* Move capitalize to shared utils

* Add utils for handling nested objects

* Icons panel

* Update code.js

* Move utils around

* Add fit and finish to sidebar panels

* Theme card: Move icons to separate data structure

* Move data to dedicated folder since we now have a lot more of it

* Add default icon families and variants to themes

* Data

* Add `deepEntries()`

* Add Duotone

* Spruce up icons preview

* Use theme's icon family in showcase

* Font cards

* Font cards

* Add `max-inline-size` to preview container

* Remove alternate preview options

* Remove theme subtitle

* Support FA kit codes

* Remove Pro badges from theme cards

* Use panagram preview for Fonts

* Consistent heading and label capitalization

* Classes for different icons-card types

* Update data.js.njk

* Variable style on icon family cards

* Fix Sharp Duotone

* Clean up FA kit code hint

* Hide non-functional Icon Library field

* Fix theme icon heights

* icon variant -> style in theme metadata

* Fix bug with icons defaults not being shown

* More convenient theme defaults

* Fix bug with non updating URL

* Fix bug

* Fix multiplying badges

* Custom docs pages

* Add Duotone icons to Mellow theme

* Fix 404

* Remove "Create" from sidebar

* Fix bug

* Move vue components to `/assets/`, move their CSS with them

* Safari/FF compatibility

* Make panels scrollable again

* Fix extra spacing

---------

Co-authored-by: lindsaym-fa <dev@lindsaym.design>
This commit is contained in:
Lea Verou
2025-05-09 17:04:06 -04:00
committed by GitHub
parent 38c13640fc
commit bdd25571a8
80 changed files with 4225 additions and 625 deletions

View File

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

View File

@@ -32,7 +32,8 @@
<script type="module" src="/assets/scripts/theme-picker.js"></script>
{# Preset Theme #}
{% if forceTheme %}
{% if noTheme %}
{% elif forceTheme %}
<link id="theme-stylesheet" rel="stylesheet" id="theme-stylesheet" href="/dist/styles/themes/{{ forceTheme }}.css" render="blocking" fetchpriority="high" />
{% else %}
<noscript><link id="theme-stylesheet" rel="stylesheet" id="theme-stylesheet" href="/dist/styles/themes/default.css" render="blocking" fetchpriority="high" /></noscript>

View File

@@ -1,5 +1,5 @@
{%- if not page.data.unlisted -%}
<a href="{{ page.url }}"{{ page.data.keywords | attr('data-keywords') }}>
{% if page.url %}<a href="{{ page.url }}"{{ page.data.keywords | attr('data-keywords') }}>{% endif %}
<wa-card with-header>
<div slot="header">
{% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
@@ -9,5 +9,5 @@
<div class="wa-caption-s">{{ pageSubtitle }}</div>
{%- endif %}
</wa-card>
</a>
{% if page.url %}</a>{% endif %}
{% endif %}

View File

@@ -7,8 +7,8 @@
</div>
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
<wa-avatar shape="rounded" style="--size: 3em; --background-color: var(--wa-color-green-60); --text-color: var(--wa-color-green-95);">
<wa-icon slot="icon" name="sword-laser" family="duotone" style="font-size: 1.5em;"></wa-icon>
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-green-60); --text-color: var(--wa-color-green-95);">
<wa-icon slot="icon" name="sword-laser"></wa-icon>
</wa-avatar>
<div class="wa-stack wa-gap-2xs">
<div class="wa-split wa-gap-2xs">
@@ -23,8 +23,8 @@
</div>
<wa-divider></wa-divider>
<div class="wa-flank">
<wa-avatar shape="rounded" style="--size: 3em; --background-color: var(--wa-color-cyan-60); --text-color: var(--wa-color-cyan-95);">
<wa-icon slot="icon" name="robot-astromech" family="duotone" style="font-size: 1.5em;"></wa-icon>
<wa-avatar shape="rounded" style="--background-color: var(--wa-color-cyan-60); --text-color: var(--wa-color-cyan-95);">
<wa-icon slot="icon" name="robot-astromech"></wa-icon>
</wa-avatar>
<div class="wa-stack wa-gap-2xs">
<div class="wa-split wa-gap-2xs">
@@ -52,7 +52,7 @@
</wa-card>
<wa-card>
<wa-avatar shape="rounded" style="--size: 1.9lh; float: left; margin-right: var(--wa-space-m);">
<wa-icon slot="icon" name="hat-wizard" family="duotone" style="font-size: 1.75em;"></wa-icon>
<wa-icon slot="icon" name="hat-wizard" style="font-size: 1.75em;"></wa-icon>
</wa-avatar>
<p class="wa-body-l" style="margin: 0;">&ldquo;All we have to decide is what to do with the time that is given to us. There are other forces at work in this world, Frodo, besides the will of evil.&rdquo;</p>
</wa-card>
@@ -140,5 +140,196 @@
</div>
</div>
</wa-card>
<wa-card>
<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>
</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>
</wa-card>
<wa-card>
<div class="wa-stack">
<div class="wa-split wa-align-items-start">
<dl class="wa-stack wa-gap-2xs">
<dt class="wa-heading-s">Amount</dt>
<dd class="wa-heading-l">$5,610.00</dd>
</dl>
<wa-badge appearance="filled outlined" variant="success">Paid</wa-badge>
</div>
<wa-divider></wa-divider>
<dl class="wa-stack">
<div class="wa-flank wa-align-items-center">
<dt><wa-icon name="user" label="Name" fixed-width></wa-icon></dt>
<dd>Tom Bombadil</dd>
</div>
<div class="wa-flank wa-align-items-center">
<dt><wa-icon name="calendar-days" label="Date" fixed-width></wa-icon></dt>
<dd><wa-format-date date="2025-03-15"></wa-format-date></dd>
</div>
<div class="wa-flank wa-align-items-center">
<dt><wa-icon name="coin-vertical" fixed-width></wa-icon></dt>
<dd>Paid with copper pennies</dd>
</div>
</dl>
</div>
<div slot="footer">
<a href="" class="wa-cluster wa-gap-2xs">
<span>Download Receipt</span>
<wa-icon name="arrow-right"></wa-icon>
</a>
</div>
</wa-card>
<wa-card>
<div class="wa-stack">
<div class="wa-split">
<div class="wa-cluster wa-heading-l">
<wa-icon name="book-sparkles"></wa-icon>
<h3>Fellowship</h3>
</div>
<wa-badge>Most Popular</wa-badge>
</div>
<span class="wa-flank wa-align-items-baseline wa-gap-2xs">
<span class="wa-heading-2xl">$120</span>
<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>
</div>
<div slot="footer" class="wa-stack wap-gap-s">
<h4 class="wa-heading-s">What You Get</h4>
<div class="wa-stack">
<div class="wa-flank">
<wa-icon name="user" fixed-width></wa-icon>
<span class="wa-caption-m">9 users</span>
</div>
<div class="wa-flank">
<wa-icon name="ring" fixed-width></wa-icon>
<span class="wa-caption-m">1 ring</span>
</div>
<div class="wa-flank">
<wa-icon name="chess-rook" fixed-width></wa-icon>
<span class="wa-caption-m">API access to Isengard</span>
</div>
<div class="wa-flank">
<wa-icon name="feather" fixed-width></wa-icon>
<span class="wa-caption-m">Priority eagle support</span>
</div>
</div>
</div>
</wa-card>
<wa-card with-footer>
<div class="wa-flank:end">
<div class="wa-stack wa-gap-xs">
<div class="wa-cluster wa-gap-xs">
<h3 class="wa-heading-s">Migs Mayfeld</h3 class="wa-heading-s">
<wa-badge pill>Admin</wa-badge>
</div>
<span class="wa-caption-m">Bounty Hunter</span>
</div>
<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-icon slot="prefix" name="at"></wa-icon>
Email
</wa-button>
<wa-button appearance="outlined">
<wa-icon slot="prefix" name="phone"></wa-icon>
Phone
</wa-button>
</div>
</wa-card>
<wa-card>
<div class="wa-flank:end">
<a href="" class="wa-flank wa-link-plain">
<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>
<div class="wa-gap-2xs wa-stack">
<span class="wa-heading-s">Second Breakfast</span>
<span class="wa-caption-m">19 Items</span>
</div>
</a>
<wa-dropdown>
<wa-icon-button id="more-actions-2" slot="trigger" name="ellipsis-vertical" label="View menu"></wa-icon-button>
<wa-menu>
<wa-menu-item>Copy link</wa-menu-item>
<wa-menu-item>Rename</wa-menu-item>
<wa-menu-item>Move to trash</wa-menu-item>
</wa-menu>
</wa-dropdown>
<wa-tooltip for="more-actions-2">View menu</wa-tooltip>
</div>
</wa-card>
<wa-card with-header with-footer>
<div slot="header" class="wa-stack wa-gap-xs">
<h2 class="wa-heading-m">Decks</h2>
</div>
<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">
<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>
<div class="wa-stack wa-gap-2xs">
<span class="wa-align-items-center wa-cluster wa-gap-xs wa-heading-s">
Vigilance <wa-icon name="arrow-right"></wa-icon>
</span>
<p class="wa-caption-m">
Protect, defend, and restore as you ready heavy-hitters.
</p>
</div>
</a>
<a href="" class="wa-flank wa-align-items-start wa-link-plain">
<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>
<div class="wa-stack wa-gap-2xs">
<span class="wa-align-items-center wa-cluster wa-gap-xs wa-heading-s">
Command <wa-icon name="arrow-right"></wa-icon>
</span>
<p class="wa-caption-m">
Build imposing armies and stockpile resources.
</p>
</div>
</a>
<a href=""class="wa-flank wa-align-items-start wa-link-plain">
<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>
<div class="wa-stack wa-gap-2xs">
<span class="wa-align-items-center wa-cluster wa-gap-xs wa-heading-s">
Aggression <wa-icon name="arrow-right"></wa-icon>
</span>
<p class="wa-caption-m">
Relentlessly deal damage and apply pressure to your opponent.
</p>
</div>
</a>
<a href="" class="wa-flank wa-align-items-start wa-link-plain">
<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>
<div class="wa-stack wa-gap-2xs">
<span class="wa-align-items-center wa-cluster wa-gap-xs wa-heading-s">
Cunning <wa-icon name="arrow-right"></wa-icon>
</span>
<p class="wa-caption-m">
Disrupt and frustrate your opponent with dastardly tricks.
</p>
</div>
</a>
</div>
</div>
<div slot="footer">
<a href="" class="wa-cluster wa-gap-xs">
<span>Or start a deck from scratch</span>
<wa-icon name="arrow-right"></wa-icon>
</a>
</div>
</wa-card>
</div>
</div>

View File

@@ -2,6 +2,7 @@
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}">
<head>
{% include 'head.njk' %}
{% block head %}{% endblock %}
</head>
<body class="layout-{{ layout | stripExtension }}">

View File

@@ -122,19 +122,8 @@
</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 }}">
{{ h | capitalize }}
</wa-tooltip>
{%- endif -%}
{%- endfor -%}
<div slot="label">
Gray undertone
</div>
</wa-radio-group>
<swatch-select label="Gray undertone" shape="circle" :values="hues" v-model="grayColor"></swatch-select>
<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"

View File

@@ -5,35 +5,22 @@
{% extends '../_includes/base.njk' %}
{% block head %}
<script>
globalThis.wa_data ??= {};
wa_data.baseTheme = "{{ page.fileSlug }}";
wa_data.themes = {
{% for theme in collections.theme -%}
"{{ theme.fileSlug }}": {
"title": "{{ theme.data.title }}",
"palette": "{{ theme.data.palette }}",
"brand": "{{ theme.data.brand }}"
},
{% endfor %}
};
wa_data.palettes = {
{% for palette in collections.palette -%}
"{{ palette.fileSlug }}": {
"title": "{{ palette.data.title }}",
},
{% endfor %}
};
</script>
<link href="/docs/themes/remix.css" rel="stylesheet">
<script src="/docs/themes/remix.js" type="module"></script>
<link href="{{ page.url }}../remix.css" rel="stylesheet">
<script type="module" src="{{ page.url }}../edit/index.js"></script>
{% endblock %}
{% block header %}
<script>
if (location.pathname.endsWith('/custom/') && !location.search) {
location.href = "../edit/";
}
</script>
<div id="theme-app" data-theme-id="{{ page.fileSlug }}">
<iframe src='{{ page.url }}demo.html' id="demo"></iframe>
<iframe ref="preview" :src="'{{ page.url }}demo.html' + urlParams" src='{{ page.url }}demo.html' id="demo"></iframe>
<wa-details id="mix_and_match" class="wa-gap-m" >
{% if page.fileSlug !== 'custom' %}
<wa-details id="mix_and_match" class="wa-gap-m" :open="saved || unsavedChanges">
<h4 slot="summary" data-no-anchor data-no-outline id="remix">
<wa-icon name="arrows-rotate"></wa-icon>
Remix this theme
@@ -41,92 +28,64 @@ wa_data.palettes = {
<wa-tooltip for="what-is-remixing">Customize this theme by changing its colors and/or remixing it with design elements from other themes!</wa-tooltip>
</h4>
<wa-select name="colors" label="Colors from…" value="" clearable>
<wa-icon name="palette" slot="prefix" variant="regular"></wa-icon>
{% for theme in collections.theme | sort %}
{% set currentTheme = theme.fileSlug == page.fileSlug %}
<wa-option label="{{ theme.data.title }}" value="{{ theme.fileSlug if not currentTheme }}" {{ (theme.fileSlug if currentTheme) | attr('data-id') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/theme-color.njk" %}
</div>
<span class="page-name">
{{ theme.data.title }}
{% if theme.data.isPro %}<wa-badge class="pro">PRO</wa-badge>{% endif %}
{% if currentTheme %}<wa-badge variant="neutral" appearance="outlined">This theme</wa-badge>{% endif %}
</span>
</wa-card>
</wa-option>
{% endfor %}
</wa-select>
<wa-select name="palette" label="Palette" clearable>
<wa-select name="palette" label="Color palette" clearable v-model="theme.palette">
<wa-icon name="swatchbook" slot="prefix" variant="regular"></wa-icon>
{% set defaultPalette = palette %}
{% for palette in collections.palette | sort %}
{% set currentPalette = palette.fileSlug == defaultPalette %}
<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 %}
</div>
<span class="page-name">
{{ palette.data.title }}
{% if palette.data.isPro %}<wa-badge class="pro">PRO</wa-badge>{% endif %}
{% if currentPalette %}<wa-badge variant="neutral" appearance="outlined">Theme default</wa-badge>{% endif %}
</span>
</wa-card>
</wa-option>
{% endfor %}
{% set palette = defaultPalette %}
<wa-option v-for="(palette, paletteId) in palettes" :label="palette.title" :value="paletteId === baseTheme.palette ? '' : paletteId">
<palette-card :palette="paletteId" size="small">
<template #extra>
<wa-badge v-if="paletteId === baseTheme.palette" variant="neutral" appearance="outlined">Theme default</wa-badge>
</template>
</palette-card>
</wa-option>
</wa-select>
<wa-select name="brand" label="Brand color" value="" clearable>
<div class="selected-swatch" slot="prefix"></div>
{% for hue in hues %}
{% set currentBrand = hue == brand %}
<wa-option label="{{ hue | capitalize }}" value="{{ hue if not currentBrand }}" {{ (hue if currentBrand) | attr('data-id') }} style="--color: var(--wa-color-{{ hue }})">
{{ hue | capitalize }}
{% if currentBrand %}<wa-badge variant="neutral" appearance="outlined">Theme default</wa-badge>{% endif %}
<color-select :model-value="computed.brand" @update:model-value="value => theme.brand = value" label="Brand color"
:values="hues"></color-select>
<wa-select name="colors" class="theme-colors-select" label="Color contrast from…" value="" clearable v-model="theme.colors">
<wa-icon name="palette" slot="prefix" variant="regular"></wa-icon>
<template v-for="(themeMeta, themeId) in themes">
<wa-option v-if="themeId !== 'custom'" :label="themeMeta.title" :value="themeId === computed.colors ? '' : themeId">
<theme-card :theme="themeId" type="colors" :rest="{base: computed.base, palette: computed.palette, brand: computed.brand}" size="small">
<template #extra>
<wa-badge v-if="themeId === theme.base" variant="neutral" appearance="outlined">This theme</wa-badge>
</template>
</theme-card>
</wa-option>
{% endfor %}
</template>
</wa-select>
<wa-select name="typography" label="Typography from…" clearable>
<wa-select name="typography" label="Typography from…" clearable v-model="theme.typography">
<wa-icon name="font-case" slot="prefix"></wa-icon>
{% for theme in collections.theme | sort %}
{% set currentTheme = theme.fileSlug == page.fileSlug %}
<wa-option label="{{ theme.data.title }}" value="{{ theme.fileSlug if not currentTheme }}" {{ (theme.fileSlug if currentTheme) | attr('data-id') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/theme-typography.njk" %}
</div>
<span class="page-name">
{{ theme.data.title }}
{% if theme.data.isPro %}<wa-badge class="pro">PRO</wa-badge>{% endif %}
{% if currentTheme %}<wa-badge variant="neutral" appearance="outlined">This theme</wa-badge>{% endif %}
</span>
</wa-card>
</wa-option>
{% endfor %}
<wa-option v-for="(themeMeta, themeId) in themes" :label="themeMeta.title" :value="themeId === theme.base ? '' : themeId">
<fonts-card :theme="themeId" size="small">
<template #extra>
<wa-badge v-if="themeId === theme.base" variant="neutral" appearance="outlined">This theme</wa-badge>
</template>
</fonts-card>
</wa-option>
</wa-select>
</wa-details>
{% endif %}
<h2>Color</h2>
{% set paletteURL = '/docs/palettes/' + palette + '/' %}
<div class="index-grid">
{% set themePage = page %}
{% set page = paletteURL | getCollectionItemFromUrl %}
{% set pageSubtitle = "Default color palette" %}
{% include 'page-card.njk' %}
{% set page = themePage %}
<wa-card style="--header-background: var(--wa-color-{{ brand }})" class="wa-palette-{{ palette }}">
{% if page.fileSlug === 'custom' %}
<palette-card :palette="computed.palette" subtitle="Color palette"></palette-card>
{% else %}
{% set themePage = page %}
{% set paletteURL = '/docs/palettes/' + palette + '/' %}
{% set page = paletteURL | getCollectionItemFromUrl %}
{% set pageSubtitle = "Default color palette" %}
{% include 'page-card.njk' %}
{% set page = themePage %}
{% endif %}
<wa-card class="wa-palette-{{ palette }}" style="--header-background: var(--wa-color-{{ brand }})"
:class="`wa-palette-${computed.palette}`" :style="{'--header-background': palettes[computed.palette]?.colors[computed.brand]?.key}">
<div slot="header"></div>
<div class="page-name">{{ brand | capitalize }}</div>
<div class="wa-caption-s">Default brand color</div>
<div class="page-name" v-content="capitalize(computed.brand)">{{ brand | capitalize }}</div>
<div class="wa-caption-s">{{ 'Brand color' if page.fileSlug === 'custom' else 'Default brand color' }}</div>
</wa-card>
</div>
{% endblock %}
@@ -139,7 +98,25 @@ wa_data.palettes = {
You can import this theme from the Web Awesome CDN.
{% set stylesheet = 'styles/themes/' + page.fileSlug + '.css' %}
{% include 'import-stylesheet-code.md.njk' %}
<wa-tab-group class="import-stylesheet-code">
<wa-tab panel="html">In HTML</wa-tab>
<wa-tab panel="css">In CSS</wa-tab>
<wa-tab-panel name="html">
Add the following code to the `<head>` of your page:
```html { v-content:html="code.html.highlighted" }
<link rel="stylesheet" href="{% cdnUrl stylesheet %}" />
```
</wa-tab-panel>
<wa-tab-panel name="css">
Add the following code at the top of your CSS file:
```css { v-content:html="code.css.highlighted" }
@import url('{% cdnUrl stylesheet %}');
```
</wa-tab-panel>
</wa-tab-group>
## Dark mode
@@ -203,5 +180,6 @@ systemDark.addEventListener('change', applyDark);
applyDark();
```
</div> {# end theme app #}
{% endmarkdown %}
{% endblock %}

View File

@@ -1,21 +1,9 @@
/**
* Data related to theme remixing and palette tweaking
* Data related to palettes and colors.
* Must work in both browser and Node.js
*/
export const cdnUrl = globalThis.document ? document.documentElement.dataset.cdnUrl : '/dist/';
export const urls = {
theme: id => `styles/themes/${id}.css`,
colors: id => `styles/themes/${id}/color.css`,
palette: id => `styles/color/${id}.css`,
brand: id => `styles/brand/${id}.css`,
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 tints = ['05', '10', '20', '30', '40', '50', '60', '70', '80', '90', '95'];
export const hueRanges = {
red: { min: 5, max: 35 }, // 30
@@ -29,6 +17,9 @@ export const hueRanges = {
pink: { min: 320, max: 365 }, // 45
};
export const hues = Object.keys(hueRanges);
export const allHues = [...hues, 'gray'];
export const moreHue = {
red: 'Redder',
orange: 'More orange', // https://www.reddit.com/r/grammar/comments/u9n0uo/is_it_oranger_or_more_orange/
@@ -54,20 +45,3 @@ export const maxGrayChroma = {
purple: 0.3,
pink: 0.25,
};
export const docsURLs = {
colors: '/docs/themes/',
palette: '/docs/palettes/',
typography: '/docs/themes/',
};
export const icons = {
colors: 'palette',
palette: 'swatchbook',
brand: 'droplet',
typography: 'font-case',
};
export const hues = Object.keys(hueRanges);
export const tints = ['05', '10', '20', '30', '40', '50', '60', '70', '80', '90', '95'];

65
docs/assets/data/fonts.js Normal file
View File

@@ -0,0 +1,65 @@
import { deepEntries } from '../scripts/util/deep.js';
import { themeConfig } from './theming.js';
import themes from '/assets/data/themes.js';
/**
* Map of font pairings (body + heading) to the first theme that uses them.
*/
export const pairings = {};
// NOTE Do not use Symbols, we want these to be enumerable when used as keys
export const sameAs = { body: '$body' };
export const fontNames = {
'system-ui': 'OS Default',
'ui-serif': 'OS Default Serif',
'ui-sans-serif': 'OS Default Sans Serif',
'ui-monospace': 'OS Default Code Font',
'ui-monospace': 'OS Default Code Font',
};
export function defaultTitle(fonts) {
let { body, heading = sameAs.body } = fonts;
let names = [body];
if (heading !== sameAs.body) {
names.unshift(heading);
}
return names.map(name => fontNames[name] ?? name).join(' • ');
}
for (let id in themes) {
let theme = themes[id];
let { fonts } = theme;
if (fonts) {
let { body, heading = sameAs.body } = fonts;
pairings[body] ??= {};
pairings[body][heading] ??= {
id, // First theme that uses this pairing
ids: new Set([id]), // All themes that use this pairing
url: themeConfig.typography.url(id), // Stylesheet URL
fonts,
get title() {
return defaultTitle(this.fonts);
},
};
pairings[body][heading].ids.add(id);
}
}
export const pairingsEntries = deepEntries(pairings, {
descend(value, key, parent, path) {
if (value?.fonts) {
return false; // Don't recurse into pairing objects
}
},
filter(value, key, parent, path) {
// Only keep 2 levels (body → heading → pairing)
return path.length === 1;
},
});
export const pairingsList = pairingsEntries.map(arg => arg.at(-1));

View File

@@ -0,0 +1,7 @@
export const iconLibraries = {
default: {
title: 'Font Awesome',
family: ['classic', 'sharp', 'duotone', 'sharp-duotone'],
style: ['solid', 'regular', 'light', 'thin'],
},
};

View File

@@ -0,0 +1,6 @@
export * from './colors.js';
// export * from './fonts.js';
export * from './icons.js';
export * from './theming.js';
export const cdnUrl = globalThis.document ? document.documentElement.dataset.cdnUrl : '/dist/';

View File

@@ -0,0 +1,32 @@
---
layout: null
permalink: '/assets/data/palettes.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

@@ -0,0 +1,22 @@
---
layout: null
permalink: '/assets/data/themes.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 }},
icons: {{ (theme.data.icons | dump or 'null') | safe }},
},
{%- endif %}
{% endfor %}
};

View File

@@ -0,0 +1,82 @@
import { deepEach, isPlainObject } from '../scripts/util/deep.js';
/**
* Data related to themes, theme remixing
* Must work in both browser and Node.js
*/
export const cdnUrl = globalThis.document ? document.documentElement.dataset.cdnUrl : '/dist/';
// This should eventually replace all uses of `urls` and `themeParams`
export const themeConfig = {
base: { url: id => `styles/themes/${id}.css`, default: 'default' },
colors: {
url: id => `styles/themes/${id}/color.css`,
docs: '/docs/themes/',
icon: 'palette',
default() {
return this.base;
},
},
palette: {
url: id => `styles/color/${id}.css`,
docs: '/docs/palette/',
icon: 'swatchbook',
default(baseTheme) {
return baseTheme?.palette;
},
},
brand: {
url: id => `styles/brand/${id}.css`,
icon: 'droplet',
default(baseTheme) {
return baseTheme?.brand;
},
},
typography: {
url: id => `styles/themes/${id}/typography.css`,
docs: '/docs/themes/',
icon: 'font-case',
default() {
return this.base;
},
},
icon: {
library: { cssProperty: '--wa-icon-library', default: 'default' },
family: {
cssProperty: '--wa-icon-family',
default(baseTheme) {
return baseTheme?.icon?.family ?? 'classic';
},
},
style: {
cssProperty: '--wa-icon-variant',
default(baseTheme) {
return baseTheme?.icon?.style ?? 'solid';
},
},
},
};
// 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);
export const urls = themeParams.reduce((acc, aspect) => {
acc[aspect] = themeConfig[aspect].url;
return acc;
}, {});
export const themeDefaults = { ...themeConfig };
deepEach(themeDefaults, (value, key, parent, path) => {
if (isPlainObject(value)) {
// Replace w/ default value or shallow clone
return value.default ?? { ...value };
}
});
export const selectors = {
palette: id =>
[':where(:root)', ':host', ":where([class^='wa-theme-'], [class*=' wa-theme-'])", `.wa-palette-${id}`].join(',\n'),
theme: id => [':where(:root)', ':host', `.wa-theme-${id}`].join(',\n'),
};

View File

@@ -165,3 +165,8 @@ my.palettes = new SavedEntities({
key: 'savedPalettes',
type: 'palette',
});
my.themes = new SavedEntities({
key: 'savedThemes',
type: 'theme',
});

View File

@@ -1,4 +1,4 @@
const IDENTITY = x => x;
import { deepEach, deepGet, deepSet } from './util/deep.js';
export default class Permalink extends URLSearchParams {
/** Params changed since last URL I/O */
@@ -13,6 +13,59 @@ export default class Permalink extends URLSearchParams {
return Object.fromEntries(this.entries());
}
/**
* Set multiple values from an object. Nested values will be joined with a hyphen.
* @param {object} values - The object containing the values to set.
* @param {object} defaults - The object containing the default values.
*
*/
setAll(values, defaults) {
deepEach(values, (value, key, parent, path) => {
let fullPath = [...path, key];
let param = fullPath.join('-');
let defaultValue = deepGet(defaults, fullPath);
if (typeof value === 'object') {
// We'll handle this when we descend into it
return;
}
if (!value || value === defaultValue) {
// Remove the param from the URL
this.delete(param);
return;
}
this.set(param, value);
});
}
getAll(...args) {
if (args.length > 0) {
return super.getAll(...args);
}
// 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('-');
deepSet(obj, path, value);
}
return obj;
}
delete(key, value) {
let hadValue = this.has(key);
super.delete(key, value);
if (hadValue) {
this.changed = true;
}
}
set(key, value, defaultValue) {
if (equals(value, defaultValue) || equals(value, '')) {
value = null;

View File

@@ -74,7 +74,8 @@ const sidebar = {
a = sidebar.addChild(a, parentA);
// This is mainly to port Pro badges
let badges = Array.from(parentLi.querySelectorAll('wa-badge'), badge => badge.cloneNode(true));
let badges = Array.from(parentLi.querySelectorAll(':scope > wa-badge'), badge => badge.cloneNode(true));
let append = [...badges];
if (entity.delete) {

View File

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

View File

@@ -1,7 +1,8 @@
/**
* Get import code for remixed themes and tweaked palettes.
*/
import { urls } from './data.js';
import { selectors, themeConfig } from '../../data/theming.js';
import { deepEach, deepGet } from '/assets/scripts/util/deep.js';
export function cssImport(url, options = {}) {
let { language = 'html', cdnUrl = '/dist/', attributes } = options;
@@ -21,29 +22,65 @@ export function cssLiteral(value, options = {}) {
if (language === 'css') {
return value;
} else {
return `<style>\n${value}\n</style>`;
return `<style${options.attributes ?? ''}>\n${value}\n</style>`;
}
}
// Params in correct order
export const themeParams = ['colors', 'palette', 'brand', 'typography'];
/**
* Get code for a theme, including tweaks
* @param {*} theme
* @param {*} options
* @returns
*/
export function getThemeCode(theme, options = {}) {
let urls = [];
let declarations = [];
let id = options.id ?? theme.base ?? 'default';
export function getThemeCode(base, params, options) {
let ret = [];
deepEach(themeConfig, (config, aspect, obj, path) => {
if (!config?.default) {
// We're not in a config object
return;
}
if (base) {
ret.push(urls.theme(base));
}
let value = deepGet(theme, [...path, aspect]);
for (let aspect of themeParams) {
let value = params[aspect];
if (!value) {
return;
}
if (value) {
ret.push(urls[aspect](value));
if (config.url) {
// This is implemented by pulling in different CSS files
urls.push(config.url(value));
} else {
if (config.cssProperty) {
declarations.push(`${config.cssProperty}: ${value};`);
}
}
});
let ret = urls.map(url => cssImport(url, options)).join('\n');
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) {
options.attributes ??= '';
options.attributes += faKitAttribute;
cssCode =
`/* Note: To use Font Awesome Pro icons,\n set ${faKitAttribute} on the <link> (or any other) element */\n\n` +
cssCode;
}
cssCode = cssLiteral(cssCode, options);
if (ret) {
ret += '\n\n' + cssCode;
}
}
return ret.map(url => cssImport(url, options)).join('\n');
return ret;
}
export function cssRule(selector, declarations, { indent = ' ' } = {}) {

View File

@@ -0,0 +1,17 @@
/**
* Picks a random element from an array.
* @param {any[]} arr
*/
export function sample(arr) {
if (!Array.isArray(arr)) {
return arr;
}
if (arr.length < 2) {
return arr[0];
}
let index = Math.floor(Math.random() * arr.length);
return arr[index];
}

View File

@@ -0,0 +1,180 @@
/**
* @typedef { string | number | Symbol } Property
* @typedef { (value: any, key: Property, parent: object, path: Property[]) => any } EachCallback
*/
export function isPlainObject(obj) {
return isObject(obj, 'Object');
}
export function isObject(obj, type) {
if (!obj || typeof obj !== 'object') {
return false;
}
let proto = Object.getPrototypeOf(obj);
return proto.constructor?.name === type;
}
export function deepMerge(target, source, options = {}) {
let {
emptyValues = [undefined],
containers = ['Object', 'EventTarget'],
isContainer = value => containers.some(type => isObject(value, type)),
} = options;
if (isContainer(target) && isContainer(source)) {
for (let key in source) {
if (key in target && isContainer(target[key]) && isContainer(source[key])) {
target[key] = deepMerge(target[key], source[key], options);
} else if (!emptyValues.includes(source[key])) {
target[key] = source[key];
}
}
return target;
}
return target ?? source;
}
/**
* Iterate over a deep array, recursively for plain objects
* @param { any } obj The object to iterate over. Can be an array or a plain object, or even a primitive value.
* @param { EachCallback } callback. value is === parent[key]
* @param { object } [parentObj] The parent object of the current value Mainly used internally to facilitate recursion.
* @param { Property } [key] The key of the current value. Mainly used internally to facilitate recursion.
* @param { Property[] } [path] Any existing path (not including the key). Mainly used internally to facilitate recursion.
*/
export function deepEach(obj, callback, parentObj, key, path = []) {
if (key !== undefined) {
let ret = callback(obj, key, parentObj, path);
if (ret !== undefined) {
if (ret === false) {
// Do not descend further
return;
}
// Overwrite value
parentObj[key] = ret;
obj = ret;
}
}
let newPath = key !== undefined ? [...path, key] : path;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
deepEach(obj[i], callback, obj, i, newPath);
}
} else if (isPlainObject(obj)) {
for (let key in obj) {
deepEach(obj[key], callback, obj, key, newPath);
}
}
}
/**
* Get a value from a deeply nested object
* @param {*} obj
* @param {PropertyPath} path
* @returns
*/
export function deepGet(obj, path) {
if (path.length === 0) {
return obj;
}
let ret = obj;
for (let key of path) {
if (ret === undefined) {
return undefined;
}
ret = ret[key];
}
return ret;
}
/**
* Set a value in a deep object, creating object literals as needed
* @param { * } obj
* @param { Property[] } path
* @param { any } value
*/
export function deepSet(obj, path, value) {
if (path.length === 0) {
return;
}
let key = path.pop();
let ret = path.reduce((acc, property) => {
if (acc[property] === undefined) {
acc[property] = {};
}
return acc[property];
}, obj);
ret[key] = value;
}
export function deepClone(obj) {
if (!obj) {
return obj;
}
let ret = obj;
if (Array.isArray(obj)) {
ret = obj.map(item => deepClone(item));
} else if (isPlainObject(obj)) {
ret = { ...obj };
for (let key in obj) {
ret[key] = deepClone(obj[key]);
}
}
return ret;
}
/**
* Like Object.entries, but for deeply nested objects.
* For shallow objects the output is the same as Object.entries.
* @param {*} obj
* @param { object } options
* @param { EachCallback } each - If this returns false, the entry is not added to the result and the recursion is stopped.
* @param { EachCallback } filter - If this returns false, the entry is not added to the result.
* @param { EachCallback } descend - If this returns false, recursion is stopped.
* @returns {any[][]}
*/
export function deepEntries(obj, options = {}) {
let { each, filter, descend } = options;
let entries = [];
deepEach(obj, (value, key, parent, path) => {
let ret = each?.(value, key, parent, path);
if (ret !== false) {
let included = filter?.(value, key, parent, path) ?? true;
if (included) {
entries.push([...path, key, value]);
}
let descendRet = descend?.(value, key, parent, path);
if (descendRet === false) {
return false; // Stop recursion
}
}
return ret;
});
return entries;
}

View File

@@ -35,3 +35,5 @@ export function domChange(fn, { behavior = 'smooth', ignoreInitialLoad = true }
return null;
}
}
export default domChange;

View File

@@ -0,0 +1,24 @@
/**
* Make the first letter of a string uppercase
* @param {*} str
* @returns
*/
export function capitalize(str) {
str += '';
return str[0].toUpperCase() + str.slice(1);
}
/**
* Convert a readable string to a slug.
* @param {*} str - Input string. If argument is not a string, it will be stringified.
* @returns {string} - The slugified string
*/
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();
}

View File

@@ -256,15 +256,6 @@ wa-page > main {
}
h1.title {
wa-icon-button {
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
&:not(:hover, :focus) {
opacity: 0.5;
}
}
wa-badge {
vertical-align: middle;
font-size: 1.5rem;
@@ -392,19 +383,7 @@ wa-page > main:has(> .index-grid) {
}
wa-card {
box-shadow: none;
--spacing: var(--wa-space-m);
inline-size: 100%;
&:hover {
--border-color: var(--wa-color-brand-border-loud);
border-color: var(--border-color);
box-shadow: 0 0 0 var(--wa-border-width-s) var(--border-color);
.page-name {
color: var(--wa-color-brand-on-quiet);
}
}
[slot='header'] {
display: flex;
@@ -418,11 +397,11 @@ wa-page > main:has(> .index-grid) {
min-block-size: calc(6rem + var(--spacing));
}
}
}
.page-name {
font-size: var(--wa-font-size-s);
font-weight: var(--wa-font-weight-action);
}
wa-card .page-name {
font-size: var(--wa-font-size-s);
font-weight: var(--wa-font-weight-action);
}
.index-category {
@@ -431,6 +410,146 @@ wa-page > main:has(> .index-grid) {
margin-block-start: var(--wa-space-2xl);
}
/* Interactive cards */
wa-card[role='button'][tabindex='0'],
button,
a[href],
wa-option,
wa-radio,
wa-checkbox {
/* Disabled state */
&:is(:disabled, [disabled], [aria-disabled='true']) {
&:is(wa-card, :has(> wa-card)) {
opacity: 60%;
cursor: not-allowed;
}
}
&:where(:not(:disabled, [disabled], [aria-disabled='true'])) {
&:has(> wa-card) {
/* Parents only (not interactive <wa-card>) */
margin: calc(var(--wa-border-width-m) + 1px);
padding: 0;
/* Hover state */
&:hover,
&:state(hover),
&:state(current) {
/* Do not change the parent background as a hover effect (we style the card instead) */
background: transparent !important;
}
&::part(control),
&:is(wa-option)::part(checked-icon) {
--background-color-checked: var(--wa-color-brand-fill-loud);
--checked-icon-scale: 0.5;
--offset: var(--wa-space-2xs);
position: absolute;
inset: calc(var(--offset) + var(--wa-border-width-m));
inset-block-end: auto;
inset-inline-start: auto;
z-index: 1;
margin: 0;
background: var(--wa-color-brand-fill-loud);
color: var(--wa-color-brand-on-loud);
}
&::part(checked-icon) {
color: var(--wa-color-brand-on-loud);
}
&:is(wa-option)::part(checked-icon) {
inset-block-start: calc(var(--wa-space-smaller) - 0.5em);
inset-inline-end: calc(var(--wa-space-smaller) - 0.5em);
width: 1em;
height: 1em;
line-height: 1em;
padding: 0.4em;
border-radius: var(--wa-border-radius-circle);
text-align: center;
font-size: var(--wa-font-size-xs);
}
}
/* Hover state */
&:hover,
&:state(hover),
&:state(current) {
&:is(wa-card),
> wa-card {
--border-color: var(--wa-color-brand-border-loud);
border-color: var(--border-color);
box-shadow: 0 0 0 var(--wa-border-width-s) var(--border-color);
}
}
&:is(wa-card, :has(> wa-card)) {
/* Interactive card parent */
position: relative;
cursor: pointer;
/* Unselected state */
&:where(:not(:state(checked), :state(selected), [aria-checked='true'], [aria-selected='true'])) {
&::part(checked-icon),
&::part(control) {
display: none;
}
}
}
&:is(wa-card),
> wa-card {
/* The card itself */
box-shadow: none;
}
}
}
/* Selected cards */
:state(selected),
:state(checked),
[aria-checked='true'],
[aria-selected='true'] {
&:is(wa-card, :has(> wa-card)) {
background: transparent;
}
&:is(wa-card),
> wa-card {
--border-color: var(--wa-color-brand-border-loud);
box-shadow: 0 0 0 var(--wa-border-width-m) var(--border-color);
&::part(body) {
background: var(--wa-color-brand-fill-quiet);
}
}
}
wa-select:has(> wa-option > wa-card) {
&::part(listbox) {
--column-width: 1fr;
--columns: 1;
--gap: var(--wa-space-smaller);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--column-width), 1fr));
width: calc(var(--columns) * var(--column-width) + (var(--columns) - 1) * var(--gap) + 2 * var(--wa-space));
max-width: var(--auto-size-available-width, 90vw);
gap: var(--gap);
padding: var(--wa-space-smaller) var(--wa-space);
}
> wa-option > wa-card {
--spacing: var(--wa-space-s);
}
}
wa-radio:has(> wa-card) {
grid-template-columns: 1fr;
width: auto;
}
/* Swatches */
.swatch {
position: relative;

View File

@@ -41,9 +41,9 @@
}
/* Header */
header {
#site-search-container header {
flex: 0 0 auto;
align-items: middle;
align-items: center;
/* Fixes an iOS Safari 16.4 bug that draws the parent element's border radius incorrectly when showing/hiding results */
border-radius: var(--wa-border-radius-l);
}

View File

@@ -1,4 +1,9 @@
wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
wa-card:has(
> .theme-icon-host,
> [slot='header'] > .theme-icon-host,
> .fonts-icon-host,
> [slot='header'] > .fonts-icon-host
) {
&::part(header) {
/* We want to add a background color, so any spacing needs to go on .theme-icon */
flex: 1;
@@ -12,6 +17,7 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
}
.theme-icon-host,
.fonts-icon-host,
.palette-icon-host {
flex: 1;
border-radius: inherit;
@@ -23,12 +29,17 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
}
}
.theme-icon:not(.theme-color-icon),
.palette-icon,
.icons-icon {
min-height: 5.5rem;
}
.palette-icon {
display: grid;
grid-template-columns: repeat(var(--hues, 9), 1fr);
gap: var(--wa-space-3xs);
min-width: 20ch;
min-height: 9ch;
align-content: center;
.swatch {
@@ -42,7 +53,8 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
}
}
.theme-icon {
.theme-icon,
.fonts-icon {
min-width: 18ch;
padding: var(--wa-space-xs) var(--wa-space-m);
border-radius: inherit;
@@ -57,11 +69,18 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
}
.theme-color-icon {
display: grid;
display: flex;
gap: var(--wa-space-xs);
grid-template-columns: repeat(4, auto);
min-width: 15ch;
background: var(--wa-color-surface-lowered);
& + & {
border-start-start-radius: 0;
border-start-end-radius: 0;
}
div {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -72,26 +91,17 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
padding: var(--wa-space-2xs) var(--wa-space-xs);
color: var(--text-color);
font-weight: var(--wa-font-weight-semibold);
&.plain {
font-weight: var(--wa-font-weight-bold);
}
}
}
.theme-typography-icon {
display: flex;
flex-direction: column;
gap: var(--wa-space-xs);
}
.theme-overall-icon {
.theme-icon.theme-overall-icon,
.fonts-icon {
display: flex;
flex-flow: column;
gap: var(--wa-space-xs);
gap: var(--wa-space-2xs);
justify-content: center;
width: 100%;
min-height: 7.5rem;
min-height: 6.75rem;
box-sizing: border-box;
background: var(--wa-color-surface-lowered);
@@ -130,3 +140,50 @@ wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
}
}
}
.fonts-icon {
font-family: var(--wa-font-family-body);
padding-block: var(--wa-space-s);
overflow: hidden;
position: relative;
& h2,
& p {
white-space: nowrap;
}
&::after {
content: '';
position: absolute;
right: 0;
width: 50%;
height: 100%;
background-image: linear-gradient(to left, var(--wa-color-surface-lowered), 20%, transparent);
}
}
.icons-icon {
display: grid;
grid-template-columns: repeat(var(--columns, 5), auto);
gap: var(--wa-space-xs);
place-items: center;
place-content: center;
& wa-icon {
font-size: 1.25em;
}
}
.page-card {
wa-badge {
margin-inline: var(--wa-space-3xs);
}
}
:is(.theme-card, .icons-card)::part(header) {
background: var(--wa-color-surface-lowered);
}
.icons-card::part(header) {
color: var(--wa-color-neutral-on-quiet);
}

145
docs/assets/styles/ui.css Normal file
View File

@@ -0,0 +1,145 @@
/* App UI, for themer, palette tweaking etc */
:root {
--fa-sliders-simple: '\f1de';
}
.title {
wa-icon-button {
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
&:not(:hover, :focus) {
opacity: 0.5;
}
}
}
.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);
max-height: 90dvh;
overflow: auto;
code {
white-space: nowrap;
}
}
.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);
}
}
.swatch-select {
padding: 2px;
wa-radio-button {
--swatch-border-color: color-mix(in oklab, canvastext, transparent 80%);
&::part(base) {
/* a <button> */
width: 2em;
height: 2em;
padding: 0;
border-radius: var(--border-radius, var(--wa-border-radius-m));
background: var(--color);
background-clip: border-box;
border-color: var(--swatch-border-color);
}
}
&.swatch-shape-circle {
--border-radius: var(--wa-border-radius-circle);
}
wa-radio-button:is([checked], :state(checked)) {
--swatch-border-color: var(--wa-color-surface-default);
&::part(base) {
box-shadow:
inset 0 0 0 var(--indicator-width) var(--wa-color-surface-default),
0 0 0 calc(var(--indicator-width) + 1px) var(--indicator-color);
}
}
&::part(form-control-input) {
flex-wrap: wrap;
gap: var(--wa-space-xs);
}
}
/* Repeated to increase specificity */
.editable-text.editable-text {
display: inline-flex;
align-items: center;
gap: var(--wa-space-xs);
--edit-hint-color: oklab(from var(--wa-color-warning-fill-quiet) l a b / 50%);
> .text {
&:hover,
&:focus {
background-color: var(--edit-hint-color);
box-shadow: 0 0 0 var(--wa-space-2xs) var(--edit-hint-color);
color: inherit;
border-radius: calc(var(--wa-border-radius-m) - var(--wa-space-2xs));
}
}
> input {
font: inherit;
margin-block: calc(-1 * var(--wa-space-smaller));
field-sizing: content;
}
wa-icon-button {
font-size: 90%;
}
}
.info-tip-default-trigger {
color: var(--wa-color-text-quiet);
&:not(:hover, :focus) {
opacity: 65%;
}
}

View File

@@ -0,0 +1,78 @@
import { capitalize } from '../../scripts/util/string.js';
const 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>
`;
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,
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,82 @@
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>
</template>
<template v-else>
<span class="text" ref="wrapper" @focus="edit" @click="edit" tabindex="0">{{ value }}</span>
<wa-icon-button name="pencil" :label="'Edit ' + label" @click="edit"></wa-icon-button>
</template>
</span>
`;
export default {
props: {
modelValue: String,
label: {
type: String,
default: 'Rename',
},
},
emits: ['update:modelValue', 'submit'],
data() {
return {
value: this.modelValue,
previousValue: undefined,
isEditing: false,
};
},
computed: {},
methods: {
edit(event) {
if (this.isEditing) {
return;
}
event.stopPropagation();
this.isEditing = true;
this.previousValue = this.value;
this.$nextTick(() => {
this.$refs.input.focus();
this.$refs.input.select();
});
},
done(event) {
if (!this.isEditing) {
return;
}
event.stopPropagation();
this.isEditing = false;
if (!this.previousValue || this.previousValue !== this.value) {
this.$emit('submit', this.value);
}
},
cancel(event) {
if (!this.isEditing) {
return;
}
event.stopPropagation();
this.isEditing = false;
this.value = this.previousValue;
},
handleInput(event) {
this.value = event.target.value;
},
},
watch: {
value(newValue) {
this.$emit('update:modelValue', newValue);
},
},
template,
};

View File

@@ -0,0 +1,132 @@
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>
<template v-html="html"></template>
<template>
<link rel="stylesheet" href="/dist/styles/native/content.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<div class="fonts-icon" role="presentation">
<h2>When my six o'clock alarm buzzes, I require a pot of good java.</h2>
<p>By quarter past seven, I've jotted hazy musings in a flax-bound notebook, sipping lukewarm espresso.</p>
</div>
</template>
</wa-scoped>
</template>
<slot></slot>
<template #extra>
<slot name="extra" />
</template>
</page-card>
`;
export default {
props: {
theme: String,
src: String,
fonts: Object,
pairing: Object,
},
data() {
return {};
},
computed: {
content() {
let pairingTitle = this.computedPairing.title;
// let themeTitle = this.themeId ? `As seen in ${this.themeMeta.title}` : '';
if (this.title) {
return { title: this.title, subtitle: this.subtitle ?? pairingTitle };
} else {
return { title: pairingTitle, subtitle: this.subtitle };
}
},
url() {
let ret = this.src ?? this.pairing?.url;
if (!ret && this.theme) {
return themeConfig.typography.url(this.theme);
}
return ret;
},
themeId() {
return this.theme ?? this.pairing?.id;
},
themeMeta() {
return themes[this.themeId] ?? {};
},
computedFonts() {
let ret = this.fonts ?? this.pairing?.fonts ?? this.themeMeta?.fonts;
let defaults = themes.default.fonts;
return Object.assign({}, defaults, { ...ret });
},
computedPairing() {
let ret;
if (this.pairing) {
ret = { ...this.pairing };
} else {
// Get from theme
let fonts = this.computedFonts;
let { body, heading = sameAs.body } = fonts;
let pairing = pairings[body]?.[heading];
ret = Object.assign({ fonts }, pairing);
}
ret.url = this.url;
ret.title ??= defaultTitle(fonts);
return ret;
},
computed() {
let ret = { fonts: this.computedFonts };
for (let key in ret.fonts) {
if (ret.fonts[key] === sameAs.body) {
ret.fonts[key] = ret.fonts.body;
}
}
ret.pairing = this.computedPairing;
ret.theme = this.themeId;
ret.url = this.url;
return ret;
},
html() {
let { id, url } = this.computedPairing;
if (id) {
let theme = { typography: id };
return getThemeCode(theme, { id, language: 'html' });
} else {
return cssImport(url, { language: 'html' });
}
},
},
template,
components: {
PageCard,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,175 @@
import { sample } from '../../scripts/util/array.js';
import { capitalize } from '../../scripts/util/string.js';
import PageCard from './page-card.js';
import { iconLibraries } from '/assets/data/icons.js';
const iconNames = [
'user',
'paper-plane',
'face-laugh',
'pen-to-square',
'trash',
'cart-shopping',
'link',
'sun',
'bookmark',
'sparkles',
'thumbs-up',
'gear',
];
const brands = new Set(['web-awesome', 'font-awesome']);
const ICON_GRID = { columns: 6, rows: 2 };
const TOTAL_ICONS = ICON_GRID.columns * ICON_GRID.rows;
const template = `
<page-card class="icons-card" :class="'icons-' + type + '-card'" :pro="$slots.default ? false : iconsMeta.isPro" :info="iconsMeta">
<template #icon>
<div slot="header" class="icons-icon" :class="'icons-' + type + '-icon'" :style="{ '--columns': ICON_GRID.columns }">
<template v-for="icon of icons">
<wa-icon v-bind="icon"></wa-icon>
</template>
</div>
</template>
<slot></slot>
</page-card>
`;
const defaultDefaults = {
library: 'default',
family: 'classic',
style: 'solid',
};
export default {
props: {
library: String,
family: String,
style: String,
defaults: Object,
type: {
type: String,
validate(value) {
return ['library', 'family', 'style'].includes(value);
},
},
vary: {
type: [Array, String],
validate(value) {
if (Array.isArray(value)) {
return value.every(v => ['family', 'style'].includes(v));
}
return ['family', 'style'].includes(value);
},
default() {
return [];
},
},
},
data() {
return {};
},
created() {
Object.assign(this, { iconNames, brands, ICON_GRID });
},
computed: {
computedLibrary() {
return this.library ?? 'default';
},
libraryMeta() {
return iconLibraries[this.computedLibrary] ?? {};
},
defaultTitle() {
let titles = {};
for (let key in this.computed) {
let value = this.computed[key];
if (key === 'library') {
titles[key] = iconLibraries[value].title;
}
titles[key] ??= capitalize(value);
}
if (this.type) {
return titles[this.type];
} else {
return titles.library + ' ' + titles.family + ' • ' + titles.style;
}
},
icons() {
let { family, style } = this.computed;
let library = this.libraryMeta;
let vary = Array.isArray(this.vary) ? this.vary : [this.vary];
let ret = [];
if (vary.length > 0) {
for (let param of vary) {
let allValues = library[param];
let random = (allValues.random ??= []);
while (random.length < TOTAL_ICONS) {
random.push(sample(allValues));
}
}
}
while (ret.length < TOTAL_ICONS) {
ret.push(
...iconNames.map((name, i) => {
let index = ret.length + i;
return {
library: this.computedLibrary,
name,
family: !this.family && vary.includes('family') ? library.family.random[index] : family,
variant: !this.style && vary.includes('style') ? library.style.random[index] : style,
};
}),
);
}
return ret.slice(0, TOTAL_ICONS);
},
computedDefaults() {
return Object.assign({}, defaultDefaults, this.defaults);
},
computed() {
let { library, family, style } = this;
let ret = { library, family, style };
for (let key in this.computedDefaults) {
if (!ret[key]) {
ret[key] = this.computedDefaults[key];
}
}
return ret;
},
iconsMeta() {
return { title: this.defaultTitle };
},
},
methods: {
capitalize,
},
template,
components: {
PageCard,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,12 @@
export { default as ColorSelect } from './color-select.js';
export { default as EditableText } from './editable-text.js';
export { default as FontsCard } from './fonts-card.js';
export { default as IconsCard } from './icons-card.js';
export { default as InfoTip } from './info-tip.js';
export { default as PageCard } from './page-card.js';
export { default as PaletteCard } from './palette-card.js';
export { default as SwatchSelect } from './swatch-select.js';
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';

View File

@@ -0,0 +1,38 @@
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>
`;
let maxUid = 0;
export default {
props: {
slot: String,
},
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,
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,83 @@
/**
* Generic component for displaying a (possibly interactive) card that represents a page
* For more specific use cases check out theme-card, icons-card, etc.
*/
export const ICON_PLACEHOLDER = `
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7C1 3.68629 3.68629 1 7 1H43C46.3137 1 49 3.68629 49 7V43C49 46.3137 46.3137 49 43 49H7C3.68629 49 1 46.3137 1 43V7Z" stroke="var(--wa-color-surface-border)" stroke-width="2" stroke-linecap="round" stroke-dasharray="6 6"/>
<path d="M14.1566 18.7199L21.5367 16.7424C22.6036 16.4565 23.7003 17.0896 23.9862 18.1566L26.8463 28.8306C27.1322 29.8975 26.499 30.9942 25.4321 31.2801L18.052 33.2576C16.985 33.5435 15.8884 32.9103 15.6025 31.8434L12.7424 21.1694C12.4565 20.1024 13.0897 19.0057 14.1566 18.7199Z" stroke="var(--wa-color-neutral-border-normal)" stroke-width="2"/>
<path d="M33.8449 16.3273H26.2045C23.9953 16.3273 22.2045 18.1181 22.2045 20.3273V31.3778C22.2045 33.587 23.9953 35.3778 26.2045 35.3778H33.8449C36.0541 35.3778 37.8449 33.587 37.8449 31.3778V20.3273C37.8449 18.1181 36.0541 16.3273 33.8449 16.3273Z" fill="var(--wa-color-neutral-border-normal)" stroke="var(--wa-color-neutral-fill-quiet)" stroke-width="2"/>
</svg>`;
const template = `
<wa-card with-header class="page-card" :aria-disabled="disabled ? 'true' : null" :inert="disabled"
@click="handleClick" @keyup.enter="handleClick" @keyup.space="handleClick"
:role="action ? 'button' : null" :tabindex="action? 0 : null">
<slot name="icon" slot="header">
<div slot="header" v-html="icon || ICON_PLACEHOLDER"></div>
</slot>
<div class="page-name">
<div>
<slot>
{{ content.title }}
<wa-badge class="pro" v-if="pro">PRO</wa-badge>
<div v-if="content.subtitle" class="wa-caption-m">{{ content.subtitle }}</div>
</slot>
</div>
<slot name="extra"></slot>
<wa-icon v-if="action" name="angle-right" class="angle-right" variant="regular"></wa-icon>
</div>
</wa-card>
`;
export default {
props: {
title: String,
subtitle: String,
info: Object,
icon: String,
pro: Boolean,
disabled: Boolean,
action: Function,
},
data() {
return {};
},
created() {
Object.assign(this, { ICON_PLACEHOLDER });
},
computed: {
content() {
let defaultTitle = this.info?.title ?? {};
if (this.title) {
return { title: this.title, subtitle: this.subtitle ?? defaultTitle };
} else {
return { title: defaultTitle, subtitle: this.subtitle };
}
},
},
methods: {
handleClick(event) {
if (this.disabled) {
event.stopImmediatePropagation();
return;
}
if (this.action) {
this.action(event);
}
},
},
template,
components: {},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,63 @@
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'];
const template = `
<page-card class="palette-card" :pro="$slots.default ? false : paletteMeta.isPro" :info="paletteMeta">
<template #icon>
<wa-scoped slot="header" class="palette-icon-host">
<template>
<link rel="stylesheet" :href="'/dist/styles/color/' + palette + '.css'">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<div class="palette-icon" style="--hues: {{ hues|length }}; --suffixes: {{ suffixes|length }}">
<template v-for="(hue, hueIndex) of hues">
<div class="swatch" v-for="(suffix, suffixIndex) of suffixes"
:data-hue="hue" :data-suffix="suffix"
:style="{
'--color': 'var(--wa-color-' + hue + suffix + ')',
gridColumn: hueIndex + 1,
gridRow: suffixIndex + 1
}">&nbsp;</div>
</template>
</div>
</template>
</wa-scoped>
</template>
<slot></slot>
<template #extra>
<slot name="extra" />
</template>
</page-card>
`;
export default {
props: {
palette: String,
},
data() {
return {};
},
created() {
Object.assign(this, { hues: allHues, suffixes: ['-80', '', '-20'] });
},
computed: {
paletteMeta() {
return palettes[this.palette] ?? {};
},
},
template,
components: {
PageCard,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,173 @@
.sidebar.panel-container {
position: relative;
display: flex;
flex-flow: column;
gap: 0;
padding: 0;
width: 32ch;
overflow: hidden;
scrollbar-width: thin;
}
@keyframes back-icon-hover {
to {
transform: translateX(-0.2em);
}
}
.panel {
/* Remove the uniform spacing used in wa-details */
--spacing: 0;
/* Specify value to manually set spacing where needed */
--panel-spacing: var(--wa-space-2xl);
--panel-background: var(--wa-color-surface-default);
display: flex;
flex-flow: column;
max-height: 100%;
margin-bottom: 0;
position: relative;
background: var(--panel-background);
border: none;
transition:
translate var(--wa-transition-slow) allow-discrete,
opacity var(--wa-transition-slow) 25ms allow-discrete;
/* Ensure horizontal scrollbar isn't visible when translate takes effect */
overflow-x: hidden !important;
@starting-style {
display: block;
}
.panel-header {
flex-direction: row-reverse;
justify-content: start;
gap: var(--wa-space-xs);
cursor: pointer;
background: var(--panel-background);
color: var(--wa-color-text-normal);
padding-block-end: var(--panel-spacing);
padding-inline: var(--panel-spacing);
transition: inherit;
transition-property: all;
margin-block: 0;
font-size: inherit;
[data-step='0'] &,
.previous & {
padding-block-start: var(--panel-spacing);
}
.back-icon {
vertical-align: -0.15em;
margin-inline-end: var(--wa-space-xs);
font-size: var(--wa-font-size-m);
transition: transform var(--wa-transition-normal);
}
&:hover .back-icon {
animation: back-icon-hover var(--wa-transition-slow) alternate infinite;
}
label {
pointer-events: none;
font: inherit;
color: inherit;
}
}
.panel-content {
flex: 1;
min-height: 0;
display: flex;
flex-flow: column;
gap: var(--panel-spacing);
padding-block-end: var(--panel-spacing);
padding-inline: var(--panel-spacing);
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-block-end: 0;
}
&:not(.open) {
padding: 0;
&:not(.previous, .next) {
/* Hide all but the immediately preceding or following steps */
display: none;
}
&.next {
height: 0;
overflow: hidden;
}
&.next {
opacity: 0;
}
&.next {
translate: 100% 0%;
}
.panel-header {
font-size: var(--wa-font-size-s);
margin: 0;
}
.panel-content {
opacity: 0;
pointer-events: none;
content-visibility: hidden;
padding: 0;
}
}
&.open {
flex: 1;
opacity: 1;
.panel-header {
font-size: var(--wa-font-size-l);
.back-icon {
display: none;
}
}
}
.panel-content {
flex: 1;
min-height: 0;
display: flex;
flex-flow: column;
transition: inherit;
@starting-style {
display: flex;
content-visibility: visible;
}
}
&:not(.open) {
&.previous {
.panel-content {
opacity: 0;
translate: -100% 0%;
}
}
&.next {
.panel-content {
opacity: 0;
translate: inherit;
}
}
}
}

View File

@@ -0,0 +1,89 @@
/**
* Scrollable element in a vertical flex container
* Showing shadows as an indicator of scrollability (PE wherever scroll-timeline is supported for now, can be polyfilled with JS later)
*/
.scrollable {
--scroll-shadow-height: 0.5em;
flex-shrink: 1;
min-height: 0;
overflow: auto;
position: relative;
scrollbar-width: inherit;
&:is(.panel-content > div) {
display: flex;
flex-flow: column;
gap: inherit;
}
.scroll-shadow {
position: sticky;
z-index: 1;
inset-inline: 0;
display: block;
&::before {
content: '';
position: absolute;
inset-inline: 0;
height: var(--scroll-shadow-height);
pointer-events: none;
mix-blend-mode: multiply;
background: radial-gradient(farthest-side, var(--wa-color-shadow) 10%, transparent) center / 120% 200%;
transition: var(--wa-transition-slow);
/* transition-property: opacity, transform, display, height, min-height; */
transition-behavior: allow-discrete;
}
}
&:not(.can-scroll-top) .scroll-shadow-top,
&:not(.can-scroll-bottom) .scroll-shadow-bottom {
opacity: 0;
&::before {
height: 0;
}
}
&:not(.can-scroll-top) .scroll-shadow-top {
&::before {
transform: translateY(-100%);
}
}
.scroll-shadow-top {
top: 0;
&::before {
background-position: bottom;
}
}
&:not(.can-scroll-bottom) .scroll-shadow-bottom {
&::before {
transform: translateY(100%);
}
}
.scroll-shadow-bottom {
top: 100%;
&::before {
bottom: 0;
background-position: top;
}
}
}
.scrollable:where(.panel-content) {
.scroll-shadow-top {
/* TODO convert this magic number to a token that explains what it is */
margin-bottom: -18px;
}
.scroll-shadow-bottom {
transform: translateY(var(--padding-bottom, var(--panel-spacing)));
}
}

View File

@@ -0,0 +1,67 @@
import { capitalize } from '../../scripts/util/string.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">
<info-tip v-for="value in values">
<wa-radio-button :value :label="getLabel(value)" :style="{'--color': getColor(value)}"></wa-radio-button>
<template #content>
{{ getLabel(value) }}
</template>
</info-tip>
</wa-radio-group>
`;
export default {
props: {
modelValue: String,
name: String,
label: String,
shape: {
type: String,
default: 'rounded',
validator: value => ['circle', 'rounded'].includes(value),
},
getLabel: {
type: Function,
default: capitalize,
},
getColor: {
type: Function,
default: value => `var(--wa-color-${value})`,
},
values: {
type: Array,
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

@@ -0,0 +1,97 @@
import PageCard from './page-card.js';
import { getThemeCode } from '/assets/scripts/tweak/code.js';
import themes from '/docs/themes/data.js';
const iconTemplates = {
colors: `
<div class="theme-icon theme-color-icon" role="presentation">
<div style="background: var(--wa-color-brand-fill-loud); border-color: var(--wa-color-brand-border-loud); color: var(--wa-color-brand-on-loud);">A</div>
<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>
<div class="wa-invert theme-icon theme-color-icon" role="presentation">
<div style="background: var(--wa-color-brand-fill-loud); border-color: var(--wa-color-brand-border-loud); color: var(--wa-color-brand-on-loud);">A</div>
<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: `
<div class="row row-1">
<h2>Aa</h2>
<div class="swatches">
<div class="wa-brand"></div>
<div class="wa-success"></div>
<div class="wa-warning"></div>
<div class="wa-danger"></div>
</div>
</div>
<div class="row row-2">
<wa-input value="Input" size="small"></wa-input>
<wa-button size="small" variant="brand">Go</wa-button>
</div>`,
};
const template = `
<page-card class="theme-card" :class="type + '-card'" :info="themeMeta">
<template #icon>
<wa-scoped slot="header" class="theme-icon-host" inert>
<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'" >
${iconTemplates.colors}
</template>
<div v-else class="theme-icon theme-overall-icon" :class="'wa-theme-' + theme" role="presentation">
${iconTemplates.theme}
</div>
</template>
</wa-scoped>
</template>
<slot></slot>
<template #extra>
<slot name="extra" />
</template>
</page-card>
`;
export default {
props: {
theme: String,
type: {
type: String,
validator(value) {
return !value || ['colors'].includes(value);
},
},
rest: Object,
},
data() {
return {};
},
computed: {
themeMeta() {
return themes[this.theme] ?? {};
},
themeCode() {
let theme = { ...(this.rest || {}), [this.type || 'base']: this.theme };
theme.base ||= 'default';
return getThemeCode(theme, { id: this.theme, language: 'html', cdnUrl: '/dist/' });
},
},
template,
components: {
PageCard,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,120 @@
const template = `
<section class="panel-container" ref="container" :style="{'--panel-step': step}" @open="handleOpen">
<slot ref="panels"></slot>
</section>
`;
export default {
props: {
/** Currently selected id */
modelValue: String,
},
emits: ['update:modelValue'],
data() {
return {
value: '',
previousValue: '',
step: 0,
trail: [],
};
},
mounted() {
let { container } = this.$refs;
let activePanel = container.querySelector(':scope > .open');
if (activePanel) {
let { step, value } = activePanel.dataset;
this.step = Number(step);
this.value = value;
this.$emit('update:modelValue', this.value);
}
},
computed: {
panels() {
if (!this.$refs.container) {
return new Map();
}
let { container } = this.$refs;
return new Map(
[...container.querySelectorAll(':scope > .panel')].map(panel => [
panel.dataset.value,
Number(panel.dataset.step),
]),
);
},
},
methods: {
handleOpen(e) {
let { value, step } = e.detail;
this.value = value;
this.step = step;
},
updatePanels() {
let { container } = this.$refs;
if (!container) {
return;
}
let { step, value } = this;
if (this.panels.get(value) !== step) {
// Hasn't stabilized yet
return;
}
let previousValue = this.trail.findLast(panel => this.panels.get(panel) === step - 1);
for (let panel of container.querySelectorAll(':scope > .panel')) {
let panelStep = Number(panel.dataset.step);
let panelValue = panel.dataset.value;
let isPrevious = previousValue ? panelValue === previousValue : panelStep === step - 1;
let isOpen = panelValue === value;
let isNext = panelStep === step + 1;
panel.classList.toggle('previous', isPrevious);
panel.classList.toggle('open', isOpen);
panel.classList.toggle('next', isNext);
}
},
},
watch: {
value() {
if (this.value !== this.modelValue) {
this.$emit('update:modelValue', this.value);
}
},
modelValue: {
immediate: true,
async handler(value, previousValue) {
if (this.value !== this.modelValue) {
this.value = this.modelValue;
}
if (previousValue) {
this.trail.push(previousValue);
}
this.updatePanels();
},
},
step() {
this.updatePanels();
},
},
template,
components: {},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,73 @@
import UiScrollable from './ui-scrollable.js';
const template = `
<ui-scrollable :disabled="!open" role="group" :name="name || 'panel'" :data-value="value" :data-step="step" class="panel" :class="{open}">
<h2 :inert="open" class="panel-header" @click="openPanel" ref="panelHeader">
<wa-icon name="chevron-left" class="back-icon" />
<slot name="title">{{ title }}</slot>
</h2>
<div class="panel-content">
<slot></slot>
</div>
</ui-scrollable>
`;
export default {
props: {
title: String,
name: String,
step: Number,
/** Id of this panel */
value: String,
/** Currently selected id */
modelValue: String,
},
emits: ['update:modelValue', 'open'],
data() {
return {};
},
mounted() {
if (this.open) {
this.$refs.panelHeader.dispatchEvent(
new CustomEvent('open', { detail: { value: this.value, step: this.step }, bubbles: true }),
);
}
},
computed: {
open() {
return this.value === this.modelValue;
},
},
methods: {
openPanel() {
let wasOpen = this.open;
this.$emit('update:modelValue', wasOpen ? '' : this.value);
},
},
watch: {
open: {
immediate: true,
handler(open) {
if (open && this.$refs.panelHeader) {
this.$refs.panelHeader.dispatchEvent(
new CustomEvent('open', { detail: { value: this.value, step: this.step }, bubbles: true }),
);
}
},
},
},
template,
components: {
UiScrollable,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,77 @@
const template = `
<div class="scrollable" :class="{'can-scroll-top': canScrollTop, 'can-scroll-bottom': canScrollBottom}" ref="container">
<div v-if="!disabled" class="scroll-shadow scroll-shadow-top"></div>
<slot></slot>
<div v-if="!disabled" class="scroll-shadow scroll-shadow-bottom"></div>
</div>
`;
export default {
props: {
disabled: Boolean,
},
data() {
return {
scrollTop: 0,
scrollHeight: 0,
height: 0,
};
},
mounted() {
let { container, content } = this.$refs;
container.addEventListener('scroll', this.handleScroll, { passive: true });
this.scrollHeight = container.scrollHeight;
this.height = container.clientHeight;
},
computed: {
canScrollTop() {
return !this.disabled && this.scrollTop > 1;
},
maxScrollTop() {
return this.scrollHeight - this.height;
},
canScrollBottom() {
return !this.disabled && this.scrollTop < this.maxScrollTop - 1;
},
scrollProgress() {
return this.scrollTop / this.maxScrollTop;
},
scrollProgressEnd() {
return this.scrollProgress + this.maxScrollTop / this.scrollHeight;
},
scrollBottom() {
return this.scrollHeight * this.scrollProgressEnd;
},
},
methods: {
handleScroll(event) {
let { container } = this.$refs;
this.scrollTop = container.scrollTop;
},
},
watch: {
scrollTop(value, oldValue) {
let { container } = this.$refs;
if (container && oldValue === 0) {
this.scrollHeight = container.scrollHeight;
this.height = container.clientHeight;
}
},
},
template,
components: {},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -1,5 +1,5 @@
import my from '/assets/scripts/my.js';
import Permalink from '/assets/scripts/tweak/permalink.js';
import Permalink from '/assets/scripts/permalink.js';
export default {
data() {

View File

@@ -0,0 +1,32 @@
---
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,6 +1,4 @@
:root {
--fa-sliders-simple: '\f1de';
}
@import url('/assets/styles/ui.css');
.core-column {
position: relative;
@@ -86,17 +84,6 @@ wa-dropdown > .color.swatch {
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;
@@ -182,24 +169,3 @@ wa-dropdown > .color.swatch {
[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);
}
}

View File

@@ -1,13 +1,14 @@
// 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 { maxGrayChroma, moreHue, selectors, themeConfig } from '../../assets/data/index.js';
import { cdnUrl, hueRanges, hues, 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';
import content from '/assets/scripts/vue/directives/content.js';
import savedMixin from '/assets/scripts/vue/mixins/saved.js';
import { SwatchSelect } from '/assets/vue/components/index.js';
import content from '/assets/vue/directives/content.js';
import savedMixin from '/assets/vue/mixins/saved.js';
await Promise.all(['wa-slider'].map(tag => customElements.whenDefined(tag)));
@@ -64,7 +65,7 @@ let paletteAppSpec = {
created() {
// Non-reactive variables to expose
Object.assign(this, { moreHue });
Object.assign(this, { moreHue, hues });
this.grayChroma = this.originalGrayChroma;
this.grayColor = this.originalGrayColor;
@@ -121,7 +122,11 @@ let paletteAppSpec = {
code() {
let ret = {};
for (let language of ['html', 'css']) {
let code = getPaletteCode(this.id, this.colors, this.tweaked, { language, cdnUrl });
let code = getPaletteCode(this.id, this.colors, this.tweaked, {
language,
cdnUrl,
attributes: ' class="wa-palette',
});
ret[language] = {
raw: code,
highlighted: Prism.highlight(code, Prism.languages[language], language),
@@ -345,6 +350,10 @@ let paletteAppSpec = {
content,
},
components: {
SwatchSelect,
},
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
@@ -368,7 +377,7 @@ export function getPaletteCode(paletteId, colors, tweaked, options) {
let imports = [];
if (paletteId) {
imports.push(urls.palette(paletteId));
imports.push(themeConfig.palette.url(paletteId));
}
let css = '';

View File

@@ -7,4 +7,7 @@ palette: rudimentary
brand: green
fonts:
body: Inter
icons:
family: classic
style: solid
---

0
docs/docs/themes/app/index.js vendored Normal file
View File

View File

@@ -6,4 +6,7 @@ palette: bright
brand: blue
fonts:
body: Quicksand
icons:
family: classic
style: solid
---

View File

@@ -9,4 +9,7 @@ fonts:
body: Space Grotesk
heading: IBM Plex Sans Condensed
code: Space Mono
icons:
family: sharp
style: solid
---

View File

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

7
docs/docs/themes/custom.md vendored Normal file
View File

@@ -0,0 +1,7 @@
---
title: Custom
description: Your very own custom theme.
isPro: true
tags: pro
order: 98
---

22
docs/docs/themes/data.js.njk vendored Normal file
View File

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

@@ -8,4 +8,7 @@ fonts:
body: ui-sans-serif
code: ui-monospace
longform: ui-serif
icons:
family: classic
style: solid
---

View File

@@ -1,90 +0,0 @@
---
layout: blank
pagination:
data: collections.theme
size: 1
alias: theme
permalink: '/docs/themes/{{ theme.fileSlug }}/demo.html'
eleventyExcludeFromCollections: true
override:tags: []
eleventyComputed:
forceTheme: "{{ theme.fileSlug }}"
---
{% set isPro = theme.data.isPro %}
{% set status = theme.data.status %}
{% set since = theme.data.since %}
<link rel="stylesheet" href="/docs/themes/showcase.css" />
{% set content %}
<header>
{% include 'breadcrumbs.njk' %}
<h1 class="title">{{ theme.data.title }}</h1>
<p id="mix_and_match" class="wa-size-s"></p>
<p id="theme-status">{% include 'status.njk' %}</p>
<p id="theme-showcase-description">{{ theme.data.description | inlineMarkdown | safe }}</p>
</header>
{% include 'theme-showcase.njk' %}
{% endset %}
<wa-comparer style="width: 100%" position="90">
<div slot="after" class="theme-showcase wa-gap-xl">
{{ content | safe }}
</div>
<div slot="before" class="theme-showcase wa-gap-xl wa-invert">
{{ content | safe }}
</div>
</wa-comparer>
<script type="module">
import { urls as stylesheetURLs, docsURLs, icons } from "/assets/scripts/tweak/data.js";
import { getThemeCode } from "/assets/scripts/tweak/code.js";
function updateTheme() {
let params = new URLSearchParams(window.location.search);
params = Object.fromEntries(params.entries());
for (let link of document.querySelectorAll('link.mix-and-match')) {
link.remove();
}
let tweaks = [];
let code = getThemeCode("{{ theme.fileSlug }}", params, {attributes: 'class="mix-and-match"'});
document.head.insertAdjacentHTML('beforeend', code);
for (let name in stylesheetURLs) {
let override = params[name];
if (override) {
let docsURL = docsURLs[name] ? docsURLs[name] + override + '/' : '';
let title = override.replace(/^[a-z]/g, c => c.toUpperCase());
if (docsURL) {
title = `<a href="${ docsURL }">${ title }</a>`;
}
let icon = icons[name];
tweaks.push(`<wa-icon name="${icon}" variant="regular"></wa-icon> ${ title }`);
}
}
let isRemixed = tweaks.length > 0;
document.documentElement.classList.toggle('is-remixed', isRemixed);
if (isRemixed) {
for (let p of document.querySelectorAll("#theme-status")) {
let proBadge = p.querySelector(".pro");
if (!proBadge) {
p.insertAdjacentHTML('beforeend', '<wa-badge class="pro">PRO</wa-badge>');
}
}
for (let p of mix_and_match) {
if (tweaks.length) {
p.innerHTML = `<strong><wa-icon name="arrows-rotate"></wa-icon> Remixed</strong> ` + tweaks.map(msg => `<wa-badge appearance=outlined>
${ msg }</wa-badge>`).join(' ');
}
}
}
}
updateTheme();
</script>

69
docs/docs/themes/demo/index.js vendored Normal file
View File

@@ -0,0 +1,69 @@
import { themeConfig, themeParams } from '/assets/data/index.js';
import my from '/assets/scripts/my.js';
import { documentTheme, theme } from '/docs/themes/preview/preview.js';
let urlParams = new URLSearchParams(location.search);
if (urlParams.has('uid') && my.themes.saved.length > 0) {
let savedTheme = my.themes.saved.find(p => p.uid === Number(urlParams.get('uid')));
// Update title
document.title = savedTheme.title;
for (let title of document.querySelectorAll('.title')) {
title.innerHTML = savedTheme.title;
}
}
for (let editLink of document.querySelectorAll('.edit-link')) {
editLink.href = '../edit/?' + urlParams;
editLink.addEventListener('click', e => {
editLink.href = '../edit/?' + urlParams;
});
}
theme.addEventListener('change', e => {
let tweaks = [];
for (let aspect of themeParams) {
if (theme[aspect] && theme[aspect] !== documentTheme[aspect]) {
let config = themeConfig[aspect];
let override = theme[aspect];
let { docs, icon } = config;
let docsURL = docs ? docs + override + '/' : '';
let title = override.replace(/^[a-z]/g, c => c.toUpperCase());
if (docsURL) {
title = `<a href="${docsURL}">${title}</a>`;
}
tweaks.push(`<wa-icon name="${icon}" variant="regular"></wa-icon> ${title}`);
}
}
let isRemixed = tweaks.length > 0;
document.documentElement.classList.toggle('is-remixed', isRemixed);
if (isRemixed) {
for (let p of document.querySelectorAll('#theme-status')) {
let proBadge = p.querySelector('.pro');
if (!proBadge) {
p.insertAdjacentHTML('beforeend', '<wa-badge class="pro">PRO</wa-badge>');
}
}
for (let p of mix_and_match) {
if (tweaks.length) {
p.innerHTML =
`<strong><wa-icon name="arrows-rotate"></wa-icon> Remixed</strong> ` +
tweaks
.map(
msg => `<wa-badge appearance=outlined>
${msg}</wa-badge>`,
)
.join(' ');
} else {
p.innerHTML = '';
}
}
}
});

46
docs/docs/themes/demo/index.njk vendored Normal file
View File

@@ -0,0 +1,46 @@
---
layout: blank
pagination:
data: collections.theme
size: 1
alias: theme
permalink: '/docs/themes/{{ theme.fileSlug }}/demo.html'
eleventyExcludeFromCollections: true
override:tags: []
eleventyComputed:
forceTheme: "{{ theme.fileSlug if theme.fileSlug !== 'custom' else 'default' }}"
---
{% set isPro = theme.data.isPro %}
{% set status = theme.data.status %}
{% set since = theme.data.since %}
<link rel="stylesheet" href="/docs/themes/showcase.css" />
{% set content %}
<header>
{% include 'breadcrumbs.njk' %}
<h1 class="title">{{ theme.data.title }}</h1>
<p id="mix_and_match" class="wa-size-s"></p>
<p id="theme-status">{% include 'status.njk' %}</p>
<p id="theme-showcase-description">{{ theme.data.description | inlineMarkdown | safe }}</p>
{% if theme.fileSlug === 'custom' %}
<p>
<wa-button href="../edit/" class="edit-link" target="_parent" appearance="outlined">
<wa-icon slot="prefix" name="pencil"></wa-icon>
Edit theme
</wa-button>
</p>
{% endif %}
</header>
{% include 'theme-showcase.njk' %}
{% endset %}
<wa-comparer style="width: 100%" position="90">
<div slot="after" class="theme-showcase wa-gap-xl">
{{ content | safe }}
</div>
<div slot="before" class="theme-showcase wa-gap-xl wa-invert">
{{ content | safe }}
</div>
</wa-comparer>
<script type="module" src="../demo/index.js"></script>

269
docs/docs/themes/edit/index.js vendored Normal file
View File

@@ -0,0 +1,269 @@
// import { createApp, nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
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 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 {
ColorSelect,
EditableText,
FontsCard,
IconsCard,
InfoTip,
PageCard,
PaletteCard,
SwatchSelect,
ThemeCard,
UiPanel,
UiPanelContainer,
} 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],
data() {
let mobileMQ = window.matchMedia('(max-width: 768px)');
let isMobile = mobileMQ.matches;
mobileMQ.addEventListener('change', e => {
this.isMobile = e.matches;
});
let id = location.pathname.match(/\/themes\/([^/]+)\/?$/)?.[1];
let isCustom = id === 'custom' || id === 'edit';
return {
type: 'theme',
collection: 'themes',
id: id === 'edit' ? 'custom' : id,
isCustom,
urlParams: location.search,
theme: {
base: isCustom ? '' : id,
palette: '',
typography: '',
colors: '',
brand: '',
icon: {
kit: '',
library: '',
family: '',
style: '',
},
},
ui: {
panel: 'styles',
showCode: false,
code: 'css',
preview: 'app',
},
isMobile,
isCreated: false,
};
},
created() {
// Data that won't change so we don't need reactivity.
// By adding them here instead of in data() we skip having them wrapped in Proxies.
Object.assign(this, {
themes,
palettes,
hues: allHues,
iconLibraries,
themeDefaults,
sameAs,
pairings: pairingsList,
});
if (location.search) {
let urlTheme = this.permalink.getAll();
deepMerge(this.theme, urlTheme, { emptyValues: [undefined, ''] });
}
this.isCreated = true;
},
computed: {
originalTitle() {
if (this.isCustom) {
return 'My Theme';
}
return themes[this.computed.base]?.title ?? 'Unknown Theme';
},
/** Default theme title for saving */
defaultTitle() {
let ret = this.originalTitle;
if (!this.isCustom) {
ret += ' (remixed)';
}
return ret;
},
slug() {
return slugify(this.title);
},
cssFilename() {
return `theme-${this.slug}.css`;
},
computedBase() {
return this.theme.base || themeDefaults.base;
},
baseTheme() {
return themes[this.computedBase];
},
// Resolved defaults for the current theme
defaults() {
let ret = deepClone(themeDefaults);
deepEach(ret, value => {
// Resolve defaults that depend on other values based on the current theme params
if (typeof value === 'function') {
return value.call(this.theme, this.baseTheme);
}
});
return ret;
},
computed() {
let ret = deepClone(themeDefaults);
deepMerge(ret, this.theme, { emptyValues: [undefined, ''] });
deepEach(ret, value => {
// Resolve defaults that depend on other values
if (typeof value === 'function') {
return value.call(ret, this.baseTheme);
}
});
return ret;
},
code() {
let ret = {};
let theme = { ...this.theme };
theme.base ||= 'default';
for (let language of ['html', 'css']) {
let code = getThemeCode(theme, { id: this.slug, language, cdnUrl });
ret[language] = {
raw: code,
highlighted: Prism.highlight(code, Prism.languages[language], language),
};
}
ret.css.dataURI = `data:text/css;charset=utf-8,${encodeURIComponent(ret.css.raw)}`;
ret.css.blob = URL.createObjectURL(new Blob([ret.css.raw], { type: 'text/css' }));
return ret;
},
codeToUse() {
let attributes = this.theme.icon.kit ? ` data-fa-kit-code="${this.theme.icon.kit}"` : '';
let code = `<link rel="stylesheet" href="path/to/${this.cssFilename}"${attributes}>`;
return {
raw: code,
highlighted: Prism.highlight(code, Prism.languages.html, 'html'),
};
},
tweaked() {
return Object.values(this.theme).filter(Boolean).length > 0;
},
},
watch: {
theme: {
deep: true,
handler() {
this.permalink.setAll(this.theme, this.defaults);
// Update page URL
this.permalink.updateLocation();
let theme = JSON.parse(JSON.stringify(this.theme));
this.$refs.preview?.contentWindow.postMessage({
type: 'updatePreview',
theme,
id: this.slug,
});
this.unsavedChanges = true;
},
},
'ui.preview': {
immediate: true,
handler() {
if (!this.isCreated) {
return;
}
// Update urlParams only when the preview changes
// We use postMessage for other updates
let urlParams = new URLSearchParams(this.computed);
urlParams.sort();
urlParams = urlParams + '';
this.urlParams = urlParams ? '?' + urlParams : '';
},
},
},
methods: {
capitalize,
log(...args) {
console.log(...args);
return args[0];
},
},
components: {
ColorSelect,
EditableText,
FontsCard,
IconsCard,
InfoTip,
PageCard,
PaletteCard,
ThemeCard,
UiPanel,
UiPanelContainer,
SwatchSelect,
},
directives: { content },
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function init() {
let appContainer = document.querySelector('#theme-app');
globalThis.app?.unmount?.();
if (!appContainer) {
return;
}
globalThis.app = createApp(appSpec).mount(appContainer);
}
init();
addEventListener('turbo:render', init);

179
docs/docs/themes/edit/index.njk vendored Normal file
View File

@@ -0,0 +1,179 @@
---
layout: blank
title: Create
permalink: "/docs/themes/edit/"
override:tags: [themes, pro]
isPro: true
order: 99
unlisted: true
---
{% block head %}
<link href="/assets/styles/docs.css" rel="stylesheet" />
<link href="/assets/styles/ui.css" rel="stylesheet" />
<link href="{{ page.url }}style.css" rel="stylesheet" />
<script src="{{ page.url }}index.js" type="module"></script>
{% endblock %}
<wa-page id="theme-app">
<header slot="header">
<a href="/" aria-label="Web Awesome">
{% include "logo-simple.njk" %}
</a>
<div class="title">
<h1><editable-text :model-value="title" label="theme name" @submit="newTitle => save({title: newTitle})"></editable-text></h1>
<wa-icon-button v-if="saved" class="delete" name="trash" label="Delete theme" @click="deleteSaved"></wa-icon-button>
</div>
<wa-button v-if="tweaked || uid" @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>
<wa-button size="small" @click="ui.showCode = !showCode" appearance="outlined">
<wa-icon name="code" variant="solid"></wa-icon>
Code
</wa-button>
<wa-drawer label="Theme code" with-header :open="ui.showCode" @wa-after-hide="ui.showCode = false" light-dismiss>
<wa-tab-group class="import-stylesheet-code" :active="ui.code" @wa-tab-show="ui.code = $event.detail.name">
<wa-tab panel="css">CSS</wa-tab>
<wa-tab-panel name="css">
<p>
<wa-button variant="brand" :href="code.css.blob" :download="cssFilename">
<wa-icon name="arrow-down-to-line" variant="solid" slot="prefix"></wa-icon>
Download <code v-text="cssFilename"></code>
</wa-button>
</p>
<pre class="language-css"><code v-content:html="code.css.highlighted"></code></pre>
<wa-divider></wa-divider>
<p>To use this theme, add this code snippet to the <code>&lt;head&gt;</code> section of your HTML file:</p>
<pre class="language-html"><code v-content:html="codeToUse.highlighted"></code></pre>
<wa-callout size="small" variant="warning" appearance="filled">
<wa-icon slot="icon" name="triangle-exclamation" variant="regular"></wa-icon>
Don't forget to replace <code v-text="'path/to/' + cssFilename"></code> with your local path!
</wa-callout>
</wa-tab-panel>
<wa-tab panel="html">HTML</wa-tab>
<wa-tab-panel name="html">
<p>To use this theme, add this code snippet to the <code>&lt;head&gt;</code> section of your HTML file:</p>
<pre class="language-html"><code v-content:html="code.html.highlighted"></code></pre>
</wa-tab-panel>
</wa-tab-group>
</wa-drawer>
</header>
<ui-panel-container class="sidebar" slot="navigation" v-model="ui.panel">
<ui-panel v-model="ui.panel" value="styles" title="Styles" :step="0">
<theme-card :theme="computed.base" size="small" :action="e => ui.panel = 'theme'" title="Starting Theme"></theme-card>
<wa-divider></wa-divider>
<palette-card :palette="computed.palette" size="small" :action="e => ui.panel = 'color'" role="button" tabindex="0"
title="Colors" :subtitle="`${ palettes[computed.palette]?.title } palette &bull; ${ capitalize(computed.brand) } brand`">
</palette-card>
<fonts-card :theme="computed.typography" title="Fonts" size="small" :action="e => ui.panel = 'typography'">
</fonts-card>
<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>
</ui-panel>
<ui-panel v-model="ui.panel" value="theme" :step="1">
<template #title>
<label for="starting-theme">Starting Theme</label>
</template>
<wa-radio-group id="starting-theme" class="card-picker" v-model="theme.base">
<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>
</wa-radio>
</template>
</wa-radio-group>
</ui-panel>
<ui-panel v-model="ui.panel" value="color" title="Colors" :step="1">
<palette-card :palette="computed.palette" size="small" :action="e => ui.panel = 'color-palette'" role="button" tabindex="0"
title="Color Palette" :subtitle="palettes[computed.palette]?.title">
</palette-card>
<swatch-select label="Brand Color" shape="rounded"
:get-color="hue => palettes[computed.palette].colors[hue].key"
:model-value="computed.brand" @update:model-value="value => theme.brand = value"
:values="hues"></swatch-select>
</ui-panel>
<ui-panel v-model="ui.panel" value="color-palette" title="Color Palette" :step="2">
<wa-radio-group class="card-picker" v-model="theme.palette" size="small">
<wa-radio v-for="(palette, paletteId) in palettes" :value="paletteId">
<palette-card :palette="paletteId" size="small" :aria-selected="!theme.palette && paletteId === themes[computed.base].palette ? 'true' : null">
<template #extra><wa-badge appearance="outlined" variant="neutral" v-if="paletteId === themes[computed.base].palette">Default</wa-badge></template>
</palette-card>
</wa-radio>
</wa-radio-group>
</ui-panel>
<ui-panel v-model="ui.panel" value="typography" title="Fonts" :step="1">
<wa-radio-group class="card-picker" v-model="theme.typography" >
<wa-radio v-for="pairing of pairings" :value="pairing.id" size="small">
<fonts-card :pairing :aria-selected="!theme.typography && pairing.id === theme.base ? 'true' : null">
<template #extra><wa-badge appearance="outlined" variant="neutral" v-if="pairing.id === theme.base">Default</wa-badge></template>
</fonts-card>
</wa-radio>
</wa-radio-group>
</ui-panel>
<ui-panel v-model="ui.panel" value="icons" title="Icons" :step="1">
<wa-select label="Library" value="default" disabled style="display: none;">
<wa-option value="default" selected>Font Awesome</wa-option>
</wa-select>
<icons-card type="family" vary="style" size="small" :action="e => ui.panel = 'icon-family'" :defaults="computed.icon"
title="Icon Family">
</icons-card>
<icons-card type="style" size="small" :action="e => ui.panel = 'icon-style'" :defaults="computed.icon"
title="Icon Style">
</icons-card>
<wa-input label="Font Awesome Pro Kit Code" v-model="theme.icon.kit" placeholder="e.g. f0nta7e50e">
<info-tip slot="suffix"><template #content>You need a Font Awesome Pro license to use certain families and styles.</template></info-tip>
<a href="https://fontawesome.com/kits" target="_blank" slot="hint" class="wa-caption-m wa-cluster wa-gap-2xs">
<span>Find your kit code here</span>
<wa-icon name="arrow-up-right-from-square" variant="regular" style="font-size: 0.75em"></wa-icon>
</a>
</wa-input>
</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">
<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>
</wa-radio-group>
</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">
<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>
</wa-radio-group>
</ui-panel>
</ui-panel-container>
<div class="preview-container">
<iframe class="preview" ref="preview" :src="`{{ page.url }}../preview/${ui.preview}/${urlParams}`"></iframe>
</div>
</wa-page>

149
docs/docs/themes/edit/style.css vendored Normal file
View File

@@ -0,0 +1,149 @@
@import url('/assets/styles/theme-icons.css');
@import url('/assets/vue/components/panel.css');
@import url('/assets/vue/components/scrollable.css');
:root {
--ui-border: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-surface-border);
}
html,
body {
min-height: 100%;
height: 100%;
padding: 0;
margin: 0;
}
.sidebar {
max-width: 22rem;
}
wa-card {
.page-name {
display: flex;
gap: var(--wa-space-xs);
}
}
wa-page > header {
flex: 1;
padding-block: var(--wa-space-s);
.title {
display: flex;
margin: 0 auto;
gap: var(--wa-space-xs);
align-items: center;
text-indent: 4lh;
.editable-text {
text-indent: 0;
}
h1 {
font-size: var(--wa-font-size-l);
margin: 0;
}
wa-icon-button {
transition: var(--wa-transition-slow);
}
&:not(:hover, :focus-within, :has(input)) wa-icon-button {
opacity: 0;
}
}
&::part(menu) {
overflow: initial;
}
}
.preview-header {
padding-block: var(--wa-space-s) 0;
wa-radio-group {
margin-left: auto;
&::part(form-control) {
display: flex;
gap: var(--wa-space-xs);
align-items: center;
}
}
}
.preview-container {
padding: var(--wa-space-m);
display: grid;
height: 100%;
max-inline-size: 140ch;
margin: auto;
iframe.preview {
border-radius: var(--wa-border-radius-m);
border: var(--ui-border);
width: 100%;
height: 100%;
}
}
wa-tab::part(base) {
padding-bottom: var(--wa-space-xs);
}
wa-tab-panel:empty {
--padding: 0;
}
wa-radio-group.card-picker {
&::part(form-control-input) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20ch, 1fr));
gap: var(--wa-space-l);
}
wa-radio {
display: flex;
margin: 0 !important;
}
}
.panel-content {
&:is(.panel[data-value='styles'] *) {
gap: var(--wa-space-l);
}
> wa-divider {
--spacing: 0;
}
> footer {
display: flex;
align-items: center;
justify-content: space-between;
.next {
margin-inline-start: auto;
}
}
}
.panel wa-card {
--spacing: var(--wa-space-s);
position: relative;
.coming-soon {
position: absolute;
top: calc(-1 * var(--wa-space-2xs));
left: 50%;
translate: -50% 0;
}
}
wa-card wa-icon.angle-right {
margin-inline-start: auto;
align-self: center;
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
}

View File

@@ -7,4 +7,7 @@ palette: elegant
brand: indigo
fonts:
body: Figtree
icons:
family: classic
style: solid
---

View File

@@ -9,6 +9,9 @@ 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

@@ -8,4 +8,7 @@ fonts:
body: Mulish
heading: Lora
longform: Lora
icons:
family: classic
style: solid
---

View File

@@ -9,4 +9,7 @@ fonts:
body: Nunito
heading: Fredoka
code: Azeret Mono
icons:
family: classic
style: solid
---

View File

@@ -9,4 +9,7 @@ fonts:
body: DM Sans
heading: Playfair Display
longform: Playfair
icons:
family: sharp
style: regular
---

39
docs/docs/themes/preview/app.css vendored Normal file
View File

@@ -0,0 +1,39 @@
body:has(> .showcase-examples-wrapper) {
background-color: var(--wa-color-surface-lowered);
}
.showcase-examples-wrapper {
container-type: inline-size;
inline-size: 100%;
min-block-size: 100%;
box-sizing: border-box;
padding: var(--wa-space-xl);
background-color: var(--wa-color-surface-lowered);
}
.showcase-examples {
column-count: 1;
column-gap: var(--wa-space-xl);
& wa-card {
display: inline-block;
width: 100%;
&:has(+ wa-card) {
margin-block-end: var(--wa-space-xl);
}
}
@container (width > 500px) {
column-count: 2;
}
@container (width > 750px) {
column-count: 3;
}
@container (width > 950px) {
column-count: auto;
column-width: 32ch;
}
}

20
docs/docs/themes/preview/app.njk vendored Normal file
View File

@@ -0,0 +1,20 @@
---
title: App-focused theme preview
layout: blank
eleventyExcludeFromCollections: true
override:tags: []
noTheme: true
---
{% block head %}
<link href="{{ page.url }}../app.css" rel="stylesheet" />
<script type="module" src="{{ page.url }}../preview.js"></script>
{% endblock %}
<style>
.showcase-examples-wrapper {
padding: var(--wa-space-3xl);
}
</style>
{% include 'theme-showcase.njk' %}

342
docs/docs/themes/preview/content.css vendored Normal file
View File

@@ -0,0 +1,342 @@
html {
background: var(--wa-color-surface-default);
background-attachment: fixed;
background-image: radial-gradient(var(--wa-color-surface-lowered) 1.5px, transparent 0);
background-size: 28px 28px;
background-position: -19px -19px;
min-height: 100vh;
}
/* page layout */
.content {
max-width: none;
margin: 0;
}
.preview-container {
background: var(--wa-color-surface-lowered);
container-type: inline-size;
padding: 0;
max-inline-size: 1400px;
margin-inline: auto;
border: var(--wa-border-width-s) var(--wa-color-neutral-border-quiet) var(--wa-border-style);
overflow: clip;
}
.overlap {
position: relative;
color: var(--wa-color-text-normal);
z-index: 1;
}
.grid-12-col {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: var(--wa-space-m);
}
/* general and utility */
.strata {
padding: var(--wa-space-3xl) 7%;
}
pre wa-copy-button {
display: none;
}
wa-input::part(input) {
width: 100%;
}
.square-frame,
.landscape-frame {
flex: 1 1 auto;
overflow: hidden;
&:not(wa-card *) {
border-radius: calc(var(--wa-border-radius-l) - var(--wa-panel-border-width));
}
& > img {
block-size: 100%;
inline-size: 100%;
object-fit: cover;
}
}
.square-frame {
aspect-ratio: 1 / 1;
}
.landscape-frame {
aspect-ratio: 16 / 9;
}
/* strata - hero/header */
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.project-header .title {
grid-column-end: col-end;
}
.project-header wa-icon-button {
color: inherit;
font-size: var(--wa-font-size-l);
&:not(:last-of-type) {
margin-right: var(--wa-space-m);
}
}
/* strata product cards */
.products wa-card {
height: 100%;
}
.products wa-card::part(body) {
flex-grow: 1;
}
.product-card {
grid-column: span 12;
position: relative;
}
.product-card {
max-width: 45ch;
margin: 0 auto;
}
.product-card .title-rating {
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: var(--wa-flow-spacing);
}
.product-card .title {
margin: 0;
}
.product-card p:last-of-type {
margin-bottom: 0;
}
/* strata - blog post */
.blog .column-post-header {
grid-column: span 12;
position: relative;
}
.blog .post-body {
grid-column: span 12;
}
.blog .post-header {
position: sticky;
top: 1rem;
}
.blog .post-title {
margin-top: 0;
line-height: 1.2;
}
.blog .authors {
margin: var(--wa-space-2xl) 0;
}
.blog .authors a {
display: flex;
align-items: center;
gap: var(--wa-space-s);
}
/* strata - message composer */
.message-composer .card-header [slot='header'] {
display: flex;
align-items: center;
}
.message-composer .card-footer [slot='footer'] {
display: flex;
align-items: center;
justify-content: space-between;
}
.message-composer wa-card p {
margin-bottom: 0;
}
.message-composer .card-footer [slot='footer'] .tools {
display: flex;
align-items: center;
}
.message-composer .grouped-buttons:not(:first-of-type) {
padding-inline-start: var(--wa-space-m);
}
.message-composer .grouped-buttons:not(:last-of-type) {
padding-inline-end: var(--wa-space-m);
border-right: var(--wa-border-width-s) var(--wa-border-style) var(--wa-color-neutral-border-quiet);
}
.message-composer wa-card::part(header) {
border-start-start-radius: calc(var(--border-radius) - var(--border-width));
border-start-end-radius: calc(var(--border-radius) - var(--border-width));
}
.message-composer wa-card::part(footer) {
border-end-start-radius: calc(var(--border-radius) - var(--border-width));
border-end-end-radius: calc(var(--border-radius) - var(--border-width));
}
/* strata - product detail */
.product-detail .product-detail-images {
grid-column: span 12;
}
.product-detail .product-detail-info {
grid-column: span 12;
}
.product-detail wa-carousel {
max-width: 350px;
margin: 0 auto;
}
.product-detail .title-rating {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.product-detail .price {
font-size: var(--wa-font-size-xl);
}
.product-detail .price-discounted {
text-decoration: line-through;
color: var(--wa-color-text-quiet);
margin-inline-end: var(--wa-space-m);
}
/* strata - support table */
.support-table {
font-size: var(--wa-font-size-s);
}
.support-table th {
padding: var(--wa-space-l);
}
.support-table td {
padding: var(--wa-space-m) var(--wa-space-l);
}
.support-table .desc {
max-width: 30ch;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.support-table .excerpt {
color: var(--wa-color-text-quiet);
}
.support-table wa-avatar {
--size: var(--wa-font-size-2xl);
}
.support-table wa-card > * {
border-radius: calc(var(--border-radius) - var(--border-width));
}
/* strata - Checkout Form */
.checkout-form .payment {
grid-column: span 12;
order: 2;
}
.checkout-form .order {
grid-column: span 12;
}
.checkout-form .payment wa-input,
.checkout-form .payment wa-switch {
margin-bottom: var(--wa-space-l);
}
.checkout-form .order-item {
display: grid;
gap: var(--wa-space-xl);
align-items: center;
justify-content: space-between;
margin-bottom: var(--wa-space-xl);
}
.order-item .square-frame {
grid-column: span 2;
}
.order-item .name {
grid-column: span 5;
}
.order-item .finish {
display: block;
}
.order-item wa-input {
grid-column: span 3;
margin: 0;
}
.order-item .price {
grid-column: span 2;
}
/* responsive */
@container preview (min-width: 1040px) {
.product-card {
grid-column: span 4;
}
.blog .column-post-header {
grid-column: 1 / 5;
}
.blog .post-body {
grid-column: 5 / 13;
}
.product-detail .product-detail-images {
grid-column: 1 / 6;
}
.product-detail .product-detail-info {
grid-column: 7 / 13;
}
.checkout-form .payment {
grid-column: 1 / 6;
order: 1;
}
.checkout-form .order {
grid-column: 7 / 13;
order: 2;
}
}

141
docs/docs/themes/preview/content.njk vendored Normal file
View File

@@ -0,0 +1,141 @@
---
title: Content-focused theme preview
layout: blank
eleventyExcludeFromCollections: true
override:tags: []
noTheme: true
---
{% block head %}
<link href="{{ page.url }}../content.css" rel="stylesheet" />
<script type="module" src="{{ page.url }}../preview.js"></script>
{% endblock %}
<div class="preview-container">
<section class="overlap">
<div class="hero-background"></div>
<header class="strata project-header">
<h1 style="display: flex; align-items: center; margin: 0;">
<wa-icon id="project-logo" name="shapes"></wa-icon>
<span id="project-name" style="margin-inline-start: var(--wa-space-l);">Project Name</span>
</h1>
<div>
<wa-icon-button name="magnifying-glass" label="Search"></wa-icon-button>
<wa-icon-button name="user" label="Account"></wa-icon-button>
<wa-icon-button name="bag-shopping" label="Your Basket"></wa-icon-button>
</div>
</header>
<section class="strata hero">
<div class="title">
<h1 class="hero-title">What you know you can't explain, but you feel it.</h1>
<wa-button variant="brand" class="hero-cta">
<wa-icon slot="prefix" name="arrow-down"></wa-icon>
Free Your Mind
</wa-button>
</div>
</section>
<section class="strata products grid-12-col">
<wa-card class="card-image product-card">
<wa-badge variant="brand" class="badge-stock">New</wa-badge>
<div slot="image" class="landscape-frame">
<img id="product-1" src="/assets/images/themer/default/morpheus.jpg" alt="" />
</div>
<div class="title-rating">
<h2 class="title">Morpheus</h2>
<wa-rating label="Rating" value="4" readonly></wa-rating>
</div>
<div class="description">
<p>I see it in your eyes. You have the look of a man who accepts what he sees because he is expecting to wake up. Ironically, that's not far from the truth.</p>
</div>
<div slot="footer">
<wa-button size="small">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
Add to Cart
</wa-button>
<wa-button size="small" appearance="outline">
<wa-icon slot="prefix" name="bookmark" variant="regular"></wa-icon>
Save
</wa-button>
</div>
</wa-card>
<wa-card class="card-image product-card">
<wa-badge variant="warning" class="badge-stock">Low Stock</wa-badge>
<div slot="image" class="landscape-frame">
<img id="product-2" src="/assets/images/themer/default/seraph.jpg" alt="" />
</div>
<div class="title-rating">
<h2 class="title">Seraph</h2>
<wa-rating label="Rating" value="5" readonly></wa-rating>
</div>
<div class="description">
<p>The Oracle has many enemies, I had to be sure. You do not truly know someone until you fight them.</p>
</div>
<div slot="footer">
<wa-button size="small">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
Add to Cart
</wa-button>
<wa-button size="small" appearance="outline">
<wa-icon slot="prefix" name="bookmark" variant="regular"></wa-icon>
Save
</wa-button>
</div>
</wa-card>
<wa-card class="card-image product-card">
<div slot="image" class="landscape-frame">
<img id="product-3" src="/assets/images/themer/default/keymaker.jpg" alt="" />
</div>
<div class="title-rating">
<h2 class="title">Keymaker</h2>
<wa-rating label="Rating" value="3" readonly></wa-rating>
</div>
<div class="description">
<p>Only the One can open the door. And only during that window can that door be opened.</p>
</div>
<div slot="footer">
<wa-button size="small">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
Add to Cart
</wa-button>
<wa-button size="small" appearance="outline">
<wa-icon slot="prefix" name="bookmark" variant="regular"></wa-icon>
Save
</wa-button>
</div>
</wa-card>
</section>
<section class="strata blog grid-12-col">
<div class="column-post-header">
<div class="post-header">
<h1 class="post-title">Simulacra &amp; Simulation</h1>
<div class="post-meta">
<div class="authors">
<a href="">
<wa-avatar image="/assets/images/themer/avatar-baudrillard.jpg" label="Jean Baudrillard" shape="rounded"> </wa-avatar>
Jean Baudrillard
</a>
</div>
<div class="categories">
<a href=""><wa-tag size="small" variant="neutral">Action</wa-tag></a>
<a href=""><wa-tag size="small" variant="success">Dystopia</wa-tag></a>
<a href=""><wa-tag size="small" variant="warning">Sci-fi</wa-tag></a>
</div>
</div>
</div>
</div>
<div class="post-body">
<p>At an abandoned hotel, a police squad corners Trinity, who overpowers them with superhuman abilities. She flees, pursued by the police and a group of suited Agents capable of similar superhuman feats. She answers a ringing public telephone and vanishes.</p>
<p>Fishburne stated that once he read the script, he did not understand why other people found it confusing. However, he doubted if the movie would ever be made, because it was "so smart." The Wachowskis instructed Fishburne to base his performance on the character Morpheus in Neil Gaiman's <a href=""><i>Sandman</i></a> comics.</p>
<h2>The New Biology of Machines</h2>
<p>The method used for creating these effects involved a technically expanded version of an old art photography technique known as time-slice photography, in which an array of cameras are placed around an object and triggered simultaneously. Each camera captures a still picture, contributing one frame to the video sequence, which creates the effect of "virtual camera movement"; the illusion of a viewpoint moving around an object that appears frozen in time.</p>
<div class="landscape-frame" style="margin: 0 0 1rem 0;" >
<img id="blog_feature" src="/assets/images/themer/default/blog_feature.jpg" alt="blog post example image" />
</div>
<p>For the "real world," the actors' hair was less styled, their clothing had more textile content, and the cinematographers used longer lenses to soften the backgrounds and emphasize the actors.</p>
<pre class="codeblock">
<code class="language-css">:host,
.wa-theme-purple-power {
/* ... */
}</code></pre>

258
docs/docs/themes/preview/preview.js vendored Normal file
View File

@@ -0,0 +1,258 @@
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 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';
import { domChange } from '/assets/scripts/util/dom-change.js';
const themeIds = Object.keys(themes);
const paletteIds = Object.keys(palettes);
let dummy;
export const aspects = {};
deepEach(themeConfig, (config, aspect, obj, path) => {
if (!config?.default) {
// We're not in a config object
return;
}
if (config.url) {
config.values ??= aspect === 'palette' ? paletteIds : aspect === 'brand' ? allHues : themeIds;
config.urls ??= config.values.map(id => config.url(id));
config.selector ??= `link[rel="stylesheet"]:is(${config.urls.map(url => `[href$="/${url}"]`).join(', ')})`;
config.getValue = RegExp(`/${config.url('([^\\\\]+)')}($|\\?|#)`);
} else {
let styleClass = aspect === 'palette' || aspect === 'brand' ? aspect : `theme-${aspect}`;
config.selector ??= `style.wa-${styleClass}`;
}
});
/**
* @typedef {object} Theme
* @property {string} base
* @property {string} colors
* @property {string} palette
* @property {string} brand
* @property {string} typography
*/
export const theme = new EventTarget();
// Read base theme from document
// TODO read from non-URL aspects too
for (let aspect of themeParams) {
let element = document.querySelector(themeConfig[aspect].selector);
if (element) {
let value = element.href.match(themeConfig[aspect].getValue)?.[1];
if (value) {
theme[aspect] = value;
}
}
}
export const documentTheme = { ...theme };
if (location.search) {
let permalink = new Permalink();
// Apply any overrides from URL
let urlOverrides = permalink.getAll();
updateTheme(urlOverrides, { silent: true });
}
theme.base ??= 'default';
let isSameOrigin = false;
try {
isSameOrigin = Boolean(parent.document);
} catch (e) {}
if (isSameOrigin) {
// Were in the same origin as the parent, so lets be proactive about updating the preview.
// For third-party websites, we wait until a message is sent from the parent
// to avoid messing up the site for visitors
updatePreview({ immediate: true });
}
window.addEventListener('message', event => {
if (!event.data) {
return;
}
let { type, theme, id } = event.data;
if (type === 'updatePreview') {
updatePreview({ theme, id });
}
});
/**
* Returns a theme object to be fed to `getThemeCode()`,
* i.e. with empties for aspects that are set to their default values, and a resolved base
* Does NOT update `theme`, you need to call `updateTheme()` for that.
* @param {object} newTheme
* @returns {object}
*/
export function resolveTheme(newTheme) {
let ret = deepClone(theme);
ret = deepMerge(ret, newTheme, { emptyValues: [undefined, ''] });
deepEach(newTheme, (value, key, parent, path) => {
if (typeof value === 'object') {
return;
}
let defaultValue = deepGet(themeDefaults, path)?.[key];
defaultValue = typeof defaultValue === 'function' ? defaultValue.call(parent, themes) : defaultValue;
if (!value || value === defaultValue) {
delete parent[key];
}
});
return ret;
}
/**
* Update the current theme and fire a change event on it.
* Does NOT update the visible preview, you must call `updatePreview()` for that.
* @param {Theme} newTheme
* @param {object} options
* @param {boolean} options.silent - If true, don't fire the change event
* @returns {Theme & {any: boolean}} - The changed properties
*/
function updateTheme(newTheme, options = {}) {
let resolvedNewTheme = resolveTheme(newTheme);
let changed = {};
let anyChanged = false;
deepEach(theme, (value, key, parent, path) => {
if (typeof value === 'object') {
return;
}
let oldValue = deepGet(resolvedNewTheme, path)?.[key];
if (value !== oldValue) {
changed[key] = oldValue;
anyChanged = true;
parent[key] = value;
}
});
Object.defineProperty(changed, 'any', { value: anyChanged, enumerable: false });
if (anyChanged && !options.silent) {
theme.dispatchEvent(new CustomEvent('change', { detail: changed }));
}
return changed;
}
export async function updatePreview(options = {}) {
if (options.theme) {
updateTheme(options.theme, options);
}
let code = getThemeCode(theme, { id: options.id, attributes: ' class="wa-themer"' });
dummy ??= document.createElement('div');
dummy.innerHTML = code;
let allStylesheets = {};
let first, last;
let changeDom = false;
// DOM diffing of old and new <link> elements
for (let aspect of themeParams) {
allStylesheets[aspect] ??= {};
let stylesheets = allStylesheets[aspect];
// TODO use old values in selector instead of any?
let selector = themeConfig[aspect].selector;
let oldStylesheets = [...document.querySelectorAll(selector)];
let newStylesheets = [...dummy.querySelectorAll(selector)];
let oldUrls = new Set(oldStylesheets.map(link => link.href));
let newUrls = new Set(newStylesheets.map(link => link.href));
stylesheets.elements = new Map();
for (let link of oldStylesheets) {
let action = !link.href || newUrls.has(link.href) ? 'keep' : 'remove';
stylesheets.elements.set(link, action);
if (action === 'remove') {
changeDom = true;
}
}
for (let link of newStylesheets) {
if (!link.href || !oldUrls.has(link.href)) {
stylesheets.elements.set(link, 'add');
changeDom = true;
}
}
first ??= oldStylesheets[0];
last = oldStylesheets.at(-1);
}
// Replace all themer <style> elements, we don't diff those since it does not involve URL loading
// First, remove old ones
for (let oldStyle of document.querySelectorAll('style.wa-themer, style.wa-theme')) {
oldStyle.remove();
}
// Then, insert new ones
for (let newStyle of dummy.querySelectorAll('style.wa-themer, style.wa-theme')) {
(last || document.head.lastElementChild).after(newStyle);
}
if (!changeDom) {
return;
}
let toLoad = [];
let toRemove = [];
await domChange(async () => {
let previous;
for (let aspect of themeParams) {
let stylesheets = allStylesheets[aspect];
for (let [link, action] of stylesheets.elements) {
if (action === 'remove') {
toRemove.push(link);
} else if (action === 'add') {
toLoad.push(link);
if (previous) {
previous.after(link);
} else if (first) {
first.before(link);
} else {
// If no first, it means we didn't find any theme stylesheets
document.head.append(link);
}
}
previous = link;
}
}
let promises = toLoad.map(link => new Promise(resolve => (link.onload = resolve)));
await Promise.all(promises);
// Remove old stylesheets once the new ones load
for (let link of toRemove) {
link.remove();
}
}, options);
}

View File

@@ -1,3 +1,5 @@
@import url('/assets/styles/ui.css');
#mix_and_match {
margin-block-end: var(--wa-space-2xl);
@@ -30,9 +32,13 @@
wa-select:has(> wa-option > wa-card) {
&::part(listbox) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
width: min(90vw, 800px);
--column-width: 16ch;
--columns: 3;
}
&.theme-colors-select::part(listbox) {
--column-width: 15ch;
--columns: 4;
}
&:state(blank)::part(display-input) {
@@ -41,60 +47,6 @@
}
}
wa-option:has(> wa-card) {
position: relative;
padding: var(--wa-space-smaller);
&::part(checked-icon) {
position: absolute;
inset-block-start: calc(var(--wa-space-smaller) - 0.5em);
inset-inline-end: calc(var(--wa-space-smaller) - 0.5em);
width: 1em;
height: 1em;
line-height: 1em;
padding: 0.4em;
border-radius: var(--wa-border-radius-circle);
text-align: center;
background: var(--wa-color-brand-fill-loud);
color: var(--wa-color-brand-on-loud);
font-size: var(--wa-font-size-xs);
}
wa-card {
--spacing: var(--wa-space-smaller);
&::part(body) {
background: var(--wa-color-neutral-fill-quiet);
padding-block: var(--wa-space-s);
}
}
&:state(current),
&:state(hover),
&:state(selected),
&:hover {
background: transparent;
}
&:hover {
wa-card {
border-color: var(--wa-color-brand-border-loud);
box-shadow: 0 0 0 var(--wa-border-width-m) var(--wa-color-brand-border-normal);
}
}
&[aria-selected='true'] {
wa-card {
--border-color: var(--wa-color-brand-border-loud);
box-shadow: 0 0 0 var(--wa-border-width-m) var(--wa-color-brand-border-loud);
&::part(body) {
background: var(--wa-color-brand-fill-quiet);
}
}
}
}
.selected-swatch,
wa-select[name='brand'] wa-option::before {
content: '';
@@ -120,8 +72,3 @@
}
}
}
#test_select wa-option:state(selected) {
outline: 2px solid red;
background: yellow;
}

View File

@@ -1,163 +0,0 @@
import Prism from '/assets/scripts/prism.js';
import { cdnUrl, getThemeCode, Permalink } from '/assets/scripts/tweak.js';
await Promise.all(['wa-select', 'wa-option', 'wa-details'].map(tag => customElements.whenDefined(tag)));
const domChange = document.startViewTransition ? document.startViewTransition.bind(document) : fn => fn();
let selects, data, codeSnippets;
let computed = {
get isRemixed() {
return Object.values(data.params).filter(Boolean).length > 0;
},
get palette() {
return data.params.palette || data.defaultParams.palette;
},
get brand() {
return data.params.brand || data.defaultParams.brand;
},
};
function selectsChanged(event) {
data.params[event.target.name] = event.target.value;
render(event.target.name);
}
function init() {
selects = Object.fromEntries(
[...document.querySelectorAll('#mix_and_match wa-select')].map(select => [select.getAttribute('name'), select]),
);
codeSnippets = document.querySelector('#usage ~ wa-tab-group.import-stylesheet-code:first-of-type');
codeSnippets = {
html: codeSnippets?.querySelector('code.language-html'),
css: codeSnippets?.querySelector('code.language-css'),
};
data = {
baseTheme: wa_data.baseTheme,
themes: wa_data.themes,
palettes: wa_data.palettes,
defaultParams: {
colors: '',
get palette() {
let colors = data.params.colors || data.baseTheme;
return data.themes[colors].palette;
},
get brand() {
let colors = data.params.colors || data.baseTheme;
return data.themes[colors].brand;
},
typography: '',
},
params: { colors: '', palette: '', brand: '', typography: '' },
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);
}
}
if (computed.isRemixed) {
// Start with the remixing UI open if the theme has been remixed
mix_and_match.setAttribute('open', '');
mix_and_match.open = true;
}
for (let name in selects) {
selects[name].addEventListener('change', selectsChanged);
}
Promise.all(Object.values(selects).map(select => select.updateComplete)).then(() => render());
globalThis.remixApp = { selects, codeSnippets, data, computed, render };
}
init();
// Async load CSS for other themes *before* current theme stylesheet
let themeStylesheet = document.querySelector('#theme-stylesheet');
for (const theme in data.themes) {
themeStylesheet.insertAdjacentHTML(
'beforebegin',
`<link rel="preload" as="style" href="/dist/styles/themes/${theme}/color.css" onload="this.rel = 'stylesheet'" />
<link rel="preload" as="style" href="/dist/styles/themes/${theme}/typography.css" onload="this.rel = 'stylesheet'" />`,
);
}
for (const palette in data.palettes) {
themeStylesheet.insertAdjacentHTML(
'beforebegin',
`<link rel="preload" as="style" href="/dist/styles/color/${palette}.css" onload="this.rel = 'stylesheet'" />`,
);
}
function setDefault(select, value) {
let oldDefaultOption = select.querySelector(`wa-option[value=""]:not([data-id="${value}"])`);
let newDefaultOption = select.querySelector(`wa-option[value="${value}"]`);
if (oldDefaultOption) {
oldDefaultOption.value = oldDefaultOption.dataset.id;
}
if (newDefaultOption) {
newDefaultOption.dataset.id ??= newDefaultOption.value;
newDefaultOption.value = '';
}
}
function render(changedAspect) {
if (!globalThis.demo) {
return;
}
let url = new URL(demo.src);
if (!changedAspect || changedAspect === 'colors') {
// Update the default palette when the theme colors change to the default palette of that theme
setDefault(selects.palette, data.defaultParams.palette);
setDefault(selects.brand, data.defaultParams.brand);
}
let brand = data.params.brand || data.defaultParams.brand;
selects.brand.style.setProperty('--color', `var(--wa-color-${brand})`);
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]);
}
}
// Update demo URL
domChange(() => {
url.search = data.urlParams;
demo.src = url;
return new Promise(resolve => (demo.onload = resolve));
});
// Update page URL
data.urlParams.updateLocation();
// Update code snippets
for (let language in codeSnippets) {
let codeSnippet = codeSnippets[language];
if (!codeSnippet) {
continue;
}
let code = getThemeCode(data.baseTheme, data.params, { language, cdnUrl });
codeSnippet.textContent = code;
Prism.highlightElement(codeSnippet);
}
}
addEventListener('turbo:render', init);

View File

@@ -1,3 +1,5 @@
@import url('preview/app.css');
html {
background: transparent;
}
@@ -38,8 +40,6 @@ body,
isolation: isolate;
background-color: var(--wa-color-surface-lowered);
border-radius: var(--wa-border-radius-l);
padding: var(--wa-space-xl);
box-sizing: border-box;
overflow: hidden;
@media (width < 500px) {
@@ -49,6 +49,8 @@ body,
header {
min-width: min(25ch, 100vw);
align-self: center;
padding: var(--wa-space-xl);
box-sizing: border-box;
h1 {
margin-bottom: var(--wa-space-2xs);
@@ -63,54 +65,3 @@ body,
}
}
}
.showcase-examples-wrapper {
inline-size: 100%;
block-size: 100%;
}
.showcase-examples {
column-gap: var(--wa-space-xl);
& wa-card {
display: inline-block;
width: 100%;
&:has(+ wa-card) {
margin-block-end: var(--wa-space-xl);
}
}
@supports not (zoom: 1) {
column-count: 1;
@media (width > 750px) {
column-count: 2;
}
@media (width > 950px) {
column-count: 3;
}
}
@supports (zoom: 1) {
column-count: 1;
zoom: 40%;
@media (width > 350px) {
column-count: 2;
}
@media (width > 450px) {
zoom: 55%;
}
@media (width > 750px) {
zoom: 70%;
}
@media (width > 950px) {
column-count: 3;
}
}
}

View File

@@ -7,4 +7,7 @@ palette: vogue
brand: indigo
fonts:
body: Inter
icons:
family: classic
style: solid
---

View File

@@ -40,4 +40,9 @@
* Component Groups
*/
--wa-form-control-activated-color: var(--wa-color-neutral-fill-loud);
/**
* Icons
*/
--wa-icon-family: sharp;
}

View File

@@ -46,4 +46,9 @@
/* Panels */
--wa-panel-border-radius: var(--wa-border-radius-m);
/**
* Icons
*/
--wa-icon-variant: light;
}

View File

@@ -39,4 +39,9 @@
--wa-form-control-label-font-weight: var(--wa-font-weight-normal);
--wa-tooltip-arrow-size: 0rem;
/**
* Icons
*/
--wa-icon-variant: regular;
}

View File

@@ -38,4 +38,10 @@
* Component Groups
*/
--wa-form-control-border-color: var(--wa-color-neutral-border-normal);
/**
* Icons
*/
--wa-icon-family: duotone;
--wa-icon-variant: light;
}

View File

@@ -30,4 +30,10 @@
--wa-form-control-activated-color: var(--wa-color-neutral-fill-loud);
--wa-form-control-background-color: transparent;
--wa-form-control-value-line-height: var(--wa-line-height-normal);
/**
* Icons
*/
--wa-icon-family: sharp;
--wa-icon-variant: regular;
}