"""The catalogue publication playbook.

Every number in here was read off the live database on 2026-08-09 against the
default working set (KL / Penang / Pulau Pinang / Johor / Selangor, condo,
apartment and serviced), so the deck can be re-generated when the numbers move.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from kit import *
from tabs import TAB_SPECS, TAB_MATRIX

TOTAL = 30
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..",
                   "Project-Catalogue-Publication-Playbook.pptx")

prs = deck()

def page():
    """The current slide's 1-based number.

    Derived from the deck rather than counted, so calling it twice on one slide
    (once for the header, once at the end of the block) cannot inflate it.
    """
    return len(prs.slides._sldIdLst)

# ============================================================ 01 title
s = blank(prs, NAVY_DEEP)
rect(s, 0, 0, Pt(6), H, fill=GOLD)
rect(s, W - Inches(4.6), 0, Inches(4.6), H, fill=RGBColor(0x18, 0x2E, 0x4C))
box(s, M, Inches(1.62), Inches(8.0), Inches(0.3), "Internal playbook  ·  v1  ·  August 2026",
    size=11, color=GOLD, bold=True, caps=True)
box(s, M, Inches(2.10), Inches(7.9), Inches(2.0),
    "Publishing the\nProject Catalogue", size=48, color=PAPER, font=DISPLAY,
    bold=True, spacing=1.04)
rule(s, M, Inches(4.10), Inches(2.2), color=GOLD, weight=2.0)
box(s, M, Inches(4.42), Inches(7.4), Inches(1.2),
    "How a scraped record becomes a page a buyer can trust — the four-signature "
    "process, the standard for every tab, and the eight-week plan to clear the queue.",
    size=15.5, color=RGBColor(0xC3, 0xD0, 0xDF), spacing=1.36)
for i, (v, l) in enumerate([("955", "in scope"), ("411", "ready to review"),
                            ("0", "published today")]):
    x = M + Inches(2.55) * i
    box(s, x, Inches(5.78), Inches(2.3), Inches(0.5), v, size=30,
        color=PAPER, font=DISPLAY, bold=True)
    box(s, x, Inches(6.28), Inches(2.3), Inches(0.3), l, size=10.5,
        color=RGBColor(0x8E, 0xA4, 0xBD), caps=True, bold=True)
box(s, W - Inches(4.20), Inches(2.30), Inches(3.2), Inches(0.3),
    "Who this is for", size=10.5, color=GOLD, bold=True, caps=True)
for i, (nm, role) in enumerate([("Ke Xin", "submit · review"), ("Shawn", "submit · review"),
                                ("Boon", "submit · review"), ("Zen", "approve"),
                                ("Dylan", "approve")]):
    y = Inches(2.76) + Inches(0.46) * i
    box(s, W - Inches(4.20), y, Inches(1.5), Inches(0.3), nm, size=15,
        color=PAPER, bold=True)
    box(s, W - Inches(2.62), y + Inches(0.04), Inches(1.7), Inches(0.3), role,
        size=10.5, color=RGBColor(0x8E, 0xA4, 0xBD), font=MONO)
page()

# ============================================================ 02 the stakes
s = blank(prs)
y = header(s, "Why the process exists", "A published record is a page a stranger acts on", page(), TOTAL)
cy = para(s, M, y, CW * 0.60,
    "Everything in the catalogue arrived from a scraper. It is evidence, not fact: "
    "portals disagree, mistype, list one development twice, and leave the fields we "
    "care about empty. Publishing turns that into a page a buyer reads before "
    "committing several hundred thousand ringgit.",
    size=15, color=BODY, spacing=1.40)
bullets(s, M, cy + Inches(0.34), CW * 0.60, [
    ("Nobody publishes alone.", "Four names on every release — one to submit, two to read cold, one to approve."),
    ("The record is the product.", "Six of the eight tabs are computed from fields we fill in. Wrong field, wrong number, silently."),
    ("Unpublishing is fast, un-embarrassing is not.", "Assume a client has already screenshotted the page."),
], size=14)
rect(s, M + CW * 0.645, y, CW * 0.355, Inches(3.62), fill=WASH)
box(s, M + CW * 0.645 + Inches(0.28), y + Inches(0.26), CW * 0.30, Inches(0.3),
    "The queue today", size=10.5, color=NAVY, bold=True, caps=True)
rows = [("In scope (4 states, condo)", "955"), ("Publication-complete", "411"),
        ("Have at least one layout", "431"), ("Fully analysable", "371"),
        ("Missing layouts entirely", "524"), ("Published", "0")]
cy = y + Inches(0.68)
for i, (k, v) in enumerate(rows):
    tone = STOP if v in ("0",) else (WARN if k.startswith("Missing") else INK)
    box(s, M + CW * 0.645 + Inches(0.28), cy, CW * 0.20, Inches(0.28), k, size=12, color=BODY)
    box(s, M + CW * 0.645 + Inches(0.28), cy, CW * 0.30, Inches(0.28), v, size=12.5,
        color=tone, bold=True, font=MONO, align=PP_ALIGN.RIGHT)
    cy += Inches(0.34)
    if i < len(rows) - 1:
        rule(s, M + CW * 0.645 + Inches(0.28), cy - Inches(0.07), CW * 0.30)
box(s, M + CW * 0.645 + Inches(0.28), cy + Inches(0.10), CW * 0.30, Inches(0.6),
    "524 records cannot be reviewed at all until someone sources their floor "
    "plans. That is a different job from reviewing — plan it separately.",
    size=10.5, color=MUTED, spacing=1.30)
page()

# ============================================================ 03 pipeline
s = blank(prs)
y = header(s, "The shape of the work", "One record, five gates", page(), TOTAL)
stages = [
    ("1", "MERGE", "Fold duplicates\ntogether", "Anyone", NAVY),
    ("2", "VALIDATE", "Fix every column\n+ every layout", "Initiator", NAVY),
    ("3", "SUBMIT", "Pick 2 reviewers\n+ 1 approver", "Initiator", NAVY),
    ("4", "COLD-EYE ×2", "Both must sign\nindependently", "Reviewers", GOLD),
    ("5", "APPROVE", "Release to\nthe public site", "Zen / Dylan", GO),
]
bw = (CW - Inches(0.5) * 4) / 5
for i, (num, name, what, who, tone) in enumerate(stages):
    x = M + (bw + Inches(0.5)) * i
    rect(s, x, y + Inches(0.30), bw, Inches(2.05), fill=WASH)
    rect(s, x, y + Inches(0.30), bw, Pt(3.5), fill=tone)
    box(s, x + Inches(0.20), y + Inches(0.50), bw, Inches(0.3), num, size=11,
        color=tone, font=MONO, bold=True)
    box(s, x + Inches(0.20), y + Inches(0.82), bw - Inches(0.3), Inches(0.3),
        name, size=14.5, color=INK, bold=True)
    box(s, x + Inches(0.20), y + Inches(1.18), bw - Inches(0.3), Inches(0.7),
        what, size=11.5, color=BODY, spacing=1.24)
    box(s, x + Inches(0.20), y + Inches(1.96), bw - Inches(0.3), Inches(0.3),
        who, size=10, color=MUTED, caps=True, bold=True)
    if i < 4:
        box(s, x + bw + Inches(0.10), y + Inches(1.10), Inches(0.3), Inches(0.3),
            "▶", size=13, color=HAIRLINE)
rule(s, M, y + Inches(2.72), CW)
lh = para(s, M, y + Inches(2.94), CW * 0.47, "Enforced, not advisory",
    size=12.5, color=NAVY, bold=True, caps=True, spacing=1.20)
bullets(s, M, lh + Inches(0.16), CW * 0.47, [
    "You cannot review or approve your own submission — the form rejects it.",
    "The approver's button does not appear until BOTH reviewers have signed.",
    "Any reviewer can send it back; that reopens it for the initiator.",
], size=13)
para(s, M + CW * 0.53, y + Inches(2.94), CW * 0.47,
    "Where a decision gets written down", size=12.5, color=NAVY, bold=True, caps=True, spacing=1.20)
bullets(s, M + CW * 0.53, lh + Inches(0.16), CW * 0.47, [
    ("Submit note —", "what you changed and what you were unsure about."),
    ("Reviewer note —", "what you checked, and what you want changed."),
    ("Both are permanent", "and attached to the record. Write them for the next person."),
], size=13)
page()

# ============================================================ 04 rule zero
s = blank(prs)
y = header(s, "Before anything else", "Rule zero: sign in as yourself", page(), TOTAL)
cy = para(s, M, y, CW * 0.55,
    "Every edit, every signature and every release is stamped with the account that "
    "made it. A shared login makes the audit trail worthless and makes the review "
    "KPI impossible to score — there is no way to tell whose work it was.",
    size=15, color=BODY, spacing=1.40)
box(s, M, cy + Inches(0.34), CW * 0.55, Inches(0.3), "If you cannot sign in",
    size=11, color=NAVY, bold=True, caps=True)
bullets(s, M, cy + Inches(0.70), CW * 0.55, [
    "Tell Wai Kit — do not borrow a colleague's session.",
    "Your name must be on the panel before you can be picked as a reviewer.",
    "Panel membership is managed in the app; it does not need a deploy.",
], size=13.5)
panel = rect(s, M + CW * 0.60, y, CW * 0.40, Inches(3.55), fill=WASH)
box(s, M + CW * 0.60 + Inches(0.3), y + Inches(0.28), CW * 0.34, Inches(0.3),
    "The panel, as configured", size=10.5, color=NAVY, bold=True, caps=True)
cy = y + Inches(0.74)
for nm, role, tone, wash in [("Ke Xin", "Reviewer", NAVY, WASH), ("Shawn", "Reviewer", NAVY, WASH),
                             ("Boon", "Reviewer", NAVY, WASH), ("Zen", "Approver", GO, GO_WASH),
                             ("Dylan", "Approver", GO, GO_WASH)]:
    box(s, M + CW * 0.60 + Inches(0.3), cy, Inches(1.6), Inches(0.3), nm, size=15,
        color=INK, bold=True)
    chip(s, M + CW * 0.60 + Inches(2.0), cy + Inches(0.04), role, wash, tone)
    cy += Inches(0.52)
note = ("A reviewer submits and reads cold. An approver only releases — "
        "and never reviews the same record.")
nh = text_height(note, CW * 0.34, 10.5, 1.30)
box(s, M + CW * 0.60 + Inches(0.3), cy + Inches(0.10), CW * 0.34, nh, note,
    size=10.5, color=MUTED, spacing=1.30)
panel.height = int(cy + Inches(0.10) + nh + Inches(0.26) - y)
page()

# ============================================================ 05 divider
divider(prs, "Stage one", "Merge before you touch anything",
        "A development held twice is two half-records. Fix the shape of the data "
        "before investing a review in it.", page(), TOTAL)

# ============================================================ 06 merge
s = blank(prs)
y = header(s, "Stage 1 · Duplicates to merge", "Merge first — and here is why first", page(), TOTAL)
cy = para(s, M, y, CW * 0.52,
    "Merging moves one record's sources onto another and then DELETES it. Do that after "
    "publishing and you delete a live page — the URL dies and any link a client was sent "
    "breaks. A merge is also how a half-record becomes reviewable: one side often carries "
    "the layouts, the other the hero image.",
    size=14, color=BODY, spacing=1.34)
box(s, M, cy + Inches(0.30), CW * 0.52, Inches(0.3), "What you are judging",
    size=11, color=NAVY, bold=True, caps=True)
bullets(s, M, cy + Inches(0.66), CW * 0.52, [
    ("Same development?", "Name and location agree — the table now prints each record's own city and state, amber when they disagree."),
    ("Which one survives?", "Keep the record with more evidence: more layouts, a hero image, more feeds."),
    ("Anything pinned?", "A pinned field blocks the merge. If the pin repeats the survivor's value, clear it."),
], size=12.5, gap=Inches(0.11))
stat(s, M + CW * 0.565, y, CW * 0.205, "133", "clusters in scope", "of 202 in total", NAVY)
stat(s, M + CW * 0.795, y, CW * 0.205, "171", "records queued", "to be absorbed", NAVY)
cy = y + Inches(1.52)
box(s, M + CW * 0.565, cy, CW * 0.435, Inches(0.3), "Of those 171", size=11,
    color=NAVY, bold=True, caps=True)
for i, (label, cnt, tone, wash) in enumerate([
        ("Merge now — nothing in the way", "64", GO, GO_WASH),
        ("Blocked by a pin that is safe to clear", "86", WARN, WARN_WASH),
        ("Needs a human decision", "21", STOP, STOP_WASH)]):
    yy = cy + Inches(0.42) + Inches(0.60) * i
    rect(s, M + CW * 0.565, yy, CW * 0.435, Inches(0.48), fill=wash)
    box(s, M + CW * 0.565 + Inches(0.18), yy + Inches(0.11), CW * 0.30, Inches(0.3),
        label, size=12, color=INK)
    box(s, M + CW * 0.565, yy + Inches(0.08), CW * 0.415, Inches(0.3), cnt,
        size=16, color=tone, bold=True, font=MONO, align=PP_ALIGN.RIGHT)
box(s, M + CW * 0.565, cy + Inches(2.28), CW * 0.435, Inches(0.5),
    "The 21 are mostly one question: does the catalogue name the project SPV or the "
    "parent brand? Settle it once and most of them resolve.",
    size=10.5, color=MUTED, spacing=1.30)
page()

# ============================================================ 07 validate columns
s = blank(prs)
y = header(s, "Stage 1 · Validate the record", "Read every column before you trust it", page(), TOTAL)
cy = para(s, M, y, CW,
    "The provider tabs show the value WE hold, with the feed's own version in grey underneath "
    "when the two disagree. Grey is not an error — it is the scraper's guess, kept so you can "
    "re-check it after the next sync.",
    size=14, color=BODY, spacing=1.32)
cols = ["Column", "What to check", "If it is wrong"]
rows = [
    ("Project name", "The developer's own name for it, not the portal's SEO title", "Duplicate detection stops working"),
    ("Location", "City AND state; the city is the identifying half", "New Supply compares the wrong neighbourhood"),
    ("Developer", "Parent brand, consistently spelled", "Developer Record tab shows nothing"),
    ("Property type", "Condo / serviced / apartment as sold", "Falls out of the filter, and out of comparables"),
    ("Tenure", "Freehold or leasehold — check the sales chart", "Buyers make the wrong financing assumption"),
    ("Completion year", "The developer's stated handover", "Financial Projection starts from the wrong year"),
    ("Total units", "The whole development, not the phase", "New Supply density is wrong"),
    ("Coordinates", "Pin lands on the actual site, not the sales gallery", "Amenities and New Supply are centred wrongly"),
]
table(s, M, cy + Inches(0.26), CW, cols, rows,
      widths=[CW * 0.20, CW * 0.44, CW * 0.36], size=11.5, row_h=Inches(0.32),
      colorer=lambda ri, ci, cell, col, bold, font:
          (INK, True, TEXT) if ci == 0 else ((STOP, False, TEXT) if ci == 2 else (col, bold, font)))
page()

# ============================================================ 08 divider
divider(prs, "Stage two", "Layouts are the whole product",
        "Six of the eight tabs are computed from floor plans. A record with no layouts "
        "is a brochure; a record with wrong layouts is worse than one with none.",
        page(), TOTAL)

# ============================================================ 09 layouts
s = blank(prs)
y = header(s, "Stage 2 · Floor plans", "What a finished layout looks like", page(), TOTAL)
for i, (v, l, note, tone) in enumerate([
        ("3,072", "layouts in scope", "across 431 projects", NAVY),
        ("790", "have no price", "no analysis can run", STOP),
        ("224", "have no size", "no PSF, no comparables", STOP),
        ("189", "have no bedrooms", "rental estimate collapses", STOP)]):
    stat(s, M + (CW / 4 + Inches(0.02)) * i, y, CW / 4 - Inches(0.16), v, l, note, tone)
box(s, M, y + Inches(1.62), CW * 0.47, Inches(0.3),
    "Every layout needs all five", size=11, color=NAVY, bold=True, caps=True)
bullets(s, M, y + Inches(1.98), CW * 0.47, [
    ("Name —", "as the developer sells it (Type A, not “3B”)."),
    ("Size in sqft —", "built-up, matching the brochure."),
    ("Price —", "the launch price for that type."),
    ("Bedrooms —", "see the next slide. This is the one that matters most."),
    ("The plan image —", "attached to that layout, not floating in the gallery."),
], size=13.5)
box(s, M + CW * 0.53, y + Inches(1.62), CW * 0.47, Inches(0.3),
    "Delete rather than keep", size=11, color=STOP, bold=True, caps=True)
bullets(s, M + CW * 0.53, y + Inches(1.98), CW * 0.47, [
    "Duplicate layouts the portals invented — two feeds naming one unit type differently.",
    "“Type A” and “3B” for the same plan: keep the developer's name, drop the portal's.",
    "Layouts with a placeholder price (RM 1, RM 785). A missing price is honest; a fake one poisons the median.",
], size=13.5)
page()

# ============================================================ 10 dual key
s = blank(prs)
y = header(s, "Stage 2 · Dual key", "Ticking “dual key” is not what makes it a dual key", page(), TOTAL)
cy = callout(s, M, y, CW,
    "The field that does the work is KEY BEDROOMS — the bedroom count of each key, e.g. [0, 2] for a "
    "studio plus a two-bed. The “dual key” tick is DERIVED from it. Fill the keys and the tick sets "
    "itself; tick it without filling the keys and every analysis tab behaves as if it were a normal unit.",
    size=13.5)
box(s, M, cy + Inches(0.30), CW * 0.47, Inches(0.3), "What changes once the keys are filled",
    size=11, color=NAVY, bold=True, caps=True)
b = bullets(s, M, cy + Inches(0.66), CW * 0.47, [
    ("Rent becomes a sum.", "Each key is priced against its OWN bedroom count, then added — not one lookup for the whole unit."),
    ("The chart switches to absolute rent.", "PSF is disabled, because a summed rent over the whole floor area is meaningless."),
    ("Yield and cashflow follow", "the summed rent, on both the Summary and the Rental tab."),
    ("New Supply labels it", "“Dual Keys” in the unit mix."),
], size=12.5, gap=Inches(0.11))
box(s, M + CW * 0.53, cy + Inches(0.30), CW * 0.47, Inches(0.3),
    "Where we stand today", size=11, color=NAVY, bold=True, caps=True)
for i, (v, l, tone) in enumerate([
        ("46", "layouts flagged dual key, across 31 projects", NAVY),
        ("35", "layouts whose NAME says dual key — flag off", WARN),
        ("4", "layouts with key bedrooms actually filled", STOP)]):
    yy = cy + Inches(0.70) + Inches(0.74) * i
    box(s, M + CW * 0.53, yy, Inches(0.9), Inches(0.5), v, size=26, color=tone,
        font=DISPLAY, bold=True)
    box(s, M + CW * 0.53 + Inches(1.0), yy + Inches(0.08), CW * 0.36, Inches(0.5),
        l, size=12.5, color=BODY, spacing=1.26)
para(s, M, b + Inches(0.18), CW * 0.47,
    "An 850 sqft dual key left as a plain 3-bed is priced as one 3-bed unit. That understates "
    "the rent materially — and nothing on the page hints at it.",
    size=11.5, color=MUTED, spacing=1.30)
para(s, M + CW * 0.53, cy + Inches(0.70) + Inches(0.74) * 3 + Inches(0.10), CW * 0.47,
    "So of 46 dual keys, 42 are being priced as ordinary units — and 35 more are not flagged at all.",
    size=11.5, color=STOP, bold=True, spacing=1.30)
page()

# ============================================================ 11 bedrooms
s = blank(prs)
y = header(s, "Stage 2 · The single most important field", "Bedrooms decides which market the unit is priced against", page(), TOTAL)
box(s, M, y, CW, Inches(0.5),
    "The rental estimate does not interpolate. It selects comparable listings whose bedroom count matches EXACTLY, "
    "within 1 km. Change the number and you change the entire comparable set.",
    size=14.5, color=BODY, spacing=1.34)
chain = [("Layout\nbedrooms", NAVY), ("Comparable\nlistings", NAVY), ("Median\nrent", NAVY),
         ("Gross\nyield", GOLD), ("Cashflow +\nprojection", GO)]
bw = (CW - Inches(0.44) * 4) / 5
for i, (t, tone) in enumerate(chain):
    x = M + (bw + Inches(0.44)) * i
    rect(s, x, y + Inches(0.72), bw, Inches(0.82), fill=WASH)
    rect(s, x, y + Inches(0.72), Pt(3.5), Inches(0.82), fill=tone)
    box(s, x + Inches(0.18), y + Inches(0.90), bw - Inches(0.3), Inches(0.6), t,
        size=12.5, color=INK, bold=True, spacing=1.22)
    if i < 4:
        box(s, x + bw + Inches(0.09), y + Inches(1.02), Inches(0.3), Inches(0.3), "→",
            size=15, color=MUTED)
box(s, M, y + Inches(1.80), CW * 0.47, Inches(0.3), "Three ways it goes wrong",
    size=11, color=STOP, bold=True, caps=True)
bullets(s, M, y + Inches(2.16), CW * 0.47, [
    ("Left empty —", "the filter switches off and studios are averaged with four-beds."),
    ("Off by one —", "a confident number from the wrong market. No warning."),
    ("No comparables for that count —", "rent returns 0, so yield is 0% and cashflow is maximally negative."),
    ("Empty on the fast estimate —", "it silently assumes 3 bedrooms, so a studio is quoted three-bed rents."),
], size=13)
rect(s, M + CW * 0.53, y + Inches(1.80), CW * 0.47, Inches(2.30), fill=WASH)
box(s, M + CW * 0.53 + Inches(0.26), y + Inches(2.00), CW * 0.41, Inches(0.3),
    "The backlog", size=11, color=NAVY, bold=True, caps=True)
box(s, M + CW * 0.53 + Inches(0.26), y + Inches(2.36), Inches(1.5), Inches(0.6), "189",
    size=40, color=STOP, font=DISPLAY, bold=True)
box(s, M + CW * 0.53 + Inches(1.70), y + Inches(2.46), CW * 0.28, Inches(0.6),
    "layouts in scope have no\nbedroom count at all", size=13, color=BODY, spacing=1.28)
box(s, M + CW * 0.53 + Inches(0.26), y + Inches(3.20), CW * 0.41, Inches(0.7),
    "Fix these before anything else. A layout with no bedrooms produces a number that looks "
    "authoritative and is not — which is worse than a blank.",
    size=11.5, color=MUTED, spacing=1.30)
page()

# ============================================================ 12 tab matrix
s = blank(prs)
y = header(s, "Stage 2 · The public page", "Eight tabs, and what each one runs on", page(), TOTAL)
cy = para(s, M, y, CW,
    "Open every tab before you submit. Six of the eight are computed — they will render something "
    "plausible whether or not the data behind them is right.",
    size=14, color=BODY, spacing=1.32)
cy = table(s, M, cy + Inches(0.30), CW, ["Tab", "Runs on", "How it fails"], TAB_MATRIX,
      widths=[CW * 0.24, CW * 0.42, CW * 0.34], size=12.5, row_h=Inches(0.40),
      colorer=lambda ri, ci, cell, col, bold, font:
          (INK, True, TEXT) if ci == 0 else ((STOP, False, TEXT) if ci == 2 else (col, bold, font)))
box(s, M, cy + Inches(0.22), CW, Inches(0.3),
    "A ninth tab, 360° Aerial, appears only when a VR bake has been approved for the project.",
    size=11.5, color=MUTED)
page()

# ============================================================ 13-20 the eight tabs
for spec in TAB_SPECS:
    name, audience, sees, powered, checks, (fail_head, fail_body) = spec
    s = blank(prs)
    y = header(s, f"Tab standard · {audience.lower()}", name, page(), TOTAL)
    cy = para(s, M, y, CW * 0.62, sees, size=14.5, color=BODY, spacing=1.32)
    box(s, M, cy + Inches(0.30), CW * 0.62, Inches(0.3), "Check before you submit",
        size=11, color=NAVY, bold=True, caps=True)
    cy = bullets(s, M, cy + Inches(0.66), CW * 0.62, checks, size=13.5)
    callout(s, M, cy + Inches(0.28), CW * 0.62, fail_body, head=fail_head, size=11.5)
    rect(s, M + CW * 0.66, y, CW * 0.34, Inches(4.98), fill=WASH)
    box(s, M + CW * 0.66 + Inches(0.28), y + Inches(0.26), CW * 0.28, Inches(0.3),
        "Powered by", size=11, color=NAVY, bold=True, caps=True)
    cy = y + Inches(0.68)
    for item in powered:
        rect(s, M + CW * 0.66 + Inches(0.28), cy + Inches(0.09), Pt(3.2), Pt(3.2), fill=NAVY)
        tb = box(s, M + CW * 0.66 + Inches(0.48), cy, CW * 0.26, Inches(0.4), item,
                 size=12, color=BODY, spacing=1.26)
        cy += Inches(0.30) + Inches(0.20) * (len(item) // 34)
    box(s, M + CW * 0.66 + Inches(0.28), Inches(6.12), CW * 0.28, Inches(0.4),
        "Comment on anything here you could not verify.", size=10.5, color=MUTED, spacing=1.28)

# ============================================================ 21 divider
divider(prs, "Stages three & four", "Four signatures, and what each one is for",
        "A second pair of eyes is only worth having if it is genuinely independent — and if "
        "what it found is written down.", page(), TOTAL)

# ============================================================ 22 cold-eye
s = blank(prs)
y = header(s, "Stage 3 · Cold-eye review", "What a review actually is", page(), TOTAL)
cy = para(s, M, y, CW * 0.55,
    "A review is not a second opinion on whether the project is good. It is a check that the "
    "RECORD matches reality — done by someone who did not fill it in, and therefore does not "
    "already believe it is right.",
    size=15, color=BODY, spacing=1.38)
box(s, M, cy + Inches(0.30), CW * 0.55, Inches(0.3),
    "Do this, in this order", size=11, color=NAVY, bold=True, caps=True)
bullets(s, M, cy + Inches(0.66), CW * 0.55, [
    ("Open the developer's own page first,", "then the record. Not the other way round — read the truth before the claim."),
    ("Walk all eight tabs", "as a buyer would, not as a form."),
    ("Spot-check two layouts", "end to end: size, price, bedrooms, image."),
    ("Write the note", "even when you approve — say what you checked."),
    ("Send it back", "for anything you would not want a client to see. That is not friction; it is the job."),
], size=13.5)
rect(s, M + CW * 0.60, y, CW * 0.40, Inches(4.30), fill=WASH)
box(s, M + CW * 0.60 + Inches(0.3), y + Inches(0.26), CW * 0.34, Inches(0.3),
    "What the system enforces", size=11, color=NAVY, bold=True, caps=True)
enforced = [
    ("Two reviewers", "Exactly two, both must sign"),
    ("Not your own", "Initiator cannot review or approve"),
    ("Not the approver", "The approver never reviews it too"),
    ("Order", "Approve button is hidden until both sign"),
    ("Any reviewer", "can send it back, which reopens it"),
]
cy = y + Inches(0.72)
for k, v in enforced:
    box(s, M + CW * 0.60 + Inches(0.3), cy, CW * 0.34, Inches(0.28), k, size=13,
        color=INK, bold=True)
    box(s, M + CW * 0.60 + Inches(0.3), cy + Inches(0.26), CW * 0.34, Inches(0.28), v,
        size=11.5, color=MUTED)
    cy += Inches(0.72)
box(s, M + CW * 0.60 + Inches(0.3), cy + Inches(0.04), CW * 0.34, Inches(0.5),
    "Everything else is discipline, not code. That is what the KPI is for.",
    size=11, color=NAVY, bold=True, spacing=1.28)
page()

# ============================================================ 23 KPI
s = blank(prs)
y = header(s, "Stage 3 · Measuring the review", "Review quality, scored on what escapes", page(), TOTAL)
cy = para(s, M, y, CW,
    "“Quality” has to be measurable or it becomes a popularity contest. The measure is ESCAPED DEFECTS — "
    "things that reached the public page and should not have. Catching problems is rewarded; approving "
    "quickly is not.",
    size=14.5, color=BODY, spacing=1.34)
cols = ["Measure", "How it is counted", "Why this one"]
rows = [
    ("Escaped defects", "Faults found AFTER release, per record you signed", "The only one a client actually feels"),
    ("Catch rate", "Change-requests you raised that were genuinely wrong", "Rewards reading, not rubber-stamping"),
    ("Note quality", "Does the note say what was checked?", "An empty note is an unverifiable review"),
    ("Turnaround", "Hours from assignment to decision", "Ranked last — speed is the cheapest thing here"),
]
cy = table(s, M, cy + Inches(0.26), CW, cols, rows,
      widths=[CW * 0.22, CW * 0.42, CW * 0.36], size=12.5, row_h=Inches(0.40),
      colorer=lambda ri, ci, cell, col, bold, font:
          (INK, True, TEXT) if ci == 0 else (col, bold, font))
callout(s, M, cy + Inches(0.30), CW,
    "A reviewer who sends records back is doing the job. One who approves everything inside five "
    "minutes is not — and the escaped-defect count will show it within a fortnight. Lead "
    "allocation follows this table, not volume.",
    tone=GO, wash=GO_WASH, size=12.5)
page()

# ============================================================ 24 approval
s = blank(prs)
y = header(s, "Stage 4 · Approval", "Zen and Dylan: the last gate", page(), TOTAL)
cy = para(s, M, y, CW * 0.55,
    "The approver is not a third reviewer. Both cold-eye checks are already done — the approver "
    "decides whether this record is one we are willing to put our name to publicly.",
    size=15, color=BODY, spacing=1.38)
box(s, M, cy + Inches(0.30), CW * 0.55, Inches(0.3), "The approver's four questions",
    size=11, color=NAVY, bold=True, caps=True)
bullets(s, M, cy + Inches(0.66), CW * 0.55, [
    ("Did both reviewers actually review?", "Read their notes. Two blank approvals is a reason to pause."),
    ("Do the money tabs hold up?", "Yield and projection are what a client will quote back at us."),
    ("Would I send this link to a client today?", "If not, send it back."),
    ("Is anything unresolved but acceptable?", "Say so in the note — a known gap on the record beats a surprise later."),
], size=13.5)
box(s, M + CW * 0.60, y, CW * 0.40, Inches(0.3), "The lifecycle", size=11,
    color=NAVY, bold=True, caps=True)
states = [("Pending", "with the two reviewers", NAVY, WASH),
          ("Awaiting approval", "both signed — with Zen / Dylan", GOLD, WARN_WASH),
          ("Changes requested", "back with the initiator", STOP, STOP_WASH),
          ("Approved", "live on the public site", GO, GO_WASH),
          ("Cancelled", "withdrawn by the initiator", MUTED, WASH)]
cy = y + Inches(0.40)
for nm, desc, tone, wash in states:
    rect(s, M + CW * 0.60, cy, CW * 0.40, Inches(0.62), fill=wash)
    rect(s, M + CW * 0.60, cy, Pt(3.5), Inches(0.62), fill=tone)
    box(s, M + CW * 0.60 + Inches(0.22), cy + Inches(0.09), CW * 0.36, Inches(0.28),
        nm, size=13, color=INK, bold=True)
    box(s, M + CW * 0.60 + Inches(0.22), cy + Inches(0.33), CW * 0.36, Inches(0.24),
        desc, size=11, color=MUTED)
    cy += Inches(0.74)
dn = ("Taking a page DOWN does not need the panel. If something is wrong after release, unpublish "
      "it immediately and fix it afterwards — holding a bad page up while three signatures are "
      "collected is the wrong trade.")
dh = text_height(dn, CW * 0.50, 12.5, 1.32)
rect(s, M, Inches(6.62) - dh - Inches(0.36), CW * 0.55, dh + Inches(0.36), fill=WASH)
box(s, M + Inches(0.26), Inches(6.62) - dh - Inches(0.18), CW * 0.50, dh, dn,
    size=12.5, color=BODY, spacing=1.32)
page()

# ============================================================ 25 divider
divider(prs, "The plan", "Eight weeks, ten a day, popular first",
        "The constraint is not how fast one person can fill a record — it is how many reviews "
        "five people can absorb.", page(), TOTAL)

# ============================================================ 26 the plan
s = blank(prs)
y = header(s, "The plan", "Ten a day clears the ready queue in eight weeks", page(), TOTAL)
sy = y
for i, (v, l, note, tone) in enumerate([
        ("411", "records ready", "complete, unpublished", NAVY),
        ("10", "submitted per day", "2 each, 5 days a week", NAVY),
        ("8", "weeks to clear", "41 working days", GO),
        ("40", "actions per day", "submits + reviews + approvals", WARN)]):
    sy = max(sy, stat(s, M + (CW / 4 + Inches(0.02)) * i, y, CW / 4 - Inches(0.16), v, l, note, tone))
cy = para(s, M, sy + Inches(0.32), CW * 0.47,
    "The real constraint is review capacity", size=11, color=NAVY, bold=True, caps=True, spacing=1.2)
cy = para(s, M, cy + Inches(0.16), CW * 0.47,
    "Ten submissions a day is not ten pieces of work — it is forty. Each record also needs two "
    "independent reviews and an approval: about eight actions each per day across five people, "
    "and the three initiators are also the two reviewers.",
    size=13, color=BODY, spacing=1.32)
bullets(s, M, cy + Inches(0.18), CW * 0.47, [
    ("Reviewing beats submitting.", "If the review queue is not empty at day end, submit fewer tomorrow."),
    ("Rotate the pairs.", "Never the same two people reviewing each other."),
    ("Batch by developer or area.", "The tenth Sunway record is far faster than the first."),
], size=13, gap=Inches(0.11))
px, pw = M + CW * 0.53, CW * 0.47
inner = pw - Inches(0.6)
h1 = text_height("And the 524 with no layouts", inner, 11, 1.2, caps=True)
b1 = ("More than half the catalogue cannot enter this process at all — there is nothing to review "
      "until someone collects the floor plans. Treat that as a separate sourcing workstream with "
      "its own owner, running in parallel. If it does not start now, week nine has nothing in it.")
b2 = ("790 layouts we DO hold still have no price, and 224 no size. Those are quick wins — the "
      "record already exists, it just cannot be analysed yet.")
h2 = text_height(b1, inner, 13, 1.36)
h3 = text_height(b2, inner, 12.5, 1.32)
ph = Inches(0.22) + h1 + Inches(0.18) + h2 + Inches(0.26) + h3 + Inches(0.24)
rect(s, px, sy + Inches(0.32), pw, ph, fill=WASH)
cy2 = sy + Inches(0.54)
box(s, px + Inches(0.3), cy2, inner, h1, "And the 524 with no layouts", size=11,
    color=WARN, bold=True, caps=True)
cy2 += h1 + Inches(0.18)
box(s, px + Inches(0.3), cy2, inner, h2, b1, size=13, color=BODY, spacing=1.36)
cy2 += h2 + Inches(0.30)
box(s, px + Inches(0.3), cy2, inner, h3, b2, size=12.5, color=MUTED, spacing=1.32)
page()

# ============================================================ 27 priority
s = blank(prs)
y = header(s, "The plan · Sequencing", "“Popular first” — made concrete", page(), TOTAL)
box(s, M, y, CW, Inches(0.44),
    "We have no traffic data for these projects yet, so popularity has to be a proxy. Three that "
    "we can actually sort on today, in priority order:",
    size=14.5, color=BODY, spacing=1.32)
c1 = M
c2 = M + CW * 0.345
c3 = M + CW * 0.69
for x, title, sub in [(c1, "1 · Brand", "Developers a buyer already searches for"),
                      (c2, "2 · Density", "Areas where we hold the most records"),
                      (c3, "3 · Depth", "Records with the most layouts")]:
    box(s, x, y + Inches(0.74), CW * 0.30, Inches(0.3), title, size=13,
        color=NAVY, bold=True, caps=True)
    box(s, x, y + Inches(1.04), CW * 0.30, Inches(0.3), sub, size=11.5, color=MUTED)
devs = [("Sime Darby Property", 23), ("Sunway", 21), ("Mah Sing Group", 16),
        ("SP Setia", 12), ("Paramount", 11), ("Exsim Group", 10), ("EcoWorld", 9)]
areas = [("Johor Bahru", 80), ("Shah Alam", 41), ("Petaling Jaya", 36), ("Cheras", 28),
         ("Sepang", 26), ("Puchong", 25), ("Bukit Jalil", 25)]
for x, data in [(c1, devs), (c2, areas)]:
    cy = y + Inches(1.44)
    for nm, cnt in data:
        box(s, x, cy, CW * 0.24, Inches(0.28), nm, size=12.5, color=BODY)
        box(s, x, cy, CW * 0.30, Inches(0.28), str(cnt), size=12.5, color=INK,
            bold=True, font=MONO, align=PP_ALIGN.RIGHT)
        cy += Inches(0.36)
        rule(s, x, cy - Inches(0.08), CW * 0.30)
bullets(s, c3, y + Inches(1.44), CW * 0.30, [
    "A project with 13 layouts carries far more analysis than one with 2 — more value per review.",
    "Sort the To Publish tab by whatever you are batching, and take the top ten.",
], size=12.5)
callout(s, M, Inches(5.60), CW,
    "Publishing in geographic clusters is not a nicety. New Supply counts only published "
    "neighbours, so ten scattered releases leave every one of them showing an empty competitive "
    "set — while ten in Johor Bahru make all ten look complete.",
    tone=WARN, wash=WARN_WASH, size=13)
page()

# ============================================================ 28 traps
s = blank(prs)
y = header(s, "Known traps", "Things that will bite, in order of likelihood", page(), TOTAL)
traps = [
    ("The dual-key tick does nothing on its own", "Fill key bedrooms. The tick is derived from them.", STOP),
    ("A wrong bedroom count never errors", "It returns a confident number from the wrong market.", STOP),
    ("Rent that is exactly 0.35% of price", "That is the fallback, not an estimate. Check the layout's rent.", STOP),
    ("Every unit says “Available”", "The sold flag is never supplied. Known defect — do not trust the badge.", WARN),
    ("New Supply looks empty", "It counts only published neighbours. Expected until we publish clusters.", WARN),
    ("Amenity write-up repeats next door", "The AI outlook is cached per coordinate at ~11 m. Same corner, same text.", WARN),
    ("An empty developer track record", "Almost always a mis-standardised developer name, not a new developer.", WARN),
    ("A layout that never appears", "Only PropertySifu-attributed layouts reach the public page.", MUTED),
]
cy = y
for i, (head, body, tone) in enumerate(traps):
    col = M if i < 4 else M + CW * 0.52
    yy = y + Inches(1.14) * (i % 4)
    rect(s, col, yy, CW * 0.48, Inches(0.96), fill=WASH)
    rect(s, col, yy, Pt(3.5), Inches(0.96), fill=tone)
    box(s, col + Inches(0.24), yy + Inches(0.14), CW * 0.43, Inches(0.3), head,
        size=13, color=INK, bold=True)
    box(s, col + Inches(0.24), yy + Inches(0.44), CW * 0.43, Inches(0.5), body,
        size=11.5, color=BODY, spacing=1.28)
page()

# ============================================================ 29 checklist
s = blank(prs)
y = header(s, "Print this one", "The submit checklist", page(), TOTAL)
groups = [
    ("The record", ["Name as the developer writes it", "City and state both set",
                    "Developer linked and standardised", "Tenure and property type",
                    "Completion year", "Total units", "Pin on the actual site"]),
    ("Every layout", ["Name as sold", "Size in sqft", "Price",
                      "Bedrooms — exact", "Key bedrooms if dual key",
                      "Floor-plan image attached", "No placeholder prices"]),
    ("The eight tabs", ["Overview reads complete", "Units · Price calculator renders",
                        "Investment verdict is not “insufficient”", "Projection rent is realistic",
                        "New Supply — expect empty for now", "Amenities has an outlook",
                        "Developer track record is populated"]),
    ("Before you submit", ["Two reviewers picked", "One approver picked",
                           "Submit note written", "Anything unverified is named in the note",
                           "You are signed in as yourself"]),
]
gw = CW / 4
for gi, (title, items) in enumerate(groups):
    x = M + gw * gi
    box(s, x, y, gw - Inches(0.3), Inches(0.3), title, size=12.5, color=NAVY,
        bold=True, caps=True)
    rule(s, x, y + Inches(0.34), gw - Inches(0.3), color=NAVY, weight=1.5)
    cy = y + Inches(0.54)
    for it in items:
        rect(s, x, cy + Inches(0.02), Inches(0.145), Inches(0.145), fill=None,
             line=HAIRLINE, lw=1.1)
        box(s, x + Inches(0.26), cy - Inches(0.015), gw - Inches(0.56), Inches(0.4),
            it, size=11.5, color=BODY, spacing=1.22)
        cy += Inches(0.34) + Inches(0.17) * (len(it) // 26)
rect(s, M, Inches(6.02), CW, Inches(0.66), fill=NAVY_DEEP)
box(s, M + Inches(0.28), Inches(6.18), CW - Inches(0.6), Inches(0.4),
    "If you cannot tick a box, submit anyway and say so in the note. A named gap is reviewable; "
    "a silent one is not.", size=13, color=PAPER, spacing=1.28)
page()

# ============================================================ 30 close
s = blank(prs, NAVY_DEEP)
rect(s, 0, 0, Pt(6), H, fill=GOLD)
box(s, M, Inches(1.90), CW * 0.62, Inches(0.3), "To start on Monday", size=11,
    color=GOLD, bold=True, caps=True)
ct = "Clear the merge queue,\nthen take ten."
ch = text_height(ct, CW * 0.62, 34, 1.08, DISPLAY)
box(s, M, Inches(1.98), CW * 0.62, ch, ct,
    size=34, color=PAPER, font=DISPLAY, bold=True, spacing=1.08)
steps = [
    ("Everyone", "Sign in; confirm you can see the Duplicates and To Publish tabs."),
    ("Week 1", "Work the 133 duplicate clusters before any review starts."),
    ("Week 1", "Fix the 189 layouts with no bedroom count, and the 46 dual keys."),
    ("Week 2 on", "Ten submissions a day, batched by developer or by area."),
    ("Ongoing", "Someone owns sourcing plans for the 524 with none."),
]
cy = Inches(1.98) + ch + Inches(0.40)
for k, v in steps:
    box(s, M, cy, Inches(1.55), Inches(0.3), k, size=11, color=GOLD, bold=True, caps=True)
    vh = text_height(v, CW * 0.46, 13.5, 1.26)
    box(s, M + Inches(1.75), cy - Inches(0.02), CW * 0.46, vh, v, size=13.5,
        color=RGBColor(0xD2, 0xDD, 0xE9), spacing=1.26)
    cy += max(Inches(0.46), vh + Inches(0.14))
rect(s, W - Inches(4.9), Inches(2.30), Inches(4.0), Inches(2.9),
     fill=RGBColor(0x18, 0x2E, 0x4C))
box(s, W - Inches(4.62), Inches(2.56), Inches(3.5), Inches(0.3), "Questions",
    size=10.5, color=GOLD, bold=True, caps=True)
box(s, W - Inches(4.62), Inches(2.96), Inches(3.5), Inches(2.0),
    "Process, panel and access — Wai Kit.\n\n"
    "Anything the page shows that you cannot explain from the record: screenshot it, "
    "note the project id, and send it. Several of the traps in this deck were found "
    "exactly that way.",
    size=13.5, color=RGBColor(0xB9, 0xC7, 0xD8), spacing=1.38)
box(s, M, Inches(6.72), CW, Inches(0.3),
    "Figures read from the live catalogue on 9 August 2026.", size=10,
    color=RGBColor(0x60, 0x77, 0x93), font=MONO)
page()

prs.save(OUT)
print(f"saved {os.path.abspath(OUT)}  ·  {len(prs.slides._sldIdLst)} slides")
