meta-sovereign 0.49.13 → 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.
@@ -12622,7 +12622,12 @@ audio.remote-player {
12622
12622
  :root[data-effects='reduced'] *::after {
12623
12623
  animation-duration: 0.01ms !important;
12624
12624
  animation-iteration-count: 1 !important;
12625
- transition-duration: 0.01ms !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;
12626
12631
  }
12627
12632
  :root[data-effects='reduced'] {
12628
12633
  --surface-glass-bg: var(--bg-elev);
@@ -12637,3 +12642,10 @@ audio.remote-player {
12637
12642
  0 0 0 4px rgba(0, 0, 0, 0.5),
12638
12643
  0 0 0 3px rgba(102, 178, 255, 1);
12639
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
+ }
@@ -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.13",
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",