css-is-awesome 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/AGENTS.md +343 -0
  2. package/CHANGELOG.md +1036 -0
  3. package/CLAUDE.md +9 -0
  4. package/CONTRACT.md +580 -0
  5. package/GEMINI.md +9 -0
  6. package/LICENSE +21 -0
  7. package/LICENSE-third-party +88 -0
  8. package/MIGRATION.md +312 -0
  9. package/README.md +380 -0
  10. package/ROADMAP.md +714 -0
  11. package/THEMING.md +290 -0
  12. package/THREE-TIERS.md +158 -0
  13. package/VERSIONING.md +200 -0
  14. package/_index.scss +34 -0
  15. package/api.scss +16 -0
  16. package/bin/README.md +57 -0
  17. package/bin/cia.cjs +114 -0
  18. package/bin/migrate-bootstrap.cjs +427 -0
  19. package/bin/migrate-tailwind.cjs +832 -0
  20. package/css-is-awesome.instructions.md +533 -0
  21. package/dist/css-is-awesome.core.css +598 -0
  22. package/dist/css-is-awesome.core.min.css +1 -0
  23. package/dist/css-is-awesome.css +3733 -0
  24. package/dist/css-is-awesome.min.css +1 -0
  25. package/dist/css-is-awesome.utilities.css +2445 -0
  26. package/dist/css-is-awesome.utilities.min.css +1 -0
  27. package/dist/tokens.css +508 -0
  28. package/dist/tokens.d.ts +266 -0
  29. package/dist/tokens.min.css +1 -0
  30. package/figma-tokens/README.md +57 -0
  31. package/figma-tokens/primitives-brand.scss +121 -0
  32. package/figma-tokens/semantic-dark.scss +61 -0
  33. package/figma-tokens/semantic-light.scss +61 -0
  34. package/figma-tokens/tokens.json +144 -0
  35. package/llm.txt +183 -0
  36. package/mcp/server.cjs +1495 -0
  37. package/package.json +199 -0
  38. package/public/icons/README.md +278 -0
  39. package/public/icons/arrow-right.svg +4 -0
  40. package/public/icons/check.svg +4 -0
  41. package/public/icons/chevron-down.svg +4 -0
  42. package/public/icons/close.svg +4 -0
  43. package/public/icons/core/arrow-down.svg +13 -0
  44. package/public/icons/core/arrow-left.svg +13 -0
  45. package/public/icons/core/arrow-right.svg +13 -0
  46. package/public/icons/core/arrow-up.svg +13 -0
  47. package/public/icons/core/bell.svg +13 -0
  48. package/public/icons/core/calendar.svg +15 -0
  49. package/public/icons/core/check.svg +12 -0
  50. package/public/icons/core/chevron-down.svg +12 -0
  51. package/public/icons/core/chevron-left.svg +12 -0
  52. package/public/icons/core/chevron-right.svg +12 -0
  53. package/public/icons/core/chevron-up.svg +12 -0
  54. package/public/icons/core/clock.svg +13 -0
  55. package/public/icons/core/close.svg +13 -0
  56. package/public/icons/core/copy.svg +13 -0
  57. package/public/icons/core/download.svg +14 -0
  58. package/public/icons/core/edit.svg +13 -0
  59. package/public/icons/core/error.svg +14 -0
  60. package/public/icons/core/external-link.svg +14 -0
  61. package/public/icons/core/eye-off.svg +15 -0
  62. package/public/icons/core/eye.svg +13 -0
  63. package/public/icons/core/filter.svg +12 -0
  64. package/public/icons/core/heart.svg +12 -0
  65. package/public/icons/core/help.svg +14 -0
  66. package/public/icons/core/home.svg +13 -0
  67. package/public/icons/core/info.svg +14 -0
  68. package/public/icons/core/loading.svg +12 -0
  69. package/public/icons/core/lock.svg +13 -0
  70. package/public/icons/core/mail.svg +13 -0
  71. package/public/icons/core/menu.svg +14 -0
  72. package/public/icons/core/message.svg +12 -0
  73. package/public/icons/core/minus.svg +12 -0
  74. package/public/icons/core/more-horizontal.svg +14 -0
  75. package/public/icons/core/pause.svg +13 -0
  76. package/public/icons/core/play.svg +12 -0
  77. package/public/icons/core/plus.svg +13 -0
  78. package/public/icons/core/refresh.svg +15 -0
  79. package/public/icons/core/save.svg +14 -0
  80. package/public/icons/core/search.svg +13 -0
  81. package/public/icons/core/settings.svg +13 -0
  82. package/public/icons/core/share.svg +14 -0
  83. package/public/icons/core/sort.svg +15 -0
  84. package/public/icons/core/star.svg +12 -0
  85. package/public/icons/core/success.svg +13 -0
  86. package/public/icons/core/trash.svg +16 -0
  87. package/public/icons/core/unlock.svg +13 -0
  88. package/public/icons/core/upload.svg +14 -0
  89. package/public/icons/core/user.svg +13 -0
  90. package/public/icons/core/users.svg +15 -0
  91. package/public/icons/core/warning.svg +14 -0
  92. package/public/icons/download.svg +4 -0
  93. package/public/icons/edit.svg +4 -0
  94. package/public/icons/menu.svg +4 -0
  95. package/public/icons/search.svg +4 -0
  96. package/public/theme.css +4179 -0
  97. package/public/themes/README.md +102 -0
  98. package/public/themes/boilerplate/theme.css +154 -0
  99. package/public/themes/boilerplate-dark/theme.css +171 -0
  100. package/public/themes/boilerplate-light/theme.css +171 -0
  101. package/public/themes/cupertino/theme.css +165 -0
  102. package/public/themes/cupertino-dark/theme.css +200 -0
  103. package/public/themes/cupertino-light/theme.css +227 -0
  104. package/public/themes/glass/theme.css +190 -0
  105. package/public/themes/glass-dark/theme.css +201 -0
  106. package/public/themes/glass-light/theme.css +229 -0
  107. package/public/themes/graphite/theme.css +155 -0
  108. package/public/themes/graphite-dark/theme.css +223 -0
  109. package/public/themes/graphite-light/theme.css +191 -0
  110. package/public/themes/press/theme.css +189 -0
  111. package/public/themes/press-dark/theme.css +199 -0
  112. package/public/themes/press-light/theme.css +235 -0
  113. package/public/themes/prism/theme.css +159 -0
  114. package/public/themes/prism-dark/theme.css +185 -0
  115. package/public/themes/prism-light/theme.css +188 -0
  116. package/public/themes/sketchbook/theme.css +152 -0
  117. package/public/themes/sketchbook-dark/theme.css +194 -0
  118. package/public/themes/sketchbook-light/theme.css +188 -0
  119. package/public/themes/terminal/theme.css +159 -0
  120. package/public/themes/terminal-dark/theme.css +229 -0
  121. package/public/themes/terminal-light/theme.css +152 -0
  122. package/scripts/README.md +70 -0
  123. package/scripts/icon-contract.json +67 -0
  124. package/scripts/icon-validator.js +205 -0
  125. package/scripts/prepare-dist.mjs +65 -0
  126. package/scripts/theme-a11y.js +415 -0
  127. package/scripts/theme-contract.json +171 -0
  128. package/scripts/theme-validator.js +674 -0
  129. package/scss/README.md +55 -0
  130. package/scss/_animations-utilities.scss +107 -0
  131. package/scss/_animations.scss +248 -0
  132. package/scss/_app-styles.scss +15 -0
  133. package/scss/_generator.scss +174 -0
  134. package/scss/_icons.scss +475 -0
  135. package/scss/_index.scss +17 -0
  136. package/scss/_layout.scss +597 -0
  137. package/scss/_mixins.scss +1036 -0
  138. package/scss/_system.scss +291 -0
  139. package/scss/_utilities.scss +377 -0
  140. package/scss/api.scss +61 -0
  141. package/scss/components/_accordion.scss +72 -0
  142. package/scss/components/_buttons.scss +171 -0
  143. package/scss/components/_copy-button.scss +69 -0
  144. package/scss/components/_data.scss +190 -0
  145. package/scss/components/_feedback.scss +197 -0
  146. package/scss/components/_forms.scss +272 -0
  147. package/scss/components/_index.scss +17 -0
  148. package/scss/components/_navigation.scss +186 -0
  149. package/scss/components/_overlay.scss +259 -0
  150. package/scss/components/_stepper.scss +159 -0
  151. package/scss/components/_tabs.scss +72 -0
  152. package/scss/core.scss +54 -0
  153. package/scss/examples/_theming.scss +107 -0
  154. package/scss/examples/_usage.scss +317 -0
  155. package/scss/main.scss +64 -0
  156. package/scss/recipes/README.md +110 -0
  157. package/scss/recipes/_bare-tags.scss +232 -0
  158. package/scss/recipes/_recipe-template.md +119 -0
  159. package/scss/recipes/combobox.md +509 -0
  160. package/scss/recipes/dialog.md +265 -0
  161. package/scss/recipes/print-to-pdf.md +263 -0
  162. package/scss/theme/README.md +67 -0
  163. package/scss/theme/_brand.scss +18 -0
  164. package/scss/theme/_colors-dark.scss +68 -0
  165. package/scss/theme/_colors-light.scss +70 -0
  166. package/scss/theme/_components.scss +94 -0
  167. package/scss/theme/_icons.scss +104 -0
  168. package/scss/theme/_index.scss +62 -0
  169. package/scss/theme/_shadows.scss +65 -0
  170. package/scss/theme/_shape.scss +13 -0
  171. package/scss/theme/_typography.scss +9 -0
  172. package/scss/theme/registry.json +447 -0
  173. package/scss/themes/boilerplate-dark.scss +212 -0
  174. package/scss/themes/boilerplate-light.scss +209 -0
  175. package/scss/themes/boilerplate.scss +181 -0
  176. package/scss/themes/cupertino-dark.scss +238 -0
  177. package/scss/themes/cupertino-light.scss +266 -0
  178. package/scss/themes/cupertino.scss +194 -0
  179. package/scss/themes/glass-dark.scss +238 -0
  180. package/scss/themes/glass-light.scss +266 -0
  181. package/scss/themes/glass.scss +220 -0
  182. package/scss/themes/graphite-dark.scss +260 -0
  183. package/scss/themes/graphite-light.scss +228 -0
  184. package/scss/themes/graphite.scss +182 -0
  185. package/scss/themes/press-dark.scss +237 -0
  186. package/scss/themes/press-light.scss +273 -0
  187. package/scss/themes/press.scss +241 -0
  188. package/scss/themes/prism-dark.scss +217 -0
  189. package/scss/themes/prism-light.scss +220 -0
  190. package/scss/themes/prism.scss +190 -0
  191. package/scss/themes/sketchbook-dark.scss +231 -0
  192. package/scss/themes/sketchbook-light.scss +231 -0
  193. package/scss/themes/sketchbook.scss +178 -0
  194. package/scss/themes/terminal-dark.scss +267 -0
  195. package/scss/themes/terminal-light.scss +175 -0
  196. package/scss/themes/terminal.scss +185 -0
  197. package/scss/tokens.scss +23 -0
  198. package/scss/utilities-only.scss +4 -0
@@ -0,0 +1,1036 @@
1
+ // ============================================================================
2
+ // MIXINS - Core Utilities
3
+ // ============================================================================
4
+ // Icons are in _icons.scss (separate file)
5
+ // Every mixin has parameters. Override anything.
6
+ // ============================================================================
7
+ @use 'sass:map';
8
+ @use 'sass:list';
9
+ @use 'sass:math';
10
+ @use 'sass:meta';
11
+ @use 'sass:string';
12
+ @use './theme' as t;
13
+ @use './system' as s;
14
+
15
+ // ============================================================================
16
+ // HELPER: Component token runtime override (Feature 1.3)
17
+ // ----------------------------------------------------------------------------
18
+ // `comp(key, fallback)` returns `var(--<key>, <fallback>)`. The fallback is a
19
+ // fully-resolved CSS value — typically the result of another mixin helper
20
+ // like `radius(md)` or `space(4)`, or a literal like `1px`. Themes can then
21
+ // override `--<key>` on their :root block without rebuilding the library.
22
+ //
23
+ // Example:
24
+ // border-radius: comp(btn-radius, radius(md));
25
+ // // → border-radius: var(--btn-radius, var(--radius-md, 0.25rem));
26
+ //
27
+ // The legacy private `_comp()` helper is retained so existing callers that
28
+ // looked up a map key (to forward into space()/radius()/etc.) still work.
29
+ // New call sites should prefer `comp()` at the CSS-property level.
30
+ // ============================================================================
31
+ @function _comp($key, $fallback) {
32
+ $val: map.get(t.$theme-components, $key);
33
+ @if $val {
34
+ @return $val;
35
+ }
36
+ @return $fallback;
37
+ }
38
+
39
+ @function comp($key, $fallback) {
40
+ @return var(--#{$key}, #{$fallback});
41
+ }
42
+
43
+ // ============================================================================
44
+ // COLOR FUNCTIONS
45
+ // ============================================================================
46
+
47
+ @function color($name) {
48
+ $fallback: map.get(t.$theme-light, $name);
49
+ @if not $fallback {
50
+ $fallback: map.get(t.$theme-brand, $name);
51
+ }
52
+ @if $fallback {
53
+ @return var(--#{$name}, #{$fallback});
54
+ }
55
+ @return var(--#{$name}, currentColor);
56
+ }
57
+
58
+ @function brand($name) {
59
+ $fallback: map.get(t.$theme-brand, $name);
60
+ @return var(--brand-#{$name}, #{$fallback or currentColor});
61
+ }
62
+
63
+ @function color-static($name) {
64
+ $value: map.get(t.$theme-light, $name);
65
+ @if not $value {
66
+ $value: map.get(t.$theme-brand, $name);
67
+ }
68
+ @return $value or currentColor;
69
+ }
70
+
71
+ // ============================================================================
72
+ // SPACING FUNCTIONS (2026 System)
73
+ // ============================================================================
74
+ // UPDATED: 2026-04 - Numbered scale (1..9) is source of truth; t-shirt keys
75
+ // (xs/sm/md/lg/xl/2xl/3xl/4xl) layer on top as aliases.
76
+ // All values in rem for accessibility.
77
+ //
78
+ // Usage:
79
+ // padding: space(4); // Returns var(--space-4, 1rem)
80
+ // padding: space(md); // Returns var(--space-md, 1rem) (alias)
81
+ // Both styles continue to work, but they are no longer independent: themes
82
+ // declare only the NUMBERED set, and the library emits each t-shirt name as a
83
+ // reference to it (--space-md: var(--space-4)). Previously both sets were
84
+ // emitted as separate literals holding equal values, so a theme could set
85
+ // --space-md while every component read var(--space-4) and nothing moved.
86
+ // Theme the numbered step; the alias follows.
87
+ // ============================================================================
88
+
89
+ @function space($key) {
90
+ $fallback: map.get(t.$space, $key);
91
+ // Not a scale key — a raw length like `12px` or `2rem`. Pass it through
92
+ // instead of inventing `var(--space-12px, 1rem)`, which silently resolved to
93
+ // the fallback and threw the caller's value away. This is what lets the
94
+ // layout mixins accept either a scale step or a literal.
95
+ @if not $fallback {
96
+ @return $key;
97
+ }
98
+ @return var(--space-#{$key}, #{$fallback});
99
+ }
100
+
101
+ @function space-raw($key) {
102
+ @return map.get(t.$space, $key) or 1rem;
103
+ }
104
+
105
+ // Semantic spacing aliases for better readability
106
+ @function gap($type: normal) {
107
+ $fallback: map.get(t.$gap, $type);
108
+ @return var(--gap-#{$type}, #{$fallback or 1rem});
109
+ }
110
+
111
+ @function padding($type: normal) {
112
+ $fallback: map.get(t.$padding, $type);
113
+ @return var(--padding-#{$type}, #{$fallback or 1rem});
114
+ }
115
+
116
+ // ============================================================================
117
+ // GEOMETRIC GRID — explicit sizes on cia's 4px coordinate system
118
+ // ============================================================================
119
+ // Pure functions for sizes that aren't a design choice — they're geometric
120
+ // truth shared across Figma, the design system, and the codebase. NOT
121
+ // themeable; consumers don't tune the 4px grid. Distinct from space() which
122
+ // IS themeable (consumer can re-tune the spacing scale per theme).
123
+ //
124
+ // Use grid() for icon widths, control heights, fixed dimensions that must
125
+ // align with the design grid. Use px() for the rare off-grid pixel value
126
+ // that doesn't fit any system (a 17px hero margin, a 33px badge offset).
127
+ // Both return rem (for a11y zoom + browser-default-font-size respect).
128
+ // Both inline at call site — no custom properties emitted.
129
+ //
130
+ // Decision tree:
131
+ // color/type/radius/shadow/motion → m.color() / m.font-size() / m.radius() / m.shadow() / m.duration() (themed tokens)
132
+ // margin / padding / gap → m.space(n) (themeable spacing scale)
133
+ // explicit geometric size on grid → m.grid(n) (this section — 4px grid, not themed)
134
+ // off-grid pixel value (rare) → m.px(value) (this section — raw rem conversion)
135
+ // component → m.btn() / m.modal() / etc. + parameter-power overrides
136
+ //
137
+ // MCP / Figma → Code: AI agents resolve a design's px value via
138
+ // `step = math.round(px / 4)` and emit `m.grid(step)`. See the
139
+ // `resolve_size` MCP tool for the canonical mapping.
140
+ // ============================================================================
141
+
142
+ // Geometric value on the 4px grid. n is the step count (n × 0.25rem).
143
+ // m.grid(4) → 1rem (16px)
144
+ // m.grid(6) → 1.5rem (24px)
145
+ // m.grid(10) → 2.5rem (40px)
146
+ // m.grid(20) → 5rem (80px)
147
+ @function grid($n, $base: 0.25rem) {
148
+ @return $n * $base;
149
+ }
150
+
151
+ // Raw pixel value converted to rem (off-grid, intentional). For values
152
+ // that genuinely don't fit any cia scale. Prefer m.grid() / m.space() /
153
+ // the typed token functions when they fit — m.px() is the escape hatch,
154
+ // not the default. Returns rem so user zoom + browser font-size scaling
155
+ // still work.
156
+ // m.px(17) → 1.0625rem
157
+ // m.px(33) → 2.0625rem
158
+ @function px($value) {
159
+ @return math.div($value, 16) * 1rem;
160
+ }
161
+
162
+ // Snap a px value to the nearest cia grid step. For tooling
163
+ // (Figma → code, MCP, codegen) — pure math, no rendering output. AI
164
+ // agents call this to map a design's px value to the cia grid step,
165
+ // then emit m.grid(step) in generated code.
166
+ // m.grid-from-px(24) → 6
167
+ // m.grid-from-px(17) → 4 (rounds to nearest grid step)
168
+ @function grid-from-px($px, $base-px: 4) {
169
+ @return math.round(math.div($px, $base-px));
170
+ }
171
+
172
+ // ============================================================================
173
+ // TYPOGRAPHY
174
+ // ============================================================================
175
+
176
+ @function font-family($type: primary) {
177
+ $stack: map.get(t.$theme-fonts, $type);
178
+ @if $stack {
179
+ $fallback: list.join($stack, (), comma);
180
+ @return var(--font-#{$type}, #{$fallback});
181
+ }
182
+ @return var(--font-#{$type}, sans-serif);
183
+ }
184
+
185
+ /// Letter-spacing from the tracking scale: tighter | tight | normal | wide |
186
+ /// wider | widest. Emits a custom property with the scale value as fallback,
187
+ /// so a theme can retune tracking without a rebuild — the same contract as
188
+ /// `space()` and `font-size()`. A raw value (`0.03em`) passes straight through.
189
+ @function letter-spacing($key) {
190
+ $fallback: map.get(s.$letter-spacings, $key);
191
+ @if not $fallback {
192
+ @return $key;
193
+ }
194
+ @return var(--letter-spacing-#{$key}, #{$fallback});
195
+ }
196
+
197
+ @function font-size($size) {
198
+ $fallback: map.get(s.$font-sizes, $size) or 1rem;
199
+ @return var(--font-size-#{$size}, #{$fallback});
200
+ }
201
+
202
+ @function font-size-raw($size) {
203
+ @return map.get(s.$font-sizes, $size) or 1rem;
204
+ }
205
+
206
+ @function line-height($size) {
207
+ $fallback: map.get(s.$line-heights, $size) or 1.5;
208
+ @return var(--line-height-#{$size}, #{$fallback});
209
+ }
210
+
211
+ @function line-height-raw($size) {
212
+ @return map.get(s.$line-heights, $size) or 1.5;
213
+ }
214
+
215
+ @function font-weight($weight) {
216
+ $fallback: map.get(s.$font-weights, $weight) or 400;
217
+ @return var(--font-weight-#{$weight}, #{$fallback});
218
+ }
219
+
220
+ @function font-weight-raw($weight) {
221
+ @return map.get(s.$font-weights, $weight) or 400;
222
+ }
223
+
224
+ // Font type map
225
+ $_font-types: (
226
+ reg: (weight: normal, style: normal),
227
+ regular: (weight: normal, style: normal),
228
+ italic: (weight: normal, style: italic),
229
+ light: (weight: light, style: normal),
230
+ light-it: (weight: light, style: italic),
231
+ medium: (weight: medium, style: normal),
232
+ medium-it: (weight: medium, style: italic),
233
+ semibold: (weight: semibold, style: normal),
234
+ semibold-it: (weight: semibold, style: italic),
235
+ bold: (weight: bold, style: normal),
236
+ bold-it: (weight: bold, style: italic),
237
+ black: (weight: black, style: normal),
238
+ black-it: (weight: black, style: italic),
239
+ );
240
+
241
+ // Font mixin - the complete text-style block in one call.
242
+ // $family handling:
243
+ // - Bare slug (e.g. `meme`, `pacifico`) → emits font-family: var(--font-<slug>).
244
+ // Consumer declares the variable in their global stylesheet (globals.css).
245
+ // - Sass list / pre-formed string with commas or var() → passes through.
246
+ // $color handling: pass a contract color name (e.g. `text-primary`, `ink`) and
247
+ // the mixin emits `color: m.color($color)` at the end of the block.
248
+ @mixin font(
249
+ $type: reg,
250
+ $size: null,
251
+ $lh: null,
252
+ $ls: null,
253
+ $family: null,
254
+ $color: null
255
+ ) {
256
+ $cfg: map.get($_font-types, $type) or map.get($_font-types, reg);
257
+ font-weight: font-weight(map.get($cfg, weight));
258
+ font-style: map.get($cfg, style);
259
+
260
+ @if $family {
261
+ $is-string: meta.type-of($family) == 'string';
262
+ $is-bare-name: $is-string
263
+ and not string.index('#{$family}', ',')
264
+ and not string.index('#{$family}', 'var(');
265
+ @if $is-bare-name {
266
+ // Slugs are CSS variable names — emit var(--font-<slug>) and trust
267
+ // the consumer to declare the variable somewhere global
268
+ // (typically `:root { --font-<slug>: ...; }` in globals.css).
269
+ font-family: var(--font-#{$family});
270
+ } @else {
271
+ font-family: $family;
272
+ }
273
+ }
274
+
275
+ // Route scale keys through font-size()/line-height() so they emit
276
+ // `var(--font-size-N)` rather than a baked literal — otherwise a consumer
277
+ // overriding the token at runtime silently has no effect here, while the
278
+ // same override works on mixins that use the functions. Raw values
279
+ // (`0.8rem`, `1.4`) still pass straight through.
280
+ @if $size {
281
+ @if map.get(s.$font-sizes, $size) {
282
+ font-size: font-size($size);
283
+ } @else {
284
+ font-size: $size;
285
+ }
286
+ }
287
+
288
+ @if $lh {
289
+ @if map.get(s.$line-heights, $lh) {
290
+ line-height: line-height($lh);
291
+ } @else {
292
+ line-height: $lh;
293
+ }
294
+ }
295
+
296
+ @if $ls {
297
+ @if map.get(s.$letter-spacings, $ls) {
298
+ letter-spacing: letter-spacing($ls);
299
+ } @else {
300
+ letter-spacing: $ls;
301
+ }
302
+ }
303
+
304
+ @if $color {
305
+ color: color($color);
306
+ }
307
+ }
308
+
309
+ // ============================================================================
310
+ // FONTS — two-line model
311
+ // ----------------------------------------------------------------------------
312
+ // Two lines of work for any custom font, period:
313
+ //
314
+ // 1) Declare the CSS variable on :root in your global stylesheet:
315
+ // :root { --font-meme: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
316
+ //
317
+ // 2) Use it in any component:
318
+ // @include m.font($lh: 0.95, $family: meme); // mixin form
319
+ // font-family: var(--font-meme); // raw form
320
+ //
321
+ // Override anywhere CSS variables work — root, theme, page, block, element.
322
+ //
323
+ // HOSTED FONTS (Google Fonts / CDN):
324
+ // `font-load` registers a URL and emits `@import url(...)`. Call it from a
325
+ // global Sass file (NOT a .module.scss — CSS Modules pure mode forbids the
326
+ // :root selector the auto-emit would need). The CSS variable still gets
327
+ // declared via the consumer's global stylesheet, same as system fonts.
328
+ //
329
+ // // src/styles/fonts.scss (a global .scss imported from layout.tsx):
330
+ // @include m.font-face('Pacifico', 'https://fonts.googleapis.com/css2?family=Pacifico');
331
+ //
332
+ // // src/app/globals.css:
333
+ // :root { --font-pacifico: 'Pacifico', cursive; }
334
+ //
335
+ // // Any component:
336
+ // .heading { @include m.font($family: pacifico); }
337
+ //
338
+ // Sister `font-face-local()` covers self-hosted woff2/ttf/etc. via @font-face.
339
+ // ============================================================================
340
+ $_loaded-fonts: () !default;
341
+
342
+ @mixin font-face($name, $url, $alias: null, $fallback: sans-serif) {
343
+ $existing: map.get($_loaded-fonts, $name);
344
+ @if $existing {
345
+ @if map.get($existing, url) != $url {
346
+ @error "font-face(): '#{$name}' is already registered with a different URL. Existing: #{map.get($existing, url)}. New: #{$url}. One name, one URL.";
347
+ }
348
+ // Same name + URL = no-op. @import already emitted on the first call.
349
+ } @else {
350
+ $_loaded-fonts: map.set($_loaded-fonts, $name, (url: $url, fallback: $fallback)) !global;
351
+ @at-root {
352
+ /* stylelint-disable-next-line no-invalid-position-at-import-rule, import-notation -- @at-root lifts this @import to the top of the compiled stylesheet (valid position); url() notation is required because Sass rejects the bare-string form inside @at-root. */
353
+ @import url(#{$url});
354
+ }
355
+ }
356
+
357
+ // Optional: emit a theme-slot alias at the call site. Best called from a
358
+ // selector context that owns the override (e.g. inside :root in a global
359
+ // Sass file, or inside [data-theme="x"] for a per-theme override).
360
+ @if $alias {
361
+ --font-#{$alias}: '#{$name}', #{$fallback};
362
+ }
363
+ }
364
+
365
+ @mixin font-face-local(
366
+ $name,
367
+ $src,
368
+ $weight: 400,
369
+ $style: normal,
370
+ $display: swap,
371
+ $alias: null,
372
+ $fallback: sans-serif
373
+ ) {
374
+ $existing: map.get($_loaded-fonts, $name);
375
+ @if $existing {
376
+ @if map.get($existing, src) != $src {
377
+ @error "font-face-local(): '#{$name}' is already registered with a different src. Existing: #{map.get($existing, src)}. New: #{$src}. One name, one src.";
378
+ }
379
+ // Same name + src = no-op.
380
+ } @else {
381
+ $_loaded-fonts: map.set($_loaded-fonts, $name, (src: $src, fallback: $fallback)) !global;
382
+ @at-root {
383
+ @font-face {
384
+ font-family: '#{$name}';
385
+ src: url(#{$src}) format('woff2');
386
+ font-weight: $weight;
387
+ font-style: $style;
388
+ font-display: $display;
389
+ }
390
+ }
391
+ }
392
+
393
+ @if $alias {
394
+ --font-#{$alias}: '#{$name}', #{$fallback};
395
+ }
396
+ }
397
+
398
+ // ============================================================================
399
+ // TYPE SCALE PRESETS
400
+ // ============================================================================
401
+ // Semantic typography compositions for consistent hierarchy.
402
+ // Usage: @include m.type(heading-1); or @include m.type(caption);
403
+ // ============================================================================
404
+
405
+ $_type-scale: (
406
+ display: (size: 8, weight: bold, lh: 2, ls: tight),
407
+ heading-1: (size: 7, weight: bold, lh: 2, ls: null),
408
+ heading-2: (size: 6, weight: semibold, lh: 2, ls: null),
409
+ heading-3: (size: 5, weight: semibold, lh: 3, ls: null),
410
+ heading-4: (size: 4, weight: medium, lh: 4, ls: null),
411
+ body: (size: 3, weight: normal, lh: 4, ls: null),
412
+ body-sm: (size: 2, weight: normal, lh: 4, ls: null),
413
+ caption: (size: 1, weight: normal, lh: 4, ls: null),
414
+ overline: (size: 1, weight: semibold, lh: 4, ls: wider, tt: uppercase),
415
+ );
416
+
417
+ @mixin type($preset) {
418
+ $cfg: map.get($_type-scale, $preset);
419
+ @if not $cfg {
420
+ @error "Unknown type preset: #{$preset}. Available: #{map.keys($_type-scale)}";
421
+ }
422
+
423
+ $s: map.get($cfg, size);
424
+ $w: map.get($cfg, weight);
425
+ $lh-key: map.get($cfg, lh);
426
+ $ls-key: map.get($cfg, ls);
427
+ $tt: map.get($cfg, tt);
428
+
429
+ font-size: font-size($s);
430
+ font-weight: font-weight($w);
431
+
432
+ @if $lh-key {
433
+ $lh-val: map.get(s.$line-heights, $lh-key);
434
+ @if $lh-val { line-height: $lh-val; }
435
+ }
436
+
437
+ @if $ls-key {
438
+ $ls-val: map.get(s.$letter-spacings, $ls-key);
439
+ @if $ls-val and $ls-val != 0 { letter-spacing: $ls-val; }
440
+ }
441
+
442
+ @if $tt { text-transform: $tt; }
443
+ }
444
+
445
+ // ============================================================================
446
+ // UI TEXT PRESETS — type() + a default color, packaged for the common cases
447
+ // ----------------------------------------------------------------------------
448
+ // When to reach for these: the lockup is a recognisable UI pattern (eyebrow
449
+ // kicker, figure caption, footer note) rather than raw typography. They sit
450
+ // on top of type() and add a default color token; pass $color to override.
451
+ //
452
+ // When NOT to reach for these: you just need a size/weight without a
453
+ // semantic role — use type(<preset>) directly, no color, you keep currentColor.
454
+ //
455
+ // Adding a new one: name it for what the pattern IS (eyebrow, caption-text),
456
+ // not what it does (small-caps-tracked-muted). Default $color to the slot
457
+ // the pattern uses 80% of the time. Wrap an existing type() preset whenever
458
+ // you can — don't redeclare font-size/weight/ls/lh inline.
459
+ // ============================================================================
460
+
461
+ // Eyebrow / kicker — small caps-tracked label that tags a section above
462
+ // its heading. One-liner replacement for the
463
+ // font-size + color + text-transform + letter-spacing
464
+ // four-property lockup that shows up wherever a chip-style label sits over
465
+ // a title.
466
+ @mixin eyebrow($color: text-secondary) {
467
+ @include type(overline);
468
+ color: color($color);
469
+ }
470
+
471
+ // ============================================================================
472
+ // BORDER & RADIUS
473
+ // ============================================================================
474
+
475
+ @function radius($size) {
476
+ $fallback: map.get(t.$theme-radius, $size);
477
+ @return var(--radius-#{$size}, #{$fallback or 0.25rem});
478
+ }
479
+
480
+ @function radius-raw($size) {
481
+ @return map.get(t.$theme-radius, $size) or 0.25rem;
482
+ }
483
+
484
+ @mixin border(
485
+ $width: 1px,
486
+ $style: solid,
487
+ $color: border-default,
488
+ $sides: all
489
+ ) {
490
+ @if $sides == all {
491
+ border: $width $style color($color);
492
+ } @else if meta.type-of($sides) == list {
493
+ @each $side in $sides {
494
+ border-#{$side}: $width $style color($color);
495
+ }
496
+ } @else {
497
+ border-#{$sides}: $width $style color($color);
498
+ }
499
+ }
500
+
501
+ // ============================================================================
502
+ // SHADOWS
503
+ // ============================================================================
504
+
505
+ @function shadow($level) {
506
+ $fallback: map.get(t.$theme-shadows-light, $level);
507
+ @return var(--shadow-#{$level}, #{$fallback or none});
508
+ }
509
+
510
+ @function shadow-raw($level) {
511
+ @return map.get(t.$theme-shadows-light, $level) or none;
512
+ }
513
+
514
+ @mixin elevation($level: 2) {
515
+ box-shadow: shadow($level);
516
+ }
517
+
518
+ // ============================================================================
519
+ // Z-INDEX
520
+ // ============================================================================
521
+
522
+ @function z($layer) {
523
+ $fallback: map.get(s.$z-layers, $layer);
524
+ @return var(--z-#{$layer}, #{$fallback or 0});
525
+ }
526
+
527
+ @function z-raw($layer) {
528
+ @return map.get(s.$z-layers, $layer) or 0;
529
+ }
530
+
531
+ // ============================================================================
532
+ // BREAKPOINTS
533
+ // ============================================================================
534
+
535
+ @mixin media($size) {
536
+ $width: $size;
537
+ @if meta.type-of($size) != number {
538
+ $width: map.get(s.$breakpoints, $size);
539
+ }
540
+ @media screen and (min-width: $width) { @content; }
541
+ }
542
+
543
+ @mixin media-down($size) {
544
+ $width: $size;
545
+ @if meta.type-of($size) != number {
546
+ $width: map.get(s.$breakpoints, $size);
547
+ }
548
+ @media screen and (max-width: calc(#{$width} - 1px)) { @content; }
549
+ }
550
+
551
+ @mixin media-between($min, $max) {
552
+ $min-w: $min;
553
+ $max-w: $max;
554
+ @if meta.type-of($min) != number {
555
+ $min-w: map.get(s.$breakpoints, $min);
556
+ }
557
+ @if meta.type-of($max) != number {
558
+ $max-w: map.get(s.$breakpoints, $max);
559
+ }
560
+ @media screen and (min-width: $min-w) and (max-width: calc(#{$max-w} - 1px)) { @content; }
561
+ }
562
+
563
+ // Convenience aliases
564
+ @mixin mobile-only { @include media-down(md) { @content; } }
565
+ @mixin tablet { @include media(md) { @content; } }
566
+ @mixin tablet-only { @include media-between(md, lg) { @content; } }
567
+ @mixin desktop { @include media(lg) { @content; } }
568
+ @mixin wide { @include media(xl) { @content; } }
569
+
570
+ // ============================================================================
571
+ // PRINT
572
+ // ============================================================================
573
+ // Print/PDF control. Every browser already ships a layout engine and a PDF
574
+ // writer wired together behind Ctrl+P — the page IS the PDF source. These own the `@media print` layer: `print-base` ships
575
+ // sensible page-level defaults ON; the rest are per-element toggles. Nothing
576
+ // emits until called, and nothing here needs a button or a server — Ctrl+P
577
+ // (or the print dialog's "Save as PDF") drives it all.
578
+ // ============================================================================
579
+
580
+ // Primitive: bare print media wrapper. Co-locate inside the selector it changes
581
+ // so the override lives next to the rule it overrides.
582
+ // .subtitle { color: #ccc; @include m.print { color: #555; } }
583
+ @mixin print {
584
+ @media print { @content; }
585
+ }
586
+
587
+ // Page-level defaults, ON by default. Include ONCE at the stylesheet ROOT —
588
+ // NOT inside a selector. It emits `@page`, which is invalid nested under a rule
589
+ // (`@page { body { … } }` does nothing). Toggle pieces off via args.
590
+ // @include m.print-base; // at root
591
+ // @include m.print-base($size: A4, $freeze-animations: false);
592
+ //
593
+ // Emits the print VARIABLE control plane — the whole filter flips from here:
594
+ // --is-print 0 on screen, 1 on paper. Read it in calc()/opacity/
595
+ // @container style(--is-print: 1) for custom print effects.
596
+ // --print-hide display applied to elements carrying `print-hidden`.
597
+ // --print-show display applied to elements carrying `print-only`.
598
+ // Override either locally for a per-element exception — no rule rewrite:
599
+ // .keep-this { --print-hide: revert; } // stays on paper despite print-hidden
600
+ // .pdf-row { --print-show: flex; } // print-only block laid out as flex
601
+ @mixin print-base($freeze-animations: true, $size: letter, $margin: 0.5in) {
602
+ :root {
603
+ --is-print: 0;
604
+ --print-hide: none;
605
+ --print-show: revert;
606
+ }
607
+
608
+ @media print {
609
+ :root { --is-print: 1; }
610
+
611
+ @page { size: $size; margin: $margin; }
612
+
613
+ @if $freeze-animations {
614
+ // The opacity:0 entrance-fade trap: a page snapshotted mid-animation
615
+ // prints invisible text.
616
+ //
617
+ // We do NOT solve this by forcing `opacity: 1` and `transform: none`.
618
+ // That fixes the fade but destroys every DELIBERATE use of the same
619
+ // properties — a 0.15 watermark, a 0.4 disabled control, a stamp rotated
620
+ // -4deg all get flattened on paper.
621
+ //
622
+ // Instead, collapse animations to zero duration and pin them to their
623
+ // final frame. An entrance fade lands on its end state (visible), while
624
+ // an element that was never animating is left completely alone.
625
+ //
626
+ // `!important` stays, and is load-bearing: @media adds no specificity, so
627
+ // any author `animation:` shorthand of equal-or-higher specificity would
628
+ // otherwise win and re-introduce the trap.
629
+ /* stylelint-disable declaration-no-important -- must beat author animation/transition declarations; @media contributes no specificity of its own. */
630
+ *,
631
+ *::before,
632
+ *::after {
633
+ animation-delay: 0s !important;
634
+ animation-duration: 0s !important;
635
+ animation-fill-mode: forwards !important;
636
+ transition-delay: 0s !important;
637
+ transition-duration: 0s !important;
638
+ }
639
+ /* stylelint-enable declaration-no-important */
640
+ }
641
+ }
642
+ }
643
+
644
+ // Hide on paper — the "hide the nav" case. The display VALUE is variable-driven
645
+ // (--print-hide), so consumers re-aim it per element without a rule rewrite;
646
+ // !important on the declaration keeps the hide reliable against component
647
+ // `display` rules. Requires `print-base` at the root for the variable default.
648
+ @mixin print-hidden {
649
+ /* stylelint-disable-next-line declaration-no-important -- hide-on-print must beat any component display rule; the value stays variable-driven via --print-hide. */
650
+ @media print { display: var(--print-hide, none) !important; }
651
+ }
652
+
653
+ // Show ONLY on paper (e.g. an inline URL footer). Hidden on screen; the paper
654
+ // display is variable-driven (--print-show) so a print-only block can be flex.
655
+ @mixin print-only {
656
+ /* stylelint-disable declaration-no-important -- show/hide-on-print must beat component display rules; values stay variable-driven. */
657
+ @media screen { display: none !important; }
658
+ @media print { display: var(--print-show, revert) !important; }
659
+ /* stylelint-enable declaration-no-important */
660
+ }
661
+
662
+ // ============================================================================
663
+ // CONTAINER QUERIES
664
+ // ============================================================================
665
+ // Component-level responsive design. Use alongside viewport breakpoints.
666
+ // Usage: @include m.container('card'); then @include m.cq(md) { ... }
667
+ // ============================================================================
668
+
669
+ @mixin container($name: null, $type: inline-size) {
670
+ container-type: $type;
671
+ @if $name {
672
+ container-name: $name;
673
+ }
674
+ }
675
+
676
+ @mixin contain($size, $name: null) {
677
+ $width: $size;
678
+ @if meta.type-of($size) != number {
679
+ $width: map.get(s.$breakpoints, $size);
680
+ }
681
+ @if $name {
682
+ @container #{$name} (min-width: #{$width}) { @content; }
683
+ } @else {
684
+ @container (min-width: #{$width}) { @content; }
685
+ }
686
+ }
687
+
688
+ @mixin contain-down($size, $name: null) {
689
+ $width: $size;
690
+ @if meta.type-of($size) != number {
691
+ $width: map.get(s.$breakpoints, $size);
692
+ }
693
+ @if $name {
694
+ @container #{$name} (max-width: calc(#{$width} - 1px)) { @content; }
695
+ } @else {
696
+ @container (max-width: calc(#{$width} - 1px)) { @content; }
697
+ }
698
+ }
699
+
700
+ @mixin contain-between($min, $max, $name: null) {
701
+ $min-w: $min;
702
+ $max-w: $max;
703
+ @if meta.type-of($min) != number {
704
+ $min-w: map.get(s.$breakpoints, $min);
705
+ }
706
+ @if meta.type-of($max) != number {
707
+ $max-w: map.get(s.$breakpoints, $max);
708
+ }
709
+ @if $name {
710
+ @container #{$name} (min-width: #{$min-w}) and (max-width: calc(#{$max-w} - 1px)) { @content; }
711
+ } @else {
712
+ @container (min-width: #{$min-w}) and (max-width: calc(#{$max-w} - 1px)) { @content; }
713
+ }
714
+ }
715
+
716
+ // ============================================================================
717
+ // TRANSITIONS
718
+ // ============================================================================
719
+ $_speeds: (
720
+ instant: 0ms,
721
+ fast: 150ms,
722
+ normal: 250ms,
723
+ slow: 400ms,
724
+ slower: 600ms,
725
+ );
726
+
727
+ $_easings: (
728
+ linear: linear,
729
+ ease: ease,
730
+ ease-in: ease-in,
731
+ ease-out: ease-out,
732
+ ease-in-out: ease-in-out,
733
+ bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55),
734
+ smooth: cubic-bezier(0.4, 0, 0.2, 1),
735
+ );
736
+
737
+ // Speed and easing can be given two ways:
738
+ // positionally @include transition(color, slow, bounce)
739
+ // by name @include transition(color, $duration: slow, $easing: bounce)
740
+ //
741
+ // The named form exists because the positional one is undiscoverable — the
742
+ // signature is `$props...`, so nothing tells you a speed keyword is even
743
+ // accepted, and an unrecognised word silently becomes a transitioned property.
744
+ // A raw duration (`220ms`) is passed through as-is.
745
+ @mixin transition($props...) {
746
+ $kw: meta.keywords($props);
747
+ $speed: map.get($kw, duration) or fast;
748
+ $easing: map.get($kw, easing) or smooth;
749
+ $result: ();
750
+
751
+ // Pass 1 — pick up positional speed/easing keywords wherever they appear.
752
+ // Previously this happened in the same loop that built the list, so
753
+ // `transition(color, slow)` silently used the default speed: `color` was
754
+ // appended before `slow` had been seen. Only `transition(slow, color)` worked.
755
+ @each $prop in $props {
756
+ @if map.has-key($_speeds, $prop) {
757
+ $speed: $prop;
758
+ } @else if map.has-key($_easings, $prop) {
759
+ $easing: $prop;
760
+ }
761
+ }
762
+
763
+ // Pass 2 — build the list, now that speed and easing are settled.
764
+ // `or $speed` / `or $easing` lets a raw value through — `220ms` or a literal
765
+ // cubic-bezier() — instead of resolving to null and emitting `color ;`.
766
+ @each $prop in $props {
767
+ @if not map.has-key($_speeds, $prop) and not map.has-key($_easings, $prop) {
768
+ $result: list.append(
769
+ $result,
770
+ $prop (map.get($_speeds, $speed) or $speed) (map.get($_easings, $easing) or $easing),
771
+ comma
772
+ );
773
+ }
774
+ }
775
+ transition: $result;
776
+ @media (prefers-reduced-motion: reduce) {
777
+ transition: none;
778
+ }
779
+ }
780
+
781
+ // ============================================================================
782
+ // LAYOUT
783
+ // ============================================================================
784
+ // One flex primitive. Pass only what differs from the defaults.
785
+ //
786
+ // @include m.flex; // flex row, centered cross-axis
787
+ // @include m.flex($direction: column, $gap: 3); // vertical stack with gap
788
+ // @include m.flex($justify: between, $gap: 3); // header bar / accordion trigger
789
+ // @include m.flex($justify: center); // perfectly centered children
790
+ // @include m.flex($inline: true, $gap: 2); // inline-flex chip lockup
791
+ //
792
+ // $justify shorthand maps to CSS: start/end/center/between/around/evenly →
793
+ // flex-start/flex-end/center/space-between/space-around/space-evenly. Same
794
+ // idea for $align (start/end → flex-start/flex-end). Full CSS values pass
795
+ // through unchanged, so $justify: space-between still works.
796
+
797
+ $_flex-justify: (
798
+ start: flex-start,
799
+ end: flex-end,
800
+ center: center,
801
+ between: space-between,
802
+ around: space-around,
803
+ evenly: space-evenly,
804
+ );
805
+
806
+ $_flex-align: (
807
+ start: flex-start,
808
+ end: flex-end,
809
+ center: center,
810
+ baseline: baseline,
811
+ stretch: stretch,
812
+ );
813
+
814
+ @mixin flex(
815
+ $direction: row,
816
+ $gap: null,
817
+ $align: center,
818
+ $justify: start,
819
+ $wrap: nowrap,
820
+ $inline: false
821
+ ) {
822
+ $j: map.get($_flex-justify, $justify) or $justify;
823
+ $a: map.get($_flex-align, $align) or $align;
824
+
825
+ display: if(sass($inline): inline-flex; else: flex);
826
+ flex-direction: $direction;
827
+ flex-wrap: $wrap;
828
+ align-items: $a;
829
+ justify-content: $j;
830
+
831
+ @if $gap { gap: space($gap); }
832
+ }
833
+
834
+ @mixin pad($size: 4) {
835
+ padding: space($size);
836
+ }
837
+
838
+ @mixin pad-x($size: 4) {
839
+ padding-inline: space($size);
840
+ }
841
+
842
+ @mixin pad-y($size: 4) {
843
+ padding-block: space($size);
844
+ }
845
+
846
+ @mixin pad-asym($y: 2, $x: 4) {
847
+ padding: space($y) space($x);
848
+ }
849
+
850
+ // ============================================================================
851
+ // RESETS
852
+ // ============================================================================
853
+
854
+ @mixin button-reset {
855
+ appearance: none;
856
+ background: none;
857
+ border: none;
858
+ padding: 0;
859
+ margin: 0;
860
+ font: inherit;
861
+ color: inherit;
862
+ cursor: pointer;
863
+ text-decoration: none;
864
+ &:focus { outline: none; }
865
+ }
866
+
867
+ @mixin list-reset {
868
+ list-style: none;
869
+ margin: 0;
870
+ padding: 0;
871
+ }
872
+
873
+ @mixin header-reset {
874
+ @for $i from 1 through 6 {
875
+ h#{$i} { margin: 0; padding: 0; font: inherit; }
876
+ }
877
+ }
878
+
879
+ @mixin form-reset {
880
+ input, button, select, textarea {
881
+ font: inherit;
882
+ color: inherit;
883
+ }
884
+ input:not([type="checkbox"]):not([type="radio"]),
885
+ select,
886
+ textarea {
887
+ width: 100%;
888
+ }
889
+ }
890
+
891
+ // ============================================================================
892
+ // UTILITIES
893
+ // ============================================================================
894
+
895
+ @mixin truncate($lines: 1) {
896
+ @if $lines == 1 {
897
+ overflow: hidden;
898
+ text-overflow: ellipsis;
899
+ white-space: nowrap;
900
+ } @else {
901
+ display: -webkit-box;
902
+ -webkit-line-clamp: $lines;
903
+ -webkit-box-orient: vertical;
904
+ overflow: hidden;
905
+ }
906
+ }
907
+
908
+ @mixin sr-only {
909
+ position: absolute;
910
+ width: 1px;
911
+ height: 1px;
912
+ padding: 0;
913
+ margin: -1px;
914
+ overflow: hidden;
915
+ clip: rect(0, 0, 0, 0);
916
+ border: 0;
917
+ }
918
+
919
+ @mixin disabled($opacity: 0.5) {
920
+ opacity: $opacity;
921
+ cursor: not-allowed;
922
+ pointer-events: none;
923
+ }
924
+
925
+ @mixin focus-ring($color: border-focus, $width: 3px, $offset: 0) {
926
+ &:focus-visible {
927
+ outline: none;
928
+ box-shadow: 0 0 0 $width color($color);
929
+ @if $offset > 0 {
930
+ outline: $width solid color($color);
931
+ outline-offset: $offset;
932
+ box-shadow: none;
933
+ }
934
+ }
935
+ }
936
+
937
+ @mixin hover($prop, $value, $speed: fast) {
938
+ @include transition($prop, $speed);
939
+ &:hover { #{$prop}: $value; }
940
+ }
941
+
942
+ @mixin interactive($bg-hover: interactive-hover, $bg-active: interactive-active) {
943
+ @include transition(background-color, fast);
944
+ &:hover { background-color: color($bg-hover); }
945
+ &:active { background-color: color($bg-active); }
946
+ &:disabled, &[disabled] { @include disabled; }
947
+ }
948
+
949
+ // ============================================================================
950
+ // THEME
951
+ // ============================================================================
952
+ // Authoring helpers for theme files. Keep boilerplate out of per-theme source;
953
+ // theme authors write only the token values.
954
+
955
+ // Wraps a theme's :root[data-theme] selector + color-scheme around its body.
956
+ //
957
+ // Usage:
958
+ // @include theme('press') {
959
+ // --background-default: light-dark(#f7f2e8, #0a0a08);
960
+ // --ink: light-dark(#1a1a1a, #f5f5f0);
961
+ // }
962
+ //
963
+ // // Mode-stable themes pass an explicit scheme:
964
+ // @include theme('sketchbook', light) {
965
+ // --background-default: #faf8f1;
966
+ // }
967
+ //
968
+ // SELECTOR MODEL
969
+ // ----------------------------------------------------------------------------
970
+ // Emits `:root, :root[data-theme="<name>"]` by default, so ONE theme file
971
+ // dropped in as theme.css restyles the page with no markup change, AND the same
972
+ // file still responds to data-theme when several themes are loaded together.
973
+ //
974
+ // The bare `:root` is the whole point of the product promise: install cia once,
975
+ // then reskin by swapping a single file. Requiring the consumer to also edit
976
+ // `<html data-theme=...>` to match the file they just swapped makes a mismatch
977
+ // silently render an untokenised page — the failure is invisible and the fix is
978
+ // non-obvious. Before this, the shipped themes disagreed three ways: 9 emitted
979
+ // `:root[data-theme=x]`, 7 emitted `[data-theme=x]`, and 5 emitted a bare
980
+ // `:root`. Only the last group actually worked when dropped in alone.
981
+ //
982
+ // Pass $standalone: false for the multi-theme bundle, where every theme shares
983
+ // one file and a bare `:root` would make them collide — there, the data-theme
984
+ // attribute is the only thing distinguishing them.
985
+ @mixin theme($name, $scheme: light dark, $standalone: true) {
986
+ $scoped: ':root[data-theme="#{$name}"]';
987
+
988
+ // @if/@else rather than the if() FUNCTION: Sass deprecated `if()` in favour
989
+ // of the CSS if() syntax, and every theme build emitted a deprecation
990
+ // warning through it — 24 themes' worth of noise in the release log.
991
+ $selector: $scoped;
992
+ @if $standalone {
993
+ $selector: ':root, #{$scoped}';
994
+ }
995
+
996
+ #{$selector} {
997
+ color-scheme: #{$scheme};
998
+ @content;
999
+ }
1000
+ }
1001
+
1002
+ // Derives hover/active states from a base color via color-mix in oklch.
1003
+ // Mixes BLACK in light mode, WHITE in dark mode — so light hover = darker,
1004
+ // dark hover = lighter, both feel right without separate per-mode declarations.
1005
+ //
1006
+ // Usage:
1007
+ // --action-primary-default: light-dark(#3A5FCD, #60a5fa);
1008
+ // @include states(action-primary);
1009
+ // /* emits --action-primary-hover and --action-primary-active */
1010
+ @mixin states($group, $hover-amount: 12%, $active-amount: 20%) {
1011
+ --#{$group}-hover: color-mix(in oklch, var(--#{$group}-default), light-dark(black, white) #{$hover-amount});
1012
+ --#{$group}-active: color-mix(in oklch, var(--#{$group}-default), light-dark(black, white) #{$active-amount});
1013
+ }
1014
+
1015
+ // Registers a list of color tokens with @property so theme swaps animate
1016
+ // smoothly via `transition: --token 240ms`. Only emits when called.
1017
+ //
1018
+ // Usage:
1019
+ // @include theme-properties((
1020
+ // action-primary-default,
1021
+ // action-primary-hover,
1022
+ // action-primary-active,
1023
+ // background-default,
1024
+ // ink,
1025
+ // ));
1026
+ @mixin theme-properties($tokens) {
1027
+ @at-root {
1028
+ @each $token in $tokens {
1029
+ @property --#{$token} {
1030
+ syntax: '<color>';
1031
+ inherits: true;
1032
+ initial-value: transparent;
1033
+ }
1034
+ }
1035
+ }
1036
+ }