feat(layout): in-app Resources menu in user dropdown (TASK-905) (#316)

* feat(layout): in-app Resources menu in user dropdown (TASK-905)

New UserMenuResources component adds a Resources block to the user-menu
dropdown in TopBar, closing the product → marketing handoff seam.
Logged-in users now have a clear path back out to Docs / Changelog /
GitHub / Status / Support without having to remember getpad.dev URLs
or visit the marketing site separately.

Cloud-mode (cloudMode=true) shows: Docs / Changelog / GitHub / Status /
Support. Replaces the prior inline Support/Status pair — that block
became a special case of this unified Resources component.

Self-hosted (cloudMode=false) shows the trimmed Docs / GitHub set.
Changelog / Status are Cloud-specific surfaces; getpad.dev's
support@getpad.dev mailbox isn't the operator's to direct people to.
The Docs link still points at getpad.dev because that's the canonical
project documentation regardless of deployment shape.

Component is wired into BOTH the desktop and mobile branches of
TopBar (the existing dropdown duplication). All links open in a new
tab so a user mid-task doesn't lose state. Each entry has a small
external-link icon so the off-property nature is visible without the
user having to hover-and-read the title.

The `:global(.user-dropdown)` selectors keep the new styles scoped to
the existing dropdown surface in TopBar without forcing a CSS
refactor of that component.

Visual contract: docs/brand.md §6/§7. Companion to AuthHeader,
AuthFooter, and +error.svelte from PLAN-900.

Test plan:
- web/npm run check — 0 errors (694 files, +1 new component)
- web/npm run build — clean
- Svelte autofixer — clean

* fix(layout): UserMenuResources mirrors dropdown-item styles per Codex (round 2)

Codex caught that .dropdown-item and .dropdown-divider rules in
TopBar.svelte's <style> are scoped to that component — Svelte's
scoped CSS attaches a per-component hash so the rules don't apply to
DOM rendered by UserMenuResources.svelte (a separate component). The
new resource links lost the dropdown padding/color/text-decoration/
hover styling, and the divider rendered as an unstyled empty 1px row.

Mirror the base .dropdown-item / .dropdown-divider / .dropdown-item:hover
rules inside UserMenuResources using :global(.user-dropdown) qualifiers
so the dropdown surface remains the styling boundary — the rules apply
to anything dropped into the menu but never leak outside it.

Same scoping pattern that already worked for .resources-label and
.external-icon in this component, just extended to the base classes.

* fix(layout): respect canonical link order from brand spec per Codex (round 3)

Codex caught that UserMenuResources rendered links in the order
Docs / Changelog / GitHub / Status / Support, but docs/brand.md §7
defines a canonical relative order with GitHub before Docs and
Changelog. The whole point of the brand spec is one canonical order
across surfaces; violating it in the user menu undermines that.

Reorder Cloud to GitHub / Docs / Changelog / Status / Support, and
self-hosted to GitHub / Docs. Status and Support are user-menu-specific
additions that don't appear in the marketing footer; they land at the
end so the brand-spec subset stays in canonical position at the front.
This commit is contained in:
xarmian
2026-04-29 23:57:16 -04:00
committed by GitHub
parent 8f2be1b391
commit f122bec84a
2 changed files with 182 additions and 44 deletions
+20 -44
View File
@@ -10,6 +10,7 @@
import { goto } from '$app/navigation';
import PadLogo from '$lib/components/layout/PadLogo.svelte';
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
import UserMenuResources from '$lib/components/layout/UserMenuResources.svelte';
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
@@ -855,32 +856,22 @@
<button class="dropdown-item" onclick={toggleTheme}>
{currentTheme === 'dark' ? 'Light mode' : 'Dark mode'}
</button>
{#if authStore.cloudMode}
<div class="dropdown-divider"></div>
<a
href="mailto:support@getpad.dev"
class="dropdown-item"
onclick={closeUserMenu}
>
Support
</a>
<a
href="https://status.getpad.dev"
target="_blank"
rel="noopener noreferrer"
class="dropdown-item"
onclick={closeUserMenu}
>
Status
</a>
{/if}
<!--
"Connect a project…" sits after the cloud-mode Support/Status
block (when present) and just above the Sign-out divider —
it's a CLI-onboarding action, semantically closer to
Settings/Support than to account actions, but visually we
want it adjacent to the divider so it reads as a discrete
action rather than another link.
Resources block (TASK-905). Replaces the prior inline Cloud
Support/Status pair — that block became a special case of
the unified Resources component, which adds Docs, Changelog,
and GitHub on Cloud and a trimmed Docs/GitHub list on
self-hosted. Closes the product → marketing handoff seam.
-->
<UserMenuResources cloudMode={authStore.cloudMode} onclose={closeUserMenu} />
<!--
"Connect a project…" sits after the Resources block and just
above the Sign-out divider — it's a CLI-onboarding action,
semantically closer to Settings/Resources than to account
actions, but visually we want it adjacent to the divider so
it reads as a discrete action rather than another link.
-->
{#if workspaceStore.current?.slug}
<button
@@ -976,25 +967,10 @@
<button class="dropdown-item" onclick={toggleTheme}>
{currentTheme === 'dark' ? 'Light mode' : 'Dark mode'}
</button>
{#if authStore.cloudMode}
<div class="dropdown-divider"></div>
<a
href="mailto:support@getpad.dev"
class="dropdown-item"
onclick={closeUserMenu}
>
Support
</a>
<a
href="https://status.getpad.dev"
target="_blank"
rel="noopener noreferrer"
class="dropdown-item"
onclick={closeUserMenu}
>
Status
</a>
{/if}
<!-- Resources — see desktop branch for placement rationale. -->
<UserMenuResources cloudMode={authStore.cloudMode} onclose={closeUserMenu} />
<!-- Connect a project — see desktop branch for placement rationale. -->
{#if workspaceStore.current?.slug}
<button
@@ -0,0 +1,162 @@
<script lang="ts">
// Resources block rendered inside the user-menu dropdown in TopBar.svelte
// (desktop and mobile branches both consume this).
//
// Closes the product → marketing handoff seam: a logged-in user looking
// for Docs / Changelog / GitHub / Status / Support has no obvious path
// from inside the app today. This block sits at the bottom of the user
// 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
// 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.
//
// 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
// TopBar.svelte; that block became a special case of this one.
//
// Visual contract: docs/brand.md §6 (link-list canonical order) and §7
// (external-link convention). Companion to AuthHeader/AuthFooter/+error
// from PLAN-900.
let {
cloudMode = false,
onclose
}: {
cloudMode?: boolean;
// Caller hands in the close-the-dropdown handler (typically
// `closeUserMenu` from TopBar). Triggered on every link click so
// the menu collapses without waiting for the link to navigate.
onclose?: () => void;
} = $props();
type ResourceLink = {
label: string;
href: string;
// Most links open in a new tab. The Support entry on Cloud uses a
// mailto:, which doesn't navigate the browser but still benefits
// from `noopener` semantics; we keep target="_blank" for parity
// and to surface the action as "off-property".
};
// 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
// 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: 'https://github.com/PerpetualSoftware/pad' },
{ label: 'Docs', href: 'https://getpad.dev/docs' },
{ label: 'Changelog', href: 'https://getpad.dev/changelog' },
{ label: 'Status', href: 'https://status.getpad.dev' },
{ label: 'Support', href: 'mailto:support@getpad.dev' }
];
// Self-hosted is a subset that preserves the relative canonical order —
// GitHub before 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: 'https://github.com/PerpetualSoftware/pad' },
{ label: 'Docs', href: 'https://getpad.dev/docs' }
];
const links = $derived(cloudMode ? cloudLinks : selfHostedLinks);
</script>
<div class="dropdown-divider"></div>
<div class="resources-label">Resources</div>
{#each links as link (link.label)}
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="dropdown-item resources-item"
onclick={() => onclose?.()}
>
<span>{link.label}</span>
<svg
class="external-icon"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</a>
{/each}
<style>
/* Svelte's scoped CSS hashes class selectors so styles only apply to
DOM produced by the same component. The base .dropdown-divider and
.dropdown-item rules live in TopBar.svelte and would NOT apply to
the elements rendered here without explicit :global() opt-in. We
mirror the parent's rules under `:global(.user-dropdown)` so the
dropdown surface stays the styling boundary — the rules apply to
anything dropped into the menu, but never leak outside it.
Codex review (TASK-905, round 1) caught the missing styling. */
:global(.user-dropdown) :global(.dropdown-divider) {
height: 1px;
background: var(--border);
}
:global(.user-dropdown) :global(.dropdown-item) {
display: block;
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-4);
font-size: 0.85em;
color: var(--text-secondary);
text-decoration: none;
transition: background 0.1s, color 0.1s;
}
:global(.user-dropdown) :global(.dropdown-item):hover {
background: var(--bg-hover);
color: var(--text-primary);
text-decoration: none;
}
:global(.user-dropdown) .resources-label {
padding: var(--space-2) var(--space-4) var(--space-1);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
}
:global(.user-dropdown) .resources-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
:global(.user-dropdown) .external-icon {
color: var(--text-muted);
flex-shrink: 0;
opacity: 0.7;
transition: opacity 150ms ease, color 150ms ease;
}
:global(.user-dropdown) .resources-item:hover .external-icon,
:global(.user-dropdown) .resources-item:focus-visible .external-icon {
opacity: 1;
color: var(--text-secondary);
}
</style>