@glimstone/core 4.1.1 → 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 +9 -3
- package/dist/appearance.d.ts +37 -9
- package/dist/appearance.js +86 -35
- package/dist/browserAccent.d.ts +12 -0
- package/dist/browserAccent.js +66 -0
- package/dist/contrast.d.ts +26 -0
- package/dist/contrast.js +107 -0
- package/dist/disco.js +2 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/signIn.d.ts +136 -0
- package/dist/signIn.js +225 -0
- package/dist/theme.d.ts +14 -0
- package/dist/theme.js +71 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/glyph-map.md +1 -0
- package/glyphs.json +13 -0
- package/glyphs.md +4 -3
- package/package.json +1 -1
- package/tokens.css +168 -10
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), `
|
|
19
|
-
|
|
20
|
-
|
|
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`,
|
package/dist/appearance.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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;
|
package/dist/appearance.js
CHANGED
|
@@ -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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
|
148
|
-
* tokens.css
|
|
149
|
-
*
|
|
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(
|
|
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
|
|
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(${
|
|
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
|
-
/**
|
|
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
|
+
};
|
package/dist/contrast.js
ADDED
|
@@ -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,
|
|
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";
|
package/dist/signIn.d.ts
ADDED
|
@@ -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;
|