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.
- package/js/src/web/app.css +13 -1
- package/js/src/web/tutorial-clear.js +151 -0
- package/js/src/web/tutorial.js +48 -4
- package/package.json +1 -1
package/js/src/web/app.css
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
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",
|