# How to Use PropertyLab (Main · User Portal)

**Portal:** Main · **Route:** `main.portal.courses.index` (`/property/academy?tab=how-to`) ·
**Nav:** Learning Hub → the **How to Use PropertyLab** tab — FIRST since 2026-09-03, left of
[DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md), because the portal comes before the
method: a member cannot walk the road until they can work the screens it sends them to. A bare
`/property/academy` therefore opens this tab ·
**Gated by:** nothing — reading is always open

## What it does

The road teaches the **method**; this tab teaches the **portal**: what each screen is, what to click
first, what to fill, and why it is laid out that way. One lesson per screen, read one point at a
time, with the REAL screen beside the words and the element under discussion spotlit.

---

## THE RULE (founder, 2026-09-03)

> *"which follow exactly my UI/UX, make it as rule for this guide on how to use propertylab. why you
> create your own UI/UX — imagine you are user, u read this, here say go this UI, then when goto
> actual one, dun have, how to explain to user?"*

**A lesson may only show what the product actually has.** Not a plausible screen, not a tidier
version, not a better example — the screen, with its own data, including the parts that are missing.
It came from a first version of the Analyze Property lessons that mounted the real components with
INVENTED data: a made-up project, made-up counts, a card with four green verdicts. It read beautifully
and every member who followed it would have found something else on the real page.

The rule has five parts, and **all five are machine-checked** in
[`portalGuide.test.js`](/resources/js/utils/portalGuide/portalGuide.test.js) — none of them is a
matter of care or memory:

| # | The rule | The check | What it caught |
|---|---|---|---|
| 1 | **The picture is the shipping component**, mounted — never a screenshot, never a copy. | `PREVIEWS[*].loader` imports the real file; the suite mounts every one. | A screenshot is wrong the first time somebody moves a button, and nothing tells us. |
| 2 | **That component must be reachable from a page a controller renders**, without counting the guide's own imports. | The suite walks the import graph from every `Inertia::render('…')` name in `app/`, skipping `utils/portalGuide` + `Components/PortalGuide`. | The first project-page lesson taught `Site/Partials/ProjectDetailOverview.vue` — real code, imported by nothing, dead since July, while the live page mounts `Components/ProjectDetail/OverviewTab.vue`. It mounted fine. It was still a screen no member has ever seen. |
| 3 | **Every element a sentence points at exists in that DOM.** | Each point's `data-guide` anchor must resolve in the mounted component. | A renamed block silently turning a lesson into "look at the thing that is no longer there". |
| 4 | **The data is captured from the running app**, never typed. | Every `PREVIEWS` entry declares `source`; the fixtures declare `CAPTURED_AT` + `SOURCES`; a capture older than `STALE_AFTER_DAYS` (180) fails. | Invented numbers — the fault above. |
| 5 | **A human has re-read the lesson since anything it RENDERS changed.** | `reviewedAt` against the mtime of the named component **and every `.vue` it transitively imports**. | Hours after the lessons shipped, another session fixed a caption inside `ProjectInvestmentSummary.vue` — a child of the taught component — that the Investment lesson quoted word for word. Watching only the named file, the suite stayed green while two sentences had just become false. The guard cannot judge whether words are still true; it forces someone to look. |

**The one exception, and it is narrow:** a screen whose content the MEMBER types — the Wealth
Planning canvas, the road's own answer cards — has no server payload to capture, so it is mounted
with the road's cast (Ryan, [`utils/road/cast.js`](/resources/js/utils/road/cast.js)), the same person
every station already uses. *Our data is captured; the member's data is Ryan's.* Everything the
CATALOGUE knows in those screens (the 「带进 I 卡」 banner's payload, for instance) is still captured.
Ryan is not typed either: `demoState.js` runs his Improve card through the road's own
`improveComputed()`, so the price, loan, tenure and instalment a lesson quotes are the numbers the
I card prints — and the blank second property row is literally what `nextPropertyDefaults()` builds.

**PPP is the exception's exception, and it is stated rather than smuggled.** Ryan cannot walk that
strategy — he is a Malaysian employee with RM 40,000 in the bank, and PPP begins by paying for a
property in cash — so its four lessons are mounted with the founder's own **Grade A pair**
([`utils/wealthPlan/pppPresets.js`](/resources/js/utils/wealthPlan/pppPresets.js): Viia Residence and
Binastra Cochrane, real projects at real prices) plus the PPP canvas's own defaults. Exactly two
numbers belong to a person rather than to the product — an **age** (42, because 70 minus the age is
what caps the tenure) and the **margin** a foreign buyer is commonly offered (50%) — and the lesson
that shows each one says so on screen. `PPP_TYPED` in the registry names that source.

**What the rule costs, on purpose.** The lessons now teach a card whose three badges are all
「资料不足」, a project with no rent estimate, and an area whose prices are falling 8.94% a year —
because that is Binastra Cochrane. The founder's own example project is not a showcase, and a guide
that only teaches the showcase is the guide this rule replaced.

---

## The lessons

Sections are a quiet segmented control, never a second tab strip:
**Wealth Planning** · **Analyze Property** · **其他工具**.

### A section may FORK, and Wealth Planning does (2026-09-03)

A plan is created ON a strategy and never changed after, and the two strategies are two separate
pages — so the section's eleven chapters are **grouped**, not listed:

| Group | For whom | Chapters |
|---|---|---|
| 先选一条路 | everyone, first | `wp-strategy` |
| RRR · 楼叠楼 | Malaysian citizen / PR | `wp-you` → `wp-goal` → `wp-properties` → `wp-projection` → `wp-loan` → `wp-reality` |
| PPP · 楼养楼 | overseas buyers, cash-rich | `ppp-you` → `ppp-pair` → `ppp-four` → `ppp-loan` |

`GROUPS` in `lessons.js` names them; a lesson carries `group`. The grouping is not decoration — it
changes one behaviour: **a chapter continues into the next chapter of its OWN group only.** Finishing
RRR's Step 6 must not walk a Malaysian into the PPP chapters, which are a different product for a
different buyer. A section with no groups (Analyze Property, 其他工具) renders and continues exactly
as it did before. Pinned by `HowToTab.test.js`, which reads the ungrouped `analyze` section.

Two facts about PPP the lessons state out loud, because a member meets them and nothing else explains
them: **step 2 (Goal) is literally the same screen as RRR's**, so the PPP chapters skip it and say so;
and **steps 5 and 6 do not exist for a foreigner** — the canvas hides them, because a Malaysian DSR
cannot be run on income earned elsewhere. `ppp-loan` therefore teaches step 5 with the passport
swapped (a Malaysian building PPP) and opens by saying who can see the screen at all.

| Lesson | Screen it mounts | Data |
|---|---|---|
| `wp-strategy` · 先选路：RRR 还是 PPP | `WealthPlanning/Partials/StrategyChooser.vue` | the product's own copy |
| `wp-you` · Step 1 · You | `WealthPlanning/Partials/StepYou.vue` | Ryan (member-typed) |
| `wp-goal` · Step 2 · Goal | `WealthPlanning/Partials/StepGoal.vue` | Ryan |
| `wp-properties` · Step 3 · Properties | `WealthPlanning/Partials/StepProperties.vue` | Ryan's I-card unit + a blank second row |
| `wp-projection` · Step 4 · Projection | `WealthPlanning/Partials/StepProjection.vue` | Ryan's whole plan |
| `wp-loan` · Step 5 · Loan check | `WealthPlanning/Partials/StepLoanCheck.vue` | Ryan, no saved WhoPay report |
| `wp-reality` · Step 6 · Reality | `WealthPlanning/Partials/StepReality.vue` | Ryan's whole plan |
| `ppp-you` · Step 1 · 你从哪里买 | `Partials/StepYou.vue` (`showResidency`) | Grade A buyer, 42, foreigner |
| `ppp-pair` · Step 3 · 两间房，两个角色 | `Partials/Ppp/PppStepPair.vue` | Grade A pair |
| `ppp-four` · Step 4 · 这一对成不成立 | `Partials/Ppp/PppStepFour.vue` (`full`) | Grade A pair, `analysePpp` |
| `ppp-loan` · Step 5 · 什么时候签 | `Partials/Ppp/PppStepLoan.vue` | the same pair, passport swapped |
| `ap-listing` · New Project：这一屏怎么用 | `Site/Partials/RoadFilterStrip.vue` | captured listing |
| `ap-card` · 一张卡片怎么读 | `Site/Partials/ProjectCard.vue` | Binastra Cochrane + one passing card |
| `ap-overview` · 项目页 · Overview | `Components/ProjectDetail/OverviewTab.vue` | captured detail |
| `ap-units` · Units · Price | `Components/ProjectDetail/ProjectUnitsPriceTab.vue` | captured detail |
| `ap-invest` · Investment Analysis | `Components/ProjectDetail/ProjectInvestmentAnalysis.vue` | captured saved analysis |
| `ap-projection` · Financial Projection | `Components/ProjectDetail/FinancialProjectionTab.vue` | captured plan price + the layout rent (`layout_rent`, derived from the captured analysis by the page's own rule — the same figure `ap-units` and `ap-invest` show) |
| `ap-developer` · Developer Record | `Components/ProjectDetail/DeveloperTab.vue` | captured detail |
| `ap-amenities` · Amenities | `Components/ProjectDetail/AmenitiesPanel.vue` | captured analysis + locationInsight |
| `ap-supply` · New Supply | `Components/ProjectDetail/NewSupplyPanel.vue` | captured `…/supply` |
| the rest of `lessons.js` (其他工具) | — | `soon: true` — a dimmed row with a **Soon** tag |

**The section covers Analyze Property's OWN screens, and stops there: nine lessons, no `soon` rows
left in it.** Two more were written on 2026-09-03 and removed the same day at the founder's word —
`ap-area-guide` (the Learning Hub's Area Guide tab) and `ap-to-card` (the road's I card). Both taught
screens that live in another section, and a section that wanders into its neighbours teaches a member
the wrong map of the product. They are in git if they are ever wanted back; their previews, the road
journey fixture (`demoRoad.js`) and the `ProjectPrefill` capture went with them.

The Analyze lessons run in the order a member meets the screens: the list → one card → the project
page's own tabs, left to right → the answer card they end in. A named-but-unwritten lesson is listed,
not hidden: a member sees what is coming, and we see what we still owe.

**The section says out loud that the tool is shut.** Analyze Property is admin-only while it is
reworked, and a Day-3 unlock for a five-day trainee
([the lock](/docs/modules_handbook/main/analyze-property/readMe.md)). Reading is never gated, so the
lessons are written — but the section's blurb says the page will not open yet. A member who reads
nine lessons and then meets an 「我们正在升级」 screen was misled by our silence, not by the lock.

**One lesson deliberately teaches a contradiction.** The Amenities panel's narrative can call the
same unit *priced above the area median* while Investment Analysis calls it *below market* — two
different references (a spending-power picture of the whole area vs. a few dozen comparable
transactions). `ap-amenities` names the disagreement instead of hiding it, and sends the reader to
Market Value, the one place the comparison can be checked by hand.

## How it works

- **A lesson is data**, in [`utils/portalGuide/lessons.js`](/resources/js/utils/portalGuide/lessons.js):
  `{ key, section, title, blurb, minutes, component, reviewedAt, href, points[], terms[], doNext }`.
  A **point** is the road's own shape (`title` + one teaching `line` + optional `calc` steps) with
  `preview` in place of the road's `viz`: `{ key, anchor, caption, props? }`.
- **Rendered by** [`Lms/Partials/HowTo/HowToTab.vue`](/resources/js/Pages/Main/Portal/Lms/Partials/HowTo/HowToTab.vue)
  through the road's [`Components/Road/PointStepper.vue`](/resources/js/Components/Road/PointStepper.vue) —
  words left, picture right, ONE forward button last, the 「现在去做」 link and the term chips in the
  stepper's `#below` slot. Progress is per-reader in `localStorage` (`portalGuide.seen`).
- **Finishing a chapter CONTINUES into the next one** (founder, 2026-09-03: *"when one chapter finish,
  when click next should not go back to the 课程列表 but continue to next chapter"*). The last forward
  button names it — 「下一课：一张卡片怎么读」 — so continuing is never a surprise, and the reader lands
  on the next chapter's first point with the page scrolled back to the top. Only a section's LAST
  chapter returns to the list. Pinned in
  [`HowToTab.test.js`](/resources/js/Pages/Main/Portal/Lms/Partials/HowTo/HowToTab.test.js).
- **Every chapter — and every point in it — has its own link.** `?tab=how-to&lesson=ap-card&point=4`,
  written with `history.replaceState` exactly the way `ShowTabs` writes `?tab=` (same URL, no server
  round-trip, no history entry per click, other params untouched) and read back on mount and on
  `popstate`. The section is taken FROM the lesson, so a link opens the right pivot too; closing a
  lesson removes both params, and so does leaving the tab — otherwise a stale link would reopen a
  lesson nobody asked for.
- **The chapter rail lives INSIDE a chapter** (founder: *"once i go into specific chapter … i wanna
  jump to specific chapter, i can click from there but no need to go back to initial list"*): a
  sticky 228px list of the section's chapters on the left, with ticks for what is read, the current
  one highlighted and `Soon` rows listed but disabled. Below `lg` there is no room for a rail, so the
  same list hides behind one 「全部 N 课」 button rather than disappearing.
- **The way OUT is one shape, portal-wide** — [`utils/portalNav.js`](/resources/js/utils/portalNav.js)
  `NAV_SECONDARY`: a bordered white pill, arrow first, with the destination NAMED (「全部课程」, not
  「返回」). The founder could not tell the guide's bare text link was a button, *"and this apply to
  all like the wealth planning too"* — the road had the right shape all along, as a private constant
  in `Road/Stage.vue`, while every other screen invented a thinner version of it. That constant moved
  into `utils/`; the road, this tab and the Wealth Planning canvas import it.
- **A term defines itself IN the page.** The chips under 「这一屏的名词」 open the meaning inline,
  underneath the row, pushing the page down. They are NOT the road's `<Term>` popover — that floats,
  and at the bottom of a long lesson it is easy to miss and easy to clip — and nothing there is a
  link, so nothing can send a reader to another tab to find out what 交楼 means. Same guard file.
- ⚠️ **A taught screen must size itself against ITS OWN width, not the viewport's.** The frame is
  ~500px wide on a 1600px screen, so every `sm:` / `lg:` / `xl:` rule inside the mounted component
  fires and crushes it — the founder read the Step 2 preview on 2026-09-03 and its two-field row was
  four 100px columns of wrapped labels. The fix is not in the guide: the Wealth Planning steps now
  carry `@container` on their own root and use `@`-variants (`sm`→`@2xl`, `lg`→`@3xl`/`@4xl`,
  `xl`→`@4xl`), which is also what they needed in the app — the canvas, the guided interview and the
  report walkthrough are three different widths, and the last two teleport to `body`, so AppShell's
  `@container` is not even an ancestor there. **Before teaching a new screen, check it in a narrow
  frame**; if it collapses, fix the component, not the lesson.
- **A lesson therefore never says 「左边」/「右边」 about a component's own layout** — the same screen
  is two columns in the app and one in the frame. Name the thing instead (「淡蓝色那张卡」,
  「第一栏」).
- **The picture column** is [`Components/PortalGuide/LivePreview.vue`](/resources/js/Components/PortalGuide/LivePreview.vue):
  it lazily imports the component named by `preview.key`, mounts it with the captured payload, and
  spotlights the anchor with one absolutely-positioned ring whose huge `box-shadow` dims everything
  else. The frame is inert (`pointer-events: none`) — typing into a preview would be typing into
  nothing, which is worse than a picture; the real page is one button away, and that button is the
  point of the lesson.
  - ⚠️ **An inert frame can only show a screen's DEFAULT state.** A tab inside a tab (Units · Price's
    two halves, Investment Analysis's four) cannot be switched by the reader, so a lesson teaches the
    default view and *points at the button* for the others. Do not add a prop to a shipping component
    just to let the guide open a different sub-tab.
- **Which component, and with what** — [`registry.js`](/resources/js/utils/portalGuide/registry.js).
  Components are imported lazily so the Learning Hub does not pull a project page into its bundle for
  a tab most readers never open. `propsFor(key, overrides)` is **async** (the saved analysis is 325 KB
  and loads on demand) and merges a step's own overrides one level deep, so a screen can be taught in
  the state the point needs — a card with no data beside a card that passes.

### The fixtures, and re-capturing them

Everything is in [`fixtures/index.js`](/resources/js/utils/portalGuide/fixtures/index.js): what was
captured, from which route, and the ONE redaction (`build_team.agents` — real agents' names and
mobile numbers, emptied; on the live page that is one member's API response, in a committed fixture
it would be a contact list shipped to every reader forever).

```bash
php artisan tinker --execute="require 'scripts/capture-portal-guide-fixtures.php';"
```

That script — [`scripts/capture-portal-guide-fixtures.php`](/scripts/capture-portal-guide-fixtures.php) —
drives the REAL controllers through the HTTP kernel as an admin, inside a transaction it always rolls
back, and rewrites all five fixture files. It picks the "a card that passes" example **by rule** (the
first cash-flow pass in the unfiltered list), not by name, so a re-capture finds today's example.
Then: bump `CAPTURED_AT`, run the suite, and read the lessons beside the real screens before bumping
each lesson's `reviewedAt`.

**Never hand-edit a fixture.** A number nobody captured is exactly what the rule forbids.

**When guard 5 goes red, the ritual is short and it is not optional:** open the real screen, read the
lesson's points beside it, fix whatever moved, then bump `reviewedAt`. Bumping the date without
opening the screen is the one way to defeat every guard on this page at once.

### Writing a lesson

1. Open the real screen and read it. If you cannot open it, you cannot write the lesson.
2. Add stable `data-guide` anchors to the component being taught (they ship — two dozen bytes, and
   they are what the guard holds on to). **Put the anchor INSIDE the component**, not on its tag:
   a component with more than one root node cannot inherit an attribute (Vue drops it silently —
   `UnitsTab` did exactly this).
3. Register it in `registry.js` with a `source` and captured props.
   - **If the screen lives inside a page rather than in a component of its own, extract it first.**
     The strategy chooser was markup inside `Index.vue`'s modal, and a modal cannot be mounted open
     with props — so it became `Partials/StrategyChooser.vue`, which the modal now hosts. Extracting
     is the honest fix; drawing a copy of the screen is the fault this guide exists to prevent.
     (`strategyChooser.test.js` reads that file for its recommendation sentences.)
4. Write the lesson in `lessons.js` following the six-part rhythm every lesson keeps, so a reader who
   learns it on lesson one can skim lesson nine: **你会看到什么 → 先点哪里、先填什么 → 为什么这样设计
   → 名词 → 例子 → 现在去做**. Quote numbers by reading them off the fixture (`FIX.…`), never by
   typing them.
5. `npx vitest run resources/js/utils/portalGuide`, then read the lesson beside the real screen.

The road's shape rules apply too ([content-rules.md](/docs/modules_handbook/main/dmaic-road/content-rules.md)
13 · 16 · 17): a title ≤ 14 wide, a paragraph that actually teaches, terms that exist in the road's
registry, no chained arithmetic inside a sentence (use `calc`), and never the words 书 / 课程.

The previews mount portal components, some of which reach for the Inertia runtime (`<Link>`,
`usePage()` behind `useSite`, `useForm`). The test stubs `@inertiajs/vue3` the way the road's card
tests do — the component's real template still renders, which is all these guards read.

## Related files

**Frontend**
- [Lms/Partials/HowTo/HowToTab.vue](/resources/js/Pages/Main/Portal/Lms/Partials/HowTo/HowToTab.vue) — the tab: section pivot, lesson list, one lesson.
- [Components/PortalGuide/LivePreview.vue](/resources/js/Components/PortalGuide/LivePreview.vue) — the real screen, spotlit.
- [utils/portalGuide/lessons.js](/resources/js/utils/portalGuide/lessons.js) · [registry.js](/resources/js/utils/portalGuide/registry.js) · [demoState.js](/resources/js/utils/portalGuide/demoState.js) (every member-typed screen: Ryan's RRR plan, derived from the road's cast, and the PPP pair) · [fixtures/](/resources/js/utils/portalGuide/fixtures/index.js) (everything else).
- [utils/portalGuide/portalGuide.test.js](/resources/js/utils/portalGuide/portalGuide.test.js) — the four guards above.
- Components taught so far, all reachable from a rendered page: [StrategyChooser.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StrategyChooser.vue) · [StepYou.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepYou.vue) · [StepGoal.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepGoal.vue) · [StepProperties.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepProperties.vue) · [StepProjection.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepProjection.vue) · [StepLoanCheck.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepLoanCheck.vue) · [StepReality.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/StepReality.vue) · [PppStepPair.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepPair.vue) · [PppStepFour.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepFour.vue) · [PppStepLoan.vue](/resources/js/Pages/Main/Portal/WealthPlanning/Partials/Ppp/PppStepLoan.vue) · [RoadFilterStrip.vue](/resources/js/Pages/Main/Site/Partials/RoadFilterStrip.vue) · [ProjectCard.vue](/resources/js/Pages/Main/Site/Partials/ProjectCard.vue) · [OverviewTab.vue](/resources/js/Components/ProjectDetail/OverviewTab.vue) · [ProjectUnitsPriceTab.vue](/resources/js/Components/ProjectDetail/ProjectUnitsPriceTab.vue) · [ProjectInvestmentAnalysis.vue](/resources/js/Components/ProjectDetail/ProjectInvestmentAnalysis.vue) · [FinancialProjectionTab.vue](/resources/js/Components/ProjectDetail/FinancialProjectionTab.vue) · [DeveloperTab.vue](/resources/js/Components/ProjectDetail/DeveloperTab.vue) · [ImproveCard.vue](/resources/js/Pages/Main/Portal/Road/Partials/Cards/ImproveCard.vue).

**Backend** — one script, no runtime: [scripts/capture-portal-guide-fixtures.php](/scripts/capture-portal-guide-fixtures.php).
The Learning Hub page ([`CoursesController@index`](/app/Http/Controllers/Main/Portal/CoursesController.php))
mounts the tab and sends it nothing; per-reader progress lives in `localStorage`. A server-side table
lands with the rest of the lessons, so Manage can see who read what.

**Known orphans this work exposed** (not deleted — flagged): `Pages/Main/Site/Partials/ProjectDetailOverview.vue`,
`ProjectDetailHero.vue` and `ProjectDetailTabs.vue` are imported by nothing. They are the pre-July
project page; the live one is `Components/ProjectDetail/*`. Guard 2 will keep any future lesson from
teaching them.

**Related modules** — [Courses / LMS](/docs/modules_handbook/manage/lms/readMe.md) (the tab's host) ·
[DMAIC 之路](/docs/modules_handbook/main/dmaic-road/readMe.md) (the method, the cast and the term
registry) · [Wealth Planning](/docs/modules_handbook/main/wealth-planning/readMe.md) ·
[Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md).
