@flung.dev/connect 0.3.0 → 0.4.0

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/README.md CHANGED
@@ -158,6 +158,7 @@ Set any of these on the element. Each has a default for light and dark.
158
158
  | `--flung-focus-width` | `2px` | Its width. |
159
159
  | `--flung-focus-offset` | `2px` | Its gap from the control. |
160
160
  | `--flung-grid-min` | `16rem` | The narrowest card in the `grid` layout. |
161
+ | `--flung-qr-size` | `132px` | The QR code of an approval link (Farcaster). |
161
162
  | `--flung-tooltip-background` | `--flung-text` | The tooltip that shows an account name cut short. |
162
163
  | `--flung-tooltip-text` | `--flung-surface` | Its words. |
163
164
  | `--flung-tooltip-radius` | `--flung-radius-inner` | Its corners. |
@@ -196,6 +197,7 @@ Every piece the panel draws has a `part` name for `::part()`. Pieces with a kind
196
197
  | `tag`, `tag-page` | The **Page** tag beside a company page. |
197
198
  | `form`, `label`, `input` | The Mastodon server or Bluesky handle question. |
198
199
  | `note`, `spinner`, `note-text`, `link` | The wait, and the sign-in link when the browser blocked the window. |
200
+ | `approve`, `approve-ended`, `approve-text`, `approve-go`, `link-approval`, `approve-scan`, `qr`, `approve-hint`, `approve-actions`, `button-retry` | An approval in the platform's phone app (Farcaster): the line, Open Warpcast, the QR code, and Try again once it ended. |
199
201
  | `confirm`, `confirm-text` | The question before a disconnect. |
200
202
  | `message`, `message-error` | The status line under the list. It has `hidden` when empty. |
201
203
  | `footer`, `footer-link`, `footer-mark` | "Secured by Flung". |
@@ -298,12 +300,13 @@ panel.subscribe(() => draw(panel.getState()))
298
300
  | `configure({ platforms, mode, labels })` | Changes those options while shown. |
299
301
  | `destroy()` | Stops every wait and listener. |
300
302
 
301
- `ConnectState` has `hasToken`, `loading`, `loaded`, `platforms` (each with `name`, `accounts`, `step`, `busy`, `handlePrompt`, `connectable` and `variants`), `accounts`, `notice` (the last thing that happened, as data), `message` (that notice, or the loading, empty or no-token line, in the labels' words) and `labels`. Each account item has `displayName`, `username` (the handle when it differs), `avatarUrl` (only ever `https`), `initial`, `page` (a LinkedIn company page) and `reconnect`. A step is `idle`, `handle` (with `required`), `choose` (with `variants`), `waiting` (with `blockedUrl` and `variant`) or `confirm` (with `account`).
303
+ `ConnectState` has `hasToken`, `loading`, `loaded`, `platforms` (each with `name`, `accounts`, `step`, `busy`, `handlePrompt`, `connectable`, `variants` and `connectFlow`), `accounts`, `notice` (the last thing that happened, as data), `message` (that notice, or the loading, empty or no-token line, in the labels' words) and `labels`. Each account item has `displayName`, `username` (the handle when it differs), `avatarUrl` (only ever `https`), `initial`, `page` (a LinkedIn company page) and `reconnect`. A step is `idle`, `handle` (with `required`), `choose` (with `variants`), `waiting` (with `blockedUrl` and `variant`), `approve` (with `approvalUrl`, `accountId` and `ended`) or `confirm` (with `account`). `encodeQr(text)` gives the modules of a QR code for an `approve` step's link.
302
304
 
303
305
  ## Behaviour worth knowing
304
306
 
305
307
  - **Popups.** Browsers open a window only straight from a click, so the panel opens it first and fills it in once Flung answers. When a browser blocks it anyway, the panel shows a link to the consent page instead.
306
308
  - **Waiting.** The panel waits on the connect session, not the window: some platforms' sign-in pages cut the link back to the page that opened them. Sessions last ten minutes. **Cancel** stops waiting.
309
+ - **Farcaster** is approved in Warpcast, its phone app, not on a sign-in page (`connectFlow: 'approval'` in `GET /v1/platforms`). The panel opens no window and does not redirect: it shows **Open Warpcast** and a QR code of the same link for a computer (hidden on a narrow screen, where the button opens the app), and reads the session after two seconds, then half as long again each time, up to every ten. Approved, it reads as connected, like any connect; expired or declined, it says why, with **Try again** for a new link. `--flung-qr-size` sets the code's size; it stays dark on light in a dark theme, as cameras need.
307
310
  - **Mastodon and Bluesky** ask first: the person's Mastodon server (required), or their Bluesky handle (optional; without it they sign in through bsky.social).
308
311
  - **LinkedIn** asks whether to connect a personal profile or a company page when your Flung offers pages. A reconnect of a page goes through the pages app by itself.
309
312
  - **Reconnect** appears beside an account that needs it, or whose platform only lets Flung renew it for a limited time and that time ends within two weeks.
@@ -45,9 +45,13 @@ export declare class ConnectClient {
45
45
  disconnect(accountId: string): Promise<Account>;
46
46
  /** A connect session's state: pending, connected, failed or expired. */
47
47
  session(id: string): Promise<ConnectSession>;
48
- /** Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. */
48
+ /**
49
+ * Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. It reads the
50
+ * session every `intervalMs` (two seconds); with `maxIntervalMs` it waits half as long again after each read, up to that.
51
+ */
49
52
  waitForSession(id: string, options?: {
50
53
  signal?: AbortSignal | undefined;
51
54
  intervalMs?: number | undefined;
55
+ maxIntervalMs?: number | undefined;
52
56
  }): Promise<ConnectSession>;
53
57
  }
@@ -57,14 +57,20 @@ export class ConnectClient {
57
57
  async session(id) {
58
58
  return this.#call('GET', `/v1/connect-sessions/${encodeURIComponent(id)}`);
59
59
  }
60
- /** Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. */
60
+ /**
61
+ * Resolves once the session is no longer `pending`. Rejects with an `AbortError` when `signal` aborts. It reads the
62
+ * session every `intervalMs` (two seconds); with `maxIntervalMs` it waits half as long again after each read, up to that.
63
+ */
61
64
  async waitForSession(id, options = {}) {
65
+ let wait = options.intervalMs ?? POLL_MS;
62
66
  for (;;) {
63
67
  options.signal?.throwIfAborted();
64
68
  const session = await this.session(id);
65
69
  if (session.status !== 'pending')
66
70
  return session;
67
- await delay(options.intervalMs ?? POLL_MS, options.signal);
71
+ await delay(wait, options.signal);
72
+ if (options.maxIntervalMs !== undefined)
73
+ wait = Math.min(options.maxIntervalMs, Math.round(wait * 1.5));
68
74
  }
69
75
  }
70
76
  async #call(method, path, body) {
@@ -1,7 +1,7 @@
1
1
  import type { TokenSource } from './client.js';
2
2
  import type { ConnectDetail, DisconnectDetail, ErrorDetail, LoadDetail } from './events.js';
3
3
  import type { ConnectLabels } from './labels.js';
4
- import type { Account, ConnectSession, ConnectVariant, Platform } from './types.js';
4
+ import type { Account, ConnectFlow, ConnectSession, ConnectVariant, Platform } from './types.js';
5
5
  /** What one connected account looks like to a panel: the API's account and what to show for it. */
6
6
  export interface AccountItem {
7
7
  /** The account as the API returned it. */
@@ -55,6 +55,22 @@ export type PlatformStep =
55
55
  readonly accountId: string | null;
56
56
  readonly variant: ConnectVariant | null;
57
57
  }
58
+ /**
59
+ * An approval connect (Farcaster, `connectFlow: 'approval'`): no window and no redirect. Show `approvalUrl` as a link
60
+ * and a QR code (`encodeQr`) while the panel reads the session; it is null while the link is being made. `ended` says
61
+ * why it stopped, `expired` or `failed` with the platform's `message`: offer Try again (`connect`, or `reconnect` with
62
+ * `accountId`) and Cancel. A connected approval goes back to `idle` with a `connected` notice, as other connects do.
63
+ */
64
+ | {
65
+ readonly kind: 'approve';
66
+ readonly approvalUrl: string | null;
67
+ readonly accountId: string | null;
68
+ readonly variant: ConnectVariant | null;
69
+ readonly ended: {
70
+ readonly status: 'expired' | 'failed';
71
+ readonly message: string;
72
+ } | null;
73
+ }
58
74
  /** Asking whether to disconnect `account`: call `confirmDisconnect` or `cancel`. */
59
75
  | {
60
76
  readonly kind: 'confirm';
@@ -83,6 +99,8 @@ export interface PlatformItem {
83
99
  readonly connectable: boolean;
84
100
  /** The other ways it can connect here, such as LinkedIn's `pages`; empty for most platforms. */
85
101
  readonly variants: readonly ConnectVariant[];
102
+ /** How its connect ends: `consent` through a sign-in window or redirect, `approval` in the platform's phone app (Farcaster). */
103
+ readonly connectFlow: ConnectFlow;
86
104
  }
87
105
  /** The last thing that happened, as data. `message` in the state words it. */
88
106
  export type ConnectNotice =
@@ -4,6 +4,8 @@ import { DEFAULT_LABELS, formatLabel } from './labels.js';
4
4
  import { HANDLE_PROMPT, isConnectVariant, isPageAccount, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
5
5
  /** How soon a reconnect deadline is worth showing. */
6
6
  const SOON_MS = 14 * 86_400_000;
7
+ /** How an approval connect reads its session: after two seconds, then half as long again each time, up to every ten. */
8
+ const APPROVAL_POLL = { intervalMs: 2_000, maxIntervalMs: 10_000 };
7
9
  const IDLE = Object.freeze({ kind: 'idle' });
8
10
  const NO_VARIANTS = Object.freeze([]);
9
11
  /**
@@ -86,7 +88,7 @@ class Controller {
86
88
  return;
87
89
  this.#destroyed = true;
88
90
  for (const step of this.#steps.values())
89
- if (step.kind === 'waiting')
91
+ if (step.kind === 'waiting' || step.kind === 'approve')
90
92
  step.abort.abort();
91
93
  this.#listeners.clear();
92
94
  }
@@ -148,7 +150,7 @@ class Controller {
148
150
  const step = this.#steps.get(platform);
149
151
  if (step === undefined || step.kind === 'idle')
150
152
  return;
151
- if (step.kind === 'waiting')
153
+ if (step.kind === 'waiting' || step.kind === 'approve')
152
154
  step.abort.abort();
153
155
  this.#set(platform, { kind: 'idle' });
154
156
  };
@@ -156,9 +158,9 @@ class Controller {
156
158
  if (this.#notice !== null)
157
159
  this.#tell(null);
158
160
  };
159
- /** A connect may start: there is a token, and the platform is not already waiting on one. */
161
+ /** A connect may start: there is a token, and the platform is not already waiting on one. An approval that ended may start again. */
160
162
  #ready(platform) {
161
- return !this.#destroyed && this.#client !== null && this.#steps.get(platform)?.kind !== 'waiting';
163
+ return !this.#destroyed && this.#client !== null && !waitingStep(this.#steps.get(platform));
162
164
  }
163
165
  async #load() {
164
166
  const client = this.#client;
@@ -175,10 +177,10 @@ class Controller {
175
177
  const asked = this.#asked;
176
178
  const granted = typeof this.#options.token === 'string' ? tokenPlatforms(this.#options.token) : null;
177
179
  const offered = platforms
178
- .map((info) => ({ platform: info.platform, connectable: info.connectable, variants: (info.variants ?? []).filter(isConnectVariant) }))
180
+ .map((info) => ({ platform: info.platform, connectable: info.connectable, variants: (info.variants ?? []).filter(isConnectVariant), connectFlow: info.connectFlow === 'approval' ? 'approval' : 'consent' }))
179
181
  .filter((info) => (info.connectable || info.variants.length > 0) && (asked ?? [info.platform]).includes(info.platform) && (granted ?? [info.platform]).includes(info.platform));
180
182
  this.#platforms = offered.map((info) => info.platform);
181
- this.#offers = new Map(offered.map((info) => [info.platform, Object.freeze({ connectable: info.connectable, variants: Object.freeze(info.variants) })]));
183
+ this.#offers = new Map(offered.map((info) => [info.platform, Object.freeze({ connectable: info.connectable, variants: Object.freeze(info.variants), connectFlow: info.connectFlow })]));
182
184
  this.#accounts = accounts.filter((account) => account.status !== 'disconnected' && this.#platforms.includes(account.platform));
183
185
  this.#loading = false;
184
186
  this.#loaded = true;
@@ -218,6 +220,8 @@ class Controller {
218
220
  const redirect = this.#mode === 'redirect' && typeof location !== 'undefined';
219
221
  const redirectUrl = redirect ? withoutRedirectResult(location.href) : undefined;
220
222
  const begin = () => (account === null ? client.connect(platform, { handle, redirectUrl, variant: variant ?? undefined }) : client.reconnect(account.id, { redirectUrl }));
223
+ if (this.#offers.get(platform)?.connectFlow === 'approval')
224
+ return this.#approve(platform, account, begin);
221
225
  const abort = new AbortController();
222
226
  const step = { kind: 'waiting', abort, blockedUrl: null, accountId: account?.id ?? null, variant };
223
227
  this.#notice = null;
@@ -240,6 +244,35 @@ class Controller {
240
244
  })
241
245
  .then((session) => this.#finish(platform, session), (error) => this.#settle(platform, step, error));
242
246
  }
247
+ /**
248
+ * An approval connect: no window and no redirect. The link to the platform's app is shown in the step while the session
249
+ * is read, backing off; an approval that expires or fails stays in the step, saying why, until Try again or Cancel.
250
+ */
251
+ #approve(platform, account, begin) {
252
+ const client = this.#client;
253
+ const step = { kind: 'approve', abort: new AbortController(), approvalUrl: null, accountId: account?.id ?? null, variant: null, ended: null };
254
+ this.#notice = null;
255
+ this.#set(platform, step);
256
+ void begin()
257
+ .then((session) => {
258
+ if (this.#steps.get(platform) !== step)
259
+ return null;
260
+ step.approvalUrl = safeUrl(session.authorizationUrl, ['https:']);
261
+ this.#emit();
262
+ return client.waitForSession(session.id, { signal: step.abort.signal, ...APPROVAL_POLL });
263
+ })
264
+ .then(async (session) => {
265
+ if (session === null || this.#destroyed || this.#steps.get(platform) !== step)
266
+ return;
267
+ if (session.status === 'connected')
268
+ return this.#finish(platform, session);
269
+ const message = session.error?.message ?? '';
270
+ step.ended = { status: session.status === 'expired' ? 'expired' : 'failed', message };
271
+ step.approvalUrl = null;
272
+ this.#emit();
273
+ this.#options.onError?.({ code: session.status === 'expired' ? 'expired' : (session.error?.code ?? 'platform_error'), message });
274
+ }, (error) => this.#settle(platform, step, error));
275
+ }
243
276
  async #finish(platform, session) {
244
277
  if (this.#destroyed)
245
278
  return;
@@ -270,7 +303,7 @@ class Controller {
270
303
  if (this.#steps.get(platform) === step)
271
304
  this.#set(platform, { kind: 'idle' });
272
305
  // By name and by the signal, not `instanceof DOMException`: the reason may come from another realm's DOMException.
273
- const aborted = (step.kind === 'waiting' && step.abort.signal.aborted) || (typeof error === 'object' && error !== null && error.name === 'AbortError');
306
+ const aborted = ((step.kind === 'waiting' || step.kind === 'approve') && step.abort.signal.aborted) || (typeof error === 'object' && error !== null && error.name === 'AbortError');
274
307
  if (aborted)
275
308
  return;
276
309
  this.#fail(error, 'connect', platform);
@@ -309,10 +342,11 @@ class Controller {
309
342
  name: PLATFORM_NAMES[platform],
310
343
  accounts: Object.freeze(this.#accounts.filter((account) => account.platform === platform).map((account) => items.get(account.id))),
311
344
  step,
312
- busy: step.kind === 'waiting',
345
+ busy: step.kind === 'waiting' || (step.kind === 'approve' && step.ended === null),
313
346
  handlePrompt: prompt === undefined ? null : Object.freeze({ required: prompt.required }),
314
347
  connectable: offer?.connectable ?? true,
315
348
  variants: offer?.variants ?? NO_VARIANTS,
349
+ connectFlow: offer?.connectFlow ?? 'consent',
316
350
  });
317
351
  });
318
352
  return Object.freeze({
@@ -336,8 +370,14 @@ function publicStep(step, items, now) {
336
370
  return Object.freeze({ kind: 'choose', variants: step.variants });
337
371
  if (step.kind === 'waiting')
338
372
  return Object.freeze({ kind: 'waiting', blockedUrl: step.blockedUrl, accountId: step.accountId, variant: step.variant });
373
+ if (step.kind === 'approve')
374
+ return Object.freeze({ kind: 'approve', approvalUrl: step.approvalUrl, accountId: step.accountId, variant: step.variant, ended: step.ended === null ? null : Object.freeze({ ...step.ended }) });
339
375
  return Object.freeze({ kind: 'confirm', account: items.get(step.account.id) ?? accountItem(step.account, now) });
340
376
  }
377
+ /** Whether a step holds the platform: a sign-in window open, or an approval still awaited. */
378
+ function waitingStep(step) {
379
+ return step?.kind === 'waiting' || (step?.kind === 'approve' && step.ended === null);
380
+ }
341
381
  /** What to show for an account. */
342
382
  function accountItem(account, now) {
343
383
  const name = account.username ?? account.platformUserId;
@@ -13,4 +13,6 @@ export { DEFAULT_LABELS, formatLabel } from './labels.js';
13
13
  export type { ConnectLabels } from './labels.js';
14
14
  export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
15
15
  export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
16
- export type { Account, AccountStatus, ConnectSession, ConnectSessionStatus, ConnectVariant, Platform, PlatformInfo } from './types.js';
16
+ export type { Account, AccountStatus, ConnectFlow, ConnectSession, ConnectSessionStatus, ConnectVariant, Platform, PlatformInfo } from './types.js';
17
+ export { encodeQr } from './qr.js';
18
+ export type { QrMatrix } from './qr.js';
@@ -8,3 +8,4 @@ export { connectInPopup, readRedirectResult, withoutRedirectResult } from './flo
8
8
  export { DEFAULT_LABELS, formatLabel } from './labels.js';
9
9
  export { HANDLE_PROMPT, isConnectVariant, isPageAccount, isPlatform, parsePlatformList, PLATFORM_NAMES, tokenPlatforms } from './platforms.js';
10
10
  export { CONNECT_VARIANTS, PLATFORMS } from './types.js';
11
+ export { encodeQr } from './qr.js';
@@ -31,6 +31,24 @@ export interface ConnectLabels {
31
31
  blocked: string;
32
32
  /** The link to the consent page when the window was blocked. `{platform}`. */
33
33
  openSignIn: string;
34
+ /** Above the approval link and QR code, for a platform approved in its phone app (Farcaster). `{platform}`. */
35
+ approve: string;
36
+ /** The link that opens the platform's app to approve. `{platform}`. */
37
+ openApproval: string;
38
+ /** Beside the QR code, for a person on a computer. */
39
+ scanApproval: string;
40
+ /** The QR code's name for a screen reader. `{platform}`. */
41
+ approvalQr: string;
42
+ /** While the approval is awaited. */
43
+ awaitingApproval: string;
44
+ /** Starts a new approval after one expired or failed. */
45
+ tryAgain: string;
46
+ /** After an approval link ran out of time. `{platform}`. */
47
+ approvalExpired: string;
48
+ /** After an approval was declined or failed. `{platform}` and `{message}`. */
49
+ approvalFailed: string;
50
+ /** After an approval was declined, when the platform said nothing more. `{platform}`. */
51
+ approvalDeclined: string;
34
52
  /** After a connect. `{platform}`. */
35
53
  connected: string;
36
54
  /** After a connect that failed. `{platform}` and `{message}`. */
@@ -14,6 +14,15 @@ export const DEFAULT_LABELS = {
14
14
  waiting: 'Finish signing in to {platform} in the window that opened.',
15
15
  blocked: 'Your browser did not open the {platform} window.',
16
16
  openSignIn: 'Open {platform} sign-in',
17
+ approve: 'Approve Flung in Warpcast, the {platform} app.',
18
+ openApproval: 'Open Warpcast',
19
+ scanApproval: 'On a computer, scan this with your phone’s camera instead.',
20
+ approvalQr: 'QR code that opens Warpcast to approve {platform}',
21
+ awaitingApproval: 'Waiting for approval',
22
+ tryAgain: 'Try again',
23
+ approvalExpired: 'The {platform} approval link ran out before it was approved.',
24
+ approvalFailed: '{platform} was not approved: {message}',
25
+ approvalDeclined: '{platform} was not approved. Try again for a new link.',
17
26
  connected: '{platform} is connected.',
18
27
  failed: '{platform} was not connected: {message}',
19
28
  expired: 'Signing in to {platform} took too long. Try again.',
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A QR code of a short text, such as the link that opens Warpcast to approve Flung: byte mode, error correction level M,
3
+ * the smallest version that holds it, and the mask with the lowest penalty. Written here so the package keeps no runtime
4
+ * dependency. It follows Project Nayuki's QR Code generator (MIT, https://www.nayuki.io/page/qr-code-generator-library)
5
+ * step for step, so its output is that library's, module for module.
6
+ */
7
+ /** The code as rows of modules, `true` for dark, with no quiet zone: draw one of at least four light modules around it. */
8
+ export interface QrMatrix {
9
+ readonly size: number;
10
+ readonly modules: readonly (readonly boolean[])[];
11
+ }
12
+ /** Encodes `text` as UTF-8. Throws a `RangeError` when it is too long for a QR code (about 2,300 bytes at level M). */
13
+ export declare function encodeQr(text: string): QrMatrix;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * A QR code of a short text, such as the link that opens Warpcast to approve Flung: byte mode, error correction level M,
3
+ * the smallest version that holds it, and the mask with the lowest penalty. Written here so the package keeps no runtime
4
+ * dependency. It follows Project Nayuki's QR Code generator (MIT, https://www.nayuki.io/page/qr-code-generator-library)
5
+ * step for step, so its output is that library's, module for module.
6
+ */
7
+ /** Error correction codewords per block at level M, by version (index 0 unused). */
8
+ const ECC_PER_BLOCK = [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28];
9
+ /** Error correction blocks at level M, by version (index 0 unused). */
10
+ const BLOCKS = [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49];
11
+ /** Level M's two format bits. */
12
+ const FORMAT_BITS_M = 0;
13
+ const PENALTY_N1 = 3;
14
+ const PENALTY_N2 = 3;
15
+ const PENALTY_N3 = 40;
16
+ const PENALTY_N4 = 10;
17
+ /** Encodes `text` as UTF-8. Throws a `RangeError` when it is too long for a QR code (about 2,300 bytes at level M). */
18
+ export function encodeQr(text) {
19
+ const bytes = new TextEncoder().encode(text);
20
+ let version = 1;
21
+ for (;; version += 1) {
22
+ if (version > 40)
23
+ throw new RangeError('The text is too long for a QR code.');
24
+ if (4 + countBits(version) + bytes.length * 8 <= dataCodewords(version) * 8)
25
+ break;
26
+ }
27
+ const bits = [];
28
+ const append = (value, length) => {
29
+ for (let i = length - 1; i >= 0; i -= 1)
30
+ bits.push((value >>> i) & 1);
31
+ };
32
+ append(0x4, 4);
33
+ append(bytes.length, countBits(version));
34
+ for (const byte of bytes)
35
+ append(byte, 8);
36
+ const capacity = dataCodewords(version) * 8;
37
+ append(0, Math.min(4, capacity - bits.length));
38
+ append(0, (8 - (bits.length % 8)) % 8);
39
+ for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11)
40
+ append(pad, 8);
41
+ const data = new Array(bits.length / 8).fill(0);
42
+ bits.forEach((bit, i) => {
43
+ data[i >>> 3] = data[i >>> 3] | (bit << (7 - (i & 7)));
44
+ });
45
+ return new Grid(version).draw(interleave(version, data));
46
+ }
47
+ const countBits = (version) => (version <= 9 ? 8 : 16);
48
+ function rawDataModules(version) {
49
+ let result = (16 * version + 128) * version + 64;
50
+ if (version >= 2) {
51
+ const align = Math.floor(version / 7) + 2;
52
+ result -= (25 * align - 10) * align - 55;
53
+ if (version >= 7)
54
+ result -= 36;
55
+ }
56
+ return result;
57
+ }
58
+ const dataCodewords = (version) => Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[version] * BLOCKS[version];
59
+ /** Splits the data into blocks, adds each block's Reed-Solomon codewords, and interleaves them. */
60
+ function interleave(version, data) {
61
+ const blocks = BLOCKS[version];
62
+ const eccLength = ECC_PER_BLOCK[version];
63
+ const raw = Math.floor(rawDataModules(version) / 8);
64
+ const shortBlocks = blocks - (raw % blocks);
65
+ const shortLength = Math.floor(raw / blocks);
66
+ const divisor = rsDivisor(eccLength);
67
+ const split = [];
68
+ for (let i = 0, k = 0; i < blocks; i += 1) {
69
+ const block = data.slice(k, k + shortLength - eccLength + (i < shortBlocks ? 0 : 1));
70
+ k += block.length;
71
+ const ecc = rsRemainder(block, divisor);
72
+ if (i < shortBlocks)
73
+ block.push(0);
74
+ split.push(block.concat(ecc));
75
+ }
76
+ const result = [];
77
+ for (let i = 0; i < split[0].length; i += 1) {
78
+ split.forEach((block, j) => {
79
+ if (i !== shortLength - eccLength || j >= shortBlocks)
80
+ result.push(block[i]);
81
+ });
82
+ }
83
+ return result;
84
+ }
85
+ function rsMultiply(x, y) {
86
+ let z = 0;
87
+ for (let i = 7; i >= 0; i -= 1) {
88
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
89
+ z ^= ((y >>> i) & 1) * x;
90
+ }
91
+ return z;
92
+ }
93
+ function rsDivisor(degree) {
94
+ const result = new Array(degree - 1).fill(0).concat([1]);
95
+ let root = 1;
96
+ for (let i = 0; i < degree; i += 1) {
97
+ for (let j = 0; j < result.length; j += 1) {
98
+ result[j] = rsMultiply(result[j], root);
99
+ if (j + 1 < result.length)
100
+ result[j] = result[j] ^ result[j + 1];
101
+ }
102
+ root = rsMultiply(root, 0x02);
103
+ }
104
+ return result;
105
+ }
106
+ function rsRemainder(data, divisor) {
107
+ const result = divisor.map(() => 0);
108
+ for (const byte of data) {
109
+ const factor = byte ^ result.shift();
110
+ result.push(0);
111
+ divisor.forEach((coefficient, i) => {
112
+ result[i] = result[i] ^ rsMultiply(coefficient, factor);
113
+ });
114
+ }
115
+ return result;
116
+ }
117
+ const bit = (value, i) => ((value >>> i) & 1) !== 0;
118
+ class Grid {
119
+ #size;
120
+ #version;
121
+ #modules;
122
+ #fixed;
123
+ constructor(version) {
124
+ this.#version = version;
125
+ this.#size = version * 4 + 17;
126
+ this.#modules = Array.from({ length: this.#size }, () => new Array(this.#size).fill(false));
127
+ this.#fixed = Array.from({ length: this.#size }, () => new Array(this.#size).fill(false));
128
+ }
129
+ draw(codewords) {
130
+ this.#functionPatterns();
131
+ this.#codewords(codewords);
132
+ let best = 0;
133
+ let lowest = Infinity;
134
+ for (let mask = 0; mask < 8; mask += 1) {
135
+ this.#mask(mask);
136
+ this.#format(mask);
137
+ const penalty = this.#penalty();
138
+ if (penalty < lowest) {
139
+ best = mask;
140
+ lowest = penalty;
141
+ }
142
+ this.#mask(mask);
143
+ }
144
+ this.#mask(best);
145
+ this.#format(best);
146
+ return { size: this.#size, modules: this.#modules.map((row) => [...row]) };
147
+ }
148
+ #set(x, y, dark) {
149
+ ;
150
+ this.#modules[y][x] = dark;
151
+ this.#fixed[y][x] = true;
152
+ }
153
+ #functionPatterns() {
154
+ const size = this.#size;
155
+ for (let i = 0; i < size; i += 1) {
156
+ this.#set(6, i, i % 2 === 0);
157
+ this.#set(i, 6, i % 2 === 0);
158
+ }
159
+ this.#finder(3, 3);
160
+ this.#finder(size - 4, 3);
161
+ this.#finder(3, size - 4);
162
+ const positions = this.#alignmentPositions();
163
+ const last = positions.length - 1;
164
+ positions.forEach((x, i) => {
165
+ positions.forEach((y, j) => {
166
+ if (!((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)))
167
+ this.#alignment(x, y);
168
+ });
169
+ });
170
+ this.#format(0);
171
+ this.#versionInfo();
172
+ }
173
+ #finder(x, y) {
174
+ for (let dy = -4; dy <= 4; dy += 1) {
175
+ for (let dx = -4; dx <= 4; dx += 1) {
176
+ const distance = Math.max(Math.abs(dx), Math.abs(dy));
177
+ const xx = x + dx;
178
+ const yy = y + dy;
179
+ if (xx >= 0 && xx < this.#size && yy >= 0 && yy < this.#size)
180
+ this.#set(xx, yy, distance !== 2 && distance !== 4);
181
+ }
182
+ }
183
+ }
184
+ #alignment(x, y) {
185
+ for (let dy = -2; dy <= 2; dy += 1)
186
+ for (let dx = -2; dx <= 2; dx += 1)
187
+ this.#set(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
188
+ }
189
+ #alignmentPositions() {
190
+ if (this.#version === 1)
191
+ return [];
192
+ const count = Math.floor(this.#version / 7) + 2;
193
+ const step = Math.floor((this.#version * 8 + count * 3 + 5) / (count * 4 - 4)) * 2;
194
+ const result = [6];
195
+ for (let position = this.#size - 7; result.length < count; position -= step)
196
+ result.splice(1, 0, position);
197
+ return result;
198
+ }
199
+ #format(mask) {
200
+ const data = (FORMAT_BITS_M << 3) | mask;
201
+ let remainder = data;
202
+ for (let i = 0; i < 10; i += 1)
203
+ remainder = (remainder << 1) ^ ((remainder >>> 9) * 0x537);
204
+ const bits = ((data << 10) | remainder) ^ 0x5412;
205
+ const size = this.#size;
206
+ for (let i = 0; i <= 5; i += 1)
207
+ this.#set(8, i, bit(bits, i));
208
+ this.#set(8, 7, bit(bits, 6));
209
+ this.#set(8, 8, bit(bits, 7));
210
+ this.#set(7, 8, bit(bits, 8));
211
+ for (let i = 9; i < 15; i += 1)
212
+ this.#set(14 - i, 8, bit(bits, i));
213
+ for (let i = 0; i < 8; i += 1)
214
+ this.#set(size - 1 - i, 8, bit(bits, i));
215
+ for (let i = 8; i < 15; i += 1)
216
+ this.#set(8, size - 15 + i, bit(bits, i));
217
+ this.#set(8, size - 8, true);
218
+ }
219
+ #versionInfo() {
220
+ if (this.#version < 7)
221
+ return;
222
+ let remainder = this.#version;
223
+ for (let i = 0; i < 12; i += 1)
224
+ remainder = (remainder << 1) ^ ((remainder >>> 11) * 0x1f25);
225
+ const bits = (this.#version << 12) | remainder;
226
+ for (let i = 0; i < 18; i += 1) {
227
+ const a = this.#size - 11 + (i % 3);
228
+ const b = Math.floor(i / 3);
229
+ this.#set(a, b, bit(bits, i));
230
+ this.#set(b, a, bit(bits, i));
231
+ }
232
+ }
233
+ #codewords(data) {
234
+ const size = this.#size;
235
+ let i = 0;
236
+ for (let right = size - 1; right >= 1; right -= 2) {
237
+ if (right === 6)
238
+ right = 5;
239
+ for (let vertical = 0; vertical < size; vertical += 1) {
240
+ for (let j = 0; j < 2; j += 1) {
241
+ const x = right - j;
242
+ const upward = ((right + 1) & 2) === 0;
243
+ const y = upward ? size - 1 - vertical : vertical;
244
+ if (!this.#fixed[y][x] && i < data.length * 8) {
245
+ ;
246
+ this.#modules[y][x] = bit(data[i >>> 3], 7 - (i & 7));
247
+ i += 1;
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ /** Flips the data modules under `mask`; applying it twice undoes it. */
254
+ #mask(mask) {
255
+ for (let y = 0; y < this.#size; y += 1) {
256
+ for (let x = 0; x < this.#size; x += 1) {
257
+ const flip = [(x + y) % 2 === 0, y % 2 === 0, x % 3 === 0, (x + y) % 3 === 0, (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, ((x * y) % 2) + ((x * y) % 3) === 0, (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (((x + y) % 2) + ((x * y) % 3)) % 2 === 0][mask];
258
+ const row = this.#modules[y];
259
+ if (flip === true && !this.#fixed[y][x])
260
+ row[x] = !row[x];
261
+ }
262
+ }
263
+ }
264
+ #penalty() {
265
+ const size = this.#size;
266
+ const at = (x, y) => this.#modules[y][x];
267
+ let result = 0;
268
+ for (const across of [true, false]) {
269
+ for (let a = 0; a < size; a += 1) {
270
+ let color = false;
271
+ let run = 0;
272
+ const history = [0, 0, 0, 0, 0, 0, 0];
273
+ for (let b = 0; b < size; b += 1) {
274
+ const dark = across ? at(b, a) : at(a, b);
275
+ if (dark === color) {
276
+ run += 1;
277
+ if (run === 5)
278
+ result += PENALTY_N1;
279
+ else if (run > 5)
280
+ result += 1;
281
+ }
282
+ else {
283
+ this.#history(run, history);
284
+ if (!color)
285
+ result += this.#finderLike(history) * PENALTY_N3;
286
+ color = dark;
287
+ run = 1;
288
+ }
289
+ }
290
+ if (color) {
291
+ this.#history(run, history);
292
+ run = 0;
293
+ }
294
+ this.#history(run + size, history);
295
+ result += this.#finderLike(history) * PENALTY_N3;
296
+ }
297
+ }
298
+ for (let y = 0; y < size - 1; y += 1) {
299
+ for (let x = 0; x < size - 1; x += 1) {
300
+ const color = at(x, y);
301
+ if (color === at(x + 1, y) && color === at(x, y + 1) && color === at(x + 1, y + 1))
302
+ result += PENALTY_N2;
303
+ }
304
+ }
305
+ const dark = this.#modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
306
+ const total = size * size;
307
+ result += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * PENALTY_N4;
308
+ return result;
309
+ }
310
+ #history(run, history) {
311
+ history.pop();
312
+ history.unshift(history[0] === 0 ? run + this.#size : run);
313
+ }
314
+ #finderLike(history) {
315
+ const [h0, n, h2, h3, h4, h5, h6] = history;
316
+ const core = n > 0 && h2 === n && h3 === n * 3 && h4 === n && h5 === n;
317
+ return (core && h0 >= n * 4 && h6 >= n ? 1 : 0) + (core && h6 >= n * 4 && h0 >= n ? 1 : 0);
318
+ }
319
+ }
@@ -16,7 +16,15 @@ export interface PlatformInfo {
16
16
  connectable: boolean;
17
17
  /** The connect variants set up here, such as LinkedIn's `pages`. Optional: an API older than the field leaves it out. */
18
18
  variants?: string[] | undefined;
19
+ /**
20
+ * How a connect ends: `consent`, a sign-in page that comes back; `approval` (Farcaster), a link that opens the
21
+ * platform's phone app to approve, after which the session finishes when read. Optional: an API older than the field
22
+ * leaves it out, which reads as `consent`.
23
+ */
24
+ connectFlow?: ConnectFlow | undefined;
19
25
  }
26
+ /** How a platform's connect ends: a sign-in page that sends the person back, or an approval in the platform's phone app. */
27
+ export type ConnectFlow = 'consent' | 'approval';
20
28
  export type AccountStatus = 'active' | 'needs_reconnect' | 'disconnected';
21
29
  /** A connected social account, as `GET /v1/accounts` returns it. */
22
30
  export interface Account {
@@ -30,6 +30,11 @@ export declare function mark(path: string, options?: {
30
30
  part?: string;
31
31
  size?: number;
32
32
  }): SVGSVGElement;
33
+ /**
34
+ * A QR code of `text` as one SVG path of dark modules on a light square, named `label` for a screen reader. It stays dark
35
+ * on light in a dark theme too, as cameras need; `--flung-qr-size` sets its size.
36
+ */
37
+ export declare function qrCode(text: string, label: string): SVGSVGElement;
33
38
  /**
34
39
  * An account's picture: an `<img>` that sends no referrer, loads lazily and has an empty `alt` (the name is beside it),
35
40
  * or a circle with the account's initial when there is no picture or it does not load. `avatarUrl` is only ever an
@@ -3,6 +3,7 @@
3
3
  * supplies (an account name, an error message) can become markup. Attributes go through `setAttribute`, text through
4
4
  * `textContent`.
5
5
  */
6
+ import { encodeQr } from '../core/qr.js';
6
7
  /** An HTML element of the element's own. `true` sets an empty attribute; `false` and `undefined` set none. */
7
8
  export function html(tag, attributes = {}, ...children) {
8
9
  const element = document.createElement(tag);
@@ -57,6 +58,42 @@ export function mark(path, options = {}) {
57
58
  svg.append(shape);
58
59
  return svg;
59
60
  }
61
+ /** The light modules kept around a QR code so a camera finds its edges (the standard asks for four; three reads well on a screen). */
62
+ const QR_QUIET = 3;
63
+ /**
64
+ * A QR code of `text` as one SVG path of dark modules on a light square, named `label` for a screen reader. It stays dark
65
+ * on light in a dark theme too, as cameras need; `--flung-qr-size` sets its size.
66
+ */
67
+ export function qrCode(text, label) {
68
+ const code = encodeQr(text);
69
+ const size = code.size + QR_QUIET * 2;
70
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
71
+ svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
72
+ svg.setAttribute('class', 'qr');
73
+ svg.setAttribute('part', 'qr');
74
+ svg.setAttribute('role', 'img');
75
+ svg.setAttribute('aria-label', label);
76
+ svg.setAttribute('shape-rendering', 'crispEdges');
77
+ // Presentation attributes, as `mark` sets: an unstyled panel still draws a code a camera reads.
78
+ svg.setAttribute('width', '132');
79
+ svg.setAttribute('height', '132');
80
+ const ground = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
81
+ ground.setAttribute('width', String(size));
82
+ ground.setAttribute('height', String(size));
83
+ ground.setAttribute('fill', '#ffffff');
84
+ const modules = document.createElementNS('http://www.w3.org/2000/svg', 'path');
85
+ const runs = [];
86
+ code.modules.forEach((row, y) => {
87
+ row.forEach((dark, x) => {
88
+ if (dark)
89
+ runs.push(`M${x + QR_QUIET} ${y + QR_QUIET}h1v1h-1z`);
90
+ });
91
+ });
92
+ modules.setAttribute('d', runs.join(''));
93
+ modules.setAttribute('fill', '#000000');
94
+ svg.append(ground, modules);
95
+ return svg;
96
+ }
60
97
  /**
61
98
  * An account's picture: an `<img>` that sends no referrer, loads lazily and has an empty `alt` (the name is beside it),
62
99
  * or a circle with the account's initial when there is no picture or it does not load. `avatarUrl` is only ever an
@@ -1,7 +1,7 @@
1
1
  import { createConnectController, initialConnectState } from '../core/controller.js';
2
2
  import { DEFAULT_LABELS, formatLabel as format } from '../core/labels.js';
3
3
  import { parsePlatformList } from '../core/platforms.js';
4
- import { avatar, button, html, mark } from './dom.js';
4
+ import { avatar, button, html, mark, qrCode } from './dom.js';
5
5
  import { CHOICE_MARKS, FLUNG_MARK, PLATFORM_MARKS } from './marks.js';
6
6
  import { BASE_STYLES, STYLES } from './styles.js';
7
7
  import { panelTooltip, TOOLTIP_BASE_STYLES, TOOLTIP_STYLES } from './tooltip.js';
@@ -12,13 +12,22 @@ const LAYOUTS = ['list', 'grid', 'compact'];
12
12
  */
13
13
  function stepTile(item, tiles) {
14
14
  const { step } = item;
15
- const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' ? step.accountId : null;
15
+ const accountId = step.kind === 'confirm' ? step.account.account.id : step.kind === 'waiting' || step.kind === 'approve' ? step.accountId : null;
16
16
  if (accountId !== null)
17
17
  return tiles.findIndex((tile) => tile.accounts.some((account) => account.account.id === accountId));
18
- if (step.kind === 'waiting')
18
+ if (step.kind === 'waiting' || step.kind === 'approve')
19
19
  return tiles.findIndex((tile) => tile.variant === step.variant);
20
20
  return 0;
21
21
  }
22
+ /**
23
+ * A step's kind for moving focus, with an approval split by where it is: making its link, showing it, or ended. Each
24
+ * change is a new place for focus.
25
+ */
26
+ function focusKind(step) {
27
+ if (step.kind !== 'approve')
28
+ return step.kind;
29
+ return step.ended !== null ? 'approve-ended' : step.approvalUrl !== null ? 'approve-link' : 'approve';
30
+ }
22
31
  /** The key of a tile's Connect button, so focus can come back to it. */
23
32
  const connectKey = (platform, variant) => (variant === null ? `connect-${platform}` : `connect-${platform}-${variant}`);
24
33
  /** Builds the class on first use, so importing the package on a server, where there is no `HTMLElement`, is safe. */
@@ -139,11 +148,15 @@ export function createFlungConnectClass() {
139
148
  const fallback = [];
140
149
  for (const item of state.platforms) {
141
150
  const before = this.#steps.get(item.platform) ?? { kind: 'idle', variant: null };
142
- const now = item.step.kind;
151
+ const now = focusKind(item.step);
143
152
  if (now === before.kind)
144
153
  continue;
145
154
  if (now === 'handle')
146
155
  wanted = `handle-${item.platform}`;
156
+ else if (now === 'approve-link')
157
+ wanted = `approve-open-${item.platform}`;
158
+ else if (now === 'approve-ended')
159
+ wanted = `approve-retry-${item.platform}`;
147
160
  else if (now === 'choose')
148
161
  wanted = `choose-${item.platform}-personal`;
149
162
  else if (now === 'confirm')
@@ -152,7 +165,7 @@ export function createFlungConnectClass() {
152
165
  else if (now === 'idle')
153
166
  fallback.push(connectKey(item.platform, before.variant), connectKey(item.platform, null));
154
167
  }
155
- this.#steps = new Map(state.platforms.map((item) => [item.platform, { kind: item.step.kind, variant: item.step.kind === 'waiting' ? item.step.variant : null }]));
168
+ this.#steps = new Map(state.platforms.map((item) => [item.platform, { kind: focusKind(item.step), variant: item.step.kind === 'waiting' || item.step.kind === 'approve' ? item.step.variant : null }]));
156
169
  const find = (key) => (key === null ? undefined : [...this.#root.querySelectorAll('[data-key]')].find((node) => node.dataset['key'] === key));
157
170
  (find(wanted) ?? (active !== null ? fallback.map(find).find((node) => node !== undefined) : undefined))?.focus();
158
171
  }
@@ -238,11 +251,32 @@ export function createFlungConnectClass() {
238
251
  const text = step.blockedUrl === null ? format(labels.waiting, { platform: name }) : `${format(labels.blocked, { platform: name })} `;
239
252
  return html('p', { class: 'note', part: 'note' }, html('span', { class: 'spinner', part: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text', part: 'note-text' }, text, link), button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform)));
240
253
  }
254
+ if (step.kind === 'approve')
255
+ return this.#approval(item, step, labels);
241
256
  if (step.kind === 'confirm') {
242
257
  return html('div', { class: 'confirm', part: 'confirm' }, html('p', { part: 'confirm-text' }, format(labels.confirmDisconnect, { account: step.account.name })), button(labels.disconnect, `confirm-${platform}`, 'confirm', () => void this.#controller?.confirmDisconnect(platform), { variant: 'danger' }), button(labels.keep, `keep-${platform}`, 'keep', () => this.#controller?.cancel(platform)));
243
258
  }
244
259
  return null;
245
260
  }
261
+ /**
262
+ * An approval in the platform's phone app (Farcaster): the line saying so, Open Warpcast, a QR code of the same link
263
+ * for a computer, and the wait. Ended, why it ended, with Try again and Cancel.
264
+ */
265
+ #approval(item, step, labels) {
266
+ const { platform, name } = item;
267
+ const cancel = button(labels.cancel, `cancel-${platform}`, 'cancel', () => this.#controller?.cancel(platform));
268
+ if (step.ended !== null) {
269
+ const why = step.ended.status === 'expired' ? format(labels.approvalExpired, { platform: name }) : step.ended.message === '' ? format(labels.approvalDeclined, { platform: name }) : format(labels.approvalFailed, { platform: name, message: step.ended.message });
270
+ const accountId = step.accountId;
271
+ const retry = button(labels.tryAgain, `approve-retry-${platform}`, 'retry', () => (accountId === null ? this.#controller?.connect(platform) : this.#controller?.reconnect(accountId)), { variant: 'primary' });
272
+ return html('div', { class: 'approve ended', part: 'approve approve-ended' }, html('p', { class: 'approve-text', part: 'approve-text', role: 'alert' }, why), html('div', { class: 'approve-actions', part: 'approve-actions' }, retry, cancel));
273
+ }
274
+ const url = step.approvalUrl;
275
+ const go = url === null
276
+ ? null
277
+ : html('div', { class: 'approve-go', part: 'approve-go' }, html('a', { class: 'approve-open', href: url, target: '_blank', rel: 'noopener', part: 'link link-approval', 'data-key': `approve-open-${platform}` }, labels.openApproval), html('div', { class: 'approve-scan', part: 'approve-scan' }, qrCode(url, format(labels.approvalQr, { platform: name })), html('p', { class: 'approve-hint', part: 'approve-hint' }, labels.scanApproval)));
278
+ return html('div', { class: 'approve', part: 'approve' }, html('p', { class: 'approve-text', part: 'approve-text' }, format(labels.approve, { platform: name })), go, html('p', { class: 'note', part: 'note', role: 'status' }, html('span', { class: 'spinner', part: 'spinner', 'aria-hidden': 'true' }), html('span', { class: 'note-text', part: 'note-text' }, labels.awaitingApproval), cancel));
279
+ }
246
280
  /**
247
281
  * What to connect, when a platform offers its own connect and a variant: a group of two buttons, each with an icon,
248
282
  * a name and a line under it (read as the button's description). Arrow keys move between them, Escape closes the
@@ -84,6 +84,7 @@ export const STYLES = `
84
84
  --flung-focus-width: 2px;
85
85
  --flung-focus-offset: 2px;
86
86
  --flung-grid-min: 16rem;
87
+ --flung-qr-size: 132px;
87
88
  display: block;
88
89
  font: var(--flung-font-size)/var(--flung-line-height) var(--flung-font);
89
90
  color: var(--flung-text);
@@ -167,6 +168,21 @@ button.option:hover:not(:disabled) { background: var(--flung-surface); border-co
167
168
  .option-text { min-width: 0; display: flex; flex-direction: column; }
168
169
  .option-name { font-weight: var(--flung-font-weight-strong); line-height: 1.3; }
169
170
  .option-description { font-size: var(--flung-font-size-small); font-weight: normal; color: var(--flung-muted); line-height: 1.3; }
171
+ .approve { margin: 10px 0 0 calc(var(--flung-badge-size) + var(--flung-gap)); padding: var(--flung-gap); border-radius: var(--flung-radius-inner); background: var(--flung-raised); display: flex; flex-direction: column; gap: 10px; }
172
+ .approve-text { margin: 0; font-size: calc(var(--flung-font-size) - 1px); }
173
+ .approve.ended .approve-text { color: var(--flung-danger); }
174
+ .approve-actions { display: flex; flex-wrap: wrap; gap: 8px; }
175
+ .approve-go { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
176
+ .approve-open {
177
+ display: inline-flex; align-items: center; height: var(--flung-button-height); padding: var(--flung-button-padding); border-radius: var(--flung-button-radius);
178
+ font-size: calc(var(--flung-font-size) - 1px); font-weight: var(--flung-font-weight-medium); text-decoration: none; white-space: nowrap;
179
+ background: var(--flung-accent); color: var(--flung-accent-text); border: var(--flung-border-width) solid var(--flung-accent);
180
+ }
181
+ .approve-open:hover { background: color-mix(in srgb, var(--flung-accent) 88%, var(--flung-surface)); }
182
+ .approve-scan { display: flex; align-items: center; gap: var(--flung-gap); }
183
+ .qr { flex: none; width: var(--flung-qr-size); height: var(--flung-qr-size); display: block; border-radius: var(--flung-radius-inner); border: var(--flung-border-width) solid var(--flung-border); }
184
+ .approve-hint { margin: 0; font-size: var(--flung-font-size-small); color: var(--flung-muted); max-width: 16rem; }
185
+ .approve .note { margin: 0; }
170
186
  .form label { flex-basis: 100%; font-size: var(--flung-font-size-small); color: var(--flung-muted); }
171
187
  .form input { flex: 1 1 12rem; min-width: 0; height: var(--flung-button-height); font: inherit; font-size: calc(var(--flung-font-size) - 1px); color: inherit; background: var(--flung-surface); border: var(--flung-border-width) solid var(--flung-border); border-radius: var(--flung-button-radius); padding: 0 10px; }
172
188
  button {
@@ -196,7 +212,7 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: var(--flun
196
212
  .footer .flung { width: 14px; height: 14px; fill: currentColor; display: block; }
197
213
  .panel[data-layout="grid"] .list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--flung-grid-min)), 1fr)); gap: var(--flung-gap); padding: var(--flung-padding); }
198
214
  .panel[data-layout="grid"] .platform, .panel[data-layout="grid"] .platform:first-child { border: var(--flung-border-width) solid var(--flung-border); border-radius: var(--flung-radius-inner); box-shadow: var(--flung-card-shadow); padding: var(--flung-gap); }
199
- .panel[data-layout="grid"] .accounts, .panel[data-layout="grid"] .note, .panel[data-layout="grid"] .confirm, .panel[data-layout="grid"] .form, .panel[data-layout="grid"] .choose { margin-left: 0; }
215
+ .panel[data-layout="grid"] .accounts, .panel[data-layout="grid"] .note, .panel[data-layout="grid"] .confirm, .panel[data-layout="grid"] .form, .panel[data-layout="grid"] .choose, .panel[data-layout="grid"] .approve { margin-left: 0; }
200
216
  .panel[data-layout="grid"] .accounts { border-left: 0; border-top: var(--flung-border-width) solid var(--flung-border); margin-top: var(--flung-gap); padding-top: 4px; }
201
217
  .panel[data-layout="grid"] .account { padding-left: 0; }
202
218
  .panel[data-layout="compact"] .count { display: none; }
@@ -204,7 +220,8 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: var(--flun
204
220
  .panel[data-layout="compact"] .account { padding-top: 3px; padding-bottom: 3px; }
205
221
  @media (max-width: 420px) {
206
222
  .platform { padding: var(--flung-gap); }
207
- .accounts, .note, .confirm, .form, .choose { margin-left: 0; }
223
+ .accounts, .note, .confirm, .form, .choose, .approve { margin-left: 0; }
224
+ .approve-scan { display: none; }
208
225
  .accounts { border-left: 0; border-top: var(--flung-border-width) solid var(--flung-border); margin-top: 10px; }
209
226
  .account { padding-left: 0; }
210
227
  .row button { padding: 0 12px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flung.dev/connect",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Let people connect and disconnect their social accounts from your page: a <flung-connect> web component and a React component for Flung, with no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",