@ofidj/generator-fidj 3.7.5 → 3.8.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.
@@ -1,4 +1,5 @@
1
- import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn} from "./service-agreement";
1
+ import {agreementMarkup, bindAgreement, acceptedAgreement, signInErrorMessage, providerEntry, rememberSignIn, forgetSignIn, showEmailEntry, type SigninShape} from "./service-agreement";
2
+ import {openProviderWindow, relayProviderAnswer, type ProviderWindow} from "./provider-window";
2
3
  import { FidjNodeService, FidjOidcClient } from "@ofidj/node";
3
4
  import "./style.css";
4
5
  import { showVersionBadge } from "./version";
@@ -16,7 +17,7 @@ type Settings = {
16
17
  // An owner may let their own app collect the credential beside the Fidj door.
17
18
  // Off unless asked for: the promise the entry makes otherwise is that this app
18
19
  // never sees a password.
19
- ownCredentials?: boolean;
20
+ signin?: SigninShape;
20
21
  };
21
22
  const root = document.querySelector<HTMLDivElement>("#app")!;
22
23
  const sdk = new FidjNodeService();
@@ -39,6 +40,10 @@ let leaving = false;
39
40
  let signInEmail = "";
40
41
  let signInPassword = "";
41
42
  let signInAgreementAccepted = false;
43
+ // Whether the person asked for the app's own form: the entry is rebuilt on every
44
+ // render, and a refused sign-in is a render, so it has to be remembered or the
45
+ // form folds away under the complaint about it.
46
+ let emailEntryOpen = false;
42
47
  const escape = (value: unknown) =>
43
48
  String(value ?? "").replace(
44
49
  /[&<>"']/g,
@@ -85,6 +90,85 @@ async function load() {
85
90
  api("privacy"),
86
91
  ]);
87
92
  }
93
+ // Taking the Fidj door, from inside the click that asked for it. The window has
94
+ // to exist before the authorization URL is fetched — building it costs a
95
+ // discovery round-trip, and a window opened after an await is one the browser
96
+ // no longer attributes to the press, which every popup blocker refuses.
97
+ // The window this page is waiting on, while it is waiting on it.
98
+ let waitingFor: ProviderWindow | null = null;
99
+
100
+ // What the entry says while that window is open: the way back to it, and the way
101
+ // out of it. The waiting happens in a window that can be behind this page, and
102
+ // from here that looks exactly like nothing having happened.
103
+ function offerTheWindowBack(providerWindow: ProviderWindow) {
104
+ const waiting = root.querySelector<HTMLButtonElement>("button[data-busy]");
105
+ if (!waiting) return;
106
+ waiting.classList.add("is-waiting");
107
+ // It says what is happening rather than what to do, because what to do is
108
+ // happening in the other window. Dropping the accent is the point: this is a
109
+ // state, not the way in. It stays pressable all the same — somebody who has
110
+ // lost that window behind this page needs precisely this to be pressable.
111
+ waiting.textContent = "Connecting with Fidj…";
112
+ waiting.title = "Bring the Fidj window back to the front";
113
+ // Borrowing a submit button's press: submitting would open a second window.
114
+ waiting.onclick = (event) => {
115
+ event.preventDefault();
116
+ providerWindow.focus();
117
+ };
118
+ const cancel = document.createElement("button");
119
+ cancel.type = "button";
120
+ cancel.id = "cancel-provider";
121
+ cancel.className = "quiet";
122
+ cancel.textContent = "Cancel";
123
+ cancel.onclick = () => {
124
+ cancel.disabled = true;
125
+ providerWindow.giveUp();
126
+ };
127
+ waiting.insertAdjacentElement("afterend", cancel);
128
+ }
129
+
130
+ function signInThroughProvider(trigger: HTMLElement | null) {
131
+ // Held, not read again: `oidc` is settled once at start-up, and reading it
132
+ // inside the callback asks the compiler to prove that across an await.
133
+ const provider = oidc;
134
+ if (!provider) return;
135
+ // Already open: bring that one back rather than start a second conversation.
136
+ if (waitingFor?.isOpen()) {
137
+ waitingFor.focus();
138
+ return;
139
+ }
140
+ const providerWindow = openProviderWindow();
141
+ trigger?.setAttribute("data-busy", "true");
142
+ void action(async () => {
143
+ let url: string;
144
+ try {
145
+ // Somebody who has just signed out is asked again rather than recognised:
146
+ // otherwise pressing the door undoes the sign-out without a screen.
147
+ url = await provider.beginLogin(
148
+ provider.signedOutHere() ? {prompt: "login"} : {},
149
+ );
150
+ } catch (reason) {
151
+ providerWindow?.giveUp();
152
+ throw reason;
153
+ }
154
+ // Blocked, or a browser that would not open one: leaving this page is the
155
+ // flow this one replaced, and it still works.
156
+ if (!providerWindow) {
157
+ window.location.assign(url);
158
+ return;
159
+ }
160
+ providerWindow.show(url);
161
+ waitingFor = providerWindow;
162
+ offerTheWindowBack(providerWindow);
163
+ const callback = await providerWindow.answer().finally(() => {
164
+ waitingFor = null;
165
+ });
166
+ // Closed by hand. A person who changed their mind is not owed an error.
167
+ if (!callback) return;
168
+ await provider.completeLogin(callback);
169
+ await load();
170
+ });
171
+ }
88
172
  async function action(task: () => Promise<void>) {
89
173
  if (busy) return;
90
174
  busy = true;
@@ -108,7 +192,7 @@ function render() {
108
192
  <main>${notice ? `<p class="notice" role="status">${escape(notice)}</p>` : ""}${error ? `<p class="error" role="alert">${escape(error)}</p>` : ""}
109
193
  ${
110
194
  !session
111
- ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? providerEntry(settings.title, settings.appId, settings.ownCredentials ? credentialFields() : "") : `<p>Sign in with your Fidj account.</p>${credentialFields()}${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
195
+ ? `<section class="welcome"><div><p class="eyebrow">A LITTLE SPACE FOR YOUR IDEAS</p><h1>Good ideas<br>start here.</h1><p>Keep your notes together, with access you understand and privacy you control.</p><div class="promise"><img src="/fidj-logo.png" alt=""><span>Your account connects through Fidj.<br>Your choices belong to this app.</span></div></div><form id="signin" class="card"><h2>Welcome to ${escape(settings.title)}</h2>${oidc ? providerEntry(settings.title, settings.appId, settings.signin === "button" ? "" : credentialFields(), false, settings.signin || "button") : `<p>Sign in with your Fidj account.</p>${credentialFields()}${agreementMarkup()}<button class="primary" type="submit">Continue</button>${settings.localDemo ? `<div class="demo"><strong>Try the local example</strong><p>Alex owns the app. Maya and Sam start with the Free role.</p><button type="button" data-demo="alex">Alex · owner</button><button type="button" data-demo="maya">Maya · member</button><button type="button" data-demo="sam">Sam · member</button></div>` : ""}`}</form></section>`
112
196
  : `
113
197
  <div class="page-heading"><div><p class="eyebrow">YOUR WORKSPACE</p><h1>A place to think.</h1><p>${escape(session.username)} <span class="roles">${session.roles.map(escape).join(" · ") || "No assigned roles"}</span></p></div><button id="signout">Sign out</button></div>
114
198
  <nav><button id="workspace-tab" class="${view === "workspace" ? "selected" : ""}">My notes</button><button id="privacy-tab" class="${view === "privacy" ? "selected" : ""}">My privacy</button><button id="refresh">Refresh access</button></nav>
@@ -131,6 +215,12 @@ function render() {
131
215
  }
132
216
  <footer>Built with Fidj · One identity. Separate choices for every app.</footer></main>`;
133
217
  void bindAgreement(el<HTMLFormElement>("signin"), settings.title, settings.apiEndpoint, settings.appId, signInAgreementAccepted);
218
+ // The app's own form, folded away under the Fidj door rather than beside it.
219
+ if (emailEntryOpen) showEmailEntry(true);
220
+ el("use-email")?.addEventListener("click", () => {
221
+ emailEntryOpen = !emailEntryOpen;
222
+ showEmailEntry(emailEntryOpen, true);
223
+ });
134
224
  el<HTMLFormElement>("signin")?.addEventListener("submit", (event) => {
135
225
  event.preventDefault();
136
226
  const email = el<HTMLInputElement>("email")?.value || "";
@@ -149,11 +239,14 @@ function render() {
149
239
  render();
150
240
  return;
151
241
  }
242
+ // The Fidj door opens a window of its own, from inside this click: opening
243
+ // it after the authorization URL has been fetched is what a popup blocker
244
+ // refuses, because the browser no longer attributes it to the press.
245
+ if (oidc) {
246
+ signInThroughProvider(event.submitter as HTMLElement | null);
247
+ return;
248
+ }
152
249
  void action(async () => {
153
- if (oidc) {
154
- window.location.assign(await oidc.beginLogin());
155
- return;
156
- }
157
250
  try {
158
251
  await sdk.login(email, password, { autoSignup: false, ...acceptance });
159
252
  } catch (reason) {
@@ -308,4 +401,15 @@ async function start() {
308
401
  root.textContent =
309
402
  "This app could not load. Please check the server and retry.";
310
403
  }
311
- void start();
404
+ // This document is the window the entry opened, and the provider has just
405
+ // answered into it: hand the answer to the page that opened it and get out of
406
+ // the way. A window the browser will not close was not scripted open, so the
407
+ // ordinary return path runs after all rather than stranding somebody here.
408
+ if (relayProviderAnswer()) {
409
+ root.innerHTML = '<p role="status">Signing you in…</p>';
410
+ window.setTimeout(() => {
411
+ if (!window.closed) void start();
412
+ }, 800);
413
+ } else {
414
+ void start();
415
+ }
@@ -0,0 +1,177 @@
1
+ // Signing in happens in a window of its own.
2
+ //
3
+ // Fidj's screens are served from another origin — Fidj's front end, or the API
4
+ // itself, depending on the deployment — and that origin refuses to be framed,
5
+ // so no dialog drawn inside this page can hold them. A window can, and it keeps
6
+ // the page the person was already on: they come back to what they were reading,
7
+ // not to a shell that reloaded underneath them and forgot where they were.
8
+ //
9
+ // The window is opened empty and synchronously, before anything is awaited. A
10
+ // window opened after an await is a window the browser no longer connects to
11
+ // the click that asked for it, and every popup blocker refuses it — so the
12
+ // authorization URL, which takes a discovery round-trip to build, is fetched
13
+ // afterwards and navigated into a window that already exists.
14
+
15
+ // One window per page, not one per browser.
16
+ //
17
+ // Naming it is what lets a second press reuse the window instead of stacking
18
+ // another one on top, and that much is wanted. Sharing the name across tabs is
19
+ // not: the answer comes back to whichever page opened the window, so a second
20
+ // tab reusing the first tab's window would hand that tab's sign-in to a page
21
+ // that never asked, and leave the one that did waiting forever. Stable for this
22
+ // document, unique to it.
23
+ const WINDOW_NAME = "fidj-signin-" + Math.random().toString(36).slice(2, 10);
24
+ const CALLBACK = "fidj:oidc-callback";
25
+
26
+ // Comfortable rather than minimal: the provider asks for a password, states who
27
+ // is asking and where the person is going back to, and a cramped window turns
28
+ // that into scrolling. Clamped to the screen so a small display still gets a
29
+ // window it can show whole, and centred on the display the opener is on.
30
+ function features() {
31
+ const width = Math.min(560, Math.max(320, window.screen.availWidth - 80));
32
+ const height = Math.min(760, Math.max(480, window.screen.availHeight - 80));
33
+ const left = Math.round(
34
+ (window.screenX || 0) + Math.max(0, (window.outerWidth - width) / 2),
35
+ );
36
+ const top = Math.round(
37
+ (window.screenY || 0) + Math.max(0, (window.outerHeight - height) / 3),
38
+ );
39
+ return `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
40
+ }
41
+
42
+ export type ProviderWindow = {
43
+ // Send the opened window to the provider. Separate from opening it because
44
+ // the URL is not known yet at the moment the click has to be honoured.
45
+ show(url: string): void;
46
+ // Still open, and still the one being waited for.
47
+ isOpen(): boolean;
48
+ // Bring it back in front. A window can be behind the page that opened it, on
49
+ // another desktop, or minimised, and from the page that looks exactly like
50
+ // nothing having happened — so the page needs to be able to fetch it.
51
+ focus(): void;
52
+ // Close it: the provider could not be reached and there is nothing for the
53
+ // window to show, or the person asked to stop.
54
+ giveUp(): void;
55
+ // The callback the provider handed to that window, or null when the person
56
+ // closed it without finishing. Closing is a decision, not a failure: it
57
+ // resolves so the entry simply reappears, with nothing said.
58
+ answer(): Promise<URL | null>;
59
+ };
60
+
61
+ export function openProviderWindow(): ProviderWindow | null {
62
+ let opened: Window | null = null;
63
+ try {
64
+ opened = window.open("", WINDOW_NAME, features());
65
+ } catch {
66
+ opened = null;
67
+ }
68
+ // Blocked, or a browser that refuses named windows. The caller falls back to
69
+ // leaving this page, which is the flow this one replaced.
70
+ if (!opened) return null;
71
+ try {
72
+ opened.document.write(
73
+ '<!doctype html><meta charset="utf-8"><title>Signing in with Fidj</title><body style="margin:0;font:15px/1.5 system-ui;color:#4a4540;display:grid;place-items:center;height:100vh">Opening Fidj…</body>',
74
+ );
75
+ opened.document.close();
76
+ } catch {}
77
+ return {
78
+ show(url: string) {
79
+ // `replace`, so the blank placeholder is not a history entry the person
80
+ // can press Back into.
81
+ try {
82
+ opened!.location.replace(url);
83
+ } catch {
84
+ opened!.location.href = url;
85
+ }
86
+ },
87
+ isOpen() {
88
+ try {
89
+ return !opened!.closed;
90
+ } catch {
91
+ return false;
92
+ }
93
+ },
94
+ focus() {
95
+ try {
96
+ opened!.focus();
97
+ } catch {}
98
+ },
99
+ giveUp() {
100
+ try {
101
+ opened!.close();
102
+ } catch {}
103
+ },
104
+ answer() {
105
+ return new Promise<URL | null>((resolve) => {
106
+ let done = false;
107
+ const finish = (result: URL | null) => {
108
+ if (done) return;
109
+ done = true;
110
+ window.removeEventListener("message", onMessage);
111
+ window.clearInterval(watch);
112
+ try {
113
+ opened!.close();
114
+ } catch {}
115
+ resolve(result);
116
+ };
117
+ const onMessage = (event: MessageEvent) => {
118
+ // Only this origin, and only the window this entry opened: the
119
+ // callback carries a single-use authorization code, and anything else
120
+ // posting into this page is not part of this conversation.
121
+ if (event.origin !== window.location.origin) return;
122
+ if (event.source !== opened) return;
123
+ const data = event.data as { fidj?: string; href?: string } | null;
124
+ if (!data || data.fidj !== CALLBACK || typeof data.href !== "string")
125
+ return;
126
+ let callback: URL;
127
+ try {
128
+ callback = new URL(data.href);
129
+ } catch {
130
+ return;
131
+ }
132
+ if (callback.origin !== window.location.origin) return;
133
+ finish(callback);
134
+ };
135
+ window.addEventListener("message", onMessage);
136
+ // A window closed by hand answers nothing, and nothing is what this
137
+ // page would wait for forever otherwise. `closed` is the only signal a
138
+ // cross-origin window gives, so it is polled.
139
+ const watch = window.setInterval(() => {
140
+ if (opened!.closed) finish(null);
141
+ }, 400);
142
+ });
143
+ },
144
+ };
145
+ }
146
+
147
+ // The other half, running in that window: the provider has just answered into
148
+ // it, and its only remaining job is to hand the answer back and get out of the
149
+ // way. It never completes the sign-in itself — the transaction that has to
150
+ // match the answer was written by the page that opened it, and session storage
151
+ // is copied into a new window, not shared with it.
152
+ //
153
+ // Answering `true` means the hand-off was made and this window asked to close;
154
+ // the caller shows one line and, if the browser refuses to close a window it
155
+ // did not see scripted open, finishes the sign-in here after all.
156
+ export function relayProviderAnswer(): boolean {
157
+ let opener: Window | null = null;
158
+ try {
159
+ opener = window.opener as Window | null;
160
+ } catch {
161
+ return false;
162
+ }
163
+ if (!opener || opener === window || opener.closed) return false;
164
+ if (!new URL(window.location.href).searchParams.has("state")) return false;
165
+ try {
166
+ opener.postMessage(
167
+ { fidj: CALLBACK, href: window.location.href },
168
+ window.location.origin,
169
+ );
170
+ } catch {
171
+ return false;
172
+ }
173
+ try {
174
+ window.close();
175
+ } catch {}
176
+ return true;
177
+ }
@@ -28,7 +28,13 @@ export async function bindAgreement(form: HTMLFormElement | null, title: string,
28
28
  const dialog = form.querySelector<HTMLDialogElement>("#agreement-dialog")!;
29
29
  const status = form.querySelector<HTMLElement>("#agreement-status")!;
30
30
  const retry = form.querySelector<HTMLButtonElement>("#retry-agreement")!;
31
- const submitButtons = form.querySelectorAll<HTMLButtonElement>('button[type="submit"]');
31
+ // What this agreement gates is the app's own door, and only that one. The
32
+ // Fidj door is about to be asked the same question on the screen that names
33
+ // the app, where the answer is recorded with its version — so an agreement
34
+ // that is still loading, or that could not be loaded at all, must not be what
35
+ // stands between a person and the door that does not need it.
36
+ const gated = form.querySelector("#email-entry") || form;
37
+ const submitButtons = gated.querySelectorAll<HTMLButtonElement>('button[type="submit"]');
32
38
  const update = () => submitButtons.forEach(button => { button.disabled = checkbox.disabled; });
33
39
  form.querySelector("#agreement-label")!.textContent = `I accept the service agreement for ${title}.`;
34
40
  update();
@@ -97,21 +103,25 @@ export function forgetSignIn(appId: string) {
97
103
  } catch {}
98
104
  }
99
105
 
100
- // Both doors, not one. An app that delegates to Fidj still has people who would
101
- // rather type an address and a password than be sent somewhere, and people Fidj
102
- // already recognises who should not have to. So the entry offers the credential
103
- // form and the Fidj entry together, and leads with whichever fits what this
104
- // browser knows: a remembered address puts Fidj first, no memory puts the form
105
- // first.
106
+ // Both doors, but not side by side. An app that delegates to Fidj still has
107
+ // people who would rather type an address and a password than be sent
108
+ // somewhere, so the credential form stays — folded away under a line of text
109
+ // rather than standing in front of the door most people should take. Offering
110
+ // the two as equals asked every arrival to choose between them, and the one
111
+ // that looked like an ordinary login won by looking ordinary.
106
112
  //
107
- // The trade-off is stated where it is made: on this path the app's own page
108
- // handles the Fidj password, so it is the app — not only Fidj — that must be
109
- // trusted with it. The Fidj entry beside it never is.
113
+ // The trade-off is stated where it is made: on the credential path the app's
114
+ // own page handles the Fidj password, so it is the app — not only Fidj — that
115
+ // must be trusted with it. The Fidj entry above it never is, which is why it is
116
+ // the one wearing the accent.
117
+ export type SigninShape = "button" | "inline" | "both";
118
+
110
119
  export function providerEntry(
111
120
  title: string,
112
121
  appId: string,
113
122
  credentials: string,
114
123
  isFidjItself = false,
124
+ shape: SigninShape = "button",
115
125
  ) {
116
126
  const escapeText = (value: unknown) =>
117
127
  String(value ?? "").replace(
@@ -122,25 +132,72 @@ export function providerEntry(
122
132
  ]!,
123
133
  );
124
134
  const hint = signInHint(appId);
125
- const both = Boolean(credentials);
135
+ const both = shape === "both" && Boolean(credentials);
136
+ // The app's own form and nothing else. Nothing is folded away because there is
137
+ // no second way in to fold it under, and the trade-off is stated plainly:
138
+ // on this path it is this site that holds the password.
139
+ if (shape === "inline")
140
+ return (
141
+ `<p class="signin-lead">${
142
+ isFidjItself
143
+ ? "One account across every app that uses Fidj, and a separate set of choices for each one."
144
+ : `${escapeText(title)} accounts are Fidj accounts. Sign in below — ${escapeText(title)} handles your password itself on this page.`
145
+ }</p>` +
146
+ agreementMarkup() +
147
+ credentials
148
+ );
126
149
  const lead = hint
127
150
  ? `<p class="signin-lead">You signed in here with Fidj before. ${escapeText(title)} accounts are Fidj accounts — continue as yourself, or use another.</p>`
128
151
  : both
129
- ? `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. Sign in below, or let Fidj do it on its own page — where this site never sees your password.</p>`
152
+ ? `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. Fidj asks in a window of its own, so this site never sees your password.</p>`
130
153
  : isFidjItself
131
- ? `<p class="signin-lead">One account across every app that uses Fidj, and a separate set of choices for each one. Sign in, or create yours, on the next screen.</p>`
132
- : `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. You will sign in — or create yours — on Fidj's own page, so this site never sees your password.</p>`;
154
+ ? `<p class="signin-lead">One account across every app that uses Fidj, and a separate set of choices for each one. Fidj asks in a window of its own; this page stays where it is.</p>`
155
+ : `<p class="signin-lead">${escapeText(title)} accounts are Fidj accounts. You will sign in — or create yours — in a Fidj window, so this site never sees your password.</p>`;
156
+ // The accent is the Fidj mark, and this is the one control on the screen that
157
+ // is Fidj's rather than the app's. It is also the door the app would rather
158
+ // people took, and those two happen to want the same thing.
133
159
  const fidj = hint
134
- ? `<button class="primary" type="submit" name="entry" value="fidj">Continue as ${escapeText(hint)}</button><button type="button" id="forget-hint" class="quiet">Use a different account</button>`
135
- : `<button class="${both ? "secondary" : "primary"}" type="submit" name="entry" value="fidj">${isFidjItself ? "Sign in" : "Sign in with Fidj"}</button>`;
160
+ ? `<button class="primary fidj-entry" type="submit" name="entry" value="fidj">Continue as ${escapeText(hint)}</button><button type="button" id="forget-hint" class="quiet">Use a different account</button>`
161
+ : `<button class="primary fidj-entry" type="submit" name="entry" value="fidj">${isFidjItself ? "Sign in" : "Sign in with Fidj"}</button>`;
136
162
  // Fidj is the only door: it collects the agreement itself, a moment later, on
137
163
  // the screen that names the app — and records it with its version. Collecting
138
164
  // it here first recorded nothing and asked the same question twice.
139
- if (!both) return lead + fidj;
140
- // Both doors. A remembered address puts Fidj first because it is one tap; no
141
- // memory puts the form first, because that is what the person came to do.
142
- const divider = `<div class="signin-divider"><span>or</span></div>`;
143
- return hint
144
- ? lead + agreementMarkup() + fidj + divider + credentials
145
- : lead + agreementMarkup() + credentials + divider + fidj;
165
+ if (shape !== "both" || !both) return lead + fidj;
166
+ // Both doors. The app's own one is a second thought for the person who wants
167
+ // it, which is what a disclosure is for: it costs one click and takes nothing
168
+ // away, where a second button of equal weight cost everybody a decision.
169
+ return (
170
+ lead +
171
+ fidj +
172
+ `<div class="signin-alternate"><button type="button" id="use-email" class="signin-toggle" aria-expanded="false" aria-controls="email-entry"><span class="signin-toggle-label">Inline form<span class="caret" aria-hidden="true"></span></span></button>
173
+ <div id="email-entry" hidden>${agreementMarkup()}${credentials}</div></div>`
174
+ );
175
+ }
176
+
177
+ // Opening the app's own form takes the space the Fidj door was using.
178
+ //
179
+ // The two are alternatives, not a list, and watching one fold away as the other
180
+ // arrives is what says so — where a form appearing underneath a button that is
181
+ // still there reads as "and also". The toggle stays put through both states, so
182
+ // it ends up labelling whichever one is on screen: a caret pointing down at a
183
+ // form that is not here yet, and up at the one it would put away.
184
+ //
185
+ // Shared, because both app shapes render the same entry.
186
+ export function showEmailEntry(open: boolean, focus = false) {
187
+ const fields = document.getElementById("email-entry");
188
+ const toggle = document.getElementById("use-email");
189
+ if (!fields || !toggle) return;
190
+ toggle.setAttribute("aria-expanded", String(open));
191
+ fields.hidden = !open;
192
+ const door = document.querySelector<HTMLButtonElement>(".fidj-entry");
193
+ if (door) {
194
+ door.classList.toggle("is-folded", open);
195
+ // Out of the tab order and out of the accessibility tree the moment it
196
+ // starts leaving: something mid-fold is not something to press, and an
197
+ // animation is not what decides that.
198
+ if (open) door.setAttribute("aria-hidden", "true");
199
+ else door.removeAttribute("aria-hidden");
200
+ door.tabIndex = open ? -1 : 0;
201
+ }
202
+ if (open && focus) document.getElementById("email")?.focus();
146
203
  }