Skip to content

Member Portal — design decisions (June 2026 consultation)

Status: Decided · 2026-06-15 · CEO consultation (hardened 2026-06-15 — header-contrast token + contextual FAB + implementation-owner watches added) Scope: Member portal only (Application MVC + Raklet.Backend/Content/styles/core/social/**, served at <org>.raklet.org). NOT the admin SPA. Relationship to existing docs: This is the handoff. It refines the just-shipped work and reconciles the three docs that drifted: DESIGN.md (token source of truth) · PORTAL-PAGES.md (page inventory) · portal-design-system.md (conventions) · mobile-portal-chrome.md (nav spec).

North star: the portal should feel like a place, not a CRM. Warm ground, white cards that read as physical objects, calm motion. Every decision below serves that.

The single biggest finding: the mobile-chrome spec and DESIGN.md have already drifted. The chrome reintroduced --portal-brand: #1B2035 (which DESIGN.md killed) and a cooler neutral palette than the reconciled set. Priority 1 fixes that.


Priority 0 — Mobile navigation model (DECIDED)

Three surfaces, three non-overlapping jobs

Surface Job Holds
Bottom tab bar Frequency — the ~5 most-touched things, one tap from anywhere Home(Feed) · Search · center slot (contextual: "+" on Feed/Boards, else a normal tab) · Notifications · Account(avatar)
Drawer (left slide) Completeness — the full org map (the bar only holds 5) Members · Events · Jobs · Directory · Boards · Campaigns · Membership · Wallet · Messages (org-configurable)
Account sheet (up) Identity — "me," not "the community" Profile · Payments · Tickets · Settings · Admin · My Orgs · Log Out
  • Cut Feed from the drawer. The Home tab owns it; it's the only true duplicate.
  • Profile in both bar + sheet is intentional (discoverability). The bottom-bar Profile tab opens the account sheet, it does not navigate to a profile page.

Back / menu interaction — Model B (hamburger-always + back-beside)

Decided by CEO 2026-06-15. The in-review "first cut" is correct; the older spec's single context-aware slot is superseded. Payoff: the drawer (full org map) is reachable in one tap from any deep page — no back-out chain.

Top bar (56px, sticky), left cluster:

Page type Left cluster Center Right
Root (list/index) logo avatar → sheet (or "Sign In" pill, anon)
Deep (detail/flow) logo avatar → sheet
  • Hamburger holds the left edge on every page — it never moves (that's what "always visible" means). On deep pages the back button appears immediately inboard of it: [☰][←] · logo · avatar.
  • Make the two controls visually distinct so the pair reads as intentional, not a row of glyphs: hamburger = plain icon button (ink stroke); back = same stroke but in a subtle hairline-bordered chip (--portal-hairline border, --portal-card fill, --r-control radius). The eye parses "menu" vs "go back" instantly.
  • Logo stays optically centered (absolute-centered); the wider left cluster on deep pages does not shift it.
  • Back animation: fade + 8px slide-in (translateX(-8px)→0, opacity 0→1, --m-micro). Appears/disappears as the route's root/deep flag flips. Action = window.history.back().
  • Hamburger → ✕ morph (250ms, --m-enter curve) still fires when the drawer opens, on root and deep pages; the back chip stays put beside it while the drawer is open.
  • Add an edge-swipe-back gesture, don't rely on the chip alone. This ships as a WebView app with no OS back button; iOS users reflexively swipe from the left edge to go back. The hairline chip is the visible affordance, but wire a left-edge swipe → history.back() as the muscle- memory path (guard it so it doesn't fight the drawer's own left-edge open gesture — e.g. drawer opens only on root pages / from the hamburger, swipe-back only on deep pages). Without this, deep-page back will feel broken to iOS users even though the chip is present.
  • (Alternative left order [←][☰] — back at the edge — is a one-line flip if reachability testing prefers it. Default ships as [☰][←].)

Root vs deep taxonomy (drives the left cluster)

Implement as one per-route boolean IsRootPage. Default: index/list = root, detail/flow/ settings = deep.

only (ROOT) + (DEEP)
Feed/Home, Search, Notifications Event detail, Member detail, Job detail, Board post
Members, Events, Jobs, Directory Profile (own), all Settings/*
Boards, Campaigns, Membership Payments/*, Tickets, Invoices
Wallet, Messages Checkout, ChoosePlan, Apply, Subscription detail

Consistency rule: center (logo) and right (avatar → account sheet) are identical on every page, root or deep. The account sheet is reachable everywhere. The rule is constant ("the left cluster is your way out — open the map, or step back"); only the back chip's presence changes.

The center "+" FAB — make it contextual (DECIDED, was a watch)

Promoted from "watch" to a decision 2026-06-15. The elevated center slot is the single most prominent control on every mobile screen; the original spec aimed it at create-post. For a member/association audience that mostly consumes (pays dues, browses events, looks up the directory), spending the most valuable pixel on the rarest action is backwards.

  • Default: the "+" is contextual, not global. Show the elevated create FAB only on surfaces where members actually post — Feed and Boards. On every other root page the center slot is a normal tab (org-configurable; sensible default = Search, since the bottom bar's slot 2 can then yield to Notifications-grouping if an org prefers).
  • One per-route boolean ShowCreateFab (default false; true on Feed/Boards). Mirrors the IsRootPage flag pattern below — same plumbing.
  • Don't delete the create affordance elsewhere — it moves into the drawer / page-level CTAs (e.g. "Post a Job" already lives on the Jobs header). The FAB is a Feed/Boards convenience, not the only way to create.
  • Confirm the Feed/Boards default against analytics before adding more create surfaces; the decision here is "stop making it global," not "members never create."

Priority 1 — Reconcile tokens into one set (DECIDED)

One CSS custom-property file consumed by both the nav chrome and the pages. The chrome migrates onto DESIGN.md's neutrals; --portal-brand is killed (again).

/* COLOR — neutrals (DESIGN.md is source of truth; chrome migrates to these) */
--portal-ink:        #1a2233;   /* titles, primary text */
--portal-muted:      #6b7589;   /* subtitles, secondary text */
--portal-faint:      #9aa3b5;   /* meta text, INACTIVE nav icons (replaces #9CA3AF) */
--portal-hairline:   #e9ecf3;   /* card / input borders */
--portal-hairline-2: #dfe3ec;   /* hover border */
--portal-card:       #ffffff;   /* card surface */
--portal-subtle:     #f6f7f9;   /* inset fill INSIDE a card — never a 2nd white card */

/* BRAND — org-set; the only place brand originates (accent + tint + FONT) */
--portal-accent:     <org>;                 /* links, focus, active, primary fill (SCSS fallback #1A6EF5) */
--portal-header:     var(--portal-accent);  /* top brand bar — REPLACES the killed --portal-brand */
--portal-on-header:  <auto>;                /* header FOREGROUND — white OR --portal-ink, chosen by accent luminance (see below) */
--portal-surface:    #f4f5f7;               /* page bg — org-tintable, see Priority 3 */
--org-font:          <org>;                 /* body + chrome typeface — org-set; SCSS fallback `DM Sans` (see Typography) */

/* STATUS — undefined in DESIGN.md today; add for completeness */
--portal-danger:  #e5484d;
--portal-success: #1f9d57;
--portal-warning: #d98a0b;
--portal-badge:   var(--portal-danger);

/* RADIUS */
--r-card: 12px;  --r-control: 10px;  --r-pill: 999px;

/* ELEVATION — add tier 0 and tier 3 to DESIGN.md's two */
--e0: none;                             /* flat: hairline only */
--e1: 0 1px 2px rgba(20,30,60,.04);     /* rest card */
--e2: 0 8px 24px rgba(20,30,60,.09);    /* hover / active */
--e3: 0 16px 40px rgba(20,30,60,.16);   /* drawer / sheet / modal */
--focus-ring: 0 0 0 3px rgba(0,0,0,.06);/* neutral ring, pairs with accent border */

/* MOTION — reconcile chrome timings + page micro-transitions into one scale */
--m-micro:   140ms ease;                       /* hover, focus, color, back chip */
--m-control: 200ms ease;                       /* toggles, filter accordion */
--m-enter:   300ms cubic-bezier(.16,1,.3,1);   /* drawer / sheet IN, hamburger morph */
--m-exit:    220ms cubic-bezier(.4,0,1,1);     /* drawer / sheet OUT */

Header foreground contrast — the catch in killing --portal-brand. The old --portal-brand: #1B2035 was a fixed dark navy, so white logo + white nav icons on the header bar were always legible. Moving the header to var(--portal-accent) removes that guarantee: an org with a light or bright brand (yellow, mint, pale blue) now gets white-on-light = an unreadable bar. Fix: --portal-on-header is computed, not white-by-default.

  • Compute relative luminance of the resolved accent (sRGB → linear → 0.2126R + 0.7152G + 0.0722B). Pick #ffffff when luminance < ~0.5 (dark accent) else --portal-ink (#1a2233) for light accents. Prefer the WCAG ratio test (≥ 4.5:1) over a hard 0.5 cut when the generator can afford it.
  • This must be resolved where the accent is known — at branding.css generation time (the Azure-blob customcode pipeline), the same place the tint clamp lives (Priority 3), not in static SCSS (CSS can't branch on an arbitrary runtime color). SCSS fallback: --portal-on-header: #fff.
  • Applies to everything sitting on the header bar: logo treatment, hamburger/back icons, the translucent search control's icon + placeholder, and the "Sign In" pill text.

Typography — pin the full scale (DESIGN.md leaves body as "inherit," which is the gap):

Role Size / weight / line-height
Page title 24px / 700 / 1.2, -0.01em
Section-card header 17px / 700 / 1.3
Card title (list item) 16px / 600 / 1.35
Body (pin this) 15px / 400 / 1.55
Subtitle / secondary 14px / 400 / 1.5, muted
Meta / caption 13px / 500, faint
Tab / badge label 11–12px / 600
  • Font is part of the branding set--org-font originates in the same per-org branding config as --portal-accent and --portal-surface, and is emitted by the same branding.css generator (alongside --portal-on-header and the tint clamp). It's not a one-off page setting; it's brand, same as color. DM Sans is the SCSS fallback applied to both chrome and body when the org hasn't set a font — so an unbranded org still looks intentional, not like a system fallback. (The chrome spec scoped DM Sans to nav only; extend it to body.)
  • Blast radius — QA before shipping. This is wider than "reconcile tokens": every org that hasn't set --org-font currently renders body text in system-ui and will shift to the DM Sans fallback on every page (line-length, wrapping, vertical rhythm all move). Visually QA a sample of unbranded orgs (a dense Directory, a long Event detail, a Settings form) before flipping the default. Confirm the DM Sans webfont is already self-hosted/preloaded so the swap doesn't add a FOUT or a render-blocking fetch.
  • Branded orgs (those with --org-font set) are unaffected by the fallback change — their typeface already flows through branding.css. The generator should self-host / preload the org font the same way, so a custom brand font doesn't FOUT either.

Spacing — explicit 4px base: 4 · 8 · 12 · 16 · 20 · 24 · 32 · 48. Card padding 20px, gap-between-cards 16px, page gutters 16px mobile / 24px desktop.

Reduced motion: add @media (prefers-reduced-motion: reduce) — drop transforms and spring curves, keep opacity-only fades. This is missing everywhere today (a11y gap).


Priority 2 — One list + filter pattern (every list page)

Codify what Jobs/Directory shipped as the pattern for Events, Members, Connections, Boards, Campaigns:

  • Layout (desktop): full-width .card.PageHeader on top → below it two columns: sticky left filter card (~280px) + right results column.
  • Results are individual white cards directly on the tinted bg — never inside a wrapper card. If markup wraps the list in a .card, neutralize it (border:none; background:transparent; padding:0).
  • Toolbar row between header and results: result count + sort control ("128 members · Sort: A–Z"). Thin, flat, no card.
  • Filters (mobile): collapse behind a "Filters" button with an active-count badge; tap expands an inline accordion above results (single-line label+input rows, matching what shipped) with Apply + Clear filters. Inline accordion over a bottom sheet — filtering is refine-in-place, not a context switch.
  • Result card: the canonical interactive card (--r-card, --portal-hairline, --e1 rest, --e2 hover-lift). One per item, no double-boxing.
  • Four distinct states (where list pages usually rot):
  • Loading → 3–5 skeleton cards with shimmer (not a centered spinner — preserves layout, no jump).
  • Empty (no data) → one centered card: icon + line + primary CTA ("No jobs yet — Post a Job").
  • Filtered-empty → different copy ("No results match your filters") + a Clear filters link. Must not look like true-empty.
  • Error → inline card + Retry.
  • Header: keep H-A .card.PageHeader; primary action (Post a Job, etc.) on the title row.

Priority 3 — Figure/ground on the tinted background (DECIDED)

Page surface is org-tintable, with guardrails (CEO 2026-06-15). --portal-surface is org-settable (default neutral grey #f4f5f7; demo org sets peach). The warm theme stays — and stays safe — under one rule:

  • Guardrail: the tint must be near-white. Lightness ≥ 96%, low chroma (derive as a 4–6% mix of the brand hue into white, or validate an org value to L ≥ 96% / HSL S ≤ ~30%). Clamp anything darker/more saturated. Below this, white cards lose contrast and hairlines + focus rings disappear.
  • Owner / where it runs: the clamp can't live in CSS — it needs a code owner at branding.css generation time (the Azure-blob customcode pipeline, same stage as the --portal-on-header luminance computation in Priority 1). That generator is the one place that sees the raw org-set value, so it both derives --portal-surface from the brand hue and clamps any explicit override to L ≥ 96%. Static SCSS only carries the neutral fallback #f4f5f7. Pin this to a specific owner before the warm-theme rollout — it's the gate that keeps every other figure/ground guarantee true.

The rule that kills the "nested white boxes" look:

  • Tint is the ground; white cards are the figure. One elevation level only — never a white card directly inside another white card.
  • One surface per region. A region is either a white card or flat tinted, never both. Filters = one card. Each result = one card. Header = one card. The gaps between them = tinted, showing through. That gap is what creates depth — let the peach breathe.
  • Don't flood the content column white. The classic failure: a white panel filling the right column with white cards on it. Keep the column flat (tinted); cards float on it.
  • Sub-grouping inside a card uses hairline dividers or --portal-subtle inset fills — not another white card. (Settings sub-sections = divider-separated rows in one card.)
  • Depth = tint + hairline + --e1, not heavy borders. On warm ground a 1px hairline + rest-shadow is plenty.

Priority 4 — Coherence fixes, ranked

  1. Chrome tokens ≠ DESIGN.md tokens (the --portal-brand reintro + cooler neutrals) — Priority 1 above. Top.
  2. Header foreground contrast regression — killing --portal-brand (fixed dark navy) for var(--portal-accent) breaks white-on-light for bright-brand orgs. Fixed by the computed --portal-on-header token (Priority 1). Ships with #1 — they're the same migration.
  3. Page headers still in 6 patterns (H-A..H-F); DESIGN.md says converge on H-A — not done. Drive via PORTAL-PAGES.md.
  4. Two card systems (8px / 16px) not fully migrated to 12px.
  5. No prefers-reduced-motion handling anywhere — a11y gap (Priority 1).
  6. No danger/success/warning roles defined — added in Priority 1.
  7. Body font size unpinned ("inherit") — pinned to 15px/1.55; DM Sans body default needs cross-org QA (Priority 1 blast-radius note).
  8. Focus ring is neutral rgba(0,0,0,.06) — keep it, but the Priority 3 tint guardrail is what keeps it visible on the warm bg.

Edits to existing docs (do these when implementing)

  • DESIGN.md — replace the Color table with the Priority 1 token block (add --portal-header, --portal-on-header, --portal-subtle, status colors, --e0/--e3, the motion scale); pin Body type to 15px/1.55; add the prefers-reduced-motion rule; add the org-tintable-surface guardrail (L ≥ 96%); note that --portal-on-header + the surface clamp are computed at branding.css generation time, not in static SCSS.
  • mobile-portal-chrome.md — update the Color & Typography block: kill --portal-brand (use --portal-header: var(--portal-accent) plus the computed --portal-on-header foreground), swap neutrals to the reconciled set, extend DM Sans to body default. Update the top-bar table to Model B ([☰][←] deep, hamburger holds the edge, back as a hairline chip inboard) and the bottom-bar row to the contextual center slot (+ on Feed/Boards only). Add the edge-swipe-back gesture note.
  • branding.css generation pipeline (code, not docs) — needs an owner. It's the single place the per-org brand set is resolved: emits --portal-accent, --org-font (with the DM Sans fallback + self-host/preload), computes --portal-on-header (accent luminance → white | ink), and clamps --portal-surface to L ≥ 96%. This is the gate that makes the header-contrast, typography-default, and figure/ground guarantees real.
  • portal-design-system.md — point its "Page headers" + "Ongoing work" sections at this doc; add the four list-page states and the figure/ground rule.
  • PORTAL-PAGES.md — no content change; it remains the per-page audit tracker against the reconciled tokens.

Consultation 2026-06-15. Decisions: Model B nav (hamburger-always + back-beside, back as a hairline chip inboard); reconcile chrome onto DESIGN.md tokens (kill --portal-brand); one list+filter pattern with four states; org-tintable page surface clamped to L ≥ 96%.

Hardened 2026-06-15 (second pass): added computed --portal-on-header foreground token to restore header contrast lost when --portal-brand was killed; promoted the center "+" FAB to contextual (Feed/Boards only, normal tab elsewhere); added watches — edge-swipe-back gesture for the iOS WebView, a named owner for the branding.css-time tint/contrast computation, and cross-org QA for the DM Sans body-default swap.