---
phase: 18-app-shell-navigation
plan: 03
type: execute
wave: 3
depends_on:
  - 18-01
  - 18-02
files_modified:
  - backend/templates/app_layout.templ
  - backend/internal/web/ui/app.css
  - backend/internal/web/handlers_tablos_test.go
  - backend/internal/web/router.go
autonomous: true
requirements:
  - NAV-01
  - NAV-02

must_haves:
  truths:
    - "Every authenticated page shows a top header bar with breadcrumb, search placeholder, and right-side icons"
    - "Breadcrumb renders the correct path for each page (e.g., 'Dashboard' on the home page, 'Dashboard > Project Details' on tablo detail)"
    - "Clicking the avatar circle opens a dropdown with workspace info, settings link, and logout button"
    - "Logout from the avatar dropdown still works — POST /logout succeeds"
    - "Settings link in dropdown navigates to /settings which renders a stub page"
    - "go test ./... passes including updated sidebar test and new header test"
  artifacts:
    - path: "backend/templates/app_layout.templ"
      provides: "PageHeader templ component with three zones and avatar dropdown"
      contains: "PageHeader"
    - path: "backend/internal/web/ui/app.css"
      provides: "CSS for .page-header, .breadcrumb, .header-avatar-menu, .header-avatar-dropdown"
      contains: "page-header"
    - path: "backend/internal/web/handlers_tablos_test.go"
      provides: "Updated TestTablosDashboard_Sidebar + new TestTablosDashboard_Header test"
      contains: "page-header"
    - path: "backend/internal/web/router.go"
      provides: "/settings stub route"
      contains: "/settings"
  key_links:
    - from: "backend/templates/app_layout.templ"
      to: "backend/internal/web/ui/app.css"
      via: ".page-header, .header-avatar-menu, .breadcrumb classes rendered in PageHeader"
      pattern: "page-header"
    - from: "backend/templates/app_layout.templ"
      to: "/logout route"
      via: "form method=POST action=/logout inside avatar dropdown, CSRF token via ui.CSRFField"
      pattern: "action.*logout"
    - from: "PageHeader"
      to: "AppLayout"
      via: "called inside dashboard-main div, above children"
      pattern: "@PageHeader"
---

<objective>
Implement the PageHeader templ component (three-zone top bar: breadcrumb left, search placeholder center, bell/inbox/avatar right), wire it into AppLayout, add all supporting CSS to app.css, implement the avatar dropdown using native details/summary HTML, add a /settings stub route, and update tests to assert the new shell structure.

Purpose: Delivers NAV-02. After this plan every authenticated page has the top header bar matching Figma, the avatar dropdown works, and logout continues to function.
Output: PageHeader component, avatar dropdown, /settings stub, updated tests, new CSS in app.css.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/ROADMAP.md
@.planning/phases/18-app-shell-navigation/18-CONTEXT.md
@.planning/phases/18-app-shell-navigation/18-RESEARCH.md
@.planning/phases/18-app-shell-navigation/18-01-SUMMARY.md
@.planning/phases/18-app-shell-navigation/18-02-SUMMARY.md
</context>

<interfaces>
<!-- Read screenshots/Homepage.png and screenshots/Board.png before implementing -->
<!-- Located at /Users/arthur.belleville/Documents/perso/projects/xtablo-source/screenshots/ -->

<!-- AppLayout signature after Plan 01 -->
templ AppLayout(
    title string,
    user *auth.User,
    csrfToken string,
    activePath string,
    tablos []sqlc.Tablo,
    pageTitle string,
    breadcrumb []BreadcrumbItem,
    headerActions templ.Component,
)

<!-- BreadcrumbItem struct (defined in Plan 01) -->
type BreadcrumbItem struct {
    Label string
    Href  string
}

<!-- CSRFField usage (existing pattern) -->
@ui.CSRFField(csrfToken)   // renders a hidden input with CSRF token

<!-- auth.User struct -->
type User struct {
    ID           uuid.UUID
    Email        string
    PasswordHash pgtype.Text
    CreatedAt    time.Time
    UpdatedAt    time.Time
}
// Only Email is available. No Name or AvatarURL.

<!-- Existing CSS design tokens available -->
var(--color-surface-elevated)
var(--color-border-panel)
var(--color-text-muted)
var(--color-text-brand)
var(--shadow-floating-control)
var(--color-surface-elevated-strong)
var(--color-border-panel-muted)

<!-- Existing test file location -->
backend/internal/web/handlers_tablos_test.go
// Existing test: TestTablosDashboard_Sidebar (line ~602) asserts "dashboard-sidebar" and "sidebar-nav-shell"
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Implement PageHeader component and wire into AppLayout</name>
  <files>backend/templates/app_layout.templ</files>
  <action>
Add the PageHeader templ component to app_layout.templ. It renders the full-width top bar with three zones. Place the function after DashboardSidebar and before AppLayout in the file.

PageHeader signature:
    templ PageHeader(pageTitle string, breadcrumb []BreadcrumbItem, headerActions templ.Component, user *auth.User, csrfToken string)

Structure:

    <header class="page-header">
        <!-- LEFT: breadcrumb zone -->
        <div class="page-header-left">
            <nav class="breadcrumb" aria-label="Breadcrumb">
                for i, crumb := range breadcrumb {
                    if i > 0 {
                        <span class="breadcrumb-separator" aria-hidden="true">/</span>
                    }
                    if crumb.Href != "" {
                        <a href={ templ.SafeURL(crumb.Href) } class="breadcrumb-item breadcrumb-item--link">{ crumb.Label }</a>
                    } else {
                        <span class="breadcrumb-item breadcrumb-item--current" aria-current="page">{ crumb.Label }</span>
                    }
                }
                if len(breadcrumb) == 0 {
                    <span class="breadcrumb-item breadcrumb-item--current">{ pageTitle }</span>
                }
            </nav>
        </div>

        <!-- CENTER: search placeholder -->
        <div class="page-header-center">
            <div class="header-search-placeholder" aria-label="Search (coming soon)" role="search">
                <svg class="header-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
                    <circle cx="11" cy="11" r="8"/>
                    <path d="m21 21-4.35-4.35"/>
                </svg>
                <span class="header-search-text">Search...</span>
            </div>
        </div>

        <!-- RIGHT: bell, inbox, avatar -->
        <div class="page-header-right">
            <!-- Bell placeholder -->
            <button class="header-icon-button" aria-label="Notifications (coming soon)" type="button" disabled>
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="18" height="18">
                    <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
                    <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
                </svg>
            </button>

            <!-- Inbox placeholder -->
            <button class="header-icon-button" aria-label="Inbox (coming soon)" type="button" disabled>
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="18" height="18">
                    <polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
                    <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
                </svg>
            </button>

            <!-- Avatar dropdown using native details/summary -->
            <details class="header-avatar-menu">
                <summary class="header-avatar-button" aria-label="User menu">
                    <span class="header-avatar-initial">{ string([]rune(user.Email)[:1]) }</span>
                </summary>
                <div class="header-avatar-dropdown">
                    <!-- Workspace / user info -->
                    <div class="avatar-dropdown-workspace">
                        <span class="avatar-dropdown-workspace-name">{ user.Email }</span>
                        <span class="avatar-dropdown-workspace-meta">1 member</span>
                    </div>
                    <hr class="avatar-dropdown-divider"/>
                    <!-- Settings -->
                    <a href="/settings" class="avatar-dropdown-item">Settings</a>
                    <hr class="avatar-dropdown-divider"/>
                    <!-- Logout -->
                    <form method="POST" action="/logout" class="avatar-dropdown-logout-form">
                        @ui.CSRFField(csrfToken)
                        <button type="submit" class="avatar-dropdown-item avatar-dropdown-item--danger">Log out</button>
                    </form>
                </div>
            </details>
        </div>

        <!-- Per-page header actions slot (Phase 19-22 will populate) -->
        if headerActions != nil {
            <div class="page-header-actions">
                @headerActions
            </div>
        }
    </header>

Wire PageHeader into AppLayout. In the AppLayout body, inside the dashboard-main div, call PageHeader BEFORE { children... }:

    <main id="app-main-content" class="dashboard-main">
        @PageHeader(pageTitle, breadcrumb, headerActions, user, csrfToken)
        { children... }
    </main>

Remove the earlier minimal breadcrumb stub that was added in Plan 01 (the `if len(breadcrumb) > 0 { <!-- comment --> }` and `if headerActions != nil { @headerActions }` blocks added as placeholders). The PageHeader call now handles both breadcrumb and headerActions rendering.

Add a small inline script at the bottom of the body (after existing script tags) for the avatar dropdown outside-click close behavior:

    <script>
    document.addEventListener('click', function(e) {
      document.querySelectorAll('details.header-avatar-menu').forEach(function(d) {
        if (!d.contains(e.target)) { d.removeAttribute('open'); }
      });
    });
    </script>
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && templ generate ./... && go build ./... 2>&1 | tail -5</automated>
  </verify>
  <done>PageHeader component exists in app_layout.templ. AppLayout calls @PageHeader inside dashboard-main before children. Build passes. templ generate produces no errors.</done>
</task>

<task type="auto">
  <name>Task 2: Add page-header CSS to app.css, add /settings stub route, update tests</name>
  <files>backend/internal/web/ui/app.css, backend/internal/web/router.go, backend/internal/web/handlers_tablos_test.go</files>
  <action>
--- app.css: Add header bar CSS ---

Append a new section after the sidebar collapse rules added in Plan 02:

    /* ── Page header bar ───────────────────────────────────────────────── */

    .page-header {
        display: flex;
        align-items: center;
        gap: 1rem;
        padding: 0.75rem 1.5rem;
        background: var(--color-surface-elevated);
        border-bottom: 1px solid var(--color-border-panel);
        margin: -2rem -2rem 1.5rem -2rem; /* cancel .dashboard-main padding on three sides, add bottom gap */
    }

    .page-header-left {
        flex: 0 0 auto;
    }

    .page-header-center {
        flex: 1 1 auto;
        display: flex;
        justify-content: center;
    }

    .page-header-right {
        flex: 0 0 auto;
        display: flex;
        align-items: center;
        gap: 0.5rem;
    }

    .page-header-actions {
        flex: 0 0 auto;
        margin-left: auto;
    }

    /* Breadcrumb */
    .breadcrumb {
        display: flex;
        align-items: center;
        gap: 0.375rem;
        font-size: 0.875rem;
    }

    .breadcrumb-separator {
        color: var(--color-text-muted);
        user-select: none;
    }

    .breadcrumb-item {
        color: var(--color-text-muted);
        font-weight: 500;
    }

    .breadcrumb-item--link {
        color: var(--color-text-brand);
        text-decoration: none;
    }

    .breadcrumb-item--link:hover {
        text-decoration: underline;
    }

    .breadcrumb-item--current {
        color: var(--color-text-muted);
    }

    /* Search placeholder */
    .header-search-placeholder {
        display: flex;
        align-items: center;
        gap: 0.5rem;
        padding: 0.375rem 0.75rem;
        border: 1px solid var(--color-border-panel);
        border-radius: 0.375rem;
        background: var(--color-surface-muted-inverse);
        color: var(--color-text-muted);
        font-size: 0.875rem;
        min-width: 16rem;
        max-width: 28rem;
        width: 100%;
        cursor: default;
    }

    /* Icon buttons (bell, inbox) */
    .header-icon-button {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 2rem;
        height: 2rem;
        border-radius: 0.375rem;
        border: none;
        background: transparent;
        color: var(--color-text-muted);
        cursor: pointer;
    }

    .header-icon-button:hover {
        background: var(--overlay-dark-soft);
    }

    .header-icon-button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
    }

    /* Avatar dropdown */
    .header-avatar-menu {
        position: relative;
    }

    .header-avatar-menu > summary {
        list-style: none;
        cursor: pointer;
    }

    .header-avatar-menu > summary::-webkit-details-marker {
        display: none;
    }

    .header-avatar-button {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 2rem;
        height: 2rem;
        border-radius: 50%;
        background: var(--color-text-brand);
        color: #fff;
        font-size: 0.8125rem;
        font-weight: 600;
        cursor: pointer;
        border: none;
        outline: none;
    }

    .header-avatar-initial {
        text-transform: uppercase;
        line-height: 1;
        pointer-events: none;
    }

    .header-avatar-dropdown {
        position: absolute;
        right: 0;
        top: calc(100% + 0.5rem);
        min-width: 14rem;
        background: var(--color-surface-elevated);
        border: 1px solid var(--color-border-panel);
        border-radius: 0.5rem;
        box-shadow: var(--shadow-floating-control);
        z-index: 50;
        overflow: hidden;
    }

    .avatar-dropdown-workspace {
        padding: 0.75rem 1rem;
        display: flex;
        flex-direction: column;
        gap: 0.125rem;
    }

    .avatar-dropdown-workspace-name {
        font-size: 0.875rem;
        font-weight: 600;
        color: var(--color-text-muted);
        word-break: break-all;
    }

    .avatar-dropdown-workspace-meta {
        font-size: 0.75rem;
        color: var(--color-text-muted);
        opacity: 0.7;
    }

    .avatar-dropdown-divider {
        border: none;
        border-top: 1px solid var(--color-border-panel-muted);
        margin: 0;
    }

    .avatar-dropdown-item {
        display: block;
        width: 100%;
        padding: 0.625rem 1rem;
        font-size: 0.875rem;
        color: var(--color-text-muted);
        text-decoration: none;
        background: transparent;
        border: none;
        text-align: left;
        cursor: pointer;
    }

    .avatar-dropdown-item:hover {
        background: var(--overlay-dark-soft);
    }

    .avatar-dropdown-item--danger {
        color: #dc2626; /* red — logout action; no token for this; matches Figma red logout */
    }

    .avatar-dropdown-item--danger:hover {
        background: #fef2f2;
    }

    .avatar-dropdown-logout-form {
        display: contents; /* form element itself doesn't affect layout */
    }

After adding these rules, regenerate tailwind.css:
    cd backend && npx tailwindcss -i tailwind.input.css -o static/tailwind.css --minify

(Use the same CSS build command identified in Plan 02.)

--- router.go: Add /settings stub ---

In the authenticated routes block (inside the auth middleware group), add a GET handler for /settings that renders a simple stub page. Since no full settings template exists, use an inline http.HandlerFunc that writes a minimal HTML response:

    r.Get("/settings", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _, _ = w.Write([]byte(`<!DOCTYPE html><html><head><title>Settings - Xtablo</title><link rel="stylesheet" href="/static/tailwind.css"/></head><body style="padding:2rem;font-family:sans-serif"><h1>Settings</h1><p>Coming soon.</p><a href="/">Back to dashboard</a></body></html>`))
    })

Place this route near the other single-page GET routes (e.g., next to /account/providers). This stub satisfies the D-06 requirement that the settings link navigates somewhere. A full settings page is deferred.

--- handlers_tablos_test.go: Update existing test and add header test ---

Locate TestTablosDashboard_Sidebar (around line 602). This test currently asserts "dashboard-sidebar" and "sidebar-nav-shell". These class names are preserved by the rebuild, so the test already passes. Add two new assertions to this test:

1. Check that "page-header" appears in the response body (the new header bar is present).
2. Check that "breadcrumb" appears in the response body.

Example using the existing assertion pattern:
    if !strings.Contains(body, "page-header") {
        t.Errorf("expected page-header class in response body")
    }
    if !strings.Contains(body, "breadcrumb") {
        t.Errorf("expected breadcrumb class in response body")
    }

Also add a new standalone test named TestTablosDashboard_Header that:
- Calls the TablosListHandler (same setup as TestTablosDashboard_Sidebar)
- Asserts the response body contains "page-header"
- Asserts the response body contains "header-avatar-menu" (the avatar dropdown element)
- Asserts the response body contains "Dashboard" (the breadcrumb label for the home page)
- Asserts the response body contains "header-search-placeholder"

Pattern: copy the setup from TestTablosDashboard_Sidebar — same mock setup, same request/response recorder pattern.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && grep -c "page-header" static/tailwind.css && go test ./internal/web/ -run "TestTablosDashboard" -v 2>&1 | tail -20</automated>
  </verify>
  <done>tailwind.css contains "page-header" CSS. TestTablosDashboard_Sidebar passes with new assertions. TestTablosDashboard_Header exists and passes. /settings route is registered and returns 200. Logout from avatar dropdown POSTs to /logout and succeeds.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Client → POST /logout | Logout is a state-changing POST; CSRF token guards against cross-site forgery |
| Avatar dropdown → DOM | Dropdown contains a logout form; clicking outside closes it via JS listener |
| user.Email → HTML | Email rendered inside dropdown; templ auto-escapes it |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-03-01 | Cross-site request forgery | POST /logout from avatar dropdown | mitigate | ui.CSRFField(csrfToken) in the logout form — same pattern as the original SidebarOrganizationFooter logout form |
| T-18-03-02 | Tampering | user.Email rendered in dropdown | accept | templ auto-escapes all string interpolations; no raw HTML insertion possible |
| T-18-03-03 | Information disclosure | /settings stub exposes that settings page exists | accept | Page is behind authentication middleware; no sensitive data exposed |
| T-18-03-04 | Elevation of privilege | nil headerActions panic | mitigate | Plan 01 stub guard replaced by PageHeader's own nil-guard before @headerActions |
</threat_model>

<verification>
1. `cd backend && templ generate ./... && go build ./...` — zero errors
2. `grep -c "page-header" backend/static/tailwind.css` — returns > 0
3. `grep -c "header-avatar-menu" backend/static/tailwind.css` — returns > 0
4. `cd backend && go test ./... 2>&1 | tail -10` — all tests pass including TestTablosDashboard_Header
5. `cd backend && go test ./internal/web/ -run TestTablosDashboard_Header -v` — explicitly verify new test exists and passes
6. Manual: Start server, visit dashboard — top header bar visible with breadcrumb "Dashboard", search placeholder, bell and inbox placeholders, avatar circle
7. Manual: Click avatar circle — dropdown opens showing email, Settings link, Log out button (red text)
8. Manual: Click Log out — redirects to login page (POST /logout succeeds)
9. Manual: Visit a tablo detail page — breadcrumb shows "Dashboard > [tablo name]"
10. Manual: Click Settings in dropdown — navigates to /settings, shows "Coming soon" stub
</verification>

<success_criteria>
- PageHeader component renders in every authenticated page (verified by page-header class in test)
- Breadcrumb shows correct labels per page (Dashboard on home, Dashboard > title on tablo detail, Planning on /planning)
- Avatar dropdown uses details/summary HTML — no Alpine.js
- Avatar dropdown contains: email as workspace name, Settings link to /settings, Logout form with CSRF
- Logout POST succeeds from the avatar dropdown
- /settings route returns 200 with stub content
- TestTablosDashboard_Sidebar still passes (existing class names preserved)
- TestTablosDashboard_Header passes (new assertions for page-header, breadcrumb, header-avatar-menu)
- go build ./... and go test ./... both pass
- tailwind.css contains page-header and header-avatar-dropdown CSS
</success_criteria>

<output>
After completion, create `.planning/phases/18-app-shell-navigation/18-03-SUMMARY.md`
</output>
