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-hairlineborder,--portal-cardfill,--r-controlradius). 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-entercurve) 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 theIsRootPageflag 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#ffffffwhen 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.cssgeneration 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-fontoriginates in the same per-org branding config as--portal-accentand--portal-surface, and is emitted by the samebranding.cssgenerator (alongside--portal-on-headerand the tint clamp). It's not a one-off page setting; it's brand, same as color.DM Sansis 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-fontcurrently renders body text insystem-uiand 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-fontset) are unaffected by the fallback change — their typeface already flows throughbranding.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.PageHeaderon 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,--e1rest,--e2hover-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.cssgeneration time (the Azure-blob customcode pipeline, same stage as the--portal-on-headerluminance computation in Priority 1). That generator is the one place that sees the raw org-set value, so it both derives--portal-surfacefrom 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-subtleinset 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¶
- Chrome tokens ≠ DESIGN.md tokens (the
--portal-brandreintro + cooler neutrals) — Priority 1 above. Top. - Header foreground contrast regression — killing
--portal-brand(fixed dark navy) forvar(--portal-accent)breaks white-on-light for bright-brand orgs. Fixed by the computed--portal-on-headertoken (Priority 1). Ships with #1 — they're the same migration. - Page headers still in 6 patterns (H-A..H-F); DESIGN.md says converge on H-A — not done. Drive via PORTAL-PAGES.md.
- Two card systems (8px / 16px) not fully migrated to 12px.
- No
prefers-reduced-motionhandling anywhere — a11y gap (Priority 1). - No danger/success/warning roles defined — added in Priority 1.
- Body font size unpinned ("inherit") — pinned to 15px/1.55; DM Sans body default needs cross-org QA (Priority 1 blast-radius note).
- 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 theprefers-reduced-motionrule; add the org-tintable-surface guardrail (L ≥ 96%); note that--portal-on-header+ the surface clamp are computed atbranding.cssgeneration 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-headerforeground), 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.cssgeneration 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-surfaceto 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.