@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/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.1";
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.1";
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.1",
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
  }
@@ -1950,6 +2002,19 @@ body {
1950
2002
  smallest floor. An explicit stage is a width of its own and stays. */
1951
2003
  .glim-modal-foot > :is(.glim-btn-sm, .glim-btn-md, .glim-btn-lg),
1952
2004
  .glim-modal-foot > .glim-tip-wrap > :is(.glim-btn-sm, .glim-btn-md, .glim-btn-lg) { min-width: var(--btn-w-xs); }
2005
+ /* A button wider than the whole foot, as on a phone, runs its words onto a
2006
+ second line and grows with them instead of ending in an ellipsis. */
2007
+ :is(.glim-modal-foot, .glim-modal-foot > .glim-tip-wrap) > .glim-btn {
2008
+ height: auto;
2009
+ min-height: var(--btn-h);
2010
+ padding-block: 0.375rem;
2011
+ }
2012
+ :is(.glim-modal-foot, .glim-modal-foot > .glim-tip-wrap) > .glim-btn-key { min-height: var(--btn-h-key); }
2013
+ :is(.glim-modal-foot, .glim-modal-foot > .glim-tip-wrap) > .glim-btn > .glim-btn-label {
2014
+ white-space: normal;
2015
+ text-wrap: balance;
2016
+ text-align: center;
2017
+ }
1953
2018
  .glim-path > .glim-btn:not(.glim-btn-icon) { min-width: 0; }
1954
2019
 
1955
2020
  /* Rule 40. A surface-filled button's hovered ground takes 22% of the tint and
@@ -2003,6 +2068,7 @@ body {
2003
2068
  .group:hover > .glim-brand-tile {
2004
2069
  background-color: var(--tile);
2005
2070
  color: var(--tile-ink);
2071
+ box-shadow: inset 0 0 0 1px var(--tile-edge, transparent);
2006
2072
  --mark-ink: var(--tile-ink);
2007
2073
  --mark-cut: var(--tile);
2008
2074
  --brand-android: var(--tile-ink);
@@ -3167,6 +3233,96 @@ body {
3167
3233
  font-weight: inherit;
3168
3234
  }
3169
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
+
3170
3326
  /* What stands in place of a control the environment forbids
3171
3327
  (UnavailableNotice). A paragraph, because there is no control to hang an
3172
3328
  info bubble off. */
@@ -3423,7 +3579,9 @@ body {
3423
3579
  margin: 0;
3424
3580
  text-wrap: pretty;
3425
3581
  font-size: 0.875rem;
3426
- 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;
3427
3585
  overflow-wrap: break-word;
3428
3586
  }
3429
3587
  /* A phone keeps the window in view: the newest two across its width. */