meta-sovereign 0.49.11 → 0.49.13

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,33 @@ 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
+ transition-duration: 0.01ms !important;
12626
+ }
12627
+ :root[data-effects='reduced'] {
12628
+ --surface-glass-bg: var(--bg-elev);
12629
+ }
12630
+ :root[data-effects='reduced'] .chat-composer {
12631
+ background: var(--bg-elev);
12632
+ }
12633
+ :root[data-effects='reduced']
12634
+ .tutorial-overlay-spotlight
12635
+ .tutorial-spotlight-frame {
12636
+ box-shadow:
12637
+ 0 0 0 4px rgba(0, 0, 0, 0.5),
12638
+ 0 0 0 3px rgba(102, 178, 255, 1);
12639
+ }
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
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meta-sovereign",
3
- "version": "0.49.11",
3
+ "version": "0.49.13",
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",