# Dashboard (Main / User portal)

**Portal:** Main · **Route:** `main.dashboard` (`GET /dashboard`, `auth`) · **Nav:** the portal home after sign-in (and, since 2026-08-26, the only entry point to **Property Concierge** — see below)

## What it does
The landing screen of the user portal, shown to every signed-in main-portal user. It renders one of
two faces, chosen by whether the user currently holds an **active membership**:

- **Member** — a greeting + cards for each membership they hold (tier, price, benefits) + a quick link
  to their courses.
- **Non-member** — a greeting + a hardcoded onboarding journey (register a webinar, join WhatsApp,
  log in, pick a membership).

…and, **since 2026-08-26, a PERSONAL half under both of them** (`Components/Dashboard/PersonalDashboard.vue`),
on the user's instruction that the page should "not only serve as guideline how to do, it is a
personalized dashboard". It answers *what is mine, and what was I doing*: the wealth plan, the
sessions booked with them, the lesson they stopped halfway through, their last analyses and AI
threads, and Property Concierge. It renders for members AND non-members — a non-member who has run
one analysis still owns something — and each block hides itself when it has nothing to say, so a
brand-new account sees only the two blocks that work with no data (start a plan, ask the concierge).

## The Investor Command Centre (2026-09-24) — READ THIS FIRST

The page was rebuilt to the owner's mockup. Everything below this section that describes a
*greeting band with a setup checklist*, a *wealth card with a stacked equity/debt bar*, an
*"AI conversations" card* or a *"Latest property analysis"* card is **history** — those surfaces
are gone. The sections are kept because they record WHY each decision was made, and several of
those reasons still bind (a section that vanishes reads as broken; measure contrast against the
real background; one card per row must be laid out as a row).

### The six blocks, in order

| # | Block | Component | Answers |
|---|---|---|---|
| 1 | Identity band | `Dashboard/IdentityBand.vue` | who you are · where your property stands · what needs you |
|   | *(no eyebrow)* | | "Investor Portal" was removed from the band AND the sidebar (2026-09-24) — the logo carries the wordmark, and the band was repeating the nav. The member's NAME is the brand blue: it is the only word there that is about them rather than about us. |
| 2 | KPI four-up | `Dashboard/KpiRow.vue` | worth · owed · yours · what it pays |
| 3 | My Portfolio | `Dashboard/MyPropertiesSection.vue` | each unit's figures + its seven stages |
| 4 | Action Centre (⅗) | `Dashboard/ActionCentre.vue` | everything waiting on you |
|   | Wealth Goal (⅖) | `Dashboard/WealthGoalCard.vue` | the target, and how far you are |
| 5 | Continue (⅗) | `Dashboard/PersonalDashboard.vue` | what you were mid-way through |
|   | Upcoming (⅖) | `Dashboard/UpcomingCard.vue` | what is booked with us |
| 6 | Concierge | `Dashboard/ConciergeCard.vue` | the jobs you can hand over |

### The masthead is the owner's own artwork (2026-09-24)

He supplied it as a working code package (`PropertyLab-Hero-Section`: HTML + CSS + JS + a KL
skyline WebP), not a picture, and it is reproduced as given — layer order, gradients, motion. Only
the STRINGS are ours, because the package ships illustrative values and the page has real ones.

**Five layers, and the order is the whole trick:**

| z | Layer | Why |
|---|---|---|
| 1 | `.sky-seam` | navy wash down the right edge, so the artwork has a sky to sit against |
| 2 | `.aurora` + `.bloom` | two drifting ribbons, `mix-blend-mode: screen`, masked so they never reach the text |
| 3 | `.skyline-foreground` | **the same image again**, cut to its own silhouette through an SVG mask |
| 4 | `::before` / `::after` | the page-coloured scrim the headline sits on |
| 5 | content + quote | |

⚠️ **The photograph is an ELEMENT, not the band's background** (`.sky-photo`),
and that is structural, not stylistic. A `background-image` cannot be masked, so
the photo's left edge was a hard vertical seam wherever the page-coloured scrim
had already gone transparent — reported as *"the middle is like suddenly from
white to blur, really ugly"* when a collapsed sidebar widened the band and moved
that seam into the centre. As an element it carries its own left-to-right fade,
so the edge does not exist at any width. The silhouette layer needs the SAME
fade, composited with its own mask (`mask-composite: intersect`), or the
silhouette's edge reappears exactly where the photo's used to be.

**One pair of variables drives the artwork** — `--sky-size` and `--sky-pos` on
`.hero` — because the photo, the silhouette and the silhouette's mask must all
read the same numbers. ⚠️ The phone override used to move the image by setting
`background-position` on `.hero`, which after this change moved *nothing*: the
image is not there any more. It sets the variables instead, and swaps the fade
from left-to-right to bottom-up, because on a phone the picture is the horizon
UNDER the text and a horizontal fade cuts the skyline down its middle.

⚠️ **Layer 3 is why the band reads as a photograph rather than a gradient.** Without it the aurora
washes straight over the towers. It costs one extra paint of the same (cached) image.

### The column is 96rem here, not the portal's 72rem (2026-09-24)

Three rounds of the owner's feedback land on one geometry, and it is worth stating once:

1. *"how to make it full wide"* → the band left the `max-w-6xl` wrapper.
2. *"this is weird"* → the KPI row's overlap, which reads as one line across the composition
   while the band and the cards share a width, took a rectangular **bite** out of the skyline
   once the band was wider than the cards. The overlap was dropped.
3. *"the section is too middle … weird to see wording at top right corner and scroll down
   nothing"* → a full-width band above a 72rem column leaves a ~240px strip down each side that
   carries the skyline and the quote at the TOP and nothing for the rest of the scroll.

The strip closes either by narrowing the band or by widening the content. The owner picked the
second (shown three rendered variants), so **the Dashboard's column is `max-w-[96rem]`** and the
masthead's `--gutter` maths is on 96rem to match. On a 1919px screen the side margins fall from
240px to 15px. Other portal pages keep 72rem — a reading page is better narrow.

⚠️ **The quote lives INSIDE `.hero-content`**, not against the band, for the same reason: anchored
to a full-bleed band it sat in that empty strip, the one place with something at the top and
nothing beneath it. Its right edge is now the column's right edge, directly above the Wealth goal
card.

**Full bleed, with the TEXT still on the page column.** The band is a sibling of the
`mx-auto max-w-6xl` wrapper, not a child of it — inside the column its negative margins can only
reach `<main>`'s padding, so it stopped well short of the edges on a wide screen. Lifting it out
then moves the headline to the screen edge, out of line with the KPI card beneath it, so
`.hero-content` re-centres on the same column:

```css
.hero-content {
    --gutter: 16px;                             /* = main's p-4  */
    margin: 0 auto;
    max-width: calc(96rem + 2 * var(--gutter));   /* the page column */
    padding: 28px var(--gutter) 70px;
}
@media (min-width: 640px)  { .hero-content { --gutter: 24px } }   /* = sm:p-6 */
@media (min-width: 1024px) { .hero-content { --gutter: 32px } }   /* = lg:p-8 */
```

One variable drives both the padding and the max-width, so the inner box matches the page column
exactly and is centred at every breakpoint. A hardcoded gutter is 8px out on the middle sizes — the widths nobody
opens. ⚠️ The package's `@media (max-width: 860px){ .hero-content{ max-width: 70% } }` had to move
to `.hero-content > *`: narrowing the container was harmless when the content was left-aligned in
a full-width band, and CENTRES the headline once it has `margin: 0 auto` (measured 128px out of
line at 760px). Verified by measuring `getBoundingClientRect().x` of the `h1` and the first KPI
card at eight widths — all zero.

⚠️ **The KPI row OVERLAPS this band from below** (`-mt-20` on `KpiRow`, 70px of bottom padding on
`.hero-content`). They are one composition; change one number and the other has to move with it.

Assets: `public/main/images/dashboard/kl-skyline.webp` and `kl-skyline-mask.svg` (the silhouette,
extracted from the package's base64 and given its own file rather than inlined).

⚠️ **Read the FINAL cascade.** The package is ONE long stylesheet whose later blocks override
earlier ones, several times over for the same selector. Building from the first rule you find
produces a different design: `.hero` starts dark navy and ends light, and `.aurora svg` is
declared **seven times** — the last word is `filter: none · top: -20% · height: 105% · width: 145%
· opacity: 1 · overflow: visible`, with its own `animation-name: pearl-flow` on the second ribbon.
Taking the early values shipped a band with ONE visible ribbon instead of two (owner, 2026-09-24):
an inherited `filter: blur(24px)` smeared the second one out of existence, because the softness is
supposed to come from the `feGaussianBlur` INSIDE each SVG, applied to one wide stroke, and not
from blurring the whole element on top of it. The same mistake had stale values on the bloom's
colour and the quote's size and position.

⚠️ **Check the brace balance after any edit to this `<style>` block.** Three
separate slice-replacements in one session anchored on the wrong selector — the
worst landed inside a `@media` rule and deleted the headline's colour, which
then rendered WHITE on the near-white scrim and was invisible. Counting `{`
against `}` is the one check a text replacement cannot give you; the render is
the other.

⚠️ **A screenshot cannot tell you a control is CLICKABLE.** Lifting the page
column into the band put the Customise button underneath `.hero-content` —
which, since the greeting was centred, fills the band's whole height and width
at `z-index: 5`. The button was painted, `opacity: 1`, `visibility: visible`,
and completely unreachable. It was reported, not caught. The column carries
`relative z-10` for this reason; do not remove it.

**Hit-test after any change to layering, `z-index` or overlap.** One pass of
`elementFromPoint` over every control says in a second what a picture cannot:

```js
document.querySelectorAll('main button, main a[href]').forEach((el) => {
    const r = el.getBoundingClientRect();
    const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
    if (!el.contains(top) && el !== top) console.log('BLOCKED', el, 'by', top);
});
```

**How it was found, and how to check the next one:** dump the COMPUTED styles from both the
package and the component in headless Chrome and diff them — do not read the CSS and reason about
it. A probe script that walks the layers and prints `getComputedStyle` for each turns "why does
mine look different" into a fourteen-line diff.

```
google-chrome --headless --disable-gpu --no-sandbox --virtual-time-budget=3000 \
  --window-size=1200,300 --dump-dom file://<page-with-probe>.html
```

**Plain CSS in a `<style scoped>` block**, which is the exception that proves §13's rule: masks,
`mix-blend-mode`, keyframes and layered gradients have no utility spelling. Everything that CAN be
a utility still is. Motion stops under `prefers-reduced-motion` through the media query alone — the
package drove it from JS; the query needs neither a class nor a listener.

**The one thing NOT copied:** the package tells a positive figure from a negative one by colour
alone, and that pair measures **ΔE 6.0** for a deuteranopic reader (checked with the palette
validator, not judged by eye) — roughly one Malaysian man in twelve would see them as the same
colour. The arrow icon stays as the second channel; the package's colours are otherwise exact.

### Order, and why pairing is by HEIGHT (2026-09-24)

Two structural faults, both found by screenshotting the real page rather than reasoning about it:

- **Summary and detail were separated.** The KPI row says RM 452,760 and My Portfolio says which
  unit that is; the Action Centre sat between them, so a reader had to cross a to-do list to find
  out what the number referred to. The portfolio moved directly under the figures that describe it.
- **An L-shaped hole.** Upcoming was stacked under the Wealth Goal, making that column half again
  as tall as the Action Centre beside it, and the void under the Action Centre pushed My Portfolio
  down the page — which is what the owner reported as *"my portfolio is at bottom"*. The fix is to
  pair cards by HEIGHT, not by topic: Action Centre with Wealth Goal (both tall), Continue with
  Upcoming (both short). Upcoming spans the whole row when there is no half-finished lesson.

**Screenshotting the real page**, which is how both were found: dump the authenticated HTML through
the HTTP kernel, rewrite `https://localhost/` to `/` (a CLI request has no host, so `asset()` builds
that), serve `public/` on `127.0.0.1` with `python3 -m http.server`, and point headless Chrome at it
with `--virtual-time-budget=10000` so Vue has time to mount.
⚠️ Write the dump somewhere temporary and **delete it the moment you are done** — it carries the
member's data and a CSRF token, and anything under `public/` on this box is served to the internet.

### Why the band is tall, and how the gap under it was closed

The band is 300px because the ARTWORK is sized off its height (`--sky-size` is a
percentage of it), and the greeting needs nowhere near that. With the text
pinned to the top, all 146px of slack piled up underneath it — measured — and on
the left half, where there is no picture, that read as a gap between the
masthead and the first block.

Two changes, neither of which shrinks the photo:

- **The content is vertically centred** (`display: flex` on `.hero`, and
  `.hero-content` fills the height and centres its children). The slack splits
  above and below, and the greeting lines up with the quote.
- **The page column is lifted into the band's foot** (`-mt-12` on the
  `max-w-[96rem]` wrapper). `.hero::after` has already faded the bottom ~50px to
  page colour, so the cards land on page colour and cover nothing. ⚠️ This is
  NOT the overlap that once bit a notch out of the skyline — that one put cards
  over visible artwork. Check `::after` before changing either number.

**A shorter band does NOT have to mean a smaller skyline — the way out is the
ASSET.** The original photo is 2060x763 and the towers run from y=57 to the very
bottom, so at any height most of the frame is city sprawl that `.hero::after`
fades to page colour anyway. `kl-skyline-tall.webp` is that photo cropped to
**2060x560** (3.68:1 rather than 2.70:1), so the towers fill far more of it and
render about **36% larger at the same band height**. The silhouette mask is
cropped to the SAME box, pixel for pixel, or the towers get a halo — it is a PNG
now (`kl-skyline-tall-mask.png`) because cropping an SVG of 33KB of paths means
editing its viewBox and every coordinate.

`--sky-size` is `auto 100%` deliberately: nothing is cropped off the top, so the
spires cannot be cut. They sit 7.3% down the source (x=1747, measured off the
mask), which the old 118% factor was already brushing.

**The band's height is a TRADE, and the trade is measured.** The photo is 118%
of the band and anchored to its bottom, so the skyline scales with it: the
tallest spires sit **7.3% down the source image** (x=1747, measured off the
silhouette mask), which the 18% crop already brushes. Keep the 118% when
changing the height and the crop ratio holds, so nothing NEW is cut — the whole
picture simply gets smaller. 240px is the owner's call, after 300 read as too
tall and 210 made the towers too small.

### Phones and tablets get NO picture (2026-09-25)

Below 1400px the band stacks: greeting, quote, then the city across the foot at its own
proportions. On a phone it was a ~130px strip of skyline wedged between the quote and the first
card (~260px on a tablet), pushing My Portfolio a screen further down — owner: *"very ugly, if
mobile mode hide the skyline photo"*, then *"also hide for tablet"*. So **below `lg` (1023px)
`.sky-stage` is `display: none`** (the photo, bloom and hem go with it); laptops from 1024 to
1399 keep the stacked city. Two numbers move to cover for it:

- `.hero-content` bottom padding becomes **40px**. The page column is lifted 24px into the band's
  foot (`-mt-6`); with the city it landed on the picture's hem, without it it would land on the
  quote's attribution.
- `.aurora` gains a **downward** mask on top of the sideways one. The city used to hide where a
  drifting ribbon hit the band's `overflow: hidden` edge; on bare paper that is a hard line.

The rule lives in `IdentityBand.vue` (`@media (max-width: 1023px)`), after the ≤1399px block it
overrides.

### The ribbon is TWO layers, and it has to be

A light streak that sweeps the whole band needs opposite physics on each half,
which is why `.aurora` (dark) has a twin in `.aurora-light`:

| | dark half (the sky) | light half (the scrim) |
|---|---|---|
| blend | `screen` — adds light to a dark ground | `multiply` — lays a pale blue on white |
| on the other half | **`screen(white, x)` is white: a no-op** | multiply on a dark sky is a black smear |

So the original aurora could not reach the greeting for three stacked reasons:
it is masked off left of 23%, the page-coloured scrim paints OVER it (z-4 vs
z-2), and even without either it would be mathematically invisible on
near-white. None of that is tunable — it is what `screen` does.

Both layers draw the **same bezier path** and run the **same keyframes**, and
their masks are complementary (one fades out by 68%, the other in from 23%), so
they hand the streak over around the middle of the band instead of overlapping
into a bright seam. Measured after: the headline keeps **17:1** contrast against
the tinted scrim (large text needs 3:1).

⚠️ **Do not shorten the band alone to close the gap.** The photo is 118% of the band's
height and anchored to its bottom, so a shorter band crops more off the TOP —
at 260px with a factor raised to keep the photo's size it takes 68px off, which
is the towers' spires. Lower the height and the factor together, or not at all.

### The same ribbons behind the other five sections (2026-09-25)

The two ribbons now live in **`Components/Portal/AuroraRibbons.vue`** — moved verbatim out of
`IdentityBand.vue`, which mounts it inside its `.aurora` layer and keeps only the placing and the
masks. The owner asked for the dashboard's aurora behind Wealth Planning, Analyze Property,
Learning Hub, AI Coach and Landlord Management; a second hand-copied ribbon would drift from this
one the next time either is touched, so there is one.

Those sections get it from the LAYOUT, not the page: `AppLayout` works out whether the current
path belongs to a sidebar section other than Dashboard and fills AppShell's `backdrop` slot with
`Components/Portal/AuroraBackdrop.vue` (AppShell makes `<main>` `relative isolate` only when that
slot is filled, so the backdrop's `z-index: -1` lands above the page colour and below the content).
A new page in one of those sections gets it with no code.

⚠️ **It is a legibility budget, measured over the whole drift, not one screenshot.** Grey text
has no room for light (`text-gray-500` on the page colour is 4.63:1 before anything is behind it),
so the backdrop fades out by 104px (84px below `lg`) — above the tab strips — and the masthead's
one line became `text-gray-600` (GUIDELINES §15). Worst cases after, at 390 / 768 / 1024 / 1440:
the one line 5.5:1, the Learning Hub's blue eyebrow 4.6:1, the tab labels untouched. The numbers
and the reasons are in `AuroraBackdrop.vue`'s header.

The dashboard's own band is pixel-identical after the move (compared before/after at 1440 and 390).

### The member arranges it — `LayoutCustomiser` (2026-09-24)

A **Customise** button opens a panel over the blocks it rearranges: drag a row,
or use its arrows, and turn any block off. Saved per member on
`user_profiles.dashboard_layout`.

Four decisions that are not the obvious ones:

- **NULL means "never customised", and is not an empty layout.** Somebody who
  has never opened the panel must keep following the curated order AS IT
  CHANGES; somebody who deliberately hid everything must keep their empty
  dashboard. One blank value cannot represent both, so **Reset stores null**
  rather than a copy of today's order — a copy would freeze that member on the
  version of the order that existed the day they pressed it, and they would
  never receive a block added later.
- **Visible is a VETO, not a switch.** A block left on still renders nothing
  when it has nothing to say: turning "My portfolio" on does not conjure a
  property. The panel says so in its footer, or somebody turns a block on, sees
  nothing, and reports it broken.
- **Halves pair automatically; there is no fixed two-column row.** Each block
  declares `full` or `half` (`DashboardBlocks::SIZES`); consecutive halves share
  a row and a lone half takes the whole width. A hard "Action Centre beside
  Wealth goal" row is exactly what free reordering would break.
- **Drag AND arrows, and the arrows are not a consolation prize.** The drag is
  the browser's own HTML5 drag-and-drop — no library added to a live build —
  and it **does not fire on touch screens at all**. The arrows are the only path
  that works on a phone, a keyboard and a screen reader, which between them are
  most of the actual use.

**A reorder is MOVEMENT, not a redraw.** Both the panel's rows and the
dashboard's own blocks are in a `TransitionGroup`, so Vue measures each element
before and after and animates the difference (a FLIP): pressing an arrow slides
every row it displaced, and saving slides the blocks to where they now belong
instead of blinking the page into a new order. The dragged row lifts — scale and
shadow — because a row that only changes opacity reads as *disabled*, not as
*held*, and a brand-coloured line marks the edge it will join. All of it is off
under `prefers-reduced-motion`, where the reorder still WORKS: the rows arrive
rather than travel.

⚠️ The dashboard row's `:key` is its block keys joined, not its index — a row
that merely moved has to be recognised as the same row or it is replaced instead
of animated.

⚠️ **The server owns the block list** (`App\Actions\Dashboard\DashboardBlocks`)
because the page that renders the blocks and the endpoint that saves an
arrangement have to agree on it. `normalise()` drops unknown keys and
duplicates, and **appends blocks the member has never seen, visible** — a member
cannot have chosen to hide something that did not exist when they last saved.
The Form Request validates both lists against the same registry: a layout is
stored for years, so an unknown key would be a permanent silent no-op.

*(Asked for as iOS-style widget boxes, to keep a non-buyer from seeing empty
tables. Worth knowing: the empty-table problem did not exist — every block
already renders nothing when it has no data, verified against a real non-buyer
account. The customiser is for members who want their own order.)*

### My Portfolio — one unit at a time, and a rail that needs a date

**More than one property means a picker, not a stack.** Four units stacked made
this section longer than the rest of the dashboard put together, and the fourth
was three cards below the fold. A native `<select>` on purpose: it is the
control every phone already knows how to open and it is keyboard- and
screen-reader-complete for free. The card is keyed by uuid so switching
REPLACES it — the rail's step-in animation replays for the new unit instead of
the old card's dots sliding to new values, which reads as a glitch.

⚠️ **The stage rail renders only when the unit has a VP DATE**, not merely when
its phase is past `construction`. Every stage's dates chain from vacant
possession, so without one the rail draws seven steps whose timings are
guesses — and a unit CAN reach `journey` with no VP date at all, by a colleague
moving a stage by hand. No date, no timeline.

**The rail says which way it is going, in three channels** (all off under
`prefers-reduced-motion`, where it still reads correctly and simply does not
move):

- **Depth.** A done step is a gradient fill with a drop shadow and an inner
  highlight — it sits in front. An unreached step is flat, because nothing has
  happened there. That contrast is the whole message.
- **Sequence.** The steps arrive left to right, `--i * 55ms` apart — the
  direction the rail is read in.
- **Life.** The live step scales 1.18, its core breathes, and TWO rings pulse
  outward offset by half a cycle. One ring is a blink; two read as a signal.

The fill carries a slow sheen so the eye reads it as travelling toward the live
step rather than as a static bar.

### The rules the layout exists to enforce

- **One `<h1>` and one dark block per screenful.** The masthead is both, until the Concierge card
  at the very bottom. Two stacked navy blocks were reported as *"weird"*; that is the fix, and
  `DashboardHero.vue` was deleted rather than made collapsible.
- **Every total is computed ONCE, server-side.** `App\Actions\Dashboard\BuildPortfolioKpis`
  sums the units; the KPI row, the portfolio caption, the wealth goal and the insight paragraph
  all read that one answer, so the page cannot disagree with itself. A total is **null unless
  every unit in it has the part being totalled** — adding a known unit to an unknown one does not
  make a total, and the reader cannot see which half was missing.
- **The loan starts at VACANT POSSESSION.** `UnitEconomics` amortises from `projects.vp_at`
  (`loan_started_on`, `months_paid`, `outstanding`, `principal_paid`, `net_equity`), by the
  standard remaining-balance identity. Before the keys the buyer is on progressive interest, not
  on an instalment, so amortising from the booking date would credit them with principal they have
  not repaid and overstate their equity by exactly that.
  With no VP date yet, `outstanding` is the full loan amount (assumed = net price until the team
  enters the real loan) — owner, 2026-09-25.
  **Net equity** (value − what is still owed) and **gross equity** (value − what they paid) are
  different questions and both are carried; do not collapse them.
- **No trend percentages.** There is no historical market-value series, so the mockup's "+22%" is
  not rendered. It goes in when monthly snapshots exist, and not a day before.
- **Say a number ONCE.** A "PropertyLab Insight" card shipped at position 5 and was removed the
  same day (owner: *"remove propertylab insight"*): it restated the KPI tiles and the portfolio
  card's figures as a paragraph, which is a fourth copy of the same answer occupying half a row.
  If a block's content can be derived by reading the block above it, it is not a block.
- **A dark surface must be DENSE.** The Concierge block first shipped as the mockup's 3×2 grid of
  icon tiles — which works at the mockup's half-column width and falls apart at full width, where
  five tiles across 1120px are 350px boxes each holding a 16px glyph. Reported as *"very ugly"*.
  It is a one-row BAND now: identity, the named services as inline links, the CTA. Light cards can
  carry air because the page around them is light; a dark block's empty area is the loudest thing
  on the screen.
- **A decision is made where it is asked.** "Choose your renovation arrangement" is three buttons
  — `Dashboard/RenovationChoice.vue` — never a link. It used to be a link to `/dashboard`, the page
  the reader was already standing on.
- **…and it is asked on the unit's own card too (owner, 2026-09-25).** An earlier pass took it OUT
  of My Portfolio on "one ask, one place" grounds, and the card then said *"Nothing for you to do
  right now — PropertyLab is on it"* about the very unit the Action Centre was asking about (owner:
  *"why show nothing at my portfolio card? if show, need to do some effect to highlight people to
  click"*). A card that contradicts the list beside it is worse than a question asked twice. So:
  - Both places render the SAME server item (`BuildAttentionBand`'s `renovation_choice`, passed to
    `MyPropertiesSection` as `attention`) through the SAME component, so WHEN it is asked is decided
    once, server-side, and answering it in either place clears both on the reload.
  - On the card it is a lit amber panel ("needs attention" colour; the buttons carry the brand edge,
    because they are the action) with a ping dot and a slow glow — both off under
    `prefers-reduced-motion`, where it keeps a steady ring. "Nothing for you to do" never renders
    while a decision is pending.
  - With several units the picker **opens on the first unit waiting on the member** (a decision or
    their own next task) and marks it "— needs you", or a decision on the third property is one
    select away from ever being seen. Only on load: answering must not move the card out from under
    them.
  - On a phone the card's header wraps and the picker takes its own full-width row; beside a unit
    name the title had ~100px and broke onto two lines.
  - On a phone (<640px) the stage rail keeps all seven dots but prints only the LIVE step's label.
    Seven columns at 360px are ~41px each — narrower than "Inspection" — so the full set either
    pushed the row past the card (the last dot, "Sold", was cut in half) or ran the words into each
    other. The line under the rail names the live stage anyway; the other labels stay in the DOM,
    visually hidden, for screen readers.
- **Anything DATED belongs above the fold.** Upcoming shipped at position 5 and moved into the
  right-hand column the same day (owner: *"i wanna have a section to show the upcoming event, now
  scroll down only can see"*). The rest of the lower half is things a member picks up whenever
  they like; a session tomorrow evening is not one of them, and a date that has to be scrolled to
  is a date that gets missed. It also balances the row — Action Centre is the tallest block on the
  page, so its column needs two cards beside it or the row ends in white space.
- **The lifecycle rail is ONE track, ONE fill, and dots on a layer above.** It was built as a
  half-width connector per step first, and the seams landed under the dots, so the line appeared
  to cut straight through the first circle (owner: *"overlap with the line"*). Per-segment
  connectors cannot be made reliable here — the dot hides the join with a `ring`, and the moment a
  state needs a ring of its own (the halo on the current step) it overrides the white one and the
  line reappears through the middle. The current step's halo is therefore its own absolutely
  positioned layer. Geometry: each `<li>` is `flex-1`, so a dot's centre is at `(i + 0.5) / n`;
  the track is inset `50/n` percent at each end and reaching step `i` fills `i/n` of the row (the
  `n − 1` cancels). Plain percentages, never `calc()` — the arithmetic is known at author time.
- **Setup steps are one collapsed line at the bottom of the Action Centre**, never between two
  dated items, and they are excluded from the masthead's "N actions need your attention" count.

### What was removed, and where it went

- **"Your AI conversations"** and **"Latest property analysis"** — both were history lists (a
  record of visits, not a reason to act) and both live one click away in the sidebar sections that
  own them. The dashboard answers three questions in five seconds; a card that answers none of
  them costs the three that do.
- **`DashboardHero.vue`** — deleted 2026-09-24 (the greeting is the masthead, the setup steps are
  Action Centre rows).
- **`AttentionBand.vue`** — became `ActionCentre.vue`.
- **`InsightCard.vue`** — deleted the day it shipped; see *say a number once* above.
- **`PersonalDashboard.vue`** — reduced to the single "Continue where you left off" card. Its
  other three surfaces moved out (Upcoming up to block 3, Concierge to its own band, the two
  history lists deleted).

### The wealth goal's three figures

Read from `state.goal` on the plan (`passive`, `age`), never recomputed — the plan's verdict and
trajectory stay client-side inside Wealth Planning, so this card never says "on track".

| Figure | Means | Source |
|---|---|---|
| Earning now | what LET units pay today | units at stage `tenanted`; RM 0 until one is let, and 0 because it is true |
| Once let | what the units they own would pay | `BuildPortfolioKpis.cashflow` — NOT the plan's projection, which counts properties not yet bought |
| Gap | target − once let | the work outstanding |

### `tc()` — plural strings

`useSite()` gained `tc(key, count)` for lines like `':n property|:n properties'`. Plain `t()` was
being called with those keys and printed the pipe **verbatim on screen**. Use `tc` for any line
whose wording depends on a count; the choice is made on the TRANSLATED line, so a language with no
plural (Chinese) simply omits the second form.

---

### The hero: guidance first, in one band
The setup checklist sits at the TOP (2026-08-26, third pass — *"normally they would have this at
top to guide the user first"*), which is the SaaS convention and reverses the second pass. What
makes it survivable is that it is now **one band inside `DashboardHero.vue`** — a progress ring
plus four chips, ~320px — rather than the 650px card of full-width rows it began as. Guidance
leads; the member's own data still starts inside the fold. **The whole hero band removes itself
once all four steps are done**, so a settled member gets a greeting and nothing else.

A checklist with four equal actions is a to-do list, not guidance, so exactly one — the first
unfinished step — is promoted to a solid button beside the ring. Its label is the ACTION
("Choose membership"), not the chip's status text, or the same words appear twice side by side.

### Three fixes from reading the live page (2026-08-26, fourth pass)
- **A section that vanishes reads as broken, not as empty.** "What's next" hid itself when nothing
  was upcoming, and a member whose only webinar had finished an hour earlier asked *"why no
  upcoming event?"*. It always renders now, with an empty state that says what would appear there.
  ⚠️ Consequence: its partner in the two-column row can never be alone, so `span()` must be passed
  "is the partner ON SCREEN", not "does the partner have content" — getting that wrong put the two
  cards on separate rows.
- **A completed step must not print a stale date.** The hero read "Webinar booked · Wed 26 Aug ·
  8:00pm", which says *you have a session coming up* even when that session ended an hour ago. The
  step is complete either way, so the payload carries `is_upcoming` and the chip falls back to
  "Registered".
- **Contrast on the dark hero.** The done chips used `text-slate-500` on `navy-950` — **3.8:1**,
  which fails for small text and was reported as "not clear". Done and to-do are told apart by the
  tick and the ring now, not by dimming the words; both sit at 7:1 or better. Measure against the
  real background rather than trusting a shade name.
- **Profile gained a "Back to dashboard" link.** It is reached from the sidebar FOOTER and from the
  hero's "Choose membership" CTA, so no nav row is lit while you are on it (§15).

### Type scale and visualisation
The first cut used 11–12px labels and 14px headings and read as cramped on the portal's
most-visited page (*"why the font size is so small"*). Section titles are 16px, body 14px, figures
24–30px, and the greeting 36px. The wealth card **visualises** rather than lists: one stacked bar
puts equity against debt inside the portfolio's value, drawn from the same two stored columns —
no new maths, and still no projection. Colour is unchanged brand blue / navy / emerald.

> A card that spans a full row must be laid out as a ROW. "Pick up where you left off" takes the
> whole width whenever it is alone in its grid row, and as a stacked block that simply moved the
> emptiness from beside the card to inside it.

### Order is the point (measured, not felt)
The first cut of this appended the personal blocks UNDER whichever face won, which measured at
**1211px on a 1000px fold**: an account with a wealth plan, three analyses and a half-watched
lesson opened on a six-step card telling it to log in. The page now composes in value order —
**greeting → what's yours → what the account gives you / still needs** — and membership status no
longer decides whether a member's own data is above the fold. After: wealth plan at **217px**, all
six blocks inside the fold, page height **2119px → 1496px**.

Consequences worth keeping:
- **The greeting and the events list are the PAGE's**, not each face's. Both faces carried their
  own copy, which is part of what pushed everything down.
- **A two-column row where one side may be missing uses `span()`** — a lone survivor takes the
  whole row. Without it the surviving card kept half the grid and left a ~500px void (measured).
- **The onboarding checklist is a compact strip at the BOTTOM, with four steps that can all
  actually complete.** Two of the original six could never fail: "Log in to system" was only ever
  shown to someone signed in, and "Remember the Webinar Date" was a checkbox stored nowhere, so it
  reset on reload. The remaining four each have a real signal — the last two (talked to the AI,
  chose a membership) were permanently unticked for everyone until the dashboard started passing
  those answers in. The whole strip hides once they are all done.
- **`last_message_preview` is Markdown** (it is the raw message body truncated), so the controller
  strips it for display — the live page was printing literal `**` at the reader. Stripping happens
  on read, not on write: the stored preview must stay a faithful copy.

> 🐞 **Never gate a section on a raw prop the child re-filters.** "What's next" hosts
> `UpcomingEvents`, which drops sessions once they END. Gating the section on `events.length`
> rendered an empty card ten minutes after a webinar finished. The child now reports its
> post-clock count (`@count`) and the section is `v-show`n from that — `v-if` would unmount the
> child and it could never report.

### Rules the widget reads live by
A dashboard is the most-hit page in the portal, so each widget is ONE indexed, `limit`ed query on
named columns (whole page measured at ~220 ms against live data). Three constraints are easy to
break by accident and are commented at each call site in the controller:

- **Never select a JSON blob.** `wealth_plans.state` and `property_analyses.result` are wide; the
  analyses table carries a migration comment recording a real *"Out of sort memory"* incident from
  selecting whole rows.
- **Never touch the catalogue connection.** `Appointment::toCalendarArray()` and
  `PropertyAnalysis::catalogProject()` both resolve across it — a tile must not pay a
  cross-database read per row.
- **A portal user may have no lead row yet** (it is created lazily on first write), so every widget
  hangs off `$user->lead?->id` and renders empty rather than 500ing.

### What each widget may honestly claim
- **Wealth plan** — the PROMOTED summary columns only (`property_count`, `total_mv`, `total_loan`,
  `target_age`), plus net equity, which is a subtraction of two stored columns. It states what the
  member OWNS and never whether they are on track: the verdict and the trajectory are computed
  client-side from `state`, and a second server-side answer to "am I on track" would eventually
  disagree with the plan page. *(2026-09-20: the MOBILE dashboard now does carry the verdict and the
  four headline figures — `wealth_plan.summary`. That is not a second answer: it is the plan page's
  own JavaScript engine run under Node (`Src\Wealth\Services\WealthPlanEngine`), cached per plan
  edit. The rule this paragraph protects still stands — never WRITE plan maths in PHP — and the
  portal card is unchanged. See
  [`wealth-planning/mobile-api.md`](/docs/modules_handbook/main/wealth-planning/mobile-api.md).)*
- **Upcoming sessions** — the member's own `appointments` + `zoom_meetings`, i.e. what an admin
  booked against their lead on Manage → Calendar. ⚠️ That calendar is **not** a broadcast events
  feed: rows are agent-owned and targeted by `lead_id`, which is exactly why "define it there and it
  shows up here" works. ⚠️ `scheduled_at` is stored as **Asia/KL wall time, not UTC** — comparing or
  converting it as UTC moves every appointment by eight hours. An appointment has no title column;
  its label is its type.
- **Continue watching** — TWO stores, because neither answers both halves:
  `lms_lesson_progress` knows WHICH lesson was last opened (no position column),
  `video_watch_events` is append-only milestones (no per-lesson rollup). ⚠️ The lesson player
  accepts **no seek parameter**, so the card says *"you were 62% in"* rather than *"resume at 62%"* —
  it reports how far they got and never promises to start there.
- **Latest analyses / AI threads** — stored columns + the denormalised
  `last_message_preview` (never a join onto the messages table for one line). A thread with a
  `playbook_key` links into Investment Prompt, not the chat, because that is where its intake and
  follow-up chips live.
- **My properties** (2026-09-21) — every live booking on the member's lead, resolved by the Post-VP tracker's `UnitJourneyPresenter` in its member view: the stage the unit is in, and the next step assigned to the OWNER. It reads `projects.name`, never the catalogue connection. It sits ABOVE the personal widgets: a bought unit is the biggest thing a member has with us, and its next step may be due. See [Post-VP Tracker](/docs/modules_handbook/manage/post-vp/readMe.md).
- **Property Concierge** — services read from `ConciergeRequest::SERVICES` so the card can never
  drift from what the request form offers, plus a count of the member's live requests so the card
  says *track* rather than *start* when there is something in flight.

It is also the portal's **one always-reachable page**: a customer whose email **or phone** is not yet
proven is confined here by the `contact.verified` middleware (see below), and meets the **Extra Bonus
overlay**.

Both faces also show a shared **"Upcoming events & webinars"** list — every upcoming funnel session.
This is the member-portal half of the events members-only gating (a session inherits its **slot's**
visibility — see [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md)): a member
who qualifies sees the session's full details; everyone else sees a locked **"Exclusive to {Membership}
members"** preview with a *View memberships* link.

## The Extra Bonus overlay (contact verification gate)

Signing in only ever proves **ONE** contact key: the funnel flow signs a lead in on their **phone**
(the WhatsApp welcome's `{{login_link}}` is network proof of the number), an emailed code signs them
in on their **email** — see the
[login handbook](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md). The
**other** key is proven **here**, framed as a reward rather than a chore.

- **The guard** — [`EnsureContactIsVerified`](/app/Http/Middleware/EnsureContactIsVerified.php), alias
  `contact.verified`, stacked as `['auth', 'main', 'contact.verified']` on the portal route group in
  [`routes/main.php`](/routes/main.php). Every portal page bounces a customer with an unproven email
  **or phone** back to `/dashboard`. **Exempt:** staff (`isManageUser()`, same rule as the `main` hold,
  so an admin previewing the portal is never trapped) and the checkout gateway's `return` / `cancel`
  URLs (never intercept a payment mid-flight). `/dashboard`, `/profile` and `logout` sit outside the
  group by construction — which is load-bearing: the profile endpoints are exactly what frees a held
  customer.
- **Why BOTH keys (2026-08-07).** Each key is a sign-in route on its own — a code is sent **to** it —
  so an unproven key is an unclosed door. An email-first customer whose phone was captured by a form /
  import / admin edit and never proven is precisely the account a stranger's mistyped-or-borrowed
  number could later be used against, the same reasoning that already held the email. `hasVerifiedContact()`
  is the one predicate, and a **MISSING key counts as unproven** (the overlay asks them to add it), so
  deleting a number can never become a way *out* of the gate.
- **Two predicates, two consumers.** `HandleInertiaRequests` shares
  `auth.user.needs_email_verification` **and** `needs_phone_verification` (plus `phone` for display),
  each computed with the *identical* rule the middleware's matching half uses — so the overlay is shown
  to exactly the people the guard holds, and agrees with it about *which* key is outstanding.
- **The overlay** — [`Components/Main/VerifyContactOverlay.vue`](/resources/js/Components/Main/VerifyContactOverlay.vue),
  mounted unconditionally in `Dashboard.vue` (it renders itself only while held). It shows **only the
  key(s) actually outstanding**, and how many there are decides the shape:
  - **BOTH outstanding → the DUAL step.** One click sends **both codes at once** and both are entered
    together in one submit — the same shape `/register`'s [`ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue)
    already uses, so the two surfaces feel like one product. (An earlier build collected them one at a
    time; that was reversed — the "two boxes invite pasting the wrong code" worry has a working
    counter-example in this very codebase, and a second round trip is a real cost to every registrant.)
  - **ONE outstanding → that key's own single-code flow.** The combined send deliberately skips a key
    that is already proven: an SMS costs money, and a code for a proven key proves nothing.
  - Either way, four steps per key: the pitch → code(s) sent → *"填错了？"* → code sent to a **new** one.
    **"Change" is always a single-key sub-flow**, even out of the dual step, because the change
    endpoints prove the NEW value on its own.
  - The phone side adds a **SMS / WhatsApp channel picker** (shown only when
    `WhatsappTemplate::otpLoginReady()`, passed as the `whatsappAvailable` page prop — never offer a
    channel that would silently fall back), and an account with **no phone at all** opens straight on
    the change form with no way back (a "返回" would land on a pitch offering to send a code nowhere).
- **The dual endpoints** — `POST main.profile.contact.verify.codes` (`throttle:6,1`) sends to every
  outstanding key; `PUT main.profile.contact.verify` proves them in one submit, **judging each
  independently and stamping a correct code even when its sibling is wrong** (the codes are separate
  proofs, each send cost something, and re-doing a code that was already right is pure waste). Which
  keys are outstanding is read from the **account**, never the request, so the client cannot declare
  itself verified; [`ConfirmContactVerificationRequest`](/app/Http/Requests/Profile/ConfirmContactVerificationRequest.php)
  makes each code required only while its key is unproven. A **partial delivery failure** is a success
  with a `warning` flash naming the failed side — collapsing it into an error would hide a code that
  *did* arrive behind a dead end.
- Everything underneath reuses the profile module's endpoints wholesale (`main.profile.{email,phone}.verify.code`
  / `.verify`, and `main.profile.{email,phone}.code` / `main.profile.email` + `main.profile.phone.update`
  for a change) via one shared `consumeVerifyCode()`, so there is no second OTP implementation —
  including their **reject-on-duplicate** rules (an email or number already on another account is
  refused, never merged: the customer has proven only one key). The phone change additionally re-checks
  ownership **at commit time**, since minutes pass between issuing the code and confirming it.
- **The hold is total, and that takes three things** — a `v-if` alone was not enough. The backdrop is
  **`<Teleport to="body">`**'d, because the portal sidebar is `fixed z-40` and an overlay nested inside
  `<main>` renders *underneath* it (that bug shipped: the sidebar stayed clickable). It sits at **z-50**
  — the shared `Modal` layer, above the sidebar and below `FlashToast` (z-60) so its own confirmations
  stay readable — and it **locks `body` scroll** while up, so the page behind cannot be scrolled either.
- **Success is celebrated, not swallowed.** Verifying is the last thing between this person and the
  portal, so the overlay swaps to a green *"恭喜您, {name}!"* panel with `Confetti` and a **领取我的
  Extra Bonus** button that visits `/courses`. It is keyed on the **transition** out of the hold
  (`held` true → false, i.e. BOTH keys proven), so somebody who was already verified never sees it on a
  plain page load — and finishing the *first* of two keys advances the overlay instead of celebrating
  early. That panel is dismissible — they are verified now, so nothing should trap them.
- **Blast radius:** the guard applies to **all** users, not just new ones (product decision
  2026-07-28), so existing customers with an unproven key meet the overlay once and then never again.
  When the phone half was added (2026-08-07) the production snapshot held **zero** accounts in the
  newly-caught state (email proven + phone not), so it landed as pure forward defence rather than
  locking out a live cohort.
- Pinned by [PortalContactGateTest](/tests/Feature/Auth/PortalContactGateTest.php): each key holds
  independently, a missing phone still holds, staff pass, `/dashboard` stays reachable, the shared
  props agree with the guard, and a held customer can reach the profile endpoints that free them.

## How it works
- [`Main\DashboardController@index`](/app/Http/Controllers/Main/DashboardController.php) reads the
  user's active membership ids once (`$user->activeMemberships()` — the live list from the lead's
  active subscriptions, see [Login foundation §5](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)),
  then returns three props via `Inertia::render('Dashboard')`: `isMember`, `memberships` (full detail
  for the member cards), and `events`.
- **Events query** — `Event::upcoming()` (scheduled, date ≥ today) over `TYPE_WEEKLY` (funnel
  sessions), grouped by slot and ordered soonest-first, each group mapped by `eventGroupCard()`.
- **Server-side gating (the important bit)** — `eventCard()` calls
  [`Event::isAccessibleTo($memberIds)`](/src/Event/Event.php). When the user **can't** access a
  members-only event the card carries only `accessible: false` + a `lock_reason`, and its
  `description` / `location` / `zoom_link` are set to **null** — the gated detail never reaches the
  client. PUBLIC sessions (the default) are accessible to everyone; a session is members-only when its slot is. `lock_reason` reads
  `"Exclusive to {names} members"`, or `"Exclusive to members"` when the event gates on *any* active
  member (empty pivot).
- **Frontend** — [`Pages/Dashboard.vue`](/resources/js/Pages/Dashboard.vue) picks `MemberDashboard`
  vs `NonMemberDashboard` on `isMember` and passes `events` to both; each renders the shared
  [`Components/Dashboard/UpcomingEvents.vue`](/resources/js/Components/Dashboard/UpcomingEvents.vue),
  which draws a full card for accessible events (description, location / Zoom *Join link*) and a
  locked card (lock badge + membership CTA → `/profile?tab=membership`) otherwise.

### Member mobile Dashboard API and authentication
- `POST /member-api/v1/member-auth/challenges` sends a five-minute email OTP for an existing eligible main-portal user. Unknown, inactive, and incomplete identities receive the same 202 response shape with a fake challenge to prevent account enumeration.
- `POST /member-api/v1/member-auth/tokens` consumes the challenge and six-digit code, rechecks portal and account eligibility, and returns a short-lived member JWT. `POST /member-api/v1/member-auth/tokens/refresh` rotates a JWT inside the configured refresh window; `DELETE /member-api/v1/member-auth/token` invalidates it.
- `GET /member-api/v1/member/dashboard` is a stateless, read-only JSON endpoint behind `auth:api` and the API throttle. Mobile clients never receive database credentials.
- **Parity with this page is audited control by control in [`mobile-parity.md`](mobile-parity.md)** (2026-09-25) — every website control traced to its API field, the app widget and the test that proves it, per user state. Read it before calling the app "the same as the website": sharing the payload is not sharing the feature.
- `attention[]` is built by `ReadsMemberAgenda::memberAttention()` — the SAME call the portal's Action Centre makes. Until 2026-09-25 the app's builder passed empty inputs, so the phone never listed a step agreed with an advisor, a booked session, or the setup rows. `sessions[]` (appointments + Zoom meetings booked with the member) and `layout.presets` are additive on contract 1.
- The owner's writes from the app — `PUT my-properties/{id}/renovation`, `…/numbers`, `…/stages/{stage}` — run the portal's own `RaiseRenovationEnquiry` / `SaveOwnerNumbers` / `MoveOwnerStage`, and each answers with the unit in `show()`'s `{ data: { property } }` envelope.
- `BuildMobileDashboard` selects promoted wealth totals, the latest incomplete lesson, up to three event series, and the open Concierge count. It never selects the wide wealth state JSON.
- `wealth_plan: { has_plan, summary|null }` (additive, 2026-09-20) is the plan's KEY RESULTS — verdict + headline figures + loan-eligibility status — built by `App\Actions\Wealth\BuildWealthPlanSummary`. It is cached against the plan's id + `updated_at`, so on a hit the dashboard still selects no `state`; on a miss it analyses once. It is wrapped so that nothing in it can fail the dashboard: engine down → `summary: null`, and `portfolio` is the fallback the app prints. The plan shown is `WealthPlan::shownFor()` — the same one the portal card shows.
- Event membership gating happens before transformation: locked events carry schedule and lock reason only; location and join URL remain null.
- Contact verification matches the portal gate. Unverified customers receive 403; anonymous requests receive 401.
- `MemberAuthTransformer` and `MobileDashboardTransformer` own stable response shapes. The Flutter client at `/home/ubuntu/propertylab-mobile` stores the JWT in platform-secure storage, refreshes on launch, and has no preview-data fallback.

## The hero, and why the status colours carry an arrow (2026-09-24, dataviz review)

Reviewed against the `dataviz` skill after the owner said the page did not read as world class. Four
findings, and the rules they leave behind.

- **Exactly one hero figure per view, ≥48px, PROPORTIONAL figures.** The page had none: nine numbers
  all at 18px, so nothing led — and the largest numbers on screen (the wealth plan's `text-3xl`)
  belonged to a hypothetical plan rather than to the unit the member owns. The portfolio value is now
  the hero and the plan card sits below it. `tabular-nums` is off every display-size number — equal
  width digits make `121` look loose — and stays ONLY on the unit page's ledger, a column that aligns
  vertically.
- **No large saturated blocks.** The portfolio line was a flat navy slab, which reads loud at that
  size and stacked against the Property Concierge card's own navy. It is a light card with a hairline
  border now, and the page is down to one dark block. Where a hero IS dark — the unit page's Forecast
  tab — it is navy **lit from inside** (radial glows), which is the owner's ruling: a flat slab was
  rejected once already.
- ⚠️ **A signed figure never relies on hue alone.** Measured with the skill's validator, not by eye:
  emerald-600/rose-600 are **ΔE 5.8** apart for a deuteranopic reader and emerald-300/rose-300 on navy
  are **ΔE 2.9** — the target is ≥ 8. Roughly 1 in 12 Malaysian men are affected, and the ± sign at
  11px was carrying the whole signal. Every signed figure now also carries an
  `ArrowUpRight` / `ArrowDownRight`. **Run
  `node scripts/validate_palette.js "<hex,hex>" --mode light|dark` before adding another status pair.**
- **The stat-tile contract is still half-met, on purpose.** A tile is `label · value · delta vs a
  named period · trend`. There is no delta and no sparkline because there is no history: market value
  is derived from TODAY's nearby asking prices and nothing snapshots it. Inventing a trend line is the
  one thing worse than not having one. Snapshotting monthly is the prerequisite, and is not built.

## Read in order of TIME DISTANCE (2026-09-24, owner asked for the redesign)

The page used to be ordered by what PropertyLab offers — greeting, setup ring, your unit, your
things, membership. A person opens it with three questions instead, and they have an order: what
needs me · what do I own · what was I in the middle of. The sections follow that, and the greeting
and setup checklist come after all three because neither is something to act on.

1. **Masthead** (§15) — `INVESTOR PORTAL` / `MEMBER PORTAL` over `Hello, {name}`, and one line. The
   greeting IS the title of a personal dashboard, so the page carries one heading rather than a plain
   "Dashboard" above a greeting band saying the same thing in a darker box.
2. **`AttentionBand`** — everything waiting on this person, built server-side by
   `App\Actions\Dashboard\BuildAttentionBand`: a unit's next task THAT IS THEIRS, an unanswered
   renovation decision, a step agreed with an advisor, a session to turn up to. Overdue first.
   - **Only items that are theirs.** A stage PropertyLab is late on is ours to chase — the same rule
     `UnitJourneyPresenter` applies to `is_overdue` in the member view.
   - **Only items with a WHEN or a decision.** A suggestion ("explore Analyze Property") would make
     the band a second nav menu.
   - ⚠️ **Empty renders NOTHING** — no heading, no empty state. Most visits are empty, and a box
     saying "no tasks" on most visits teaches people to skip the one place that matters when it is
     not empty.
3. **`MyPropertiesSection`** — now led by a PORTFOLIO line (value · equity · forecast rent · cash
   flow) across every unit that can be valued. It shows with one unit too, repeating that unit's card
   on purpose: somebody with two must never add their own figures up, and the line cannot appear only
   once they have two. Hidden when no unit can be valued — a total of one known and one unknown unit
   is not a total.
4. **`PersonalDashboard`** — unchanged for now. Ordering it by what the member last touched needs its
   five fixed blocks refactored into one sortable set; deferred rather than done half-way.
5. **`DashboardHero` is GONE** (deleted 2026-09-24, same day it was demoted). Collapsing it was the
   wrong fix: it left two titles on the page ("Dashboard" above "Hello, …") and two `bg-navy-950`
   blocks stacked, since the Property Concierge card is dark too. Its four steps are now ordinary rows
   in the band above — a step that is done simply is not listed, because a list that keeps showing
   completed work is a progress bar pretending to be a list — and its greeting became the masthead.

   **The rule this produced:** before shipping a dashboard change, count the page's titles and its
   dark blocks. More than one of either is the smell. A weak element gets folded into something
   already on the page and deleted; adding a second surface beside it always looks like progress in a
   diff and always costs the reader.

## Related files

**Backend**
- [app/Actions/Dashboard/BuildMobileDashboard.php](/app/Actions/Dashboard/BuildMobileDashboard.php) — builds the compact read-only member-app payload.
- [app/Http/Controllers/Api/v1/MemberAuthController.php](/app/Http/Controllers/Api/v1/MemberAuthController.php) · [app/Http/Transformers/v1/MemberAuthTransformer.php](/app/Http/Transformers/v1/MemberAuthTransformer.php) — passwordless challenge, JWT exchange, refresh, and sign-out responses.
- [app/Http/Controllers/Api/v1/MobileDashboardController.php](/app/Http/Controllers/Api/v1/MobileDashboardController.php) · [app/Http/Transformers/v1/MobileDashboardTransformer.php](/app/Http/Transformers/v1/MobileDashboardTransformer.php) — verification gate and versioned JSON response.

- [app/Http/Controllers/Main/DashboardController.php](/app/Http/Controllers/Main/DashboardController.php) — builds `isMember` / `memberships` / `events`; `upcomingEvents()` + `eventCard()` + `lockReason()` do the gating and withhold gated detail.
- [app/Http/Controllers/Concerns/ReadsMemberAgenda.php](/app/Http/Controllers/Concerns/ReadsMemberAgenda.php) — `upcomingSessions()`, `onboarding()` and `memberAttention()`, shared by the portal and the app so the Action Centre is one list.
- [src/Event/Event.php](/src/Event/Event.php) — `scopeUpcoming()` + `isAccessibleTo()` + `VISIBILITY_*` (shared with the Manage events module).

**Frontend (Vue)**
- [resources/js/Pages/Dashboard.vue](/resources/js/Pages/Dashboard.vue) — member vs non-member switch; passes `events` down.
- [resources/js/Components/Dashboard/MemberDashboard.vue](/resources/js/Components/Dashboard/MemberDashboard.vue) · [NonMemberDashboard.vue](/resources/js/Components/Dashboard/NonMemberDashboard.vue) — the two faces.
- [resources/js/Components/Dashboard/IdentityBand.vue](/resources/js/Components/Dashboard/IdentityBand.vue) — the masthead band (photo, scrim, greeting); mounts the ribbons.
- [resources/js/Components/Portal/AuroraRibbons.vue](/resources/js/Components/Portal/AuroraRibbons.vue) · [AuroraBackdrop.vue](/resources/js/Components/Portal/AuroraBackdrop.vue) — the one aurora, and its backdrop placing for the five other sections (mounted by `Layouts/AppLayout.vue`).
- [resources/js/Components/Dashboard/UpcomingEvents.vue](/resources/js/Components/Dashboard/UpcomingEvents.vue) — the shared upcoming-events list (full card vs locked "Exclusive to {Membership} members" preview).

**Routes**
- [routes/api/v1.php](/routes/api/v1.php) — the `member-api/v1` group (protected member mobile API routes).

- [routes/main.php](/routes/main.php) — `main.dashboard` (`GET /dashboard`, `auth`).

## Related modules
- [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) — where the sessions + their per-slot members-only gating are created.
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — how the signed-in user's membership(s) are read.
- [Memberships](/docs/modules_handbook/manage/membership/memberships/readMe.md) — the tiers an event can be gated to.
