feat(auth): footer parity with marketing site on auth-page family (TASK-903) (#314)

* feat(auth): footer parity with marketing site on auth-page family (TASK-903)

New <AuthFooter cloudMode={...} /> replaces the prior LegalFooter +
SupportFooter pair. Single component matches the brand spec
(docs/brand.md §7) which describes ONE footer pattern, not two
separate strips.

Cloud mode (cloudMode=true) carries the full getpad.dev marketing
footer: copyright line ("© <year> Pad · Perpetual Software") + the
nine-link list in canonical order — GitHub, Docs, Changelog,
Contribute, FAQ, Security, Privacy, Terms, Sub-processors. Visual
contract anchored on pad-web/src/routes/+layout.svelte (border-top,
max-w-6xl, flex-wrap, sm: breakpoint at 640px).

Self-hosted (cloudMode=false) renders the legal-essentials only —
Terms / Privacy / Sub-processors — preserving the visual treatment of
the prior LegalFooter exactly so existing self-hosted deployments see
no change after this PR. The Status / Support / GitHub / Changelog /
Contribute / FAQ / Security links were Cloud-only in the prior shape
too; that stays the case.

Wired the new AuthFooter into all five auth-family pages:
  - /login            (replaces LegalFooter + SupportFooter)
  - /register         (replaces LegalFooter + SupportFooter)
  - /forgot-password  (replaces LegalFooter + SupportFooter)
  - /reset-password/[token]  (NEW — was footer-less)
  - /join/[code]      (NEW — was footer-less)

LegalFooter.svelte and SupportFooter.svelte are deleted; they were
internal to the auth-pages feature and never used elsewhere
(grep-verified). AuthHeader's comment that referenced them is
updated to point at AuthFooter instead.

Year is computed once per page render via new Date().getFullYear()
— no auto-refresh needed since auth pages don't sit open across a
year boundary in any realistic flow.

Parent: PLAN-900.

Test plan:
- web/npm run check — 0 errors (692 files now, was 693; net -1 reflects
  2 deletions + 1 addition)
- web/npm run build — clean
- go build ./... && go vet ./... && go test ./... — all pass
- Svelte autofixer — clean

* fix(auth): self-hosted AuthFooter renders nothing per Codex review (round 2)

Codex P1: the prior LegalFooter + SupportFooter both gated their entire
body on `{#if cloudMode}` — i.e. self-hosted rendered nothing at all.
The first draft of AuthFooter incorrectly assumed self-hosted should
get the legal-essentials subset (Terms / Privacy / Sub-processors
links), which would have rendered getpad.dev's hosted-service legal
links on someone else's deployment, misrepresenting the operator's
own legal terms.

Revert the self-hosted branch to render nothing. The brand spec
(docs/brand.md §7) already flags an operator-owned legal/footer
mechanism as deferred to the operator-branding follow-up plan, so
this restores the prior behavior exactly.

Removed the now-dead self-hosted link list, the .auth-footer-legal
CSS rules, and the $derived links computation.

* fix(auth): flex-direction on reset-password + join wrappers per Codex (round 3)

Codex P2: AuthFooter on /reset-password/[token] and /join/[code] sat
horizontally next to the auth card instead of below it because those
two page wrappers were `display: flex` without `flex-direction: column`.
The other three auth pages (login, register, forgot-password) already
had column layout so they were unaffected — only the two pages that
gained a footer in this PR were broken.

Add `flex-direction: column` to .page (reset-password) and .join-page
so the footer renders below the card on those routes too.
This commit is contained in:
xarmian
2026-04-29 23:23:39 -04:00
committed by GitHub
parent 973301847c
commit 2900a66861
9 changed files with 171 additions and 111 deletions
@@ -0,0 +1,156 @@
<script lang="ts">
// Footer rendered below the auth-page family.
//
// On Pad Cloud (cloudMode=true) this carries the full getpad.dev marketing
// footer link list — same canonical order as pad-web/src/routes/+layout.svelte
// — so the auth pages feel like one continuous property with the marketing
// site rather than a stripped-down stub.
//
// On self-hosted (cloudMode=false) this renders NOTHING. Operators ship
// Pad under their own brand and have their own legal docs; rendering
// getpad.dev Terms/Privacy/Sub-processors links would misrepresent the
// operator's legal terms as if they were the deployment's own. This
// matches the prior LegalFooter + SupportFooter behavior (both gated
// their entire body on `{#if cloudMode}`) and the brand spec
// (docs/brand.md §7) note that an operator-owned legal/footer
// mechanism is deferred to the operator-branding follow-up plan.
//
// Visual contract: docs/brand.md, section 7. Source of truth for tokens,
// link order, and copyright format is pad-web/src/routes/+layout.svelte.
// When changing those, update the brand doc and this file together.
//
// Replaces the prior LegalFooter + SupportFooter pair — the brand spec
// describes a single footer pattern, not two separate strips.
let { cloudMode = false }: { cloudMode?: boolean } = $props();
// Canonical Cloud-mode link order from docs/brand.md §7. Do not reorder.
// All Cloud links are off-property (github.com or getpad.dev) so every
// anchor opens in a new tab — the user is mid-auth-flow and we don't want
// to lose their form state by navigating away.
const cloudLinks: Array<{ label: string; href: string }> = [
{ label: 'GitHub', href: 'https://github.com/PerpetualSoftware/pad' },
{ label: 'Docs', href: 'https://getpad.dev/docs' },
{ label: 'Changelog', href: 'https://getpad.dev/changelog' },
{ label: 'Contribute', href: 'https://getpad.dev/contribute' },
{ label: 'FAQ', href: 'https://getpad.dev/faq' },
{ label: 'Security', href: 'https://getpad.dev/security' },
{ label: 'Privacy', href: 'https://getpad.dev/privacy' },
{ label: 'Terms', href: 'https://getpad.dev/terms' },
{ label: 'Sub-processors', href: 'https://getpad.dev/subprocessors' }
];
// Year is computed once per page render — no auto-refresh, but auth pages
// don't sit open across a year boundary in any realistic flow.
const year = new Date().getFullYear();
</script>
{#if cloudMode}
<footer class="auth-footer cloud">
<div class="auth-footer-row">
<p class="auth-footer-copyright">
&copy; {year} Pad
<span class="auth-footer-sep" aria-hidden="true">·</span>
<a
href="https://perpetualsoftware.org"
target="_blank"
rel="noopener noreferrer"
class="auth-footer-perpetual"
>
Perpetual Software
</a>
</p>
<nav class="auth-footer-links" aria-label="Site navigation">
{#each cloudLinks as link (link.label)}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="auth-footer-link"
>
{link.label}
</a>
{/each}
</nav>
</div>
</footer>
{/if}
<!-- Self-hosted (cloudMode === false) intentionally renders nothing — see
the doc-comment in the script block. The operator-customizable footer
is a separate, larger plan that will land after PLAN-900 ships. -->
<style>
/* Cloud footer — full marketing-style strip. Matches pad-web's footer
structure: a top border, max-w-6xl container, copyright line on the
left and link row on the right. Stacks vertically on small screens. */
.auth-footer.cloud {
margin-top: var(--space-10);
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-6);
width: 100%;
}
.auth-footer-row {
max-width: 72rem; /* matches max-w-6xl */
margin: 0 auto;
padding: 0 var(--space-6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.auth-footer-copyright {
font-size: 0.875rem; /* text-sm */
color: var(--text-muted);
margin: 0;
}
.auth-footer-sep {
margin: 0 0.25rem;
}
.auth-footer-perpetual {
color: color-mix(in srgb, var(--text-muted) 60%, transparent);
text-decoration: none;
transition: color 150ms ease;
}
.auth-footer-perpetual:hover,
.auth-footer-perpetual:focus-visible {
color: var(--text-secondary);
text-decoration: none;
}
.auth-footer-links {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
column-gap: var(--space-6);
row-gap: var(--space-3);
}
.auth-footer-link {
font-size: 0.875rem;
color: var(--text-muted);
text-decoration: none;
transition: color 150ms ease;
}
.auth-footer-link:hover,
.auth-footer-link:focus-visible {
color: var(--text-secondary);
text-decoration: none;
}
/* sm breakpoint = 640px (Tailwind default; matches pad-web's sm:flex-row). */
@media (min-width: 640px) {
.auth-footer-row {
flex-direction: row;
justify-content: space-between;
text-align: left;
}
}
</style>
@@ -12,7 +12,7 @@
//
// Self-hosted (cloudMode === false) renders nothing — operators ship Pad
// under their own brand and must not get getpad.dev chrome imposed on them.
// This matches the existing pattern in LegalFooter.svelte / SupportFooter.svelte.
// Same pattern as the companion AuthFooter.svelte in this directory.
let { cloudMode = false }: { cloudMode?: boolean } = $props();
@@ -1,51 +0,0 @@
<script lang="ts">
// Legal links shown below the auth card on Pad Cloud (cloudMode=true).
// Self-hosted installs do not need these links — the Terms/Privacy that
// live at getpad.dev are Perpetual Software LLC's for the hosted service,
// not for the user's own instance.
//
// Caller typically reads cloudMode from authStore (which the root layout
// already populates via authStore.load()), so no additional session fetch
// is needed. Links open in a new tab so the auth flow is not interrupted.
let { cloudMode = false }: { cloudMode?: boolean } = $props();
</script>
{#if cloudMode}
<nav class="legal-footer" aria-label="Legal">
<a href="https://getpad.dev/terms" target="_blank" rel="noopener noreferrer">Terms</a>
<span aria-hidden="true">·</span>
<a href="https://getpad.dev/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
<span aria-hidden="true">·</span>
<a href="https://getpad.dev/subprocessors" target="_blank" rel="noopener noreferrer">Sub-processors</a>
</nav>
{/if}
<style>
.legal-footer {
margin-top: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.8rem;
}
.legal-footer a {
color: var(--text-muted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
border-radius: 2px;
}
.legal-footer a:hover {
color: var(--text-primary);
}
.legal-footer a:focus-visible {
color: var(--text-primary);
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
</style>
@@ -1,47 +0,0 @@
<script lang="ts">
// Support/help links shown below the auth card on Pad Cloud.
//
// Gated on cloudMode — the support@getpad.dev mailbox and status.getpad.dev
// page are for the hosted service, not for self-hosted deployments. Caller
// typically reads cloudMode from authStore. Links open in a new tab so
// clicking one mid-login does not lose the form state.
let { cloudMode = false }: { cloudMode?: boolean } = $props();
</script>
{#if cloudMode}
<nav class="support-footer" aria-label="Support">
<a href="mailto:support@getpad.dev">Support</a>
<span aria-hidden="true">·</span>
<a href="https://status.getpad.dev" target="_blank" rel="noopener noreferrer">Status</a>
</nav>
{/if}
<style>
.support-footer {
margin-top: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: 0.8rem;
}
.support-footer a {
color: var(--text-muted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
border-radius: 2px;
}
.support-footer a:hover {
color: var(--text-primary);
}
.support-footer a:focus-visible {
color: var(--text-primary);
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
</style>
+2 -4
View File
@@ -3,8 +3,7 @@
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import AuthHeader from '$lib/components/auth/AuthHeader.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
import SupportFooter from '$lib/components/auth/SupportFooter.svelte';
import AuthFooter from '$lib/components/auth/AuthFooter.svelte';
let email = $state('');
let error = $state('');
@@ -100,8 +99,7 @@
{/if}
</div>
<LegalFooter cloudMode={authStore.cloudMode} />
<SupportFooter cloudMode={authStore.cloudMode} />
<AuthFooter cloudMode={authStore.cloudMode} />
</div>
<style>
+4
View File
@@ -6,6 +6,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte';
import AuthHeader from '$lib/components/auth/AuthHeader.svelte';
import AuthFooter from '$lib/components/auth/AuthFooter.svelte';
let code = $derived(page.params.code ?? '');
let status = $state<'loading' | 'login' | 'register' | 'accepting' | 'error' | 'setup' | '2fa'>('loading');
@@ -339,11 +340,14 @@
</p>
{/if}
</div>
<AuthFooter cloudMode={authStore.cloudMode} />
</div>
<style>
.join-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
+2 -4
View File
@@ -6,8 +6,7 @@
import { goto } from '$app/navigation';
import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte';
import AuthHeader from '$lib/components/auth/AuthHeader.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
import SupportFooter from '$lib/components/auth/SupportFooter.svelte';
import AuthFooter from '$lib/components/auth/AuthFooter.svelte';
let email = $state('');
let password = $state('');
@@ -391,8 +390,7 @@
{/if}
</div>
<LegalFooter {cloudMode} />
<SupportFooter {cloudMode} />
<AuthFooter {cloudMode} />
</div>
<style>
+2 -4
View File
@@ -4,8 +4,7 @@
import { goto } from '$app/navigation';
import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte';
import AuthHeader from '$lib/components/auth/AuthHeader.svelte';
import LegalFooter from '$lib/components/auth/LegalFooter.svelte';
import SupportFooter from '$lib/components/auth/SupportFooter.svelte';
import AuthFooter from '$lib/components/auth/AuthFooter.svelte';
import { authStore } from '$lib/stores/auth.svelte';
let name = $state('');
@@ -234,8 +233,7 @@
{/if}
</div>
<LegalFooter cloudMode={authStore.cloudMode} />
<SupportFooter cloudMode={authStore.cloudMode} />
<AuthFooter cloudMode={authStore.cloudMode} />
</div>
<style>
@@ -5,6 +5,7 @@
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import AuthHeader from '$lib/components/auth/AuthHeader.svelte';
import AuthFooter from '$lib/components/auth/AuthFooter.svelte';
let token = $derived(page.params.token ?? '');
@@ -101,11 +102,14 @@
<a href="/login">Back to sign in</a>
</p>
</div>
<AuthFooter cloudMode={authStore.cloudMode} />
</div>
<style>
.page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;