@leta-io/design-tokens 0.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Map Elevation — LETA Design System
3
+ * @leta-io/design-tokens/map-elevation.css
4
+ *
5
+ * ─── Why this exists ───────────────────────────────────────────────────────
6
+ *
7
+ * A shadow does not communicate elevation on its own. It communicates elevation
8
+ * by CONTRASTING with whatever sits behind it. Every shadow in the Figma library
9
+ * — Neutral Drop Shadow 1/2/3 — was tuned against the app's own near-white page
10
+ * surfaces, where a soft 10% black reads clearly.
11
+ *
12
+ * A map is a different substrate entirely: mid-tone, saturated, and visually
13
+ * busy (roads, parks, water, labels). Against it those shadows disappear, and
14
+ * white overlays read as flat shapes pasted onto the tiles rather than floating
15
+ * above them. Verified directly in the app — with Neutral Drop Shadow 2 applied
16
+ * exactly as Figma specifies, the depot/recipient cards, zoom control and
17
+ * collapse button all still read flat.
18
+ *
19
+ * Compounding it, two of Neutral Drop Shadow 2's three layers use `spread: -8`
20
+ * against `blur: 8`, which places them inside the element's own edge — invisible
21
+ * behind an opaque surface. So it effectively renders as one soft 10% layer.
22
+ * Neutral Drop Shadow 3 is stronger but its `spread: -4/-6` eats most of it too.
23
+ *
24
+ * So map overlays get their own elevation, built the way map products (Google,
25
+ * Mapbox, Apple) build theirs:
26
+ *
27
+ * 1. a **hairline ring** — guarantees a crisp edge no matter what colour the
28
+ * tile underneath happens to be; a shadow alone can never promise contrast
29
+ * against arbitrary imagery
30
+ * 2. a **contact shadow** — tight and relatively opaque, anchoring the element
31
+ * 3. an **ambient shadow** — wider and softer, giving the actual sense of lift
32
+ *
33
+ * ─── This is an addition to the system, not an override of it ──────────────
34
+ *
35
+ * Neutral Drop Shadow 1/2/3 remain correct and unchanged for everything on a
36
+ * page surface. This is a fourth, substrate-specific level. It should be added
37
+ * to the Figma library as an effect style ("Map Overlay Shadow") so design and
38
+ * code stay in sync — until then, Figma will show map overlays a step flatter
39
+ * than the build.
40
+ *
41
+ * ─── Usage ─────────────────────────────────────────────────────────────────
42
+ *
43
+ * import '@leta-io/design-tokens/map-elevation.css';
44
+ *
45
+ * // Any element floating on a map: cards, banners, legend, zoom control,
46
+ * // expand/collapse buttons, popovers, notification banners.
47
+ * style={{ boxShadow: 'var(--shadow-map-overlay)' }}
48
+ *
49
+ * Pins are the exception: a pin is a teardrop, not a rectangle, and `box-shadow`
50
+ * traces the element's BOX — it would draw a rectangular shadow around a pointed
51
+ * shape. Pins use `filter: drop-shadow()`, which follows the shape's alpha.
52
+ * `MapIcon` carries that equivalent internally (`filter` takes no custom
53
+ * properties reliably across engines, and it has no `spread` parameter, so the
54
+ * values are folded rather than copied).
55
+ */
56
+
57
+ :root {
58
+ /**
59
+ * Elevation for anything floating on a map surface.
60
+ * Ring + contact + ambient — see the note above for why all three.
61
+ */
62
+ --shadow-map-overlay:
63
+ 0 0 0 1px rgb(16 16 16 / 0.06),
64
+ 0 1px 3px rgb(16 16 16 / 0.22),
65
+ 0 6px 16px -4px rgb(16 16 16 / 0.28);
66
+
67
+ /**
68
+ * ─── Map pins do not follow the app theme ──────────────────────────────
69
+ *
70
+ * A pin sits on **map imagery**, not on the app's page surface. That is the same
71
+ * argument this file already makes for shadows, and it applies just as directly
72
+ * to the pin's own colours: the point of the white chassis is to guarantee the
73
+ * marker reads against arbitrary cartography, and the point of the coloured inner
74
+ * is to carry a status colour. Neither job changes because the app went dark.
75
+ *
76
+ * They were bound to app-surface semantic tokens, so in dark mode every pin turned
77
+ * inside out — measured 2026-08-26: chassis `#fefefe` → `#0d0d0d`, inner navy
78
+ * `#192037` → `#f9f9fa`, content `#fafafa` → `#101010`. One root cause behind three
79
+ * separate reports:
80
+ *
81
+ * • "the pin colours look desaturated" — they had inverted, not desaturated.
82
+ * `--surface-primary-bg` goes `#ff3941` → `#ffd7d9`: vivid red to pale pink.
83
+ * • "the pins have no drop shadow and don't stand out" — a near-black pin with a
84
+ * black shadow on a dark map.
85
+ * • "why do these pins have a dark blur behind them" — the same black shadow,
86
+ * now visible as a smudge wherever the map underneath was lighter.
87
+ *
88
+ * These are drawn from the **primitive** `--colors-*` layer, which is stable across
89
+ * themes (verified), so there is deliberately no `[data-theme="dark"]` block for
90
+ * them. A dark shadow under a light chassis then means what it is supposed to mean.
91
+ */
92
+ --map-pin-body: var(--colors-neutral-white);
93
+ --map-pin-edge: var(--colors-neutral-200);
94
+ --map-pin-content: var(--colors-neutral-50);
95
+
96
+ /**
97
+ * Status colours for a pin's inner shape. Callers pass one of these rather than an
98
+ * app-surface token, for the reason above — a pin's red has to stay red on a dark
99
+ * map, and `--surface-primary-bg` does not.
100
+ */
101
+ --map-pin-primary: var(--colors-primary-default);
102
+ --map-pin-secondary: var(--colors-secondary-default);
103
+ --map-pin-error: var(--colors-error-default);
104
+ --map-pin-success: var(--colors-success-default);
105
+ --map-pin-warning: var(--colors-warning-default);
106
+ --map-pin-neutral: var(--colors-neutral-default);
107
+
108
+ /**
109
+ * Pin / glyph filter — the `filter: drop-shadow()` equivalent of the above, for
110
+ * shapes rather than boxes (see the note on pins). Consumed by `MapIcon`.
111
+ *
112
+ * A chain rather than a single shadow, because dark mode adds a colour step to it
113
+ * (below). Light mode is shadow only: on light tiles a dark shadow both grounds the
114
+ * pin and gives its edge contrast, and the pin's own colour is already vivid
115
+ * against pale cartography.
116
+ */
117
+ --map-glyph-filter:
118
+ drop-shadow(0 1px 2px rgb(16 16 16 / 0.30))
119
+ drop-shadow(0 4px 8px rgb(16 16 16 / 0.22));
120
+ }
121
+
122
+ /**
123
+ * ─── Dark basemap: the shadow gets deeper, the colours do not move ─────────
124
+ *
125
+ * With the chassis staying light (above), a dark shadow means what it is supposed
126
+ * to mean again — a light object casting onto a dark ground — so this is a tuning
127
+ * step, not a different device. Pure black, because the light-mode value is mixed
128
+ * toward the page ink and reads as nothing on a dark map.
129
+ *
130
+ * **Tighter than light mode, not heavier.** The instinct is to strengthen a shadow
131
+ * that is failing to show; on a dark ground that produces a soft black cloud around
132
+ * an already-dark pin, which reads as a smudge rather than as lift — reported as
133
+ * "why do these pins have a dark blur behind them". So the contact layer stays close
134
+ * (1px blur) and the ambient one is small and faint (4px at 32%). Elevation on a
135
+ * dark map comes from the pin's light chassis; the shadow only has to seat it.
136
+ *
137
+ * Note what is deliberately NOT here: no halo, and no `saturate()`. Both were tried
138
+ * and removed (2026-08-26). The halo ringed the glyph in light and changed what the
139
+ * marker was; the saturation was compensating for a colour that had *inverted*
140
+ * rather than faded, so it could not have worked — a near-white inner does not get
141
+ * more colourful when you saturate it. Fixing the inversion at the source removed
142
+ * the need for either.
143
+ */
144
+ :root[data-theme="dark"] {
145
+ --map-glyph-filter:
146
+ drop-shadow(0 1px 1px rgb(0 0 0 / 0.55))
147
+ drop-shadow(0 2px 4px rgb(0 0 0 / 0.32));
148
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Scroll Utilities — LETA Design System
3
+ * @leta-io/design-tokens/scroll-utilities.css
4
+ *
5
+ * Scroll behaviour + scrollbar appearance utilities.
6
+ * Import alongside tokens.css and text-styles.css wherever the design system
7
+ * is consumed (Storybook, Playground, host applications).
8
+ *
9
+ * Three independent concerns live here:
10
+ * 1. Scroll CHAINING — .scroll-isolated / .scroll-rigid (overscroll-behavior)
11
+ * 2. Scrollbar LOOK — .scroll-slim / .scroll-hidden (see the note below)
12
+ * 3. SCROLL EDGES — --scroll-edge-* tokens (see the note below)
13
+ *
14
+ * ─── Rule reference ────────────────────────────────────────────────────────
15
+ *
16
+ * .scroll-isolated (overscroll-behavior: contain)
17
+ * Use on all NESTED / INTERNAL scroll containers: dropdown option lists,
18
+ * modal body panels, sidebar navigation, search-results panels, date-picker
19
+ * lists, mobile dropdown panels, and configuration form columns.
20
+ * Effect: the browser stops at the element boundary — no scroll chain to
21
+ * the parent and no pull-to-refresh trigger when the user reaches the top
22
+ * or bottom of the inner list.
23
+ *
24
+ * .scroll-rigid (overscroll-behavior: none)
25
+ * Use on DATA TABLE bodies and precision SNAP-SCROLL surfaces (time wheels).
26
+ * Effect: zero overscroll bounce / rubber-band on any axis. Prevents
27
+ * distracting UI shifts when aligning tabular data or advancing a snap list.
28
+ *
29
+ * Root page / document scroll: leave untouched (native elastic bounce on iOS,
30
+ * standard desktop behaviour). Do NOT apply either class to <html> or <body>.
31
+ *
32
+ * ─── Usage in component code (inline-style equivalent) ────────────────────
33
+ *
34
+ * Nested list / panel → style={{ overscrollBehavior: 'contain' }}
35
+ * Data table / wheel → style={{ overscrollBehavior: 'none' }}
36
+ *
37
+ * ─── Usage in playground / host app (class-based) ─────────────────────────
38
+ *
39
+ * import '@leta-io/design-tokens/scroll-utilities.css';
40
+ *
41
+ * // Internal panel
42
+ * <div className="scroll-isolated" style={{ overflowY: 'auto', maxHeight: 300 }}>
43
+ * {items}
44
+ * </div>
45
+ *
46
+ * // Data table viewport
47
+ * <div className="scroll-rigid" style={{ overflow: 'auto', maxHeight: 600 }}>
48
+ * <table>…</table>
49
+ * </div>
50
+ *
51
+ * ─── Already applied in design-system components ──────────────────────────
52
+ *
53
+ * contain: ModalShell (body) · SideBar (nav content) · DesktopDropdowns (all
54
+ * scroll lists + filter-group panes) · MapSearchInput (results panel) ·
55
+ * MobileDropdown (panel) · DateTimePicker (desktop time-list, mobile
56
+ * content) · Page (config column)
57
+ *
58
+ * none: Table (body — both fillHeight and maxBodyHeight modes, both-axes
59
+ * scrollX mode) · DateTimePicker WheelColumn (snap-scroll time wheel)
60
+ */
61
+
62
+ /* ─── Scroll Edges ───────────────────────────────────────────────────────────
63
+ *
64
+ * **"Scroll Edge"** is the LETA name for the affordance that tells a user
65
+ * *there is more content past this edge*. One concept, two treatments — chosen by
66
+ * what sits at the edge, not by axis:
67
+ *
68
+ * · **Edge fade** — a soft gradient painted OVER the scrolling content itself,
69
+ * for when content slides under an opaque neighbour with no chrome between
70
+ * them. Used by the Table's pinned anchor columns (Order ID left / Actions
71
+ * right) on horizontal scroll.
72
+ *
73
+ * · **Edge shadow** — a drop shadow cast BY a piece of chrome onto the content
74
+ * scrolling beneath it, for when a fixed header or footer needs to read as
75
+ * separate from (and above) the scroll region. Used by ModalShell's header and
76
+ * footer, and by FooterFrame's `scrollShadow`.
77
+ *
78
+ * Both are driven by the same detection primitive — `useScrollEdges(ref)` in
79
+ * `@leta-io/components` — so "is there more past this edge?" is answered one way
80
+ * everywhere. Never hand-roll that check; the hook also catches the cases a naive
81
+ * implementation misses (content growing without the container resizing, and the
82
+ * scroll target being swapped out under a stable ref).
83
+ *
84
+ * ─── Why these are tokens and not literals ─────────────────────────────────
85
+ *
86
+ * A scroll edge is drawn in INK over a surface, so it must invert with the theme.
87
+ * The light palette's table/modal surface is `#fefefe` and the dark palette's is
88
+ * `#0d0d0d` — very nearly black. A dark-ink fade (the light-mode treatment) over
89
+ * `#0d0d0d` is mathematically invisible: there is almost no headroom left to
90
+ * darken. So dark mode flips to LIGHT ink, the same logic as the hairline in
91
+ * `dark-elevation.css` — on a dark surface, "lifted" reads as light, not darker.
92
+ *
93
+ * Deliberately NOT reusing `--shadow-neutral-*`: those are ELEVATION (an object
94
+ * floating above a page) and in dark mode they carry a full 1px hairline ring on
95
+ * all four sides. A scroll edge is a single-edge affordance — a ring around a
96
+ * modal header is wrong, and that is exactly what reusing the elevation token
97
+ * produced before this was split out.
98
+ *
99
+ * Consume as:
100
+ * background: linear-gradient(to right,
101
+ * rgb(var(--scroll-edge-ink) / var(--scroll-edge-fade-1)), …);
102
+ * box-shadow: var(--scroll-edge-shadow-down);
103
+ */
104
+
105
+ :root {
106
+ /** Ink the edge is drawn in — dark over light surfaces. Space-separated RGB
107
+ * channels so callers can apply their own alpha via `rgb(… / …)`. */
108
+ --scroll-edge-ink: 16 16 16;
109
+ /** Eased fade falloff (4 stops — a plain 2-stop gradient reads as a hard band). */
110
+ --scroll-edge-fade-1: 0.045;
111
+ --scroll-edge-fade-2: 0.028;
112
+ --scroll-edge-fade-3: 0.012;
113
+ /** Chrome edge shadows — `down` for a header above content, `up` for a footer below. */
114
+ --scroll-edge-shadow-down: 0 4px 10px -4px rgb(var(--scroll-edge-ink) / 0.1);
115
+ --scroll-edge-shadow-up: 0 -4px 10px -4px rgb(var(--scroll-edge-ink) / 0.1);
116
+ }
117
+
118
+ /* Dark mode: light ink, and LOWER alphas than light mode.
119
+ *
120
+ * The ink flips because a near-black surface (`#0d0d0d`) gives a dark fade nowhere
121
+ * to go — the edge has to be drawn as a subtle lift rather than a shadow.
122
+ *
123
+ * The alphas come DOWN rather than up, which is the counter-intuitive part: light
124
+ * ink on near-black is far more perceptible than dark ink on near-white, because
125
+ * perception near black is logarithmic (a small absolute lift is a large relative
126
+ * change). Matching light mode's alphas here reads as a hard white band — an
127
+ * "intense glow", reported 2026-08-24 — so these sit at roughly half. Tune by eye
128
+ * against the Table's pinned columns and a scrolling modal footer, not by matching
129
+ * the light-mode numbers. */
130
+ [data-theme='dark'] {
131
+ --scroll-edge-ink: 255 255 255;
132
+ --scroll-edge-fade-1: 0.05;
133
+ --scroll-edge-fade-2: 0.028;
134
+ --scroll-edge-fade-3: 0.01;
135
+ --scroll-edge-shadow-down: 0 4px 10px -4px rgb(var(--scroll-edge-ink) / 0.06);
136
+ --scroll-edge-shadow-up: 0 -4px 10px -4px rgb(var(--scroll-edge-ink) / 0.06);
137
+ }
138
+
139
+ /** Isolate scroll chain — for nested lists, panels, and navigation drawers. */
140
+ .scroll-isolated {
141
+ overscroll-behavior: contain;
142
+ }
143
+
144
+ /** Rigid scroll — for data tables and snap-scroll precision surfaces. */
145
+ .scroll-rigid {
146
+ overscroll-behavior: none;
147
+ }
148
+
149
+ /* ─── Scrollbar appearance ───────────────────────────────────────────────────
150
+ *
151
+ * WHAT IS AND ISN'T POSSIBLE, per platform:
152
+ *
153
+ * · macOS — the OS default is already an *overlay* scrollbar: invisible at
154
+ * rest, fading in while scrolling, taking zero layout width. Nothing
155
+ * needs fixing there, which is why this problem is usually invisible
156
+ * to a designer on a Mac and glaring to a user on Windows.
157
+ * (A Mac set to "Always show scroll bars" behaves like Windows, and
158
+ * these rules cover that too.)
159
+ * · Windows / Linux — classic scrollbars: always visible and, critically, they
160
+ * OCCUPY LAYOUT WIDTH (~15px), so a panel silently gets narrower and
161
+ * a chunky grey gutter sits against the design.
162
+ *
163
+ * Both engine families can be styled, so a consistent result IS achievable:
164
+ * · `scrollbar-width` / `scrollbar-color` — the standard properties. Firefox
165
+ * has had them for years; Chrome/Edge support them from 121. `thin` still
166
+ * reserves a (narrow) gutter, so layout stays stable and nothing overlaps.
167
+ * · `::-webkit-scrollbar*` — the older Blink/WebKit pseudo-elements, giving
168
+ * exact width, colour and radius. Kept alongside the standard properties
169
+ * because they win where both are supported and give a nicer thumb.
170
+ *
171
+ * Deliberately NOT doing `scrollbar-width: none` / `display: none` as a general
172
+ * rule: that removes the only cue that a region scrolls at all. Hiding is right
173
+ * only where another element already carries that signal — the Table hides its
174
+ * vertical bar because the pagination footer is the boundary, and the horizontal
175
+ * bar stays visible precisely because nothing else says "there is more to the
176
+ * right". Use `.scroll-hidden` for that narrow case, `.scroll-slim` by default.
177
+ */
178
+
179
+ /**
180
+ * Slim, low-contrast scrollbar — the default for internal scroll containers
181
+ * (modal panels, dropdown lists, drawers, side panels).
182
+ *
183
+ * The thumb is a neutral token at low opacity that darkens on hover, so it reads
184
+ * as part of the surface rather than as OS chrome, and the track is transparent
185
+ * so it disappears against whatever it is sitting on.
186
+ */
187
+ .scroll-slim {
188
+ scrollbar-width: thin;
189
+ scrollbar-color: var(--border-neutral-default) transparent;
190
+ }
191
+ .scroll-slim::-webkit-scrollbar {
192
+ width: 8px;
193
+ height: 8px;
194
+ }
195
+ .scroll-slim::-webkit-scrollbar-track {
196
+ background: transparent;
197
+ }
198
+ .scroll-slim::-webkit-scrollbar-thumb {
199
+ background-color: var(--border-neutral-default);
200
+ border-radius: var(--rounding-round);
201
+ /* Inset the thumb inside its 8px lane so it reads as a 4px pill with padding,
202
+ rather than a bar wedged against the content edge. */
203
+ border: 2px solid transparent;
204
+ background-clip: content-box;
205
+ }
206
+ .scroll-slim::-webkit-scrollbar-thumb:hover {
207
+ background-color: var(--icons-neutral-idle);
208
+ }
209
+ /* Stops the little square where the two bars meet from rendering as grey chrome. */
210
+ .scroll-slim::-webkit-scrollbar-corner {
211
+ background: transparent;
212
+ }
213
+
214
+ /**
215
+ * Fully hidden scrollbar — only where a *different* element already tells the
216
+ * user the region scrolls (see the note above). Scrolling itself is unaffected.
217
+ */
218
+ .scroll-hidden {
219
+ scrollbar-width: none;
220
+ }
221
+ .scroll-hidden::-webkit-scrollbar {
222
+ width: 0;
223
+ height: 0;
224
+ }
225
+
226
+ /* ─── Global slim scrollbars, gated on platform ──────────────────────────────
227
+ *
228
+ * Applied only when `applyScrollbarMode()` has detected CLASSIC scrollbars —
229
+ * i.e. Windows/Linux, where bars are always visible and eat layout width.
230
+ * macOS's overlay scrollbars are already invisible-at-rest and zero-width, and
231
+ * styling them would REGRESS that into a permanent gutter, so they are left
232
+ * alone. See `scrollbars.ts` for the full reasoning.
233
+ *
234
+ * Call once from the app entry: applyScrollbarMode()
235
+ */
236
+ [data-scrollbars='classic'] * {
237
+ scrollbar-width: thin;
238
+ scrollbar-color: var(--border-neutral-default) transparent;
239
+ }
240
+ [data-scrollbars='classic'] *::-webkit-scrollbar {
241
+ width: 8px;
242
+ height: 8px;
243
+ }
244
+ [data-scrollbars='classic'] *::-webkit-scrollbar-track {
245
+ background: transparent;
246
+ }
247
+ [data-scrollbars='classic'] *::-webkit-scrollbar-thumb {
248
+ background-color: var(--border-neutral-default);
249
+ border-radius: var(--rounding-round);
250
+ border: 2px solid transparent;
251
+ background-clip: content-box;
252
+ }
253
+ [data-scrollbars='classic'] *::-webkit-scrollbar-thumb:hover {
254
+ background-color: var(--icons-neutral-idle);
255
+ }
256
+ [data-scrollbars='classic'] *::-webkit-scrollbar-corner {
257
+ background: transparent;
258
+ }