Skip to content

Commit abaea25

Browse files
Merge pull request #31 from minimaldesign/theme-architecture
Theme architecture: swappable skins with their own cascade layer
2 parents 0a50722 + 5590b4a commit abaea25

58 files changed

Lines changed: 1282 additions & 255 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,14 @@ jobs:
4040
run: |
4141
set -e
4242
# layers survived the build (the preset-env polyfill must stay off)
43-
grep -q '@layer settings, base, elements, global, components, pages, helpers;' dist/mcss.css
43+
grep -q '@layer settings, base, elements, global, components, theme, helpers;' dist/mcss.css
4444
! grep -q ':not(#' dist/mcss.css
45+
# the bundle ships zero-opinion: no theme content in mcss.css
46+
# ('@layer theme' appears in a comment; check real block syntax)
47+
! grep -q '@layer theme {' dist/mcss.css
48+
# per-file themes self-layer so any import method lands them right
49+
grep -q '@layer theme {' dist/css/theme.wireframe.css
50+
grep -q '@layer theme {' dist/css/theme.default.css
4551
# no unresolved custom media or mixins shipped
4652
# (plain '@custom-media' appears in a comment; check real syntax)
4753
! grep -qE '@custom-media --|media \(--' dist/mcss.css

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ mCSS is a CSS framework (ITCSS-based) paired with an Astro documentation site, c
2525

2626
## CSS cascade layers are load-bearing
2727

28-
The framework uses native `@layer`; the layer name (declared in `src/styles/framework/mcss.css`) decides priority, and unlayered site CSS beats every layer. Import new framework files with `layer(<name>)`; never re-enable preset-env's `cascade-layers` polyfill in `postcss.config.cjs`. Always consult [agents/css.md](agents/css.md) before adding or moving CSS files.
28+
The framework uses native `@layer`; the layer name (declared in `src/styles/framework/mcss.css`) decides priority, and unlayered site CSS beats every layer. Import new framework files with `layer(<name>)`, except `theme.*.css` files: those wrap themselves in `@layer theme` and are activated from the consumer entry (`_global.css`), never imported by `mcss.css`. Never re-enable preset-env's `cascade-layers` polyfill in `postcss.config.cjs`. Always consult [agents/css.md](agents/css.md) before adding or moving CSS files.
2929

3030
## Detailed reference docs
3131

DESIGN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ components:
137137

138138
mCSS is a utility-conscious CSS framework and documentation site with an ITCSS cascade, compact component classes, and design tokens stored as CSS custom properties. The visual identity is clean, technical, and documentation-first: blue interaction color, cool neutral surfaces, dense spacing, rounded-but-not-pill shapes, and system typography that keeps pages fast and readable.
139139

140-
The canonical token sources are `src/styles/framework/settings.tokens.css` for raw scales and `src/styles/framework/settings.theme.default.css` for semantic theme and component defaults. This file translates those tokens into the DESIGN.md format for coding agents. When the CSS and this file disagree, the CSS source files win.
140+
The canonical token sources are `src/styles/framework/settings.tokens.css` for raw scales and `src/styles/framework/settings.ui.css` for semantic aliases and component defaults (interface tokens). This file translates those tokens into the DESIGN.md format for coding agents. When the CSS and this file disagree, the CSS source files win.
141141

142142
## Colors
143143

@@ -202,13 +202,13 @@ Buttons use `.button` or `.bt`, with variants such as `.bt-primary`, `.bt-outlin
202202

203203
Cards use `.card` with `.card-filled` and `.card-raised` variants. Keep internal spacing on `--card-spacing` and use `.card_header`, `.card_media`, `.card_content`, and `.card_actions` for composition.
204204

205-
Tags, notices, avatars, headers, footers, hero controls, and the read-progress bar all have semantic custom properties in `settings.theme.default.css`. When creating a new component, add semantic tokens there before using raw scale values inside component CSS.
205+
Tags, notices, avatars, headers, footers, hero controls, and the read-progress bar all have semantic custom properties in `settings.ui.css`. When creating a new component, add interface tokens there before using raw scale values inside component CSS.
206206

207207
Class naming is BEM-like with mCSS separators: `.block`, `.block_element`, `.block-modifier`, and `.is-*` state classes composed inside the block.
208208

209209
## Do's and Don'ts
210210

211-
Do preserve the native cascade-layer setup: framework files import into named layers (`settings, base, elements, global, components, pages, helpers`) via `src/styles/framework/mcss.css`; site-only files import unlayered via `src/styles/_global.css`.
211+
Do preserve the native cascade-layer setup: framework files import into named layers (`settings, base, elements, global, components, theme, helpers`) via `src/styles/framework/mcss.css`; site-only files import unlayered via `src/styles/_global.css`.
212212

213213
Do add new CSS files with the correct layer prefix, such as `component.name.css` or `help.name.css`, then import them with `layer(<name>)` in `framework/mcss.css` (framework) or plainly in `_global.css` (site).
214214

agents/components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
4. **Rest spread**: `{...rest}` on the root element so consumers can set any native attribute.
99
5. **`data-testid`**: accepted as a prop with a sensible per-component default.
1010
6. **States vs modifiers**: runtime states are `.is-*` classes (`.is-active`, `.is-online`); build-time variants are block modifiers (`.card-filled`). Prefer styling ARIA attributes when one exists (`[aria-current]`, `[aria-disabled]`).
11-
7. **Theme tokens**: every themable knob has a declared default in `settings.theme.default.css`; local-only custom properties use descriptive names (`--avatar-size`, not `--w`).
11+
7. **Interface tokens**: every themable knob has a declared default in `settings.ui.css`; local-only custom properties use descriptive names (`--avatar-size`, not `--w`).
1212
8. **Slots**: default slot for main content; named slots documented on the component's docs page.
1313
9. **A11y**: keyboard operable, labelled, JS motion gated on `prefers-reduced-motion`, no live-region roles on static content.
1414

agents/css.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,29 @@ mCSS uses **native CSS cascade layers** (`@layer`) on top of an ITCSS-inspired f
99
## Cascade rules (load-bearing)
1010

1111
1. Framework layers, in priority order (later wins):
12-
`settings, base, elements, global, components, pages, helpers`
12+
`settings, base, elements, global, components, theme, helpers`
13+
The order runs **from default to deliberate**: `settings` through `components` are the framework's defaults, `theme` is a deliberate override of those defaults, helpers are the last word. (Not "generic to specific": a theme is broad in reach but late in intent, and intent decides cascade order.)
1314
2. **Unlayered CSS beats every layer for normal declarations.** That's the consumer guarantee ("your CSS wins") and why site CSS is unlayered.
14-
3. **Helpers are the exception: every helper declaration carries `!important`.** Important layered declarations beat unlayered CSS (the cascade inverts for important), so helpers win over site and consumer CSS too. They're element-level overrides, like inline styling. For important declarations layer order also inverts (earlier layer wins), which is why the reduced-motion block in `base.reset.css` still beats helpers.
15-
4. `!important` is REQUIRED in every helper declaration and banned everywhere else in the framework, except the reduced-motion block in `base.reset.css` and third-party override files (see `site/external.astro.css`). `help.colors.css` and `help.spacing.css` get it from their generators.
16-
5. `postcss.config.cjs` disables preset-env's `cascade-layers` polyfill. **Never remove that option** — the polyfill strips every `@layer` rule and silently replaces the cascade with specificity hacks. The browser floor is set in `.browserslistrc` (`defaults and supports css-cascade-layers`).
15+
3. **The `theme` layer holds at most one active theme** (`theme.*.css`): token overrides plus optional skin rules. Theme files are self-layered (each wraps its own content in `@layer theme`) and are **never imported by `mcss.css`**; the consumer entry activates one (see `_global.css`). No theme = the default look. Unlayered consumer CSS still beats themes.
16+
4. **Helpers are the exception: every helper declaration carries `!important`.** Important layered declarations beat unlayered CSS (the cascade inverts for important), so helpers win over site and consumer CSS too. They're element-level overrides, like inline styling. For important declarations layer order also inverts (earlier layer wins), which is why the reduced-motion block in `base.reset.css` still beats helpers.
17+
5. `!important` is REQUIRED in every helper declaration and banned everywhere else in the framework, except the reduced-motion block in `base.reset.css` and third-party override files (see `site/external.astro.css`). `help.colors.css` and `help.spacing.css` get it from their generators.
18+
6. `postcss.config.cjs` disables preset-env's `cascade-layers` polyfill. **Never remove that option** — the polyfill strips every `@layer` rule and silently replaces the cascade with specificity hacks. The browser floor is set in `.browserslistrc` (`defaults and supports css-cascade-layers`).
1719

1820
## Layer table
1921

2022
| Layer | Prefix | Purpose |
2123
| ---------- | ------------- | ----------------------------------------------------------- |
22-
| settings | `settings.*` | Tokens, themes (media queries + mixins import unlayered: build-time only) |
24+
| settings | `settings.*` | Primitive tokens (`settings.tokens.css`) and interface tokens (`settings.ui.css`); media queries + mixins import unlayered: build-time only |
2325
| base | `base.*` | Reset only (`base.reset.css`) |
2426
| elements | `elements.*` | Bare HTML element styles (text, form, media) |
2527
| global | `global.*` | Structural patterns (a11y, grid, layout, prose, wrap) |
2628
| components | `component.*` | Library components (card, hero, notice, …), including CSS-only single-class ones (badge, button, toggle) |
27-
| pages | `page.*` | Page-specific styles that helpers may override |
29+
| theme | `theme.*` | Swappable skins: token overrides + skin rules, self-layered via `@layer theme`, activated by the consumer entry (never by `mcss.css`) |
2830
| helpers | `help.*` | Utility overrides (colors, spacing, typography), all `!important`: beats everything, including unlayered CSS |
2931

30-
Site-only files keep the same prefixes but live in `src/styles/site/` and import unlayered (`component.header.css`, `page.docs.css`, `external.astro.css`, `devtools.css`, …).
32+
`page.*` is a file-naming convention only (page-specific styles, e.g. site `page.docs.css`); those files are plain unlayered consumer CSS, there is no `pages` layer.
33+
34+
Site-only files keep the same prefixes but live in `src/styles/site/` and import unlayered (`component.header.css`, `page.docs.css`, `external.astro.css`, …). Debug helpers (`help.devtools.css`: baseline overlay, `.dev-error` toast) are framework helpers, not site CSS: framework components reference them.
3135

3236
## Class Naming (BEM-like, different separators)
3337

@@ -51,13 +55,14 @@ A selector must only contain classes from its own block. Never write a descendan
5155
To style a component from outside:
5256

5357
- **Its root**: mix your own class onto the element in markup (`<Section class="home_section …">`) and style that class.
54-
- **Its internals**: use the component's `<part>Class` props (`headerClass`, `titleClass`, …) to mix a class onto the part, or set the component's theme tokens on your own hook class. If neither exists yet, add the prop or token to the component. Do not reach in with a selector.
58+
- **Its internals**: use the component's `<part>Class` props (`headerClass`, `titleClass`, …) to mix a class onto the part, or set the component's interface tokens on your own hook class. If neither exists yet, add the prop or token to the component. Do not reach in with a selector.
5559
- **Bare HTML tags** (`.home_themer_formRow > button`), `.is-*` states, and ARIA attribute selectors are fine inside your own block.
5660
- **Context blocks are not components**: a component may reference the environment it sits in (`@scope (.prose) to (.not-prose)` in `component.notice.css`, `:root.theme-dark`), but the context's own file must never name specific components (that's why `global.prose.css` lists only bare tags).
61+
- **Theme files are the one sanctioned exception**: a `theme.*.css` file may select component classes from outside (`.card::after`, `.bt`) because a theme is by definition a skin over the whole system, versioned with the framework. Even there, prefer token overrides; reach for selectors only for what tokens can't express (pseudo-elements, `nth-child` rhythm, `text-decoration`).
5762

5863
### Token naming grammar
5964

60-
Theme tokens are the public API of every component; they follow `--component-part-property`, longhand, kebab-case:
65+
Interface tokens (`settings.ui.css`) are the public API of every component; they follow `--component-part-property`, longhand, kebab-case:
6166

6267
- `--bt-background-color-hover`, `--notice-border-width`, `--avatar-status-dot-color-online`
6368
- No abbreviations (`--card-bg-color` is legacy; new tokens spell out `background-color`), no camelCase, no underscores.
@@ -71,6 +76,7 @@ Never `transition: all` — it also transitions layout properties, so any late-a
7176

7277
- **One block per file.** Every block gets its own file named after it (`.featureItem` lives in `component.featureItem.css`, never inside `component.featureGrid.css`), even for small companion blocks (`component.fieldRow.css`, site `component.webring.css`).
7378
- Framework file: create `src/styles/framework/<prefix>.<name>.css` and add `@import url(./<file>) layer(<layer>);` in the matching block of `framework/mcss.css`.
79+
- Theme file: create `src/styles/framework/theme.<name>.css` wrapping its own content in `@layer theme { … }`; do NOT import it in `mcss.css` (the consumer entry activates it; the dist index lists it commented out).
7480
- Site file: create `src/styles/site/<prefix>.<name>.css` and add a plain `@import` in `_global.css` (unlayered).
7581
- Framework CSS must never reference site-only selectors (e.g. `.expressive-code`); the site file mirrors any shared pattern itself.
7682

agents/pitfalls.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ URLs for blog posts, docs, and component pages are derived from their file paths
1414

1515
## Theme Toggle
1616

17-
The light/dark toggle persists in **`localStorage`** (key `theme`). The inline script in `BaseLayout.astro` applies the class before paint to avoid FOUC; the toggle behavior itself lives in `ThemeToggle.astro` (self-contained; rendered twice, its module script runs once). Don't introduce a second theme mechanism.
17+
The light/dark toggle persists in **`localStorage`** (key `theme`). The inline script in `BaseLayout.astro` applies the class before paint to avoid FOUC; the toggle behavior itself lives in `ThemeToggle.astro` (self-contained; rendered twice, its module script runs once). Don't introduce a second color-scheme mechanism. (Theme files, `theme.*.css`, are full skins and unrelated to the light/dark toggle.)

dist/css/component.prevNext.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ xxl: 1920px
4242
text-decoration: none;
4343
color: var(--text-color);
4444
border: var(--border-sm) solid var(--prevNext-border-color);
45-
border-radius: var(--radius-lg);
45+
border-radius: var(--prevNext-border-radius);
4646
transition: border-color var(--transition);
4747
}
4848

dist/css/component.toggle.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ xxl: 1920px
2626

2727
@layer components {
2828
.toggle {
29-
/* Themable via the --toggle-* tokens in settings.theme.default.css */
29+
/* Themable via the --toggle-* tokens in settings.ui.css */
3030
--toggle-w: var(--toggle-width);
3131
--toggle-h: var(--toggle-height);
3232
--toggle-bd: var(--toggle-border-width);

dist/css/elements.text.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ pre {
326326
font-family: var(--mono);
327327
font-size: 0.9rem;
328328
white-space: pre-wrap; /* override overflow default */
329+
border-radius: var(--pre-border-radius);
329330
}
330331

331332
q {

dist/css/help.devtools.css

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*! mCSS v0.9.0 | MIT | https://mcss.dev */
2+
/*
3+
SETTINGS MEDIA QUERIES
4+
5+
https://github.com/argyleink/open-props/blob/main/src/props.media.css
6+
https://open-props.style/#media-queries
7+
8+
Can't use var() inside media queries 🤦‍♂️
9+
https://stackoverflow.com/questions/40722882/css-native-variables-not-working-in-media-queries/47212942#47212942
10+
so breakpoints are not in settings.tokens.css
11+
12+
xxs: 240px
13+
xs: 360px
14+
sm: 480px
15+
md: 768px
16+
lg: 1024px
17+
xl: 1440px
18+
xxl: 1920px
19+
*/
20+
21+
/* https://github.com/postcss/postcss-mixins */
22+
23+
/* Example */
24+
25+
/* truncate single line of text and adds … */
26+
27+
@layer helpers {
28+
/* DEV TOOLS */
29+
30+
/* Debug-only helpers, inert unless you add the classes yourself.
31+
!important is intentional and required in the helpers layer: a debug
32+
overlay must win over every other style, including unlayered CSS. */
33+
34+
/* Baseline grid overlay: add .dev to <body> while checking vertical
35+
rhythm (line spacing comes from --baseline in settings.tokens.css). */
36+
body.dev {
37+
background-size: 100% calc(var(--baseline) * 1px) !important;
38+
background-image: linear-gradient(
39+
to bottom,
40+
rgba(0, 0, 0, 0) calc((var(--baseline) - 1) * 1px),
41+
hotpink calc(var(--baseline) * 1px)
42+
) !important;
43+
}
44+
45+
/* Fixed error toast, used by components to surface dev-time mistakes
46+
(see Hero.astro's slide count check). */
47+
.dev-error {
48+
position: fixed !important;
49+
top: var(--md1) !important;
50+
left: var(--md1) !important;
51+
z-index: var(--z-top) !important;
52+
padding: var(--sm1) !important;
53+
font-size: 1rem !important;
54+
color: var(--danger-500) !important;
55+
background: var(--danger-100) !important;
56+
border: 1px solid var(--danger-500) !important;
57+
border-radius: var(--radius-md) !important;
58+
}
59+
60+
}

0 commit comments

Comments
 (0)