@strifeapp/strife 1.10.0 → 1.11.1

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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `<strife-toolbar>` — the "Öppna i Strife" site toolbar (STR-2181/2183).
3
+ *
4
+ * A browser is connected once: Studio's play button opens the page in a new tab, this script
5
+ * offers the window that opened it a connection (postMessage), Studio answers, and the script
6
+ * sets a privilege-free first-party cookie. Nothing travels in the URL. From then on the
7
+ * element renders a small island at the edge of every page of the site — never inside
8
+ * Studio's preview iframe, never when framed. Collapsed it is the Strife mark; on hover,
9
+ * focus or tap it morphs open to "Redigera i Strife" and a ×. Opening goes to
10
+ * `api.strife.app/toolbar/open` in a new tab, where Strife's own login and team membership
11
+ * decide. The × drops the cookie; opening a page from Studio again connects the browser again.
12
+ *
13
+ * Nothing runs on the site's server. Cached pages are safe: the decision is made in the
14
+ * visitor's browser.
15
+ *
16
+ * <strife-toolbar document-id={pageData?.docId}></strife-toolbar>
17
+ * <script>import '@strifeapp/strife/toolbar-element'</script>
18
+ *
19
+ * Attributes, all optional: `team` (the team id; without it Strife resolves the team from the
20
+ * page's host among the editor's own teams), `document-id` (opens exactly that document, no
21
+ * index lookup),
22
+ * `api` (defaults to https://api.strife.app), `expanded` (reflects the tapped-open state on
23
+ * touch devices), `lang` (optional; otherwise the page's `<html lang>` — Swedish for `sv`,
24
+ * English for everything else). Docks to the right edge, vertically centred; move it with a
25
+ * rule on the element itself. Style from outside with `::part(island | mark | edit | hide)`, or move it
26
+ */
27
+ export { MESSAGE_CONNECT, MESSAGE_CONNECTED, MESSAGE_READY, TOOLBAR_COOKIE } from './toolbar-shared.js';
28
+ export declare const TAG_NAME = "strife-toolbar";
29
+ /** UI strings by page language: the nearest `lang` attribute above the element (normally
30
+ * `<html lang>`), so a site can also force a language with `lang` on the element itself.
31
+ * Swedish for `sv*`, English for everything else. */
32
+ declare const STRINGS: {
33
+ readonly en: {
34
+ readonly edit: "Edit in Strife";
35
+ readonly hide: "Hide the Strife toolbar";
36
+ readonly hideShort: "Hide";
37
+ };
38
+ readonly sv: {
39
+ readonly edit: "Redigera i Strife";
40
+ readonly hide: "Dölj Strife-verktygsfältet";
41
+ readonly hideShort: "Dölj";
42
+ };
43
+ };
44
+ export declare function stringsFor(lang: string | null | undefined): (typeof STRINGS)[keyof typeof STRINGS];
45
+ /** True when the toolbar should render in this document: connected, top-level, not edit mode. */
46
+ export declare function shouldRenderToolbar(): boolean;
47
+ export declare class StrifeToolbarElement extends HTMLElement {
48
+ #private;
49
+ static get observedAttributes(): string[];
50
+ /** Pointer must rest in the catchment this long before the island unfolds (no flicker when
51
+ * the pointer merely passes through the bottom of the page). */
52
+ static readonly OPEN_DELAY_MS = 90;
53
+ /** Grace after the pointer leaves the catchment before the island folds again. */
54
+ static readonly CLOSE_GRACE_MS = 2400;
55
+ /** Hover dwell before the FIRST tooltip appears. Once one is showing the toolbar is "warm"
56
+ * (the Interest Invokers model: initial delay only) — moving to the next action shows its
57
+ * tooltip at once, and the tooltip slides instead of blinking. */
58
+ static readonly TIP_DELAY_MS = 220;
59
+ /** Leaving an action keeps the tooltip this long, so crossing the separator to the next
60
+ * action never closes it; leaving the island for good does. */
61
+ static readonly TIP_LINGER_MS = 160;
62
+ /** After the tooltip has gone, the toolbar stays warm this long — re-entering shows the
63
+ * next tooltip without the initial delay. */
64
+ static readonly TIP_WARM_MS = 400;
65
+ connectedCallback(): void;
66
+ disconnectedCallback(): void;
67
+ attributeChangedCallback(): void;
68
+ /** Re-evaluate whether to render — the browser may connect after the element upgraded. */
69
+ refresh(): void;
70
+ /** Tapped-open state (touch); hover and focus open it without touching the attribute. */
71
+ get expanded(): boolean;
72
+ set expanded(value: boolean);
73
+ /** Open the current page in Strife — a new tab, so the site stays where it is. */
74
+ open(): void;
75
+ /** Disconnect this browser: drop the cookie and go away. Play in Studio connects it again. */
76
+ hide(): void;
77
+ }
@@ -0,0 +1,639 @@
1
+ /**
2
+ * `<strife-toolbar>` — the "Öppna i Strife" site toolbar (STR-2181/2183).
3
+ *
4
+ * A browser is connected once: Studio's play button opens the page in a new tab, this script
5
+ * offers the window that opened it a connection (postMessage), Studio answers, and the script
6
+ * sets a privilege-free first-party cookie. Nothing travels in the URL. From then on the
7
+ * element renders a small island at the edge of every page of the site — never inside
8
+ * Studio's preview iframe, never when framed. Collapsed it is the Strife mark; on hover,
9
+ * focus or tap it morphs open to "Redigera i Strife" and a ×. Opening goes to
10
+ * `api.strife.app/toolbar/open` in a new tab, where Strife's own login and team membership
11
+ * decide. The × drops the cookie; opening a page from Studio again connects the browser again.
12
+ *
13
+ * Nothing runs on the site's server. Cached pages are safe: the decision is made in the
14
+ * visitor's browser.
15
+ *
16
+ * <strife-toolbar document-id={pageData?.docId}></strife-toolbar>
17
+ * <script>import '@strifeapp/strife/toolbar-element'</script>
18
+ *
19
+ * Attributes, all optional: `team` (the team id; without it Strife resolves the team from the
20
+ * page's host among the editor's own teams), `document-id` (opens exactly that document, no
21
+ * index lookup),
22
+ * `api` (defaults to https://api.strife.app), `expanded` (reflects the tapped-open state on
23
+ * touch devices), `lang` (optional; otherwise the page's `<html lang>` — Swedish for `sv`,
24
+ * English for everything else). Docks to the right edge, vertically centred; move it with a
25
+ * rule on the element itself. Style from outside with `::part(island | mark | edit | hide)`, or move it
26
+ */
27
+ var _a;
28
+ import { DEFAULT_API, EDIT_MODE_PARAM, buildOpenUrl, expiredToolbarCookie, hasToolbarCookie, offerConnection, } from './toolbar-shared.js';
29
+ export { MESSAGE_CONNECT, MESSAGE_CONNECTED, MESSAGE_READY, TOOLBAR_COOKIE } from './toolbar-shared.js';
30
+ export const TAG_NAME = 'strife-toolbar';
31
+ /** UI strings by page language: the nearest `lang` attribute above the element (normally
32
+ * `<html lang>`), so a site can also force a language with `lang` on the element itself.
33
+ * Swedish for `sv*`, English for everything else. */
34
+ const STRINGS = {
35
+ en: { edit: 'Edit in Strife', hide: 'Hide the Strife toolbar', hideShort: 'Hide' },
36
+ sv: { edit: 'Redigera i Strife', hide: 'Dölj Strife-verktygsfältet', hideShort: 'Dölj' },
37
+ };
38
+ export function stringsFor(lang) {
39
+ const code = (lang ?? '').trim().toLowerCase();
40
+ return code === 'sv' || code.startsWith('sv-') ? STRINGS.sv : STRINGS.en;
41
+ }
42
+ // The Strife mark (the two sweeps from the marketing site's navigation), filled with the
43
+ // island's text colour.
44
+ const MARK_SVG = `<svg viewBox="0 0 241 360" aria-hidden="true" focusable="false">
45
+ <path fill="currentColor" d="M229.419 0C229.419 0 97.1711 78.8463 46.7906 109.321C-3.58997 139.795 -10.2564 183.348 12.3962 220.576L188.243 115.609C243.638 79.0033 250.317 51.3008 229.419 0Z"/>
46
+ <path fill="currentColor" d="M229.419 139.412C229.419 139.412 97.1711 217.917 46.7906 248.259C-3.58997 278.602 -10.2564 321.966 12.3962 359.033L188.243 254.52C243.638 218.073 250.317 190.49 229.419 139.412Z"/>
47
+ </svg>`;
48
+ const CLOSE_SVG = `<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
49
+ <g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
50
+ <line x1="14" y1="4" x2="4" y2="14"/><line x1="4" y1="4" x2="14" y2="14"/>
51
+ </g>
52
+ </svg>`;
53
+ const EDIT_SVG = `<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
54
+ <g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
55
+ <path d="M12.5717 2.92528L2.91893 12.583C2.52903 12.973 2.52863 13.6051 2.91783 13.9957L4.00304 15.0849C4.39404 15.4749 5.02703 15.4749 5.41803 15.0849L15.0701 5.42687C15.4599 5.03677 15.4604 4.40475 15.0712 4.01415L13.9874 2.92638C13.597 2.53458 12.9627 2.53408 12.5717 2.92528Z"/>
56
+ <path d="M10.387 5.35999L12.637 7.60999"/>
57
+ </g>
58
+ <g fill="currentColor">
59
+ <path d="M7.24297 3.48999L6.29697 3.18006L5.98097 2.22998C5.87897 1.92008 5.37197 1.92008 5.26997 2.22998L4.95396 3.18006L4.00797 3.48999C3.85497 3.53999 3.75098 3.68998 3.75098 3.84998C3.75098 4.00998 3.85497 4.14995 4.00797 4.19995L4.95396 4.52014L5.26997 5.4701C5.32097 5.6201 5.46397 5.7201 5.62497 5.7201C5.78597 5.7201 5.92997 5.6201 5.97997 5.4701L6.29597 4.52014L7.24197 4.19995C7.39497 4.15005 7.49896 4.01008 7.49896 3.84998C7.49896 3.68988 7.39597 3.54009 7.24297 3.48999Z"/>
60
+ <path d="M16.658 11.99L15.395 11.57L14.974 10.3101C14.837 9.90006 14.162 9.90006 14.025 10.3101L13.604 11.57L12.341 11.99C12.137 12.0601 11.999 12.25 11.999 12.46C11.999 12.6801 12.137 12.8699 12.341 12.9399L13.604 13.36L14.025 14.62C14.093 14.83 14.285 14.96 14.5 14.96C14.715 14.96 14.906 14.83 14.975 14.62L15.396 13.36L16.659 12.9399C16.863 12.87 17.001 12.6801 17.001 12.46C17.001 12.25 16.862 12.0601 16.658 11.99Z"/>
61
+ <path d="M9.25 2.5C9.664 2.5 10 2.16 10 1.75C10 1.34 9.664 1 9.25 1C8.836 1 8.5 1.34 8.5 1.75C8.5 2.16 8.836 2.5 9.25 2.5Z"/>
62
+ </g>
63
+ </svg>`;
64
+ // A right-docked tab (Adam's reference: Nuxt DevTools' island, turned on its side). Closed it
65
+ // is a half-pill peeking in from the right edge with the mark; open it lifts off the edge into
66
+ // a vertical pill and grows DOWNWARD with icon actions — open in Strife, hide — each with a
67
+ // tooltip to its left, since text cannot run down a 34 px column. The morph is a grid whose
68
+ // actions row animates 0fr → 1fr (Baseline: animatable grid tracks), so the pill grows to its
69
+ // content's real height without measuring. The edge treatment is the chip's signature from
70
+ // @strifeapp/ui — a lighter top hairline fading to a darker bottom one, painted as a
71
+ // border-box gradient under the padding-box background — with the top edge pushed a notch
72
+ // brighter plus an inset highlight, so the plate reads as lit from above. The tooltip mirrors
73
+ // str-tooltip's look (dark plate, half-pixel border, x-small type, 65 ms fade+scale) without
74
+ // its Lit machinery: this package stays dependency-free. Where the browser has Interest Invokers
75
+ // (interestfor + popover="hint", Chrome/Edge 142+) the tooltips are native, in the top layer
76
+ // and anchored by the browser; elsewhere a JS-driven layer emulates the warm-toolbar behaviour.
77
+ const STYLES = `
78
+ :host {
79
+ all: initial;
80
+ display: block;
81
+ position: fixed;
82
+ inset: 50% 0 auto auto;
83
+ /* Anchor the MARK's centre at 50%: the host grows downward when open, so centre by a
84
+ constant (catchment padding + half the mark) instead of the host's own height. */
85
+ translate: 0 calc(-1 * (var(--_catch) + var(--_mark-h) / 2));
86
+ z-index: 2147483000;
87
+ color-scheme: dark;
88
+ font: 500 0.6875rem/1 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
89
+ letter-spacing: 0.01em;
90
+ -webkit-font-smoothing: antialiased;
91
+ /* Invisible catchment: the host's padding receives pointer events, so hovering NEAR the
92
+ island opens it and a short drift outside the pill does not close it. */
93
+ --_catch: 1.5rem;
94
+ padding: var(--_catch) 0 var(--_catch) var(--_catch);
95
+
96
+ --_mark-h: 2.5rem;
97
+ --_w-closed: 1.625rem;
98
+ --_w-open: 2.125rem;
99
+ --_lift: 0.625rem;
100
+ --_bg: #151517;
101
+ --_fg: #d4d4d8;
102
+ --_fg-strong: #f4f4f5;
103
+ --_muted: #a1a1aa;
104
+ --_edge-top: rgba(255, 255, 255, 0.22);
105
+ --_edge-bottom: rgba(255, 255, 255, 0.05);
106
+ --_hairline: rgba(255, 255, 255, 0.11);
107
+ --_hover: rgba(255, 255, 255, 0.06);
108
+ --_tip-bg: #2a2a2a;
109
+ --_tip-border: rgba(255, 255, 255, 0.12);
110
+ --_ease-spring: cubic-bezier(0.22, 1.2, 0.36, 1);
111
+ --_ease-out: cubic-bezier(0.4, 0, 0.2, 1);
112
+ --_expand: 420ms;
113
+ --_collapse: 280ms;
114
+ }
115
+ @supports (transition-timing-function: linear(0, 1)) {
116
+ :host {
117
+ --_ease-spring: linear(
118
+ 0, 0.006, 0.025 2.8%, 0.101 6.1%, 0.539 18.9%, 0.721 25.3%, 0.849 31.5%,
119
+ 0.937 38.1%, 0.968 41.8%, 0.991 45.7%, 1.006 50.1%, 1.015 55%, 1.017 63.9%, 1.001
120
+ );
121
+ }
122
+ }
123
+ :host([hidden]) { display: none; }
124
+ @media print { :host { display: none; } }
125
+
126
+ .island {
127
+ position: relative;
128
+ display: grid;
129
+ grid-template-rows: var(--_mark-h) 0fr;
130
+ grid-template-columns: var(--_w-closed);
131
+ justify-items: stretch;
132
+ margin-inline-start: auto;
133
+ margin-inline-end: 0;
134
+ box-sizing: border-box;
135
+ border: 1px solid transparent;
136
+ border-inline-end-width: 0;
137
+ border-radius: 9999px 0 0 9999px;
138
+ color: var(--_fg);
139
+ background:
140
+ linear-gradient(var(--_bg), var(--_bg)) padding-box,
141
+ linear-gradient(to bottom, var(--_edge-top), var(--_edge-bottom)) border-box;
142
+ box-shadow:
143
+ inset 0 1px 0 rgba(255, 255, 255, 0.09),
144
+ -2px 0 10px rgba(0, 0, 0, 0.22);
145
+ overflow: clip;
146
+ transition:
147
+ grid-template-rows var(--_collapse) var(--_ease-out),
148
+ grid-template-columns var(--_collapse) var(--_ease-out),
149
+ margin-inline-end var(--_collapse) var(--_ease-out),
150
+ border-radius var(--_collapse) var(--_ease-out),
151
+ box-shadow 200ms var(--_ease-out);
152
+ }
153
+ .island.is-open {
154
+ grid-template-rows: var(--_mark-h) 1fr;
155
+ grid-template-columns: var(--_w-open);
156
+ margin-inline-end: var(--_lift);
157
+ border-inline-end-width: 1px;
158
+ border-radius: 9999px;
159
+ box-shadow:
160
+ inset 0 1px 0 rgba(255, 255, 255, 0.1),
161
+ 0 4px 14px rgba(0, 0, 0, 0.3),
162
+ 0 1px 2px rgba(0, 0, 0, 0.25);
163
+ transition:
164
+ grid-template-rows var(--_expand) var(--_ease-spring),
165
+ grid-template-columns var(--_expand) var(--_ease-spring),
166
+ margin-inline-end var(--_expand) var(--_ease-spring),
167
+ border-radius var(--_expand) var(--_ease-out),
168
+ box-shadow 200ms var(--_ease-out);
169
+ }
170
+
171
+ button {
172
+ all: unset;
173
+ box-sizing: border-box;
174
+ cursor: pointer;
175
+ color: inherit;
176
+ white-space: nowrap;
177
+ transition: background-color 120ms var(--_ease-out), color 120ms var(--_ease-out);
178
+ }
179
+ button:focus-visible {
180
+ outline: 2px solid var(--_fg-strong);
181
+ outline-offset: -3px;
182
+ border-radius: 9999px;
183
+ }
184
+
185
+ .mark {
186
+ display: grid;
187
+ place-items: center;
188
+ block-size: var(--_mark-h);
189
+ inline-size: 100%;
190
+ padding: 0;
191
+ color: var(--_muted);
192
+ /* The mark is the handle, not a button into Strife: a pointer gets the plain arrow, since
193
+ clicking it does nothing. Touch, where the tap unfolds the island, shows no cursor. */
194
+ cursor: default;
195
+ }
196
+ .mark svg {
197
+ display: block;
198
+ inline-size: 0.8125rem;
199
+ block-size: 0.8125rem;
200
+ /* Docked half-pill: the visible half sits left of centre, so the mark rides a touch
201
+ towards the edge while closed and settles back when the pill lifts. */
202
+ translate: 0.0625rem 0;
203
+ transition: translate var(--_collapse) var(--_ease-out);
204
+ }
205
+ .island.is-open .mark svg { translate: 0 0; }
206
+
207
+ .actions {
208
+ display: flex;
209
+ flex-direction: column;
210
+ align-items: stretch;
211
+ min-block-size: 0;
212
+ overflow: clip;
213
+ opacity: 0;
214
+ translate: 0 -0.3rem;
215
+ transition: opacity 120ms var(--_ease-out), translate var(--_collapse) var(--_ease-out);
216
+ }
217
+ .island.is-open .actions {
218
+ opacity: 1;
219
+ translate: 0 0;
220
+ transition: opacity 200ms var(--_ease-out) 100ms, translate var(--_expand) var(--_ease-spring);
221
+ }
222
+ .sep {
223
+ align-self: center;
224
+ block-size: 1px;
225
+ inline-size: 0.875rem;
226
+ background: var(--_hairline);
227
+ flex: none;
228
+ }
229
+ .action {
230
+ display: grid;
231
+ place-items: center;
232
+ block-size: 2.125rem;
233
+ color: var(--_muted);
234
+ /* The hover plate is an inset disc, not the whole cell: 4px in from the pill's edge and
235
+ concentric with it. The cell's radius equals the pill's (fully round) and
236
+ background-clip: content-box lets the browser derive the plate's radius by the
237
+ nested-radius rule (outer minus inset), so it stays concentric if the pill's width or
238
+ the inset ever changes. */
239
+ --_plate-inset: 0.25rem;
240
+ padding: var(--_plate-inset);
241
+ border-radius: 9999px;
242
+ background-clip: content-box;
243
+ }
244
+ .action:hover { background-color: var(--_hover); color: var(--_fg-strong); }
245
+ .action:focus-visible { outline-offset: -4px; }
246
+ .action svg {
247
+ inline-size: 0.9375rem;
248
+ block-size: 0.9375rem;
249
+ }
250
+
251
+ /* Native tooltips (Interest Invokers): popover="hint" targets sit in the top layer, so the
252
+ island's clipping is irrelevant, and the invoker is their implicit anchor. First hover waits
253
+ the start delay; once any action has interest the toolbar is warm and the next tooltip
254
+ shows at once (interest-delay-start: 0s), leaving without delay when interest ends. */
255
+ .action {
256
+ interest-delay-start: 220ms;
257
+ interest-delay-end: 160ms;
258
+ }
259
+ .actions:has(:interest-source) .action {
260
+ interest-delay-start: 0s;
261
+ }
262
+ .tip-native {
263
+ margin: 0;
264
+ padding: 0.25rem 0.5rem;
265
+ border: 0.5px solid var(--_tip-border);
266
+ border-radius: 0.375rem;
267
+ background: var(--_tip-bg);
268
+ color: var(--_fg);
269
+ font: inherit;
270
+ font-size: 0.75rem;
271
+ white-space: nowrap;
272
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
273
+ inset: auto;
274
+ position-area: inline-start center;
275
+ margin-inline-end: 0.5rem;
276
+ opacity: 0;
277
+ scale: 0.9;
278
+ transition:
279
+ opacity 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
280
+ scale 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
281
+ display 65ms allow-discrete,
282
+ overlay 65ms allow-discrete;
283
+ }
284
+ .tip-native:popover-open {
285
+ opacity: 1;
286
+ scale: 1;
287
+ @starting-style {
288
+ opacity: 0;
289
+ scale: 0.9;
290
+ }
291
+ }
292
+
293
+ /* JS fallback tooltip layer — outside the clipped island, to the left of the hovered action. */
294
+ .tip {
295
+ position: absolute;
296
+ right: 100%;
297
+ top: 0;
298
+ margin-inline-end: 0.5rem;
299
+ padding: 0.25rem 0.5rem;
300
+ border: 0.5px solid var(--_tip-border);
301
+ border-radius: 0.375rem;
302
+ background: var(--_tip-bg);
303
+ color: var(--_fg);
304
+ font-size: 0.75rem;
305
+ white-space: nowrap;
306
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
307
+ pointer-events: none;
308
+ opacity: 0;
309
+ scale: 0.9;
310
+ transform-origin: right center;
311
+ transition: opacity 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94), scale 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
312
+ }
313
+ .tip.is-visible {
314
+ opacity: 1;
315
+ scale: 1;
316
+ /* Warm toolbar: the tooltip glides to the next action instead of blinking. */
317
+ transition:
318
+ top 140ms var(--_ease-out),
319
+ opacity 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
320
+ scale 65ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
321
+ }
322
+
323
+ @media (prefers-reduced-motion: reduce) {
324
+ .island, .island.is-open, .actions, .island.is-open .actions, .mark svg, .tip {
325
+ transition: none;
326
+ }
327
+ }
328
+ `;
329
+ /** Live elements on the page, hidden or not: a connect that arrives after they upgraded must
330
+ * make the hidden ones render. */
331
+ const instances = new Set();
332
+ /** The handshake (`offerConnection` in toolbar-shared.ts) wired to this page's `window`. */
333
+ function offerThisPage() {
334
+ if (typeof window === 'undefined' || typeof document === 'undefined')
335
+ return;
336
+ offerConnection({
337
+ isTopLevel: window.top === window.self,
338
+ opener: window.opener,
339
+ cookies: document.cookie,
340
+ protocol: window.location.protocol,
341
+ setCookie: (value) => {
342
+ document.cookie = value;
343
+ },
344
+ addListener: (listener) => window.addEventListener('message', listener),
345
+ removeListener: (listener) => window.removeEventListener('message', listener),
346
+ onConnected: () => {
347
+ for (const element of instances)
348
+ element.refresh();
349
+ },
350
+ });
351
+ }
352
+ /** True when the toolbar should render in this document: connected, top-level, not edit mode. */
353
+ export function shouldRenderToolbar() {
354
+ if (typeof window === 'undefined' || typeof document === 'undefined')
355
+ return false;
356
+ if (window.top !== window.self)
357
+ return false;
358
+ if (new URL(window.location.href).searchParams.has(EDIT_MODE_PARAM))
359
+ return false;
360
+ return hasToolbarCookie(document.cookie);
361
+ }
362
+ let sharedSheet = null;
363
+ /** Styles as a constructed stylesheet (allowed under a strict style-src CSP), with a <style>
364
+ * fallback for engines that lack adoptedStyleSheets. */
365
+ function applyStyles(root) {
366
+ if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
367
+ try {
368
+ if (!sharedSheet) {
369
+ sharedSheet = new CSSStyleSheet();
370
+ sharedSheet.replaceSync(STYLES);
371
+ }
372
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sharedSheet];
373
+ return;
374
+ }
375
+ catch {
376
+ // fall through to the <style> element
377
+ }
378
+ }
379
+ const style = document.createElement('style');
380
+ style.textContent = STYLES;
381
+ root.appendChild(style);
382
+ }
383
+ /** Interest Invokers (`interestfor` + popover="hint"): the browser owns the tooltip's show/hide
384
+ * delays, the warm-toolbar handoff, keyboard interest and the top layer. Detected by the IDL
385
+ * attribute; where absent the element runs its own JS tooltip. */
386
+ function interestInvokersSupported() {
387
+ return typeof HTMLElement !== 'undefined' && 'interestForElement' in HTMLElement.prototype;
388
+ }
389
+ function hoverCapable() {
390
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
391
+ ? window.matchMedia('(hover: hover)').matches
392
+ : true;
393
+ }
394
+ export class StrifeToolbarElement extends HTMLElement {
395
+ static get observedAttributes() {
396
+ return ['expanded'];
397
+ }
398
+ #root = null;
399
+ #island = null;
400
+ #mark = null;
401
+ #tip = null;
402
+ #tipTimer = null;
403
+ #tipHideTimer = null;
404
+ #tipVisible = false;
405
+ #tipHiddenAt = 0;
406
+ #hovered = false;
407
+ #focused = false;
408
+ #openTimer = null;
409
+ #closeTimer = null;
410
+ /** Pointer must rest in the catchment this long before the island unfolds (no flicker when
411
+ * the pointer merely passes through the bottom of the page). */
412
+ static OPEN_DELAY_MS = 90;
413
+ /** Grace after the pointer leaves the catchment before the island folds again. */
414
+ static CLOSE_GRACE_MS = 2400;
415
+ /** Hover dwell before the FIRST tooltip appears. Once one is showing the toolbar is "warm"
416
+ * (the Interest Invokers model: initial delay only) — moving to the next action shows its
417
+ * tooltip at once, and the tooltip slides instead of blinking. */
418
+ static TIP_DELAY_MS = 220;
419
+ /** Leaving an action keeps the tooltip this long, so crossing the separator to the next
420
+ * action never closes it; leaving the island for good does. */
421
+ static TIP_LINGER_MS = 160;
422
+ /** After the tooltip has gone, the toolbar stays warm this long — re-entering shows the
423
+ * next tooltip without the initial delay. */
424
+ static TIP_WARM_MS = 400;
425
+ connectedCallback() {
426
+ instances.add(this);
427
+ if (!shouldRenderToolbar()) {
428
+ this.hidden = true;
429
+ return;
430
+ }
431
+ this.hidden = false;
432
+ if (!this.#root) {
433
+ this.#root = this.attachShadow({ mode: 'open' });
434
+ applyStyles(this.#root);
435
+ this.#render();
436
+ }
437
+ document.addEventListener('pointerdown', this.#onDocumentPointerDown, true);
438
+ document.addEventListener('keydown', this.#onKeyDown);
439
+ this.#sync();
440
+ }
441
+ disconnectedCallback() {
442
+ instances.delete(this);
443
+ document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
444
+ document.removeEventListener('keydown', this.#onKeyDown);
445
+ this.#clearTimers();
446
+ }
447
+ #clearTimers() {
448
+ if (this.#openTimer !== null)
449
+ clearTimeout(this.#openTimer);
450
+ if (this.#closeTimer !== null)
451
+ clearTimeout(this.#closeTimer);
452
+ if (this.#tipTimer !== null)
453
+ clearTimeout(this.#tipTimer);
454
+ if (this.#tipHideTimer !== null)
455
+ clearTimeout(this.#tipHideTimer);
456
+ this.#openTimer = null;
457
+ this.#closeTimer = null;
458
+ this.#tipTimer = null;
459
+ this.#tipHideTimer = null;
460
+ }
461
+ attributeChangedCallback() {
462
+ this.#sync();
463
+ }
464
+ /** Re-evaluate whether to render — the browser may connect after the element upgraded. */
465
+ refresh() {
466
+ if (this.isConnected)
467
+ this.connectedCallback();
468
+ }
469
+ /** Tapped-open state (touch); hover and focus open it without touching the attribute. */
470
+ get expanded() {
471
+ return this.hasAttribute('expanded');
472
+ }
473
+ set expanded(value) {
474
+ this.toggleAttribute('expanded', value);
475
+ }
476
+ /** Open the current page in Strife — a new tab, so the site stays where it is. */
477
+ open() {
478
+ const url = buildOpenUrl(this.getAttribute('api') || DEFAULT_API, this.getAttribute('team'), this.getAttribute('document-id'), window.location.href);
479
+ window.open(url, '_blank', 'noopener');
480
+ }
481
+ /** Disconnect this browser: drop the cookie and go away. Play in Studio connects it again. */
482
+ hide() {
483
+ document.cookie = expiredToolbarCookie();
484
+ this.remove();
485
+ }
486
+ #render() {
487
+ const root = this.#root;
488
+ if (!root)
489
+ return;
490
+ const t = stringsFor(this.closest('[lang]')?.getAttribute('lang'));
491
+ const native = interestInvokersSupported();
492
+ // Native path: each action declares interest in its own popover="hint" tooltip; the
493
+ // browser handles delays, handoff, focus and Escape. Fallback path: a single JS-driven
494
+ // tooltip layer driven by pointer/focus events below.
495
+ const tipFor = (id, text) => native ? `<span popover="hint" id="${id}" class="tip-native" part="tooltip" role="tooltip">${text}</span>` : '';
496
+ const interest = (id) => (native ? ` interestfor="${id}"` : '');
497
+ root.innerHTML = `
498
+ <div class="island" part="island">
499
+ <button type="button" class="mark" part="mark" aria-label="Strife" aria-expanded="false">${MARK_SVG}</button>
500
+ <div class="actions">
501
+ <span class="sep" aria-hidden="true"></span>
502
+ <button type="button" class="action edit" part="edit" aria-label="${t.edit}" data-tip="${t.edit}"${interest('tip-edit')}>${EDIT_SVG}</button>
503
+ ${tipFor('tip-edit', t.edit)}
504
+ <span class="sep" aria-hidden="true"></span>
505
+ <button type="button" class="action hide" part="hide" aria-label="${t.hide}" data-tip="${t.hideShort}"${interest('tip-hide')}>${CLOSE_SVG}</button>
506
+ ${tipFor('tip-hide', t.hideShort)}
507
+ </div>
508
+ </div>
509
+ ${native ? '' : '<div class="tip" role="tooltip" aria-hidden="true"></div>'}`;
510
+ this.#island = root.querySelector('.island');
511
+ this.#mark = root.querySelector('.mark');
512
+ this.#tip = root.querySelector('.tip');
513
+ this.#mark?.addEventListener('click', () => {
514
+ // The mark is the toolbar's handle, not a shortcut into Strife: only "Edit in Strife"
515
+ // opens a document. On touch the first tap unfolds the island; with a pointer, hover
516
+ // (and keyboard focus) already does that, so a click has nothing left to do.
517
+ if (!hoverCapable())
518
+ this.expanded = !this.expanded;
519
+ });
520
+ root.querySelector('.edit')?.addEventListener('click', () => this.open());
521
+ root.querySelector('.hide')?.addEventListener('click', () => this.hide());
522
+ if (!native) {
523
+ for (const action of Array.from(root.querySelectorAll('.action'))) {
524
+ action.addEventListener('pointerenter', () => this.#showTip(action));
525
+ action.addEventListener('pointerleave', () => this.#hideTip());
526
+ action.addEventListener('focus', () => this.#showTip(action));
527
+ action.addEventListener('blur', () => this.#hideTip(true));
528
+ }
529
+ }
530
+ // Hover intent on the HOST (island + invisible catchment), not the pill itself.
531
+ this.addEventListener('pointerenter', (e) => {
532
+ if (e.pointerType !== 'mouse')
533
+ return;
534
+ this.#clearTimers();
535
+ this.#openTimer = setTimeout(() => {
536
+ this.#hovered = true;
537
+ this.#sync();
538
+ }, _a.OPEN_DELAY_MS);
539
+ });
540
+ this.addEventListener('pointerleave', () => {
541
+ this.#clearTimers();
542
+ this.#closeTimer = setTimeout(() => {
543
+ this.#hovered = false;
544
+ this.#sync();
545
+ }, _a.CLOSE_GRACE_MS);
546
+ });
547
+ root.addEventListener('focusin', () => { this.#focused = true; this.#sync(); });
548
+ root.addEventListener('focusout', (e) => {
549
+ const next = e.relatedTarget;
550
+ if (next && root.contains(next))
551
+ return;
552
+ this.#focused = false;
553
+ this.#sync();
554
+ });
555
+ }
556
+ /** Show an action's tooltip to its left, vertically centred on it. First show waits for
557
+ * the dwell; while warm it appears (or slides over) immediately. */
558
+ #showTip(action) {
559
+ const tip = this.#tip;
560
+ if (!tip)
561
+ return;
562
+ if (this.#tipHideTimer !== null)
563
+ clearTimeout(this.#tipHideTimer);
564
+ this.#tipHideTimer = null;
565
+ if (this.#tipTimer !== null)
566
+ clearTimeout(this.#tipTimer);
567
+ this.#tipTimer = null;
568
+ const place = () => {
569
+ const host = this.getBoundingClientRect();
570
+ const box = action.getBoundingClientRect();
571
+ tip.textContent = action.dataset.tip ?? '';
572
+ tip.style.top = `${box.top - host.top + box.height / 2}px`;
573
+ tip.style.translate = '0 -50%';
574
+ tip.setAttribute('aria-hidden', 'false');
575
+ tip.classList.add('is-visible');
576
+ this.#tipVisible = true;
577
+ };
578
+ const warm = this.#tipVisible || Date.now() - this.#tipHiddenAt < _a.TIP_WARM_MS;
579
+ if (warm)
580
+ place();
581
+ else
582
+ this.#tipTimer = setTimeout(place, _a.TIP_DELAY_MS);
583
+ }
584
+ /** Called when the pointer leaves an action: linger briefly so the next action can take
585
+ * over the same tooltip; hide only if nothing does. */
586
+ #hideTip(immediate = false) {
587
+ if (this.#tipTimer !== null)
588
+ clearTimeout(this.#tipTimer);
589
+ this.#tipTimer = null;
590
+ if (this.#tipHideTimer !== null)
591
+ clearTimeout(this.#tipHideTimer);
592
+ const hide = () => {
593
+ this.#tipHideTimer = null;
594
+ if (!this.#tipVisible)
595
+ return;
596
+ this.#tipVisible = false;
597
+ this.#tipHiddenAt = Date.now();
598
+ this.#tip?.classList.remove('is-visible');
599
+ this.#tip?.setAttribute('aria-hidden', 'true');
600
+ };
601
+ if (immediate)
602
+ hide();
603
+ else
604
+ this.#tipHideTimer = setTimeout(hide, _a.TIP_LINGER_MS);
605
+ }
606
+ #isOpen() {
607
+ return this.expanded || this.#hovered || this.#focused;
608
+ }
609
+ #sync() {
610
+ const open = this.#isOpen();
611
+ this.#island?.classList.toggle('is-open', open);
612
+ this.#mark?.setAttribute('aria-expanded', String(open));
613
+ if (!open)
614
+ this.#hideTip(true);
615
+ }
616
+ #onDocumentPointerDown = (e) => {
617
+ if (!this.expanded)
618
+ return;
619
+ if (e.composedPath().includes(this))
620
+ return;
621
+ this.expanded = false;
622
+ };
623
+ #onKeyDown = (e) => {
624
+ if (e.key !== 'Escape' || !this.#isOpen())
625
+ return;
626
+ this.#clearTimers();
627
+ this.expanded = false;
628
+ this.#hovered = false;
629
+ this.#focused = false;
630
+ this.#root?.activeElement?.blur();
631
+ this.#sync();
632
+ };
633
+ }
634
+ _a = StrifeToolbarElement;
635
+ // Listen before the element upgrades, so a connect that arrives later still reaches it.
636
+ offerThisPage();
637
+ if (typeof customElements !== 'undefined' && !customElements.get(TAG_NAME)) {
638
+ customElements.define(TAG_NAME, StrifeToolbarElement);
639
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Dependency-free helpers behind `<strife-toolbar>` (`@strifeapp/strife/toolbar-element`).
3
+ * Kept apart from the element so the decisions are unit-testable without a DOM: the element
4
+ * is a thin caller. Everything here runs unchanged in Node and the browser.
5
+ */
6
+ /** First-party cookie that marks a browser as connected to Strife. Privilege-free: it only
7
+ * decides whether the toolbar renders — opening Strife still goes through Strife's own login. */
8
+ export declare const TOOLBAR_COOKIE = "strife-toolbar";
9
+ export declare const TOOLBAR_COOKIE_VALUE = "1";
10
+ /** 30 days requested. Safari caps script-set cookies to seven days of Safari use without a
11
+ * visit to the site; an editor who browses the site weekly keeps the connection. */
12
+ export declare const TOOLBAR_COOKIE_MAX_AGE_SECONDS: number;
13
+ /**
14
+ * Connecting is a handshake with the window that opened this page. Studio's play button opens
15
+ * the site with `window.open`, so the new tab has an opener: the element posts READY to it,
16
+ * Studio answers CONNECT, the element sets the cookie and answers CONNECTED. Nothing travels
17
+ * in the URL — no server, cache or analytics ever sees a marker — and a site without the
18
+ * element never posts READY, so a Studio tab that opened it hears nothing and does nothing.
19
+ * The Studio side of the contract lives in `src/app/wwwroot/scripts/functions/openPublicPage.js`.
20
+ */
21
+ export declare const MESSAGE_READY = "strife-toolbar:ready";
22
+ export declare const MESSAGE_CONNECT = "strife-toolbar:connect";
23
+ export declare const MESSAGE_CONNECTED = "strife-toolbar:connected";
24
+ /** Studio's live-preview (edit-mode) token — the toolbar never renders inside the editor's iframe. */
25
+ export declare const EDIT_MODE_PARAM = "token";
26
+ export declare const DEFAULT_API = "https://api.strife.app";
27
+ /** True when a Cookie header / `document.cookie` string carries the connected marker. */
28
+ export declare function hasToolbarCookie(cookies: string | null | undefined): boolean;
29
+ /** The `document.cookie` string that connects this browser. `Secure` only over https, so a
30
+ * plain-http local site still works; `SameSite=Lax` because the cookie is only ever read
31
+ * first-party. Host-only (no `Domain`). */
32
+ export declare function toolbarCookie(secure: boolean): string;
33
+ /** The `document.cookie` string that disconnects this browser. */
34
+ export declare function expiredToolbarCookie(): string;
35
+ /** The page URL without its fragment. */
36
+ export declare function withoutHash(href: string): string;
37
+ export interface ToolbarMessage {
38
+ type: typeof MESSAGE_READY | typeof MESSAGE_CONNECT | typeof MESSAGE_CONNECTED;
39
+ }
40
+ export declare function readyMessage(): ToolbarMessage;
41
+ export declare function connectMessage(): ToolbarMessage;
42
+ export declare function connectedMessage(): ToolbarMessage;
43
+ /** True when `data` (a `MessageEvent.data`) is a toolbar message of exactly this type. */
44
+ export declare function isToolbarMessage(data: unknown, type: ToolbarMessage['type']): boolean;
45
+ /**
46
+ * Whether a CONNECT message may connect this browser: only from the window that opened this
47
+ * page (`window.opener`), which must still exist. Any other sender — an iframe on the page,
48
+ * an extension, a stray postMessage — is ignored. The check is by identity, so it survives
49
+ * the site redirecting (apex → www, http → https) after Studio opened it.
50
+ */
51
+ export declare function shouldAcceptConnect(data: unknown, source: MessageEventSource | null | undefined, opener: Window | null | undefined): boolean;
52
+ /** What the handshake needs from the page. The element builds it from `window`; tests build
53
+ * it by hand. */
54
+ export interface HandshakeContext {
55
+ /** `window.top === window.self` — a framed page (Studio's preview) must never connect. */
56
+ isTopLevel: boolean;
57
+ /** `window.opener` at script start; null when the page was not opened by another window. */
58
+ opener: Window | null | undefined;
59
+ /** `document.cookie` at script start. */
60
+ cookies: string;
61
+ /** `location.protocol` — decides the cookie's `Secure` attribute. */
62
+ protocol: string;
63
+ setCookie(value: string): void;
64
+ addListener(listener: (event: MessageEvent) => void): void;
65
+ removeListener(listener: (event: MessageEvent) => void): void;
66
+ /** Called once the cookie is set, so live elements can render. */
67
+ onConnected(): void;
68
+ }
69
+ /**
70
+ * Offer the window that opened this page (Studio, via the play button) to connect this
71
+ * browser. Top-level pages with an opener only: a framed page and a page opened from the
72
+ * address bar have nothing to talk to, and an already connected browser has nothing to ask.
73
+ * The listener is registered before READY goes out, so Studio's answer cannot slip past it,
74
+ * and CONNECT is accepted only from the opener captured here — a site that nulls
75
+ * `window.opener` after load (a common tabnabbing guard) must not break a handshake that is
76
+ * already under way. A page opened by anything other than Studio posts READY into the void.
77
+ * Returns true when an offer went out.
78
+ */
79
+ export declare function offerConnection(ctx: HandshakeContext): boolean;
80
+ /** Strife API origin with any trailing slash removed; defaults to production. */
81
+ export declare function normalizeApi(api: string | null | undefined): string;
82
+ /** "Öppna i Strife". The page URL always goes along: the API resolves the team from its host
83
+ * among the caller's own teams when `teamId` is not given, and resolves the document from
84
+ * its path when `documentId` is not given. A document id skips the index lookup and covers
85
+ * virtual sub-pages; a team id skips the host lookup. */
86
+ export declare function buildOpenUrl(api: string | null | undefined, teamId: string | null | undefined, documentId: string | null | undefined, href: string): string;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Dependency-free helpers behind `<strife-toolbar>` (`@strifeapp/strife/toolbar-element`).
3
+ * Kept apart from the element so the decisions are unit-testable without a DOM: the element
4
+ * is a thin caller. Everything here runs unchanged in Node and the browser.
5
+ */
6
+ /** First-party cookie that marks a browser as connected to Strife. Privilege-free: it only
7
+ * decides whether the toolbar renders — opening Strife still goes through Strife's own login. */
8
+ export const TOOLBAR_COOKIE = 'strife-toolbar';
9
+ export const TOOLBAR_COOKIE_VALUE = '1';
10
+ /** 30 days requested. Safari caps script-set cookies to seven days of Safari use without a
11
+ * visit to the site; an editor who browses the site weekly keeps the connection. */
12
+ export const TOOLBAR_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
13
+ /**
14
+ * Connecting is a handshake with the window that opened this page. Studio's play button opens
15
+ * the site with `window.open`, so the new tab has an opener: the element posts READY to it,
16
+ * Studio answers CONNECT, the element sets the cookie and answers CONNECTED. Nothing travels
17
+ * in the URL — no server, cache or analytics ever sees a marker — and a site without the
18
+ * element never posts READY, so a Studio tab that opened it hears nothing and does nothing.
19
+ * The Studio side of the contract lives in `src/app/wwwroot/scripts/functions/openPublicPage.js`.
20
+ */
21
+ export const MESSAGE_READY = 'strife-toolbar:ready';
22
+ export const MESSAGE_CONNECT = 'strife-toolbar:connect';
23
+ export const MESSAGE_CONNECTED = 'strife-toolbar:connected';
24
+ /** Studio's live-preview (edit-mode) token — the toolbar never renders inside the editor's iframe. */
25
+ export const EDIT_MODE_PARAM = 'token';
26
+ export const DEFAULT_API = 'https://api.strife.app';
27
+ /** True when a Cookie header / `document.cookie` string carries the connected marker. */
28
+ export function hasToolbarCookie(cookies) {
29
+ if (!cookies)
30
+ return false;
31
+ for (const part of cookies.split(';')) {
32
+ const eq = part.indexOf('=');
33
+ if (eq < 0)
34
+ continue;
35
+ if (part.slice(0, eq).trim() !== TOOLBAR_COOKIE)
36
+ continue;
37
+ if (part.slice(eq + 1).trim() === TOOLBAR_COOKIE_VALUE)
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ /** The `document.cookie` string that connects this browser. `Secure` only over https, so a
43
+ * plain-http local site still works; `SameSite=Lax` because the cookie is only ever read
44
+ * first-party. Host-only (no `Domain`). */
45
+ export function toolbarCookie(secure) {
46
+ const attributes = [
47
+ `${TOOLBAR_COOKIE}=${TOOLBAR_COOKIE_VALUE}`,
48
+ `Max-Age=${TOOLBAR_COOKIE_MAX_AGE_SECONDS}`,
49
+ 'Path=/',
50
+ 'SameSite=Lax',
51
+ ];
52
+ if (secure)
53
+ attributes.push('Secure');
54
+ return attributes.join('; ');
55
+ }
56
+ /** The `document.cookie` string that disconnects this browser. */
57
+ export function expiredToolbarCookie() {
58
+ return `${TOOLBAR_COOKIE}=; Max-Age=0; Path=/`;
59
+ }
60
+ /** The page URL without its fragment. */
61
+ export function withoutHash(href) {
62
+ const url = new URL(href);
63
+ url.hash = '';
64
+ return url.toString();
65
+ }
66
+ export function readyMessage() {
67
+ return { type: MESSAGE_READY };
68
+ }
69
+ export function connectMessage() {
70
+ return { type: MESSAGE_CONNECT };
71
+ }
72
+ export function connectedMessage() {
73
+ return { type: MESSAGE_CONNECTED };
74
+ }
75
+ /** True when `data` (a `MessageEvent.data`) is a toolbar message of exactly this type. */
76
+ export function isToolbarMessage(data, type) {
77
+ return typeof data === 'object' && data !== null && data.type === type;
78
+ }
79
+ /**
80
+ * Whether a CONNECT message may connect this browser: only from the window that opened this
81
+ * page (`window.opener`), which must still exist. Any other sender — an iframe on the page,
82
+ * an extension, a stray postMessage — is ignored. The check is by identity, so it survives
83
+ * the site redirecting (apex → www, http → https) after Studio opened it.
84
+ */
85
+ export function shouldAcceptConnect(data, source, opener) {
86
+ if (opener === null || opener === undefined)
87
+ return false;
88
+ if (source !== opener)
89
+ return false;
90
+ return isToolbarMessage(data, MESSAGE_CONNECT);
91
+ }
92
+ /**
93
+ * Offer the window that opened this page (Studio, via the play button) to connect this
94
+ * browser. Top-level pages with an opener only: a framed page and a page opened from the
95
+ * address bar have nothing to talk to, and an already connected browser has nothing to ask.
96
+ * The listener is registered before READY goes out, so Studio's answer cannot slip past it,
97
+ * and CONNECT is accepted only from the opener captured here — a site that nulls
98
+ * `window.opener` after load (a common tabnabbing guard) must not break a handshake that is
99
+ * already under way. A page opened by anything other than Studio posts READY into the void.
100
+ * Returns true when an offer went out.
101
+ */
102
+ export function offerConnection(ctx) {
103
+ if (!ctx.isTopLevel)
104
+ return false;
105
+ const opener = ctx.opener;
106
+ if (!opener)
107
+ return false;
108
+ if (hasToolbarCookie(ctx.cookies))
109
+ return false;
110
+ const onMessage = (event) => {
111
+ if (!shouldAcceptConnect(event.data, event.source, opener))
112
+ return;
113
+ ctx.removeListener(onMessage);
114
+ ctx.setCookie(toolbarCookie(ctx.protocol === 'https:'));
115
+ try {
116
+ event.source.postMessage(connectedMessage(), event.origin);
117
+ }
118
+ catch {
119
+ // An opaque origin ("null") is not a valid target; Studio then simply times out.
120
+ }
121
+ ctx.onConnected();
122
+ };
123
+ ctx.addListener(onMessage);
124
+ try {
125
+ opener.postMessage(readyMessage(), '*');
126
+ }
127
+ catch {
128
+ // The opener is already gone.
129
+ }
130
+ return true;
131
+ }
132
+ /** Strife API origin with any trailing slash removed; defaults to production. */
133
+ export function normalizeApi(api) {
134
+ const value = (api ?? '').trim();
135
+ return (value || DEFAULT_API).replace(/\/+$/, '');
136
+ }
137
+ /** "Öppna i Strife". The page URL always goes along: the API resolves the team from its host
138
+ * among the caller's own teams when `teamId` is not given, and resolves the document from
139
+ * its path when `documentId` is not given. A document id skips the index lookup and covers
140
+ * virtual sub-pages; a team id skips the host lookup. */
141
+ export function buildOpenUrl(api, teamId, documentId, href) {
142
+ const url = new URL('/toolbar/open', normalizeApi(api));
143
+ if (teamId && teamId.trim())
144
+ url.searchParams.set('team', teamId.trim());
145
+ if (documentId && documentId.trim())
146
+ url.searchParams.set('id', documentId.trim());
147
+ url.searchParams.set('url', withoutHash(href));
148
+ return url.toString();
149
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strifeapp/strife",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -38,6 +38,11 @@
38
38
  "import": "./dist/secrets.js",
39
39
  "default": "./dist/secrets.js"
40
40
  },
41
+ "./toolbar-element": {
42
+ "types": "./dist/toolbar-element.d.ts",
43
+ "import": "./dist/toolbar-element.js",
44
+ "default": "./dist/toolbar-element.js"
45
+ },
41
46
  "./content-index": {
42
47
  "types": "./content-index.d.ts",
43
48
  "import": "./dist/content-index.js",