@glimstone/core 4.1.2 → 4.2.2

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/README.md CHANGED
@@ -15,9 +15,11 @@ npm install @glimstone/core
15
15
  onto Tailwind v4 utilities for an app that uses Tailwind.
16
16
  - `glyphs.json` and `glyphs.md` are the glyph assortment: which mark means
17
17
  what, where each comes from, and the sizing rules that make them one set.
18
- - The engines: `appearance` (shape, accent, rainbow, motion), `controls` (the
19
- label engine and its width stages) and `setStoragePrefix`, which keeps an
20
- app's own names for what it stores.
18
+ - The engines: `appearance` (shape, accent, rainbow, motion), `theme` (OLED
19
+ and whether dark is showing), `contrast` (the inks that hold 4.5:1),
20
+ `browserAccent` (the browser as a source for the system accent), `controls`
21
+ (the label engine and its width stages) and `setStoragePrefix`, which keeps
22
+ an app's own names for what it stores.
21
23
  - What a component shows, as functions: `buttonView`, `badgeClassName`, the
22
24
  select's list model, the Info tile's version links and update check, the
23
25
  README button's classes, where a bubble goes, a dialog's keys and the toast
@@ -26,6 +28,10 @@ npm install @glimstone/core
26
28
  say how a field looks and how wide it is, `pressRowControl` does what a click
27
29
  on a row's label does, `treeRows` and `treeStep` hold a tree's rows and keys, and
28
30
  `tableCellClass` says what a table's cell wears.
31
+ - The sign-in card's flow: `createSignInFlow` keeps what was typed and which
32
+ form shows while the app answers each step, `signInTextEn` holds the English
33
+ words, and `showRefusal` shakes the button of a refused try and selects the
34
+ refused field.
29
35
  - What windows and menus share: `windowFoot` adds the way out, `stackWindow`
30
36
  gives Escape and Tab to the window in front, and `menu.ts` holds the menu
31
37
  model with `tidyMenu`, `menuRowView`, `placeMenu`, `menuMove`,
@@ -1,3 +1,5 @@
1
+ import { contrastOn } from './contrast.ts';
2
+ export { contrastOn };
1
3
  /**
2
4
  * The corner shapes. Like the motion levels these strings are a wire format:
3
5
  * they go into `data-shape`, the stylesheet and storage, and what the user
@@ -108,15 +110,42 @@ export declare function stormTap(state: {
108
110
  taps: number;
109
111
  }, tapped: string, current: string): Motion | undefined;
110
112
  /**
111
- * Overrides the accent tokens, or clears the override so the theme's gold comes
112
- * back. The contrast colour is computed, so a light accent never gets white
113
- * text.
113
+ * The accent mode that takes its colour from the device: Material You on
114
+ * Android, the desktop's accent colour, or the browser's `AccentColor`. It is
115
+ * stored as this word, never as the colour, so a synced setting does not carry
116
+ * one device's wallpaper to the next.
117
+ */
118
+ export declare const SYSTEM_ACCENT = "system";
119
+ /**
120
+ * The colour an accent setting stands for: a hex as it is, the system mode as
121
+ * the colour the source reported, and undefined for the default, which is also
122
+ * what the system mode gives on a device without a source.
123
+ */
124
+ export declare function resolveAccent(accent: string | undefined, system: string | undefined): string | undefined;
125
+ /**
126
+ * Overrides the accent tokens with a hex or SYSTEM_ACCENT, or clears the
127
+ * override so the theme's gold comes back. The ink on the fill is computed, so
128
+ * a light accent never gets white text.
129
+ *
130
+ * `--accent-ink`, `--accent-glyph` and `--accent-hover` are derived in
131
+ * tokens.css with color-mix, so they also follow a later light or dark switch.
132
+ * Where that mix would fall short of 4.5:1 on a ramp, the ramp's ink is set
133
+ * here instead, as `--accent-ink-light` or `--accent-ink-dark`.
134
+ */
135
+ export declare function applyAccent(accent: string | undefined): void;
136
+ /**
137
+ * Feeds the colour the device reports, or undefined where it has none, and
138
+ * repaints when the accent is in the system mode. Call it again whenever the
139
+ * source changes; `watchBrowserAccent(setSystemAccent)` does that for the
140
+ * browser.
114
141
  *
115
- * `--accent-ink`, `--accent-glyph` and `--accent-hover` are not set here:
116
- * tokens.css derives them from --accent with color-mix, so they also follow a
117
- * later light or dark switch.
142
+ * The colour is remembered on this device alone, so the first frame after a
143
+ * reload is already in it.
118
144
  */
119
- export declare function applyAccent(hex: string | undefined): void;
145
+ export declare function setSystemAccent(hex: string | undefined): void;
146
+ /** The device's colour, or undefined where there is none, so the wand swatch stays hidden. */
147
+ export declare function systemAccent(): string | undefined;
148
+ export declare function subscribeSystemAccent(fn: () => void): () => void;
120
149
  /** The current snapshot, with a stable identity between changes. */
121
150
  export declare function rainbowState(): RainbowState;
122
151
  export declare function subscribeRainbow(fn: () => void): () => void;
@@ -169,8 +198,7 @@ export declare function rainbowFromSettings(s: {
169
198
  rainbowSeed?: number;
170
199
  rainbowPalette?: string[] | null;
171
200
  }): RainbowState;
172
- /** Black or white, whichever is readable on the given colour. */
173
- export declare function contrastOn(hex: string): string;
201
+ /** `accent` is the setting, so the system mode is cached as SYSTEM_ACCENT. */
174
202
  export declare function cacheAppearance(shape: string, accent: string, rainbow?: RainbowState): void;
175
203
  /** Applied at boot, before the app renders anything. */
176
204
  export declare function applyCachedAppearance(): void;
@@ -4,7 +4,11 @@
4
4
  // so every component picks it up through the tokens it already reads.
5
5
  //
6
6
  // It has no framework dependency; the React and Vue packages wrap it.
7
+ import { accentInks, contrastOn, validHex as valid } from './contrast.js';
7
8
  import { storageKey } from './storage.js';
9
+ import { applyOled, storedOled } from './theme.js';
10
+ // Also reachable through the @glimstone/core/appearance subpath.
11
+ export { contrastOn };
8
12
  /** The shapes a picker shows. */
9
13
  export const SHAPES = ['round', 'soft', 'square'];
10
14
  /**
@@ -140,26 +144,94 @@ export function stormTap(state, tapped, current) {
140
144
  * animations: `.glim-live`'s pulse keeps its stop at every level.
141
145
  */
142
146
  /**
143
- * Overrides the accent tokens, or clears the override so the theme's gold comes
144
- * back. The contrast colour is computed, so a light accent never gets white
145
- * text.
147
+ * The accent mode that takes its colour from the device: Material You on
148
+ * Android, the desktop's accent colour, or the browser's `AccentColor`. It is
149
+ * stored as this word, never as the colour, so a synced setting does not carry
150
+ * one device's wallpaper to the next.
151
+ */
152
+ export const SYSTEM_ACCENT = 'system';
153
+ /**
154
+ * The colour an accent setting stands for: a hex as it is, the system mode as
155
+ * the colour the source reported, and undefined for the default, which is also
156
+ * what the system mode gives on a device without a source.
157
+ */
158
+ export function resolveAccent(accent, system) {
159
+ if (accent === SYSTEM_ACCENT)
160
+ return valid(system) ? system : undefined;
161
+ return valid(accent) ? accent : undefined;
162
+ }
163
+ let accentSetting;
164
+ let systemColour;
165
+ const systemListeners = new Set();
166
+ /**
167
+ * Overrides the accent tokens with a hex or SYSTEM_ACCENT, or clears the
168
+ * override so the theme's gold comes back. The ink on the fill is computed, so
169
+ * a light accent never gets white text.
146
170
  *
147
- * `--accent-ink`, `--accent-glyph` and `--accent-hover` are not set here:
148
- * tokens.css derives them from --accent with color-mix, so they also follow a
149
- * later light or dark switch.
171
+ * `--accent-ink`, `--accent-glyph` and `--accent-hover` are derived in
172
+ * tokens.css with color-mix, so they also follow a later light or dark switch.
173
+ * Where that mix would fall short of 4.5:1 on a ramp, the ramp's ink is set
174
+ * here instead, as `--accent-ink-light` or `--accent-ink-dark`.
150
175
  */
151
- export function applyAccent(hex) {
176
+ export function applyAccent(accent) {
177
+ accentSetting = accent;
178
+ paintAccent(resolveAccent(accent, systemColour));
179
+ }
180
+ function paintAccent(hex) {
152
181
  const root = document.documentElement.style;
182
+ const inks = valid(hex) ? accentInks(hex) : { light: undefined, dark: undefined };
183
+ for (const [ramp, ink] of Object.entries(inks)) {
184
+ if (ink)
185
+ root.setProperty(`--accent-ink-${ramp}`, ink);
186
+ else
187
+ root.removeProperty(`--accent-ink-${ramp}`);
188
+ }
153
189
  if (!valid(hex)) {
154
190
  root.removeProperty('--accent');
155
191
  root.removeProperty('--accent-contrast');
156
192
  root.removeProperty('--accent-soft');
157
193
  return;
158
194
  }
159
- const { r, g, b } = parse(hex);
195
+ const n = parseInt(hex.slice(1), 16);
160
196
  root.setProperty('--accent', hex);
161
197
  root.setProperty('--accent-contrast', contrastOn(hex));
162
- root.setProperty('--accent-soft', `rgba(${r}, ${g}, ${b}, 0.14)`);
198
+ root.setProperty('--accent-soft', `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.14)`);
199
+ }
200
+ /**
201
+ * Feeds the colour the device reports, or undefined where it has none, and
202
+ * repaints when the accent is in the system mode. Call it again whenever the
203
+ * source changes; `watchBrowserAccent(setSystemAccent)` does that for the
204
+ * browser.
205
+ *
206
+ * The colour is remembered on this device alone, so the first frame after a
207
+ * reload is already in it.
208
+ */
209
+ export function setSystemAccent(hex) {
210
+ const next = valid(hex) ? hex : undefined;
211
+ if (next === systemColour)
212
+ return;
213
+ systemColour = next;
214
+ try {
215
+ if (next)
216
+ localStorage.setItem(storageKey('system-accent'), next);
217
+ else
218
+ localStorage.removeItem(storageKey('system-accent'));
219
+ }
220
+ catch {
221
+ // Without storage the default shows until the source answers.
222
+ }
223
+ if (accentSetting === SYSTEM_ACCENT)
224
+ paintAccent(next);
225
+ for (const fn of systemListeners)
226
+ fn();
227
+ }
228
+ /** The device's colour, or undefined where there is none, so the wand swatch stays hidden. */
229
+ export function systemAccent() {
230
+ return systemColour;
231
+ }
232
+ export function subscribeSystemAccent(fn) {
233
+ systemListeners.add(fn);
234
+ return () => systemListeners.delete(fn);
163
235
  }
164
236
  // The rainbow state belongs to the document, not a component: the sidebar and a
165
237
  // list far away in the tree must agree on which colour position three is, so
@@ -285,32 +357,7 @@ function usablePalette(p) {
285
357
  return RAINBOW;
286
358
  return p;
287
359
  }
288
- /** Black or white, whichever is readable on the given colour. */
289
- export function contrastOn(hex) {
290
- if (!valid(hex))
291
- return '#FFFFFF';
292
- const { r, g, b } = parse(hex);
293
- // Carbon's own ink: on a yellow accent a brown-tinted black reads as a smudge.
294
- return luminance(r, g, b) > 0.55 ? '#161616' : '#FFFFFF';
295
- }
296
- function valid(hex) {
297
- return !!hex && /^#[0-9a-fA-F]{6}$/.test(hex);
298
- }
299
- function parse(hex) {
300
- const n = parseInt(hex.slice(1), 16);
301
- return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
302
- }
303
- /**
304
- * Relative luminance, used to pick black or white on top. The channels are
305
- * linearised first because raw sRGB overstates blue and understates green.
306
- */
307
- function luminance(r, g, b) {
308
- const lin = (c) => {
309
- const v = c / 255;
310
- return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
311
- };
312
- return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
313
- }
360
+ /** `accent` is the setting, so the system mode is cached as SYSTEM_ACCENT. */
314
361
  export function cacheAppearance(shape, accent, rainbow) {
315
362
  try {
316
363
  // The conditional spread omits the key under exactOptionalPropertyTypes
@@ -324,7 +371,11 @@ export function cacheAppearance(shape, accent, rainbow) {
324
371
  }
325
372
  /** Applied at boot, before the app renders anything. */
326
373
  export function applyCachedAppearance() {
374
+ applyOled(storedOled());
327
375
  try {
376
+ const system = localStorage.getItem(storageKey('system-accent')) ?? undefined;
377
+ if (valid(system))
378
+ systemColour = system;
328
379
  const raw = localStorage.getItem(storageKey('appearance'));
329
380
  if (!raw) {
330
381
  applyShape(DEFAULT_SHAPE);
@@ -0,0 +1,12 @@
1
+ /** Whether `AccentColor` carries the system's colour in this browser. */
2
+ export declare function browserAccentWorks(): boolean;
3
+ /** The system's accent as the browser reports it, or undefined where it is no source. */
4
+ export declare function browserAccent(): string | undefined;
5
+ /** A computed `rgb()` or `rgba()` colour as `#RRGGBB`, or undefined for anything else. */
6
+ export declare function cssColourToHex(colour: string): string | undefined;
7
+ /**
8
+ * Calls `onChange` with the browser's accent now and whenever it has changed
9
+ * on the window's return, so `watchBrowserAccent(setSystemAccent)` keeps the
10
+ * system mode current. Returns the function that stops watching.
11
+ */
12
+ export declare function watchBrowserAccent(onChange: (hex: string | undefined) => void): () => void;
@@ -0,0 +1,66 @@
1
+ // The browser as a source for the system accent: the CSS system colour
2
+ // `AccentColor`, read off a probe element. An app inside a native shell feeds
3
+ // setSystemAccent from the shell instead (Material You on Android, the desktop
4
+ // accent in a desktop shell); this is for the plain web.
5
+ //
6
+ // Limits, all of them the browser's:
7
+ // · Firefox and Safari resolve AccentColor to the system's colour. Chromium
8
+ // resolves it to a fixed colour of its own, whatever the system uses, so
9
+ // Chrome, Edge, Opera and every other Chromium browser count as no source.
10
+ // · Where the system has no accent of its own, as on Linux without a theme
11
+ // that names one or on iOS, the browser answers with its default.
12
+ // · Firefox with resistFingerprinting answers with a fixed colour too, and
13
+ // nothing on the page can tell.
14
+ // · No event fires when the system's accent changes, so the colour is read
15
+ // again when the window comes back to the front, which is when somebody
16
+ // returns from the system's settings.
17
+ /** Whether `AccentColor` carries the system's colour in this browser. */
18
+ export function browserAccentWorks() {
19
+ if (typeof CSS === 'undefined' || !CSS.supports('color', 'AccentColor'))
20
+ return false;
21
+ // No word boundary, so a headless Chrome's `HeadlessChrome/` counts as well.
22
+ return !/Chrom(e|ium)\//.test(navigator.userAgent);
23
+ }
24
+ /** The system's accent as the browser reports it, or undefined where it is no source. */
25
+ export function browserAccent() {
26
+ if (!browserAccentWorks())
27
+ return undefined;
28
+ const probe = document.createElement('span');
29
+ probe.style.color = 'AccentColor';
30
+ probe.style.display = 'none';
31
+ document.documentElement.append(probe);
32
+ const colour = getComputedStyle(probe).color;
33
+ probe.remove();
34
+ return cssColourToHex(colour);
35
+ }
36
+ /** A computed `rgb()` or `rgba()` colour as `#RRGGBB`, or undefined for anything else. */
37
+ export function cssColourToHex(colour) {
38
+ const m = colour.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
39
+ if (!m)
40
+ return undefined;
41
+ return '#' + m.slice(1, 4).map((v) => Number(v).toString(16).padStart(2, '0')).join('').toUpperCase();
42
+ }
43
+ /**
44
+ * Calls `onChange` with the browser's accent now and whenever it has changed
45
+ * on the window's return, so `watchBrowserAccent(setSystemAccent)` keeps the
46
+ * system mode current. Returns the function that stops watching.
47
+ */
48
+ export function watchBrowserAccent(onChange) {
49
+ let last = browserAccent();
50
+ onChange(last);
51
+ const check = () => {
52
+ if (document.visibilityState === 'hidden')
53
+ return;
54
+ const next = browserAccent();
55
+ if (next === last)
56
+ return;
57
+ last = next;
58
+ onChange(next);
59
+ };
60
+ addEventListener('focus', check);
61
+ document.addEventListener('visibilitychange', check);
62
+ return () => {
63
+ removeEventListener('focus', check);
64
+ document.removeEventListener('visibilitychange', check);
65
+ };
66
+ }
@@ -0,0 +1,26 @@
1
+ /** The ratio body text needs, and the floor of every ink computed here. */
2
+ export declare const MIN_TEXT_CONTRAST = 4.5;
3
+ export declare function validHex(hex: string | undefined): hex is string;
4
+ /** The WCAG contrast ratio of two `#rrggbb` colours, from 1 to 21. */
5
+ export declare function contrastRatio(a: string, b: string): number;
6
+ /**
7
+ * The ink for text or a glyph on a fill of the given colour: white or Carbon's
8
+ * near-black, whichever reads better. On the mid-tones where neither reaches
9
+ * 4.5:1, the near-black goes on toward black until it does, which it always
10
+ * can.
11
+ */
12
+ export declare function contrastOn(hex: string): string;
13
+ /**
14
+ * The accent used as ink on each ramp, where the theme's own derivation in
15
+ * tokens.css falls short of 4.5:1 on its card. Undefined for a ramp means the
16
+ * stylesheet's mix holds and nothing needs setting, which is the case for
17
+ * every preset.
18
+ *
19
+ * The light ink starts from the stylesheet's mix toward black and goes on
20
+ * toward black; the dark ink starts from the accent itself and goes toward
21
+ * white.
22
+ */
23
+ export declare function accentInks(hex: string): {
24
+ light: string | undefined;
25
+ dark: string | undefined;
26
+ };
@@ -0,0 +1,107 @@
1
+ // The contrast arithmetic of WCAG 2, and the inks the colour engine computes
2
+ // with it. Every ink it hands out reaches 4.5:1, the minimum for body text,
3
+ // whatever colour somebody picked or the system supplied.
4
+ /** The ratio body text needs, and the floor of every ink computed here. */
5
+ export const MIN_TEXT_CONTRAST = 4.5;
6
+ // Carbon's own ink: on a yellow accent a brown-tinted black reads as a smudge.
7
+ const DARK_INK = '#161616';
8
+ const WHITE = '#FFFFFF';
9
+ const BLACK = '#000000';
10
+ // The card surface of each ramp in tokens.css, where accent text mostly sits.
11
+ // The presets were measured against these, and OLED's darker card only adds
12
+ // to the dark figure, so the dark ink holds there as well.
13
+ const LIGHT_CARD = '#FFFFFF';
14
+ const DARK_CARD = '#262626';
15
+ // --ink-mix of the light ramp in tokens.css.
16
+ const LIGHT_INK_MIX = 0.55;
17
+ export function validHex(hex) {
18
+ return !!hex && /^#[0-9a-fA-F]{6}$/.test(hex);
19
+ }
20
+ function rgb(hex) {
21
+ const n = parseInt(hex.slice(1), 16);
22
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
23
+ }
24
+ function hexOf(c) {
25
+ return '#' + c.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('').toUpperCase();
26
+ }
27
+ /** `amount` of `to` in `from`, the way color-mix() in srgb mixes. */
28
+ function mix(from, to, amount) {
29
+ return [0, 1, 2].map((i) => from[i] + (to[i] - from[i]) * amount);
30
+ }
31
+ /**
32
+ * Relative luminance. The channels are linearised first because raw sRGB
33
+ * overstates blue and understates green.
34
+ */
35
+ function luminance(c) {
36
+ const lin = (v) => {
37
+ const s = v / 255;
38
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
39
+ };
40
+ return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
41
+ }
42
+ function ratio(a, b) {
43
+ const x = luminance(a);
44
+ const y = luminance(b);
45
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
46
+ }
47
+ /** The WCAG contrast ratio of two `#rrggbb` colours, from 1 to 21. */
48
+ export function contrastRatio(a, b) {
49
+ return ratio(rgb(a), rgb(b));
50
+ }
51
+ /**
52
+ * `from` moved toward `to` just far enough to reach the minimum against
53
+ * `ground`, as a hex colour. The ratio only grows on the way, as long as `to`
54
+ * lies on the far side of `from` from the ground, so halving finds the least
55
+ * amount that does it.
56
+ */
57
+ function pushed(from, to, ground) {
58
+ const reaches = (t) => ratio(mix(from, to, t).map(Math.round), ground) >= MIN_TEXT_CONTRAST;
59
+ let lo = 0;
60
+ let hi = 1;
61
+ for (let i = 0; i < 16; i++) {
62
+ const mid = (lo + hi) / 2;
63
+ if (reaches(mid))
64
+ hi = mid;
65
+ else
66
+ lo = mid;
67
+ }
68
+ return hexOf(mix(from, to, hi));
69
+ }
70
+ /**
71
+ * The ink for text or a glyph on a fill of the given colour: white or Carbon's
72
+ * near-black, whichever reads better. On the mid-tones where neither reaches
73
+ * 4.5:1, the near-black goes on toward black until it does, which it always
74
+ * can.
75
+ */
76
+ export function contrastOn(hex) {
77
+ if (!validHex(hex))
78
+ return WHITE;
79
+ const fill = rgb(hex);
80
+ const onWhite = ratio(fill, rgb(WHITE));
81
+ const onDark = ratio(fill, rgb(DARK_INK));
82
+ if (onWhite > onDark && onWhite >= MIN_TEXT_CONTRAST)
83
+ return WHITE;
84
+ if (onDark >= MIN_TEXT_CONTRAST)
85
+ return DARK_INK;
86
+ return pushed(rgb(DARK_INK), rgb(BLACK), fill);
87
+ }
88
+ /**
89
+ * The accent used as ink on each ramp, where the theme's own derivation in
90
+ * tokens.css falls short of 4.5:1 on its card. Undefined for a ramp means the
91
+ * stylesheet's mix holds and nothing needs setting, which is the case for
92
+ * every preset.
93
+ *
94
+ * The light ink starts from the stylesheet's mix toward black and goes on
95
+ * toward black; the dark ink starts from the accent itself and goes toward
96
+ * white.
97
+ */
98
+ export function accentInks(hex) {
99
+ if (!validHex(hex))
100
+ return { light: undefined, dark: undefined };
101
+ const accent = rgb(hex);
102
+ const light = mix(accent, rgb(BLACK), 1 - LIGHT_INK_MIX);
103
+ return {
104
+ light: ratio(light, rgb(LIGHT_CARD)) >= MIN_TEXT_CONTRAST ? undefined : pushed(light, rgb(BLACK), rgb(LIGHT_CARD)),
105
+ dark: ratio(accent, rgb(DARK_CARD)) >= MIN_TEXT_CONTRAST ? undefined : pushed(accent, rgb(WHITE), rgb(DARK_CARD)),
106
+ };
107
+ }
package/dist/disco.js CHANGED
@@ -10,7 +10,8 @@
10
10
  // hidden mode somebody found on purpose is not a default anybody inherited.
11
11
  // The glide itself follows the motion engine: with reduced motion, or at the
12
12
  // "off" level, disco steps one colour at a time instead.
13
- import { applyRainbow, contrastOn, rainbowState, RAINBOW } from './appearance.js';
13
+ import { applyRainbow, rainbowState, RAINBOW } from './appearance.js';
14
+ import { contrastOn } from './contrast.js';
14
15
  import { buildLoop, colourAt } from './discoLoop.js';
15
16
  /** The walk covers one palette colour's worth of loop every 2.4 seconds, so a
16
17
  * full turn of eight colours takes 19.2 seconds. Stepping, it moves one
package/dist/index.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  export * from "./appearance.ts";
2
2
  export * from "./appMarks.ts";
3
3
  export * from "./badge.ts";
4
+ export * from "./browserAccent.ts";
4
5
  export * from "./bubblePosition.ts";
5
6
  export * from "./button.ts";
6
7
  export * from "./clipboard.ts";
7
8
  export * from "./colorNames.ts";
8
9
  export * from "./colorPicker.ts";
10
+ export * from "./contrast.ts";
9
11
  export * from "./controls.ts";
10
12
  export * from "./dialog.ts";
11
13
  export * from "./disco.ts";
@@ -30,9 +32,11 @@ export * from "./selector.ts";
30
32
  export * from "./selectModel.ts";
31
33
  export * from "./selectScroll.ts";
32
34
  export * from "./sidebarRow.ts";
35
+ export * from "./signIn.ts";
33
36
  export * from "./storage.ts";
34
37
  export * from "./tipBubble.ts";
35
38
  export * from "./table.ts";
39
+ export * from "./theme.ts";
36
40
  export * from "./toastEngine.ts";
37
41
  export * from "./tooltip.ts";
38
42
  export * from "./tree.ts";
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  export * from "./appearance.js";
2
2
  export * from "./appMarks.js";
3
3
  export * from "./badge.js";
4
+ export * from "./browserAccent.js";
4
5
  export * from "./bubblePosition.js";
5
6
  export * from "./button.js";
6
7
  export * from "./clipboard.js";
7
8
  export * from "./colorNames.js";
8
9
  export * from "./colorPicker.js";
10
+ export * from "./contrast.js";
9
11
  export * from "./controls.js";
10
12
  export * from "./dialog.js";
11
13
  export * from "./disco.js";
@@ -30,9 +32,11 @@ export * from "./selector.js";
30
32
  export * from "./selectModel.js";
31
33
  export * from "./selectScroll.js";
32
34
  export * from "./sidebarRow.js";
35
+ export * from "./signIn.js";
33
36
  export * from "./storage.js";
34
37
  export * from "./tipBubble.js";
35
38
  export * from "./table.js";
39
+ export * from "./theme.js";
36
40
  export * from "./toastEngine.js";
37
41
  export * from "./tooltip.js";
38
42
  export * from "./tree.js";
@@ -0,0 +1,136 @@
1
+ /** The card's words, translated by the app. */
2
+ export interface SignInText {
3
+ password: string;
4
+ signIn: string;
5
+ /** The bubble on Sign in while it works. */
6
+ signingIn: string;
7
+ passkey: string;
8
+ /** "Forgot password?", with the question mark. */
9
+ forgot: string;
10
+ /** The label of the second step's code field. */
11
+ code: string;
12
+ /** The line under the code field, such as where the code comes from. */
13
+ codeHint: string;
14
+ /** Said when the app refuses a code without a reason of its own. */
15
+ codeInvalid: string;
16
+ back: string;
17
+ /** The label of the reset form's code field. */
18
+ resetCode: string;
19
+ /** The (i) beside it: where the log is, how long the code lasts, what a reset removes. */
20
+ resetCodeTip: string;
21
+ newPassword: string;
22
+ confirmPassword: string;
23
+ setPassword: string;
24
+ /** The bubble on Set password while it works. */
25
+ saving: string;
26
+ mismatch: string;
27
+ /** Says how many characters a new password needs, with the number filled in. */
28
+ tooShort: string;
29
+ /** Said when the app refuses the code from the log. */
30
+ resetRejected: string;
31
+ /** Said when a callback throws or answers without a reason. */
32
+ failed: string;
33
+ /** The reveal eye's name while the secret is hidden, and once it shows. */
34
+ show: string;
35
+ hide: string;
36
+ }
37
+ /** English words, for an app in English and for the demos. */
38
+ export declare const signInTextEn: SignInText;
39
+ /**
40
+ * What the app answers to a sign-in or a passkey. `ok` means the card's part is
41
+ * done: the app signs in or shows a step of its own. `needCode` asks for the
42
+ * second step, and `error` is said above Sign in.
43
+ */
44
+ export interface SignInAnswer {
45
+ ok?: boolean;
46
+ needCode?: boolean;
47
+ error?: string;
48
+ }
49
+ /** What the app answers to asking for a reset code or to setting the password. `rejected` means the code from the log was refused. */
50
+ export interface ResetAnswer {
51
+ ok?: boolean;
52
+ rejected?: boolean;
53
+ error?: string;
54
+ }
55
+ export interface SignInHandlers {
56
+ /** `code` is set once the app asked for one, and `passkey` when a passkey started the sign-in. */
57
+ onSignIn: (input: {
58
+ password: string;
59
+ code?: string;
60
+ passkey: boolean;
61
+ }) => Promise<SignInAnswer>;
62
+ /** An empty answer leaves the card as it was, for a prompt the person closed. */
63
+ onPasskey?: () => Promise<SignInAnswer>;
64
+ /** Writes a code to the log. The reset form shows only once this answers ok. */
65
+ onAskCode?: () => Promise<ResetAnswer>;
66
+ onSetPassword?: (input: {
67
+ code: string;
68
+ password: string;
69
+ }) => Promise<ResetAnswer>;
70
+ }
71
+ export type SignInField = "password" | "code" | "resetCode" | "newPassword" | "confirm";
72
+ /** The card's buttons, by their `data-signin` hook. */
73
+ export type SignInButton = "submit" | "set-password" | "passkey" | "forgot";
74
+ /**
75
+ * The last refused step: the button that shakes and the field that takes the
76
+ * focus with its content selected. `count` grows with every refusal, so the
77
+ * same refusal twice still shakes twice.
78
+ */
79
+ export interface SignInRefusal {
80
+ button: SignInButton;
81
+ field: SignInField | null;
82
+ count: number;
83
+ }
84
+ export interface SignInState {
85
+ view: "signIn" | "reset";
86
+ password: string;
87
+ code: string;
88
+ needCode: boolean;
89
+ /** A passkey started this sign-in, so the code goes without the password. */
90
+ viaPasskey: boolean;
91
+ resetCode: string;
92
+ newPassword: string;
93
+ confirm: string;
94
+ error: string | null;
95
+ refusal: SignInRefusal | null;
96
+ busy: "signIn" | "passkey" | "askCode" | "setPassword" | null;
97
+ }
98
+ export interface SignInOptions extends SignInHandlers {
99
+ text: SignInText;
100
+ /** The shortest new password the reset form takes. */
101
+ minPassword: number;
102
+ }
103
+ export interface SignInFlow {
104
+ state(): SignInState;
105
+ subscribe(listener: () => void): () => void;
106
+ set(field: SignInField, value: string): void;
107
+ submit(): Promise<void>;
108
+ passkey(): Promise<void>;
109
+ forgot(): Promise<void>;
110
+ back(): void;
111
+ }
112
+ /**
113
+ * The flow behind one card. `options` is read at every step, so a framework
114
+ * hands in its current props rather than the ones the card was made with.
115
+ */
116
+ export declare function createSignInFlow(options: () => SignInOptions): SignInFlow;
117
+ /** Sign in waits for the password, and in the second step for the code. */
118
+ export declare function signInBlocked(s: SignInState): boolean;
119
+ /** Set password waits for the code from the log and a new password. */
120
+ export declare function resetBlocked(s: SignInState): boolean;
121
+ /**
122
+ * Which form shows, as a key that changes whenever the focus should move to
123
+ * the first empty field of the new form.
124
+ */
125
+ export declare function signInStage(s: SignInState): string;
126
+ /**
127
+ * Puts the focus in the first empty field inside `root`, or in its first field
128
+ * when all hold something, such as a name the app filled in.
129
+ */
130
+ export declare function focusFirstField(root: HTMLElement | null): void;
131
+ /**
132
+ * Answers a refusal on screen: the button shakes once (`glim-shake`, which the
133
+ * motion engine stills) and the field takes the focus with its content
134
+ * selected, so the next try starts by typing. Returns the shake's cleanup.
135
+ */
136
+ export declare function showRefusal(root: HTMLElement | null, refusal: SignInRefusal): () => void;
package/dist/signIn.js ADDED
@@ -0,0 +1,225 @@
1
+ // The sign-in card's flow without a framework (docs/design-language.md, "The
2
+ // sign-in card"). The app answers each step through a callback, the flow keeps
3
+ // what was typed and which form shows, and the React and Vue cards draw it.
4
+ import { shake } from "./selectModel.js";
5
+ /** English words, for an app in English and for the demos. */
6
+ export const signInTextEn = {
7
+ password: "Password",
8
+ signIn: "Sign in",
9
+ signingIn: "Signing in…",
10
+ passkey: "Sign in with a passkey",
11
+ forgot: "Forgot password?",
12
+ code: "Code",
13
+ codeHint: "Six digits from your authenticator app, or one of your recovery codes.",
14
+ codeInvalid: "That code is not valid.",
15
+ back: "Back",
16
+ resetCode: "Code from the container log",
17
+ resetCodeTip: "The app has written a code to its container log. On Unraid: Docker tab, the app's icon, Logs. A code is valid for 10 minutes.",
18
+ newPassword: "New password",
19
+ confirmPassword: "Confirm password",
20
+ setPassword: "Set password",
21
+ saving: "Saving…",
22
+ mismatch: "The two passwords are not the same.",
23
+ tooShort: "The new password needs at least 12 characters.",
24
+ resetRejected: "That code is wrong or has expired. If it expired, the log now shows a new one.",
25
+ failed: "That did not work. Please try again.",
26
+ show: "Show",
27
+ hide: "Hide",
28
+ };
29
+ const START = {
30
+ view: "signIn",
31
+ password: "",
32
+ code: "",
33
+ needCode: false,
34
+ viaPasskey: false,
35
+ resetCode: "",
36
+ newPassword: "",
37
+ confirm: "",
38
+ error: null,
39
+ refusal: null,
40
+ busy: null,
41
+ };
42
+ const BUTTON_OF = {
43
+ signIn: "submit",
44
+ setPassword: "set-password",
45
+ passkey: "passkey",
46
+ askCode: "forgot",
47
+ };
48
+ /**
49
+ * The flow behind one card. `options` is read at every step, so a framework
50
+ * hands in its current props rather than the ones the card was made with.
51
+ */
52
+ export function createSignInFlow(options) {
53
+ let state = START;
54
+ const listeners = new Set();
55
+ const update = (change) => {
56
+ state = { ...state, ...change };
57
+ listeners.forEach((fn) => fn());
58
+ };
59
+ // A refusal during a step waits for the step to end: the busy button wears a
60
+ // bubble that wraps it, and the shake has to land on the button that stays.
61
+ let pending = {};
62
+ const refuse = (error, button, field = null) => {
63
+ const change = { error, refusal: { button, field, count: (state.refusal?.count ?? 0) + 1 } };
64
+ if (state.busy)
65
+ pending = change;
66
+ else
67
+ update(change);
68
+ };
69
+ async function run(busy, work) {
70
+ if (state.busy)
71
+ return;
72
+ update({ busy, error: null });
73
+ pending = {};
74
+ try {
75
+ await work();
76
+ }
77
+ catch {
78
+ refuse(options().text.failed, BUTTON_OF[busy]);
79
+ }
80
+ finally {
81
+ update({ busy: null, ...pending });
82
+ }
83
+ }
84
+ async function signIn() {
85
+ if (signInBlocked(state))
86
+ return;
87
+ const { text, onSignIn } = options();
88
+ return run("signIn", async () => {
89
+ const tried = state.needCode;
90
+ const res = await onSignIn({
91
+ password: state.viaPasskey ? "" : state.password,
92
+ code: tried ? state.code.trim() : undefined,
93
+ passkey: state.viaPasskey,
94
+ });
95
+ if (res.ok)
96
+ return;
97
+ if (res.needCode) {
98
+ // The first request for a code only opens the field.
99
+ update({ needCode: true, code: "" });
100
+ if (tried)
101
+ refuse(res.error ?? text.codeInvalid, "submit", "code");
102
+ return;
103
+ }
104
+ // A passkey's sign-in that ended starts over with the password.
105
+ if (state.viaPasskey)
106
+ update({ viaPasskey: false, needCode: false, code: "" });
107
+ refuse(res.error ?? text.failed, "submit", state.needCode ? "code" : "password");
108
+ });
109
+ }
110
+ async function setPassword() {
111
+ const { text, minPassword, onSetPassword, onAskCode } = options();
112
+ if (resetBlocked(state) || !onSetPassword)
113
+ return;
114
+ if (state.newPassword !== state.confirm)
115
+ return refuse(text.mismatch, "set-password", "confirm");
116
+ if ([...state.newPassword].length < minPassword)
117
+ return refuse(text.tooShort, "set-password", "newPassword");
118
+ return run("setPassword", async () => {
119
+ const res = await onSetPassword({ code: state.resetCode.trim(), password: state.newPassword });
120
+ if (res.ok)
121
+ return;
122
+ if (res.rejected) {
123
+ // The answer does not say whether the code expired, and the sentence
124
+ // promises a new one in that case. Asking writes one only when none is live.
125
+ await onAskCode?.().catch(() => undefined);
126
+ refuse(text.resetRejected, "set-password", "resetCode");
127
+ return;
128
+ }
129
+ refuse(res.error ?? text.failed, "set-password", "resetCode");
130
+ });
131
+ }
132
+ return {
133
+ state: () => state,
134
+ subscribe(listener) {
135
+ listeners.add(listener);
136
+ return () => listeners.delete(listener);
137
+ },
138
+ set(field, value) {
139
+ update({ [field]: value });
140
+ },
141
+ submit: () => (state.view === "reset" ? setPassword() : signIn()),
142
+ async passkey() {
143
+ const { onPasskey } = options();
144
+ if (!onPasskey)
145
+ return;
146
+ return run("passkey", async () => {
147
+ const res = await onPasskey();
148
+ if (res.ok)
149
+ return;
150
+ if (res.needCode)
151
+ update({ viaPasskey: true, needCode: true, code: "" });
152
+ else if (res.error !== undefined)
153
+ refuse(res.error, "passkey");
154
+ });
155
+ },
156
+ async forgot() {
157
+ const { text, onAskCode } = options();
158
+ if (!onAskCode)
159
+ return;
160
+ return run("askCode", async () => {
161
+ const res = await onAskCode();
162
+ if (res.ok)
163
+ update({ view: "reset", resetCode: "", newPassword: "", confirm: "" });
164
+ else
165
+ refuse(res.error ?? text.failed, "forgot");
166
+ });
167
+ },
168
+ back() {
169
+ if (state.busy)
170
+ return;
171
+ update({ view: "signIn", needCode: false, viaPasskey: false, code: "", error: null });
172
+ },
173
+ };
174
+ }
175
+ /** Sign in waits for the password, and in the second step for the code. */
176
+ export function signInBlocked(s) {
177
+ if (s.busy)
178
+ return true;
179
+ if (s.needCode)
180
+ return s.code.trim() === "";
181
+ return s.password === "";
182
+ }
183
+ /** Set password waits for the code from the log and a new password. */
184
+ export function resetBlocked(s) {
185
+ return !!s.busy || s.resetCode.trim() === "" || s.newPassword === "";
186
+ }
187
+ /**
188
+ * Which form shows, as a key that changes whenever the focus should move to
189
+ * the first empty field of the new form.
190
+ */
191
+ export function signInStage(s) {
192
+ return `${s.view}:${s.needCode}:${s.viaPasskey}`;
193
+ }
194
+ // Walks up rather than reading the layout, which a page without one, such as a test's, does not have.
195
+ function shown(el) {
196
+ for (let e = el; e; e = e.parentElement)
197
+ if (getComputedStyle(e).display === "none")
198
+ return false;
199
+ return true;
200
+ }
201
+ const FIELDS = 'input:not([type="hidden"], [type="checkbox"], [type="radio"], :disabled), textarea:not(:disabled)';
202
+ /**
203
+ * Puts the focus in the first empty field inside `root`, or in its first field
204
+ * when all hold something, such as a name the app filled in.
205
+ */
206
+ export function focusFirstField(root) {
207
+ if (!root)
208
+ return;
209
+ const fields = [...root.querySelectorAll(FIELDS)].filter(shown);
210
+ (fields.find((f) => f.value === "") ?? fields[0])?.focus({ preventScroll: true });
211
+ }
212
+ /**
213
+ * Answers a refusal on screen: the button shakes once (`glim-shake`, which the
214
+ * motion engine stills) and the field takes the focus with its content
215
+ * selected, so the next try starts by typing. Returns the shake's cleanup.
216
+ */
217
+ export function showRefusal(root, refusal) {
218
+ const field = refusal.field && root?.querySelector(`[data-signin-field="${refusal.field}"]`);
219
+ if (field) {
220
+ field.focus({ preventScroll: true });
221
+ field.select();
222
+ }
223
+ const button = root?.querySelector(`[data-signin="${refusal.button}"]`);
224
+ return button ? shake(button) : () => { };
225
+ }
@@ -0,0 +1,14 @@
1
+ /** Whether the dark ramp shows for a `data-theme` value and the system's preference. */
2
+ export declare function resolvesDark(theme: string | null | undefined, systemDark: boolean): boolean;
3
+ /** Whether the dark ramp is showing now, by choice or because the system is dark. */
4
+ export declare function darkShowing(): boolean;
5
+ /**
6
+ * Calls `fn` whenever the dark ramp starts or stops showing, through a theme
7
+ * choice or the system, so a settings page can say whether OLED is in effect.
8
+ * Returns the function that stops watching.
9
+ */
10
+ export declare function watchDarkShowing(fn: (dark: boolean) => void): () => void;
11
+ /** Turns OLED on or off and remembers it on this device. */
12
+ export declare function applyOled(on: boolean): void;
13
+ /** Whether OLED is on for this device. */
14
+ export declare function storedOled(): boolean;
package/dist/theme.js ADDED
@@ -0,0 +1,71 @@
1
+ // Which ramp is showing, and the OLED variant of the dark one. The theme
2
+ // itself is `data-theme` on <html>, `light`, `dark` or unset for the system.
3
+ // OLED is `data-oled` on <html>, present or absent; tokens.css applies it only
4
+ // while the dark ramp shows, so it can stay on through a switch to light.
5
+ //
6
+ // OLED belongs to the screen, not the person, so it is kept on the device and
7
+ // never with settings a server syncs: the same account may sign in on an LCD
8
+ // laptop.
9
+ import { storageKey } from './storage.js';
10
+ const SYSTEM_DARK = '(prefers-color-scheme: dark)';
11
+ /** Whether the dark ramp shows for a `data-theme` value and the system's preference. */
12
+ export function resolvesDark(theme, systemDark) {
13
+ if (theme === 'dark')
14
+ return true;
15
+ if (theme === 'light')
16
+ return false;
17
+ return systemDark;
18
+ }
19
+ /** Whether the dark ramp is showing now, by choice or because the system is dark. */
20
+ export function darkShowing() {
21
+ return resolvesDark(document.documentElement.getAttribute('data-theme'), matchMedia(SYSTEM_DARK).matches);
22
+ }
23
+ /**
24
+ * Calls `fn` whenever the dark ramp starts or stops showing, through a theme
25
+ * choice or the system, so a settings page can say whether OLED is in effect.
26
+ * Returns the function that stops watching.
27
+ */
28
+ export function watchDarkShowing(fn) {
29
+ let last = darkShowing();
30
+ const check = () => {
31
+ const next = darkShowing();
32
+ if (next === last)
33
+ return;
34
+ last = next;
35
+ fn(next);
36
+ };
37
+ const media = matchMedia(SYSTEM_DARK);
38
+ media.addEventListener('change', check);
39
+ const observer = new MutationObserver(check);
40
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
41
+ return () => {
42
+ media.removeEventListener('change', check);
43
+ observer.disconnect();
44
+ };
45
+ }
46
+ /** Turns OLED on or off and remembers it on this device. */
47
+ export function applyOled(on) {
48
+ const root = document.documentElement;
49
+ if (on)
50
+ root.setAttribute('data-oled', '');
51
+ else
52
+ root.removeAttribute('data-oled');
53
+ try {
54
+ if (on)
55
+ localStorage.setItem(storageKey('oled'), '1');
56
+ else
57
+ localStorage.removeItem(storageKey('oled'));
58
+ }
59
+ catch {
60
+ // The switch still holds until the page closes.
61
+ }
62
+ }
63
+ /** Whether OLED is on for this device. */
64
+ export function storedOled() {
65
+ try {
66
+ return localStorage.getItem(storageKey('oled')) === '1';
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * The version every GlimStone package carries, so an app's Version card names
3
3
  * the one it runs. All packages share it and move together.
4
4
  */
5
- export declare const GLIMSTONE_VERSION = "4.1.2";
5
+ export declare const GLIMSTONE_VERSION = "4.2.2";
package/dist/version.js CHANGED
@@ -2,4 +2,4 @@
2
2
  * The version every GlimStone package carries, so an app's Version card names
3
3
  * the one it runs. All packages share it and move together.
4
4
  */
5
- export const GLIMSTONE_VERSION = "4.1.2";
5
+ export const GLIMSTONE_VERSION = "4.2.2";
package/glyph-map.md CHANGED
@@ -228,6 +228,7 @@ ArrowLoop drew the glyphs for the segments of its Design, Corners and Motion sel
228
228
  | Row height selector, Compact | `IconRowCompact` | none | none | `IconRowCompact`, standard |
229
229
  | Row height selector, Medium | `IconRowMedium` | none | none | `IconRowMedium`, standard |
230
230
  | Row height selector, Comfortable | `IconRowComfortable` | none | none | `IconRowComfortable`, standard |
231
+ | Accent row, the swatch that takes the system's colour | `IconAccentSystem` | none | none | none |
231
232
 
232
233
  <br>
233
234
 
package/glyphs.json CHANGED
@@ -1234,6 +1234,19 @@
1234
1234
  ],
1235
1235
  "svg": "<path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M0 3A1.2 1.2 0 0 1 1.2 1.8H12.8A1.2 1.2 0 0 1 12.8 4.2H1.2A1.2 1.2 0 0 1 0 3ZM0 11A1.2 1.2 0 0 1 1.2 9.8H12.8A1.2 1.2 0 0 1 12.8 12.2H1.2A1.2 1.2 0 0 1 0 11Z\" />"
1236
1236
  },
1237
+ {
1238
+ "name": "IconAccentSystem",
1239
+ "means": "The accent takes the system's colour",
1240
+ "source": "Drawn for GlimStone",
1241
+ "viewBox": "0.5 0.5 13 13",
1242
+ "ink": [
1243
+ 0.5,
1244
+ 0.5,
1245
+ 13,
1246
+ 13
1247
+ ],
1248
+ "svg": "<path fill=\"currentColor\" d=\"M0.793 11.793L6.893 5.693A1 1 0 0 1 8.307 7.107L2.207 13.207A1 1 0 0 1 0.793 11.793ZM10.5 0.5Q11 3 13.5 3.5Q11 4 10.5 6.5Q10 4 7.5 3.5Q10 3 10.5 0.5ZM3.75 2.25Q4.1 3.65 5.5 4Q4.1 4.35 3.75 5.75Q3.4 4.35 2 4Q3.4 3.65 3.75 2.25ZM11 9Q11.35 10.4 12.75 10.75Q11.35 11.1 11 12.5Q10.65 11.1 9.25 10.75Q10.65 10.4 11 9Z\"></path>"
1249
+ },
1237
1250
  {
1238
1251
  "name": "IconGear",
1239
1252
  "means": "Settings, open a setting",
package/glyphs.md CHANGED
@@ -259,7 +259,7 @@ A file manager tells a file's kind by its glyph before its name is read, so thes
259
259
  | `IconDiagnostics` | Diagnostics | Streamline `programming/bug.svg` |
260
260
  | `IconKeyboard` | Keyboard shortcuts | Streamline `computer-devices/keyboard.svg` |
261
261
 
262
- ### Look settings, the segments of the Design, Corners, Motion, Labels and Row height selectors
262
+ ### Look settings, the segments of the Design, Corners, Motion, Labels and Row height selectors, and the system accent swatch
263
263
 
264
264
  | Name | Means | Source |
265
265
  | --- | --- | --- |
@@ -279,8 +279,9 @@ A file manager tells a file's kind by its glyph before its name is read, so thes
279
279
  | `IconRowCompact` | Compact rows | Drawn for GlimStone |
280
280
  | `IconRowMedium` | Medium rows | Drawn for GlimStone |
281
281
  | `IconRowComfortable` | Comfortable rows | Drawn for GlimStone |
282
+ | `IconAccentSystem` | The accent takes the system's colour | Drawn for GlimStone |
282
283
 
283
- The Design selector's Dark and Light segments wear `IconMoon` and `IconSun` from the actions above, and System wears `IconThemeSystem`, a ring with one half filled. Each corner glyph is a frame drawn with the corners it names, so the four read as one family whatever corners the selector around them has. The motion glyphs are one line that swings further at each level, just as a level is the same animation with bigger numbers. `IconCornerLeaf` and `IconMotionStorm` belong to the hidden shape and the hidden level and stand only where the selector offers those. The four label glyphs draw a choice with a disc for the glyph and bars for the words: three bars for Text, a small disc before two bars for Text and glyph, a large disc alone for Glyph, and for Reactive a disc with a pointer arrow at its corner, since that mode opens the words under the pointer. The row glyphs are bars across the full width at three spacings, four close ones for Compact, three for Medium and two far apart for Comfortable. Only the gap between the bars sets the three apart.
284
+ The Design selector's Dark and Light segments wear `IconMoon` and `IconSun` from the actions above, and System wears `IconThemeSystem`, a ring with one half filled. Each corner glyph is a frame drawn with the corners it names, so the four read as one family whatever corners the selector around them has. The motion glyphs are one line that swings further at each level, just as a level is the same animation with bigger numbers. `IconCornerLeaf` and `IconMotionStorm` belong to the hidden shape and the hidden level and stand only where the selector offers those. The four label glyphs draw a choice with a disc for the glyph and bars for the words: three bars for Text, a small disc before two bars for Text and glyph, a large disc alone for Glyph, and for Reactive a disc with a pointer arrow at its corner, since that mode opens the words under the pointer. The row glyphs are bars across the full width at three spacings, four close ones for Compact, three for Medium and two far apart for Comfortable. Only the gap between the bars sets the three apart. `IconAccentSystem` stands on the swatch before the accent presets that takes the colour from the device: a wand with a large spark at its tip and two small ones beside it.
284
285
 
285
286
  **`IconGrip` is two columns of three dots**, the handle TowerTorch and KnightLoader each drew for a row that drags.
286
287
 
@@ -288,7 +289,7 @@ The Design selector's Dark and Light segments wear `IconMoon` and `IconSun` from
288
289
 
289
290
  **Taking something away draws a slash through it.** `IconEyeOff`, `IconKeyRevoke`, `IconShieldOff` and `IconUnlink` are an eye, a key, a shield and a chain link with one bar from the top left to the bottom right, so the undoing reads the same on every one of them.
290
291
 
291
- **Close meanings wear different drawings.** `IconRetry` tries again, reloads or refreshes, and `IconRefresh` goes back to the default whatever its name says. `IconResetArrow` does the same for one row inside a colour card, where a full-size reset beside the swatches would read as resetting the card. `IconQueued` waits its turn, and `IconHourglass` marks a file that is a backup copy or not finished yet. `IconUpload` and `IconDownload` move a file to or from somewhere else, while `IconImport` and `IconExport` read a list or settings in and write them out. `IconCheck` marks a test and the success it reports, and `IconConfirm` is a person agreeing: Done, Confirm, Got it. `IconApply` takes something over in place of what was there, which is not saving. `IconCompressAuto` marks anything the app chooses by itself, the compression as well as the order of the providers, and keeps its name for the apps that generate from this list. `IconSync` checks for an update and fetches it, and `IconUpdate` marks an updates tab or section, where its cloud has room to read. The check answers with `IconUpToDate` when there was none to fetch, the cloud with a tick, and with `IconUpdateAvailable` when there is. `IconMoon` is the dark look and nothing else; a disk spinning down, a machine going to rest and a VM put into hibernation wear `IconSleep`.
292
+ **Close meanings wear different drawings.** `IconRetry` tries again, reloads or refreshes, and `IconRefresh` goes back to the default whatever its name says. `IconResetArrow` does the same for one row inside a colour card, where a full-size reset beside the swatches would read as resetting the card. `IconQueued` waits its turn, and `IconHourglass` marks a file that is a backup copy or not finished yet. `IconUpload` and `IconDownload` move a file to or from somewhere else, while `IconImport` and `IconExport` read a list or settings in and write them out. `IconCheck` marks a test and the success it reports, and `IconConfirm` is a person agreeing: Done, Confirm, Got it. `IconApply` takes something over in place of what was there, which is not saving. `IconCompressAuto` marks anything the app chooses by itself, the compression as well as the order of the providers, and keeps its name for the apps that generate from this list. Its wand and spark are Streamline's, and `IconAccentSystem` draws its own with three sparks, since taking the device's colour is a different meaning. `IconSync` checks for an update and fetches it, and `IconUpdate` marks an updates tab or section, where its cloud has room to read. The check answers with `IconUpToDate` when there was none to fetch, the cloud with a tick, and with `IconUpdateAvailable` when there is. `IconMoon` is the dark look and nothing else; a disk spinning down, a machine going to rest and a VM put into hibernation wear `IconSleep`.
292
293
 
293
294
  **A brand mark is the brand's own and goes only where it is meant.** `IconCoffee` is Buy Me a Coffee's mark and stands only on the button that leads there, and `IconCaptcha` is reCAPTCHA's mark and stands only where a captcha is the subject. `IconPayPal` and `IconBitcoin` stand only on the buttons that open the PayPal and the crypto window. `IconContainers` and `IconGithub` follow the same rule (section 1).
294
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glimstone/core",
3
- "version": "4.1.2",
3
+ "version": "4.2.2",
4
4
  "description": "GlimStone's tokens, glyphs and engines, without a framework",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/junkerderprovinz/glimstone.git", "directory": "packages/core" },
package/tokens.css CHANGED
@@ -154,11 +154,13 @@
154
154
  the same colour and the light theme is where they part, so use this rather
155
155
  than --accent whenever the colour is read.
156
156
 
157
- The theme sets only --ink-mix, which <html> passes down, so the one-line
158
- mix is right in both themes and the rainbow blocks need no light twin.
159
- 100% of a colour mixed with black is that colour. */
157
+ The theme sets --ink-mix, which <html> passes down, so the one-line mix
158
+ is right in both themes and the rainbow blocks need no light twin. 100% of
159
+ a colour mixed with black is that colour. An accent the mix leaves under
160
+ 4.5:1 on a ramp's card gets that ramp's ink from applyAccent instead, as
161
+ --accent-ink-dark or --accent-ink-light; no preset needs one. */
160
162
  --ink-mix: 100%;
161
- --accent-ink: color-mix(in srgb, var(--accent) var(--ink-mix), black);
163
+ --accent-ink: var(--accent-ink-dark, color-mix(in srgb, var(--accent) var(--ink-mix), black));
162
164
  /* The same ink for a line-drawn glyph. Rainbow never colours text, so a hued
163
165
  subtree redefines this one and leaves --accent-ink on the single accent. */
164
166
  --accent-glyph: var(--accent-ink);
@@ -240,6 +242,7 @@
240
242
  --accent-contrast: #161616;
241
243
  --accent-soft: rgba(252, 196, 25, 0.14);
242
244
  --ink-mix: 55%;
245
+ --accent-ink: var(--accent-ink-light, color-mix(in srgb, var(--accent) var(--ink-mix), black));
243
246
 
244
247
  --status-info-text: #8E6A00;
245
248
  --status-info-bg: rgba(142, 106, 0, 0.12);
@@ -313,6 +316,7 @@
313
316
  --accent-contrast: #161616;
314
317
  --accent-soft: rgba(252, 196, 25, 0.14);
315
318
  --ink-mix: 55%;
319
+ --accent-ink: var(--accent-ink-light, color-mix(in srgb, var(--accent) var(--ink-mix), black));
316
320
 
317
321
  --status-info-text: #8E6A00;
318
322
  --status-info-bg: rgba(142, 106, 0, 0.12);
@@ -342,6 +346,51 @@
342
346
  --focus-ring: rgba(142, 106, 0, .5);
343
347
  }
344
348
 
349
+ /* OLED, the dark ramp on true black for OLED and AMOLED screens (rule 43).
350
+ `data-oled` on <html> asks for it and counts only while the dark ramp shows,
351
+ chosen or from the system, so the two selectors below follow the two ways a
352
+ page goes dark. The surfaces move one Carbon step down and keep their order,
353
+ so every rule that names a step still lands on the step it meant. The hover
354
+ keeps its place between the card and surface2, and hover-raised is the step
355
+ above surface3, as on the dark ramp. The text ramp, the accent and the state
356
+ hues stay: they were measured on darker grounds than they get here.
357
+
358
+ A shadow on black is invisible, so the elevation becomes a ring in the
359
+ border colour. The switch track stays at #525252 rather than following
360
+ surface3 down, where it would fall to 1.6:1 on the card. The opaque warning
361
+ chip stands further from a #161616 card than from a #262626 one, and the
362
+ hairline and the scrim lose nothing on black, so they stay as well. A lit
363
+ brand tile in a near-black colour, such as GitHub's, would vanish into the
364
+ card, so --tile-edge rings every lit tile. */
365
+ [data-theme="dark"][data-oled] {
366
+ --carbon-bg: #000000;
367
+ --carbon-sidebar: #161616;
368
+ --carbon-surface: #161616;
369
+ --carbon-surface2: #262626;
370
+ --carbon-surface3: #393939;
371
+ --carbon-hover: #232323;
372
+ --carbon-hover-raised: #525252;
373
+ --carbon-border: #262626;
374
+ --elevation: 0 0 0 1px #262626;
375
+ --tile-edge: rgba(255, 255, 255, .2);
376
+ }
377
+
378
+ /* The same values for the system's dark, with no light theme chosen. */
379
+ @media (prefers-color-scheme: dark) {
380
+ :root[data-oled]:not([data-theme="light"]) {
381
+ --carbon-bg: #000000;
382
+ --carbon-sidebar: #161616;
383
+ --carbon-surface: #161616;
384
+ --carbon-surface2: #262626;
385
+ --carbon-surface3: #393939;
386
+ --carbon-hover: #232323;
387
+ --carbon-hover-raised: #525252;
388
+ --carbon-border: #262626;
389
+ --elevation: 0 0 0 1px #262626;
390
+ --tile-edge: rgba(255, 255, 255, .2);
391
+ }
392
+ }
393
+
345
394
  /* The hover step of a filled tone that has no surface ramp of its own (rule
346
395
  21): the fill mixed toward the theme's text colour, which is lighter on dark
347
396
  and darker on light, the direction the ramp moves. 15% is about one step of
@@ -913,10 +962,13 @@ body {
913
962
  glim-page-in var(--motion-page-dur) var(--motion-page-ease) both,
914
963
  glim-fade-in var(--motion-page-dur) ease-out both;
915
964
  }
965
+ /* Backwards, not both: a held end is an identity matrix rather than none,
966
+ and any transform makes the card the containing block of a fixed element
967
+ inside it. */
916
968
  .glim-toast {
917
969
  animation:
918
- glim-toast-in var(--motion-toast-dur) var(--motion-toast-ease, var(--motion-page-ease)) both,
919
- glim-fade-in var(--motion-toast-dur) ease-out both;
970
+ glim-toast-in var(--motion-toast-dur) var(--motion-toast-ease, var(--motion-page-ease)) backwards,
971
+ glim-fade-in var(--motion-toast-dur) ease-out backwards;
920
972
  }
921
973
  .glim-live { animation: glim-pulse var(--motion-pulse-dur) ease-in-out infinite; }
922
974
  /* Short, because the pointer is already waiting for the bubble. */
@@ -1188,7 +1240,7 @@ body {
1188
1240
  state is hidden. */
1189
1241
  @media (prefers-reduced-motion: reduce) {
1190
1242
  :root:not([data-motion='storm']) .glim-page-enter { animation: glim-fade-in 180ms ease both; }
1191
- :root:not([data-motion='storm']) .glim-toast { animation: glim-fade-in 160ms ease both; }
1243
+ :root:not([data-motion='storm']) .glim-toast { animation: glim-fade-in 160ms ease backwards; }
1192
1244
  /* An infinite animation stops outright, fully opaque, so "this is live"
1193
1245
  reads from colour and position. Storm is not exempt here: wanting more
1194
1246
  movement is not wanting something that never stops. */
@@ -1203,8 +1255,8 @@ body {
1203
1255
  }
1204
1256
  :root[data-motion='storm'] .glim-toast {
1205
1257
  animation:
1206
- glim-toast-in var(--motion-toast-dur) var(--motion-toast-ease, var(--motion-page-ease)) both,
1207
- glim-fade-in var(--motion-toast-dur) ease-out both;
1258
+ glim-toast-in var(--motion-toast-dur) var(--motion-toast-ease, var(--motion-page-ease)) backwards,
1259
+ glim-fade-in var(--motion-toast-dur) ease-out backwards;
1208
1260
  }
1209
1261
  :root[data-motion='storm'] .glim-shake { animation: glim-shake var(--motion-shake-dur) ease-in-out; }
1210
1262
  }
@@ -2016,6 +2068,7 @@ body {
2016
2068
  .group:hover > .glim-brand-tile {
2017
2069
  background-color: var(--tile);
2018
2070
  color: var(--tile-ink);
2071
+ box-shadow: inset 0 0 0 1px var(--tile-edge, transparent);
2019
2072
  --mark-ink: var(--tile-ink);
2020
2073
  --mark-cut: var(--tile);
2021
2074
  --brand-android: var(--tile-ink);
@@ -3180,6 +3233,96 @@ body {
3180
3233
  font-weight: inherit;
3181
3234
  }
3182
3235
 
3236
+ /* The sign-in page (SignInCard): one card in the middle of the screen, the
3237
+ same width in every app, and the full width less the gutter on a phone. */
3238
+ .glim-signin {
3239
+ display: flex;
3240
+ align-items: center;
3241
+ justify-content: center;
3242
+ min-height: 100dvh;
3243
+ padding: 1rem;
3244
+ background-color: var(--carbon-bg);
3245
+ color: var(--carbon-text);
3246
+ }
3247
+ .glim-signin-card {
3248
+ display: flex;
3249
+ flex-direction: column;
3250
+ gap: 1.5rem;
3251
+ width: min(24rem, 100%);
3252
+ padding: 2rem;
3253
+ }
3254
+ .glim-signin-head {
3255
+ display: flex;
3256
+ flex-direction: column;
3257
+ align-items: center;
3258
+ gap: 0.75rem;
3259
+ }
3260
+ .glim-signin-logo {
3261
+ display: flex;
3262
+ align-items: center;
3263
+ justify-content: center;
3264
+ width: 4rem;
3265
+ height: 4rem;
3266
+ }
3267
+ .glim-signin-logo > :is(img, svg) {
3268
+ display: block;
3269
+ max-width: 100%;
3270
+ max-height: 100%;
3271
+ object-fit: contain;
3272
+ }
3273
+ .glim-signin-name {
3274
+ margin: 0;
3275
+ font-size: var(--text-status);
3276
+ font-weight: 600;
3277
+ line-height: 1.2;
3278
+ text-align: center;
3279
+ overflow-wrap: anywhere;
3280
+ }
3281
+ .glim-signin-form {
3282
+ display: flex;
3283
+ flex-direction: column;
3284
+ gap: 1rem;
3285
+ margin: 0;
3286
+ }
3287
+ /* Every button as wide as the fields, also one a bubble wraps while disabled. */
3288
+ .glim-signin-form > :is(.glim-btn, .glim-tip-wrap),
3289
+ .glim-signin-form > .glim-tip-wrap > .glim-btn { width: 100%; }
3290
+ .glim-signin-form p { margin: 0; }
3291
+ .glim-signin-field {
3292
+ display: flex;
3293
+ flex-direction: column;
3294
+ gap: 0.375rem;
3295
+ }
3296
+ .glim-signin-field[hidden] { display: none; }
3297
+ .glim-signin-label {
3298
+ display: flex;
3299
+ align-items: center;
3300
+ gap: 0.375rem;
3301
+ color: var(--carbon-text-sub);
3302
+ font-size: var(--text-meta);
3303
+ font-weight: 500;
3304
+ }
3305
+ .glim-signin-hint {
3306
+ color: var(--carbon-text-sub);
3307
+ font-size: var(--text-meta);
3308
+ }
3309
+ .glim-signin-error {
3310
+ color: var(--status-fail-text);
3311
+ font-size: var(--text-meta);
3312
+ }
3313
+ .glim-signin-note {
3314
+ padding: 0.6rem 0.8rem;
3315
+ border-radius: var(--radius-control);
3316
+ background-color: var(--carbon-surface2);
3317
+ font-size: var(--text-subline);
3318
+ }
3319
+ .glim-signin-code { letter-spacing: 0.35em; }
3320
+ .glim-signin-log-code,
3321
+ .glim-signin-log-code::placeholder {
3322
+ text-transform: uppercase;
3323
+ letter-spacing: 0.2em;
3324
+ }
3325
+
3183
3326
  /* What stands in place of a control the environment forbids
3184
3327
  (UnavailableNotice). A paragraph, because there is no control to hang an
3185
3328
  info bubble off. */
@@ -3436,7 +3579,9 @@ body {
3436
3579
  margin: 0;
3437
3580
  text-wrap: pretty;
3438
3581
  font-size: 0.875rem;
3439
- line-height: 1.375;
3582
+ /* Whole pixels per line, or every toast stacked beside this one lands on a
3583
+ fraction and Chromium rounds its edges differently from paint to paint. */
3584
+ line-height: 1.25rem;
3440
3585
  overflow-wrap: break-word;
3441
3586
  }
3442
3587
  /* A phone keeps the window in view: the newest two across its width. */