Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

<!-- Brief description of what this PR does -->

## SemVer Impact

<!-- Which version bump does this PR require? Check one. -->

- [ ] **Patch** – backwards-compatible bug fix
- [ ] **Minor** – new feature, new component, or new public prop (backwards-compatible)
- [ ] **Major** – breaking change to an existing public API
- [ ] **None** – docs, CI, refactor, or other non-release change

## Changes

-
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml

typecheck:
uses: ./.github/workflows/typecheck.yml

test:
needs: lint
needs: [lint, typecheck]
uses: ./.github/workflows/test.yml

build:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ jobs:
lint:
uses: ./.github/workflows/lint.yml

typecheck:
uses: ./.github/workflows/typecheck.yml

test:
needs: lint
needs: [lint, typecheck]
uses: ./.github/workflows/test.yml

build:
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Typecheck

on:
workflow_call:

jobs:
typecheck:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: 'lts/*'
cache: 'npm'

- run: npm ci

- run: npm run typecheck
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Components require a theme from `@ankh-studio/themes`:
| Component | Description |
|-----------|-------------|
| `<ankh-button>` | Button with variants: filled, outlined, text, elevated, tonal |
| `<ankh-icon>` | Icon using Material Symbols font, with size and fill variants |
| `<ankh-focus-ring>` | Focus indicator for keyboard navigation |
| `<ankh-ripple>` | Material-style ripple effect |

Expand All @@ -55,6 +56,26 @@ Components require a theme from `@ankh-studio/themes`:
<ankh-button variant="tonal">Tonal</ankh-button>
```

### Icon

```html
<ankh-icon name="home"></ankh-icon>
<ankh-icon name="favorite" filled></ankh-icon>
<ankh-icon name="settings" size="lg"></ankh-icon>
<ankh-icon name="delete" label="Delete item"></ankh-icon>
```

> Requires the [Material Symbols Outlined](https://fonts.google.com/icons) font to be loaded by the consumer.

#### Icon Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `name` | `string` | — | Material Symbols icon name (required) |
| `size` | `sm` \| `md` \| `lg` \| `xl` | `md` | Rendered size |
| `filled` | `boolean` | `false` | Use the filled variant |
| `label` | `string` | — | Accessible label; omit for decorative icons |

#### Button Props

| Prop | Type | Default | Description |
Expand All @@ -73,6 +94,16 @@ Components work in any framework. For better DX, use Stencil's output targets:
- **Angular**: `@stencil/angular-output-target`
- **Vue**: `@stencil/vue-output-target`

## Versioning

This package follows [SemVer](https://semver.org/). The public API surface includes component tag names, props, events, CSS custom properties, and slots.

- **Patch** – bug fixes that don't change public behaviour
- **Minor** – new components, props, events, or slots (backwards-compatible)
- **Major** – renamed/removed tags, props, events, or breaking behavioural changes

Every PR must declare its SemVer impact using the PR template checklist.

## Browser Support

Requires browsers supporting:
Expand All @@ -89,7 +120,16 @@ Requires browsers supporting:

## Changelog

### 0.1.0-alpha
### 0.1.2

- Add `ankh-icon` component with Material Symbols font rendering
- Add accessibility tests with `vitest-axe`

### 0.1.1

- Bug fixes and CI improvements

### 0.1.0

- Initial release with button, focus-ring, ripple components

Expand Down
89 changes: 89 additions & 0 deletions docs/adr/002-icon-rendering-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR-002: Icon Rendering Strategy

**Status:** Accepted
**Date:** 2026-02-08

## Context

We need an `ankh-icon` component to render icons consistently across the design system. The two viable strategies are:

1. **Variable icon font** (Material Symbols) — a single WOFF2 file with ligature-based glyph resolution and four OpenType variation axes: fill, weight, grade, and optical size.
2. **Inline SVG** — per-icon imports, a sprite sheet, or an icon registry, rendered as `<svg>` elements in the DOM.

The core tension is **developer ergonomics and design-axis control** (font) vs **payload precision and offline resilience** (SVG).

## Decision

**Use Material Symbols icon font (Outlined) as the rendering strategy.**

### Why font over SVG

**1. A single font file provides filled and outlined variants from one source.**
SVG requires two separate files per icon (or a complex path-swap mechanism). The font's `FILL` variation axis (`0` = outlined, `1` = filled) toggles between variants without switching glyph sources, doubling payload, or requiring a build-time icon variant resolver.

**2. Optical sizing preserves visual clarity across the size scale.**
The `opsz` variation axis (continuous 20–48) adjusts stroke weight and counter proportions to match the rendered size. A 48px icon uses thinner relative strokes than an 18px icon, preventing visual heaviness at large sizes and ensuring legibility at small sizes. SVGs scale geometrically — a single SVG path rendered at 18px and 48px has identical stroke-to-body proportions, which is optically incorrect at both extremes.

**3. Ligature resolution means zero JavaScript overhead for icon selection.**
The icon name (e.g. `home`, `settings`) is rendered as text content. The font's OpenType `liga` feature maps the string to the correct glyph at render time. No JS icon registry, no dynamic imports, no build-time tree-shaking configuration. This is particularly valuable because our component uses `shadow: false` (light DOM) — the text content is plainly readable in the DOM and debuggable without tooling.

**4. Color theming works via `currentColor` with no component-level plumbing.**
The font glyph inherits `color` from the cascade. No SVG `fill` attribute management, no passing color tokens through props, no theme-aware style injection. The icon automatically follows whatever color the parent sets.

**5. Developer experience is simpler — pass a string, get an icon.**
No imports per icon, no sprite setup, no icon registry to configure. The full Material Symbols set (~2,500+ icons) is available from the name prop alone after the font is loaded.

**Where SVG would win (and why we accept the trade-off):**
- **Payload precision**: SVG allows shipping only the icons actually used. The font file is ~3–5 MB WOFF2 regardless. For our design system, where consumers typically use dozens of icons across an application, the font's one-time cache cost is acceptable.
- **Offline resilience**: SVGs are inline and available without font loading. Acceptable trade-off — the font caches after first load and `font-display: block` prevents FOIT for subsequent visits.
- **Custom icons**: SVG supports non-Material icons. If custom icon support is needed later, we can extend the component to accept SVG children alongside font-rendered icons.

### How it works

| Concern | Implementation |
|---------|---------------|
| **Icon selection (`name`)** | Ligature resolution — icon name rendered as text, font `liga` feature converts to glyph. |
| **Filled vs outlined (`filled`)** | `font-variation-settings` `FILL` axis: `0` = outlined, `1` = filled. Composed via private CSS custom properties to avoid axis override conflicts. |
| **Sizing (`size`)** | Maps to `--icon-size-sm/md/lg/xl` tokens from `@ankh-studio/tokens`. Optical size axis tracks rendered size for correct visual weight. |
| **Color** | `color: currentColor`. Inherits from parent, no hardcoded palette references. |
| **Font loading** | Consumer responsibility. The component sets `font-family` but does not bundle font files. |

### Font axis composition

`font-variation-settings` is an all-or-nothing property — a later declaration replaces all axes, not just one. To let size and fill classes compose without overriding each other, we use private CSS custom properties per axis:

```css
.ankh-icon {
--_fill: 0;
--_opsz: 24;
font-variation-settings: 'FILL' var(--_fill), 'wght' 400, 'GRAD' 0, 'opsz' var(--_opsz);
}
.ankh-icon--filled { --_fill: 1; }
.ankh-icon--lg { --_opsz: 40; }
```

This pattern avoids the common pitfall where `.ankh-icon--filled` would accidentally reset `opsz` to its default.

## Consequences

- **Consumer must load the font.** The component does not self-load Material Symbols. A future `@ankh-studio/icons` package (or documentation) should provide the recommended import path.
- **Full icon set in one file.** No tree-shaking — acceptable for a design system where many icons are expected across an application.
- **No build-time name validation.** Typos in icon names render as blank text. The component does not ship a name registry; correctness is a consumer responsibility.
- **Single font style (Outlined) for v1.** If `Rounded` or `Sharp` styles are needed, a `style` prop can be added in a follow-up without breaking changes.

## Shadow DOM Decision

`ankh-icon` uses `shadow: false` (light DOM rendering). Three factors drive this choice:

1. **Token cascade access.** The component's CSS references `--icon-size-*` custom properties from `@ankh-studio/tokens`. In light DOM, these custom properties flow in through the normal cascade without requiring `::part()` forwarding or CSS variable re-declaration on a shadow host.

2. **Consumer styling flexibility.** With light DOM, the component's classes (`ankh-icon`, `ankh-icon--md`, etc.) are directly targetable with standard CSS selectors. Consumers can extend or override styles without shadow-piercing workarounds.

3. **Font inheritance.** The component sets `font-family: 'Material Symbols Outlined'` on the inner span. Because `@font-face` declarations are scoped to the document, a shadow root would not inherit the font unless the consumer duplicated the `@font-face` rule inside it or the component self-bundled the font. Light DOM avoids this entirely.

This is consistent with all other components in the system (`ankh-button`, `ankh-focus-ring`, `ankh-ripple`), which also use `shadow: false` for the same cascade and theming reasons.

## Open Questions

- [x] Should we provide a `@ankh-studio/icons` package that bundles/re-exports the Material Symbols font CSS? — **Resolved:** `@ankh-studio/icons` exists with `icons.css` and vendored WOFF2.
- [ ] Should `weight` and `grade` be exposed as props? (Deferred — keep API minimal for v1.)
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This directory contains Architecture Decision Records (ADRs) for @ankh-studio/co
| ADR | Title | Status |
|-----|-------|--------|
| [001](./001-design-intentions.md) | Design Intentions Layer System | Proposed |
| [002](./002-icon-rendering-strategy.md) | Icon Rendering Strategy | Accepted |

## Creating an ADR

Expand Down
24 changes: 12 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"lint": "npm run lint:ts && npm run lint:css",
"lint:ts": "eslint src/",
"lint:css": "stylelint 'src/**/*.css'",
"lint:fix": "eslint src/ --fix && stylelint 'src/**/*.css' --fix"
"lint:fix": "eslint src/ --fix && stylelint 'src/**/*.css' --fix",
"typecheck": "tsc --noEmit",
"check": "npm run lint && npm run typecheck && npm run test"
},
"devDependencies": {
"@stencil-community/postcss": "^2.2.0",
Expand All @@ -71,7 +73,7 @@
"stylelint": "^17.1.0",
"stylelint-config-standard": "^40.0.0",
"unplugin-stencil": "^0.4.1",
"vite-tsconfig-paths": "^6.1.0",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.18",
"vitest-axe": "^0.1.0"
},
Expand Down
Loading