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.
@@ -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
+ };
@@ -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',
@@ -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': 'गहरी',
@@ -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': 'Тёмная',
@@ -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
+ };
@@ -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 >= room - 1 && r.top <= Math.max(room, height / 2)
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 - room, behavior });
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.12",
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",