feat(portal-shell): ghost-style Sign-in button with log-in icon #189

Merged
julien merged 1 commits from feat/portal-shell-sign-in-icon-ghost into main 2026-05-19 11:42:33 +02:00
Owner

Summary

The anonymous "Sign in" CTA in portal-shell's header was a filled brand-primary block sitting next to two round icon-only buttons (Notifications, Help). The contrast was off — the filled rectangle visually dominated the row even though it's the third action in the strip. This PR turns it into a ghost-style button (no fill, no border) with a log-in icon ahead of the label, matching the quiet posture of its neighbours while still reading as the primary CTA for unauthenticated users.

What lands

apps/portal-shell/src/app/components/header/header.html — the anonymous-state button:

Aspect Before After
Fill bg-brand-primary-500 (filled) bg-transparent (ghost)
Text colour text-white text-brand-primary-500 (-300 in dark)
Border none none — pure text-only style
Hover darker fill light bg-brand-primary-50 tint + text-brand-primary-600
Icon (none) <lib-icon name="log-in" [size]="16" aria-hidden="true" /> ahead of the label
Padding / gap px-4 gap-2 px-3 gap-1.5 (slightly tighter, makes room for the icon without growing the chrome)
Height h-11 h-11 (unchanged — 44 × 44 touch target per ADR-0016 holds)

libs/shared/ui/src/lib/icon/icon.ts — adds the missing pair of the existing log-out icon:

  • Imports LogIn from lucide-angular.
  • Registers 'log-in': LogIn, in the alphabetical registry between 'layout-dashboard' and 'log-out'.

Notes for the reviewer

  • Ghost vs. outline vs. filled — why ghost. Two intermediate iterations were considered during the design pass (outline with brand border, then a more compact outline). The ghost rendering — once the border was stripped — reads more naturally on this surface: the header strip is the right place for an "always-quiet, surfaces on hover" CTA, since attention typically lands on the search bar first, not the auth state. Filled buttons are the right call inside content where the CTA is the focal point (forms, modals).
  • Touch-target stays at 44 × 44. h-11 is kept on purpose. The CI a11y gate from ADR-0016 (touch-target check (44×44 min)) is non-negotiable for interactive controls; visually shrinking horizontal padding + reducing visual weight is the right way to "compact" a button without breaking the target rule.
  • aria-hidden="true" on the icon. The adjacent <span> carries the localised label, and the screen-reader contract is "the button announces 'Sign in', not 'log-in icon Sign in'". The icon is decorative reinforcement.
  • Label still wrapped in <span i18n="@@header.signIn"> rather than directly on the button. Required because the button now contains both an icon child and the text — Angular's i18n on the button itself would extract the icon's rendered SVG into the translation unit, which is not what translators want to see. Wrapping the text isolates the translation unit cleanly.
  • log-in belongs in shared-ui, not portal-shell. Even though portal-shell is the only consumer today, the icon registry is by contract the single point of truth for both apps — portal-admin's eventual sign-in surface will use the same icon, so registering it once in the shared lib is the right boundary.

Test plan

  • pnpm nx test portal-shell — green. The existing spec asserts btn.textContent.trim() === 'Sign in'; the <lib-icon> renders to an SVG (no text content) and the label is now inside a <span>, so the text-trim check still holds.
  • pnpm nx test shared-ui — green. Icon registry's exhaustive-key spec picks up the new entry automatically (it iterates Object.keys(ICON_REGISTRY)).
  • pnpm nx build portal-shell — clean, no bundle-size deltas worth flagging (log-in is tree-shaken alongside the rest of lucide-angular).
  • pnpm nx lint portal-shell shared-ui — clean.
  • Manual smokepnpm nx serve portal-shell, signed-out, header visible:
    • Anonymous state: ghost "Sign in" button with the log-in icon. Hover surfaces a faint fill; focus shows the brand outline ring.
    • Switch to authenticated: button is replaced by <lib-user-menu> (unchanged).
    • error state: amber "Can't reach the server" badge (unchanged).
    • Toggle dark mode: text shifts to brand-primary-300, hover surfaces a gray-800 fill; still readable.
    • Tab from the address bar into the header — focus order: search input → bell → help → Sign in. Focus ring on the ghost button matches the other icon buttons (outline-brand-primary-500 outline-offset-2).

What's next

  • portal-admin will get the same log-in icon for its own (still skeleton) sign-in surface once that wiring lands — single shared registry means no further change here.
  • Should the sign-in CTA ever need to come back forward visually (festival days, post-incident push), the ghost class block can flip to a filled variant locally without touching the icon or i18n contract.
## Summary The anonymous "Sign in" CTA in `portal-shell`'s header was a filled brand-primary block sitting next to two round icon-only buttons (Notifications, Help). The contrast was off — the filled rectangle visually dominated the row even though it's the *third* action in the strip. This PR turns it into a ghost-style button (no fill, no border) with a `log-in` icon ahead of the label, matching the quiet posture of its neighbours while still reading as the primary CTA for unauthenticated users. ## What lands `apps/portal-shell/src/app/components/header/header.html` — the anonymous-state button: | Aspect | Before | After | |---|---|---| | Fill | `bg-brand-primary-500` (filled) | `bg-transparent` (ghost) | | Text colour | `text-white` | `text-brand-primary-500` (`-300` in dark) | | Border | none | none — pure text-only style | | Hover | darker fill | light `bg-brand-primary-50` tint + `text-brand-primary-600` | | Icon | (none) | `<lib-icon name="log-in" [size]="16" aria-hidden="true" />` ahead of the label | | Padding / gap | `px-4 gap-2` | `px-3 gap-1.5` (slightly tighter, makes room for the icon without growing the chrome) | | Height | `h-11` | `h-11` (unchanged — 44 × 44 touch target per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md) holds) | `libs/shared/ui/src/lib/icon/icon.ts` — adds the missing pair of the existing `log-out` icon: - Imports `LogIn` from `lucide-angular`. - Registers `'log-in': LogIn,` in the alphabetical registry between `'layout-dashboard'` and `'log-out'`. ## Notes for the reviewer - **Ghost vs. outline vs. filled — why ghost.** Two intermediate iterations were considered during the design pass (outline with brand border, then a more compact outline). The ghost rendering — once the border was stripped — reads more naturally on this surface: the header strip is the right place for an "always-quiet, surfaces on hover" CTA, since attention typically lands on the search bar first, not the auth state. Filled buttons are the right call inside content where the CTA *is* the focal point (forms, modals). - **Touch-target stays at 44 × 44.** `h-11` is kept on purpose. The CI a11y gate from ADR-0016 (`touch-target check (44×44 min)`) is non-negotiable for interactive controls; visually shrinking horizontal padding + reducing visual weight is the right way to "compact" a button without breaking the target rule. - **`aria-hidden="true"` on the icon.** The adjacent `<span>` carries the localised label, and the screen-reader contract is "the button announces 'Sign in', not 'log-in icon Sign in'". The icon is decorative reinforcement. - **Label still wrapped in `<span i18n="@@header.signIn">` rather than directly on the button.** Required because the button now contains both an icon child and the text — Angular's `i18n` on the button itself would extract the icon's rendered SVG into the translation unit, which is not what translators want to see. Wrapping the text isolates the translation unit cleanly. - **`log-in` belongs in `shared-ui`, not portal-shell.** Even though portal-shell is the only consumer today, the icon registry is by contract the single point of truth for both apps — `portal-admin`'s eventual sign-in surface will use the same icon, so registering it once in the shared lib is the right boundary. ## Test plan - [x] `pnpm nx test portal-shell` — green. The existing spec asserts `btn.textContent.trim() === 'Sign in'`; the `<lib-icon>` renders to an SVG (no text content) and the label is now inside a `<span>`, so the text-trim check still holds. - [x] `pnpm nx test shared-ui` — green. Icon registry's exhaustive-key spec picks up the new entry automatically (it iterates `Object.keys(ICON_REGISTRY)`). - [x] `pnpm nx build portal-shell` — clean, no bundle-size deltas worth flagging (`log-in` is tree-shaken alongside the rest of lucide-angular). - [x] `pnpm nx lint portal-shell shared-ui` — clean. - [x] **Manual smoke** — `pnpm nx serve portal-shell`, signed-out, header visible: - Anonymous state: ghost "Sign in" button with the log-in icon. Hover surfaces a faint fill; focus shows the brand outline ring. - Switch to authenticated: button is replaced by `<lib-user-menu>` (unchanged). - `error` state: amber "Can't reach the server" badge (unchanged). - Toggle dark mode: text shifts to `brand-primary-300`, hover surfaces a `gray-800` fill; still readable. - Tab from the address bar into the header — focus order: search input → bell → help → Sign in. Focus ring on the ghost button matches the other icon buttons (`outline-brand-primary-500 outline-offset-2`). ## What's next - `portal-admin` will get the same `log-in` icon for its own (still skeleton) sign-in surface once that wiring lands — single shared registry means no further change here. - Should the sign-in CTA ever need to come back forward visually (festival days, post-incident push), the ghost class block can flip to a filled variant locally without touching the icon or i18n contract.
julien added 1 commit 2026-05-19 11:17:25 +02:00
feat(portal-shell): ghost-style Sign-in button with log-in icon
CI / scan (pull_request) Failing after 3m4s
CI / commits (pull_request) Successful in 3m23s
CI / check (pull_request) Successful in 4m48s
CI / a11y (pull_request) Successful in 1m50s
CI / perf (pull_request) Successful in 3m40s
3f8e33f730
Header's anonymous CTA was a filled brand-primary block — visually
loud against the otherwise quiet header icon strip (Notifications,
Help). Switched to a ghost (transparent / no border) button with a
log-in icon ahead of the label:

- bg-transparent, text in brand-primary-500 (dark mode: -300)
- hover surfaces a faint brand-primary-50 fill so the affordance
  stays readable
- gap-1.5 between the new 16 px log-in icon and the label
- h-11 preserved — 44x44 touch target per ADR-0016 still holds

Adds 'log-in' (lucide LogIn) to the shared icon registry; pair
of the existing 'log-out'.
julien merged commit aa61ea0e02 into main 2026-05-19 11:42:33 +02:00
julien deleted branch feat/portal-shell-sign-in-icon-ghost 2026-05-19 11:42:34 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#189