@rubytech/create-maxy-code 0.1.70 → 0.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/turn-completed-graph-write.test.sh +78 -7
- package/payload/platform/plugins/admin/hooks/turn-completed-graph-write.sh +11 -4
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -1
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +15 -5
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/build.md +169 -32
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-extract/SKILL.md +2 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-socials/SKILL.md +14 -13
package/package.json
CHANGED
|
@@ -232,6 +232,7 @@ RECORDER_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cu
|
|
|
232
232
|
BODY_OK=$(printf '%s' "$RECORDER_BODY" | python3 -c '
|
|
233
233
|
import sys, json
|
|
234
234
|
op = sys.argv[1]
|
|
235
|
+
acct = sys.argv[2]
|
|
235
236
|
try:
|
|
236
237
|
outer = json.load(sys.stdin)
|
|
237
238
|
msg = outer.get("initialMessage")
|
|
@@ -245,8 +246,14 @@ try:
|
|
|
245
246
|
msg.rstrip().endswith("do not emit it.")))
|
|
246
247
|
conds.append(("no-placeholder-schema", "{schema}" not in msg))
|
|
247
248
|
conds.append(("no-placeholder-conversation", "{conversation}" not in msg))
|
|
249
|
+
conds.append(("no-placeholder-accountid", "{accountId}" not in msg))
|
|
248
250
|
# schema-base.md content is interpolated.
|
|
249
251
|
conds.append(("contains-schema-base-header", "Schema Reference" in msg))
|
|
252
|
+
# Task 199 — accountId clarifier sentence + literal value rendered.
|
|
253
|
+
conds.append(("has-accountid-clarifier",
|
|
254
|
+
"accountId" in msg and "automatically supplied" in msg))
|
|
255
|
+
conds.append(("has-accountid-value",
|
|
256
|
+
"The current accountId is `" + acct + "`." in msg))
|
|
250
257
|
# The conversation lines appear in role: text format.
|
|
251
258
|
conds.append(("has-user-1", "\nuser: New Real Agent session" in msg))
|
|
252
259
|
conds.append(("has-asst-1",
|
|
@@ -266,20 +273,22 @@ try:
|
|
|
266
273
|
print("yes" if not failed else "no:"+repr(failed))
|
|
267
274
|
except Exception as e:
|
|
268
275
|
print("parse-fail:"+str(e))
|
|
269
|
-
' "$OP_ID" 2>/dev/null)
|
|
276
|
+
' "$OP_ID" "$ACCT_ID" 2>/dev/null)
|
|
270
277
|
if [[ "$BODY_OK" == "yes" ]]; then
|
|
271
|
-
pass "case-5c initialMessage is the filled
|
|
278
|
+
pass "case-5c initialMessage is the filled paragraph; schema/accountId/conversation placeholders substituted; accountId value rendered; transcript as role: text lines"
|
|
272
279
|
else
|
|
273
280
|
fail "case-5c filled-prompt shape wrong ($BODY_OK)"
|
|
274
281
|
fi
|
|
275
282
|
|
|
276
|
-
# 5h. (Task 195) exactly one `substitution` log-ingest line carrying
|
|
277
|
-
# positive byte counts for schema / conversation / body
|
|
278
|
-
|
|
283
|
+
# 5h. (Task 195 + 199) exactly one `substitution` log-ingest line carrying
|
|
284
|
+
# positive byte counts for schema / conversation / body AND
|
|
285
|
+
# accountIdPresent=yes (the envelope's accountId is non-empty here —
|
|
286
|
+
# run_hook always passes ACCOUNT_ID="$ACCT_ID" into the hook env).
|
|
287
|
+
SUBST_LINE_COUNT=$(ingest_lines | grep -cE "^substitution sessionId=${OP_ID} schemaBytes=[1-9][0-9]* conversationBytes=[1-9][0-9]* bodyBytes=[1-9][0-9]* accountIdPresent=yes$" || true)
|
|
279
288
|
if [[ "$SUBST_LINE_COUNT" -eq 1 ]]; then
|
|
280
|
-
pass "case-5h substitution log line emitted once with positive byte counts"
|
|
289
|
+
pass "case-5h substitution log line emitted once with positive byte counts and accountIdPresent=yes"
|
|
281
290
|
else
|
|
282
|
-
fail "case-5h expected exactly 1 substitution line w/ positive bytes, got $SUBST_LINE_COUNT (lines: $(ingest_lines))"
|
|
291
|
+
fail "case-5h expected exactly 1 substitution line w/ positive bytes + accountIdPresent=yes, got $SUBST_LINE_COUNT (lines: $(ingest_lines))"
|
|
283
292
|
fi
|
|
284
293
|
|
|
285
294
|
# 5i. (Task 195) substitution line lands between envelope and spawn-request.
|
|
@@ -605,6 +614,68 @@ else
|
|
|
605
614
|
fail "case-10 escaping wrong ($ESC_OK): $ESC_BODY"
|
|
606
615
|
fi
|
|
607
616
|
|
|
617
|
+
# --- Case 11 (Task 199): missing accountId → accountIdPresent=no, no crash ---
|
|
618
|
+
# When ACCOUNT_ID env is empty, the envelope's accountId field is "" and
|
|
619
|
+
# the {accountId} placeholder collapses to an empty string. The hook does
|
|
620
|
+
# NOT loud-fail (the agent's new clarifier sentence + the server-side
|
|
621
|
+
# validator on the writers handle the bad signal). The substitution log
|
|
622
|
+
# line emits accountIdPresent=no — the regression signature for upstream
|
|
623
|
+
# envelope-shape drift.
|
|
624
|
+
: > "$REQ_LOG"
|
|
625
|
+
NO_ACCT_STDIN_FILE=$(mktemp); TMPFILES+=("$NO_ACCT_STDIN_FILE")
|
|
626
|
+
printf '%s' "$ORDERED_ENVELOPE" > "$NO_ACCT_STDIN_FILE"
|
|
627
|
+
NO_ACCT_STDERR=$(mktemp); TMPFILES+=("$NO_ACCT_STDERR")
|
|
628
|
+
NO_ACCT_STDOUT=$(mktemp); TMPFILES+=("$NO_ACCT_STDOUT")
|
|
629
|
+
MAXY_SESSION_ROLE="admin" \
|
|
630
|
+
MAXY_SPECIALIST="" \
|
|
631
|
+
MAXY_UI_INTERNAL_PORT="$LISTENER_PORT" \
|
|
632
|
+
ACCOUNT_ID="" \
|
|
633
|
+
bash "$HOOK" <"$NO_ACCT_STDIN_FILE" >"$NO_ACCT_STDOUT" 2>"$NO_ACCT_STDERR"
|
|
634
|
+
NO_ACCT_RC=$?
|
|
635
|
+
sleep 0.1
|
|
636
|
+
[[ "$NO_ACCT_RC" -eq 0 ]] || fail "case-11 rc=$NO_ACCT_RC stderr=$(cat "$NO_ACCT_STDERR")"
|
|
637
|
+
[[ -z "$(cat "$NO_ACCT_STDERR")" ]] || fail "case-11 stderr must be empty, got: $(cat "$NO_ACCT_STDERR")"
|
|
638
|
+
|
|
639
|
+
# 11a. substitution log line carries accountIdPresent=no
|
|
640
|
+
SUBST_NO_COUNT=$(ingest_lines | grep -cE "^substitution sessionId=${OP_ID} schemaBytes=[1-9][0-9]* conversationBytes=[1-9][0-9]* bodyBytes=[1-9][0-9]* accountIdPresent=no$" || true)
|
|
641
|
+
if [[ "$SUBST_NO_COUNT" -eq 1 ]]; then
|
|
642
|
+
pass "case-11a empty ACCOUNT_ID → substitution log carries accountIdPresent=no"
|
|
643
|
+
else
|
|
644
|
+
fail "case-11a expected 1 substitution line w/ accountIdPresent=no, got $SUBST_NO_COUNT (lines: $(ingest_lines))"
|
|
645
|
+
fi
|
|
646
|
+
|
|
647
|
+
# 11b. recorder spawn still fires — the hook does NOT loud-fail on absent
|
|
648
|
+
# accountId. The filled body still substitutes; the placeholder
|
|
649
|
+
# collapses to empty backticks.
|
|
650
|
+
NO_ACCT_BODY=$(grep -E '^/api/admin/claude-sessions ' "$REQ_LOG" | head -1 | cut -f2-)
|
|
651
|
+
NO_ACCT_OK=$(printf '%s' "$NO_ACCT_BODY" | python3 -c '
|
|
652
|
+
import sys, json
|
|
653
|
+
try:
|
|
654
|
+
outer = json.load(sys.stdin)
|
|
655
|
+
msg = outer.get("initialMessage")
|
|
656
|
+
if not isinstance(msg, str):
|
|
657
|
+
print("no:initialMessage-not-string")
|
|
658
|
+
sys.exit(0)
|
|
659
|
+
conds = []
|
|
660
|
+
conds.append(("starts-template", msg.startswith("You are an expert Neo4J graph operator.")))
|
|
661
|
+
conds.append(("no-placeholder-accountid", "{accountId}" not in msg))
|
|
662
|
+
# Placeholder collapses to empty backticks.
|
|
663
|
+
conds.append(("empty-accountid-rendered", "The current accountId is ``." in msg))
|
|
664
|
+
conds.append(("clarifier-still-present",
|
|
665
|
+
"never refuse a write because accountId" in msg))
|
|
666
|
+
# Transcript still rendered.
|
|
667
|
+
conds.append(("has-user-line", "\nuser: " in msg))
|
|
668
|
+
failed = [name for name, ok in conds if not ok]
|
|
669
|
+
print("yes" if not failed else "no:"+repr(failed))
|
|
670
|
+
except Exception as e:
|
|
671
|
+
print("parse-fail:"+str(e))
|
|
672
|
+
' 2>/dev/null)
|
|
673
|
+
if [[ "$NO_ACCT_OK" == "yes" ]]; then
|
|
674
|
+
pass "case-11b empty ACCOUNT_ID → filled body still substitutes; {accountId} → empty backticks; clarifier sentence intact"
|
|
675
|
+
else
|
|
676
|
+
fail "case-11b filled-body shape wrong on empty accountId ($NO_ACCT_OK): $NO_ACCT_BODY"
|
|
677
|
+
fi
|
|
678
|
+
|
|
608
679
|
# --- Summary ------------------------------------------------------------
|
|
609
680
|
echo "---"
|
|
610
681
|
echo "PASSED: $PASS FAILED: $FAIL"
|
|
@@ -366,6 +366,7 @@ if vertical_path:
|
|
|
366
366
|
|
|
367
367
|
with open(envelope_path, "r", encoding="utf-8") as f:
|
|
368
368
|
envelope = json.load(f)
|
|
369
|
+
account_id = envelope.get("accountId", "") or ""
|
|
369
370
|
turns = envelope.get("turns") or []
|
|
370
371
|
lines = []
|
|
371
372
|
for t in turns:
|
|
@@ -391,11 +392,17 @@ for t in turns:
|
|
|
391
392
|
# the last one from the closing `, using the tools at your disposal.`
|
|
392
393
|
conversation_text = "\n" + "\n".join(lines) + "\n"
|
|
393
394
|
|
|
394
|
-
filled =
|
|
395
|
+
filled = (
|
|
396
|
+
body_template
|
|
397
|
+
.replace("{schema}", schema_text)
|
|
398
|
+
.replace("{accountId}", account_id)
|
|
399
|
+
.replace("{conversation}", conversation_text)
|
|
400
|
+
)
|
|
395
401
|
|
|
396
402
|
schema_bytes = len(schema_text.encode("utf-8"))
|
|
397
403
|
conv_bytes = len(conversation_text.encode("utf-8"))
|
|
398
404
|
body_bytes = len(filled.encode("utf-8"))
|
|
405
|
+
account_id_present = "yes" if account_id else "no"
|
|
399
406
|
|
|
400
407
|
spawn_body = {
|
|
401
408
|
"adminSessionId": sid,
|
|
@@ -405,7 +412,7 @@ spawn_body = {
|
|
|
405
412
|
"initialMessage": filled,
|
|
406
413
|
}
|
|
407
414
|
|
|
408
|
-
print(f"{schema_bytes}\t{conv_bytes}\t{body_bytes}")
|
|
415
|
+
print(f"{schema_bytes}\t{conv_bytes}\t{body_bytes}\t{account_id_present}")
|
|
409
416
|
print(json.dumps(spawn_body))
|
|
410
417
|
PY
|
|
411
418
|
)
|
|
@@ -413,9 +420,9 @@ rm -f "$ENVELOPE_FILE"
|
|
|
413
420
|
|
|
414
421
|
BYTES_LINE=$(printf '%s' "$COMBINED" | head -n1)
|
|
415
422
|
SPAWN_BODY=$(printf '%s' "$COMBINED" | tail -n +2)
|
|
416
|
-
IFS=$'\t' read -r SCHEMA_BYTES CONV_BYTES BODY_BYTES <<<"$BYTES_LINE"
|
|
423
|
+
IFS=$'\t' read -r SCHEMA_BYTES CONV_BYTES BODY_BYTES ACCOUNT_ID_PRESENT <<<"$BYTES_LINE"
|
|
417
424
|
|
|
418
|
-
emit_log "substitution sessionId=${ADMIN_SESSION_ID} schemaBytes=${SCHEMA_BYTES} conversationBytes=${CONV_BYTES} bodyBytes=${BODY_BYTES}"
|
|
425
|
+
emit_log "substitution sessionId=${ADMIN_SESSION_ID} schemaBytes=${SCHEMA_BYTES} conversationBytes=${CONV_BYTES} bodyBytes=${BODY_BYTES} accountIdPresent=${ACCOUNT_ID_PRESENT}"
|
|
419
426
|
emit_log "spawn-request sessionId=${ADMIN_SESSION_ID} specialist=database-operator initialMessageBytes=${BODY_BYTES}"
|
|
420
427
|
|
|
421
428
|
SPAWN_RES_FILE=$(mktemp)
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
name: database-operator
|
|
3
3
|
description: "Background recorder for the memory graph. Reads the operator's full conversation transcript (ordered turns array with text and tool calls) from stdin and writes whatever the most recent operator turn implies into the graph via the wrapped writers. Fires once per admin turn from a Stop hook; never reachable from the admin agent directly."
|
|
4
4
|
summary: "Watches every completed admin turn and records what it implies into the memory graph."
|
|
5
|
-
model: claude-
|
|
5
|
+
model: claude-sonnet-4-6
|
|
6
6
|
tools: mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memory-search, mcp__memory__profile-update, mcp__memory__profile-read, mcp__contacts__contact-create, mcp__contacts__contact-update, mcp__tasks__task-create, mcp__tasks__task-update, mcp__tasks__project-create, mcp__tasks__project-update
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
You are an expert Neo4J graph operator. Here is the schema {schema}.
|
|
10
|
+
|
|
11
|
+
The `accountId` property is automatically supplied by the writers from
|
|
12
|
+
server-side environment state — never refuse a write because accountId
|
|
13
|
+
is absent from this prompt. The current accountId is `{accountId}`.
|
|
14
|
+
|
|
10
15
|
Use your expert judgement to update the graph in reaction to the
|
|
11
16
|
following conversation {conversation}, using the tools at your disposal.
|
|
12
17
|
You are not user-facing and your text goes nowhere — do not emit it.
|
package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md
CHANGED
|
@@ -86,12 +86,12 @@ A `[SKIP]` answer is a **valid** answer for the gate. An empty answer is still a
|
|
|
86
86
|
|
|
87
87
|
If the operator's request contains "and socials", "+socials", "with socials", "with OG images", or any equivalent, append a socials step after the brochure web bundle is built:
|
|
88
88
|
|
|
89
|
-
1. Invoke the `property-socials` skill on the same `<property_dir>` (it reads `output/web/
|
|
90
|
-
2.
|
|
91
|
-
3.
|
|
92
|
-
4.
|
|
89
|
+
1. Invoke the `property-socials` skill on the same `<property_dir>` (it reads `output/web/index.html`, `property.json`, and the brand pack).
|
|
90
|
+
2. `property-socials` owns the full deliverable: OG JPGs in `output/web/socials/` (landscape 1200×628, square 1200×1200, portrait 1080×1350), the gallery page at `output/web/socials.html`, captions at `output/web/social-posts.md`, and the `og.html` template kept for re-runs. The orchestrator does **not** produce a separate gallery — everything lands when the skill returns, so a direct standalone invocation produces an identical bundle to the orchestrator-invoked one.
|
|
91
|
+
3. After the skill returns, extend the bundle's smoke-test to include `socials.html` and every JPG referenced; all must return 200 from the isolated HTTP server.
|
|
92
|
+
4. Re-zip the web bundle with the socials artefacts included.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
If the operator did **not** request socials, do not run the step; adding 1 to 3 MB of imagery and a third HTML page silently is an over-reach.
|
|
95
95
|
|
|
96
96
|
## Step 4 — populate, render, deliver
|
|
97
97
|
|
|
@@ -151,6 +151,8 @@ The recurring failure pattern is trusting a derived summary (a WebFetch markdown
|
|
|
151
151
|
|
|
152
152
|
When any field above is unresolved after consulting its primary source, **stop before substitution** and present the operator with one consolidated prompt listing every unknown by name, the primary source already consulted, and the candidate value (if any). Operator answers in one pass; substitution then proceeds from confirmed values. **Never render a brochure with fields silently coerced to `TBC`, an approximation, or a default. A `TBC` shipped to a buyer must have been confirmed as a `TBC` by the operator, not chosen by the agent.**
|
|
153
153
|
|
|
154
|
+
**EPC is in a stricter tier — `TBC` is not a confirmable outcome.** UK law requires an EPC for any marketed property, so the brochure cannot ship with `epc_rating: "TBC"` even with operator confirmation. If `property-extract` returned `TBC` (the listing displays it that way, or the page lacks the data), the operator confirmation prompt for the EPC slot must demand an actual A–G band plus the current/potential scores and a path to the certificate PDF/image. The operator's path of last resort is the [EPC Register](https://www.epcregister.com/) — look up by address or postcode, download the certificate, and supply the band + scores back to the brochure step. Confirming `TBC` for `epc_rating` is the one answer the prompt must refuse to accept; the brochure halts until a real rating arrives.
|
|
155
|
+
|
|
154
156
|
## Hard rules (load-bearing — do not violate)
|
|
155
157
|
|
|
156
158
|
- **Never `Read` an image whose longest edge exceeds 2000px** — it can drop the session. Measure with `sips -g pixelWidth -g pixelHeight <file>` first.
|
|
@@ -164,6 +166,14 @@ When any field above is unresolved after consulting its primary source, **stop b
|
|
|
164
166
|
- **Suppress screen-only chrome before snapshotting** — the template's `.download-bar` is hidden under `@media print` but Playwright's `element.screenshot()` captures the screen render, where the bar IS visible. The render script in `build.md` injects a style tag (`.download-bar { display: none !important; }`) before capture. Any custom render script must do the same.
|
|
165
167
|
- **Brand-token overrides MUST land at the `BRAND_TOKENS_OVERRIDE_SLOT` sentinel** — immediately before the closing `}` of the template's `:root` rule. Any earlier insertion site loses the CSS cascade: the template's own `--paper-25 / --serif / --gold-700` declarations win in source order, and the brochure renders in Premium register despite `register=branded`. The substitution gate and snapshot capture both pass — failure is silent until visual review.
|
|
166
168
|
- **Display-headline wrap contract on `{{ property_name }}`** — `.back-headline em`, `.cover-title`, and `.opener-title em` carry `white-space: nowrap` so multi-word property names sit on one line regardless of brand display family. Removing the nowrap rule reintroduces non-deterministic wrap (same name renders correctly in Premium and breaks in Branded when the display family changes). For a property name long enough to overflow at the column width, shrink the headline `font-size` or shorten the name — never delete the nowrap rule.
|
|
169
|
+
- **Web bundle must contain only referenced images.** Before the zip step, walk `output/web/images/` and remove every file not appearing in any of the bundle's HTML surfaces (`brochure.html`, the companion landing page, optional `og.html`). A bundle carrying unreferenced images is a build defect; the 30–50 MB target documented in `build.md → Web bundle` is enforced, not aspirational. See `build.md → Strip unreferenced images` for the recipe.
|
|
170
|
+
- **Web PDF defaults to JPEG-embedded at quality 85.** Snapshot PNGs in `output/.snapshots-web/` are re-encoded to JPEG q=85 before `img2pdf` binds them. PNG-embedded for the web grade is opt-in only (one-pagers / report-style where small-text crispness outweighs wire size). The print master never flips to JPEG — its audience archives for press runs. A web PDF over ~20 MB on a 16-page folio means the re-encode was skipped.
|
|
171
|
+
- **Companion landing page is named `index.html`.** Every reference (template, bundle, smoke-test, docs) uses `index.html` so the static host serves it as the default document without an explicit URL. Do not drift back to `page.html` — that name was deprecated in favour of hosting-platform default-document compatibility.
|
|
172
|
+
- **Floorplan ships as PNG on disk.** The brochure templates reference `images/<slug>-floorplan.png` and the contract is PNG-only on disk regardless of source format. If the operator-supplied source is JPG, convert at build time (`magick <src>.jpg <slug>-floorplan.png`) before substitution. JPEG compression artefacts on thin lines and small text are unacceptable for floorplans.
|
|
173
|
+
- **Brand-token override block must carry both vocabularies and target both templates.** The brochure (`template.html`) and the landing page (`index.html`) declare independent `:root` token vocabularies (`--paper-25 / --gold-700 / …` vs `--paper / --gold / …`). The brand-tokens block must emit **both** sets of token names with the same brand values and substitute at the `BRAND_TOKENS_OVERRIDE_SLOT` sentinel in **both** templates. A block that only carries the brochure's vocabulary leaves the landing page rendering Premium silently — the substitution gate passes and the snapshot capture proceeds. See `references/build.md → Brand-token override emission` for the mapping table and example block. Long-term vocabulary unification is a follow-up; until it lands the dual emission is structurally required.
|
|
174
|
+
- **Doc-comment strip pass runs against BOTH templates.** After substitution, the `<!-- REPLACE … -->` authoring notes must be removed from both `brochure.html` AND `index.html`. A strip pass that covers only the brochure leaves landing-page authoring notes rendering as visible body text whenever a comment delimiter is misplaced. The companion source-side check in `references/build.md → Template authoring hygiene + CI lint` catches the same defect class at template-edit time.
|
|
175
|
+
- **EPC is mandatory — `TBC` is not shippable.** UK law requires an EPC for any marketed property. The brochure must not render with `epc_rating: "TBC"`, no matter what `property-extract` returned. If the rating is missing or the listing displayed `TBC`, the operator confirmation prompt must demand an actual A–G band (plus current/potential scores and the certificate path) before substitution proceeds. The operator's authoritative fallback is the [EPC Register](https://www.epcregister.com/) — look up by address or postcode, download the certificate, supply the values. `TBC` is the one answer the operator cannot confirm for this field.
|
|
176
|
+
- **Web bundle ships the PDF, not per-page JPGs.** The bundle does not include `cover-print.jpg … backpage-print.jpg` — those duplicate the brochure content already embedded in `<slug>-brochure.pdf`. The web copy of `brochure.html` has its `.print-img` src attributes cleared so the print stylesheet drops to the live-DOM fallback; operators who want a printable PDF download the bundled PDF directly. See `references/build.md → Clear .print-img src in the web copy`. Shipping both was the historical bundle-bloat defect.
|
|
167
177
|
- **No `{{ token }}` may remain in rendered output** — `grep '{{' output/brochure.html` must return zero matches before PDFs are built.
|
|
168
178
|
|
|
169
179
|
## Scope
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
1. Copy template to property directory, populate with content
|
|
6
6
|
2. Stage `output/images/` per `images.md → Image renaming and optimisation`
|
|
7
|
-
3. **If branded register and `<brand_dir>/DESIGN.md` contains a `## Type roles` block** — emit the role overlay into both `brochure.html` and `
|
|
7
|
+
3. **If branded register and `<brand_dir>/DESIGN.md` contains a `## Type roles` block** — emit the role overlay into both `brochure.html` and `index.html` per **Type-role overlay generation** below. Run this **before** any snapshot capture; the overlay is part of what the user reviews in the browser, not a post-process applied to PDFs.
|
|
8
|
+
3b. **Doc-comment strip — run against BOTH templates.** After substitution and overlay emission, run a regex pass that removes template authoring notes (the `<!-- REPLACE … -->` markers and any demo-placeholder blocks inside comments) from both `brochure.html` and `index.html`. The single regex is `re.compile(r"<!--\s*REPLACE\s+.*?-->", re.DOTALL)`. Run it explicitly against **both** files — a strip pass that covers only the brochure leaves landing-page authoring notes rendering as visible body text whenever a comment delimiter is misplaced (the historical `page.html` template shipped this defect once already). See **Template authoring hygiene + CI lint** below for the source-side check that catches the same class of defect at template-edit time, before a property build is ever started.
|
|
8
9
|
4. Serve locally: `python3 -m http.server <port>` from the project root
|
|
9
10
|
5. Navigate Playwright to the brochure URL
|
|
10
11
|
6. Present to user in browser for review
|
|
@@ -107,7 +108,7 @@ Both are image-only — no fonts embedded, no Ghostscript anywhere in the chain.
|
|
|
107
108
|
|
|
108
109
|
### Build steps
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
Build both grades from the per-page snapshots in `output/` and `output/.snapshots-web/`, then linearize via qpdf. The print master embeds the PNG snapshots verbatim; the web grade re-encodes them as JPEG quality 85 before binding, so the deliverable that goes out to email and CDN hosting is materially smaller:
|
|
111
112
|
|
|
112
113
|
```bash
|
|
113
114
|
# Print master from 300 dpi PNGs (in output/)
|
|
@@ -124,13 +125,17 @@ qpdf --linearize --object-streams=disable \
|
|
|
124
125
|
/tmp/pre-linearize.pdf \
|
|
125
126
|
<property_slug>-brochure-print.pdf
|
|
126
127
|
|
|
127
|
-
# Web PDF from 192 dpi PNGs (in output/.snapshots-web/)
|
|
128
|
+
# Web PDF from 192 dpi PNGs → JPEG q=85 (in output/.snapshots-web/)
|
|
129
|
+
# img2pdf preserves JPEG bytes verbatim, so re-encoding once up front is the only quality step.
|
|
128
130
|
cd <property_dir>/output/.snapshots-web/
|
|
131
|
+
for png in *-print.png; do
|
|
132
|
+
magick "$png" -quality 85 -strip -interlace none -colorspace sRGB "${png%.png}.jpg"
|
|
133
|
+
done
|
|
129
134
|
img2pdf --pagesize 297mmx210mm \
|
|
130
135
|
--title "<Property Name> · <address line 1>" \
|
|
131
136
|
--author "<Agent Name>" \
|
|
132
137
|
--output /tmp/pre-linearize.pdf \
|
|
133
|
-
cover-print.
|
|
138
|
+
cover-print.jpg page2-print.jpg … backpage-print.jpg
|
|
134
139
|
qpdf --linearize --object-streams=disable \
|
|
135
140
|
/tmp/pre-linearize.pdf \
|
|
136
141
|
<property_dir>/output/<property_slug>-brochure-web.pdf
|
|
@@ -149,9 +154,9 @@ pdfinfo <output>.pdf | grep Optimized # → "Optimized: yes"
|
|
|
149
154
|
pdffonts <output>.pdf | wc -l # → 2 (header rows only; no font entries)
|
|
150
155
|
```
|
|
151
156
|
|
|
152
|
-
A typical 16-page folio ships with the print master at **65–100 MB** and the web PDF at **
|
|
157
|
+
A typical 16-page folio ships with the print master at **65–100 MB** (PNG-embedded) and the web PDF at **7–15 MB** (JPEG-embedded at q=85). If the print master lands under ~40 MB, the snapshot DPR is wrong (likely rendered at 2× instead of 3.125×). If the web PDF exceeds ~20 MB, the JPEG re-encode was skipped (bind step ran against `.png` instead of `.jpg`) or the snapshot DPR drifted to 3.125× by accident.
|
|
153
158
|
|
|
154
|
-
|
|
159
|
+
The web PDF defaults to JPEG-embedded; PNG-embedded for the web grade is **opt-in only**, for one-pagers or report-style brochures where small-text crispness matters more than wire size. For the standard photographic property folio, JPEG q=85 is visually indistinguishable from PNG at typical screen DPRs and is roughly 3–5× smaller. The print master never flips to JPEG — its audience archives for press runs.
|
|
155
160
|
|
|
156
161
|
### When to (re)generate
|
|
157
162
|
|
|
@@ -165,9 +170,8 @@ The web bundle is a parallel directory with the same on-disk shape as `output/`
|
|
|
165
170
|
|
|
166
171
|
```
|
|
167
172
|
output/web/
|
|
168
|
-
brochure.html # identical content;
|
|
173
|
+
brochure.html # identical content; .print-img src attributes cleared (see below)
|
|
169
174
|
index.html # companion landing page (mandatory in bundle)
|
|
170
|
-
cover-print.jpg, page2-print.jpg … backpage-print.jpg # 96 dpi JPEG snapshots (q=88)
|
|
171
175
|
<slug>-brochure.pdf # identical bytes to <slug>-brochure-web.pdf at the property level — simpler name inside the bundle since there's only one PDF here
|
|
172
176
|
images/
|
|
173
177
|
<slug>-NN.webp # web-tier per-slot encodings (see table below)
|
|
@@ -175,6 +179,8 @@ output/web/
|
|
|
175
179
|
<brand>-logo-light.png
|
|
176
180
|
```
|
|
177
181
|
|
|
182
|
+
The bundle deliberately does **not** include the per-page JPG snapshots (`cover-print.jpg` etc). They duplicate the brochure content already embedded in `<slug>-brochure.pdf` at smaller cost-to-quality. Operators who want printed output download the PDF from the bundle; operators who view `brochure.html` in a browser and hit Cmd+P get the live-DOM print path (see `Print snapshot capture → Live-DOM fallback`). Shipping both was the historical defect that pushed a 16-page folio bundle past 50 MB.
|
|
183
|
+
|
|
178
184
|
### Web-tier image encoding
|
|
179
185
|
|
|
180
186
|
The same per-slot tier idea as the digital floor in `images.md`, but with smaller widths and slightly lower quality — sized for screen viewing at 100% zoom, not for print sharpness.
|
|
@@ -184,41 +190,34 @@ The same per-slot tier idea as the digital floor in `images.md`, but with smalle
|
|
|
184
190
|
| Hero / cover / banner / feature / drone aerial / front elevation / outdoor full-bleed | **1300 px @ q82** | 80–300 KB |
|
|
185
191
|
| Story photo (in-spread half-page) | **1100 px @ q80** | 60–250 KB |
|
|
186
192
|
| Gallery thumb / strip / image-led grid cell | **800 px @ q76** | 25–80 KB |
|
|
187
|
-
| **Floor plan** (PNG, line art) | **source
|
|
193
|
+
| **Floor plan** (PNG output, line art) | **PNG source copied unchanged; JPG source converted to PNG at build time** — `magick <slug>-floorplan.jpg <slug>-floorplan.png` then drop the `.jpg`. The brochure templates reference `images/<slug>-floorplan.png` and the contract is PNG-only on disk; JPEG compression artefacts on thin lines and small text are unacceptable for floorplans. | matches PNG output (often 500 KB – 1 MB) |
|
|
188
194
|
| Site plan (raster line art) | **1100 px @ q80** WebP if not already PNG; if PNG, copy unchanged | 30–60 KB or source size |
|
|
189
195
|
|
|
190
196
|
A 27-image folio typically lands at **~2.5 MB** of web photographs, with the floor plan adding **0.5–1.0 MB** on top because line art is preserved at source resolution. Total web image weight is typically 3.0–3.5 MB.
|
|
191
197
|
|
|
192
198
|
**Floor-plan rule, repeated for emphasis.** Line-art PNG floor plans are **never re-encoded** for the web bundle — copy the source file as-is. Browsers render PNG line art crisply at any zoom level; converting to WebP introduces visible artefacts on text and rules even at q90+. The bundle gains a few hundred KB but the trade-off is on the right side.
|
|
193
199
|
|
|
194
|
-
###
|
|
200
|
+
### Clear `.print-img` src in the web copy
|
|
195
201
|
|
|
196
|
-
The canonical
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
cd output/web
|
|
200
|
-
for png in ../*-print.png; do
|
|
201
|
-
base=$(basename "$png" .png)
|
|
202
|
-
magick "$png" -resize 1123x794^ -quality 88 -strip -interlace none -colorspace sRGB "$base.jpg"
|
|
203
|
-
done
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
`-resize 1123x794^` downsamples to A4-landscape at 96 dpi (use `794x1123^` for portrait). `-strip` removes embedded metadata (EXIF, ICC) so the bundle stays minimal. `-interlace none` produces baseline JPEG which decodes faster in mobile previewers than progressive.
|
|
207
|
-
|
|
208
|
-
JPEG at 88% quality is roughly 5× smaller than the PNG equivalent for photographic content. A 16-page folio's web-bundle snapshots total ~3 MB. The brochure HTML's `print-img` references must be updated from `.png` to `.jpg` on the web copy:
|
|
202
|
+
The canonical `output/brochure.html` carries `<img class="print-img" src="cover-print.png">` references that power the snapshot path in the print stylesheet (Cmd+P → embedded PNG fills the page). The web bundle does **not** include those PNGs — and does not derive smaller JPEG copies either, because the PDF embedded in the bundle already covers the "I want a printable artefact" need. Instead, the web copy of `brochure.html` has its `.print-img` src attributes cleared so the print stylesheet drops to the live-DOM fallback path:
|
|
209
203
|
|
|
210
204
|
```python
|
|
211
|
-
# Edit the web copy of brochure.html only — leave the canonical output/brochure.html unchanged
|
|
205
|
+
# Edit the web copy of brochure.html only — leave the canonical output/brochure.html unchanged.
|
|
212
206
|
import re
|
|
213
207
|
with open("output/web/brochure.html") as f:
|
|
214
208
|
s = f.read()
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
# Clear every .print-img src — the print stylesheet's :has(.print-img[src]:not([src=""]))
|
|
210
|
+
# selector then fails, and the @media print live-DOM path renders the page directly.
|
|
211
|
+
s = re.sub(
|
|
212
|
+
r'(<img\s+class="print-img"[^>]*?\s+)src="[^"]*"',
|
|
213
|
+
r'\1src=""',
|
|
214
|
+
s,
|
|
215
|
+
)
|
|
217
216
|
with open("output/web/brochure.html", "w") as f:
|
|
218
217
|
f.write(s)
|
|
219
218
|
```
|
|
220
219
|
|
|
221
|
-
|
|
220
|
+
The `.print-img` element on screen is `opacity: 0; width: 1px; height: 1px` regardless of `src`, so clearing the attribute has zero visible effect — only the print stylesheet branches differently. Operators who want a printable PDF download `<slug>-brochure.pdf` directly from the bundle.
|
|
222
221
|
|
|
223
222
|
### Copy the web PDF into the bundle
|
|
224
223
|
|
|
@@ -228,6 +227,34 @@ The web PDF (`<slug>-brochure-web.pdf` at the property level) is also placed ins
|
|
|
228
227
|
cp output/<slug>-brochure-web.pdf output/web/<slug>-brochure.pdf
|
|
229
228
|
```
|
|
230
229
|
|
|
230
|
+
### Strip unreferenced images
|
|
231
|
+
|
|
232
|
+
Every image in `output/web/images/` must be reachable from at least one HTML surface in the bundle (`brochure.html`, the companion landing page, and `og.html` if present). The build accumulates working images during editing — operator drops a hero, re-numbers a sequence, swaps an image and forgets to delete the prior version — and without the strip step the bundle ships every accumulated file. Typical bloat on a re-iterated 16-page folio is 20–40 unreferenced `.webp` files at ~500 KB each, which is what pushes a "30–50 MB target" bundle to 80–120 MB.
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
cd output/web
|
|
236
|
+
|
|
237
|
+
# Collect every image actually referenced by the bundle's HTML surfaces.
|
|
238
|
+
# `cat *.html` catches brochure.html, the companion landing page, and og.html
|
|
239
|
+
# without needing to know the landing-page filename in advance.
|
|
240
|
+
referenced=$(cat *.html 2>/dev/null \
|
|
241
|
+
| grep -oE 'images/[A-Za-z0-9_.-]+\.(webp|jpg|jpeg|png|svg)' \
|
|
242
|
+
| sort -u)
|
|
243
|
+
|
|
244
|
+
# Walk images/ and remove anything not in $referenced. Brand assets
|
|
245
|
+
# (logos, QR PNGs) are referenced by the templates and survive.
|
|
246
|
+
removed=0
|
|
247
|
+
for f in images/*; do
|
|
248
|
+
if ! echo "$referenced" | grep -qxF "$f"; then
|
|
249
|
+
rm -f "$f"
|
|
250
|
+
removed=$((removed + 1))
|
|
251
|
+
fi
|
|
252
|
+
done
|
|
253
|
+
echo "[strip] removed $removed unreferenced files from output/web/images/"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The strip is a closed-set operation: two agents producing the bundle for the same property emit byte-identical surviving image sets. Run it before the zip step; a bundle carrying unreferenced images is a build defect, not "extra-safe" packaging.
|
|
257
|
+
|
|
231
258
|
### Bundling
|
|
232
259
|
|
|
233
260
|
After populating `output/web/`, zip it with `-X` (strip extra attributes) and exclude any `.DS_Store` / AppleDouble files:
|
|
@@ -247,7 +274,7 @@ Serve the unzipped bundle from an isolated temp directory and verify every refer
|
|
|
247
274
|
TMP=/tmp/web-test && rm -rf $TMP && mkdir -p $TMP
|
|
248
275
|
cd $TMP && unzip -q /path/to/<slug>-web.zip
|
|
249
276
|
python3 -m http.server 8765 &
|
|
250
|
-
for f in brochure.html index.html
|
|
277
|
+
for f in brochure.html index.html images/<slug>-01.webp images/<brand>-logo-light.png <slug>-brochure.pdf; do
|
|
251
278
|
echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/$f) $f"
|
|
252
279
|
done
|
|
253
280
|
```
|
|
@@ -258,19 +285,129 @@ The brochure should render the same as the canonical preview, just lighter on th
|
|
|
258
285
|
|
|
259
286
|
| File | `output/` (canonical archive) | `output/web/` (web bundle) |
|
|
260
287
|
|---|---|---|
|
|
261
|
-
| `brochure.html` | full-resolution images, `.png` snapshot refs | identical structure, `.
|
|
288
|
+
| `brochure.html` | full-resolution images, `.png` snapshot refs | identical structure, `.print-img` srcs cleared (live-DOM print fallback) |
|
|
262
289
|
| `index.html` | — | ✓ companion landing page; see `index-landing.md` |
|
|
263
290
|
| `<slug>-brochure-print.pdf` | ✓ canonical print master (50–80 MB, 300 dpi) | — (too large to bundle) |
|
|
264
|
-
| `<slug>-brochure-web.pdf` | ✓ web/digital deliverable (
|
|
291
|
+
| `<slug>-brochure-web.pdf` | ✓ web/digital deliverable (7–15 MB, 192 dpi JPEG-embedded) | — copied into bundle as `<slug>-brochure.pdf` |
|
|
265
292
|
| `<slug>-brochure.pdf` | — | ✓ identical bytes to `-web.pdf`; matches index.html / brochure.html link |
|
|
266
|
-
| `cover-print.png … backpage-print.png` | ✓ 300 dpi PNG (~4–7 MB each) — canonical snapshots | —
|
|
267
|
-
| `cover-print.jpg … backpage-print.jpg` | — | ✓ 96 dpi JPEG (~150 KB each, derived from the 300 dpi PNGs) |
|
|
293
|
+
| `cover-print.png … backpage-print.png` | ✓ 300 dpi PNG (~4–7 MB each) — canonical snapshots | — bundle ships the PDF instead of per-page JPGs |
|
|
268
294
|
| `images/<slug>-NN.webp` | full-quality per Render-slot table | web-tier per the web table above |
|
|
269
295
|
| `images/qr-*.png`, `images/<brand>-logo-*.png` | ✓ | ✓ (copied unchanged — already small) |
|
|
270
296
|
| `.snapshots-web/*-print.png` | ✓ intermediate (192 dpi PNGs used by the web-PDF build; deletable after) | — |
|
|
271
297
|
|
|
272
298
|
The convention: `output/` is the canonical archive for the property's lifetime. `output/web/` is a derived bundle, regenerable any time from the canonical artefacts.
|
|
273
299
|
|
|
300
|
+
## Brand-token override emission (cascade-safe, two-template, two-vocabulary)
|
|
301
|
+
|
|
302
|
+
Branded register replaces palette and type tokens in `:root`. The override is emitted as a `<style id="brand-tokens">…</style>` block at the **`BRAND_TOKENS_OVERRIDE_SLOT`** sentinel in **both** the brochure template (`template.html`) and the companion landing page (`index.html`). The sentinel sits immediately before the closing `}` of each `:root` rule — placing the override after the template's own declarations is what keeps the brand values winning the cascade. See SKILL.md hard rule "Brand-token overrides MUST land at the BRAND_TOKENS_OVERRIDE_SLOT sentinel".
|
|
303
|
+
|
|
304
|
+
### Two vocabularies (today)
|
|
305
|
+
|
|
306
|
+
The brochure and the landing page use independent token vocabularies. Until vocabulary unification lands (see follow-up below), the brand-tokens block must include **both** sets of token names so that whichever template the block lands in, the right values bind.
|
|
307
|
+
|
|
308
|
+
| Concept | `template.html` token | `index.html` token |
|
|
309
|
+
|---|---|---|
|
|
310
|
+
| Ivory page background | `--paper-25` | `--paper` |
|
|
311
|
+
| Slightly darker cream surface | `--paper-50` | `--paper-2` |
|
|
312
|
+
| Warm cream / banded surface | `--paper-100` | (no equivalent — omit) |
|
|
313
|
+
| Pure white | `--paper-0` | (no equivalent — omit) |
|
|
314
|
+
| Body ink | `--ink` | `--ink` (shared name) |
|
|
315
|
+
| Secondary ink | (no equivalent — omit) | `--ink-2` |
|
|
316
|
+
| Bronze accent | `--gold-700` | `--gold` |
|
|
317
|
+
| Antique gold rule / softer accent | `--gold-500` | `--gold-soft` |
|
|
318
|
+
| Champagne flourish | `--gold-300` | (no equivalent — omit) |
|
|
319
|
+
| Display serif family | `--serif` | `--serif` (shared name) |
|
|
320
|
+
| Body serif family | `--body-serif` | (no equivalent — omit) |
|
|
321
|
+
| Sans family | `--sans` | (no equivalent — omit) |
|
|
322
|
+
|
|
323
|
+
Pick one brand-pack value per concept, emit **both** token names in the same block. Example for a brand whose accent is brand-green `#02843E`:
|
|
324
|
+
|
|
325
|
+
```html
|
|
326
|
+
<style id="brand-tokens">
|
|
327
|
+
:root {
|
|
328
|
+
/* template.html vocabulary */
|
|
329
|
+
--paper-25: #FFFFFF;
|
|
330
|
+
--paper-50: #F4F1EA;
|
|
331
|
+
--paper-100: #EFE7D8;
|
|
332
|
+
--gold-700: #02843E;
|
|
333
|
+
--gold-500: #4FB371;
|
|
334
|
+
--gold-300: #C9E8D4;
|
|
335
|
+
--ink: #323636;
|
|
336
|
+
--serif: 'Lora', 'Cormorant Garamond', serif;
|
|
337
|
+
--body-serif: 'Lora', serif;
|
|
338
|
+
--sans: 'Inter', sans-serif;
|
|
339
|
+
|
|
340
|
+
/* index.html vocabulary — same colour values, different token names */
|
|
341
|
+
--paper: #FFFFFF;
|
|
342
|
+
--paper-2: #F4F1EA;
|
|
343
|
+
--gold: #02843E;
|
|
344
|
+
--gold-soft: #4FB371;
|
|
345
|
+
--ink-2: #2A3036;
|
|
346
|
+
}
|
|
347
|
+
</style>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The same block is substituted at the sentinel in **both** templates. A block that only carries the `template.html` vocabulary leaves the landing page rendering Premium silently — the substitution gate passes and the snapshot capture proceeds. The defect is invisible until visual review.
|
|
351
|
+
|
|
352
|
+
### Follow-up — vocabulary unification (long-term)
|
|
353
|
+
|
|
354
|
+
The dual-emission above is a tactical fix for the divergence between the two templates' `:root` vocabularies. The long-term fix is to **unify the landing page on `template.html`'s tiered vocabulary** (`--paper-0 / -25 / -50 / -100`, `--gold-300 / -500 / -700`, `--ink` plus a `--ink-2` if needed) so every brand block is authored once instead of twice. Until that refactor lands, the dual-emission contract above is structurally required.
|
|
355
|
+
|
|
356
|
+
## Template authoring hygiene + CI lint
|
|
357
|
+
|
|
358
|
+
The doc-comment strip in step 3b of the workflow above catches template authoring notes **at property-build time** — but a template that ships with a misplaced `<!-- … -->` delimiter is a source-side defect that should be caught at template-edit time, before any property build runs against it. The historical `page.html` template carried a duplicated authoring note where one copy sat inside a proper comment and the other copy sat outside any delimiters; browsers rendered the duplicate as visible body text on every property built from that template.
|
|
359
|
+
|
|
360
|
+
Add a CI step (or a `make check-templates` recipe at the plugin root) that parses every `references/*.html` and asserts:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
import re
|
|
364
|
+
from pathlib import Path
|
|
365
|
+
|
|
366
|
+
REFERENCES = Path("references")
|
|
367
|
+
TEMPLATES = list(REFERENCES.glob("*.html")) # template.html, index.html
|
|
368
|
+
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
369
|
+
|
|
370
|
+
LEAKED_PHRASES = [
|
|
371
|
+
"terminate this outer comment",
|
|
372
|
+
"REPLACE markers, use prose",
|
|
373
|
+
"delimiter pair",
|
|
374
|
+
"the bug that prompted this note",
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
defects = []
|
|
378
|
+
|
|
379
|
+
for tpl in TEMPLATES:
|
|
380
|
+
text = tpl.read_text()
|
|
381
|
+
|
|
382
|
+
# 1. Every <!-- must be closed by --> within the file.
|
|
383
|
+
if text.count("<!--") != text.count("-->"):
|
|
384
|
+
defects.append(f"{tpl}: unbalanced HTML comment delimiters")
|
|
385
|
+
|
|
386
|
+
# 2. Strip every well-formed comment, then any LEAKED_PHRASES that
|
|
387
|
+
# remain are authoring notes sitting outside comments — body-text
|
|
388
|
+
# defects waiting to ship.
|
|
389
|
+
outside_comments = COMMENT_RE.sub("", text)
|
|
390
|
+
for phrase in LEAKED_PHRASES:
|
|
391
|
+
if phrase in outside_comments:
|
|
392
|
+
defects.append(f"{tpl}: authoring-note phrase {phrase!r} found outside any HTML comment")
|
|
393
|
+
|
|
394
|
+
# 3. `{{ token }}` placeholders are fine inside slot positions but
|
|
395
|
+
# an example placeholder appearing inside a code-block in body text
|
|
396
|
+
# (e.g. an explanation of how to use the template) is a leak.
|
|
397
|
+
# Surface as a soft warning — operator decides.
|
|
398
|
+
leftover_examples = re.findall(r"\{\{\s*example[_a-z]*\s*\}\}", outside_comments)
|
|
399
|
+
if leftover_examples:
|
|
400
|
+
defects.append(f"{tpl}: example placeholders leaked outside comments: {leftover_examples}")
|
|
401
|
+
|
|
402
|
+
if defects:
|
|
403
|
+
print("Template lint failed:")
|
|
404
|
+
for d in defects:
|
|
405
|
+
print(f" - {d}")
|
|
406
|
+
raise SystemExit(1)
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Either check catches the page.html-class leak at edit time, before it ships to a property build. The lint runs against the source-side `references/*.html` only — the property-build copies under `<property_dir>/output/` are derived artefacts and are protected by step 3b above instead.
|
|
410
|
+
|
|
274
411
|
## Type-role overlay generation
|
|
275
412
|
|
|
276
413
|
If the brand pack's `DESIGN.md` contains a `## Type roles` block (declared per `references/registers.md → Type-role overrides`), parse it and emit a single `<style id="brand-type-roles">…</style>` block at the `<!-- BRAND_TYPE_ROLES_OVERRIDE_SLOT -->` sentinel immediately before the closing `</style>` of the template's main stylesheet. The override CSS is generated **mechanically** from the recipe map below — the build does not invent selectors, it emits the documented ones using values from the DESIGN doc.
|
package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-extract/SKILL.md
CHANGED
|
@@ -319,6 +319,7 @@ The skill orchestrates four capabilities, each a **what** not a **how**:
|
|
|
319
319
|
2. **Extract.** Pull the raw HTML and grep all `b-cdn.net/propertyimages/...` and `b-cdn.net/floorplans/...` URLs. Deduplicate. Also grep PDF URLs containing `epc`. Extract the `<div class="section__description">` block for the description text. (Map detection is provider-agnostic and happens in step 4 below — don't try to parse map markup at this static-HTML stage.)
|
|
320
320
|
3. **Stage.** Create the four-folder structure under `<output-dir>/<slug>-<listing-id>/`. Download images and floorplans in parallel (`xargs -n1 -P8 curl -O -L`) — there are routinely 40–60 images per listing and serial downloads are slow.
|
|
321
321
|
4. **Capture map** (conditional, provider-agnostic). Drive Playwright to the listing URL and apply the heuristic detector under **Map — extract or omit**: try iframe-by-provider, then class/id-by-keyword, then static-map images, then visual fallback. On the first hit, scroll into view, wait for tiles, screenshot the **element** (not the page), convert to `map.webp` at ~1500 px wide. If no heuristic finds a map, skip — **extract or omit**, no fallback synthesis.
|
|
322
|
+
4b. **Traverse tabbed UIs (conditional, provider-agnostic).** Modern listing pages from Loop CRM tenants, Rightmove, Zoopla and others render top-level data in tabs — *Details / Floorplan / EPC / Virtual Tour / Map View / Book Viewing* — and the static HTML returned by `WebFetch` or `curl` often does not include the inactive-tab content. If the probe in step 1 came back with data the listing visibly shows (EPC band, floorplan thumbnail, video URL) but the static-HTML extract in step 2 does not, the data is almost certainly tab-bound. Drive the Playwright session opened in step 4 (or open one if step 4 skipped) and walk the tab list: detect elements with `role="tablist"`, `role="tab"`, or class/id substrings `tab` containing the keywords `EPC`, `Floor plan`, `Map`, `Virtual tour`, `Brochure`. For each tab found, `page.click(<selector>)`, `page.waitForLoadState('networkidle')`, then harvest the panel content. For the EPC tab specifically: scrape the certificate PDF/PNG link and the displayed current/potential scores into `specifications.epc_rating`, `specifications.epc_current_score`, `specifications.epc_potential_score`. For other tabs: extract what each contract field needs and skip the rest. The traversal is principled — never hard-code a single agency's tab markup as the sole detector; the same listing surface ships with different tab implementations across CMS tenants.
|
|
322
323
|
5. **Assemble.** Write `property.json` and `description.md`. Run a final consistency pass: image count in JSON matches files on disk, EPC rating in JSON matches the description.md line, agent contact in JSON matches the description.md header. `media.map` is populated iff a map screenshot landed on disk; `address.geo` is populated iff coordinates were parseable from a recognised URL pattern (and may legitimately be `null` even when `media.map` is set — see the schema notes).
|
|
323
324
|
|
|
324
325
|
The skill does not prescribe which HTTP tool to use. `curl` is fine for the static HTML pages Muvin serves. If a future agent ships a JS-rendered listing page, switch to Chrome DevTools MCP or Playwright — the contract on the output is unchanged.
|
|
@@ -345,6 +346,7 @@ A delivered package that diverges substantially in shape from the reference is w
|
|
|
345
346
|
| Saving `/tmp/muvin_property.html` or other working files inside the output directory | The directory is the deliverable. Working files get cleaned up. |
|
|
346
347
|
| Hardcoding the tenant UUID | The UUID identifies the agency on Loop CRM. Different Muvin offices can have different tenant IDs. Extract it from the actual URLs on the page. |
|
|
347
348
|
| Treating "EPC: TBC" as a missing field | "TBC" is information — the agent is telling you the assessment hasn't been done. Record it verbatim. |
|
|
349
|
+
| Recording "EPC: TBC" when the listing's EPC tab actually shows a band but the static HTML did not | Modern listings tab-hide data behind a JS tab switcher; `WebFetch` / `curl` see only the active tab. If the listing visibly carries EPC content the static HTML lacks, drive Playwright per step 4b and harvest from the tab panel. "TBC" recorded from a missed-tab extract is a silent data-loss defect, not a faithful record. |
|
|
348
350
|
| Synthesizing a map when the listing has none | The rule is **extract or omit**. Postcode-centroid renders, OSM static maps, and AI-generated map artwork are all forbidden. If the heuristic detector returns nothing, write `media.map: null` and move on. |
|
|
349
351
|
| Hard-coding a single CMS's map markup (`section__property--map`, `<iframe src="google.com/maps">`) as the only detector | Each agent embeds maps differently — Loop CRM uses Google iframes, custom builds use Mapbox-GL canvases, others use static-map `<img>` tags. The detection order is heuristic and provider-agnostic by design. Hard-coding one breaks every other listing. |
|
|
350
352
|
| Trying to render a map provider's tiles ourselves with our own API key | The screenshot approach captures whatever the agency already pays for, with their attribution. Spinning up our own Mapbox/Google account introduces a billing relationship the user didn't ask for, and risks brand-mismatch with what the agency publishes. Screenshot the element; don't reach for an API. |
|
package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-socials/SKILL.md
CHANGED
|
@@ -27,23 +27,24 @@ shots — it does not re-design anything.
|
|
|
27
27
|
|
|
28
28
|
## Output contract
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
This skill owns the **full** social-collateral deliverable. Whether invoked directly or through `make-brochure`'s `+socials` add-on, the same files land on disk — no orchestrator-side gallery emission, no split contract.
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
```
|
|
33
|
+
output/web/
|
|
34
|
+
socials/
|
|
35
|
+
og-landscape-<hero>.jpg # 1200×628 Twitter / LinkedIn / Facebook / OG
|
|
36
|
+
og-square-<hero>.jpg # 1200×1200 Instagram feed
|
|
37
|
+
og-portrait-<hero>.jpg # 1080×1350 Instagram portrait, mobile
|
|
38
|
+
socials.html # gallery page — the third HTML endpoint in the bundle
|
|
39
|
+
social-posts.md # paste-ready captions, also linked from socials.html
|
|
40
|
+
og.html # template that renders the tiles, kept for re-runs
|
|
41
|
+
```
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|------|------------|-----|
|
|
37
|
-
| `og-landscape-<hero>.jpg` | 1200×628 | Twitter / LinkedIn / Facebook / OG |
|
|
38
|
-
| `og-square-<hero>.jpg` | 1200×1200 | Instagram feed |
|
|
39
|
-
| `og-portrait-<hero>.jpg` | 1080×1350 | Instagram portrait, mobile |
|
|
43
|
+
`<hero>` is `main`, `kitchen`, or `garden`. At minimum produce `og-landscape-main.jpg`; offer the full nine-variant set (landscape + square + portrait × main/kitchen/garden) when the user wants social collateral, not just a meta image.
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
`og-landscape-main.jpg`; offer the full five-variant set (landscape + square +
|
|
43
|
-
three portraits) when the user wants social collateral, not just a meta image.
|
|
45
|
+
**Gallery page (`socials.html`)** sits at the root of the web bundle, alongside `brochure.html` and `index.html`. It previews each tile at its actual dimensions with download links, the suggested copy from `social-posts.md` inline next to each tile, and the platform's `<meta>` snippet ready to paste into a page `<head>`. Brand it from the same DESIGN.md tokens used for the brochure — same `:root` palette, same display family, same accent.
|
|
44
46
|
|
|
45
|
-
**Captions
|
|
46
|
-
image, ready to paste. Every caption is generated through the `plainly` skill's
|
|
47
|
+
**Captions (`social-posts.md`)** are written one section per image, ready to paste. Every caption is generated through the `plainly` skill's
|
|
47
48
|
register: no em-dashes, no inflation vocabulary (*discover*, *unlock*,
|
|
48
49
|
*nestled*, *stunning*, *boasts*, *bespoke* where it isn't literally bespoke),
|
|
49
50
|
no antithetical "it's not X, it's Y", no rhetorical openers (*Imagine…*,
|