@vit-foundation/ui 0.1.0 → 0.2.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.
Files changed (56) hide show
  1. package/dist/components/account/AccountPanel.svelte +224 -0
  2. package/dist/components/account/AccountPanel.svelte.d.ts +37 -0
  3. package/dist/components/account/NewsletterSignup.svelte +91 -0
  4. package/dist/components/account/NewsletterSignup.svelte.d.ts +13 -0
  5. package/dist/components/account/index.d.ts +3 -0
  6. package/dist/components/account/index.js +2 -0
  7. package/dist/components/auth/AuthPageShell.svelte +108 -0
  8. package/dist/components/auth/AuthPageShell.svelte.d.ts +27 -0
  9. package/dist/components/auth/GoogleAuthForm.svelte +72 -0
  10. package/dist/components/auth/GoogleAuthForm.svelte.d.ts +26 -0
  11. package/dist/components/auth/LoginForm.svelte +176 -0
  12. package/dist/components/auth/LoginForm.svelte.d.ts +37 -0
  13. package/dist/components/auth/SignupForm.svelte +157 -0
  14. package/dist/components/auth/SignupForm.svelte.d.ts +27 -0
  15. package/dist/components/auth/index.d.ts +7 -0
  16. package/dist/components/auth/index.js +4 -0
  17. package/dist/components/contact/ContactForm.svelte +148 -0
  18. package/dist/components/contact/ContactForm.svelte.d.ts +27 -0
  19. package/dist/components/contact/index.d.ts +2 -0
  20. package/dist/components/contact/index.js +1 -0
  21. package/dist/components/ui/Honeypot.svelte +44 -0
  22. package/dist/components/ui/Honeypot.svelte.d.ts +24 -0
  23. package/dist/components/ui/LocaleField.svelte +21 -0
  24. package/dist/components/ui/LocaleField.svelte.d.ts +16 -0
  25. package/dist/components/ui/NewsletterIntentField.svelte +31 -0
  26. package/dist/components/ui/NewsletterIntentField.svelte.d.ts +28 -0
  27. package/dist/components/ui/index.d.ts +3 -0
  28. package/dist/components/ui/index.js +3 -0
  29. package/dist/components/weeklies/CommentSection.svelte +229 -0
  30. package/dist/components/weeklies/CommentSection.svelte.d.ts +30 -0
  31. package/dist/components/weeklies/ReactionBar.svelte +170 -0
  32. package/dist/components/weeklies/ReactionBar.svelte.d.ts +31 -0
  33. package/dist/components/weeklies/index.d.ts +4 -0
  34. package/dist/components/weeklies/index.js +2 -0
  35. package/dist/config/messages.js +68 -0
  36. package/dist/config/types.d.ts +70 -0
  37. package/dist/content/index.d.ts +2 -1
  38. package/dist/content/index.js +1 -0
  39. package/dist/content/types.d.ts +34 -0
  40. package/dist/content/types.js +4 -1
  41. package/dist/forms/constraints.d.ts +35 -0
  42. package/dist/forms/constraints.js +19 -0
  43. package/dist/forms/index.d.ts +3 -0
  44. package/dist/forms/index.js +2 -0
  45. package/dist/forms/transport.d.ts +26 -0
  46. package/dist/forms/transport.js +32 -0
  47. package/dist/forms/types.d.ts +54 -0
  48. package/dist/forms/types.js +13 -0
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.js +6 -0
  51. package/dist/styles/base.css +41 -0
  52. package/dist/testing/remote-form.d.ts +45 -0
  53. package/dist/testing/remote-form.js +85 -0
  54. package/dist/utils/contact.d.ts +4 -0
  55. package/dist/utils/contact.js +15 -0
  56. package/package.json +28 -4
@@ -0,0 +1,224 @@
1
+ <script module lang="ts">
2
+ import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
3
+
4
+ /** The preflighted display-name form. */
5
+ export type UpdateNameFormInstance = RemoteFormInstance<{ displayName: RemoteField }>;
6
+
7
+ /**
8
+ * The newsletter toggle; a success names which way it toggled. The reason
9
+ * is typed open — the host's result union may carry other successes.
10
+ */
11
+ export type NewsletterToggleFormInstance = RemoteFormInstance<
12
+ { action: RemoteField; locale: RemoteField },
13
+ FormResultOf<{ reason: string }>
14
+ >;
15
+
16
+ export type DeleteAccountFormInstance = RemoteFormInstance<{
17
+ confirm: RemoteField;
18
+ locale: RemoteField;
19
+ }>;
20
+
21
+ export type LogoutFormInstance = RemoteFormInstance<{ locale: RemoteField }>;
22
+ </script>
23
+
24
+ <script lang="ts">
25
+ import { getUiConfig } from '../../config/context.js';
26
+ import { DISPLAY_NAME } from '../../forms/constraints.js';
27
+ import Button from '../ui/Button.svelte';
28
+ import Field from '../ui/Field.svelte';
29
+ import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
30
+ import FormResultSlot from '../ui/FormResultSlot.svelte';
31
+ import Link from '../ui/Link.svelte';
32
+ import LocaleField from '../ui/LocaleField.svelte';
33
+
34
+ interface Props {
35
+ displayName: string;
36
+ email: string | null;
37
+ isSubscribed: boolean;
38
+ /** False when the server lacks the admin key for account deletion. */
39
+ canDelete: boolean;
40
+ /** The remote forms, passed by the host page (updateName preflighted). */
41
+ updateNameForm: UpdateNameFormInstance;
42
+ newsletterToggleForm: NewsletterToggleFormInstance;
43
+ deleteAccountForm: DeleteAccountFormInstance;
44
+ logoutForm: LogoutFormInstance;
45
+ }
46
+
47
+ let {
48
+ displayName,
49
+ email,
50
+ isSubscribed,
51
+ canDelete,
52
+ updateNameForm,
53
+ newsletterToggleForm,
54
+ deleteAccountForm,
55
+ logoutForm
56
+ }: Props = $props();
57
+
58
+ const config = getUiConfig();
59
+ const msg = $derived(config.messages);
60
+
61
+ const name = $derived(updateNameForm);
62
+ const newsletter = $derived(newsletterToggleForm);
63
+ const del = $derived(deleteAccountForm);
64
+
65
+ const newsletterSuccess = $derived(
66
+ newsletter.result?.ok && newsletter.result.reason === 'subscribed'
67
+ ? msg.account_newsletterSuccessOn()
68
+ : msg.account_newsletterSuccessOff()
69
+ );
70
+ </script>
71
+
72
+ <div class="panel">
73
+ <section aria-labelledby="account-name-heading">
74
+ <h2 class="subsection-heading" id="account-name-heading">{msg.account_nameHeading()}</h2>
75
+ <form class="form-stack" {...name}>
76
+ <Field
77
+ id="account-name"
78
+ label={msg.account_nameLabel()}
79
+ field={name.fields.displayName}
80
+ constraint={DISPLAY_NAME}
81
+ >
82
+ {#snippet children(attrs)}
83
+ <input
84
+ class="control"
85
+ required
86
+ autocomplete="nickname"
87
+ {...attrs}
88
+ {...name.fields.displayName.as('text', displayName)}
89
+ />
90
+ {/snippet}
91
+ </Field>
92
+ <div class="actions">
93
+ <Button type="submit" pending={name.pending}>{msg.account_nameSubmit()}</Button>
94
+ </div>
95
+ <FormResultSlot result={name.result} successMessage={msg.account_nameSuccess()} />
96
+ </form>
97
+ </section>
98
+
99
+ {#if email}
100
+ <section aria-labelledby="account-newsletter-heading">
101
+ <h2 class="subsection-heading" id="account-newsletter-heading">
102
+ {msg.account_newsletterHeading()}
103
+ </h2>
104
+ <p class="status">
105
+ {isSubscribed
106
+ ? msg.account_newsletterStatusOn({ email })
107
+ : msg.account_newsletterStatusOff()}
108
+ </p>
109
+ <form class="form-stack" {...newsletter}>
110
+ <input
111
+ {...newsletter.fields.action.as('hidden', isSubscribed ? 'unsubscribe' : 'subscribe')}
112
+ />
113
+ <LocaleField field={newsletter.fields.locale} />
114
+ <div class="actions">
115
+ <Button type="submit" pending={newsletter.pending}>
116
+ {isSubscribed ? msg.account_newsletterUnsubscribe() : msg.account_newsletterSubscribe()}
117
+ </Button>
118
+ </div>
119
+ <FormResultSlot result={newsletter.result} successMessage={newsletterSuccess} />
120
+ </form>
121
+ </section>
122
+ {/if}
123
+
124
+ <section aria-labelledby="account-data-heading">
125
+ <h2 class="subsection-heading" id="account-data-heading">{msg.account_dataHeading()}</h2>
126
+ <p class="status">{msg.account_dataDescription()}</p>
127
+ <p>
128
+ <Link class="download" href="/account/data" download>{msg.account_dataDownload()}</Link>
129
+ </p>
130
+ </section>
131
+
132
+ <section aria-labelledby="account-logout-heading">
133
+ <h2 class="subsection-heading" id="account-logout-heading">{msg.account_logout()}</h2>
134
+ <form class="form-stack" {...logoutForm}>
135
+ <LocaleField field={logoutForm.fields.locale} />
136
+ <div class="actions">
137
+ <Button type="submit" pending={logoutForm.pending}>{msg.account_logout()}</Button>
138
+ </div>
139
+ <!-- Success redirects, so only a failure ever renders here — and a
140
+ logout that failed leaves the reader signed in with nothing said. -->
141
+ <FormErrorFeedback result={logoutForm.result} />
142
+ </form>
143
+ </section>
144
+
145
+ <section class="danger" aria-labelledby="account-delete-heading">
146
+ <h2 class="subsection-heading" id="account-delete-heading">{msg.account_deleteHeading()}</h2>
147
+ <p class="status">{msg.account_deleteWarning()}</p>
148
+ {#if canDelete}
149
+ <form class="form-stack" {...del}>
150
+ <label class="confirm">
151
+ <input required {...del.fields.confirm.as('checkbox')} />
152
+ {msg.account_deleteConfirmLabel()}
153
+ </label>
154
+ <LocaleField field={del.fields.locale} />
155
+ <div class="actions">
156
+ <Button type="submit" pending={del.pending}>{msg.account_deleteSubmit()}</Button>
157
+ </div>
158
+ <FormErrorFeedback
159
+ result={del.result}
160
+ messages={{ unavailable: msg.account_error_unavailable() }}
161
+ />
162
+ </form>
163
+ {:else}
164
+ <p class="status">{msg.account_error_unavailable()}</p>
165
+ {/if}
166
+ </section>
167
+ </div>
168
+
169
+ <style>
170
+ .panel {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: var(--space-5);
174
+ max-width: 32rem;
175
+ }
176
+
177
+ section {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: var(--space-2);
181
+ }
182
+
183
+ /* Size is the declared one; only the rhythm is this panel's. */
184
+ .subsection-heading {
185
+ margin: 0;
186
+ }
187
+
188
+ input {
189
+ padding: var(--space-2);
190
+ }
191
+
192
+ .status {
193
+ color: var(--color-ink-secondary);
194
+ margin: 0;
195
+ }
196
+
197
+ /* Link renders the anchor, so the scope class cannot land on it — the same
198
+ :global reach ProjectCard and the home page's CTAs use. */
199
+ section :global(a.download) {
200
+ color: var(--color-brand);
201
+ }
202
+
203
+ .confirm {
204
+ display: flex;
205
+ align-items: baseline;
206
+ gap: var(--space-2);
207
+ font-weight: 400;
208
+ font-size: var(--text-base);
209
+ }
210
+
211
+ .confirm input {
212
+ padding: 0;
213
+ }
214
+
215
+ .danger {
216
+ border: 1px solid var(--series-8);
217
+ border-radius: var(--radius);
218
+ padding: var(--space-3);
219
+ }
220
+
221
+ .danger h2 {
222
+ color: var(--series-8);
223
+ }
224
+ </style>
@@ -0,0 +1,37 @@
1
+ import type { FormResultOf, RemoteField, RemoteFormInstance } from '../../forms/types.js';
2
+ /** The preflighted display-name form. */
3
+ export type UpdateNameFormInstance = RemoteFormInstance<{
4
+ displayName: RemoteField;
5
+ }>;
6
+ /**
7
+ * The newsletter toggle; a success names which way it toggled. The reason
8
+ * is typed open — the host's result union may carry other successes.
9
+ */
10
+ export type NewsletterToggleFormInstance = RemoteFormInstance<{
11
+ action: RemoteField;
12
+ locale: RemoteField;
13
+ }, FormResultOf<{
14
+ reason: string;
15
+ }>>;
16
+ export type DeleteAccountFormInstance = RemoteFormInstance<{
17
+ confirm: RemoteField;
18
+ locale: RemoteField;
19
+ }>;
20
+ export type LogoutFormInstance = RemoteFormInstance<{
21
+ locale: RemoteField;
22
+ }>;
23
+ interface Props {
24
+ displayName: string;
25
+ email: string | null;
26
+ isSubscribed: boolean;
27
+ /** False when the server lacks the admin key for account deletion. */
28
+ canDelete: boolean;
29
+ /** The remote forms, passed by the host page (updateName preflighted). */
30
+ updateNameForm: UpdateNameFormInstance;
31
+ newsletterToggleForm: NewsletterToggleFormInstance;
32
+ deleteAccountForm: DeleteAccountFormInstance;
33
+ logoutForm: LogoutFormInstance;
34
+ }
35
+ declare const AccountPanel: import("svelte").Component<Props, {}, "">;
36
+ type AccountPanel = ReturnType<typeof AccountPanel>;
37
+ export default AccountPanel;
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { getUiConfig } from '../../config/context.js';
3
+ import { withNewsletterIntent } from '../../forms/transport.js';
4
+ import type { NewsletterToggleFormInstance } from './AccountPanel.svelte';
5
+ import Button from '../ui/Button.svelte';
6
+ import FormResultSlot from '../ui/FormResultSlot.svelte';
7
+ import Link from '../ui/Link.svelte';
8
+ import LocaleField from '../ui/LocaleField.svelte';
9
+
10
+ interface Props {
11
+ /** From the root layout: null when logged out. */
12
+ account?: { displayName: string; newsletterSubscribed: boolean } | null;
13
+ /** The remote form the host layout passes in. */
14
+ newsletterToggleForm: NewsletterToggleFormInstance;
15
+ }
16
+
17
+ let { account = null, newsletterToggleForm }: Props = $props();
18
+
19
+ const config = getUiConfig();
20
+ const msg = $derived(config.messages);
21
+
22
+ // The newsletter is registered-users-only (the account email is already
23
+ // verified — no app mail needed). Logged out, the band routes to the auth
24
+ // pages carrying the intent as ?newsletter=1 (never an email in a URL).
25
+ const f = $derived(newsletterToggleForm);
26
+ </script>
27
+
28
+ <section class="newsletter" aria-labelledby="newsletter-title">
29
+ <div class="inner band">
30
+ <h2 id="newsletter-title">{msg.newsletter_title()}</h2>
31
+ <p>{msg.newsletter_intro()}</p>
32
+
33
+ {#if account === null}
34
+ <p class="prompt">
35
+ {msg.newsletter_promptLoggedOut()}
36
+ <Link href={withNewsletterIntent('/signup')}>{msg.comments_signupLink()}</Link>
37
+ ·
38
+ <Link href={withNewsletterIntent('/login')}>{msg.comments_loginLink()}</Link>
39
+ </p>
40
+ {:else if account.newsletterSubscribed}
41
+ <p class="prompt">
42
+ {msg.newsletter_subscribedNote()}
43
+ <Link href="/account">{msg.account_navLabel()}</Link>
44
+ </p>
45
+ {:else}
46
+ <form {...f}>
47
+ <input {...f.fields.action.as('hidden', 'subscribe')} />
48
+ <LocaleField field={f.fields.locale} />
49
+ <Button type="submit" pending={f.pending}>
50
+ {msg.account_newsletterSubscribe()}
51
+ </Button>
52
+ </form>
53
+ {#if f.result}
54
+ <div class="feedback-slot">
55
+ <FormResultSlot result={f.result} successMessage={msg.account_newsletterSuccessOn()} />
56
+ </div>
57
+ {/if}
58
+ {/if}
59
+ </div>
60
+ </section>
61
+
62
+ <style>
63
+ .newsletter {
64
+ background: var(--color-band-grey);
65
+ }
66
+
67
+ .inner {
68
+ text-align: center;
69
+ }
70
+
71
+ h2 {
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.15em;
74
+ font-size: var(--text-lg);
75
+ }
76
+
77
+ .prompt {
78
+ color: var(--color-ink-secondary);
79
+ }
80
+
81
+ form {
82
+ display: flex;
83
+ justify-content: center;
84
+ gap: var(--space-2);
85
+ flex-wrap: wrap;
86
+ }
87
+
88
+ .feedback-slot {
89
+ margin-top: var(--space-3);
90
+ }
91
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { NewsletterToggleFormInstance } from './AccountPanel.svelte';
2
+ interface Props {
3
+ /** From the root layout: null when logged out. */
4
+ account?: {
5
+ displayName: string;
6
+ newsletterSubscribed: boolean;
7
+ } | null;
8
+ /** The remote form the host layout passes in. */
9
+ newsletterToggleForm: NewsletterToggleFormInstance;
10
+ }
11
+ declare const NewsletterSignup: import("svelte").Component<Props, {}, "">;
12
+ type NewsletterSignup = ReturnType<typeof NewsletterSignup>;
13
+ export default NewsletterSignup;
@@ -0,0 +1,3 @@
1
+ export { default as AccountPanel } from './AccountPanel.svelte';
2
+ export type { DeleteAccountFormInstance, LogoutFormInstance, NewsletterToggleFormInstance, UpdateNameFormInstance } from './AccountPanel.svelte';
3
+ export { default as NewsletterSignup } from './NewsletterSignup.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as AccountPanel } from './AccountPanel.svelte';
2
+ export { default as NewsletterSignup } from './NewsletterSignup.svelte';
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { getUiConfig } from '../../config/context.js';
4
+ import { hasNewsletterIntent, withNewsletterIntent } from '../../forms/transport.js';
5
+ import PageShell from '../layout/PageShell.svelte';
6
+ import Link from '../ui/Link.svelte';
7
+
8
+ /**
9
+ * The /login and /signup chrome: heading, intro, the tab pair that
10
+ * preserves the newsletter intent, and the legal note. Those two pages
11
+ * differ in which forms they compose and in how consent is mirrored into
12
+ * the Google flow — not in any of this.
13
+ *
14
+ * The legal note is here rather than per page because it is the no-JS
15
+ * fallback for the consent copy, and it must appear on both.
16
+ */
17
+ interface Props {
18
+ /** Browser title. */
19
+ title: string;
20
+ heading: string;
21
+ intro: string;
22
+ /**
23
+ * The URL the tab pair reads. Defaulted to the config's router URL;
24
+ * only tests and stories pass one. The current tab is derived from its
25
+ * canonical pathname, so locale prefixes don't break highlighting.
26
+ */
27
+ url?: URL;
28
+ /** The email form and the Google button. */
29
+ children: Snippet;
30
+ }
31
+
32
+ let { title, heading, intro, url = undefined, children }: Props = $props();
33
+
34
+ const config = getUiConfig();
35
+ const msg = $derived(config.messages);
36
+
37
+ const currentUrl = $derived(url ?? config.url() ?? new URL('http://localhost/'));
38
+ const currentPath = $derived(config.canonicalPathname(currentUrl));
39
+
40
+ // Real links, not client-side tabs: mode switching works without JS and each
41
+ // mode keeps its own URL. The newsletter intent survives the switch.
42
+ const tabPath = (path: string): string =>
43
+ hasNewsletterIntent(currentUrl) ? withNewsletterIntent(path) : path;
44
+
45
+ const tabs = $derived([
46
+ { href: tabPath('/login'), label: msg.login_title(), current: currentPath === '/login' },
47
+ { href: tabPath('/signup'), label: msg.signup_title(), current: currentPath === '/signup' }
48
+ ]);
49
+ </script>
50
+
51
+ <PageShell {title} variant="form">
52
+ <h1>{heading}</h1>
53
+ <p class="intro">{intro}</p>
54
+ <nav class="tabs" aria-label={msg.auth_tabsLabel()}>
55
+ {#each tabs as tab (tab.href)}
56
+ <Link href={tab.href} aria-current={tab.current ? 'page' : undefined}>{tab.label}</Link>
57
+ {/each}
58
+ </nav>
59
+ {@render children()}
60
+ <p class="legal-note">
61
+ {msg.auth_legalNotePre()}
62
+ <Link href="/legal">{msg.signup_termsLink()}</Link>
63
+ </p>
64
+ </PageShell>
65
+
66
+ <style>
67
+ h1 {
68
+ margin: 0;
69
+ }
70
+
71
+ .tabs {
72
+ display: flex;
73
+ gap: var(--space-3);
74
+ border-bottom: 1px solid var(--color-hairline);
75
+ margin-bottom: var(--space-4);
76
+ max-width: 24rem;
77
+ }
78
+
79
+ .tabs :global(a) {
80
+ text-decoration: none;
81
+ color: var(--color-ink-secondary);
82
+ padding: var(--space-2) 0;
83
+ border-bottom: 2px solid transparent;
84
+ font-weight: 600;
85
+ }
86
+
87
+ .tabs :global(a:hover) {
88
+ color: var(--color-ink);
89
+ }
90
+
91
+ .tabs :global(a[aria-current='page']) {
92
+ color: var(--color-ink);
93
+ border-bottom-color: var(--color-brand);
94
+ }
95
+
96
+ .intro {
97
+ color: var(--color-ink-secondary);
98
+ margin: 0;
99
+ max-width: 40rem;
100
+ }
101
+
102
+ .legal-note {
103
+ color: var(--color-ink-muted);
104
+ font-size: var(--text-sm);
105
+ margin: 0;
106
+ max-width: 24rem;
107
+ }
108
+ </style>
@@ -0,0 +1,27 @@
1
+ import type { Snippet } from 'svelte';
2
+ /**
3
+ * The /login and /signup chrome: heading, intro, the tab pair that
4
+ * preserves the newsletter intent, and the legal note. Those two pages
5
+ * differ in which forms they compose and in how consent is mirrored into
6
+ * the Google flow — not in any of this.
7
+ *
8
+ * The legal note is here rather than per page because it is the no-JS
9
+ * fallback for the consent copy, and it must appear on both.
10
+ */
11
+ interface Props {
12
+ /** Browser title. */
13
+ title: string;
14
+ heading: string;
15
+ intro: string;
16
+ /**
17
+ * The URL the tab pair reads. Defaulted to the config's router URL;
18
+ * only tests and stories pass one. The current tab is derived from its
19
+ * canonical pathname, so locale prefixes don't break highlighting.
20
+ */
21
+ url?: URL;
22
+ /** The email form and the Google button. */
23
+ children: Snippet;
24
+ }
25
+ declare const AuthPageShell: import("svelte").Component<Props, {}, "">;
26
+ type AuthPageShell = ReturnType<typeof AuthPageShell>;
27
+ export default AuthPageShell;
@@ -0,0 +1,72 @@
1
+ <script module lang="ts">
2
+ import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
3
+
4
+ /** The Google-login remote form (no preflight: it has no typed fields to check). */
5
+ export type GoogleLoginFormInstance = RemoteFormInstance<{
6
+ newsletter: RemoteField;
7
+ locale: RemoteField;
8
+ }>;
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ import { getUiConfig } from '../../config/context.js';
13
+ import Button from '../ui/Button.svelte';
14
+ import FormErrorFeedback from '../ui/FormErrorFeedback.svelte';
15
+ import LocaleField from '../ui/LocaleField.svelte';
16
+ import NewsletterIntentField from '../ui/NewsletterIntentField.svelte';
17
+
18
+ interface Props {
19
+ /**
20
+ * Newsletter intent to carry: the band's ?newsletter=1 on /login, or
21
+ * the live state of the signup page's single consent block.
22
+ */
23
+ newsletterIntent?: boolean;
24
+ /**
25
+ * Refuse the submission until consent is given (signup page, mirroring
26
+ * the shared terms checkbox). Whether consent is owed *yet* is the
27
+ * signup consent gate's question, not this module's — it reports false
28
+ * before hydration so the no-JS fallback stays usable. /login has no
29
+ * consent block and passes nothing.
30
+ */
31
+ requireConsent?: boolean;
32
+ /** The remote form the host page passes in. */
33
+ googleLoginForm: GoogleLoginFormInstance;
34
+ }
35
+
36
+ let { newsletterIntent = false, requireConsent = false, googleLoginForm }: Props = $props();
37
+
38
+ const config = getUiConfig();
39
+ const msg = $derived(config.messages);
40
+
41
+ // One button for both journeys: with public signups open, a first-time
42
+ // Google sign-in creates the account, a returning one just logs in.
43
+ const google = $derived(googleLoginForm);
44
+ </script>
45
+
46
+ <div class="google">
47
+ <p class="divider" role="separator">{msg.login_divider()}</p>
48
+
49
+ <form class="form-stack" {...google}>
50
+ {#if newsletterIntent}
51
+ <!-- Mirrors the single consent block (signup) or the band's intent
52
+ (login); the field module owns the encoding. -->
53
+ <NewsletterIntentField field={google.fields.newsletter} />
54
+ {/if}
55
+ <LocaleField field={google.fields.locale} />
56
+ <div class="actions">
57
+ <Button type="submit" pending={google.pending} disabled={requireConsent}>
58
+ {msg.login_googleSubmit()}
59
+ </Button>
60
+ </div>
61
+ <FormErrorFeedback result={google.result} />
62
+ </form>
63
+ </div>
64
+
65
+ <style>
66
+ .google {
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: var(--space-3);
70
+ max-width: 24rem;
71
+ }
72
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { RemoteField, RemoteFormInstance } from '../../forms/types.js';
2
+ /** The Google-login remote form (no preflight: it has no typed fields to check). */
3
+ export type GoogleLoginFormInstance = RemoteFormInstance<{
4
+ newsletter: RemoteField;
5
+ locale: RemoteField;
6
+ }>;
7
+ interface Props {
8
+ /**
9
+ * Newsletter intent to carry: the band's ?newsletter=1 on /login, or
10
+ * the live state of the signup page's single consent block.
11
+ */
12
+ newsletterIntent?: boolean;
13
+ /**
14
+ * Refuse the submission until consent is given (signup page, mirroring
15
+ * the shared terms checkbox). Whether consent is owed *yet* is the
16
+ * signup consent gate's question, not this module's — it reports false
17
+ * before hydration so the no-JS fallback stays usable. /login has no
18
+ * consent block and passes nothing.
19
+ */
20
+ requireConsent?: boolean;
21
+ /** The remote form the host page passes in. */
22
+ googleLoginForm: GoogleLoginFormInstance;
23
+ }
24
+ declare const GoogleAuthForm: import("svelte").Component<Props, {}, "">;
25
+ type GoogleAuthForm = ReturnType<typeof GoogleAuthForm>;
26
+ export default GoogleAuthForm;