meta-sovereign 0.49.12 → 0.49.14
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/js/src/web/app.css +42 -0
- package/js/src/web/app.js +3 -0
- package/js/src/web/effects.js +263 -0
- package/js/src/web/locales/en.js +4 -0
- package/js/src/web/locales/hi.js +4 -0
- package/js/src/web/locales/ru.js +4 -0
- package/js/src/web/locales/zh.js +4 -0
- package/js/src/web/settings-view.js +12 -0
- package/js/src/web/tutorial-clear.js +151 -0
- package/js/src/web/tutorial.js +48 -4
- package/package.json +3 -1
package/js/src/web/app.css
CHANGED
|
@@ -12607,3 +12607,45 @@ audio.remote-player {
|
|
|
12607
12607
|
flex: 1 1 12rem;
|
|
12608
12608
|
overflow-wrap: anywhere;
|
|
12609
12609
|
}
|
|
12610
|
+
|
|
12611
|
+
/* Reduced visual effects (R-Z21). <html data-effects="reduced"> is set by
|
|
12612
|
+
effects.js when the browser renders slowly (a frame probe), or by the
|
|
12613
|
+
Settings choice / ?effects=reduced: no backdrop blur (the same solid
|
|
12614
|
+
surfaces as `prefers-reduced-transparency`), no page-sized dim around
|
|
12615
|
+
the tutorial's ring, and no non-essential animation or transition. */
|
|
12616
|
+
:root[data-effects='reduced'] * {
|
|
12617
|
+
-webkit-backdrop-filter: none !important;
|
|
12618
|
+
backdrop-filter: none !important;
|
|
12619
|
+
}
|
|
12620
|
+
:root[data-effects='reduced'] *,
|
|
12621
|
+
:root[data-effects='reduced'] *::before,
|
|
12622
|
+
:root[data-effects='reduced'] *::after {
|
|
12623
|
+
animation-duration: 0.01ms !important;
|
|
12624
|
+
animation-iteration-count: 1 !important;
|
|
12625
|
+
/* 0s, not 0.01ms: the default transition-property is `all`, so any
|
|
12626
|
+
non-zero duration turns every colour change (the theme switch at
|
|
12627
|
+
load) into a transition, and axe read light text on a dark fill
|
|
12628
|
+
(R-Z22). */
|
|
12629
|
+
transition-duration: 0s !important;
|
|
12630
|
+
transition-delay: 0s !important;
|
|
12631
|
+
}
|
|
12632
|
+
:root[data-effects='reduced'] {
|
|
12633
|
+
--surface-glass-bg: var(--bg-elev);
|
|
12634
|
+
}
|
|
12635
|
+
:root[data-effects='reduced'] .chat-composer {
|
|
12636
|
+
background: var(--bg-elev);
|
|
12637
|
+
}
|
|
12638
|
+
:root[data-effects='reduced']
|
|
12639
|
+
.tutorial-overlay-spotlight
|
|
12640
|
+
.tutorial-spotlight-frame {
|
|
12641
|
+
box-shadow:
|
|
12642
|
+
0 0 0 4px rgba(0, 0, 0, 0.5),
|
|
12643
|
+
0 0 0 3px rgba(102, 178, 255, 1);
|
|
12644
|
+
}
|
|
12645
|
+
|
|
12646
|
+
/* The card collapses to its title and buttons when its target cannot be
|
|
12647
|
+
scrolled clear of it (R-Z22). */
|
|
12648
|
+
.tutorial-overlay .tutorial-step[data-compact='true'] .tutorial-body,
|
|
12649
|
+
.tutorial-overlay .tutorial-step[data-compact='true'] .tutorial-examples {
|
|
12650
|
+
display: none;
|
|
12651
|
+
}
|
package/js/src/web/app.js
CHANGED
|
@@ -23,6 +23,7 @@ import { LocaleContext, useLocale } from './i18n.js';
|
|
|
23
23
|
import { LanguageSwitcher } from './language-switcher.js';
|
|
24
24
|
import { AppShell } from './shell/app-shell.js';
|
|
25
25
|
import { useAppearance, useApplyAppearance } from './appearance.js';
|
|
26
|
+
import { useApplyEffects } from './effects.js';
|
|
26
27
|
import { WebModeBanner } from './web-mode-banner.js';
|
|
27
28
|
import { DiskBanner } from './disk-banner.js';
|
|
28
29
|
import { SchemaBanner } from './schema-banner.js';
|
|
@@ -103,6 +104,8 @@ const App = () => {
|
|
|
103
104
|
// the header button (R-M12) — see ./tutorial-start.js.
|
|
104
105
|
const connected = useAnyConnection(api);
|
|
105
106
|
const [tutorialOpen, setTutorialOpen] = useState(false);
|
|
107
|
+
// Frosted glass and dims off on a slow-rendering browser (R-Z21).
|
|
108
|
+
useApplyEffects(tutorialOpen);
|
|
106
109
|
const decided = useRef(false);
|
|
107
110
|
useEffect(() => {
|
|
108
111
|
if (connected === null || decided.current) {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Reduced visual effects (R-Z21): on a machine that renders slowly (a
|
|
2
|
+
// browser without a GPU, software compositing) the frosted-glass blur
|
|
3
|
+
// and the tutorial's page-sized dim can make every frame take seconds,
|
|
4
|
+
// so clicks never settle. A bounded frame-time probe measures it, and
|
|
5
|
+
// `<html data-effects="reduced">` then switches the blur, the page-wide
|
|
6
|
+
// dim and the non-essential animations off (app.css, same solid look as
|
|
7
|
+
// `prefers-reduced-transparency`).
|
|
8
|
+
//
|
|
9
|
+
// Setting "Reduce visual effects": auto (default: the probe decides),
|
|
10
|
+
// on or off, kept in localStorage like the theme (a UI preference, not
|
|
11
|
+
// store data). `?effects=reduced|full` in the URL overrides it for the
|
|
12
|
+
// visit, `prefers-reduced-transparency` / `prefers-reduced-motion`
|
|
13
|
+
// count as "on" in auto.
|
|
14
|
+
|
|
15
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
16
|
+
|
|
17
|
+
export const EFFECTS_MODES = ['auto', 'on', 'off'];
|
|
18
|
+
export const EFFECTS_KEY = 'metaSovereignEffects';
|
|
19
|
+
|
|
20
|
+
const SAMPLES = 10;
|
|
21
|
+
const MIN_SAMPLES = 5;
|
|
22
|
+
const SLOW_FRAME_MS = 50;
|
|
23
|
+
const SLOW_FRAME_FEW_CORES_MS = 34;
|
|
24
|
+
const PROBE_BUDGET_MS = 4000;
|
|
25
|
+
const SETTLE_TIMEOUT_MS = 2000;
|
|
26
|
+
|
|
27
|
+
const median = (values) => {
|
|
28
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
29
|
+
const mid = sorted.length >> 1;
|
|
30
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Gaps between consecutive frame timestamps. */
|
|
34
|
+
export const frameIntervals = (stamps) =>
|
|
35
|
+
stamps.slice(1).map((stamp, index) => stamp - stamps[index]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether measured frame gaps (ms) mean "render less": the median above
|
|
39
|
+
* 50 ms, or above ~34 ms (under 30 fps) on a machine with two cores or
|
|
40
|
+
* fewer. Too few samples decide nothing.
|
|
41
|
+
*/
|
|
42
|
+
export const isSlowRendering = (intervals, { cores = 4 } = {}) => {
|
|
43
|
+
if (!Array.isArray(intervals) || intervals.length < MIN_SAMPLES) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const typical = median(intervals);
|
|
47
|
+
return (
|
|
48
|
+
typical > SLOW_FRAME_MS || (cores <= 2 && typical > SLOW_FRAME_FEW_CORES_MS)
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Samples up to `samples` animation frames, then stops (it never keeps a
|
|
54
|
+
* loop running): resolves with the frame gaps, or with what it has when
|
|
55
|
+
* the budget passes or the page is hidden (no frames are drawn then).
|
|
56
|
+
*/
|
|
57
|
+
export const probeFrames = ({
|
|
58
|
+
raf = globalThis.requestAnimationFrame?.bind(globalThis),
|
|
59
|
+
cancel = globalThis.cancelAnimationFrame?.bind(globalThis),
|
|
60
|
+
samples = SAMPLES + 1,
|
|
61
|
+
budgetMs = PROBE_BUDGET_MS,
|
|
62
|
+
setTimer = globalThis.setTimeout,
|
|
63
|
+
clearTimer = globalThis.clearTimeout,
|
|
64
|
+
} = {}) =>
|
|
65
|
+
new Promise((resolve) => {
|
|
66
|
+
if (!raf) {
|
|
67
|
+
resolve([]);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const stamps = [];
|
|
71
|
+
let handle = 0;
|
|
72
|
+
let done = false;
|
|
73
|
+
const finish = () => {
|
|
74
|
+
if (!done) {
|
|
75
|
+
done = true;
|
|
76
|
+
cancel?.(handle);
|
|
77
|
+
clearTimer(timer);
|
|
78
|
+
resolve(frameIntervals(stamps));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const timer = setTimer(finish, budgetMs);
|
|
82
|
+
const step = (stamp) => {
|
|
83
|
+
stamps.push(stamp);
|
|
84
|
+
if (stamps.length >= samples) {
|
|
85
|
+
finish();
|
|
86
|
+
} else {
|
|
87
|
+
handle = raf(step);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
handle = raf(step);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const read = (key) => {
|
|
94
|
+
try {
|
|
95
|
+
return globalThis.localStorage?.getItem(key) ?? null;
|
|
96
|
+
} catch {
|
|
97
|
+
// Storage can be disabled in restricted browser contexts.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const write = (key, value) => {
|
|
103
|
+
try {
|
|
104
|
+
globalThis.localStorage?.setItem(key, value);
|
|
105
|
+
} catch {
|
|
106
|
+
// See read().
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
let mode = null;
|
|
111
|
+
let measured = false;
|
|
112
|
+
const listeners = new Set();
|
|
113
|
+
const current = () => {
|
|
114
|
+
if (mode === null) {
|
|
115
|
+
const stored = read(EFFECTS_KEY);
|
|
116
|
+
mode = EFFECTS_MODES.includes(stored) ? stored : 'auto';
|
|
117
|
+
}
|
|
118
|
+
return mode;
|
|
119
|
+
};
|
|
120
|
+
const subscribe = (listener) => {
|
|
121
|
+
listeners.add(listener);
|
|
122
|
+
return () => listeners.delete(listener);
|
|
123
|
+
};
|
|
124
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
125
|
+
|
|
126
|
+
export const setEffectsMode = (next) => {
|
|
127
|
+
if (!EFFECTS_MODES.includes(next)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
mode = next;
|
|
131
|
+
write(EFFECTS_KEY, next);
|
|
132
|
+
notify();
|
|
133
|
+
return true;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const matches = (query) => Boolean(globalThis.matchMedia?.(query)?.matches);
|
|
137
|
+
|
|
138
|
+
/** `reduced` or `full` from the choice, the URL, the OS and the probe. */
|
|
139
|
+
export const resolveEffects = ({
|
|
140
|
+
choice = 'auto',
|
|
141
|
+
query = '',
|
|
142
|
+
slow = false,
|
|
143
|
+
osReduced = false,
|
|
144
|
+
} = {}) => {
|
|
145
|
+
const forced = new URLSearchParams(query).get('effects');
|
|
146
|
+
if (forced === 'reduced' || forced === 'full') {
|
|
147
|
+
return forced;
|
|
148
|
+
}
|
|
149
|
+
if (choice === 'on') {
|
|
150
|
+
return 'reduced';
|
|
151
|
+
}
|
|
152
|
+
if (choice === 'off') {
|
|
153
|
+
return 'full';
|
|
154
|
+
}
|
|
155
|
+
return slow || osReduced ? 'reduced' : 'full';
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* True only when `needed` consecutive probe samples are all slow, so one
|
|
160
|
+
* janky stretch (boot, a big list rendering) never decides. A fast, empty
|
|
161
|
+
* (hidden page) or missing sample ends it at once; at most `needed`
|
|
162
|
+
* samples are ever taken.
|
|
163
|
+
*/
|
|
164
|
+
export const detectSlowRendering = async ({
|
|
165
|
+
sample = probeFrames,
|
|
166
|
+
cores = 4,
|
|
167
|
+
needed = 2,
|
|
168
|
+
} = {}) => {
|
|
169
|
+
for (let taken = 0; taken < needed; taken += 1) {
|
|
170
|
+
if (!isSlowRendering(await sample(), { cores })) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolves once the app has settled after startup: the page is loaded,
|
|
179
|
+
* then the browser is idle (or `timeoutMs` passes).
|
|
180
|
+
*/
|
|
181
|
+
export const whenSettled = ({
|
|
182
|
+
ready = () => globalThis.document?.readyState === 'complete',
|
|
183
|
+
onLoad = (cb) => globalThis.addEventListener?.('load', cb, { once: true }),
|
|
184
|
+
idle = globalThis.requestIdleCallback?.bind(globalThis),
|
|
185
|
+
setTimer = globalThis.setTimeout,
|
|
186
|
+
timeoutMs = SETTLE_TIMEOUT_MS,
|
|
187
|
+
} = {}) =>
|
|
188
|
+
new Promise((resolve) => {
|
|
189
|
+
const afterLoad = () =>
|
|
190
|
+
idle
|
|
191
|
+
? idle(resolve, { timeout: timeoutMs })
|
|
192
|
+
: setTimer(resolve, timeoutMs);
|
|
193
|
+
if (ready()) {
|
|
194
|
+
afterLoad();
|
|
195
|
+
} else {
|
|
196
|
+
onLoad(afterLoad);
|
|
197
|
+
setTimer(resolve, timeoutMs);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// The decision lives in session memory only; just the user's explicit
|
|
202
|
+
// choice is stored.
|
|
203
|
+
let probing = false;
|
|
204
|
+
const probe = async (settle) => {
|
|
205
|
+
if (measured || probing || current() !== 'auto') {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
probing = true;
|
|
209
|
+
try {
|
|
210
|
+
if (settle) {
|
|
211
|
+
await whenSettled();
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
current() === 'auto' &&
|
|
215
|
+
(await detectSlowRendering({
|
|
216
|
+
cores: globalThis.navigator?.hardwareConcurrency ?? 4,
|
|
217
|
+
}))
|
|
218
|
+
) {
|
|
219
|
+
measured = true;
|
|
220
|
+
notify();
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
probing = false;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const apply = (choice) => {
|
|
228
|
+
const root = globalThis.document?.documentElement;
|
|
229
|
+
if (root) {
|
|
230
|
+
root.dataset.effects = resolveEffects({
|
|
231
|
+
choice,
|
|
232
|
+
query: globalThis.location?.search ?? '',
|
|
233
|
+
slow: measured,
|
|
234
|
+
osReduced:
|
|
235
|
+
matches('(prefers-reduced-transparency: reduce)') ||
|
|
236
|
+
matches('(prefers-reduced-motion: reduce)'),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** `{ effects, setEffects }` for the Settings control. */
|
|
242
|
+
export const useEffectsSetting = () => ({
|
|
243
|
+
effects: useSyncExternalStore(subscribe, current, current),
|
|
244
|
+
setEffects: setEffectsMode,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Keeps <html data-effects> in step with the choice and probes the frame
|
|
249
|
+
* time once the app has settled and when the tutorial opens (two slow
|
|
250
|
+
* samples in a row; a slow page is noticed once and stays reduced for
|
|
251
|
+
* the visit, in memory only).
|
|
252
|
+
*/
|
|
253
|
+
export const useApplyEffects = (tutorialOpen = false) => {
|
|
254
|
+
const choice = useSyncExternalStore(subscribe, current, current);
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
apply(choice);
|
|
257
|
+
return subscribe(() => apply(current()));
|
|
258
|
+
}, [choice]);
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
// Startup waits for the app to settle; opening the tutorial probes now.
|
|
261
|
+
probe(!tutorialOpen);
|
|
262
|
+
}, [choice, tutorialOpen]);
|
|
263
|
+
};
|
package/js/src/web/locales/en.js
CHANGED
|
@@ -1000,6 +1000,10 @@ export const en = {
|
|
|
1000
1000
|
'The theme and skin apply to every screen and are kept in this browser. The Theme button in the header switches between light and dark.',
|
|
1001
1001
|
'settings.appearance.theme': 'Theme',
|
|
1002
1002
|
'settings.appearance.skin': 'Skin',
|
|
1003
|
+
'settings.appearance.effects': 'Reduce visual effects',
|
|
1004
|
+
'settings.effects.auto': 'Automatic: when this browser renders slowly',
|
|
1005
|
+
'settings.effects.on': 'Always reduced (no blur)',
|
|
1006
|
+
'settings.effects.off': 'Full effects',
|
|
1003
1007
|
'settings.theme.system': 'Follow the system',
|
|
1004
1008
|
'settings.theme.light': 'Light',
|
|
1005
1009
|
'settings.theme.dark': 'Dark',
|
package/js/src/web/locales/hi.js
CHANGED
|
@@ -997,6 +997,10 @@ export const hi = {
|
|
|
997
997
|
'थीम और स्किन हर स्क्रीन पर लागू होती हैं और इसी ब्राउज़र में सहेजी जाती हैं। हेडर का थीम बटन हल्की और गहरी थीम के बीच बदलता है।',
|
|
998
998
|
'settings.appearance.theme': 'थीम',
|
|
999
999
|
'settings.appearance.skin': 'स्किन',
|
|
1000
|
+
'settings.appearance.effects': 'दृश्य प्रभाव घटाएँ',
|
|
1001
|
+
'settings.effects.auto': 'स्वचालित: जब ब्राउज़र धीमा रेंडर करे',
|
|
1002
|
+
'settings.effects.on': 'हमेशा घटे हुए (बिना ब्लर)',
|
|
1003
|
+
'settings.effects.off': 'पूरे प्रभाव',
|
|
1000
1004
|
'settings.theme.system': 'सिस्टम के अनुसार',
|
|
1001
1005
|
'settings.theme.light': 'हल्की',
|
|
1002
1006
|
'settings.theme.dark': 'गहरी',
|
package/js/src/web/locales/ru.js
CHANGED
|
@@ -1012,6 +1012,10 @@ export const ru = {
|
|
|
1012
1012
|
'Тема и оформление действуют на всех экранах и сохраняются в этом браузере. Кнопка «Тема» в шапке переключает светлую и тёмную.',
|
|
1013
1013
|
'settings.appearance.theme': 'Тема',
|
|
1014
1014
|
'settings.appearance.skin': 'Оформление',
|
|
1015
|
+
'settings.appearance.effects': 'Упростить оформление',
|
|
1016
|
+
'settings.effects.auto': 'Автоматически: если браузер медленно рисует',
|
|
1017
|
+
'settings.effects.on': 'Всегда упрощённо (без размытия)',
|
|
1018
|
+
'settings.effects.off': 'Все эффекты',
|
|
1015
1019
|
'settings.theme.system': 'Как в системе',
|
|
1016
1020
|
'settings.theme.light': 'Светлая',
|
|
1017
1021
|
'settings.theme.dark': 'Тёмная',
|
package/js/src/web/locales/zh.js
CHANGED
|
@@ -963,6 +963,10 @@ export const zh = {
|
|
|
963
963
|
'主题和皮肤作用于所有界面,并保存在此浏览器中。页眉中的“主题”按钮可在浅色和深色之间切换。',
|
|
964
964
|
'settings.appearance.theme': '主题',
|
|
965
965
|
'settings.appearance.skin': '皮肤',
|
|
966
|
+
'settings.appearance.effects': '减少视觉效果',
|
|
967
|
+
'settings.effects.auto': '自动:浏览器渲染缓慢时',
|
|
968
|
+
'settings.effects.on': '始终减少(无模糊)',
|
|
969
|
+
'settings.effects.off': '完整效果',
|
|
966
970
|
'settings.theme.system': '跟随系统',
|
|
967
971
|
'settings.theme.light': '浅色',
|
|
968
972
|
'settings.theme.dark': '深色',
|
|
@@ -13,6 +13,7 @@ import { MediaSettings } from './settings-media.js';
|
|
|
13
13
|
import { ScaleSettings } from './settings-scale.js';
|
|
14
14
|
import { ProxySettings } from './settings-proxy.js';
|
|
15
15
|
import { SKINS, THEME_MODES, useAppearance } from './appearance.js';
|
|
16
|
+
import { EFFECTS_MODES, useEffectsSetting } from './effects.js';
|
|
16
17
|
import { api } from './dom.js';
|
|
17
18
|
import { ChatBackgroundSettings } from './chat-wallpaper.js';
|
|
18
19
|
import { CloudThemeSettings } from './cloud-themes.js';
|
|
@@ -49,6 +50,7 @@ const Choices = ({ name, legend, options, value, onChange, label }) =>
|
|
|
49
50
|
/** Theme mode (system, light, dark, auto) and skin (t3, Telegram, …). */
|
|
50
51
|
export const AppearanceSettings = ({ appearance }) => {
|
|
51
52
|
const t = useT();
|
|
53
|
+
const { effects, setEffects } = useEffectsSetting();
|
|
52
54
|
return el(
|
|
53
55
|
'section',
|
|
54
56
|
{ className: 'card appearance', 'aria-labelledby': 'appearance-title' },
|
|
@@ -79,6 +81,16 @@ export const AppearanceSettings = ({ appearance }) => {
|
|
|
79
81
|
value: appearance.skin,
|
|
80
82
|
onChange: appearance.setSkin,
|
|
81
83
|
label: (skin) => t(`settings.skin.${skin}`),
|
|
84
|
+
}),
|
|
85
|
+
// Blur and dims off on a slow browser (R-Z21).
|
|
86
|
+
el(Choices, {
|
|
87
|
+
key: 'effects',
|
|
88
|
+
name: 'effects-mode',
|
|
89
|
+
legend: t('settings.appearance.effects'),
|
|
90
|
+
options: EFFECTS_MODES,
|
|
91
|
+
value: effects,
|
|
92
|
+
onChange: setEffects,
|
|
93
|
+
label: (mode) => t(`settings.effects.${mode}`),
|
|
82
94
|
})
|
|
83
95
|
),
|
|
84
96
|
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The card never covers the control it asks the user to click (R-Z22).
|
|
2
|
+
//
|
|
3
|
+
// A card docked to the screen edge (a section too big for a side, a
|
|
4
|
+
// short or narrow screen, taller fonts than the developer's) can end
|
|
5
|
+
// up over its own target: the target was scrolled "into view" before
|
|
6
|
+
// the card's size was known. Once the card is placed, this checks the
|
|
7
|
+
// two boxes and, if they meet, scrolls the target into the band the
|
|
8
|
+
// card leaves free (`scroll-margin` on the target, so every clipping
|
|
9
|
+
// container — the page, a drawer — takes part). If the target cannot
|
|
10
|
+
// be moved clear (the band is smaller than the target and the page
|
|
11
|
+
// cannot scroll further) the card collapses to its title and buttons,
|
|
12
|
+
// which is the smaller dock.
|
|
13
|
+
|
|
14
|
+
import { useEffect, useState } from 'react';
|
|
15
|
+
import { GAP, intersects } from './tutorial-placement.js';
|
|
16
|
+
|
|
17
|
+
const ATTEMPT_DELAYS_MS = [0, 250, 700];
|
|
18
|
+
const RESIZE_DEBOUNCE_MS = 60;
|
|
19
|
+
|
|
20
|
+
const boxOf = (node) => {
|
|
21
|
+
const r = node.getBoundingClientRect();
|
|
22
|
+
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the card leaves room: `{top, bottom, below}` in screen
|
|
27
|
+
* coordinates. A card in the lower half leaves the space above it.
|
|
28
|
+
*/
|
|
29
|
+
export const freeBand = (card, viewport, chromeBottom = 0) => {
|
|
30
|
+
const below = card.top + card.height / 2 > viewport.height / 2;
|
|
31
|
+
return below
|
|
32
|
+
? { top: chromeBottom, bottom: card.top - GAP, below }
|
|
33
|
+
: { top: card.top + card.height + GAP, bottom: viewport.height, below };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is the target where the card leaves it? A target that fits the free
|
|
38
|
+
* band must not meet the card at all. One taller than the band (the
|
|
39
|
+
* services grid) runs under the card by nature: it is read from its
|
|
40
|
+
* top, so that top must sit in the upper part of the band, with the
|
|
41
|
+
* first controls (sign-ins, the first services) in the clear.
|
|
42
|
+
*/
|
|
43
|
+
export const placedClear = (target, card, band, viewport) => {
|
|
44
|
+
if (!intersects(card, target)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
// Too tall for the screen even without a card: read from the top.
|
|
48
|
+
if (target.height <= viewport.height - band.top - GAP) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const size = band.bottom - band.top;
|
|
52
|
+
return target.top >= band.top - 2 && target.top <= band.top + size / 4;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const scrollInto = (element, band, viewport, block) => {
|
|
56
|
+
const style = element.style;
|
|
57
|
+
const saved = [style.scrollMarginTop, style.scrollMarginBottom];
|
|
58
|
+
style.scrollMarginTop = `${Math.max(0, band.top)}px`;
|
|
59
|
+
style.scrollMarginBottom = `${Math.max(0, viewport.height - band.bottom)}px`;
|
|
60
|
+
try {
|
|
61
|
+
element.scrollIntoView?.({ block, inline: 'nearest', behavior: 'auto' });
|
|
62
|
+
} finally {
|
|
63
|
+
[style.scrollMarginTop, style.scrollMarginBottom] = saved;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One check: 'clear' when the target (and the control the step names
|
|
69
|
+
* inside it, `mustShow`) is where the card leaves it, else it scrolls
|
|
70
|
+
* and reports 'scrolled', or 'stuck' (nothing more to scroll).
|
|
71
|
+
*/
|
|
72
|
+
export const clearOnce = (element, cardNode, viewport, mustShow = '') => {
|
|
73
|
+
const chrome = document.querySelector('.topbar')?.getBoundingClientRect();
|
|
74
|
+
const band = freeBand(boxOf(cardNode), viewport, chrome?.bottom ?? 0);
|
|
75
|
+
const control = mustShow ? element.querySelector(mustShow) : null;
|
|
76
|
+
const ok = () => {
|
|
77
|
+
const card = boxOf(cardNode);
|
|
78
|
+
const covered = control && intersects(card, boxOf(control));
|
|
79
|
+
return placedClear(boxOf(element), card, band, viewport) && !covered;
|
|
80
|
+
};
|
|
81
|
+
if (ok()) {
|
|
82
|
+
return 'clear';
|
|
83
|
+
}
|
|
84
|
+
const target = boxOf(element);
|
|
85
|
+
const fits = target.height <= band.bottom - band.top;
|
|
86
|
+
scrollInto(element, band, viewport, fits ? 'nearest' : 'start');
|
|
87
|
+
if (control && !ok()) {
|
|
88
|
+
scrollInto(control, band, viewport, 'nearest');
|
|
89
|
+
}
|
|
90
|
+
return ok() ? 'scrolled' : 'stuck';
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Keeps the target clear of the card after every placement. Returns
|
|
95
|
+
* true when the card had to collapse (title and buttons only).
|
|
96
|
+
*/
|
|
97
|
+
export const useClearOfCard = ({
|
|
98
|
+
cardRef,
|
|
99
|
+
element,
|
|
100
|
+
stepId,
|
|
101
|
+
placed,
|
|
102
|
+
cardSize,
|
|
103
|
+
viewportOf,
|
|
104
|
+
mustShow = '',
|
|
105
|
+
}) => {
|
|
106
|
+
const [collapsed, setCollapsed] = useState(null);
|
|
107
|
+
const compact = collapsed === stepId;
|
|
108
|
+
const key = `${placed?.side}|${placed?.style?.top}|${placed?.style?.left}`;
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const node = cardRef.current;
|
|
111
|
+
if (
|
|
112
|
+
!element?.isConnected ||
|
|
113
|
+
!node ||
|
|
114
|
+
!placed ||
|
|
115
|
+
placed.side === 'pending'
|
|
116
|
+
) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const check = (final) => {
|
|
120
|
+
const result =
|
|
121
|
+
element.isConnected && node.isConnected
|
|
122
|
+
? clearOnce(element, node, viewportOf(), mustShow)
|
|
123
|
+
: 'clear';
|
|
124
|
+
if (result === 'stuck' && final) {
|
|
125
|
+
setCollapsed(stepId);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const timers = ATTEMPT_DELAYS_MS.map((delay, i) =>
|
|
129
|
+
globalThis.setTimeout(() => {
|
|
130
|
+
check(i === ATTEMPT_DELAYS_MS.length - 1);
|
|
131
|
+
}, delay)
|
|
132
|
+
);
|
|
133
|
+
// The target can change size after the card is placed (a panel
|
|
134
|
+
// that finishes loading, a tab with less in it): check again.
|
|
135
|
+
let late = 0;
|
|
136
|
+
const Observer = globalThis.ResizeObserver;
|
|
137
|
+
const observer = Observer
|
|
138
|
+
? new Observer(() => {
|
|
139
|
+
globalThis.clearTimeout(late);
|
|
140
|
+
late = globalThis.setTimeout(() => check(true), RESIZE_DEBOUNCE_MS);
|
|
141
|
+
})
|
|
142
|
+
: null;
|
|
143
|
+
observer?.observe(element);
|
|
144
|
+
return () => {
|
|
145
|
+
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
|
146
|
+
globalThis.clearTimeout(late);
|
|
147
|
+
observer?.disconnect();
|
|
148
|
+
};
|
|
149
|
+
}, [stepId, element, key, cardSize?.height, compact]);
|
|
150
|
+
return compact;
|
|
151
|
+
};
|
package/js/src/web/tutorial.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from './tutorial-follow.js';
|
|
39
39
|
import { parseConnectionHash } from './connections/hash.js';
|
|
40
40
|
import { startIndex } from './tutorial-start.js';
|
|
41
|
+
import { useClearOfCard } from './tutorial-clear.js';
|
|
41
42
|
|
|
42
43
|
const el = React.createElement;
|
|
43
44
|
|
|
@@ -114,6 +115,8 @@ export const defaultSteps = [
|
|
|
114
115
|
id: 'pick',
|
|
115
116
|
connecting: true,
|
|
116
117
|
tutorialId: 'connections:list',
|
|
118
|
+
// The first service stays clear of the card (R-Z22).
|
|
119
|
+
mustShow: '[data-tutorial-id="connections:email"]',
|
|
117
120
|
route: { view: 'connections', detail: false },
|
|
118
121
|
completeAt: { view: 'connections', detail: true },
|
|
119
122
|
titleKey: 'tutorial.connections.title',
|
|
@@ -720,13 +723,19 @@ const prefersReducedMotion = () =>
|
|
|
720
723
|
// of the screen — the card goes in that room, above it, never over it.
|
|
721
724
|
const tooTall = (r, room) => r.height > (globalThis.innerHeight ?? 0) - room;
|
|
722
725
|
|
|
726
|
+
// The sticky header's bottom edge: a tall target's top goes just below
|
|
727
|
+
// it; useClearOfCard then makes room for the card (R-Z22).
|
|
728
|
+
const chromeBottom = () =>
|
|
729
|
+
globalThis.document?.querySelector('.topbar')?.getBoundingClientRect()
|
|
730
|
+
.bottom ?? 0;
|
|
731
|
+
|
|
723
732
|
const inView = (element, room) => {
|
|
724
733
|
const r = element.getBoundingClientRect();
|
|
725
734
|
const height = globalThis.innerHeight ?? 0;
|
|
726
735
|
const width = globalThis.innerWidth ?? 0;
|
|
727
736
|
const across = r.left >= 0 && r.right <= width;
|
|
728
737
|
return tooTall(r, room)
|
|
729
|
-
? across && r.top >=
|
|
738
|
+
? across && r.top >= chromeBottom() - 1 && r.top <= height / 2
|
|
730
739
|
: across && r.top >= 0 && r.bottom <= height;
|
|
731
740
|
};
|
|
732
741
|
|
|
@@ -766,7 +775,7 @@ const useRevealTarget = (stepId, element, room = 0) => {
|
|
|
766
775
|
tries === 0 && !prefersReducedMotion() ? 'smooth' : 'auto';
|
|
767
776
|
const r = element.getBoundingClientRect();
|
|
768
777
|
if (tooTall(r, room)) {
|
|
769
|
-
globalThis.scrollBy?.({ top: r.top -
|
|
778
|
+
globalThis.scrollBy?.({ top: r.top - chromeBottom(), behavior });
|
|
770
779
|
} else {
|
|
771
780
|
element.scrollIntoView?.({
|
|
772
781
|
block: 'nearest',
|
|
@@ -791,6 +800,8 @@ const useRevealTarget = (stepId, element, room = 0) => {
|
|
|
791
800
|
// Focus follows the tour once the user is in it. When the tour opens
|
|
792
801
|
// by itself on page load (nothing focused yet), the first step leaves
|
|
793
802
|
// focus alone so the skip link stays the first Tab stop.
|
|
803
|
+
const FOCUS_RETRY_MS = [120, 350, 800];
|
|
804
|
+
|
|
794
805
|
const useDialogFocus = (cardRef, stepId, visible) => {
|
|
795
806
|
const doc = globalThis.document;
|
|
796
807
|
const opened = useRef(null);
|
|
@@ -807,10 +818,31 @@ const useDialogFocus = (cardRef, stepId, visible) => {
|
|
|
807
818
|
);
|
|
808
819
|
useEffect(() => {
|
|
809
820
|
if (!visible || stepId === opened.current.quietStep) {
|
|
810
|
-
return;
|
|
821
|
+
return undefined;
|
|
811
822
|
}
|
|
812
823
|
opened.current.quietStep = null;
|
|
813
|
-
cardRef.current?.focus?.({ preventScroll: true });
|
|
824
|
+
const grab = () => cardRef.current?.focus?.({ preventScroll: true });
|
|
825
|
+
grab();
|
|
826
|
+
// The page may take focus back a moment later (a view that focuses
|
|
827
|
+
// its composer as it mounts, a renderer that was still painting):
|
|
828
|
+
// grab it again, for a short while, until the user acts (R-Z22).
|
|
829
|
+
let acted = false;
|
|
830
|
+
const onAct = () => {
|
|
831
|
+
acted = true;
|
|
832
|
+
};
|
|
833
|
+
const events = ['keydown', 'pointerdown', 'mousedown', 'touchstart'];
|
|
834
|
+
events.forEach((name) => doc?.addEventListener(name, onAct, true));
|
|
835
|
+
const timers = FOCUS_RETRY_MS.map((delay) =>
|
|
836
|
+
globalThis.setTimeout(() => {
|
|
837
|
+
if (!acted && doc?.activeElement !== cardRef.current) {
|
|
838
|
+
grab();
|
|
839
|
+
}
|
|
840
|
+
}, delay)
|
|
841
|
+
);
|
|
842
|
+
return () => {
|
|
843
|
+
timers.forEach((timer) => globalThis.clearTimeout(timer));
|
|
844
|
+
events.forEach((name) => doc?.removeEventListener(name, onAct, true));
|
|
845
|
+
};
|
|
814
846
|
}, [stepId, visible]);
|
|
815
847
|
};
|
|
816
848
|
|
|
@@ -871,6 +903,7 @@ const Step = ({
|
|
|
871
903
|
side,
|
|
872
904
|
follow,
|
|
873
905
|
arrowStyle,
|
|
906
|
+
compact = false,
|
|
874
907
|
}) => {
|
|
875
908
|
const t = useT();
|
|
876
909
|
const titleId = `tutorial-step-${step.id}`;
|
|
@@ -886,6 +919,7 @@ const Step = ({
|
|
|
886
919
|
'data-tutorial-step': step.id,
|
|
887
920
|
'data-side': side,
|
|
888
921
|
'data-follow': follow,
|
|
922
|
+
'data-compact': compact ? 'true' : undefined,
|
|
889
923
|
tabIndex: -1,
|
|
890
924
|
style,
|
|
891
925
|
},
|
|
@@ -1182,6 +1216,15 @@ const TutorialLayer = ({
|
|
|
1182
1216
|
const layout = cardLayout({ placed, cardSize, waiting });
|
|
1183
1217
|
useDialogFocus(cardRef, step.id, layout.side !== 'pending');
|
|
1184
1218
|
useDockSpace(dockSpace(layout, cardSize));
|
|
1219
|
+
const compact = useClearOfCard({
|
|
1220
|
+
cardRef,
|
|
1221
|
+
element,
|
|
1222
|
+
stepId: step.id,
|
|
1223
|
+
placed: layout,
|
|
1224
|
+
cardSize,
|
|
1225
|
+
viewportOf: viewportSize,
|
|
1226
|
+
mustShow: step.mustShow,
|
|
1227
|
+
});
|
|
1185
1228
|
return el(
|
|
1186
1229
|
'div',
|
|
1187
1230
|
{
|
|
@@ -1212,6 +1255,7 @@ const TutorialLayer = ({
|
|
|
1212
1255
|
side: layout.side,
|
|
1213
1256
|
follow: layout.mode,
|
|
1214
1257
|
arrowStyle: layout.arrowStyle,
|
|
1258
|
+
compact,
|
|
1215
1259
|
}),
|
|
1216
1260
|
]
|
|
1217
1261
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meta-sovereign",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.14",
|
|
4
4
|
"description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "js/src/index.js",
|
|
@@ -275,6 +275,8 @@
|
|
|
275
275
|
"test:e2e:scale-proxy": "node js/tests/e2e-scale-proxy-browser.mjs",
|
|
276
276
|
"test:e2e:scale-proxy:webkit": "E2E_BROWSER=webkit node js/tests/e2e-scale-proxy-browser.mjs",
|
|
277
277
|
"test:e2e:peer-bar": "node js/tests/e2e-peer-bar-browser.mjs",
|
|
278
|
+
"test:e2e:visual-effects": "node js/tests/e2e-visual-effects-browser.mjs",
|
|
279
|
+
"test:e2e:visual-effects:webkit": "E2E_BROWSER=webkit node js/tests/e2e-visual-effects-browser.mjs",
|
|
278
280
|
"test:e2e:voice-chat": "node js/tests/e2e-voice-chat-browser.mjs",
|
|
279
281
|
"test:e2e:voice-chat:webkit": "E2E_BROWSER=webkit node js/tests/e2e-voice-chat-browser.mjs",
|
|
280
282
|
"test:e2e:sounds": "node js/tests/e2e-sounds-browser.mjs",
|