@nubisco/ui 5.7.0 → 5.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubisco/ui",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "Vue 3 UI component library",
5
5
  "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457",
6
6
  "repository": {
@@ -0,0 +1,75 @@
1
+ /// Tokens derived from `--nb-c-primary`.
2
+ ///
3
+ /// ## Why these cannot be plain aliases at `:root`
4
+ ///
5
+ /// A custom property is substituted at computed-value time **on the element
6
+ /// that declares it**. `--nb-c-focus-ring: var(--nb-c-primary)` written at
7
+ /// `:root` therefore freezes to `:root`'s primary, and every descendant
8
+ /// inherits that frozen literal. Re-pointing `--nb-c-primary` further down the
9
+ /// tree, which is exactly what a named theme does, does not reach it.
10
+ ///
11
+ /// Measured in Chrome against a document whose `:root` primary was the default
12
+ /// and whose themed scope set its own:
13
+ ///
14
+ /// ```text
15
+ /// :root inside [data-nb-theme]
16
+ /// --nb-c-primary #5c35c4 #5b48c9 follows
17
+ /// --nb-c-focus-ring #5c35c4 #5c35c4 does not
18
+ /// --nb-c-primary-subtle #5c35c4… #5c35c4… does not
19
+ /// ```
20
+ ///
21
+ /// So any scope that re-points the primary has to re-emit these alongside it.
22
+ /// They live here, as mixins, so the formulas exist once: `_theme.scss` calls
23
+ /// them for the default theme and `_theme-api.scss` calls them for every named
24
+ /// theme. Two copies would agree today and drift the first time one is edited.
25
+ ///
26
+ /// @group @nubisco/theme-api
27
+
28
+ /// The pale wash of the accent, for an edge drawn ON a primary fill.
29
+ ///
30
+ /// @param {Number} $weight - how much accent the mix carries. The light half
31
+ /// uses 39% and the dark half 50%, because the dark primary is already a
32
+ /// lighter step of the ramp and needs less white to land on the same wash.
33
+ @mixin subtle($weight) {
34
+ --nb-c-primary-subtle: color-mix(
35
+ in srgb,
36
+ var(--nb-c-primary) #{$weight},
37
+ var(--nb-c-white)
38
+ );
39
+ }
40
+
41
+ /// The focus ring, used by every interactive control via `:focus-visible`.
42
+ @mixin focus-ring {
43
+ --nb-c-focus-ring: var(--nb-c-primary);
44
+ }
45
+
46
+ /// The shell chrome that carries the accent.
47
+ ///
48
+ /// The sidebar is a near-black tint of the accent rather than a raw palette
49
+ /// tint, so re-pointing the primary re-tints the rail with it. That is
50
+ /// deliberate: it is what makes the shell follow a product's identity instead
51
+ /// of the vendor's.
52
+ @mixin shell {
53
+ --nb-shell-sidebar-bg: color-mix(
54
+ in srgb,
55
+ var(--nb-c-primary) 20%,
56
+ var(--nb-c-black)
57
+ );
58
+ --nb-shell-sidebar-link-active-bg: color-mix(
59
+ in srgb,
60
+ var(--nb-c-primary) 25%,
61
+ transparent
62
+ );
63
+ // Light tint of --nb-c-primary: keeps the accent on-brand and rebrandable
64
+ // while staying legible (~6:1) on the dark rail, where --nb-c-primary itself
65
+ // is too dark (~2.5:1).
66
+ --nb-shell-sidebar-link-active-color: color-mix(
67
+ in srgb,
68
+ var(--nb-c-primary) 60%,
69
+ var(--nb-c-white)
70
+ );
71
+ // The grab-line on the inspector's resize handle. A token, not a literal
72
+ // brand hex: a white-label product should never have to name the vendor's
73
+ // ramp to restyle the frame.
74
+ --nb-shell-inspector-resize-color: var(--nb-c-primary);
75
+ }
@@ -52,7 +52,9 @@
52
52
  @use 'sass:map';
53
53
  @use 'sass:meta';
54
54
  @use 'sass:string';
55
+ @use 'accent' as accent;
55
56
  @use 'logic' as logic;
57
+ @use 'variables/categories' as categories;
56
58
 
57
59
  /// The tints every palette colour is expanded into, matching the library's own
58
60
  /// ramp exactly.
@@ -84,7 +86,23 @@ $a11y-roles:
84
86
  /// are generated by one implementation rather than by two that agree today.
85
87
  @mixin _palette($palette) {
86
88
  @each $name, $base in $palette {
87
- $shades: logic.make-shades($base);
89
+ /*
90
+ * A palette entry is either one colour or a `(light, dark)` pair.
91
+ *
92
+ * With one colour, `make-shades` anchors it at its own lightness step and
93
+ * generates the rest. With two, it anchors BOTH at their own steps and
94
+ * interpolates between them, which is what an accent needs: a category's
95
+ * light and dark values are picked independently for contrast, and neither
96
+ * is a step of the other's ramp. Anchoring only the light one and hoping
97
+ * the dark half lands nearby does not hold, measured: the four category
98
+ * pairs anchor at 700/400, 500/200, 700/400 and 500/300.
99
+ */
100
+ $shades: null;
101
+ @if meta.type-of($base) == list {
102
+ $shades: logic.make-shades(list.nth($base, 1), list.nth($base, 2));
103
+ } @else {
104
+ $shades: logic.make-shades($base);
105
+ }
88
106
  @each $tint in $tints {
89
107
  $value: map.get($shades, $tint);
90
108
  --nb-c-#{$name}-#{$tint}: #{$value};
@@ -191,3 +209,103 @@ $a11y-roles:
191
209
  }
192
210
  @return true;
193
211
  }
212
+
213
+ /// The ramp steps a theme can point at, as emitted by `_palette`.
214
+ $_min-step: 100;
215
+ $_max-step: 900;
216
+
217
+ /// Keeps a derived step inside the range the ramp actually emits.
218
+ @function _step($level, $offset) {
219
+ $v: $level + $offset;
220
+ @if $v < $_min-step {
221
+ @return $_min-step;
222
+ }
223
+ @if $v > $_max-step {
224
+ @return $_max-step;
225
+ }
226
+ @return $v;
227
+ }
228
+
229
+ /// Themes an app from its audience category.
230
+ ///
231
+ /// This is the one call an app makes. It takes the category the product
232
+ /// belongs to, resolves that to an accent pair, and wires it through the same
233
+ /// `theme()` machinery a hand-written theme uses.
234
+ ///
235
+ /// ```scss
236
+ /// @use '@nubisco/ui/styles/theme-api' as nb;
237
+ ///
238
+ /// @include nb.app-theme('acta', $category: 'engineers');
239
+ /// ```
240
+ ///
241
+ /// An app with an identity of its own overrides the accent and keeps the
242
+ /// category, which still drives anything that groups products by audience:
243
+ ///
244
+ /// ```scss
245
+ /// @include nb.app-theme('doodloop',
246
+ /// $category: 'creatives',
247
+ /// $accent: (#0b7285, #41d6e0)
248
+ /// );
249
+ /// ```
250
+ ///
251
+ /// ## What it emits
252
+ ///
253
+ /// The accent becomes a full ramp named `accent`, anchored at both ends, and
254
+ /// `primary` / `primary-hover` / `primary-active` point at steps of it: the
255
+ /// anchor step, then two steps away from the surface, darker in light mode and
256
+ /// lighter in dark. Each carries its `-a11y` foreground. Everything derived
257
+ /// from the primary is re-emitted in the same scope, because a `var()` alias
258
+ /// written at `:root` would not follow (see `_accent.scss`).
259
+ ///
260
+ /// `--nb-c-category` carries the audience colour separately from the accent.
261
+ /// For most apps they are the same value; for one that overrode its accent
262
+ /// they are not, and a surface that groups products by audience wants the
263
+ /// category, not the app's own colour.
264
+ ///
265
+ /// @param {String} $id - the app's identifier, chosen by the app. The library
266
+ /// keeps no registry of these: it never needs to know which products exist.
267
+ /// @param {String} $category - one of `creatives`, `engineers`, `home`, `fun`
268
+ /// @param {List} $accent - an optional `(light, dark)` override
269
+ @mixin app-theme($id, $category, $accent: null) {
270
+ $category-pair: categories.category($category);
271
+ $pair: $category-pair;
272
+ @if $accent != null {
273
+ $pair: categories.accent-pair($accent);
274
+ }
275
+
276
+ $light-hex: list.nth($pair, 1);
277
+ $dark-hex: list.nth($pair, 2);
278
+ $light-level: logic.color-level($light-hex);
279
+ $dark-level: logic.color-level($dark-hex);
280
+
281
+ @include theme(
282
+ $id,
283
+ $palette: (accent: $pair),
284
+ $light: (
285
+ primary: 'accent-#{$light-level}',
286
+ primary-hover: 'accent-#{_step($light-level, 50)}',
287
+ primary-active: 'accent-#{_step($light-level, 100)}',
288
+ category: list.nth($category-pair, 1)
289
+ ),
290
+ $dark: (
291
+ primary: 'accent-#{$dark-level}',
292
+ primary-hover: 'accent-#{_step($dark-level, -50)}',
293
+ primary-active: 'accent-#{_step($dark-level, -100)}',
294
+ category: list.nth($category-pair, 2)
295
+ )
296
+ );
297
+
298
+ // The primary-derived tokens, re-emitted in the theme's own scopes. Without
299
+ // these the app gets its accent on buttons and the vendor's purple on focus
300
+ // rings and the whole shell.
301
+ [data-nb-theme='#{$id}'] {
302
+ @include accent.subtle(39%);
303
+ @include accent.focus-ring;
304
+ @include accent.shell;
305
+ }
306
+
307
+ .dark[data-nb-theme='#{$id}'],
308
+ .dark [data-nb-theme='#{$id}'] {
309
+ @include accent.subtle(50%);
310
+ }
311
+ }
@@ -1,5 +1,6 @@
1
1
  @use 'sass:color';
2
2
  @use 'sass:map';
3
+ @use 'accent';
3
4
  @use 'config';
4
5
  @use 'variables/layout';
5
6
  @use 'variables/colors';
@@ -158,11 +159,7 @@
158
159
  // hover border visibly moves. The dark theme repeats the mix at 50%
159
160
  // because its --nb-c-primary is already a lighter step of the same ramp
160
161
  // and needs less white to land on the same wash (dE2000 0.00 there).
161
- --nb-c-primary-subtle: color-mix(
162
- in srgb,
163
- var(--nb-c-primary) 39%,
164
- var(--nb-c-white)
165
- );
162
+ @include accent.subtle(39%);
166
163
 
167
164
  --nb-c-secondary: var(--nb-c-plain-black-700);
168
165
  --nb-c-secondary-a11y: var(--nb-c-plain-black-700-a11y);
@@ -375,7 +372,7 @@
375
372
  --nb-c-component-discrete-text: var(--nb-c-nouveau-gray-700);
376
373
 
377
374
  // Focus ring — used by all interactive controls via :focus-visible
378
- --nb-c-focus-ring: var(--nb-c-primary);
375
+ @include accent.focus-ring;
379
376
 
380
377
  // ── Scrim / overlay ───────────────────────────────────────────────────
381
378
  // The dim laid over the page by anything that takes over the viewport:
@@ -588,11 +585,7 @@
588
585
  // Dark chrome derived from the brand: a near-black tint of --nb-c-primary so
589
586
  // rebranding the primary colour re-tints the sidebar too (semantic, not a
590
587
  // raw palette tint).
591
- --nb-shell-sidebar-bg: color-mix(
592
- in srgb,
593
- var(--nb-c-primary) 20%,
594
- var(--nb-c-black)
595
- );
588
+ @include accent.shell;
596
589
  --nb-shell-sidebar-width: calc(var(--nb-base-unit) * 7); // 56px
597
590
  // The verbose variant's rail width. A token rather than a literal inside
598
591
  // Shell.vue's variant rule, so a product with long navigation labels can
@@ -613,19 +606,6 @@
613
606
  var(--nb-c-white) 90%,
614
607
  transparent
615
608
  );
616
- --nb-shell-sidebar-link-active-bg: color-mix(
617
- in srgb,
618
- var(--nb-c-primary) 25%,
619
- transparent
620
- );
621
- // Light tint of --nb-c-primary: keeps the accent on-brand and rebrandable
622
- // while staying legible (~6:1) on the dark rail, where --nb-c-primary itself
623
- // is too dark (~2.5:1).
624
- --nb-shell-sidebar-link-active-color: color-mix(
625
- in srgb,
626
- var(--nb-c-primary) 60%,
627
- var(--nb-c-white)
628
- );
629
609
  --nb-shell-topbar-height: calc(var(--nb-base-unit) * 6.25); // 50px
630
610
 
631
611
  // Shell chrome. Every rule that paints the frame reads one of these, so a
@@ -672,10 +652,6 @@
672
652
  --nb-shell-inspector-max-width: max(calc(var(--nb-base-unit) * 36), 50vw);
673
653
  --nb-shell-inspector-border: 1px solid var(--nb-c-border);
674
654
  --nb-shell-inspector-bg: var(--nb-c-surface);
675
- // The grab-line on the inspector's resize handle. A token, not a literal
676
- // brand hex: a white-label product should never have to name the vendor's
677
- // ramp to restyle the frame.
678
- --nb-shell-inspector-resize-color: var(--nb-c-primary);
679
655
  // The dimmer behind the navigation drawer at collapsed widths. Black at
680
656
  // 45% reads on both themes because it darkens whatever is under it rather
681
657
  // than painting a colour of its own.
@@ -707,11 +683,7 @@
707
683
  --nb-c-primary: #{colors.get-color('grape-hyacinth', 400)};
708
684
  // Same wash, re-mixed: the dark --nb-c-primary is a 400, so it takes less
709
685
  // white than the light 500 does to reach the tint this token used to alias.
710
- --nb-c-primary-subtle: color-mix(
711
- in srgb,
712
- var(--nb-c-primary) 50%,
713
- var(--nb-c-white)
714
- );
686
+ @include accent.subtle(50%);
715
687
  --nb-c-primary-a11y: #{colors.color-contrast(
716
688
  colors.get-color('grape-hyacinth', 400)
717
689
  )};
@@ -0,0 +1,169 @@
1
+ @use 'sass:list';
2
+ @use 'sass:map';
3
+ @use 'sass:meta';
4
+ @use '../logic/a11y' as a11y;
5
+ @use 'layers';
6
+
7
+ /// The audience categories, and the accent pair each one resolves to.
8
+ ///
9
+ /// Nubisco groups its products by audience: creatives, engineers, home, fun.
10
+ /// These are the four accents that grouping resolves to, each a
11
+ /// `(light, dark)` pair.
12
+ ///
13
+ /// ## What is deliberately not here
14
+ ///
15
+ /// There is no product list. The library holds the four values and nothing
16
+ /// else: it does not know which product belongs to which category, and it must
17
+ /// not. A registry of product ids here would mean cutting a library release
18
+ /// every time a product is added or renamed, and it would invert the
19
+ /// dependency, making the design system know about the things that consume it.
20
+ /// An app names its own category when it themes itself, so a new product needs
21
+ /// no change to this file. The product-to-category map is a portfolio fact and
22
+ /// lives in the workspace standards.
23
+ ///
24
+ /// ## Why these exact values
25
+ ///
26
+ /// Every value clears WCAG AA (4.5:1) against all four surface layers of the
27
+ /// mode it belongs to. That is a stricter bar than measuring against pure
28
+ /// white and pure black, and it is the one that matters: the library never
29
+ /// renders a pure-white surface. Light accents are measured against the
30
+ /// darkest light layer (`layer-2`, `#e7e8e9`) and dark accents against the
31
+ /// lightest dark layer (`layer-3`, `#3d3e3f`), because those are the worst
32
+ /// cases. `tests/categoryContrast.test.ts` measures the compiled values and
33
+ /// fails if an edit drops one below the bar.
34
+ ///
35
+ /// Hover and active are the adjacent steps of the same ramp, darker in light
36
+ /// mode and lighter in dark, so both are strictly further from the surface
37
+ /// than the base. The base is therefore the worst case, and testing it is
38
+ /// enough.
39
+ ///
40
+ /// @type Map
41
+ /// @access public
42
+ /// @group @nubisco/colors
43
+ $categories: (
44
+ creatives: (
45
+ #846201,
46
+ #e8c15a,
47
+ ),
48
+ engineers: (
49
+ #5b48c9,
50
+ #a99dff,
51
+ ),
52
+ home: (
53
+ #086f9e,
54
+ #5ec1e8,
55
+ ),
56
+ fun: (
57
+ #b83266,
58
+ #f686ae,
59
+ ),
60
+ );
61
+
62
+ /// The `(light, dark)` accent pair for a category.
63
+ ///
64
+ /// An unknown name is a build error rather than a silent fallback: a typo that
65
+ /// quietly resolved to the default accent would ship a product in the wrong
66
+ /// colour and nothing would say so.
67
+ ///
68
+ /// @param {String} $name - a key of `$categories`
69
+ /// @return {List} the `(light, dark)` pair
70
+ /// @access public
71
+ /// @group @nubisco/colors
72
+ @function category($name) {
73
+ @if not map.has-key($categories, $name) {
74
+ @error "Unknown category #{$name}. Expected one of: #{map.keys($categories)}.";
75
+ }
76
+ @return map.get($categories, $name);
77
+ }
78
+
79
+ /// Validates a `(light, dark)` accent pair supplied by an app.
80
+ ///
81
+ /// An app with an established identity of its own passes its own pair rather
82
+ /// than taking its category's. This checks the shape so a malformed value
83
+ /// fails at build time instead of emitting nonsense custom properties.
84
+ ///
85
+ /// @param {List} $accent - a two-item list of colours
86
+ /// @return {List} the same pair, once validated
87
+ /// @access public
88
+ /// @group @nubisco/colors
89
+ @function accent-pair($accent) {
90
+ @if meta.type-of($accent) != list or list.length($accent) != 2 {
91
+ @error "An accent must be a (light, dark) pair of colours, got #{$accent}.";
92
+ }
93
+ @each $value in $accent {
94
+ @if meta.type-of($value) != color {
95
+ @error "An accent pair must hold colours, got #{$value}.";
96
+ }
97
+ }
98
+ @return $accent;
99
+ }
100
+
101
+ /// The surfaces an accent is measured against, per mode.
102
+ ///
103
+ /// These are the library's own layers, resolved from
104
+ /// `variables/_layers.scss`, not pure white and pure black. That distinction
105
+ /// is the point: an accent verified against a surface the product never
106
+ /// renders has not been verified. The worst case is the darkest light layer
107
+ /// and the lightest dark layer, and both are in here.
108
+ ///
109
+ /// @type Map
110
+ /// @access private
111
+ /// @group @nubisco/colors
112
+ /// Read from the layer ramps rather than restated, so a change to the surface
113
+ /// system is measured against automatically instead of silently invalidating
114
+ /// numbers copied out of it.
115
+ $_grounds: (
116
+ light: (
117
+ layers.level(layers.$light, surface, 0),
118
+ layers.level(layers.$light, surface, 1),
119
+ layers.level(layers.$light, surface, 2),
120
+ layers.level(layers.$light, surface, 3),
121
+ ),
122
+ dark: (
123
+ layers.level(layers.$dark, surface, 0),
124
+ layers.level(layers.$dark, surface, 1),
125
+ layers.level(layers.$dark, surface, 2),
126
+ layers.level(layers.$dark, surface, 3),
127
+ ),
128
+ );
129
+
130
+ /// Fails the build if an accent pair does not clear WCAG AA on every surface.
131
+ ///
132
+ /// The library measures the accents it ships, in
133
+ /// `tests/categoryContrast.test.ts`. It cannot measure an accent an app
134
+ /// supplies, because it never sees it. An app that overrides its accent calls
135
+ /// this so its own build holds it to the same bar.
136
+ ///
137
+ /// ```scss
138
+ /// @debug nb.assert-accent-contrast((#0b7285, #41d6e0));
139
+ /// ```
140
+ ///
141
+ /// @param {List} $accent - a `(light, dark)` pair
142
+ /// @param {Number} $minimum - the ratio to clear, 4.5 by default
143
+ /// @return {String} a summary, so the call reads as an assertion that returns
144
+ /// @access public
145
+ /// @group @nubisco/colors
146
+ @function assert-accent-contrast($accent, $minimum: 4.5) {
147
+ $pair: accent-pair($accent);
148
+ $worst: 21;
149
+
150
+ @each $mode, $grounds in $_grounds {
151
+ $value: list.nth($pair, 2);
152
+ $direction: 'lighter';
153
+ @if $mode == light {
154
+ $value: list.nth($pair, 1);
155
+ $direction: 'darker';
156
+ }
157
+ @each $ground in $grounds {
158
+ $ratio: a11y.contrast-ratio($value, $ground);
159
+ @if $ratio < $minimum {
160
+ @error "Accent #{$value} is #{$ratio}:1 on #{$mode} surface #{$ground}, below #{$minimum}:1. Pick a #{$direction} value.";
161
+ }
162
+ @if $ratio < $worst {
163
+ $worst: $ratio;
164
+ }
165
+ }
166
+ }
167
+
168
+ @return 'accent clears #{$minimum}:1 on every surface (worst #{$worst}:1)';
169
+ }
@@ -1,5 +1,6 @@
1
1
  @forward 'layout';
2
2
  @forward 'colors';
3
+ @forward 'categories';
3
4
  @forward 'type';
4
5
  @forward 'breakpoints';
5
6
  @forward 'grid';