Icons: place icons with custom properties, not inline SVG
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/README.md b/README.md
index c1b757b..aa17b82 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Gloss
The design system for Arbitrary Definitions, a personal umbrella of
-self-hosted, single-user tools. Sixteen components and five foundations,
+self-hosted, single-user tools. Sixteen components and six foundations,
documented by a small Ruby/ERB site (no Jekyll) with hand-maintained CSS and
hand-written content.
diff --git a/SKILL.md b/SKILL.md
index 932af80..0b81e61 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -55,6 +55,10 @@ guessing from a similar one or from memory:
regardless, so using them as selectors invents nothing.
- `data-*` attributes for variants and states: `data-variant="primary"`,
`data-size="sm"`, `data-tone="danger"`, `data-elevated`.
+- Icons are custom properties, never `<svg>` markup: `--gl-icon:
+ var(--gl-icon-trash)` on a Button, IconButton, or Avatar, or
+ `--gl-icon-start`/`--gl-icon-end` on anything else. See
+ `_foundations/icons.md`.
- Exactly seven plain, unmodified classes exist, each because there is no
element or attribute to hang a selector on: `.btn` (an `<a>` acting as a
button — a bare `a` selector would catch every prose link on the page),
diff --git a/_components/button.md b/_components/button.md
index fa30668..20619ba 100644
--- a/_components/button.md
+++ b/_components/button.md
@@ -92,20 +92,19 @@ disabled primary still reads as the primary.
## With an icon
-Pass an icon as the first child. The gap is `--gl-space-2xs`, set by the
-button itself.
+Set `--gl-icon` to one of the [icon tokens](<%= relative_url('/foundations/icons/') %>).
+The button draws it before the label, at the label's size and color, with
+the button's own `--gl-space-2xs` gap.
<figure class="example">
<figcaption>Icon</figcaption>
<div>
-<button data-variant="primary">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
+<button data-variant="primary" style="--gl-icon: var(--gl-icon-plus)">
New
</button>
</div>
<details><summary>Markup</summary>
-<pre><code><button data-variant="primary">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
+<pre><code><button data-variant="primary" style="--gl-icon: var(--gl-icon-plus)">
New
</button></code></pre>
</details>
@@ -174,6 +173,7 @@ class Button needs, and only anchors reach for it.
<tr><td><code>data-variant</code></td><td><code>primary | dark | ghost | danger</code></td><td>Omit for the default, quiet look — no fill at rest.</td></tr>
<tr><td><code>data-size</code></td><td><code>sm | lg</code></td><td>Omit for the default (medium) size. Padding, plus one step of font size at each end.</td></tr>
<tr><td><code>data-block</code></td><td>boolean</td><td>Full width of its container.</td></tr>
+ <tr><td><code>--gl-icon</code></td><td>an icon token</td><td>Draws the icon before the label. See <a href="<%= relative_url('/foundations/icons/') %>">Icons</a>.</td></tr>
<tr><td><code>disabled</code></td><td>boolean</td><td>Removes the button from the tab order.</td></tr>
<tr><td><code>aria-disabled</code></td><td><code>"true"</code></td><td>Looks disabled, stays focusable.</td></tr>
<tr><td><code>[role="group"]</code></td><td></td><td>Wrap adjacent buttons in a group to join their radii; a hairline divider separates them, since the buttons themselves carry no border.</td></tr>
diff --git a/_components/card-row.md b/_components/card-row.md
index e4bfe7d..e777e31 100644
--- a/_components/card-row.md
+++ b/_components/card-row.md
@@ -63,9 +63,7 @@ patterns for it in context).
<div data-surface="sunken">
<ul class="card" style="max-width: 24rem;">
<li>
- <span class="avatar" data-square>
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg>
- </span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div style="flex:1; min-width:0;">
<div style="font-weight: 550;">Dishwasher manual</div>
<div class="type-label" style="margin-top: 2px;">PDF · 2.1MB</div>
@@ -77,9 +75,7 @@ patterns for it in context).
<details><summary>Markup</summary>
<pre><code><ul class="card" style="max-width: 24rem;">
<li>
- <span class="avatar" data-square>
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg>
- </span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div style="flex:1; min-width:0;">
<div style="font-weight: 550;">Dishwasher manual</div>
<div class="type-label" style="margin-top: 2px;">PDF · 2.1MB</div>
diff --git a/_components/icon-button.md b/_components/icon-button.md
index 83fd99c..ee51585 100644
--- a/_components/icon-button.md
+++ b/_components/icon-button.md
@@ -16,31 +16,21 @@ pattern this system does not otherwise reach for.
A square icon-only button is still a `<button>`, but there is no attribute
that tells it apart from a plain [Button](<%= relative_url('/components/button/') %>)
sitting alone — `variant`/`size` mean different things for each — so
-`.icon-button` is the one class this component needs.
+`.icon-button` is the one class this component needs. The icon is
+`--gl-icon`, one of the [icon tokens](<%= relative_url('/foundations/icons/') %>),
+sized to the label type a Button of the same size would carry.
<figure class="example">
<figcaption>Variants</figcaption>
<div>
-<button class="icon-button" aria-label="Delete">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
-</button>
-<button class="icon-button" data-variant="outline" aria-label="Settings">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5" /></svg>
-</button>
-<button class="icon-button" data-variant="accent" aria-label="Filter (active)">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 8v5l-4 2v-7z" /></svg>
-</button>
+<button class="icon-button" aria-label="Delete" style="--gl-icon: var(--gl-icon-trash)"></button>
+<button class="icon-button" data-variant="outline" aria-label="Settings" style="--gl-icon: var(--gl-icon-settings)"></button>
+<button class="icon-button" data-variant="accent" aria-label="Filter (active)" style="--gl-icon: var(--gl-icon-filter)"></button>
</div>
<details open><summary>Markup</summary>
-<pre><code><button class="icon-button" aria-label="Delete">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
-</button>
-<button class="icon-button" data-variant="outline" aria-label="Settings">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3" /><path d="M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5" /></svg>
-</button>
-<button class="icon-button" data-variant="accent" aria-label="Filter (active)">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16l-6 8v5l-4 2v-7z" /></svg>
-</button></code></pre>
+<pre><code><button class="icon-button" aria-label="Delete" style="--gl-icon: var(--gl-icon-trash)"></button>
+<button class="icon-button" data-variant="outline" aria-label="Settings" style="--gl-icon: var(--gl-icon-settings)"></button>
+<button class="icon-button" data-variant="accent" aria-label="Filter (active)" style="--gl-icon: var(--gl-icon-filter)"></button></code></pre>
</details>
</figure>
@@ -56,14 +46,14 @@ is ever retuned.
<figure class="example">
<figcaption>Sizes</figcaption>
<div>
-<button class="icon-button" data-variant="outline" data-size="sm" aria-label="Back"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
-<button class="icon-button" data-variant="outline" aria-label="Back"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
-<button class="icon-button" data-variant="outline" data-size="lg" aria-label="Back"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
+<button class="icon-button" data-variant="outline" data-size="sm" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
+<button class="icon-button" data-variant="outline" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
+<button class="icon-button" data-variant="outline" data-size="lg" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
</div>
<details><summary>Markup</summary>
-<pre><code><button class="icon-button" data-variant="outline" data-size="sm" aria-label="Back"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
-<button class="icon-button" data-variant="outline" aria-label="Back"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
-<button class="icon-button" data-variant="outline" data-size="lg" aria-label="Back"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button></code></pre>
+<pre><code><button class="icon-button" data-variant="outline" data-size="sm" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
+<button class="icon-button" data-variant="outline" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
+<button class="icon-button" data-variant="outline" data-size="lg" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button></code></pre>
</details>
</figure>
@@ -75,17 +65,13 @@ An icon-only control almost always wants a [Tooltip](<%= relative_url('/componen
<figcaption>With a tooltip</figcaption>
<div>
<span>
- <button class="icon-button" aria-label="Delete" aria-describedby="icon-btn-tip">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
- </button>
+ <button class="icon-button" aria-label="Delete" aria-describedby="icon-btn-tip" style="--gl-icon: var(--gl-icon-trash)"></button>
<span role="tooltip" id="icon-btn-tip">Delete</span>
</span>
</div>
<details><summary>Markup</summary>
<pre><code><span>
- <button class="icon-button" aria-label="Delete" aria-describedby="icon-btn-tip">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
- </button>
+ <button class="icon-button" aria-label="Delete" aria-describedby="icon-btn-tip" style="--gl-icon: var(--gl-icon-trash)"></button>
<span role="tooltip" id="icon-btn-tip">Delete</span>
</span></code></pre>
</details>
@@ -98,6 +84,7 @@ An icon-only control almost always wants a [Tooltip](<%= relative_url('/componen
<tbody>
<tr><td><code>button.icon-button</code></td><td></td><td>Required base — the one class in this component, since nothing distinguishes it from Button structurally.</td></tr>
<tr><td><code>aria-label</code></td><td>string</td><td>Required — becomes the accessible name and the native tooltip.</td></tr>
+ <tr><td><code>--gl-icon</code></td><td>an icon token</td><td>Required — the icon. See <a href="<%= relative_url('/foundations/icons/') %>">Icons</a>.</td></tr>
<tr><td><code>data-size</code></td><td><code>sm | lg</code></td><td>Square, matching the Button of the same size: 25px small, 39–43px large. Omit for the default, 35–38px.</td></tr>
<tr><td><code>data-variant</code></td><td><code>outline | accent</code></td><td>Omit for the default, transparent ghost look.</td></tr>
</tbody>
diff --git a/_components/tooltip.md b/_components/tooltip.md
index f8d9f16..2a2e957 100644
--- a/_components/tooltip.md
+++ b/_components/tooltip.md
@@ -20,17 +20,13 @@ Hover or tab to the button below.
<figcaption>Tooltip</figcaption>
<div>
<span>
- <button class="icon-button" aria-label="Delete" aria-describedby="tooltip-basic">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
- </button>
+ <button class="icon-button" aria-label="Delete" aria-describedby="tooltip-basic" style="--gl-icon: var(--gl-icon-trash)"></button>
<span role="tooltip" id="tooltip-basic">Delete</span>
</span>
</div>
<details open><summary>Markup</summary>
<pre><code><span>
- <button class="icon-button" aria-label="Delete" aria-describedby="tooltip-basic">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" /></svg>
- </button>
+ <button class="icon-button" aria-label="Delete" aria-describedby="tooltip-basic" style="--gl-icon: var(--gl-icon-trash)"></button>
<span role="tooltip" id="tooltip-basic">Delete</span>
</span></code></pre>
</details>
diff --git a/_foundations/icons.md b/_foundations/icons.md
new file mode 100644
index 0000000..d9caf47
--- /dev/null
+++ b/_foundations/icons.md
@@ -0,0 +1,83 @@
+---
+title: Icons
+summary: >-
+ Icons are tokens, like colors. A component gets one by setting a custom
+ property, never by carrying an <svg> in its markup, and the icon
+ takes the text's color and size.
+---
+
+## Placing an icon
+
+Set `--gl-icon` on a Button, IconButton, or Avatar. Each one decides where
+its icon goes, so the markup only names the icon:
+
+```html
+<button style="--gl-icon: var(--gl-icon-plus)">New</button>
+```
+
+The property can come from a stylesheet as easily as an inline style, and
+it reacts to anything a selector can:
+
+```css
+button[data-action="delete"] { --gl-icon: var(--gl-icon-trash); }
+:root[data-theme="dark"] [data-theme-toggle] { --gl-icon: var(--gl-icon-moon); }
+```
+
+Any other element takes `--gl-icon-start`, drawn in its `::before`, or
+`--gl-icon-end`, drawn in its `::after`. An icon replaces whatever that
+pseudo-element would otherwise draw.
+
+None of the three properties inherit: an icon set on a `<p>` does not
+repeat on every link inside it.
+
+## Tokens
+
+Every icon is a monochrome SVG on a 24-unit grid with rounded strokes. It
+is painted as a mask, so the stroke color written into the token never
+shows.
+
+<table>
+ <thead><tr><th scope="col">Token</th><th scope="col">Icon</th><th scope="col">Use for</th></tr></thead>
+ <tbody>
+ <tr><td><code>--gl-icon-camera</code></td><td><span style="--gl-icon-start: var(--gl-icon-camera)"></span></td><td>Capturing a photo.</td></tr>
+ <tr><td><code>--gl-icon-chevron-left</code></td><td><span style="--gl-icon-start: var(--gl-icon-chevron-left)"></span></td><td>Back, in a header.</td></tr>
+ <tr><td><code>--gl-icon-chevron-down</code></td><td><span style="--gl-icon-start: var(--gl-icon-chevron-down)"></span></td><td>Select's own chevron. It is the one icon Select paints as a background, so its stroke carries <code>--gl-color-text-secondary</code> and is redefined for the dark theme.</td></tr>
+ <tr><td><code>--gl-icon-filter</code></td><td><span style="--gl-icon-start: var(--gl-icon-filter)"></span></td><td>Narrowing a list.</td></tr>
+ <tr><td><code>--gl-icon-moon</code></td><td><span style="--gl-icon-start: var(--gl-icon-moon)"></span></td><td>The dark theme, in a theme toggle.</td></tr>
+ <tr><td><code>--gl-icon-package</code></td><td><span style="--gl-icon-start: var(--gl-icon-package)"></span></td><td>A stored file or item, in an Avatar.</td></tr>
+ <tr><td><code>--gl-icon-plus</code></td><td><span style="--gl-icon-start: var(--gl-icon-plus)"></span></td><td>Creating something new.</td></tr>
+ <tr><td><code>--gl-icon-settings</code></td><td><span style="--gl-icon-start: var(--gl-icon-settings)"></span></td><td>Opening settings.</td></tr>
+ <tr><td><code>--gl-icon-sun</code></td><td><span style="--gl-icon-start: var(--gl-icon-sun)"></span></td><td>The light theme, in a theme toggle.</td></tr>
+ <tr><td><code>--gl-icon-trash</code></td><td><span style="--gl-icon-start: var(--gl-icon-trash)"></span></td><td>Deleting.</td></tr>
+ </tbody>
+</table>
+
+A new icon is one more line in `tokens.css`: a `url()` holding an SVG,
+with `<`, `>`, and `#` percent-encoded.
+
+## Styling
+
+These properties do inherit, so setting one on a container styles every
+icon inside it:
+
+<table>
+ <thead><tr><th scope="col">Property</th><th scope="col">Default</th><th scope="col">Effect</th></tr></thead>
+ <tbody>
+ <tr><td><code>--gl-icon-style-size</code></td><td><code>1em</code></td><td>Width and height.</td></tr>
+ <tr><td><code>--gl-icon-style-color</code></td><td><code>currentColor</code></td><td>Fill. Point it at a color token, never a literal.</td></tr>
+ <tr><td><code>--gl-icon-style-spacing</code></td><td><code>0.3em</code></td><td>Gap between the icon and the text beside it. Button, IconButton, and Avatar set it to zero and space with their own layout.</td></tr>
+ <tr><td><code>--gl-icon-style-voffset</code></td><td><code>-0.02em</code></td><td>Optical nudge. The icon is centered on the cap height, and a positive value moves it up.</td></tr>
+ </tbody>
+</table>
+
+## Limits
+
+- An icon has one color. A multicolor mark stays inline SVG.
+- An icon can't have its own background or border. Put those on the
+ element and the icon inside it, as Avatar does.
+- An icon can't animate its paths. It can change on any state a selector
+ can reach, but it swaps, not morphs.
+
+The approach, and the WebKit workarounds in `components/icon.css`, come
+from Lea Verou's [The best way to use icons on the Web](https://lea.verou.me/blog/2026/icons/)
+and her `@nudeui/icons` package.
diff --git a/_layouts/page.erb b/_layouts/page.erb
index 48a91fc..922de2d 100644
--- a/_layouts/page.erb
+++ b/_layouts/page.erb
@@ -64,14 +64,6 @@
<div class="gl-doc-header__actions">
<button data-variant="ghost" data-size="sm" data-theme-toggle
aria-label="Switch color theme" title="Switch color theme">
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="light">
- <circle cx="12" cy="12" r="4.5" stroke="currentColor" stroke-width="2"/>
- <path d="M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7"
- stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
- </svg>
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-theme-icon="dark" hidden>
- <path d="M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
- </svg>
<span class="gl-visually-hidden" data-theme-label>Theme</span>
</button>
<a class="btn" data-size="sm" href="https://github.com/<%= REPOSITORY %>">Source</a>
diff --git a/_patterns/archive.md b/_patterns/archive.md
index b74b11c..5e5d72a 100644
--- a/_patterns/archive.md
+++ b/_patterns/archive.md
@@ -25,7 +25,7 @@ Three screens, abbreviated to what each one adds.
</div>
<ul class="card">
<li>
- <span class="avatar" data-square><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg></span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div class="pattern-row">
<div class="pattern-file-title pattern-file-title--truncate">Dishwasher manual</div>
<div class="type-label pattern-file-meta">PDF · 2.1MB</div>
@@ -33,7 +33,7 @@ Three screens, abbreviated to what each one adds.
<span class="type-mono pattern-row__meta">2h</span>
</li>
<li>
- <span class="avatar" data-square><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg></span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div class="pattern-row">
<div class="pattern-file-title">Sofa receipt</div>
<div class="type-label pattern-file-meta">JPG · 640KB</div>
@@ -41,7 +41,7 @@ Three screens, abbreviated to what each one adds.
<span class="type-mono pattern-row__meta">1d</span>
</li>
<li>
- <span class="avatar" data-square><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg></span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div class="pattern-row">
<div class="pattern-file-title">Passport scan</div>
<div class="type-label pattern-file-meta">PDF · 890KB</div>
@@ -51,20 +51,18 @@ Three screens, abbreviated to what each one adds.
</ul>
</div>
<div class="pattern-dock">
- <button data-variant="primary" data-size="lg" data-block>
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8a2 2 0 0 1 2-2h1.5l1-1.5h9l1 1.5H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z" /><circle cx="12" cy="13" r="3.5" /></svg>
+ <button data-variant="primary" data-size="lg" data-block style="--gl-icon: var(--gl-icon-camera)">
Take a photo
</button>
</div>
</div>
<div class="pattern-phone">
<header class="pattern-phone__header">
- <button class="icon-button" data-size="sm" aria-label="Back"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
+ <button class="icon-button" data-size="sm" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
<span class="pattern-phone__title">Save</span>
</header>
<div class="pattern-phone__body">
- <div class="pattern-photo-slot">
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg>
+ <div class="pattern-photo-slot" style="--gl-icon-start: var(--gl-icon-package)">
<span class="type-label">Photo preview</span>
</div>
<label class="field" style="margin-top: var(--gl-space-m);">
@@ -79,7 +77,7 @@ Three screens, abbreviated to what each one adds.
</div>
<div class="pattern-phone">
<header class="pattern-phone__header">
- <button class="icon-button" data-size="sm" aria-label="Back"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg></button>
+ <button class="icon-button" data-size="sm" aria-label="Back" style="--gl-icon: var(--gl-icon-chevron-left)"></button>
<span class="type-label">Archive</span>
</header>
<div class="pattern-phone__body">
@@ -91,7 +89,7 @@ Three screens, abbreviated to what each one adds.
<div class="type-label pattern-aside__label--tight">File</div>
<ul class="card">
<li>
- <span class="avatar" data-square><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5V8Z" /><path d="M3 8l9 5 9-5M12 13v8" /></svg></span>
+ <span class="avatar" data-square style="--gl-icon: var(--gl-icon-package)"></span>
<div class="pattern-row" style="font-size: var(--gl-step--1);">PDF · 2.1MB</div>
</li>
</ul>
diff --git a/_patterns/loop.md b/_patterns/loop.md
index 6fc2075..c0bc082 100644
--- a/_patterns/loop.md
+++ b/_patterns/loop.md
@@ -20,8 +20,7 @@ actual project built on this system would write.
<header class="pattern-dashboard__header">
<span class="pattern-dashboard__title">Loop</span>
<div class="pattern-spacer"></div>
- <button data-variant="primary" data-size="sm">
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
+ <button data-variant="primary" data-size="sm" style="--gl-icon: var(--gl-icon-plus)">
New
</button>
<span class="avatar">km</span>
diff --git a/_sass/components/button.css b/_sass/components/button.css
index 02254a5..95435b9 100644
--- a/_sass/components/button.css
+++ b/_sass/components/button.css
@@ -27,6 +27,9 @@ a.btn {
--_pad-y: var(--gl-space-2xs);
--_pad-x: var(--gl-space-xs);
--_font: var(--gl-step--1);
+ /* The flex gap already separates the icon from the label. */
+ --gl-icon-start: var(--gl-icon);
+ --gl-icon-style-spacing: 0;
display: inline-flex;
align-items: center;
@@ -145,6 +148,10 @@ button:not(.icon-button):disabled,
--_size: calc(2 * var(--_pad) + 1.15 * var(--_line));
--_bg: transparent;
--_fg: var(--gl-color-text-secondary);
+ /* No spacing, so stray whitespace in the markup can't push the icon
+ off-center. */
+ --gl-icon-start: var(--gl-icon);
+ --gl-icon-style-spacing: 0;
display: inline-flex;
align-items: center;
@@ -157,6 +164,7 @@ button:not(.icon-button):disabled,
border-radius: var(--gl-radius-md);
background: var(--_bg);
color: var(--_fg);
+ font-size: var(--_line);
cursor: pointer;
transition: background-color var(--gl-dur-fast) var(--gl-ease),
color var(--gl-dur-fast) var(--gl-ease);
diff --git a/_sass/components/card.css b/_sass/components/card.css
index 8e86580..ddcdde5 100644
--- a/_sass/components/card.css
+++ b/_sass/components/card.css
@@ -110,6 +110,10 @@
<span> is too generic for a bare selector, so it keeps one class. */
.avatar {
--_size: 1.875rem;
+ --gl-icon-start: var(--gl-icon);
+ /* Sized from the box, not the initials' small font, so it follows data-size. */
+ --gl-icon-style-size: calc(var(--_size) * 0.55);
+ --gl-icon-style-spacing: 0;
display: inline-flex;
align-items: center;
diff --git a/_sass/components/icon.css b/_sass/components/icon.css
new file mode 100644
index 0000000..691d52f
--- /dev/null
+++ b/_sass/components/icon.css
@@ -0,0 +1,104 @@
+/* Icon
+ Icons are tokens (--gl-icon-* in tokens.css), placed by setting a custom
+ property — no <svg> in the markup. Adapted from Lea Verou's @nudeui/icons
+ 0.0.3 (MIT), https://lea.verou.me/blog/2026/icons/, minus its opt-in
+ classes: style queries reach every browser this system supports, and
+ the class list is closed.
+
+ --gl-icon-start renders in ::before, --gl-icon-end in ::after. --gl-icon
+ means "give this component an icon"; each component redirects it to a
+ location (see button.css). An icon takes over that pseudo-element, so a
+ component that already draws its own ::before or ::after must not
+ redirect there.
+
+ All three are non-inheriting: an icon on a <p> must not repeat on every
+ <a> inside it. The pseudo-element inherits explicitly instead. */
+
+@property --gl-icon {
+ syntax: "*";
+ inherits: false;
+}
+@property --gl-icon-start {
+ syntax: "*";
+ inherits: false;
+}
+@property --gl-icon-end {
+ syntax: "*";
+ inherits: false;
+}
+
+/* A 1em square of the text color, masked by the icon. The image-set()
+ first candidate is a transparent placeholder for the box's content; the
+ second only exists to make the declaration fail — and so drop the
+ pseudo-element — when the icon property is unset. overflow: hidden puts
+ the box's baseline at its bottom edge so vertical-align can center it on
+ the cap height (https://blog.kizu.dev/cap-height-align/). */
+@container style(--gl-icon-start) {
+ ::before {
+ --gl-icon-start: inherit;
+ content: image-set(linear-gradient(transparent 0 100%), var(--gl-icon-start));
+ display: inline-block;
+ vertical-align: calc(0.5cap - var(--gl-icon-style-size, 1em) / 2 + var(--gl-icon-style-voffset, -0.02em));
+ overflow: hidden;
+ width: var(--gl-icon-style-size, 1em);
+ height: var(--gl-icon-style-size, 1em);
+ background-color: var(--gl-icon-style-color, currentColor);
+ mask: var(--gl-icon-start) no-repeat center / contain;
+ pointer-events: none;
+ forced-color-adjust: none;
+ flex: none;
+ }
+ :where(:not(:empty))::before { margin-inline-end: var(--gl-icon-style-spacing, 0.3em); }
+}
+
+@container style(--gl-icon-end) {
+ ::after {
+ --gl-icon-end: inherit;
+ content: image-set(linear-gradient(transparent 0 100%), var(--gl-icon-end));
+ display: inline-block;
+ vertical-align: calc(0.5cap - var(--gl-icon-style-size, 1em) / 2 + var(--gl-icon-style-voffset, -0.02em));
+ overflow: hidden;
+ width: var(--gl-icon-style-size, 1em);
+ height: var(--gl-icon-style-size, 1em);
+ background-color: var(--gl-icon-style-color, currentColor);
+ mask: var(--gl-icon-end) no-repeat center / contain;
+ pointer-events: none;
+ forced-color-adjust: none;
+ flex: none;
+ }
+ :where(:not(:empty))::after { margin-inline-start: var(--gl-icon-style-spacing, 0.3em); }
+}
+
+/* WebKit only, each fix in its own layer so any unlayered rule beats it.
+ https://bugs.webkit.org/show_bug.cgi?id=301609 — icons stay missing
+ until the first interaction; an animation forces style to re-resolve
+ against the right container. Its default duration is already zero.
+ https://bugs.webkit.org/show_bug.cgi?id=325900 — `inherit` on a
+ non-inheriting registered property is unreliable, so WebKit gets them
+ inheriting (the later registration wins) and descendants reset instead. */
+@keyframes gl-webkit-301609 {}
+
+@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
+ @layer gl-webkit-301609 {
+ ::before,
+ ::after { animation-name: gl-webkit-301609; }
+ }
+
+ @property --gl-icon-start {
+ syntax: "*";
+ inherits: true;
+ }
+ @property --gl-icon-end {
+ syntax: "*";
+ inherits: true;
+ }
+
+ @layer gl-webkit-325900 {
+ @container style(--gl-icon-start) {
+ * { --gl-icon-start: initial; }
+ }
+ @container style(--gl-icon-end) {
+ * { --gl-icon-end: initial; }
+ }
+ }
+}
diff --git a/_sass/site/shell.css b/_sass/site/shell.css
index d9fa6bb..29c2d3e 100644
--- a/_sass/site/shell.css
+++ b/_sass/site/shell.css
@@ -66,6 +66,13 @@
.gl-doc-header__actions { margin-inline-start: auto; display: flex; align-items: center; gap: var(--gl-space-2xs); }
+/* Shows the theme in effect, following the same selectors as tokens.css. */
+[data-theme-toggle] { --gl-icon: var(--gl-icon-sun); --gl-icon-style-size: 1rem; }
+:root[data-theme="dark"] [data-theme-toggle] { --gl-icon: var(--gl-icon-moon); }
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) [data-theme-toggle] { --gl-icon: var(--gl-icon-moon); }
+}
+
.gl-doc-layout {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
diff --git a/assets/css/tokens.css b/assets/css/tokens.css
index 57f7bfb..0bba6a4 100644
--- a/assets/css/tokens.css
+++ b/assets/css/tokens.css
@@ -43,8 +43,21 @@
--gl-color-text-secondary: #5f5d59;
--gl-color-text-tertiary: #888681;
- /* Icons — a data URI can't read a custom property, so each stroke repeats
- its color by hand and is redefined alongside it in the dark blocks. */
+ /* Icons — monochrome SVGs, painted as a mask in the text color by
+ components/icon.css, so their own stroke color never shows. */
+ --gl-icon-camera: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8a2 2 0 0 1 2-2h1.5l1-1.5h9l1 1.5H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Z'/%3E%3Ccircle cx='12' cy='13' r='3.5'/%3E%3C/svg%3E");
+ --gl-icon-chevron-left: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18l-6-6 6-6'/%3E%3C/svg%3E");
+ --gl-icon-filter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 5h16l-6 8v5l-4 2v-7z'/%3E%3C/svg%3E");
+ --gl-icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 14.2A8.4 8.4 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2Z'/%3E%3C/svg%3E");
+ --gl-icon-package: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 8 12 3 3 8v8l9 5 9-5V8Z'/%3E%3Cpath d='M3 8l9 5 9-5M12 13v8'/%3E%3C/svg%3E");
+ --gl-icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E");
+ --gl-icon-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M12 2.5v3M12 18.5v3M3.6 7.2l2.6 1.5M17.8 15.3l2.6 1.5M3.6 16.8l2.6-1.5M17.8 8.7l2.6-1.5'/%3E%3C/svg%3E");
+ --gl-icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4.5'/%3E%3Cpath d='M12 1.8v2.4M12 19.8v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M1.8 12h2.4M19.8 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7'/%3E%3C/svg%3E");
+ --gl-icon-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13'/%3E%3C/svg%3E");
+
+ /* Select paints its chevron as a background, not a mask, and a data URI
+ can't read a custom property — so this one stroke repeats
+ --gl-color-text-secondary by hand and is redefined in the dark blocks. */
--gl-icon-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5 6 6.5l5-5' fill='none' stroke='%235f5d59' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
/* Accent */
diff --git a/assets/js/system.js b/assets/js/system.js
index bba90eb..6872652 100644
--- a/assets/js/system.js
+++ b/assets/js/system.js
@@ -35,9 +35,6 @@
function paintToggle(theme) {
document.querySelectorAll("[data-theme-toggle]").forEach(function (toggle) {
var next = theme === "dark" ? "light" : "dark";
- toggle.querySelectorAll("[data-theme-icon]").forEach(function (icon) {
- icon.hidden = icon.dataset.themeIcon !== theme;
- });
toggle.setAttribute("aria-label", "Switch to the " + next + " theme");
toggle.setAttribute("title", "Switch to the " + next + " theme");
var label = toggle.querySelector("[data-theme-label]");
diff --git a/index.md b/index.md
index bfa4ec6..c8d1fa6 100644
--- a/index.md
+++ b/index.md
@@ -55,7 +55,7 @@ same afternoon.
<div class="card-body">
<h3><a href="<%= relative_url('/foundations/color/') %>">Foundations</a></h3>
<p>
- Color, type, space, elevation, and motion — with contrast ratios
+ Color, type, space, elevation, motion, and icons — with contrast ratios
computed from the tokens and checked by hand, not typed by guess.
</p>
</div>
diff --git a/lib/build.rb b/lib/build.rb
index 98feb37..0af5ad0 100644
--- a/lib/build.rb
+++ b/lib/build.rb
@@ -48,6 +48,7 @@ LANG = "en"
CSS_BUNDLES = {
"base" => %w[base/reset.css base/typography.css],
"components" => %w[
+ components/icon.css
components/button.css
components/field.css
components/badge.css
diff --git a/rationale.md b/rationale.md
index 7c63049..f70699a 100644
--- a/rationale.md
+++ b/rationale.md
@@ -139,8 +139,8 @@ Being honest about the design is being honest about the gaps too.
by a source of truth beyond this site.
- **No logo or brand mark.** Render "Gloss" or the project's own name in
type wherever a mark would go — don't invent one.
-- **The icon set (Lucide) is a flagged substitution** — there's no shipped
- icon library of its own yet.
+- **The icon set is only what these pages use** — ten tokens, not a
+ library. Add one to `tokens.css` when a screen needs it.
- **Signal Teal, the default accent, is one pick among the sanctioned
alternates**, not a fixed identity.
- **No Table, Callout, or Progress component.** An earlier pass at this site