@danjelp/ngx-app-shell 1.0.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.
@@ -0,0 +1,340 @@
1
+ @use 'sass:list';
2
+ @use 'sass:map';
3
+
4
+ /// Single source of truth for the shell's design tokens.
5
+ ///
6
+ /// Three layers, each overriding the one below:
7
+ ///
8
+ /// 1. global tokens `--shell-surface`, `--shell-accent` … ($shell-tokens)
9
+ /// 2. component tokens `--shell-topbar-bg`, `--shell-drawer-duration` …
10
+ /// ($shell-component-tokens, fall back to layer 1)
11
+ /// 3. presets `appearance` / `motion` config, bound as host
12
+ /// attributes; they only change layer-2 *defaults*, so
13
+ /// any component token still wins.
14
+ ///
15
+ /// Every value is used as the inline fallback inside the components, so the
16
+ /// shell renders correctly with no global stylesheet at all.
17
+ $shell-tokens: (
18
+ // ── Structure ───────────────────────────────────────────────────────────
19
+ 'topbar-height': 64px,
20
+ 'topbar-padding-inline': 12px,
21
+ 'topbar-gap': 8px,
22
+ 'sidebar-width': 264px,
23
+ 'sidebar-collapsed-width': 68px,
24
+ 'sidebar-overlay-width': min(88vw, 320px),
25
+ 'sidebar-padding': 12px,
26
+ 'sidebar-gap': 2px,
27
+ 'footer-min-height': 48px,
28
+ 'main-padding': 24px,
29
+ 'main-max-width': none,
30
+
31
+ // ── Surfaces & text ────────────────────────────────────────────────────
32
+ 'color-scheme': light,
33
+ 'surface': #ffffff,
34
+ 'surface-raised': #f7f7f8,
35
+ 'sidebar-surface': #fafafb,
36
+ 'topbar-surface': #ffffff,
37
+ 'fg': #1b1c1f,
38
+ 'fg-muted': #6b6f76,
39
+ 'border': #e6e6e9,
40
+ 'hover-surface': #f0f0f3,
41
+ 'active-surface': #e9e9ee,
42
+ 'accent': #4f46e5,
43
+ 'accent-fg': #ffffff,
44
+ 'accent-surface': #eef0fe,
45
+ 'focus-ring': #4f46e5,
46
+ 'scrim': rgb(15 17 21 / 45%),
47
+ 'shadow-panel': 0 12px 32px -12px rgb(15 17 21 / 28%),
48
+
49
+ // ── Shape, rhythm, motion ──────────────────────────────────────────────
50
+ 'radius': 10px,
51
+ 'radius-sm': 6px,
52
+ 'nav-item-height': 36px,
53
+ 'nav-item-gap': 10px,
54
+ 'nav-icon-size': 18px,
55
+ 'font-family': inherit,
56
+ 'font-size': 14px,
57
+ 'font-size-sm': 12.5px,
58
+ 'transition-duration': 180ms,
59
+ 'transition-easing': cubic-bezier(0.2, 0, 0, 1),
60
+
61
+ // ── Stacking ───────────────────────────────────────────────────────────
62
+ 'z-main': 0,
63
+ 'z-topbar': 20,
64
+ 'z-scrim': 30,
65
+ 'z-sidebar': 40,
66
+ 'z-flyout': 50,
67
+ 'z-skip-link': 60
68
+ );
69
+
70
+ /// Overrides for a dark theme. Only colour tokens change.
71
+ $shell-dark-tokens: (
72
+ 'color-scheme': dark,
73
+ 'surface': #111215,
74
+ 'surface-raised': #16171b,
75
+ 'sidebar-surface': #141519,
76
+ 'topbar-surface': #111215,
77
+ 'fg': #ececf1,
78
+ 'fg-muted': #9a9ca6,
79
+ 'border': #26272d,
80
+ 'hover-surface': #1d1e23,
81
+ 'active-surface': #26272d,
82
+ 'accent': #8f8aff,
83
+ 'accent-fg': #0d0d12,
84
+ 'accent-surface': rgb(143 138 255 / 16%),
85
+ 'focus-ring': #8f8aff,
86
+ 'scrim': rgb(0 0 0 / 60%),
87
+ 'shadow-panel': 0 16px 40px -12px rgb(0 0 0 / 70%)
88
+ );
89
+
90
+ /// `tok('sidebar-width')` → `var(--shell-sidebar-width, 264px)`
91
+ /// Errors on unknown names, so a typo fails the build instead of silently
92
+ /// resolving to an empty value.
93
+ @function tok($name) {
94
+ @if not map.has-key($shell-tokens, $name) {
95
+ @error "Unknown shell token '#{$name}'. Known tokens: #{map.keys($shell-tokens)}.";
96
+ }
97
+ @return var(--shell-#{$name}, #{map.get($shell-tokens, $name)});
98
+ }
99
+
100
+ /// Raw default, without the `var()` wrapper.
101
+ @function raw($name) {
102
+ @return map.get($shell-tokens, $name);
103
+ }
104
+
105
+ /// Per-part tokens. Each defaults to a global token.
106
+ ///
107
+ /// Deliberately NOT emitted by `tokens()`: a custom property whose value is
108
+ /// another `var()` is resolved where it is declared, so a `:root` alias would
109
+ /// freeze the global value at `:root` and a theme class further down could no
110
+ /// longer reach the part. The aliases therefore live only as fallbacks inside
111
+ /// the components, where they resolve against the component's own cascade.
112
+ /// Setting a *concrete* value anywhere (`:root { --shell-topbar-bg: #0b1020 }`)
113
+ /// is exactly how they are meant to be used.
114
+ $shell-component-tokens: (
115
+ // ── Shell (the container behind every part) ────────────────────────────
116
+ // Shows wherever a part is transparent or inset: transparent topbar,
117
+ // minimal footer, floating-sidebar gaps, the margin of an inset main.
118
+ 'bg': tok('surface'),
119
+
120
+ // ── Topbar ─────────────────────────────────────────────────────────────
121
+ 'topbar-bg': tok('topbar-surface'),
122
+ 'topbar-fg': tok('fg'),
123
+ // Border tokens take the CSS shorthand — 1 to 4 values, top right bottom
124
+ // left — so each side can differ: `--shell-topbar-border-width: 0 0 2px`.
125
+ 'topbar-border-width': 0 0 1px,
126
+ 'topbar-border-style': solid,
127
+ 'topbar-border-color': tok('border'),
128
+ 'topbar-radius': 0px,
129
+ 'topbar-shadow': none,
130
+ 'topbar-blur': 12px,
131
+
132
+ // ── Sidebar (persistent panel and rail) ────────────────────────────────
133
+ 'sidebar-bg': tok('sidebar-surface'),
134
+ 'sidebar-fg': tok('fg'),
135
+ 'sidebar-border-color': tok('border'),
136
+ 'sidebar-shadow': none,
137
+ 'sidebar-radius': 0px,
138
+ 'sidebar-inset': 8px,
139
+ 'sidebar-peek-shadow': tok('shadow-panel'),
140
+
141
+ // ── Drawer (overlay mode) and its scrim ────────────────────────────────
142
+ 'drawer-bg': var(--shell-sidebar-bg, #{tok('sidebar-surface')}),
143
+ 'drawer-shadow': tok('shadow-panel'),
144
+ 'drawer-radius': 0px,
145
+ 'drawer-scrim': tok('scrim'),
146
+ 'drawer-scrim-blur': 0px,
147
+
148
+ // ── Navigation ─────────────────────────────────────────────────────────
149
+ 'nav-item-fg': tok('fg'),
150
+ 'nav-item-hover-bg': tok('hover-surface'),
151
+ 'nav-item-active-bg': tok('accent-surface'),
152
+ 'nav-item-active-fg': tok('accent'),
153
+ 'nav-item-radius': tok('radius-sm'),
154
+ 'nav-indicator-color': tok('accent'),
155
+ 'nav-indicator-size': 3px,
156
+ 'nav-heading-fg': tok('fg-muted'),
157
+ 'nav-heading-size': tok('font-size-sm'),
158
+
159
+ // ── Topbar overflow (⋯) menu ───────────────────────────────────────────
160
+ 'menu-bg': tok('surface'),
161
+ 'menu-border-color': tok('border'),
162
+ 'menu-shadow': tok('shadow-panel'),
163
+ 'menu-radius': tok('radius'),
164
+
165
+ // ── Main & footer ──────────────────────────────────────────────────────
166
+ 'main-bg': tok('surface'),
167
+ 'main-fg': tok('fg'),
168
+ 'main-border-width': 0,
169
+ 'main-border-style': solid,
170
+ 'main-border-color': tok('border'),
171
+ 'main-radius': 0px,
172
+ 'main-shadow': none,
173
+ 'main-margin': 0,
174
+ 'footer-bg': tok('surface-raised'),
175
+ 'footer-fg': tok('fg-muted'),
176
+ 'footer-border-color': tok('border'),
177
+ 'footer-shadow': none,
178
+ 'footer-font-size': tok('font-size-sm'),
179
+
180
+ // ── Frame: the rounded card of the `inset` layout ──────────────────────
181
+ // Wraps topbar, main and footer. Its radius and shadow give the card its
182
+ // shape; the parts inside keep their own tokens.
183
+ 'frame-bg': tok('surface'),
184
+ 'frame-border-width': 0,
185
+ 'frame-border-style': solid,
186
+ 'frame-border-color': tok('border'),
187
+ 'frame-radius': 14px,
188
+ 'frame-shadow': (0 1px 2px rgb(0 0 0 / 4%), 0 8px 24px -8px rgb(0 0 0 / 14%)),
189
+ 'frame-inset': 8px,
190
+
191
+ // ── Subheader: the breadcrumb row ──────────────────────────────────────
192
+ // Its height is reserved (a minimum), so late labels and actions never
193
+ // shift the page.
194
+ 'subheader-height': 44px,
195
+ 'subheader-bg': tok('surface'),
196
+ 'subheader-fg': tok('fg'),
197
+ 'subheader-border-width': 0 0 1px,
198
+ 'subheader-border-style': solid,
199
+ 'subheader-border-color': tok('border'),
200
+ 'subheader-padding-inline': 24px,
201
+ 'subheader-shadow': none,
202
+ 'subheader-radius': 0px,
203
+ // A margin plus radius/border/shadow gives an inset, pill-shaped row.
204
+ 'subheader-margin': 0,
205
+ 'subheader-font-size': tok('font-size'),
206
+
207
+ // ── Breadcrumb trail ───────────────────────────────────────────────────
208
+ 'breadcrumb-fg': tok('fg-muted'),
209
+ 'breadcrumb-hover-fg': tok('fg'),
210
+ 'breadcrumb-current-fg': tok('fg'),
211
+ 'breadcrumb-separator-color': tok('fg-muted'),
212
+ 'breadcrumb-gap': 4px,
213
+ 'breadcrumb-skeleton-bg': tok('active-surface'),
214
+ // `1em` = the row's font size, so `subheader-font-size` carries through.
215
+ 'breadcrumb-font-size': 1em,
216
+ 'breadcrumb-current-weight': 600,
217
+ 'breadcrumb-hover-bg': tok('hover-surface'),
218
+ // Longest label before it truncates with an ellipsis.
219
+ 'breadcrumb-label-max': 28ch
220
+ );
221
+
222
+ /// `ctok('topbar-bg')` → `var(--shell-topbar-bg, var(--shell-topbar-surface, #fff))`
223
+ /// Pass `$fallback` to change the default for one preset while keeping the
224
+ /// component token in charge: `ctok('topbar-shadow', $elevated-shadow)`.
225
+ @function ctok($name, $fallback: null) {
226
+ @if not map.has-key($shell-component-tokens, $name) {
227
+ @error "Unknown shell component token '#{$name}'. Known: #{map.keys($shell-component-tokens)}.";
228
+ }
229
+ $default: map.get($shell-component-tokens, $name);
230
+ @if $fallback != null {
231
+ $default: $fallback;
232
+ }
233
+ @return var(--shell-#{$name}, #{$default});
234
+ }
235
+
236
+ /// Emit the global tokens as declarations. Include once in a global
237
+ /// stylesheet if you want them inspectable in devtools or inheritable by your
238
+ /// own components:
239
+ ///
240
+ /// ```scss
241
+ /// @use 'app-shell/styles/tokens' as shell;
242
+ /// @include shell.tokens; // :root
243
+ /// @include shell.tokens('.theme-compact'); // scoped variant
244
+ /// ```
245
+ @mixin tokens($selector: ':root') {
246
+ #{$selector} {
247
+ @each $name, $value in $shell-tokens {
248
+ --shell-#{$name}: #{$value};
249
+ }
250
+ }
251
+ }
252
+
253
+ /// Emit the dark palette under a selector of your choice:
254
+ ///
255
+ /// ```scss
256
+ /// @include shell.dark-tokens(':root[data-theme="dark"]');
257
+ /// ```
258
+ @mixin dark-tokens($selector) {
259
+ #{$selector} {
260
+ @each $name, $value in $shell-dark-tokens {
261
+ --shell-#{$name}: #{$value};
262
+ }
263
+ }
264
+ }
265
+
266
+ // ── Motion ────────────────────────────────────────────────────────────────
267
+
268
+ /// Parts whose timing can be tuned on its own with
269
+ /// `--shell-<scope>-duration` / `--shell-<scope>-easing`.
270
+ ///
271
+ /// topbar hover and preset changes in the bar
272
+ /// sidebar hover, peek shadow, colours of the panel
273
+ /// collapse rail ⇄ panel width (grid track, panel, peek)
274
+ /// drawer overlay drawer and scrim
275
+ /// nav nav item hover / active colours
276
+ /// nav-expand opening and closing sections
277
+ /// menu ⋯ overflow menus
278
+ /// subheader breadcrumb row and trail
279
+ /// footer footer colour changes
280
+ $shell-motion-scopes: ('topbar', 'sidebar', 'collapse', 'drawer', 'nav', 'nav-expand', 'menu', 'subheader', 'footer');
281
+
282
+ @function _check-scope($scope) {
283
+ @if $scope != null and not list.index($shell-motion-scopes, $scope) {
284
+ @error "Unknown motion scope '#{$scope}'. Known scopes: #{$shell-motion-scopes}.";
285
+ }
286
+ @return $scope;
287
+ }
288
+
289
+ /// Duration for a scope, multiplied by `--shell-motion-scale` — written by
290
+ /// `<app-shell>` from `motion.speed`, and 0 when `motion.enabled` is false.
291
+ /// That one factor gives a global switch and speed control that also governs
292
+ /// durations an app sets per part.
293
+ @function duration($scope: null) {
294
+ $value: tok('transition-duration');
295
+ @if _check-scope($scope) != null {
296
+ $value: var(--shell-#{$scope}-duration, #{$value});
297
+ }
298
+ @return calc(#{$value} * var(--shell-motion-scale, 1));
299
+ }
300
+
301
+ @function easing($scope: null) {
302
+ $value: tok('transition-easing');
303
+ @if _check-scope($scope) != null {
304
+ $value: var(--shell-#{$scope}-easing, #{$value});
305
+ }
306
+ @return $value;
307
+ }
308
+
309
+ /// `@include motion('drawer', transform, opacity);`
310
+ @mixin motion($scope, $properties...) {
311
+ transition-property: $properties;
312
+ transition-duration: duration($scope);
313
+ transition-timing-function: easing($scope);
314
+
315
+ @media (prefers-reduced-motion: reduce) {
316
+ transition-duration: 1ms;
317
+ }
318
+ }
319
+
320
+ /// Global timing, no scope.
321
+ @mixin transition($properties...) {
322
+ @include motion(null, $properties...);
323
+ }
324
+
325
+ // ── Helpers ───────────────────────────────────────────────────────────────
326
+
327
+ @mixin focus-ring($offset: 2px) {
328
+ &:focus-visible {
329
+ outline: 2px solid tok('focus-ring');
330
+ outline-offset: $offset;
331
+ }
332
+ }
333
+
334
+ /// Truncate a label that may be squeezed by a shrinking container.
335
+ @mixin truncate {
336
+ min-width: 0;
337
+ overflow: hidden;
338
+ white-space: nowrap;
339
+ text-overflow: ellipsis;
340
+ }
@@ -0,0 +1,3 @@
1
+ // Stable public path for the token API:
2
+ // @use '@danjelp/ngx-app-shell/styles/tokens' as shell;
3
+ @forward '../src/lib/styles/tokens';