feat(web): Community link (GitHub Discussions) in the user menu and auth footer (TASK-2888) (#1251)

Dave's day-57 call: no Discord; the repo's GitHub Discussions tab is the community channel. COMMUNITY_URL in $lib/brand/links; Community right after GitHub in the user-menu Resources block (Cloud + self-hosted) and the Cloud auth footer; docs/brand.md §7 order updated; AuthFooter and AuthHeader now take their URLs from the links module and sit under the single-source guard. Four codex rounds (two findings fixed, rounds 3-4 clean on the tip), CI 7/7 green.

Claude-Session: https://claude.ai/code/session_015A7n836r64Y9THC8UWsDFF
This commit is contained in:
xarmian
2026-09-04 23:42:15 -04:00
committed by GitHub
parent 5c4fa22999
commit cd51d8f130
7 changed files with 111 additions and 23 deletions
+9 -8
View File
@@ -284,14 +284,15 @@ because Tailwind/Svelte tooling will warn without it.
Always the same order. This is the contract:
1. GitHub *(external)*
2. Docs
3. Changelog
4. Contribute
5. FAQ
6. Security
7. Privacy
8. Terms
9. Sub-processors
2. Community *(external — the repo's GitHub Discussions; TASK-2888)*
3. Docs
4. Changelog
5. Contribute
6. FAQ
7. Security
8. Privacy
9. Terms
10. Sub-processors
If a surface omits a link (e.g. self-hosted footer skips Changelog
because there's no Changelog page on a self-hosted install), keep the
+7
View File
@@ -15,6 +15,13 @@
/** The public repository. Shown in the app so a user never has to search for it. */
export const GITHUB_REPO_URL = 'https://github.com/PerpetualSoftware/pad';
/**
* Community channel: the repo's GitHub Discussions tab (TASK-2888 — Dave's
* day-57 call: no Discord; Discussions, or r/getpad, is the channel for now).
* Project-wide, so shown on Cloud and self-hosted alike.
*/
export const COMMUNITY_URL = `${GITHUB_REPO_URL}/discussions`;
/** Canonical project documentation — the same for Cloud and self-hosted. */
export const DOCS_URL = 'https://getpad.dev/docs';
@@ -22,6 +22,8 @@
// Replaces the prior LegalFooter + SupportFooter pair — the brand spec
// describes a single footer pattern, not two separate strips.
import { CHANGELOG_URL, COMMUNITY_URL, DOCS_URL, GITHUB_REPO_URL } from '$lib/brand/links';
let { cloudMode = false }: { cloudMode?: boolean } = $props();
// Canonical Cloud-mode link order from docs/brand.md §7. Do not reorder.
@@ -29,9 +31,10 @@
// 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: 'GitHub', href: GITHUB_REPO_URL },
{ label: 'Community', href: COMMUNITY_URL },
{ label: 'Docs', href: DOCS_URL },
{ label: 'Changelog', href: CHANGELOG_URL },
{ label: 'Contribute', href: 'https://getpad.dev/contribute' },
{ label: 'FAQ', href: 'https://getpad.dev/faq' },
{ label: 'Security', href: 'https://getpad.dev/security' },
@@ -14,6 +14,8 @@
// under their own brand and must not get getpad.dev chrome imposed on them.
// Same pattern as the companion AuthFooter.svelte in this directory.
import { DOCS_URL, GITHUB_REPO_URL } from '$lib/brand/links';
let { cloudMode = false }: { cloudMode?: boolean } = $props();
let mobileMenuOpen = $state(false);
@@ -21,11 +23,13 @@
// Link list mirrors pad-web's marketing nav (Docs / Blog / GitHub) but
// points at absolute marketing URLs explicitly — auth pages are pre-login,
// the user has no workspace context, and these are "go back to the
// marketing site" links rather than in-app nav.
// marketing site" links rather than in-app nav. The Docs and GitHub
// addresses come from $lib/brand/links (single source, guarded by
// Sidebar.svelte.test.ts); Blog has no constant yet.
const navLinks: Array<{ label: string; href: string }> = [
{ label: 'Docs', href: 'https://getpad.dev/docs' },
{ label: 'Docs', href: DOCS_URL },
{ label: 'Blog', href: 'https://getpad.dev/blog' },
{ label: 'GitHub', href: 'https://github.com/PerpetualSoftware/pad' }
{ label: 'GitHub', href: GITHUB_REPO_URL }
];
function handleKeydown(event: KeyboardEvent) {
@@ -290,7 +290,7 @@ describe('sidebar — GitHub repo link', () => {
// re-hardcoded the same string renders an identical href and passes every
// assertion above; it fails this one.
//
// Deliberately narrow: it names the two consumers rather than sweeping the
// Deliberately narrow: it names the consumers rather than sweeping the
// tree, so it cannot fail for an unrelated file (a doc, a fixture, a
// changelog entry legitimately quoting the URL) and cannot quietly stop
// covering them by drifting into a glob that no longer matches.
@@ -300,7 +300,9 @@ describe('sidebar — GitHub repo link', () => {
const root = process.cwd();
for (const rel of [
'src/lib/components/layout/Sidebar.svelte',
'src/lib/components/layout/UserMenuResources.svelte'
'src/lib/components/layout/UserMenuResources.svelte',
'src/lib/components/auth/AuthFooter.svelte',
'src/lib/components/auth/AuthHeader.svelte'
]) {
const source = readFileSync(join(root, rel), 'utf8');
expect(source, `${rel} hardcodes the repo URL`).not.toContain(
@@ -8,12 +8,13 @@
// menu (just above "Connect a project…" and "Sign out") and gives them
// a quiet escape hatch back out to the surrounding ecosystem.
//
// Cloud (cloudMode=true): Docs / Changelog / GitHub / Status / Support
// Self-hosted (cloudMode=false): Docs / GitHub only — Changelog and
// Cloud (cloudMode=true): GitHub / Community / Docs / Changelog / Status / Support
// Self-hosted (cloudMode=false): GitHub / Community / Docs — Changelog and
// Status are Cloud-specific surfaces, and getpad.dev's support@getpad.dev
// mailbox is not the operator's to direct people to. The Docs link still
// points at getpad.dev because that's the canonical project documentation
// even for self-hosted deployments.
// even for self-hosted deployments, and Community (the repo's GitHub
// Discussions) is the project's channel regardless of who hosts it.
//
// All links open in a new tab so a user mid-task doesn't lose state.
// Replaces the prior inline Cloud-only Support/Status block in
@@ -25,6 +26,7 @@
import {
CHANGELOG_URL,
COMMUNITY_URL,
DOCS_URL,
GITHUB_REPO_URL,
STATUS_URL,
@@ -52,13 +54,16 @@
};
// Order follows the canonical link order from docs/brand.md §7: GitHub,
// Docs, Changelog, then anything else. Status and Support are user-menu-
// specific additions (the brand-spec footer doesn't carry them) so they
// land at the end. Keeping the same relative order as the marketing
// Community, Docs, Changelog, then anything else. Status and Support are
// user-menu-specific additions (the brand-spec footer doesn't carry them)
// so they land at the end. Community sits second, as in the footer: the
// brand doc's rule is that a surface may omit links but never reorders
// the ones it keeps (codex round 1 on TASK-2888 caught the drift). Keeping the same relative order as the marketing
// footer means a user visiting both surfaces sees the same linear pattern
// — small cohesion win that Codex flagged on first review.
const cloudLinks: ResourceLink[] = [
{ label: 'GitHub', href: GITHUB_REPO_URL },
{ label: 'Community', href: COMMUNITY_URL },
{ label: 'Docs', href: DOCS_URL },
{ label: 'Changelog', href: CHANGELOG_URL },
{ label: 'Status', href: STATUS_URL },
@@ -66,11 +71,12 @@
];
// Self-hosted is a subset that preserves the relative canonical order —
// GitHub before Docs. Changelog/Status/Support are Cloud-only surfaces
// GitHub, Community, Docs. Changelog/Status/Support are Cloud-only surfaces
// (operators have their own changelog/status if any; getpad.dev's
// support@ mailbox is not theirs to direct people to).
const selfHostedLinks: ResourceLink[] = [
{ label: 'GitHub', href: GITHUB_REPO_URL },
{ label: 'Community', href: COMMUNITY_URL },
{ label: 'Docs', href: DOCS_URL }
];
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
//
// TASK-2888: the Community link (the repo's GitHub Discussions tab) is a
// project-wide channel, so it must appear in BOTH the Cloud and the
// self-hosted Resources lists, sit immediately after GitHub (docs/brand.md
// §7 canonical order — a surface may omit links but never reorders the
// ones it keeps), and take its href from the shared brand module rather
// than a re-typed literal.
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/svelte';
import UserMenuResources from './UserMenuResources.svelte';
import AuthFooter from '../auth/AuthFooter.svelte';
import { COMMUNITY_URL, GITHUB_REPO_URL } from '$lib/brand/links';
afterEach(() => cleanup());
function labels(container: HTMLElement): (string | undefined)[] {
return Array.from(container.querySelectorAll('a')).map((a) => a.textContent?.trim());
}
function communityLink(container: HTMLElement): HTMLAnchorElement | null {
return (
Array.from(container.querySelectorAll('a')).find(
(a) => a.textContent?.trim() === 'Community'
) ?? null
);
}
describe('Community link (TASK-2888)', () => {
it('points at the repo Discussions tab, derived from the repo URL', () => {
expect(COMMUNITY_URL).toBe(`${GITHUB_REPO_URL}/discussions`);
});
it.each([true, false])('user menu renders Community (cloudMode=%s), external', (cloudMode) => {
const { container } = render(UserMenuResources, { props: { cloudMode } });
const a = communityLink(container);
expect(a, 'Community link missing').not.toBeNull();
expect(a?.getAttribute('href')).toBe(COMMUNITY_URL);
expect(a?.getAttribute('target')).toBe('_blank');
expect(a?.getAttribute('rel')).toBe('noopener noreferrer');
});
it.each([true, false])(
'user menu places Community immediately after GitHub (cloudMode=%s)',
(cloudMode) => {
const { container } = render(UserMenuResources, { props: { cloudMode } });
const order = labels(container);
expect(order.indexOf('GitHub')).toBeGreaterThanOrEqual(0);
expect(order.indexOf('Community')).toBe(order.indexOf('GitHub') + 1);
}
);
it('auth footer renders Community on Cloud, immediately after GitHub', () => {
const { container } = render(AuthFooter, { props: { cloudMode: true } });
const order = labels(container);
expect(order.indexOf('GitHub')).toBeGreaterThanOrEqual(0);
expect(order.indexOf('Community')).toBe(order.indexOf('GitHub') + 1);
expect(communityLink(container)?.getAttribute('href')).toBe(COMMUNITY_URL);
});
it('auth footer renders nothing on self-hosted (unchanged)', () => {
const { container } = render(AuthFooter, { props: { cloudMode: false } });
expect(container.querySelector('a')).toBeNull();
});
});